feat(display): bridge missing GUI features
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
a1fbcdf6ed
commit
3c5c109c3a
2 changed files with 341 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue