diff --git a/pkg/display/display.go b/pkg/display/display.go index 1bcf4cf..c952d2b 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -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 { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 6a1d1e6..c1d60e9 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -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) +} diff --git a/pkg/menu/mock_platform.go b/pkg/menu/mock_platform.go new file mode 100644 index 0000000..02b1236 --- /dev/null +++ b/pkg/menu/mock_platform.go @@ -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 } diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go new file mode 100644 index 0000000..0f3f6e1 --- /dev/null +++ b/pkg/systray/mock_platform.go @@ -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{} } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go new file mode 100644 index 0000000..3317ada --- /dev/null +++ b/pkg/window/mock_platform.go @@ -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) }