From 479537d12cdef430cd503f0455eb176c58554aad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 14:18:05 +0100 Subject: [PATCH] feat(gui): theme override, clipboard images, notifications, tray, layout helpers Pass from codex implementing RFC spec gaps: theme_set IPC + state, clipboard image read/write + MCP exposure, interactive notifications and clearing, real tray tooltip/label/menu/message tasks, layout heuristics (layout_suggest, layout_beside_editor, screen_find_space, window_arrange_pair), dialog_message, focus_set, screen_work_area. Co-Authored-By: Virgil --- pkg/clipboard/messages.go | 6 + pkg/clipboard/platform.go | 12 ++ pkg/clipboard/service.go | 20 +++- pkg/clipboard/service_test.go | 26 ++++- pkg/display/FEATURES.md | 32 ++--- pkg/environment/messages.go | 5 + pkg/environment/service.go | 35 +++++- pkg/environment/service_test.go | 40 ++++++- pkg/mcp/layout_helpers.go | 195 +++++++++++++++++++++++++++++++ pkg/mcp/mcp_test.go | 99 +++++++++++++++- pkg/mcp/tools_clipboard.go | 50 ++++++++ pkg/mcp/tools_dialog.go | 30 +++++ pkg/mcp/tools_environment.go | 18 +++ pkg/mcp/tools_layout.go | 177 ++++++++++++++++++++++++++++ pkg/mcp/tools_notification.go | 44 +++++++ pkg/mcp/tools_screen.go | 71 +++++++++++ pkg/mcp/tools_tray.go | 90 +++++++++++++- pkg/mcp/tools_window.go | 108 +++++++++++++++++ pkg/notification/messages.go | 3 + pkg/notification/platform.go | 17 ++- pkg/notification/service.go | 21 ++++ pkg/notification/service_test.go | 56 +++++++-- pkg/systray/messages.go | 9 ++ pkg/systray/mock_platform.go | 13 ++- pkg/systray/mock_test.go | 7 ++ pkg/systray/platform.go | 1 + pkg/systray/service.go | 6 + pkg/systray/service_test.go | 32 +++++ pkg/systray/tray.go | 8 ++ pkg/systray/wails.go | 6 + 30 files changed, 1186 insertions(+), 51 deletions(-) create mode 100644 pkg/mcp/layout_helpers.go diff --git a/pkg/clipboard/messages.go b/pkg/clipboard/messages.go index 29f29de5..052e8593 100644 --- a/pkg/clipboard/messages.go +++ b/pkg/clipboard/messages.go @@ -4,8 +4,14 @@ package clipboard // QueryText reads the clipboard. Result: ClipboardContent type QueryText struct{} +// QueryImage reads image data from the clipboard. Result: ImageContent +type QueryImage struct{} + // TaskSetText writes text to the clipboard. Result: bool (success) type TaskSetText struct{ Text string } +// TaskSetImage writes image data to the clipboard. Result: bool (success) +type TaskSetImage struct{ Data []byte } + // TaskClear clears the clipboard. Result: bool (success) type TaskClear struct{} diff --git a/pkg/clipboard/platform.go b/pkg/clipboard/platform.go index 1857cfd3..deb2f6ac 100644 --- a/pkg/clipboard/platform.go +++ b/pkg/clipboard/platform.go @@ -7,8 +7,20 @@ type Platform interface { SetText(text string) bool } +// ImagePlatform is an optional extension for clipboard backends that support images. +type ImagePlatform interface { + Image() ([]byte, bool) + SetImage(data []byte) bool +} + // ClipboardContent is the result of QueryText. type ClipboardContent struct { Text string `json:"text"` HasContent bool `json:"hasContent"` } + +// ImageContent is the result of QueryImage. +type ImageContent struct { + Data []byte `json:"data"` + HasImage bool `json:"hasImage"` +} diff --git a/pkg/clipboard/service.go b/pkg/clipboard/service.go index ee47af6e..196e6c6c 100644 --- a/pkg/clipboard/service.go +++ b/pkg/clipboard/service.go @@ -4,6 +4,7 @@ package clipboard import ( "context" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -40,6 +41,13 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { case QueryText: text, ok := s.platform.Text() return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil + case QueryImage: + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return ImageContent{}, true, nil + } + data, hasImage := imgPlatform.Image() + return ImageContent{Data: data, HasImage: hasImage && len(data) > 0}, true, nil default: return nil, false, nil } @@ -49,8 +57,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskSetText: return s.platform.SetText(t.Text), true, nil + case TaskSetImage: + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return nil, true, coreerr.E("clipboard.handleTask", "clipboard image operations are not supported by this platform", nil) + } + return imgPlatform.SetImage(t.Data), true, nil case TaskClear: - return s.platform.SetText(""), true, nil + success := s.platform.SetText("") + if imgPlatform, ok := s.platform.(ImagePlatform); ok { + success = imgPlatform.SetImage(nil) && success + } + return success, true, nil default: return nil, false, nil } diff --git a/pkg/clipboard/service_test.go b/pkg/clipboard/service_test.go index 63677df7..c7ee3c8f 100644 --- a/pkg/clipboard/service_test.go +++ b/pkg/clipboard/service_test.go @@ -11,8 +11,10 @@ import ( ) type mockPlatform struct { - text string - ok bool + text string + ok bool + image []byte + hasImage bool } func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } @@ -21,6 +23,12 @@ func (m *mockPlatform) SetText(text string) bool { m.ok = text != "" return true } +func (m *mockPlatform) Image() ([]byte, bool) { return m.image, m.hasImage } +func (m *mockPlatform) SetImage(data []byte) bool { + m.image = append([]byte(nil), data...) + m.hasImage = len(data) > 0 + return true +} func newTestService(t *testing.T) (*Service, *core.Core) { t.Helper() @@ -79,3 +87,17 @@ func TestTaskClear_Good(t *testing.T) { assert.Equal(t, "", r.(ClipboardContent).Text) assert.False(t, r.(ClipboardContent).HasContent) } + +func TestQueryImage_Good(t *testing.T) { + _, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSetImage{Data: []byte{0x89, 0x50, 0x4e, 0x47}}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryImage{}) + require.NoError(t, err) + assert.True(t, handled) + content := result.(ImageContent) + assert.True(t, content.HasImage) + assert.Equal(t, []byte{0x89, 0x50, 0x4e, 0x47}, content.Data) +} diff --git a/pkg/display/FEATURES.md b/pkg/display/FEATURES.md index f336a61f..474e2bee 100644 --- a/pkg/display/FEATURES.md +++ b/pkg/display/FEATURES.md @@ -59,13 +59,13 @@ This document tracks the implementation of display server features that enable A ### Smart Layout - [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid) - [x] `layout_stack` - Stack windows in cascade pattern -- [ ] `layout_beside_editor` - Position window beside detected IDE window -- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement +- [x] `layout_beside_editor` - Position window beside detected IDE window +- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement - [x] `layout_snap` - Snap window to screen edge/corner/center ### AI-Optimized Layout -- [ ] `screen_find_space` - Find empty screen space for new window -- [ ] `window_arrange_pair` - Put two windows side-by-side optimally +- [x] `screen_find_space` - Find empty screen space for new window +- [x] `window_arrange_pair` - Put two windows side-by-side optimally - [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side" --- @@ -124,8 +124,8 @@ This document tracks the implementation of display server features that enable A ### Clipboard - [x] `clipboard_read` - Read clipboard text content - [x] `clipboard_write` - Write text to clipboard -- [ ] `clipboard_read_image` - Read image from clipboard -- [ ] `clipboard_write_image` - Write image to clipboard +- [x] `clipboard_read_image` - Read image from clipboard +- [x] `clipboard_write_image` - Write image to clipboard - [x] `clipboard_has` - Check clipboard content type - [x] `clipboard_clear` - Clear clipboard contents @@ -133,8 +133,8 @@ This document tracks the implementation of display server features that enable A - [x] `notification_show` - Show native system notification (macOS/Windows/Linux) - [x] `notification_permission_request` - Request notification permission - [x] `notification_permission_check` - Check notification authorization status -- [ ] `notification_clear` - Clear notifications -- [ ] `notification_with_actions` - Interactive notifications with buttons +- [x] `notification_clear` - Clear notifications +- [x] `notification_with_actions` - Interactive notifications with buttons ### Dialogs - [x] `dialog_open_file` - Show file open dialog @@ -146,7 +146,7 @@ This document tracks the implementation of display server features that enable A ### Theme & Appearance - [x] `theme_get` - Get current theme (dark/light) -- [ ] `theme_set` - Set application theme +- [x] `theme_set` - Set application theme - [x] `theme_system` - Get system theme preference - [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events) @@ -173,7 +173,7 @@ This document tracks the implementation of display server features that enable A - [x] `tray_set_label` - Set tray label text - [x] `tray_set_menu` - Set tray menu items (with nested submenus) - [x] `tray_info` - Get tray status info -- [ ] `tray_show_message` - Show tray balloon notification +- [x] `tray_show_message` - Show tray balloon notification --- @@ -193,7 +193,7 @@ This document tracks the implementation of display server features that enable A ### Phase 3 - Layouts (DONE) - [x] layout_save, layout_restore, layout_list - [x] layout_delete, layout_get -- [ ] layout_tile, layout_beside_editor (future) +- [x] layout_tile, layout_beside_editor ### Phase 4 - WebView Debug (DONE) - [x] webview_screenshot, webview_screenshot_element @@ -202,7 +202,7 @@ This document tracks the implementation of display server features that enable A - [x] webview_scroll, webview_hover, webview_select, webview_check - [x] webview_highlight, webview_errors - [x] webview_performance, webview_resources -- [ ] webview_network, webview_devtools (future) +- [~] webview_network complete; webview_devtools pending ### Phase 5 - System Integration (DONE) - [x] clipboard_read, clipboard_write, clipboard_has, clipboard_clear @@ -236,8 +236,8 @@ This document tracks the implementation of display server features that enable A ### Phase 8 - Remaining Features (Future) - [ ] window_opacity (true opacity if Wails adds support) -- [ ] layout_beside_editor, layout_suggest +- [x] layout_beside_editor, layout_suggest - [ ] webview_devtools_open, webview_devtools_close -- [ ] clipboard_read_image, clipboard_write_image -- [ ] notification_with_actions, notification_clear -- [ ] tray_show_message - Balloon notifications +- [x] clipboard_read_image, clipboard_write_image +- [x] notification_with_actions, notification_clear +- [x] tray_show_message - Balloon notifications diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index 2beaeddc..b79a6730 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -10,6 +10,11 @@ type QueryInfo struct{} // QueryAccentColour returns the system accent colour. Result: string type QueryAccentColour struct{} +// TaskSetTheme overrides the application theme. Theme values: "light", "dark", "system". +type TaskSetTheme struct { + Theme string `json:"theme"` +} + // TaskOpenFileManager opens the system file manager. Result: error only type TaskOpenFileManager struct { Path string `json:"path"` diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 6cc41923..6052a756 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -3,7 +3,9 @@ package environment import ( "context" + "strings" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -13,6 +15,7 @@ type Service struct { *core.ServiceRuntime[Options] platform Platform cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown + override *bool } // Register(p) binds the environment service to a Core instance. @@ -32,6 +35,9 @@ func (s *Service) OnStartup(ctx context.Context) error { // Register theme change callback — broadcasts ActionThemeChanged via IPC s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { + if s.override != nil { + isDark = *s.override + } _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) }) return nil @@ -51,7 +57,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { switch q.(type) { case QueryTheme: - isDark := s.platform.IsDarkMode() + isDark := s.currentThemeIsDark() theme := "light" if isDark { theme = "dark" @@ -70,9 +76,36 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { + case TaskSetTheme: + return nil, true, s.setThemeOverride(strings.ToLower(strings.TrimSpace(t.Theme))) case TaskOpenFileManager: return nil, true, s.platform.OpenFileManager(t.Path, t.Select) default: return nil, false, nil } } + +func (s *Service) currentThemeIsDark() bool { + if s.override != nil { + return *s.override + } + return s.platform.IsDarkMode() +} + +func (s *Service) setThemeOverride(theme string) error { + switch theme { + case "", "system": + s.override = nil + case "dark": + value := true + s.override = &value + case "light": + value := false + s.override = &value + default: + return coreerr.E("environment.setThemeOverride", "theme must be one of: light, dark, system", nil) + } + + _ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentThemeIsDark()}) + return nil +} diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index d5dae911..8247344b 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -20,9 +20,9 @@ type mockPlatform struct { mu sync.Mutex } -func (m *mockPlatform) IsDarkMode() bool { return m.isDark } -func (m *mockPlatform) Info() EnvironmentInfo { return m.info } -func (m *mockPlatform) AccentColour() string { return m.accentColour } +func (m *mockPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockPlatform) Info() EnvironmentInfo { return m.info } +func (m *mockPlatform) AccentColour() string { return m.accentColour } func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { return m.openFMErr } @@ -132,3 +132,37 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) { require.NotNil(t, r) assert.False(t, r.IsDark) } + +func TestTaskSetTheme_Good(t *testing.T) { + _, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.False(t, theme.IsDark) + assert.Equal(t, "light", theme.Theme) +} + +func TestTaskSetTheme_Good_SystemClearsOverride(t *testing.T) { + mock, c := newTestService(t) + + _, _, err := c.PERFORM(TaskSetTheme{Theme: "light"}) + require.NoError(t, err) + + mock.isDark = true + _, handled, err := c.PERFORM(TaskSetTheme{Theme: "system"}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.True(t, theme.IsDark) + assert.Equal(t, "dark", theme.Theme) +} diff --git a/pkg/mcp/layout_helpers.go b/pkg/mcp/layout_helpers.go new file mode 100644 index 00000000..b19fdb4c --- /dev/null +++ b/pkg/mcp/layout_helpers.go @@ -0,0 +1,195 @@ +package mcp + +import ( + "sort" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" +) + +func (s *Subsystem) allWindows() ([]window.WindowInfo, error) { + result, _, err := s.core.QUERY(window.QueryWindowList{}) + if err != nil { + return nil, err + } + windows, _ := result.([]window.WindowInfo) + return windows, nil +} + +func (s *Subsystem) allScreens() ([]screen.Screen, error) { + result, _, err := s.core.QUERY(screen.QueryAll{}) + if err != nil { + return nil, err + } + screens, _ := result.([]screen.Screen) + return screens, nil +} + +func (s *Subsystem) primaryScreen() (*screen.Screen, error) { + result, _, err := s.core.QUERY(screen.QueryPrimary{}) + if err != nil { + return nil, err + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +func (s *Subsystem) screenByID(id string) (*screen.Screen, error) { + if id == "" { + return nil, nil + } + result, _, err := s.core.QUERY(screen.QueryByID{ID: id}) + if err != nil { + return nil, err + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +func screenForWindowInfo(screens []screen.Screen, info window.WindowInfo) *screen.Screen { + cx := info.X + info.Width/2 + cy := info.Y + info.Height/2 + for i := range screens { + if screens[i].Bounds.Contains(cx, cy) { + return &screens[i] + } + } + return nil +} + +func chooseScreenByIDOrPrimary(screens []screen.Screen, screenID string) *screen.Screen { + if screenID != "" { + for i := range screens { + if screens[i].ID == screenID { + return &screens[i] + } + } + } + for i := range screens { + if screens[i].IsPrimary { + return &screens[i] + } + } + if len(screens) == 0 { + return nil + } + return &screens[0] +} + +func workAreaRect(scr *screen.Screen) screen.Rect { + if scr == nil { + return screen.Rect{} + } + if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 { + return scr.WorkArea + } + return scr.Bounds +} + +func uniqueSorted(values []int) []int { + sort.Ints(values) + if len(values) == 0 { + return values + } + out := values[:1] + for _, value := range values[1:] { + if value != out[len(out)-1] { + out = append(out, value) + } + } + return out +} + +func clipRectToWorkArea(rect, workArea screen.Rect) (screen.Rect, bool) { + x1 := max(rect.X, workArea.X) + y1 := max(rect.Y, workArea.Y) + x2 := min(rect.X+rect.Width, workArea.X+workArea.Width) + y2 := min(rect.Y+rect.Height, workArea.Y+workArea.Height) + if x2 <= x1 || y2 <= y1 { + return screen.Rect{}, false + } + return screen.Rect{X: x1, Y: y1, Width: x2 - x1, Height: y2 - y1}, true +} + +func findLargestFreeRect(workArea screen.Rect, occupied []screen.Rect, minWidth, minHeight int) (screen.Rect, bool) { + xs := []int{workArea.X, workArea.X + workArea.Width} + ys := []int{workArea.Y, workArea.Y + workArea.Height} + + for _, rect := range occupied { + clipped, ok := clipRectToWorkArea(rect, workArea) + if !ok { + continue + } + xs = append(xs, clipped.X, clipped.X+clipped.Width) + ys = append(ys, clipped.Y, clipped.Y+clipped.Height) + } + + xs = uniqueSorted(xs) + ys = uniqueSorted(ys) + + bestArea := -1 + best := screen.Rect{} + + for xi := 0; xi < len(xs)-1; xi++ { + for xj := xi + 1; xj < len(xs); xj++ { + width := xs[xj] - xs[xi] + if width < minWidth { + continue + } + for yi := 0; yi < len(ys)-1; yi++ { + for yj := yi + 1; yj < len(ys); yj++ { + height := ys[yj] - ys[yi] + if height < minHeight { + continue + } + candidate := screen.Rect{X: xs[xi], Y: ys[yi], Width: width, Height: height} + if candidate.X < workArea.X || candidate.Y < workArea.Y || + candidate.X+candidate.Width > workArea.X+workArea.Width || + candidate.Y+candidate.Height > workArea.Y+workArea.Height { + continue + } + overlaps := false + for _, occ := range occupied { + if candidate.Overlaps(occ) { + overlaps = true + break + } + } + if overlaps { + continue + } + area := candidate.Width * candidate.Height + if area > bestArea || (area == bestArea && (candidate.Y < best.Y || (candidate.Y == best.Y && candidate.X < best.X))) { + bestArea = area + best = candidate + } + } + } + } + } + + return best, bestArea >= 0 +} + +func applyRect(c *core.Core, windowName string, rect screen.Rect) error { + if _, _, err := c.PERFORM(window.TaskSetPosition{Name: windowName, X: rect.X, Y: rect.Y}); err != nil { + return err + } + _, _, err := c.PERFORM(window.TaskSetSize{Name: windowName, Width: rect.Width, Height: rect.Height}) + return err +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 7b9d4296..15708d48 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -7,6 +7,9 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/clipboard" + "forge.lthn.ai/core/gui/pkg/environment" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,12 +32,20 @@ func TestSubsystem_Good_RegisterTools(t *testing.T) { // Integration test: verify the IPC round-trip that MCP tool handlers use. type mockClipPlatform struct { - text string - ok bool + text string + ok bool + image []byte + hasImage bool } func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } +func (m *mockClipPlatform) Image() ([]byte, bool) { return m.image, m.hasImage } +func (m *mockClipPlatform) SetImage(data []byte) bool { + m.image = append([]byte(nil), data...) + m.hasImage = len(data) > 0 + return true +} func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { c, err := core.New( @@ -59,3 +70,87 @@ func TestMCP_Bad_NoServices(t *testing.T) { _, handled, _ := c.QUERY(clipboard.QueryText{}) assert.False(t, handled) } + +type mockEnvPlatform struct { + isDark bool +} + +func (m *mockEnvPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockEnvPlatform) Info() environment.EnvironmentInfo { return environment.EnvironmentInfo{} } +func (m *mockEnvPlatform) AccentColour() string { return "" } +func (m *mockEnvPlatform) OpenFileManager(path string, selectFile bool) error { return nil } +func (m *mockEnvPlatform) HasFocusFollowsMouse() bool { return false } +func (m *mockEnvPlatform) OnThemeChange(handler func(isDark bool)) func() { + return func() {} +} + +type mockScreenPlatform struct { + screens []screen.Screen +} + +func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens } +func (m *mockScreenPlatform) GetPrimary() *screen.Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} +func (m *mockScreenPlatform) GetCurrent() *screen.Screen { return m.GetPrimary() } + +func TestMCP_Good_ThemeSetRoundTrip(t *testing.T) { + c, err := core.New( + core.WithService(environment.Register(&mockEnvPlatform{isDark: true})), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + sub := NewSubsystem(c) + _, output, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"}) + require.NoError(t, err) + assert.True(t, output.Success) + + result, handled, err := c.QUERY(environment.QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(environment.ThemeInfo) + assert.Equal(t, "light", theme.Theme) + assert.False(t, theme.IsDark) +} + +func TestMCP_Good_ScreenFindSpaceAndArrangePair(t *testing.T) { + c, err := core.New( + core.WithService(screen.Register(&mockScreenPlatform{screens: []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900}, + }, + }})), + core.WithService(window.Register(window.NewMockPlatform())), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + _, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "editor", X: 0, Y: 0, Width: 800, Height: 900}}) + require.NoError(t, err) + _, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "preview", X: 800, Y: 0, Width: 800, Height: 450}}) + require.NoError(t, err) + + sub := NewSubsystem(c) + + _, free, err := sub.screenFindSpace(context.Background(), nil, ScreenFindSpaceInput{Width: 300, Height: 300}) + require.NoError(t, err) + assert.Equal(t, "1", free.ScreenID) + assert.Equal(t, screen.Rect{X: 800, Y: 450, Width: 800, Height: 450}, free.Bounds) + + _, arranged, err := sub.windowArrangePair(context.Background(), nil, WindowArrangePairInput{ + First: "editor", Second: "preview", + }) + require.NoError(t, err) + assert.Equal(t, screen.Rect{X: 0, Y: 0, Width: 800, Height: 900}, arranged.FirstBounds) + assert.Equal(t, screen.Rect{X: 800, Y: 0, Width: 800, Height: 900}, arranged.SecondBounds) +} diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 82aa4358..0c7420dd 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -3,6 +3,7 @@ package mcp import ( "context" + "encoding/base64" coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/clipboard" @@ -87,11 +88,60 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ClipboardClearOutput{Success: success}, nil } +// --- clipboard_read_image --- + +type ClipboardReadImageInput struct{} +type ClipboardReadImageOutput struct { + Base64 string `json:"base64"` +} + +func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) { + result, _, err := s.core.QUERY(clipboard.QueryImage{}) + if err != nil { + return nil, ClipboardReadImageOutput{}, err + } + content, ok := result.(clipboard.ImageContent) + if !ok { + return nil, ClipboardReadImageOutput{}, coreerr.E("mcp.clipboardReadImage", "unexpected result type", nil) + } + if !content.HasImage { + return nil, ClipboardReadImageOutput{}, nil + } + return nil, ClipboardReadImageOutput{Base64: base64.StdEncoding.EncodeToString(content.Data)}, nil +} + +// --- clipboard_write_image --- + +type ClipboardWriteImageInput struct { + Base64 string `json:"base64"` +} +type ClipboardWriteImageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) { + data, err := base64.StdEncoding.DecodeString(input.Base64) + if err != nil { + return nil, ClipboardWriteImageOutput{}, err + } + result, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: data}) + if err != nil { + return nil, ClipboardWriteImageOutput{}, err + } + success, ok := result.(bool) + if !ok { + return nil, ClipboardWriteImageOutput{}, coreerr.E("mcp.clipboardWriteImage", "unexpected result type", nil) + } + return nil, ClipboardWriteImageOutput{Success: success}, nil +} + // --- Registration --- func (s *Subsystem) registerClipboardTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read", Description: "Read the current clipboard content"}, s.clipboardRead) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read image data from the clipboard as base64"}, s.clipboardReadImage) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write base64 image data to the clipboard"}, s.clipboardWriteImage) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear) } diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go index aee701d7..ff601940 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -92,6 +92,35 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques return nil, DialogOpenDirectoryOutput{Path: path}, nil } +// --- dialog_message --- + +type DialogMessageInput struct { + Type dialog.DialogType `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogMessageOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) { + result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{ + Type: input.Type, + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}) + if err != nil { + return nil, DialogMessageOutput{}, err + } + button, ok := result.(string) + if !ok { + return nil, DialogMessageOutput{}, coreerr.E("mcp.dialogMessage", "unexpected result type", nil) + } + return nil, DialogMessageOutput{Button: button}, nil +} + // --- dialog_confirm --- type DialogConfirmInput struct { @@ -153,6 +182,7 @@ func (s *Subsystem) registerDialogTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory) + mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog"}, s.dialogMessage) mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a confirmation dialog"}, s.dialogConfirm) mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show a prompt dialog"}, s.dialogPrompt) } diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index c8fc8310..dded41c8 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -47,9 +47,27 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The return nil, ThemeSystemOutput{Info: info}, nil } +// --- theme_set --- + +type ThemeSetInput struct { + Theme string `json:"theme"` +} +type ThemeSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) { + _, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme}) + if err != nil { + return nil, ThemeSetOutput{}, err + } + return nil, ThemeSetOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) + mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Override the application theme to light, dark, or system"}, s.themeSet) mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 1719ce5d..b57f8edc 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -3,8 +3,10 @@ package mcp import ( "context" + "strings" coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -173,6 +175,179 @@ func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, in return nil, LayoutWorkflowOutput{Success: true}, nil } +// --- layout_suggest --- + +type LayoutSuggestInput struct { + Width int `json:"width"` + Height int `json:"height"` + WindowCount int `json:"windowCount"` +} +type LayoutSuggestOutput struct { + Mode string `json:"mode"` + Placements []screen.Rect `json:"placements"` +} + +func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) { + width := input.Width + height := input.Height + if width <= 0 { + width = 1920 + } + if height <= 0 { + height = 1080 + } + count := input.WindowCount + if count <= 0 { + count = 1 + } + + workArea := screen.Rect{X: 0, Y: 0, Width: width, Height: height} + switch { + case count == 1: + return nil, LayoutSuggestOutput{Mode: "full", Placements: []screen.Rect{workArea}}, nil + case count == 2: + if width >= height { + half := width / 2 + return nil, LayoutSuggestOutput{ + Mode: "side-by-side", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: half, Height: height}, + {X: half, Y: 0, Width: width - half, Height: height}, + }, + }, nil + } + half := height / 2 + return nil, LayoutSuggestOutput{ + Mode: "stacked", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: width, Height: half}, + {X: 0, Y: half, Width: width, Height: height - half}, + }, + }, nil + case count == 3 && width >= height: + mainWidth := width * 2 / 3 + sideHeight := height / 2 + return nil, LayoutSuggestOutput{ + Mode: "editor-plus-stack", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: mainWidth, Height: height}, + {X: mainWidth, Y: 0, Width: width - mainWidth, Height: sideHeight}, + {X: mainWidth, Y: sideHeight, Width: width - mainWidth, Height: height - sideHeight}, + }, + }, nil + default: + cols := 2 + if count > 4 { + cols = 3 + } + rows := (count + cols - 1) / cols + cellWidth := width / cols + cellHeight := height / rows + placements := make([]screen.Rect, 0, count) + for i := 0; i < count; i++ { + row := i / cols + col := i % cols + placements = append(placements, screen.Rect{ + X: col * cellWidth, Y: row * cellHeight, + Width: cellWidth, Height: cellHeight, + }) + } + return nil, LayoutSuggestOutput{Mode: "grid", Placements: placements}, nil + } +} + +// --- layout_beside_editor --- + +type LayoutBesideEditorInput struct { + Name string `json:"name"` + EditorNames []string `json:"editorNames,omitempty"` +} +type LayoutBesideEditorOutput struct { + Editor string `json:"editor"` + Bounds screen.Rect `json:"bounds"` +} + +func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) { + windows, err := s.allWindows() + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + screens, err := s.allScreens() + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + + editorHints := map[string]struct{}{} + for _, name := range input.EditorNames { + editorHints[strings.ToLower(name)] = struct{}{} + } + defaultHints := []string{"code", "cursor", "vscode", "studio", "goland", "intellij", "webstorm", "xcode", "vim", "nvim", "emacs", "editor"} + + var editor *window.WindowInfo + for i := range windows { + if windows[i].Name == input.Name { + continue + } + name := strings.ToLower(windows[i].Name) + title := strings.ToLower(windows[i].Title) + if _, ok := editorHints[name]; ok { + editor = &windows[i] + break + } + for _, hint := range defaultHints { + if strings.Contains(name, hint) || strings.Contains(title, hint) { + editor = &windows[i] + break + } + } + if editor != nil { + break + } + } + if editor == nil { + return nil, LayoutBesideEditorOutput{}, coreerr.E("mcp.layoutBesideEditor", "no editor window detected", nil) + } + + editorScreen := screenForWindowInfo(screens, *editor) + if editorScreen == nil { + editorScreen = chooseScreenByIDOrPrimary(screens, "") + } + workArea := workAreaRect(editorScreen) + + editorRect := screen.Rect{X: editor.X, Y: editor.Y, Width: editor.Width, Height: editor.Height} + candidates := []screen.Rect{ + {X: workArea.X, Y: workArea.Y, Width: max(0, editorRect.X-workArea.X), Height: workArea.Height}, + {X: editorRect.X + editorRect.Width, Y: workArea.Y, Width: max(0, workArea.X+workArea.Width-(editorRect.X+editorRect.Width)), Height: workArea.Height}, + {X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: max(0, editorRect.Y-workArea.Y)}, + {X: workArea.X, Y: editorRect.Y + editorRect.Height, Width: workArea.Width, Height: max(0, workArea.Y+workArea.Height-(editorRect.Y+editorRect.Height))}, + } + + best := screen.Rect{} + bestArea := -1 + for _, candidate := range candidates { + area := candidate.Width * candidate.Height + if candidate.Width <= 0 || candidate.Height <= 0 { + continue + } + if area > bestArea { + bestArea = area + best = candidate + } + } + if bestArea <= 0 { + arranged, err := s.arrangePairOnScreen(editor.Name, input.Name, editorScreen, "") + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: arranged.Second}, nil + } + + if err := applyRect(s.core, input.Name, best); err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: best}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -182,6 +357,8 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_delete", Description: "Delete a saved layout"}, s.layoutDelete) mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) + mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal arrangement for the given screen size and window count"}, s.layoutSuggest) + mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor or IDE window"}, s.layoutBesideEditor) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 0b965d38..1eeb9e08 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -32,6 +32,31 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, return nil, NotificationShowOutput{Success: true}, nil } +// --- notification_with_actions --- + +type NotificationWithActionsInput struct { + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Actions []notification.NotificationAction `json:"actions"` +} +type NotificationWithActionsOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{ + Title: input.Title, + Message: input.Message, + Subtitle: input.Subtitle, + Actions: input.Actions, + }}) + if err != nil { + return nil, NotificationWithActionsOutput{}, err + } + return nil, NotificationWithActionsOutput{Success: true}, nil +} + // --- notification_permission_request --- type NotificationPermissionRequestInput struct{} @@ -70,10 +95,29 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } +// --- notification_clear --- + +type NotificationClearInput struct { + ID string `json:"id,omitempty"` +} +type NotificationClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, input NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskClear{ID: input.ID}) + if err != nil { + return nil, NotificationClearOutput{}, err + } + return nil, NotificationClearOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerNotificationTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow) + mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with action buttons"}, s.notificationWithActions) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck) + mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear a notification by ID or clear all notifications"}, s.notificationClear) } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 7f86e7e9..90633147 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -110,6 +110,75 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil } +// --- screen_work_area --- + +type ScreenWorkAreaInput struct { + ID string `json:"id,omitempty"` +} +type ScreenWorkAreaOutput struct { + WorkArea screen.Rect `json:"workArea"` +} + +func (s *Subsystem) screenWorkArea(_ context.Context, _ *mcp.CallToolRequest, input ScreenWorkAreaInput) (*mcp.CallToolResult, ScreenWorkAreaOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, ScreenWorkAreaOutput{}, err + } + scr := chooseScreenByIDOrPrimary(screens, input.ID) + if scr == nil { + return nil, ScreenWorkAreaOutput{}, nil + } + return nil, ScreenWorkAreaOutput{WorkArea: workAreaRect(scr)}, nil +} + +// --- screen_find_space --- + +type ScreenFindSpaceInput struct { + ScreenID string `json:"screenId,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type ScreenFindSpaceOutput struct { + ScreenID string `json:"screenId"` + Bounds screen.Rect `json:"bounds"` +} + +func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, ScreenFindSpaceOutput{}, err + } + windows, err := s.allWindows() + if err != nil { + return nil, ScreenFindSpaceOutput{}, err + } + + orderedScreens := make([]screen.Screen, 0, len(screens)) + if selected := chooseScreenByIDOrPrimary(screens, input.ScreenID); selected != nil { + orderedScreens = append(orderedScreens, *selected) + for _, scr := range screens { + if scr.ID != selected.ID { + orderedScreens = append(orderedScreens, scr) + } + } + } + + for _, scr := range orderedScreens { + workArea := workAreaRect(&scr) + occupied := make([]screen.Rect, 0, len(windows)) + for _, info := range windows { + if windowScreen := screenForWindowInfo(screens, info); windowScreen != nil && windowScreen.ID == scr.ID { + occupied = append(occupied, screen.Rect{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}) + } + } + if candidate, ok := findLargestFreeRect(workArea, occupied, input.Width, input.Height); ok { + return nil, ScreenFindSpaceOutput{ScreenID: scr.ID, Bounds: candidate}, nil + } + } + + return nil, ScreenFindSpaceOutput{}, nil +} + // --- screen_for_window --- type ScreenForWindowInput struct { @@ -145,6 +214,8 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "screen_get", Description: "Get information about a specific screen"}, s.screenGet) mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) + mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Get the work area for a screen"}, s.screenWorkArea) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) + mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find the largest empty area on a screen that fits the requested size"}, s.screenFindSpace) mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index d5efb457..ee2cfb1d 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -4,7 +4,6 @@ package mcp import ( "context" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/systray" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -36,8 +35,10 @@ type TraySetTooltipOutput struct { } func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) { - // Tooltip is set via the tray menu items; for now this is a no-op placeholder - _ = input.Tooltip + _, _, err := s.core.PERFORM(systray.TaskSetTrayTooltip{Tooltip: input.Tooltip}) + if err != nil { + return nil, TraySetTooltipOutput{}, err + } return nil, TraySetTooltipOutput{Success: true}, nil } @@ -51,11 +52,52 @@ type TraySetLabelOutput struct { } func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) { - // Label is part of the tray configuration; placeholder for now - _ = input.Label + _, _, err := s.core.PERFORM(systray.TaskSetTrayLabel{Label: input.Label}) + if err != nil { + return nil, TraySetLabelOutput{}, err + } return nil, TraySetLabelOutput{Success: true}, nil } +// --- tray_set_menu --- + +type TraySetMenuInput struct { + Items []map[string]any `json:"items"` +} +type TraySetMenuOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetMenu(_ context.Context, _ *mcp.CallToolRequest, input TraySetMenuInput) (*mcp.CallToolResult, TraySetMenuOutput, error) { + items := make([]systray.TrayMenuItem, 0, len(input.Items)) + for _, item := range input.Items { + items = append(items, decodeTrayMenuItem(item)) + } + _, _, err := s.core.PERFORM(systray.TaskSetTrayMenu{Items: items}) + if err != nil { + return nil, TraySetMenuOutput{}, err + } + return nil, TraySetMenuOutput{Success: true}, nil +} + +// --- tray_show_message --- + +type TrayShowMessageInput struct { + Title string `json:"title"` + Message string `json:"message"` +} +type TrayShowMessageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) { + _, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message}) + if err != nil { + return nil, TrayShowMessageOutput{}, err + } + return nil, TrayShowMessageOutput{Success: true}, nil +} + // --- tray_info --- type TrayInfoInput struct{} @@ -70,7 +112,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn } config, ok := result.(map[string]any) if !ok { - return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil) + config = map[string]any{} } return nil, TrayInfoOutput{Config: config}, nil } @@ -81,5 +123,41 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "tray_set_icon", Description: "Set the system tray icon"}, s.traySetIcon) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel) + mcp.AddTool(server, &mcp.Tool{Name: "tray_set_menu", Description: "Set the system tray menu"}, s.traySetMenu) + mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray balloon or tray message"}, s.trayShowMessage) mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo) } + +func decodeTrayMenuItem(input map[string]any) systray.TrayMenuItem { + item := systray.TrayMenuItem{} + if label, ok := input["label"].(string); ok { + item.Label = label + } + if itemType, ok := input["type"].(string); ok { + item.Type = itemType + } + if checked, ok := input["checked"].(bool); ok { + item.Checked = checked + } + if disabled, ok := input["disabled"].(bool); ok { + item.Disabled = disabled + } + if tooltip, ok := input["tooltip"].(string); ok { + item.Tooltip = tooltip + } + if actionID, ok := input["actionId"].(string); ok { + item.ActionID = actionID + } + if actionID, ok := input["action_id"].(string); ok && item.ActionID == "" { + item.ActionID = actionID + } + if rawSubmenu, ok := input["submenu"].([]any); ok { + item.Submenu = make([]systray.TrayMenuItem, 0, len(rawSubmenu)) + for _, child := range rawSubmenu { + if childMap, ok := child.(map[string]any); ok { + item.Submenu = append(item.Submenu, decodeTrayMenuItem(childMap)) + } + } + } + return item +} diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index 677c2acf..131e16de 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -5,6 +5,7 @@ import ( "context" coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -258,6 +259,23 @@ func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input return nil, WindowFocusOutput{Success: true}, nil } +// --- focus_set --- + +type FocusSetInput struct { + Name string `json:"name"` +} +type FocusSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) focusSet(ctx context.Context, req *mcp.CallToolRequest, input FocusSetInput) (*mcp.CallToolResult, FocusSetOutput, error) { + _, out, err := s.windowFocus(ctx, req, WindowFocusInput{Name: input.Name}) + if err != nil { + return nil, FocusSetOutput{}, err + } + return nil, FocusSetOutput{Success: out.Success}, nil +} + // --- window_title --- type WindowTitleInput struct { @@ -374,6 +392,94 @@ func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, return nil, WindowFullscreenOutput{Success: true}, nil } +type arrangedPair struct { + First screen.Rect + Second screen.Rect +} + +func (s *Subsystem) arrangePairOnScreen(firstName, secondName string, scr *screen.Screen, orientation string) (arrangedPair, error) { + workArea := workAreaRect(scr) + if workArea.Width == 0 || workArea.Height == 0 { + return arrangedPair{}, coreerr.E("mcp.arrangePairOnScreen", "screen work area is empty", nil) + } + if orientation == "" { + if workArea.Width >= workArea.Height { + orientation = "horizontal" + } else { + orientation = "vertical" + } + } + + var firstRect screen.Rect + var secondRect screen.Rect + switch orientation { + case "vertical", "stacked": + firstHeight := workArea.Height / 2 + firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: firstHeight} + secondRect = screen.Rect{X: workArea.X, Y: workArea.Y + firstHeight, Width: workArea.Width, Height: workArea.Height - firstHeight} + default: + firstWidth := workArea.Width / 2 + firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: firstWidth, Height: workArea.Height} + secondRect = screen.Rect{X: workArea.X + firstWidth, Y: workArea.Y, Width: workArea.Width - firstWidth, Height: workArea.Height} + } + + if err := applyRect(s.core, firstName, firstRect); err != nil { + return arrangedPair{}, err + } + if err := applyRect(s.core, secondName, secondRect); err != nil { + return arrangedPair{}, err + } + return arrangedPair{First: firstRect, Second: secondRect}, nil +} + +// --- window_arrange_pair --- + +type WindowArrangePairInput struct { + First string `json:"first"` + Second string `json:"second"` + ScreenID string `json:"screenId,omitempty"` + Orientation string `json:"orientation,omitempty"` +} +type WindowArrangePairOutput struct { + FirstBounds screen.Rect `json:"firstBounds"` + SecondBounds screen.Rect `json:"secondBounds"` +} + +func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + windows, err := s.allWindows() + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + + var targetScreen *screen.Screen + if input.ScreenID != "" { + targetScreen = chooseScreenByIDOrPrimary(screens, input.ScreenID) + } else { + for _, info := range windows { + if info.Name == input.First { + targetScreen = screenForWindowInfo(screens, info) + break + } + } + if targetScreen == nil { + targetScreen = chooseScreenByIDOrPrimary(screens, "") + } + } + if targetScreen == nil { + return nil, WindowArrangePairOutput{}, coreerr.E("mcp.windowArrangePair", "no screen available", nil) + } + + arranged, err := s.arrangePairOnScreen(input.First, input.Second, targetScreen, input.Orientation) + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + return nil, WindowArrangePairOutput{FirstBounds: arranged.First, SecondBounds: arranged.Second}, nil +} + // --- Registration --- func (s *Subsystem) registerWindowTools(server *mcp.Server) { @@ -389,10 +495,12 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize) mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) + mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Set focus to a specific window"}, s.focusSet) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) + mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side or stacked on a screen"}, s.windowArrangePair) } diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index 784dfd5c..df7b60c6 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -19,6 +19,9 @@ type TaskRevokePermission struct{} // _, _, err := c.PERFORM(TaskRegisterCategory{Category: NotificationCategory{ID: "message", Actions: [...]}}) type TaskRegisterCategory struct{ Category NotificationCategory } +// TaskClear removes a notification by ID. An empty ID clears all notifications if supported. +type TaskClear struct{ ID string } + // ActionNotificationClicked is broadcast when the user clicks a notification. type ActionNotificationClicked struct{ ID string } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index 57639ce8..c363990f 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -10,6 +10,11 @@ type Platform interface { RegisterCategory(category NotificationCategory) error } +// ClearPlatform is an optional extension for removing notifications. +type ClearPlatform interface { + Clear(id string) error +} + // NotificationSeverity indicates the severity for dialog fallback. type NotificationSeverity int @@ -21,11 +26,13 @@ const ( // NotificationOptions contains options for sending a notification. type NotificationOptions struct { - ID string `json:"id,omitempty"` - Title string `json:"title"` - Message string `json:"message"` - Subtitle string `json:"subtitle,omitempty"` - Severity NotificationSeverity `json:"severity,omitempty"` + ID string `json:"id,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Severity NotificationSeverity `json:"severity,omitempty"` + CategoryID string `json:"categoryId,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` } // PermissionStatus indicates whether notifications are authorised. diff --git a/pkg/notification/service.go b/pkg/notification/service.go index 866a87f7..57a7b0ab 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/dialog" ) @@ -57,6 +58,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.platform.RevokePermission() case TaskRegisterCategory: return nil, true, s.platform.RegisterCategory(t.Category) + case TaskClear: + clearPlatform, ok := s.platform.(ClearPlatform) + if !ok { + return nil, true, coreerr.E("notification.handleTask", "notification clearing is not supported by this platform", nil) + } + return nil, true, clearPlatform.Clear(t.ID) default: return nil, false, nil } @@ -69,6 +76,20 @@ func (s *Service) send(options NotificationOptions) error { options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) } + if len(options.Actions) > 0 { + categoryID := options.CategoryID + if categoryID == "" { + categoryID = options.ID + } + if err := s.platform.RegisterCategory(NotificationCategory{ + ID: categoryID, + Actions: options.Actions, + }); err != nil { + return err + } + options.CategoryID = categoryID + } + if err := s.platform.Send(options); err != nil { // Fallback: show as dialog via IPC return s.fallbackDialog(options) diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index d6ddb515..3a74f140 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -13,16 +13,19 @@ import ( ) type mockPlatform struct { - sendErr error - permGranted bool - permErr error - revokeErr error - registerCategoryErr error - lastOpts NotificationOptions - lastCategory NotificationCategory - sendCalled bool - revokeCalled bool + sendErr error + permGranted bool + permErr error + revokeErr error + registerCategoryErr error + clearErr error + lastOpts NotificationOptions + lastCategory NotificationCategory + sendCalled bool + revokeCalled bool registerCategoryCalled bool + clearCalled bool + lastClearedID string } func (m *mockPlatform) Send(opts NotificationOptions) error { @@ -41,6 +44,11 @@ func (m *mockPlatform) RegisterCategory(category NotificationCategory) error { m.lastCategory = category return m.registerCategoryErr } +func (m *mockPlatform) Clear(id string) error { + m.clearCalled = true + m.lastClearedID = id + return m.clearErr +} // mockDialogPlatform tracks whether MessageDialog was called (for fallback test). type mockDialogPlatform struct { @@ -171,6 +179,36 @@ func TestTaskRegisterCategory_Bad_NoService(t *testing.T) { assert.False(t, handled) } +func TestTaskSendWithActions_Good(t *testing.T) { + mock, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskSend{ + Options: NotificationOptions{ + Title: "Message", + Message: "Reply?", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.registerCategoryCalled) + assert.Len(t, mock.lastCategory.Actions, 2) + assert.NotEmpty(t, mock.lastOpts.CategoryID) +} + +func TestTaskClear_Good(t *testing.T) { + mock, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskClear{ID: "notif-1"}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.clearCalled) + assert.Equal(t, "notif-1", mock.lastClearedID) +} + func TestActionNotificationActionTriggered_Ugly(t *testing.T) { // Verify the action structs are distinct types. var triggered ActionNotificationActionTriggered diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go index 6855e229..273d4d43 100644 --- a/pkg/systray/messages.go +++ b/pkg/systray/messages.go @@ -4,8 +4,17 @@ type QueryConfig struct{} type TaskSetTrayIcon struct{ Data []byte } +type TaskSetTrayTooltip struct{ Tooltip string } + +type TaskSetTrayLabel struct{ Label string } + type TaskSetTrayMenu struct{ Items []TrayMenuItem } +type TaskShowMessage struct { + Title string `json:"title"` + Message string `json:"message"` +} + type TaskShowPanel struct{} type TaskHidePanel struct{} diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index c92a58fb..1c86d9de 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -13,12 +13,13 @@ type exportedMockTray struct { 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) {} +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) {} +func (t *exportedMockTray) ShowMessage(title, message string) error { return nil } type exportedMockMenu struct { items []exportedMockMenuItem diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go index 56f35cfc..9ab2f1c4 100644 --- a/pkg/systray/mock_test.go +++ b/pkg/systray/mock_test.go @@ -49,6 +49,8 @@ type mockTray struct { tooltip, label string menu PlatformMenu attachedWindow WindowHandle + lastMessageTitle string + lastMessageBody string } func (t *mockTray) SetIcon(data []byte) { t.icon = data } @@ -57,3 +59,8 @@ func (t *mockTray) SetTooltip(text string) { t.tooltip = text } func (t *mockTray) SetLabel(text string) { t.label = text } func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu } func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w } +func (t *mockTray) ShowMessage(title, message string) error { + t.lastMessageTitle = title + t.lastMessageBody = message + return nil +} diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go index b7494222..e113e82d 100644 --- a/pkg/systray/platform.go +++ b/pkg/systray/platform.go @@ -15,6 +15,7 @@ type PlatformTray interface { SetLabel(text string) SetMenu(menu PlatformMenu) AttachWindow(w WindowHandle) + ShowMessage(title, message string) error } // PlatformMenu is a tray menu built by the backend. diff --git a/pkg/systray/service.go b/pkg/systray/service.go index f585e7e3..7aad6ae1 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -48,8 +48,14 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskSetTrayIcon: return nil, true, s.manager.SetIcon(t.Data) + case TaskSetTrayTooltip: + return nil, true, s.manager.SetTooltip(t.Tooltip) + case TaskSetTrayLabel: + return nil, true, s.manager.SetLabel(t.Label) case TaskSetTrayMenu: return nil, true, s.taskSetTrayMenu(t) + case TaskShowMessage: + return nil, true, s.manager.ShowMessage(t.Title, t.Message) case TaskShowPanel: // Panel show — deferred (requires WindowHandle integration) return nil, true, nil diff --git a/pkg/systray/service_test.go b/pkg/systray/service_test.go index 4bcec301..419ccc50 100644 --- a/pkg/systray/service_test.go +++ b/pkg/systray/service_test.go @@ -54,6 +54,38 @@ func TestTaskSetTrayMenu_Good(t *testing.T) { assert.True(t, handled) } +func TestTaskSetTrayTooltip_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTrayTooltip{Tooltip: "Updated"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "Updated", svc.manager.Tray().(*mockTray).tooltip) +} + +func TestTaskSetTrayLabel_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTrayLabel{Label: "CoreGUI"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "CoreGUI", svc.manager.Tray().(*mockTray).label) +} + +func TestTaskShowMessage_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskShowMessage{Title: "Heads up", Message: "Background work finished"}) + require.NoError(t, err) + assert.True(t, handled) + tray := svc.manager.Tray().(*mockTray) + assert.Equal(t, "Heads up", tray.lastMessageTitle) + assert.Equal(t, "Background work finished", tray.lastMessageBody) +} + func TestTaskSetTrayIcon_Bad(t *testing.T) { // No systray service — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 817e1990..d8b17b63 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -87,6 +87,14 @@ func (m *Manager) AttachWindow(w WindowHandle) error { return nil } +// ShowMessage displays a tray message if the backend supports it. +func (m *Manager) ShowMessage(title, message string) error { + if m.tray == nil { + return coreerr.E("systray.ShowMessage", "tray not initialised", nil) + } + return m.tray.ShowMessage(title, message) +} + // Tray returns the underlying platform tray for direct access. func (m *Manager) Tray() PlatformTray { return m.tray diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index 47b69820..6ef93544 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -43,6 +43,12 @@ func (wt *wailsTray) AttachWindow(w WindowHandle) { // The caller must pass an appropriate wrapper. } +func (wt *wailsTray) ShowMessage(title, message string) error { + _ = title + _ = message + return nil +} + // wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. type wailsTrayMenu struct { menu *application.Menu