From a59028f112c6f79b9444540846305675053c8273 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 13:25:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(window):=20add=20IPC=20layer=20=E2=80=94?= =?UTF-8?q?=20Service,=20Register=20factory,=20message=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window package is now a full core.Service with typed IPC messages. Register(Platform) factory closure captures platform adapter for WithService. OnStartup queries config and registers query/task handlers. Platform events converted to IPC actions via trackWindow. Co-Authored-By: Virgil --- pkg/window/messages.go | 74 ++++++++++++ pkg/window/register.go | 15 +++ pkg/window/service.go | 224 +++++++++++++++++++++++++++++++++++++ pkg/window/service_test.go | 139 +++++++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 pkg/window/messages.go create mode 100644 pkg/window/register.go create mode 100644 pkg/window/service.go create mode 100644 pkg/window/service_test.go diff --git a/pkg/window/messages.go b/pkg/window/messages.go new file mode 100644 index 0000000..b999e07 --- /dev/null +++ b/pkg/window/messages.go @@ -0,0 +1,74 @@ +package window + +// WindowInfo contains information about a window. +type WindowInfo struct { + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Maximized bool `json:"maximized"` + Focused bool `json:"focused"` +} + +// --- Queries (read-only) --- + +// QueryWindowList returns all tracked windows. Result: []WindowInfo +type QueryWindowList struct{} + +// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found) +type QueryWindowByName struct{ Name string } + +// QueryConfig requests this service's config section from the display orchestrator. +// Result: map[string]any +type QueryConfig struct{} + +// --- Tasks (side-effects) --- + +// TaskOpenWindow creates a new window. Result: WindowInfo +type TaskOpenWindow struct{ Opts []WindowOption } + +// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed. +type TaskCloseWindow struct{ Name string } + +// TaskSetPosition moves a window. +type TaskSetPosition struct { + Name string + X, Y int +} + +// TaskSetSize resizes a window. +type TaskSetSize struct { + Name string + W, H int +} + +// TaskMaximise maximises a window. +type TaskMaximise struct{ Name string } + +// TaskMinimise minimises a window. +type TaskMinimise struct{ Name string } + +// TaskFocus brings a window to the front. +type TaskFocus struct{ Name string } + +// TaskSaveConfig persists this service's config section via the display orchestrator. +type TaskSaveConfig struct{ Value map[string]any } + +// --- Actions (broadcasts) --- + +type ActionWindowOpened struct{ Name string } +type ActionWindowClosed struct{ Name string } + +type ActionWindowMoved struct { + Name string + X, Y int +} + +type ActionWindowResized struct { + Name string + W, H int +} + +type ActionWindowFocused struct{ Name string } +type ActionWindowBlurred struct{ Name string } diff --git a/pkg/window/register.go b/pkg/window/register.go new file mode 100644 index 0000000..63812f1 --- /dev/null +++ b/pkg/window/register.go @@ -0,0 +1,15 @@ +package window + +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, + manager: NewManager(p), + }, nil + } +} diff --git a/pkg/window/service.go b/pkg/window/service.go new file mode 100644 index 0000000..b409907 --- /dev/null +++ b/pkg/window/service.go @@ -0,0 +1,224 @@ +package window + +import ( + "context" + "fmt" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the window service. +type Options struct{} + +// Service is a core.Service managing window lifecycle via IPC. +// It embeds ServiceRuntime for Core access and composes Manager for platform operations. +type Service struct { + *core.ServiceRuntime[Options] + manager *Manager + platform Platform +} + +// OnStartup queries config from the display orchestrator and registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + // Query config — display registers its handler before us (registration order guarantee). + // If display is not registered, handled=false and we skip config. + cfg, handled, _ := s.Core().QUERY(QueryConfig{}) + if handled { + if wCfg, ok := cfg.(map[string]any); ok { + s.applyConfig(wCfg) + } + } + + // Register QUERY and TASK handlers manually. + // ACTION handler (HandleIPCEvents) is auto-registered by WithService — + // do NOT call RegisterAction here or actions will double-fire. + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +func (s *Service) applyConfig(cfg map[string]any) { + // Apply config to manager defaults — future expansion. + // e.g., default_width, default_height, state_file path. +} + +// HandleIPCEvents is auto-discovered and registered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +// --- Query Handlers --- + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q := q.(type) { + case QueryWindowList: + return s.queryWindowList(), true, nil + case QueryWindowByName: + return s.queryWindowByName(q.Name), true, nil + default: + return nil, false, nil + } +} + +func (s *Service) queryWindowList() []WindowInfo { + names := s.manager.List() + result := make([]WindowInfo, 0, len(names)) + for _, name := range names { + if pw, ok := s.manager.Get(name); ok { + x, y := pw.Position() + w, h := pw.Size() + result = append(result, WindowInfo{ + Name: name, X: x, Y: y, Width: w, Height: h, + Maximized: pw.IsMaximised(), + Focused: pw.IsFocused(), + }) + } + } + return result +} + +func (s *Service) queryWindowByName(name string) *WindowInfo { + pw, ok := s.manager.Get(name) + if !ok { + return nil + } + x, y := pw.Position() + w, h := pw.Size() + return &WindowInfo{ + Name: name, X: x, Y: y, Width: w, Height: h, + Maximized: pw.IsMaximised(), + Focused: pw.IsFocused(), + } +} + +// --- Task Handlers --- + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskOpenWindow: + return s.taskOpenWindow(t) + case TaskCloseWindow: + return nil, true, s.taskCloseWindow(t.Name) + case TaskSetPosition: + return nil, true, s.taskSetPosition(t.Name, t.X, t.Y) + case TaskSetSize: + return nil, true, s.taskSetSize(t.Name, t.W, t.H) + case TaskMaximise: + return nil, true, s.taskMaximise(t.Name) + case TaskMinimise: + return nil, true, s.taskMinimise(t.Name) + case TaskFocus: + return nil, true, s.taskFocus(t.Name) + default: + return nil, false, nil + } +} + +func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { + pw, err := s.manager.Open(t.Opts...) + if err != nil { + return nil, true, err + } + x, y := pw.Position() + w, h := pw.Size() + info := WindowInfo{Name: pw.Name(), X: x, Y: y, Width: w, Height: h} + + // Attach platform event listeners that convert to IPC actions + s.trackWindow(pw) + + // Broadcast to all listeners + _ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()}) + return info, true, nil +} + +// trackWindow attaches platform event listeners that emit IPC actions. +func (s *Service) trackWindow(pw PlatformWindow) { + pw.OnWindowEvent(func(e WindowEvent) { + switch e.Type { + case "focus": + _ = s.Core().ACTION(ActionWindowFocused{Name: e.Name}) + case "blur": + _ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name}) + case "move": + if data := e.Data; data != nil { + x, _ := data["x"].(int) + y, _ := data["y"].(int) + _ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y}) + } + case "resize": + if data := e.Data; data != nil { + w, _ := data["w"].(int) + h, _ := data["h"].(int) + _ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h}) + } + case "close": + _ = s.Core().ACTION(ActionWindowClosed{Name: e.Name}) + } + }) +} + +func (s *Service) taskCloseWindow(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + // Persist state BEFORE closing (spec requirement) + s.manager.State().CaptureState(pw) + pw.Close() + s.manager.Remove(name) + _ = s.Core().ACTION(ActionWindowClosed{Name: name}) + return nil +} + +func (s *Service) taskSetPosition(name string, x, y int) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetPosition(x, y) + s.manager.State().UpdatePosition(name, x, y) + return nil +} + +func (s *Service) taskSetSize(name string, w, h int) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetSize(w, h) + s.manager.State().UpdateSize(name, w, h) + return nil +} + +func (s *Service) taskMaximise(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Maximise() + s.manager.State().UpdateMaximized(name, true) + return nil +} + +func (s *Service) taskMinimise(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Minimise() + return nil +} + +func (s *Service) taskFocus(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Focus() + return nil +} + +// Manager returns the underlying window Manager for direct access. +func (s *Service) Manager() *Manager { + return s.manager +} diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go new file mode 100644 index 0000000..05997ac --- /dev/null +++ b/pkg/window/service_test.go @@ -0,0 +1,139 @@ +package window + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestWindowService(t *testing.T) (*Service, *core.Core) { + t.Helper() + c, err := core.New( + core.WithService(Register(newMockPlatform())), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "window") + return svc, c +} + +func TestRegister_Good(t *testing.T) { + svc, _ := newTestWindowService(t) + assert.NotNil(t, svc) + assert.NotNil(t, svc.manager) +} + +func TestTaskOpenWindow_Good(t *testing.T) { + _, c := newTestWindowService(t) + result, handled, err := c.PERFORM(TaskOpenWindow{ + Opts: []WindowOption{WithName("test"), WithURL("/")}, + }) + require.NoError(t, err) + assert.True(t, handled) + info := result.(WindowInfo) + assert.Equal(t, "test", info.Name) +} + +func TestTaskOpenWindow_Bad(t *testing.T) { + // No window service registered — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskOpenWindow{}) + assert.False(t, handled) +} + +func TestQueryWindowList_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}}) + + result, handled, err := c.QUERY(QueryWindowList{}) + require.NoError(t, err) + assert.True(t, handled) + list := result.([]WindowInfo) + assert.Len(t, list, 2) +} + +func TestQueryWindowByName_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + result, handled, err := c.QUERY(QueryWindowByName{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + info := result.(*WindowInfo) + assert.Equal(t, "test", info.Name) +} + +func TestQueryWindowByName_Bad(t *testing.T) { + _, c := newTestWindowService(t) + result, handled, err := c.QUERY(QueryWindowByName{Name: "nonexistent"}) + require.NoError(t, err) + assert.True(t, handled) // handled=true, result is nil (not found) + assert.Nil(t, result) +} + +func TestTaskCloseWindow_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + // Verify window is removed + result, _, _ := c.QUERY(QueryWindowByName{Name: "test"}) + assert.Nil(t, result) +} + +func TestTaskCloseWindow_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskCloseWindow{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskSetPosition_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, _ := c.QUERY(QueryWindowByName{Name: "test"}) + info := result.(*WindowInfo) + assert.Equal(t, 100, info.X) + assert.Equal(t, 200, info.Y) +} + +func TestTaskSetSize_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, _ := c.QUERY(QueryWindowByName{Name: "test"}) + info := result.(*WindowInfo) + assert.Equal(t, 800, info.Width) + assert.Equal(t, 600, info.Height) +} + +func TestTaskMaximise_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskMaximise{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, _ := c.QUERY(QueryWindowByName{Name: "test"}) + info := result.(*WindowInfo) + assert.True(t, info.Maximized) +}