feat(window): add IPC layer — Service, Register factory, message types

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 <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-13 13:25:34 +00:00
parent bf6f46345d
commit a59028f112
4 changed files with 452 additions and 0 deletions

74
pkg/window/messages.go Normal file
View file

@ -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 }

15
pkg/window/register.go Normal file
View file

@ -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
}
}

224
pkg/window/service.go Normal file
View file

@ -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
}

139
pkg/window/service_test.go Normal file
View file

@ -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)
}