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:
parent
bf6f46345d
commit
a59028f112
4 changed files with 452 additions and 0 deletions
74
pkg/window/messages.go
Normal file
74
pkg/window/messages.go
Normal 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
15
pkg/window/register.go
Normal 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
224
pkg/window/service.go
Normal 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
139
pkg/window/service_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue