From 3c5c109c3a22daee9b5c8f0099935b2554be908c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:17:20 +0000 Subject: [PATCH] feat(display): bridge missing GUI features Co-Authored-By: Virgil --- pkg/display/display.go | 116 +++++++++++++++++++ pkg/display/display_test.go | 225 ++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) diff --git a/pkg/display/display.go b/pkg/display/display.go index 58656eb..46c4826 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -2,6 +2,7 @@ package display import ( "context" + "encoding/base64" "fmt" "os" "path/filepath" @@ -12,6 +13,7 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/browser" + "forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/contextmenu" "forge.lthn.ai/core/gui/pkg/dialog" "forge.lthn.ai/core/gui/pkg/dock" @@ -464,6 +466,120 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { return nil, false, e } result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: w}) + case "webview:devtools-open": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskOpenDevTools{Window: w}) + case "webview:devtools-close": + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + result, handled, err = s.Core().PERFORM(webview.TaskCloseDevTools{Window: w}) + case "layout:beside-editor": + editor, _ := msg.Data["editor"].(string) + windowName, _ := msg.Data["window"].(string) + result, handled, err = s.Core().PERFORM(window.TaskBesideEditor{ + Editor: editor, + Window: windowName, + }) + case "layout:suggest": + windowCount := 0 + if count, ok := msg.Data["windowCount"].(float64); ok { + windowCount = int(count) + } + screenWidth := 0 + if width, ok := msg.Data["screenWidth"].(float64); ok { + screenWidth = int(width) + } + screenHeight := 0 + if height, ok := msg.Data["screenHeight"].(float64); ok { + screenHeight = int(height) + } + if windowCount <= 0 { + windowCount = len(s.ListWindowInfos()) + } + if screenWidth <= 0 || screenHeight <= 0 { + screenWidth, screenHeight = s.primaryScreenSize() + } + result, handled, err = s.Core().QUERY(window.QueryLayoutSuggestion{ + WindowCount: windowCount, + ScreenWidth: screenWidth, + ScreenHeight: screenHeight, + }) + case "clipboard:read-image": + result, handled, err = s.Core().QUERY(clipboard.QueryImage{}) + case "clipboard:write-image": + data, ok := msg.Data["data"].(string) + if !ok || data == "" { + return nil, false, fmt.Errorf("ws: missing required field %q", "data") + } + decoded, decodeErr := base64.StdEncoding.DecodeString(data) + if decodeErr != nil { + return nil, false, fmt.Errorf("ws: invalid base64 image data: %w", decodeErr) + } + result, handled, err = s.Core().PERFORM(clipboard.TaskSetImage{Data: decoded}) + case "notification:with-actions": + 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 + } + subtitle, _ := msg.Data["subtitle"].(string) + actions := make([]notification.NotificationAction, 0) + if raw, ok := msg.Data["actions"]; ok { + encoded, _ := json.Marshal(raw) + _ = json.Unmarshal(encoded, &actions) + } + result, handled, err = s.Core().PERFORM(notification.TaskSend{ + Opts: notification.NotificationOptions{ + Title: title, + Message: message, + Subtitle: subtitle, + Actions: actions, + }, + }) + case "notification:clear": + result, handled, err = s.Core().PERFORM(notification.TaskClear{}) + case "notification:permission-request": + result, handled, err = s.Core().PERFORM(notification.TaskRequestPermission{}) + case "notification:permission-check": + result, handled, err = s.Core().QUERY(notification.QueryPermission{}) + case "tray:show-message": + 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(systray.TaskShowMessage{ + Title: title, + Message: message, + }) + case "dialog:prompt": + 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(dialog.TaskMessageDialog{ + Opts: dialog.MessageDialogOptions{ + Type: dialog.DialogInfo, + Title: title, + Message: message, + Buttons: []string{"OK", "Cancel"}, + }, + }) default: return nil, false, nil } diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 4d85897..a99f280 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -2,14 +2,19 @@ package display import ( "context" + "encoding/base64" "os" "path/filepath" "testing" "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/menu" + "forge.lthn.ai/core/gui/pkg/notification" "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/systray" + "forge.lthn.ai/core/gui/pkg/webview" "forge.lthn.ai/core/gui/pkg/window" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,6 +35,95 @@ func (m *mockScreenPlatform) GetPrimary() *screen.Screen { return nil } +type mockClipboardPlatform struct { + text string + ok bool + image []byte + imgOk bool +} + +func (m *mockClipboardPlatform) Text() (string, bool) { return m.text, m.ok } +func (m *mockClipboardPlatform) SetText(text string) bool { + m.text = text + m.ok = text != "" + return true +} +func (m *mockClipboardPlatform) Image() ([]byte, bool) { return m.image, m.imgOk } +func (m *mockClipboardPlatform) SetImage(data []byte) bool { + m.image = data + m.imgOk = len(data) > 0 + return true +} + +type mockNotificationPlatform struct { + permGranted bool + sendCalled bool + clearCalled bool + lastOpts notification.NotificationOptions +} + +func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error { + m.sendCalled = true + m.lastOpts = opts + return nil +} +func (m *mockNotificationPlatform) SendWithActions(opts notification.NotificationOptions) error { + m.sendCalled = true + m.lastOpts = opts + return nil +} +func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return m.permGranted, nil } +func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return m.permGranted, nil } +func (m *mockNotificationPlatform) Clear() error { + m.clearCalled = true + return nil +} + +type mockDialogPlatform struct { + button string + last dialog.MessageDialogOptions +} + +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 *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) { + return "", nil +} +func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) { + m.last = opts + if m.button != "" { + return m.button, nil + } + return "OK", nil +} + +type mockWebviewConnector struct{} + +func (m *mockWebviewConnector) Navigate(url string) error { return nil } +func (m *mockWebviewConnector) Click(selector string) error { return nil } +func (m *mockWebviewConnector) Type(selector, text string) error { return nil } +func (m *mockWebviewConnector) Hover(selector string) error { return nil } +func (m *mockWebviewConnector) Select(selector, value string) error { return nil } +func (m *mockWebviewConnector) Check(selector string, checked bool) error { return nil } +func (m *mockWebviewConnector) Evaluate(script string) (any, error) { return nil, nil } +func (m *mockWebviewConnector) Screenshot() ([]byte, error) { return nil, nil } +func (m *mockWebviewConnector) GetURL() (string, error) { return "", nil } +func (m *mockWebviewConnector) GetTitle() (string, error) { return "", nil } +func (m *mockWebviewConnector) GetHTML(selector string) (string, error) { return "", nil } +func (m *mockWebviewConnector) QuerySelector(selector string) (*webview.ElementInfo, error) { + return nil, nil +} +func (m *mockWebviewConnector) QuerySelectorAll(selector string) ([]*webview.ElementInfo, error) { + return nil, nil +} +func (m *mockWebviewConnector) GetConsole() []webview.ConsoleMessage { return nil } +func (m *mockWebviewConnector) ClearConsole() {} +func (m *mockWebviewConnector) SetViewport(width, height int) error { return nil } +func (m *mockWebviewConnector) UploadFile(selector string, paths []string) error { + return nil +} +func (m *mockWebviewConnector) Close() error { return nil } + // --- Test helpers --- // newTestDisplayService creates a display service registered with Core for IPC testing. @@ -68,6 +162,36 @@ func newTestConclave(t *testing.T) *core.Core { return c } +func newExtendedTestConclave(t *testing.T) *core.Core { + t.Helper() + clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true} + notificationPlatform := &mockNotificationPlatform{permGranted: true} + dialogPlatform := &mockDialogPlatform{button: "OK"} + + c, err := core.New( + core.WithService(Register(nil)), + core.WithService(window.Register(window.NewMockPlatform())), + core.WithService(screen.Register(&mockScreenPlatform{ + screens: []screen.Screen{{ + ID: "primary", Name: "Primary", IsPrimary: true, + Size: screen.Size{Width: 2560, Height: 1440}, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440}, + }}, + })), + core.WithService(systray.Register(systray.NewMockPlatform())), + core.WithService(menu.Register(menu.NewMockPlatform())), + core.WithService(clipboard.Register(clipboardPlatform)), + core.WithService(notification.Register(notificationPlatform)), + core.WithService(dialog.Register(dialogPlatform)), + core.WithService(webview.Register()), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + return c +} + // --- Tests --- func TestNew(t *testing.T) { @@ -546,3 +670,104 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) { require.NoError(t, err) assert.Contains(t, string(data), "default_width") } + +func TestHandleWSMessage_Extended_Good(t *testing.T) { + c := newExtendedTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + _ = svc.OpenWindow( + window.WithName("editor"), + window.WithTitle("Editor"), + window.WithSize(1200, 800), + ) + _ = svc.OpenWindow( + window.WithName("assistant"), + window.WithTitle("Assistant"), + window.WithSize(900, 800), + ) + + t.Run("layout suggest", func(t *testing.T) { + result, handled, err := svc.handleWSMessage(WSMessage{Action: "layout:suggest"}) + require.NoError(t, err) + assert.True(t, handled) + suggestion, ok := result.(window.LayoutSuggestion) + require.True(t, ok) + assert.Equal(t, "side-by-side", suggestion.Mode) + }) + + t.Run("clipboard image read", func(t *testing.T) { + result, handled, err := svc.handleWSMessage(WSMessage{Action: "clipboard:read-image"}) + require.NoError(t, err) + assert.True(t, handled) + content, ok := result.(clipboard.ClipboardImageContent) + require.True(t, ok) + assert.True(t, content.HasContent) + assert.NotEmpty(t, content.Base64) + }) + + t.Run("clipboard image write", func(t *testing.T) { + payload := base64.StdEncoding.EncodeToString([]byte{9, 8, 7}) + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "clipboard:write-image", + Data: map[string]any{"data": payload}, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("notification actions", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "notification:with-actions", + Data: map[string]any{ + "title": "Heads up", + "message": "Choose one", + "actions": []any{ + map[string]any{"id": "ok", "label": "OK"}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("notification clear", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{Action: "notification:clear"}) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("webview devtools", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "webview:devtools-open", + Data: map[string]any{"window": "editor"}, + }) + require.NoError(t, err) + assert.True(t, handled) + + _, handled, err = svc.handleWSMessage(WSMessage{ + Action: "webview:devtools-close", + Data: map[string]any{"window": "editor"}, + }) + require.NoError(t, err) + assert.True(t, handled) + }) + + t.Run("tray message", func(t *testing.T) { + _, handled, err := svc.handleWSMessage(WSMessage{ + Action: "tray:show-message", + Data: map[string]any{"title": "Core", "message": "Ready"}, + }) + 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", + Data: map[string]any{"title": "Question", "message": "Continue?"}, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "OK", result) + }) +}