feat(systray): wire tray mutations and submenus
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 1m22s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 13:39:36 +00:00
parent a0cad39fbb
commit 573eb5216a
11 changed files with 126 additions and 14 deletions

View file

@ -667,6 +667,18 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
Title: title,
Message: message,
})
case "tray:set-tooltip":
tooltip, e := wsRequire(msg.Data, "tooltip")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(systray.TaskSetTooltip{Tooltip: tooltip})
case "tray:set-label":
label, e := wsRequire(msg.Data, "label")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(systray.TaskSetLabel{Label: label})
case "dialog:prompt":
title, e := wsRequire(msg.Data, "title")
if e != nil {

View file

@ -786,6 +786,24 @@ func TestHandleWSMessage_Extended_Good(t *testing.T) {
assert.True(t, handled)
})
t.Run("tray tooltip", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:set-tooltip",
Data: map[string]any{"tooltip": "Updated"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("tray label", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:set-label",
Data: map[string]any{"label": "Updated"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("prompt dialog", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "dialog:prompt",

View file

@ -36,8 +36,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.TaskSetTooltip{Tooltip: input.Tooltip})
if err != nil {
return nil, TraySetTooltipOutput{}, err
}
return nil, TraySetTooltipOutput{Success: true}, nil
}
@ -51,8 +53,10 @@ 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.TaskSetLabel{Label: input.Label})
if err != nil {
return nil, TraySetLabelOutput{}, err
}
return nil, TraySetLabelOutput{Success: true}, nil
}

View file

@ -16,16 +16,19 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error {
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
menu := m.platform.NewMenu()
m.buildMenuInto(menu, items)
return menu
}
func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) {
for _, item := range items {
if item.Type == "separator" {
menu.AddSeparator()
continue
}
if len(item.Submenu) > 0 {
sub := m.buildMenu(item.Submenu)
mi := menu.Add(item.Label)
_ = mi.AddSubmenu()
_ = sub // TODO: wire sub into parent via platform
sub := menu.AddSubmenu(item.Label)
m.buildMenuInto(sub, item.Submenu)
continue
}
mi := menu.Add(item.Label)
@ -47,7 +50,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
})
}
}
return menu
}
// RegisterCallback registers a callback for a menu action ID.

View file

@ -9,6 +9,12 @@ type QueryConfig struct{}
// TaskSetTrayIcon sets the tray icon.
type TaskSetTrayIcon struct{ Data []byte }
// TaskSetTooltip updates the tray tooltip text.
type TaskSetTooltip struct{ Tooltip string }
// TaskSetLabel updates the tray label text.
type TaskSetLabel struct{ Label string }
// TaskSetTrayMenu sets the tray menu items.
type TaskSetTrayMenu struct{ Items []TrayMenuItem }

View file

@ -20,7 +20,10 @@ 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 }
type exportedMockMenu struct {
items []exportedMockMenuItem
submenus []*exportedMockMenu
}
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
mi := &exportedMockMenuItem{label: label}
@ -28,6 +31,12 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
return mi
}
func (m *exportedMockMenu) AddSeparator() {}
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
sub := &exportedMockMenu{}
m.items = append(m.items, exportedMockMenuItem{label: label})
m.submenus = append(m.submenus, sub)
return sub
}
type exportedMockMenuItem struct {
label, tooltip string

View file

@ -21,7 +21,8 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
}
type mockTrayMenu struct {
items []string
items []string
submenus []*mockTrayMenu
}
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
@ -29,10 +30,16 @@ func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
return &mockTrayMenuItem{}
}
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
m.items = append(m.items, label)
sub := &mockTrayMenu{}
m.submenus = append(m.submenus, sub)
return sub
}
type mockTrayMenuItem struct{}
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
@ -45,9 +52,9 @@ type mockTray struct {
attachedWindow WindowHandle
}
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
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) AttachWindow(w WindowHandle) { t.attachedWindow = w }

View file

@ -21,6 +21,7 @@ type PlatformTray interface {
type PlatformMenu interface {
Add(label string) PlatformMenuItem
AddSeparator()
AddSubmenu(label string) PlatformMenu
}
// PlatformMenuItem is a single item in a tray menu.

View file

@ -54,6 +54,10 @@ 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 TaskSetTooltip:
return nil, true, s.manager.SetTooltip(t.Tooltip)
case TaskSetLabel:
return nil, true, s.manager.SetLabel(t.Label)
case TaskSetTrayMenu:
return nil, true, s.taskSetTrayMenu(t)
case TaskShowPanel:

View file

@ -39,6 +39,24 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
assert.True(t, handled)
}
func TestTaskSetTooltip_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetTooltip{Tooltip: "Updated"})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetLabel_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetLabel{Label: "Updated"})
require.NoError(t, err)
assert.True(t, handled)
}
func TestTaskSetTrayMenu_Good(t *testing.T) {
svc, c := newTestSystrayService(t)
@ -54,6 +72,33 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
assert.True(t, handled)
}
func TestTaskSetTrayMenu_Submenu_Good(t *testing.T) {
p := newMockPlatform()
c, err := core.New(
core.WithService(Register(p)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "systray")
require.NoError(t, svc.manager.Setup("Test", "Test"))
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: []TrayMenuItem{
{
Label: "File",
Submenu: []TrayMenuItem{
{Label: "Open", ActionID: "open"},
},
},
}})
require.NoError(t, err)
assert.True(t, handled)
require.Len(t, p.trays, 1)
require.NotEmpty(t, p.menus)
require.Len(t, p.menus[0].submenus, 1)
}
func TestTaskSetTrayIcon_Bad(t *testing.T) {
// No systray service — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())

View file

@ -56,6 +56,10 @@ func (m *wailsTrayMenu) AddSeparator() {
m.menu.AddSeparator()
}
func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu {
return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)}
}
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
type wailsTrayMenuItem struct {
item *application.MenuItem