feat(display): add HandleIPCEvents IPC->WS bridge

Display HandleIPCEvents converts sub-service actions to WS events.
ActionServiceStartup triggers buildMenu/setupTray after all services start.
Export mock platforms from each sub-package for integration tests.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-13 13:32:16 +00:00
parent d29cf62e6f
commit 0893456a9e
5 changed files with 236 additions and 0 deletions

View file

@ -77,6 +77,96 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// It bridges sub-service IPC actions to WebSocket events for TS apps.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
if s.events == nil && s.wailsApp != nil {
return nil // No WS event manager (testing without Wails)
}
switch m := msg.(type) {
case core.ActionServiceStartup:
// All services have completed OnStartup — safe to PERFORM on sub-services
if s.menus != nil {
s.buildMenu()
}
if s.tray != nil {
s.setupTray()
}
case window.ActionWindowOpened:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name,
Data: map[string]any{"name": m.Name}})
}
case window.ActionWindowClosed:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowClose, Window: m.Name,
Data: map[string]any{"name": m.Name}})
}
case window.ActionWindowMoved:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowMove, Window: m.Name,
Data: map[string]any{"x": m.X, "y": m.Y}})
}
case window.ActionWindowResized:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowResize, Window: m.Name,
Data: map[string]any{"w": m.W, "h": m.H}})
}
case window.ActionWindowFocused:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name})
}
case window.ActionWindowBlurred:
if s.events != nil {
s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name})
}
case systray.ActionTrayClicked:
if s.events != nil {
s.events.Emit(Event{Type: EventTrayClick})
}
case systray.ActionTrayMenuItemClicked:
if s.events != nil {
s.events.Emit(Event{Type: EventTrayMenuItemClick,
Data: map[string]any{"actionId": m.ActionID}})
}
s.handleTrayAction(m.ActionID)
}
return nil
}
// handleTrayAction processes tray menu item clicks.
func (s *Service) handleTrayAction(actionID string) {
switch actionID {
case "open-desktop":
// Show all windows
if s.windows != nil {
for _, name := range s.windows.List() {
if pw, ok := s.windows.Get(name); ok {
pw.Show()
}
}
}
case "close-desktop":
// Hide all windows — future: add TaskHideWindow
if s.windows != nil {
for _, name := range s.windows.List() {
if pw, ok := s.windows.Get(name); ok {
pw.Hide()
}
}
}
case "env-info":
if s.app != nil {
s.ShowEnvironmentDialog()
}
case "quit":
if s.app != nil {
s.app.Quit()
}
}
}
func (s *Service) loadConfig() {
// In-memory defaults. go-config integration is deferred work.
if s.configData == nil {

View file

@ -597,3 +597,23 @@ func TestGetSavedWindowStates_Good(t *testing.T) {
states := service.GetSavedWindowStates()
assert.NotNil(t, states)
}
func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
c, err := core.New(
core.WithService(Register(nil)),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
// Open a window — this should trigger ActionWindowOpened
// which HandleIPCEvents should convert to a WS event
result, handled, err := c.PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{window.WithName("test")},
})
require.NoError(t, err)
assert.True(t, handled)
info := result.(window.WindowInfo)
assert.Equal(t, "test", info.Name)
}

24
pkg/menu/mock_platform.go Normal file
View file

@ -0,0 +1,24 @@
package menu
// MockPlatform is an exported mock for cross-package integration tests.
type MockPlatform struct{}
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockPlatformMenu{} }
func (m *MockPlatform) SetApplicationMenu(menu PlatformMenu) {}
type exportedMockPlatformMenu struct{}
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem { return &exportedMockPlatformMenuItem{} }
func (m *exportedMockPlatformMenu) AddSeparator() {}
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu { return &exportedMockPlatformMenu{} }
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
type exportedMockPlatformMenuItem struct{}
func (mi *exportedMockPlatformMenuItem) SetAccelerator(acc string) PlatformMenuItem { return mi }
func (mi *exportedMockPlatformMenuItem) SetTooltip(tip string) PlatformMenuItem { return mi }
func (mi *exportedMockPlatformMenuItem) SetChecked(checked bool) PlatformMenuItem { return mi }
func (mi *exportedMockPlatformMenuItem) SetEnabled(enabled bool) PlatformMenuItem { return mi }
func (mi *exportedMockPlatformMenuItem) OnClick(fn func()) PlatformMenuItem { return mi }

View file

@ -0,0 +1,42 @@
package systray
// MockPlatform is an exported mock for cross-package integration tests.
type MockPlatform struct{}
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} }
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} }
type exportedMockTray struct {
icon, templateIcon []byte
tooltip, label string
}
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
type exportedMockMenu struct{ items []exportedMockMenuItem }
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
mi := &exportedMockMenuItem{label: label}
m.items = append(m.items, *mi)
return mi
}
func (m *exportedMockMenu) AddSeparator() {}
type exportedMockMenuItem struct {
label, tooltip string
checked, enabled bool
onClick func()
}
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }

View file

@ -0,0 +1,60 @@
package window
// MockPlatform is an exported mock for cross-package integration tests.
// For internal tests, use the unexported mockPlatform in mock_test.go.
type MockPlatform struct {
Windows []*MockWindow
}
func NewMockPlatform() *MockPlatform {
return &MockPlatform{}
}
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
w := &MockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
}
m.Windows = append(m.Windows, w)
return w
}
func (m *MockPlatform) GetWindows() []PlatformWindow {
out := make([]PlatformWindow, len(m.Windows))
for i, w := range m.Windows {
out[i] = w
}
return out
}
type MockWindow struct {
name, title, url string
width, height, x, y int
maximised, focused bool
visible, alwaysOnTop bool
closed bool
eventHandlers []func(WindowEvent)
}
func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) IsMaximised() bool { return w.maximised }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }