From 1e0b89b94ec7f973beca4f13497cede95c31c7d3 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:35:01 +0000 Subject: [PATCH] feat(browser): add browser core.Service with Platform interface and IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements pkg/browser with three-layer pattern: IPC Bus -> Service -> Platform. Stateless service — delegates OpenURL and OpenFile to platform adapter. No queries or actions, tasks only. Co-Authored-By: Claude Opus 4.6 --- pkg/browser/messages.go | 14 ++++++ pkg/browser/platform.go | 11 +++++ pkg/browser/register.go | 15 ++++++ pkg/browser/service.go | 42 +++++++++++++++++ pkg/browser/service_test.go | 92 +++++++++++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 pkg/browser/messages.go create mode 100644 pkg/browser/platform.go create mode 100644 pkg/browser/register.go create mode 100644 pkg/browser/service.go create mode 100644 pkg/browser/service_test.go diff --git a/pkg/browser/messages.go b/pkg/browser/messages.go new file mode 100644 index 0000000..c4f9c02 --- /dev/null +++ b/pkg/browser/messages.go @@ -0,0 +1,14 @@ +// pkg/browser/messages.go +package browser + +// --- Tasks (all side-effects, no queries or actions) --- + +// TaskOpenURL opens a URL in the default system browser. Result: nil +type TaskOpenURL struct { + URL string `json:"url"` +} + +// TaskOpenFile opens a file with the system default application. Result: nil +type TaskOpenFile struct { + Path string `json:"path"` +} diff --git a/pkg/browser/platform.go b/pkg/browser/platform.go new file mode 100644 index 0000000..4d29122 --- /dev/null +++ b/pkg/browser/platform.go @@ -0,0 +1,11 @@ +// pkg/browser/platform.go +package browser + +// Platform abstracts the system browser/file-opener backend. +type Platform interface { + // OpenURL opens the given URL in the default system browser. + OpenURL(url string) error + + // OpenFile opens the given file path with the system default application. + OpenFile(path string) error +} diff --git a/pkg/browser/register.go b/pkg/browser/register.go new file mode 100644 index 0000000..ff081e7 --- /dev/null +++ b/pkg/browser/register.go @@ -0,0 +1,15 @@ +// pkg/browser/register.go +package browser + +import "forge.lthn.ai/core/go/pkg/core" + +// Register creates a factory closure that captures the Platform adapter. +// The returned function has the signature WithService requires: func(*Core) (any, error). +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} diff --git a/pkg/browser/service.go b/pkg/browser/service.go new file mode 100644 index 0000000..a3b8915 --- /dev/null +++ b/pkg/browser/service.go @@ -0,0 +1,42 @@ +// pkg/browser/service.go +package browser + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the browser service. +type Options struct{} + +// Service is a core.Service that delegates browser/file-open operations +// to the platform. It is stateless — no queries, no actions. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterTask(s.handleTask) + return nil +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskOpenURL: + return nil, true, s.platform.OpenURL(t.URL) + case TaskOpenFile: + return nil, true, s.platform.OpenFile(t.Path) + default: + return nil, false, nil + } +} diff --git a/pkg/browser/service_test.go b/pkg/browser/service_test.go new file mode 100644 index 0000000..b15c52c --- /dev/null +++ b/pkg/browser/service_test.go @@ -0,0 +1,92 @@ +// pkg/browser/service_test.go +package browser + +import ( + "context" + "errors" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + lastURL string + lastPath string + urlErr error + fileErr error +} + +func (m *mockPlatform) OpenURL(url string) error { + m.lastURL = url + return m.urlErr +} + +func (m *mockPlatform) OpenFile(path string) error { + m.lastPath = path + return m.fileErr +} + +func newTestBrowserService(t *testing.T, mp *mockPlatform) (*Service, *core.Core) { + t.Helper() + c, err := core.New( + core.WithService(Register(mp)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "browser") + return svc, c +} + +func TestRegister_Good(t *testing.T) { + mp := &mockPlatform{} + svc, _ := newTestBrowserService(t, mp) + assert.NotNil(t, svc) + assert.NotNil(t, svc.platform) +} + +func TestTaskOpenURL_Good(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "https://example.com", mp.lastURL) +} + +func TestTaskOpenURL_Bad_PlatformError(t *testing.T) { + mp := &mockPlatform{urlErr: errors.New("browser not found")} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOpenFile_Good(t *testing.T) { + mp := &mockPlatform{} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenFile{Path: "/tmp/readme.txt"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "/tmp/readme.txt", mp.lastPath) +} + +func TestTaskOpenFile_Bad_PlatformError(t *testing.T) { + mp := &mockPlatform{fileErr: errors.New("file not found")} + _, c := newTestBrowserService(t, mp) + + _, handled, err := c.PERFORM(TaskOpenFile{Path: "/nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOpenURL_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskOpenURL{URL: "https://example.com"}) + assert.False(t, handled) +}