Implement display service spec wrappers
Some checks failed
Security Scan / security (push) Failing after 20s
Test / test (push) Successful in 1m28s

This commit is contained in:
Virgil 2026-04-02 13:48:27 +00:00
parent 573eb5216a
commit a4c696ec01
6 changed files with 884 additions and 21 deletions

View file

@ -60,7 +60,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetText:
return s.platform.SetText(t.Text), true, nil
case TaskClear:
return s.platform.SetText(""), true, nil
_ = s.platform.SetText("")
if writer, ok := s.platform.(imageWriter); ok {
// Best-effort clear for image-aware clipboard backends.
_ = writer.SetImage(nil)
}
return true, true, nil
case TaskSetImage:
if writer, ok := s.platform.(imageWriter); ok {
return writer.SetImage(t.Data), true, nil

View file

@ -613,6 +613,65 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
ScreenWidth: screenWidth,
ScreenHeight: screenHeight,
})
case "screen:list":
result, handled, err = s.Core().QUERY(screen.QueryAll{})
case "screen:get":
id, e := wsRequire(msg.Data, "id")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(screen.QueryByID{ID: id})
case "screen:primary":
result, handled, err = s.Core().QUERY(screen.QueryPrimary{})
case "screen:at-point":
x, _ := msg.Data["x"].(float64)
y, _ := msg.Data["y"].(float64)
result, handled, err = s.Core().QUERY(screen.QueryAtPoint{X: int(x), Y: int(y)})
case "screen:work-areas":
result, handled, err = s.Core().QUERY(screen.QueryWorkAreas{})
case "screen:for-window":
name, e := wsRequire(msg.Data, "window")
if e != nil {
return nil, false, e
}
screenInfo, screenErr := s.GetScreenForWindow(name)
if screenErr != nil {
return nil, false, screenErr
}
result, handled, err = screenInfo, true, nil
case "clipboard:read":
result, handled, err = s.Core().QUERY(clipboard.QueryText{})
case "clipboard:write":
text, e := wsRequire(msg.Data, "text")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(clipboard.TaskSetText{Text: text})
case "clipboard:has":
textResult, textHandled, textErr := s.Core().QUERY(clipboard.QueryText{})
if textErr != nil {
return nil, false, textErr
}
hasContent := false
if textHandled {
if content, ok := textResult.(clipboard.ClipboardContent); ok {
hasContent = content.HasContent
}
}
if !hasContent {
imageResult, imageHandled, imageErr := s.Core().QUERY(clipboard.QueryImage{})
if imageErr != nil {
return nil, false, imageErr
}
if imageHandled {
if content, ok := imageResult.(clipboard.ClipboardImageContent); ok {
hasContent = content.HasContent
}
}
}
result, handled, err = hasContent, true, nil
case "clipboard:clear":
result, handled, err = s.Core().PERFORM(clipboard.TaskClear{})
case "clipboard:read-image":
result, handled, err = s.Core().QUERY(clipboard.QueryImage{})
case "clipboard:write-image":
@ -625,6 +684,53 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
return nil, false, fmt.Errorf("ws: invalid base64 image data: %w", decodeErr)
}
result, handled, err = s.Core().PERFORM(clipboard.TaskSetImage{Data: decoded})
case "notification:show":
var opts notification.NotificationOptions
encoded, _ := json.Marshal(msg.Data)
_ = json.Unmarshal(encoded, &opts)
result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: opts})
case "notification:info":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityInfo,
}})
case "notification:warning":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityWarning,
}})
case "notification:error":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityError,
}})
case "notification:with-actions":
title, e := wsRequire(msg.Data, "title")
if e != nil {
@ -679,6 +785,83 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(systray.TaskSetLabel{Label: label})
case "tray:set-icon":
data, e := wsRequire(msg.Data, "data")
if e != nil {
return nil, false, e
}
decoded, decodeErr := base64.StdEncoding.DecodeString(data)
if decodeErr != nil {
return nil, false, fmt.Errorf("ws: invalid base64 tray icon data: %w", decodeErr)
}
result, handled, err = s.Core().PERFORM(systray.TaskSetTrayIcon{Data: decoded})
case "tray:set-menu":
raw, ok := msg.Data["items"]
if !ok {
return nil, false, fmt.Errorf("ws: missing required field %q", "items")
}
encoded, _ := json.Marshal(raw)
var items []systray.TrayMenuItem
if err := json.Unmarshal(encoded, &items); err != nil {
return nil, false, fmt.Errorf("ws: invalid tray menu items: %w", err)
}
result, handled, err = s.Core().PERFORM(systray.TaskSetTrayMenu{Items: items})
case "tray:info":
result, handled, err = s.GetTrayInfo(), true, nil
case "theme:get":
result, handled, err = s.GetTheme(), true, nil
case "theme:system":
result, handled, err = s.GetSystemTheme(), true, nil
case "theme:set":
isDark, _ := msg.Data["isDark"].(bool)
result, handled, err = nil, true, s.SetTheme(isDark)
case "dialog:open-file":
var opts dialog.OpenFileOptions
encoded, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(encoded, &opts); err != nil {
return nil, false, fmt.Errorf("ws: invalid open file options: %w", err)
}
paths, openErr := s.OpenFileDialog(opts)
if openErr != nil {
return nil, false, openErr
}
result, handled, err = paths, true, nil
case "dialog:save-file":
var opts dialog.SaveFileOptions
encoded, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(encoded, &opts); err != nil {
return nil, false, fmt.Errorf("ws: invalid save file options: %w", err)
}
path, saveErr := s.SaveFileDialog(opts)
if saveErr != nil {
return nil, false, saveErr
}
result, handled, err = path, true, nil
case "dialog:open-directory":
var opts dialog.OpenDirectoryOptions
encoded, _ := json.Marshal(msg.Data)
if err := json.Unmarshal(encoded, &opts); err != nil {
return nil, false, fmt.Errorf("ws: invalid open directory options: %w", err)
}
path, dirErr := s.OpenDirectoryDialog(opts)
if dirErr != nil {
return nil, false, dirErr
}
result, handled, err = path, true, nil
case "dialog:confirm":
title, e := wsRequire(msg.Data, "title")
if e != nil {
return nil, false, e
}
message, e := wsRequire(msg.Data, "message")
if e != nil {
return nil, false, e
}
confirmed, confirmErr := s.ConfirmDialog(title, message)
if confirmErr != nil {
return nil, false, confirmErr
}
result, handled, err = confirmed, true, nil
case "dialog:prompt":
title, e := wsRequire(msg.Data, "title")
if e != nil {
@ -688,14 +871,12 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: title,
Message: message,
Buttons: []string{"OK", "Cancel"},
},
})
button, accepted, promptErr := s.PromptDialog(title, message)
if promptErr != nil {
return nil, false, promptErr
}
_ = accepted
result, handled, err = button, true, nil
default:
return nil, false, nil
}
@ -1209,6 +1390,461 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight)
}
// --- Screen management ---
// GetScreens returns all known screens.
func (s *Service) GetScreens() []screen.Screen {
result, handled, _ := s.Core().QUERY(screen.QueryAll{})
if !handled {
return nil
}
screens, _ := result.([]screen.Screen)
return screens
}
// GetScreen returns a screen by ID.
func (s *Service) GetScreen(id string) (*screen.Screen, error) {
result, handled, err := s.Core().QUERY(screen.QueryByID{ID: id})
if err != nil {
return nil, err
}
if !handled {
return nil, fmt.Errorf("screen service not available")
}
scr, _ := result.(*screen.Screen)
return scr, nil
}
// GetPrimaryScreen returns the primary screen.
func (s *Service) GetPrimaryScreen() (*screen.Screen, error) {
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
if err != nil {
return nil, err
}
if !handled {
return nil, fmt.Errorf("screen service not available")
}
scr, _ := result.(*screen.Screen)
return scr, nil
}
// GetScreenAtPoint returns the screen containing the specified point.
func (s *Service) GetScreenAtPoint(x, y int) (*screen.Screen, error) {
result, handled, err := s.Core().QUERY(screen.QueryAtPoint{X: x, Y: y})
if err != nil {
return nil, err
}
if !handled {
return nil, fmt.Errorf("screen service not available")
}
scr, _ := result.(*screen.Screen)
return scr, nil
}
// GetScreenForWindow returns the screen containing the named window.
func (s *Service) GetScreenForWindow(name string) (*screen.Screen, error) {
info, err := s.GetWindowInfo(name)
if err != nil {
return nil, err
}
if info == nil {
return nil, nil
}
x := info.X
y := info.Y
if info.Width > 0 && info.Height > 0 {
x += info.Width / 2
y += info.Height / 2
}
return s.GetScreenAtPoint(x, y)
}
// GetWorkAreas returns the usable area of every screen.
func (s *Service) GetWorkAreas() []screen.Rect {
result, handled, _ := s.Core().QUERY(screen.QueryWorkAreas{})
if !handled {
return nil
}
areas, _ := result.([]screen.Rect)
return areas
}
// --- Clipboard ---
// ReadClipboard returns the current clipboard text content.
func (s *Service) ReadClipboard() (string, error) {
result, handled, err := s.Core().QUERY(clipboard.QueryText{})
if err != nil {
return "", err
}
if !handled {
return "", fmt.Errorf("clipboard service not available")
}
content, _ := result.(clipboard.ClipboardContent)
return content.Text, nil
}
// WriteClipboard writes text to the clipboard.
func (s *Service) WriteClipboard(text string) error {
result, handled, err := s.Core().PERFORM(clipboard.TaskSetText{Text: text})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("clipboard service not available")
}
if ok, _ := result.(bool); !ok {
return fmt.Errorf("clipboard write failed")
}
return nil
}
// HasClipboard reports whether the clipboard has text or image content.
func (s *Service) HasClipboard() bool {
textResult, textHandled, _ := s.Core().QUERY(clipboard.QueryText{})
if textHandled {
if content, ok := textResult.(clipboard.ClipboardContent); ok && content.HasContent {
return true
}
}
imageResult, imageHandled, _ := s.Core().QUERY(clipboard.QueryImage{})
if imageHandled {
if content, ok := imageResult.(clipboard.ClipboardImageContent); ok && content.HasContent {
return true
}
}
return false
}
// ClearClipboard clears clipboard text and any image data when supported.
func (s *Service) ClearClipboard() error {
result, handled, err := s.Core().PERFORM(clipboard.TaskClear{})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("clipboard service not available")
}
if ok, _ := result.(bool); !ok {
return fmt.Errorf("clipboard clear failed")
}
return nil
}
// ReadClipboardImage returns the clipboard image content.
func (s *Service) ReadClipboardImage() (clipboard.ClipboardImageContent, error) {
result, handled, err := s.Core().QUERY(clipboard.QueryImage{})
if err != nil {
return clipboard.ClipboardImageContent{}, err
}
if !handled {
return clipboard.ClipboardImageContent{}, fmt.Errorf("clipboard service not available")
}
content, _ := result.(clipboard.ClipboardImageContent)
return content, nil
}
// WriteClipboardImage writes raw image data to the clipboard.
func (s *Service) WriteClipboardImage(data []byte) error {
result, handled, err := s.Core().PERFORM(clipboard.TaskSetImage{Data: data})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("clipboard service not available")
}
if ok, _ := result.(bool); !ok {
return fmt.Errorf("clipboard image write failed")
}
return nil
}
// --- Notifications ---
// ShowNotification sends a native notification.
func (s *Service) ShowNotification(opts notification.NotificationOptions) error {
_, handled, err := s.Core().PERFORM(notification.TaskSend{Opts: opts})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("notification service not available")
}
return nil
}
// ShowInfoNotification sends an informational notification.
func (s *Service) ShowInfoNotification(title, message string) error {
return s.ShowNotification(notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityInfo,
})
}
// ShowWarningNotification sends a warning notification.
func (s *Service) ShowWarningNotification(title, message string) error {
return s.ShowNotification(notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityWarning,
})
}
// ShowErrorNotification sends an error notification.
func (s *Service) ShowErrorNotification(title, message string) error {
return s.ShowNotification(notification.NotificationOptions{
Title: title,
Message: message,
Severity: notification.SeverityError,
})
}
// RequestNotificationPermission requests notification permission.
func (s *Service) RequestNotificationPermission() (bool, error) {
result, handled, err := s.Core().PERFORM(notification.TaskRequestPermission{})
if err != nil {
return false, err
}
if !handled {
return false, fmt.Errorf("notification service not available")
}
granted, _ := result.(bool)
return granted, nil
}
// CheckNotificationPermission checks notification permission.
func (s *Service) CheckNotificationPermission() (bool, error) {
result, handled, err := s.Core().QUERY(notification.QueryPermission{})
if err != nil {
return false, err
}
if !handled {
return false, fmt.Errorf("notification service not available")
}
status, _ := result.(notification.PermissionStatus)
return status.Granted, nil
}
// ClearNotifications clears notifications when supported.
func (s *Service) ClearNotifications() error {
_, handled, err := s.Core().PERFORM(notification.TaskClear{})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("notification service not available")
}
return nil
}
// --- Dialogs ---
// OpenFileDialog opens a file picker and returns all selected paths.
func (s *Service) OpenFileDialog(opts dialog.OpenFileOptions) ([]string, error) {
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{Opts: opts})
if err != nil {
return nil, err
}
if !handled {
return nil, fmt.Errorf("dialog service not available")
}
paths, _ := result.([]string)
return paths, nil
}
// OpenSingleFileDialog opens a file picker and returns the first selected path.
func (s *Service) OpenSingleFileDialog(opts dialog.OpenFileOptions) (string, error) {
paths, err := s.OpenFileDialog(opts)
if err != nil {
return "", err
}
if len(paths) == 0 {
return "", nil
}
return paths[0], nil
}
// SaveFileDialog opens a save dialog and returns the selected path.
func (s *Service) SaveFileDialog(opts dialog.SaveFileOptions) (string, error) {
result, handled, err := s.Core().PERFORM(dialog.TaskSaveFile{Opts: opts})
if err != nil {
return "", err
}
if !handled {
return "", fmt.Errorf("dialog service not available")
}
path, _ := result.(string)
return path, nil
}
// OpenDirectoryDialog opens a directory picker and returns the selected path.
func (s *Service) OpenDirectoryDialog(opts dialog.OpenDirectoryOptions) (string, error) {
result, handled, err := s.Core().PERFORM(dialog.TaskOpenDirectory{Opts: opts})
if err != nil {
return "", err
}
if !handled {
return "", fmt.Errorf("dialog service not available")
}
path, _ := result.(string)
return path, nil
}
// ConfirmDialog shows a confirmation prompt.
func (s *Service) ConfirmDialog(title, message string) (bool, error) {
result, handled, err := s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Type: dialog.DialogQuestion,
Title: title,
Message: message,
Buttons: []string{"Yes", "No"},
},
})
if err != nil {
return false, err
}
if !handled {
return false, fmt.Errorf("dialog service not available")
}
button, _ := result.(string)
return button == "Yes" || button == "OK", nil
}
// PromptDialog shows a prompt-style dialog and returns the selected button.
func (s *Service) PromptDialog(title, message string) (string, bool, error) {
result, handled, err := s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: title,
Message: message,
Buttons: []string{"OK", "Cancel"},
},
})
if err != nil {
return "", false, err
}
if !handled {
return "", false, fmt.Errorf("dialog service not available")
}
button, _ := result.(string)
return button, button == "OK", nil
}
// --- Theme ---
// GetTheme returns the current theme state.
func (s *Service) GetTheme() *Theme {
result, handled, err := s.Core().QUERY(environment.QueryTheme{})
if err != nil || !handled {
return nil
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return nil
}
return &Theme{IsDark: theme.IsDark}
}
// GetSystemTheme returns the current system theme preference.
func (s *Service) GetSystemTheme() string {
result, handled, err := s.Core().QUERY(environment.QueryTheme{})
if err != nil || !handled {
return ""
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return ""
}
if theme.IsDark {
return "dark"
}
return "light"
}
// SetTheme overrides the application theme.
func (s *Service) SetTheme(isDark bool) error {
_, handled, err := s.Core().PERFORM(environment.TaskSetTheme{IsDark: isDark})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("environment service not available")
}
return nil
}
// --- Tray ---
// SetTrayIcon sets the tray icon image.
func (s *Service) SetTrayIcon(data []byte) error {
_, handled, err := s.Core().PERFORM(systray.TaskSetTrayIcon{Data: data})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("systray service not available")
}
return nil
}
// SetTrayTooltip updates the tray tooltip.
func (s *Service) SetTrayTooltip(tooltip string) error {
_, handled, err := s.Core().PERFORM(systray.TaskSetTooltip{Tooltip: tooltip})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("systray service not available")
}
return nil
}
// SetTrayLabel updates the tray label.
func (s *Service) SetTrayLabel(label string) error {
_, handled, err := s.Core().PERFORM(systray.TaskSetLabel{Label: label})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("systray service not available")
}
return nil
}
// SetTrayMenu replaces the tray menu items.
func (s *Service) SetTrayMenu(items []systray.TrayMenuItem) error {
_, handled, err := s.Core().PERFORM(systray.TaskSetTrayMenu{Items: items})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("systray service not available")
}
return nil
}
// GetTrayInfo returns current tray state information.
func (s *Service) GetTrayInfo() map[string]any {
svc, err := core.ServiceFor[*systray.Service](s.Core(), "systray")
if err != nil || svc == nil || svc.Manager() == nil {
return nil
}
return svc.Manager().GetInfo()
}
// ShowTrayMessage shows a tray message or notification.
func (s *Service) ShowTrayMessage(title, message string) error {
_, handled, err := s.Core().PERFORM(systray.TaskShowMessage{Title: title, Message: message})
if err != nil {
return err
}
if !handled {
return fmt.Errorf("systray service not available")
}
return nil
}
// GetEventManager returns the event manager for WebSocket event subscriptions.
func (s *Service) GetEventManager() *WSEventManager {
return s.events

View file

@ -10,6 +10,7 @@ import (
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
@ -79,15 +80,53 @@ func (m *mockNotificationPlatform) Clear() error {
return nil
}
type mockDialogPlatform struct {
button string
last dialog.MessageDialogOptions
type mockEnvironmentPlatform struct {
isDark bool
info environment.EnvironmentInfo
accent string
}
func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) { return nil, nil }
func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) { return "", nil }
func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo {
return m.info
}
func (m *mockEnvironmentPlatform) AccentColour() string { return m.accent }
func (m *mockEnvironmentPlatform) OpenFileManager(path string, selectFile bool) error {
return nil
}
func (m *mockEnvironmentPlatform) OnThemeChange(handler func(isDark bool)) func() {
return func() {}
}
func (m *mockEnvironmentPlatform) SetTheme(isDark bool) error {
m.isDark = isDark
return nil
}
type mockDialogPlatform struct {
button string
openFilePaths []string
saveFilePath string
openDirPath string
last dialog.MessageDialogOptions
}
func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) {
if len(m.openFilePaths) == 0 {
return []string{"/tmp/file.txt"}, nil
}
return m.openFilePaths, nil
}
func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) {
if m.saveFilePath == "" {
return "/tmp/save.txt", nil
}
return m.saveFilePath, nil
}
func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) {
return "", nil
if m.openDirPath == "" {
return "/tmp/dir", nil
}
return m.openDirPath, nil
}
func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) {
m.last = opts
@ -163,10 +202,32 @@ func newTestConclave(t *testing.T) *core.Core {
}
func newExtendedTestConclave(t *testing.T) *core.Core {
t.Helper()
fixture := newExtendedTestConclaveWithMocks(t)
return fixture.core
}
type extendedTestConclave struct {
core *core.Core
clipboardPlatform *mockClipboardPlatform
notificationPlatform *mockNotificationPlatform
dialogPlatform *mockDialogPlatform
environmentPlatform *mockEnvironmentPlatform
}
func newExtendedTestConclaveWithMocks(t *testing.T) *extendedTestConclave {
t.Helper()
clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true}
notificationPlatform := &mockNotificationPlatform{permGranted: true}
dialogPlatform := &mockDialogPlatform{button: "OK"}
environmentPlatform := &mockEnvironmentPlatform{
isDark: true,
accent: "rgb(0,122,255)",
info: environment.EnvironmentInfo{
OS: "darwin", Arch: "arm64",
Platform: environment.PlatformInfo{Name: "macOS", Version: "14.0"},
},
}
c, err := core.New(
core.WithService(Register(nil)),
@ -184,12 +245,19 @@ func newExtendedTestConclave(t *testing.T) *core.Core {
core.WithService(clipboard.Register(clipboardPlatform)),
core.WithService(notification.Register(notificationPlatform)),
core.WithService(dialog.Register(dialogPlatform)),
core.WithService(environment.Register(environmentPlatform)),
core.WithService(webview.Register()),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return c
return &extendedTestConclave{
core: c,
clipboardPlatform: clipboardPlatform,
notificationPlatform: notificationPlatform,
dialogPlatform: dialogPlatform,
environmentPlatform: environmentPlatform,
}
}
// --- Tests ---
@ -574,6 +642,137 @@ func TestGetSavedWindowStates_Good(t *testing.T) {
assert.NotNil(t, states)
}
func TestServiceWrappers_Good(t *testing.T) {
fixture := newExtendedTestConclaveWithMocks(t)
svc := core.MustServiceFor[*Service](fixture.core, "display")
t.Run("screen wrappers", func(t *testing.T) {
screens := svc.GetScreens()
require.Len(t, screens, 1)
primary, err := svc.GetPrimaryScreen()
require.NoError(t, err)
require.NotNil(t, primary)
assert.True(t, primary.IsPrimary)
atPoint, err := svc.GetScreenAtPoint(10, 10)
require.NoError(t, err)
require.NotNil(t, atPoint)
workAreas := svc.GetWorkAreas()
require.Len(t, workAreas, 1)
})
t.Run("window-screen lookup", func(t *testing.T) {
require.NoError(t, svc.OpenWindow(window.WithName("screen-win"), window.WithSize(640, 480)))
screenInfo, err := svc.GetScreenForWindow("screen-win")
require.NoError(t, err)
require.NotNil(t, screenInfo)
})
t.Run("clipboard wrappers", func(t *testing.T) {
text, err := svc.ReadClipboard()
require.NoError(t, err)
assert.Equal(t, "hello", text)
assert.True(t, svc.HasClipboard())
require.NoError(t, svc.WriteClipboard("updated"))
text, err = svc.ReadClipboard()
require.NoError(t, err)
assert.Equal(t, "updated", text)
image, err := svc.ReadClipboardImage()
require.NoError(t, err)
assert.True(t, image.HasContent)
require.NoError(t, svc.WriteClipboardImage([]byte{9, 8, 7}))
require.NoError(t, svc.ClearClipboard())
assert.False(t, svc.HasClipboard())
})
t.Run("notification wrappers", func(t *testing.T) {
require.NoError(t, svc.ShowInfoNotification("Info", "Hello"))
require.True(t, fixture.notificationPlatform.sendCalled)
assert.Equal(t, notification.SeverityInfo, fixture.notificationPlatform.lastOpts.Severity)
granted, err := svc.RequestNotificationPermission()
require.NoError(t, err)
assert.True(t, granted)
granted, err = svc.CheckNotificationPermission()
require.NoError(t, err)
assert.True(t, granted)
require.NoError(t, svc.ClearNotifications())
assert.True(t, fixture.notificationPlatform.clearCalled)
})
t.Run("dialog wrappers", func(t *testing.T) {
paths, err := svc.OpenFileDialog(dialog.OpenFileOptions{Title: "Pick"})
require.NoError(t, err)
require.NotEmpty(t, paths)
path, err := svc.OpenSingleFileDialog(dialog.OpenFileOptions{Title: "Pick"})
require.NoError(t, err)
assert.Equal(t, paths[0], path)
path, err = svc.SaveFileDialog(dialog.SaveFileOptions{Filename: "out.txt"})
require.NoError(t, err)
assert.Equal(t, "/tmp/save.txt", path)
path, err = svc.OpenDirectoryDialog(dialog.OpenDirectoryOptions{Title: "Pick Dir"})
require.NoError(t, err)
assert.Equal(t, "/tmp/dir", path)
confirmed, err := svc.ConfirmDialog("Confirm", "Continue?")
require.NoError(t, err)
assert.True(t, confirmed)
button, accepted, err := svc.PromptDialog("Question", "Continue?")
require.NoError(t, err)
assert.Equal(t, "OK", button)
assert.True(t, accepted)
})
t.Run("theme wrappers", func(t *testing.T) {
theme := svc.GetTheme()
require.NotNil(t, theme)
assert.True(t, theme.IsDark)
assert.Equal(t, "dark", svc.GetSystemTheme())
require.NoError(t, svc.SetTheme(false))
assert.False(t, fixture.environmentPlatform.isDark)
theme = svc.GetTheme()
require.NotNil(t, theme)
assert.False(t, theme.IsDark)
assert.Equal(t, "light", svc.GetSystemTheme())
})
t.Run("tray wrappers", func(t *testing.T) {
info := svc.GetTrayInfo()
require.NotNil(t, info)
assert.True(t, info["active"].(bool))
require.NoError(t, svc.SetTrayTooltip("Updated Tooltip"))
require.NoError(t, svc.SetTrayLabel("Updated Label"))
require.NoError(t, svc.SetTrayIcon([]byte{1, 2, 3}))
require.NoError(t, svc.SetTrayMenu([]systray.TrayMenuItem{
{Label: "One", ActionID: "one"},
{Type: "separator"},
{Label: "More", Submenu: []systray.TrayMenuItem{{Label: "Two", ActionID: "two"}}},
}))
info = svc.GetTrayInfo()
require.NotNil(t, info)
assert.Equal(t, "Updated Tooltip", info["tooltip"])
assert.Equal(t, "Updated Label", info["label"])
assert.True(t, info["hasIcon"].(bool))
items, ok := info["menuItems"].([]systray.TrayMenuItem)
require.True(t, ok)
require.Len(t, items, 3)
})
}
func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
c := newTestConclave(t)

View file

@ -10,3 +10,8 @@ type ActionIDECommand struct {
// EventIDECommand is the WS event type for IDE commands.
const EventIDECommand EventType = "ide.command"
// Theme is the display-level theme summary exposed by the service API.
type Theme struct {
IsDark bool `json:"isDark"`
}

View file

@ -8,6 +8,7 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
}
m.menuItems = append([]TrayMenuItem(nil), items...)
menu := m.buildMenu(items)
m.tray.SetMenu(menu)
return nil
@ -77,6 +78,11 @@ func (m *Manager) GetCallback(actionID string) (func(), bool) {
// GetInfo returns tray status information.
func (m *Manager) GetInfo() map[string]any {
return map[string]any{
"active": m.IsActive(),
"active": m.IsActive(),
"tooltip": m.tooltip,
"label": m.label,
"hasIcon": m.hasIcon,
"hasTemplateIcon": m.hasTemplateIcon,
"menuItems": append([]TrayMenuItem(nil), m.menuItems...),
}
}

View file

@ -13,10 +13,15 @@ var defaultIcon []byte
// Manager manages the system tray lifecycle.
// State that was previously in package-level vars is now on the Manager.
type Manager struct {
platform Platform
tray PlatformTray
callbacks map[string]func()
mu sync.RWMutex
platform Platform
tray PlatformTray
callbacks map[string]func()
tooltip string
label string
hasIcon bool
hasTemplateIcon bool
menuItems []TrayMenuItem
mu sync.RWMutex
}
// NewManager creates a systray Manager.
@ -36,6 +41,9 @@ func (m *Manager) Setup(tooltip, label string) error {
m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip)
m.tray.SetLabel(label)
m.tooltip = tooltip
m.label = label
m.hasTemplateIcon = true
return nil
}
@ -45,6 +53,7 @@ func (m *Manager) SetIcon(data []byte) error {
return fmt.Errorf("tray not initialised")
}
m.tray.SetIcon(data)
m.hasIcon = len(data) > 0
return nil
}
@ -54,6 +63,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error {
return fmt.Errorf("tray not initialised")
}
m.tray.SetTemplateIcon(data)
m.hasTemplateIcon = len(data) > 0
return nil
}
@ -63,6 +73,7 @@ func (m *Manager) SetTooltip(text string) error {
return fmt.Errorf("tray not initialised")
}
m.tray.SetTooltip(text)
m.tooltip = text
return nil
}
@ -72,6 +83,7 @@ func (m *Manager) SetLabel(text string) error {
return fmt.Errorf("tray not initialised")
}
m.tray.SetLabel(text)
m.label = text
return nil
}