diff --git a/go.mod b/go.mod index 77122c4..c13dc8e 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( replace github.com/wailsapp/wails/v3 => ./stubs/wails require ( + dappco.re/go/core v0.8.0-alpha.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 486e4fe..0618491 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= diff --git a/pkg/dock/messages.go b/pkg/dock/messages.go index f45cc31..4d56908 100644 --- a/pkg/dock/messages.go +++ b/pkg/dock/messages.go @@ -23,7 +23,33 @@ type TaskSetBadge struct{ Label string } // TaskRemoveBadge removes the dock/taskbar badge. Result: nil type TaskRemoveBadge struct{} +// TaskSetProgressBar updates the progress indicator on the dock/taskbar icon. +// Progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator. +// c.PERFORM(dock.TaskSetProgressBar{Progress: 0.75}) // 75% complete +// c.PERFORM(dock.TaskSetProgressBar{Progress: -1.0}) // hide indicator +// Result: nil +type TaskSetProgressBar struct{ Progress float64 } + +// TaskBounce requests user attention by animating the dock icon. +// Result: int (requestID for use with TaskStopBounce) +// c.PERFORM(dock.TaskBounce{BounceType: dock.BounceInformational}) +type TaskBounce struct{ BounceType BounceType } + +// TaskStopBounce cancels a pending attention request. +// c.PERFORM(dock.TaskStopBounce{RequestID: id}) +// Result: nil +type TaskStopBounce struct{ RequestID int } + // --- Actions (broadcasts) --- // ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. type ActionVisibilityChanged struct{ Visible bool } + +// ActionProgressChanged is broadcast after a successful TaskSetProgressBar. +type ActionProgressChanged struct{ Progress float64 } + +// ActionBounceStarted is broadcast after a successful TaskBounce. +type ActionBounceStarted struct { + RequestID int `json:"requestId"` + BounceType BounceType `json:"bounceType"` +} diff --git a/pkg/dock/platform.go b/pkg/dock/platform.go index d34004a..8ab1fd6 100644 --- a/pkg/dock/platform.go +++ b/pkg/dock/platform.go @@ -1,9 +1,21 @@ // pkg/dock/platform.go package dock +// BounceType controls how the dock icon attracts attention. +// bounce := dock.BounceInformational — single bounce +// bounce := dock.BounceCritical — continuous bounce until focused +type BounceType int + +const ( + // BounceInformational performs a single bounce to indicate a background event. + BounceInformational BounceType = iota + // BounceCritical bounces continuously until the application becomes active. + BounceCritical +) + // Platform abstracts the dock/taskbar backend (Wails v3). -// macOS: dock icon show/hide + badge. -// Windows: taskbar badge only (show/hide not supported). +// macOS: dock icon show/hide, badge, progress bar, bounce. +// Windows: taskbar badge + progress bar (show/hide and bounce not supported). // Linux: not supported — adapter returns nil for all operations. type Platform interface { ShowIcon() error @@ -11,4 +23,12 @@ type Platform interface { SetBadge(label string) error RemoveBadge() error IsVisible() bool + // SetProgressBar sets a progress indicator on the dock/taskbar icon. + // progress is clamped to [0.0, 1.0]. Pass -1.0 to hide the indicator. + SetProgressBar(progress float64) error + // Bounce requests user attention by animating the dock icon. + // Returns a request ID that can be passed to StopBounce. + Bounce(bounceType BounceType) (int, error) + // StopBounce cancels a pending attention request by its ID. + StopBounce(requestID int) error } diff --git a/pkg/dock/service.go b/pkg/dock/service.go index 346ef95..8d3c9f1 100644 --- a/pkg/dock/service.go +++ b/pkg/dock/service.go @@ -56,6 +56,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, err } return nil, true, nil + case TaskSetProgressBar: + if err := s.platform.SetProgressBar(t.Progress); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionProgressChanged{Progress: t.Progress}) + return nil, true, nil + case TaskBounce: + requestID, err := s.platform.Bounce(t.BounceType) + if err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionBounceStarted{RequestID: requestID, BounceType: t.BounceType}) + return requestID, true, nil + case TaskStopBounce: + if err := s.platform.StopBounce(t.RequestID); err != nil { + return nil, true, err + } + return nil, true, nil default: return nil, false, nil } diff --git a/pkg/dock/service_test.go b/pkg/dock/service_test.go index 503e870..4b35fe8 100644 --- a/pkg/dock/service_test.go +++ b/pkg/dock/service_test.go @@ -13,13 +13,21 @@ import ( // --- Mock Platform --- type mockPlatform struct { - visible bool - badge string - hasBadge bool - showErr error - hideErr error - badgeErr error - removeErr error + visible bool + badge string + hasBadge bool + progress float64 + bounceID int + bounceType BounceType + bounceCalled bool + stopBounceCalled bool + showErr error + hideErr error + badgeErr error + removeErr error + progressErr error + bounceErr error + stopBounceErr error } func (m *mockPlatform) ShowIcon() error { @@ -58,6 +66,32 @@ func (m *mockPlatform) RemoveBadge() error { func (m *mockPlatform) IsVisible() bool { return m.visible } +func (m *mockPlatform) SetProgressBar(progress float64) error { + if m.progressErr != nil { + return m.progressErr + } + m.progress = progress + return nil +} + +func (m *mockPlatform) Bounce(bounceType BounceType) (int, error) { + if m.bounceErr != nil { + return 0, m.bounceErr + } + m.bounceCalled = true + m.bounceType = bounceType + m.bounceID++ + return m.bounceID, nil +} + +func (m *mockPlatform) StopBounce(requestID int) error { + if m.stopBounceErr != nil { + return m.stopBounceErr + } + m.stopBounceCalled = true + return nil +} + // --- Test helpers --- func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) { @@ -192,3 +226,161 @@ func TestTaskSetBadge_Bad(t *testing.T) { assert.True(t, handled) assert.Error(t, err) } + +// --- TaskSetProgressBar --- + +func TestTaskSetProgressBar_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + var received *ActionProgressChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionProgressChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskSetProgressBar{Progress: 0.5}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 0.5, mock.progress) + require.NotNil(t, received) + assert.Equal(t, 0.5, received.Progress) +} + +func TestTaskSetProgressBar_Hide_Good(t *testing.T) { + // Progress -1.0 hides the indicator + _, c, mock := newTestDockService(t) + _, handled, err := c.PERFORM(TaskSetProgressBar{Progress: -1.0}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, -1.0, mock.progress) +} + +func TestTaskSetProgressBar_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.progressErr = assert.AnError + + _, handled, err := c.PERFORM(TaskSetProgressBar{Progress: 0.5}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskSetProgressBar_Ugly(t *testing.T) { + // No dock service — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskSetProgressBar{Progress: 0.5}) + assert.False(t, handled) +} + +// --- TaskBounce --- + +func TestTaskBounce_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + var received *ActionBounceStarted + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionBounceStarted); ok { + received = &a + } + return nil + }) + + result, handled, err := c.PERFORM(TaskBounce{BounceType: BounceInformational}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.bounceCalled) + assert.Equal(t, BounceInformational, mock.bounceType) + requestID, ok := result.(int) + require.True(t, ok) + assert.Equal(t, 1, requestID) + require.NotNil(t, received) + assert.Equal(t, BounceInformational, received.BounceType) +} + +func TestTaskBounce_Critical_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + result, handled, err := c.PERFORM(TaskBounce{BounceType: BounceCritical}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, BounceCritical, mock.bounceType) + requestID, ok := result.(int) + require.True(t, ok) + assert.Equal(t, 1, requestID) +} + +func TestTaskBounce_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.bounceErr = assert.AnError + + _, handled, err := c.PERFORM(TaskBounce{BounceType: BounceInformational}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskBounce_Ugly(t *testing.T) { + // No dock service — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskBounce{BounceType: BounceInformational}) + assert.False(t, handled) +} + +// --- TaskStopBounce --- + +func TestTaskStopBounce_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + // Start a bounce to get a requestID + result, _, err := c.PERFORM(TaskBounce{BounceType: BounceInformational}) + require.NoError(t, err) + requestID := result.(int) + + _, handled, err := c.PERFORM(TaskStopBounce{RequestID: requestID}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.stopBounceCalled) +} + +func TestTaskStopBounce_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.stopBounceErr = assert.AnError + + _, handled, err := c.PERFORM(TaskStopBounce{RequestID: 1}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskStopBounce_Ugly(t *testing.T) { + // No dock service — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskStopBounce{RequestID: 99}) + assert.False(t, handled) +} + +func TestTaskRemoveBadge_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.removeErr = assert.AnError + + _, handled, err := c.PERFORM(TaskRemoveBadge{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestQueryVisible_Ugly(t *testing.T) { + // Dock icon initially hidden + mock := &mockPlatform{visible: false} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + result, handled, err := c.QUERY(QueryVisible{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, false, result) +} diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index 67da238..9e5f6f9 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -9,5 +9,25 @@ type TaskSend struct{ Options NotificationOptions } // TaskRequestPermission requests notification permission from the OS. Result: bool (granted) type TaskRequestPermission struct{} -// ActionNotificationClicked is broadcast when the user clicks a notification. +// TaskRevokePermission revokes previously granted notification permission. Result: nil +type TaskRevokePermission struct{} + +// TaskRegisterCategory registers a notification category with its actions. +// c.PERFORM(notification.TaskRegisterCategory{Category: notification.NotificationCategory{ID: "message", Actions: actions}}) +type TaskRegisterCategory struct{ Category NotificationCategory } + +// ActionNotificationClicked is broadcast when the user clicks a notification body. type ActionNotificationClicked struct{ ID string } + +// ActionNotificationActionTriggered is broadcast when the user activates a notification action button. +// c.RegisterAction(func(_ *core.Core, msg core.Message) error { +// if a, ok := msg.(notification.ActionNotificationActionTriggered); ok { ... } +// return nil +// }) +type ActionNotificationActionTriggered struct { + NotificationID string `json:"notificationId"` + ActionID string `json:"actionId"` +} + +// ActionNotificationDismissed is broadcast when the user dismisses a notification. +type ActionNotificationDismissed struct{ ID string } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index 954a5af..940e652 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -6,6 +6,7 @@ type Platform interface { Send(options NotificationOptions) error RequestPermission() (bool, error) CheckPermission() (bool, error) + RevokePermission() error } // NotificationSeverity indicates the severity for dialog fallback. @@ -17,13 +18,30 @@ const ( SeverityError ) +// NotificationAction is a button that can be attached to a notification. +// id := "reply"; action := NotificationAction{ID: id, Title: "Reply", Destructive: false} +type NotificationAction struct { + ID string `json:"id"` + Title string `json:"title"` + Destructive bool `json:"destructive,omitempty"` +} + +// NotificationCategory groups actions under a named category/channel. +// category := NotificationCategory{ID: "message", Actions: []NotificationAction{{ID: "reply", Title: "Reply"}}} +type NotificationCategory struct { + ID string `json:"id"` + Actions []NotificationAction `json:"actions,omitempty"` +} + // 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 7dc412b..ab8c086 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -14,7 +14,8 @@ type Options struct{} type Service struct { *core.ServiceRuntime[Options] - platform Platform + platform Platform + categories map[string]NotificationCategory } func Register(p Platform) func(*core.Core) (any, error) { @@ -22,6 +23,7 @@ func Register(p Platform) func(*core.Core) (any, error) { return &Service{ ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), platform: p, + categories: make(map[string]NotificationCategory), }, nil } } @@ -53,6 +55,11 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskRequestPermission: granted, err := s.platform.RequestPermission() return granted, true, err + case TaskRevokePermission: + return nil, true, s.platform.RevokePermission() + case TaskRegisterCategory: + s.categories[t.Category.ID] = t.Category + return nil, true, nil default: return nil, false, nil } diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index 8689ddf..c6be05d 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -13,11 +13,13 @@ import ( ) type mockPlatform struct { - sendErr error - permGranted bool - permErr error - lastOpts NotificationOptions - sendCalled bool + sendErr error + permGranted bool + permErr error + revokeErr error + revokeCalled bool + lastOpts NotificationOptions + sendCalled bool } func (m *mockPlatform) Send(opts NotificationOptions) error { @@ -27,6 +29,10 @@ func (m *mockPlatform) Send(opts NotificationOptions) error { } func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } +func (m *mockPlatform) RevokePermission() error { + m.revokeCalled = true + return m.revokeErr +} // mockDialogPlatform tracks whether MessageDialog was called (for fallback test). type mockDialogPlatform struct { @@ -117,3 +123,150 @@ func TestTaskSend_Bad(t *testing.T) { _, handled, _ := c.PERFORM(TaskSend{}) assert.False(t, handled) } + +// --- TaskRevokePermission --- + +func TestTaskRevokePermission_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskRevokePermission{}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.revokeCalled) +} + +func TestTaskRevokePermission_Bad(t *testing.T) { + mock, c := newTestService(t) + mock.revokeErr = errors.New("cannot revoke") + _, handled, err := c.PERFORM(TaskRevokePermission{}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskRevokePermission_Ugly(t *testing.T) { + // No service registered — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskRevokePermission{}) + assert.False(t, handled) +} + +// --- TaskRegisterCategory --- + +func TestTaskRegisterCategory_Good(t *testing.T) { + _, c := newTestService(t) + category := NotificationCategory{ + ID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "delete", Title: "Delete", Destructive: true}, + }, + } + _, handled, err := c.PERFORM(TaskRegisterCategory{Category: category}) + require.NoError(t, err) + assert.True(t, handled) + + svc := core.MustServiceFor[*Service](c, "notification") + stored, ok := svc.categories["message"] + require.True(t, ok) + assert.Equal(t, 2, len(stored.Actions)) + assert.Equal(t, "reply", stored.Actions[0].ID) + assert.True(t, stored.Actions[1].Destructive) +} + +func TestTaskRegisterCategory_Bad(t *testing.T) { + // No service registered — PERFORM returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.PERFORM(TaskRegisterCategory{Category: NotificationCategory{ID: "x"}}) + assert.False(t, handled) +} + +func TestTaskRegisterCategory_Ugly(t *testing.T) { + // Re-registering a category replaces the previous one + _, c := newTestService(t) + first := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "a", Title: "A"}}} + second := NotificationCategory{ID: "chat", Actions: []NotificationAction{{ID: "b", Title: "B"}, {ID: "c", Title: "C"}}} + + _, _, err := c.PERFORM(TaskRegisterCategory{Category: first}) + require.NoError(t, err) + _, _, err = c.PERFORM(TaskRegisterCategory{Category: second}) + require.NoError(t, err) + + svc := core.MustServiceFor[*Service](c, "notification") + assert.Equal(t, 2, len(svc.categories["chat"].Actions)) + assert.Equal(t, "b", svc.categories["chat"].Actions[0].ID) +} + +// --- NotificationOptions with Actions --- + +func TestTaskSend_WithActions_Good(t *testing.T) { + mock, c := newTestService(t) + options := NotificationOptions{ + Title: "Team Chat", + Message: "New message from Alice", + CategoryID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + } + _, handled, err := c.PERFORM(TaskSend{Options: options}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "message", mock.lastOpts.CategoryID) + assert.Equal(t, 2, len(mock.lastOpts.Actions)) +} + +// --- ActionNotificationActionTriggered --- + +func TestActionNotificationActionTriggered_Good(t *testing.T) { + // ActionNotificationActionTriggered is broadcast by external code; confirm it can be received + _, c := newTestService(t) + var received *ActionNotificationActionTriggered + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionNotificationActionTriggered); ok { + received = &a + } + return nil + }) + _ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "n1", ActionID: "reply"}) + require.NotNil(t, received) + assert.Equal(t, "n1", received.NotificationID) + assert.Equal(t, "reply", received.ActionID) +} + +func TestActionNotificationDismissed_Good(t *testing.T) { + _, c := newTestService(t) + var received *ActionNotificationDismissed + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionNotificationDismissed); ok { + received = &a + } + return nil + }) + _ = c.ACTION(ActionNotificationDismissed{ID: "n2"}) + require.NotNil(t, received) + assert.Equal(t, "n2", received.ID) +} + +func TestQueryPermission_Bad(t *testing.T) { + // No service — QUERY returns handled=false + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.QUERY(QueryPermission{}) + assert.False(t, handled) +} + +func TestQueryPermission_Ugly(t *testing.T) { + // Platform returns error — QUERY returns error with handled=true + mock := &mockPlatform{permErr: errors.New("platform error")} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + _, handled, queryErr := c.QUERY(QueryPermission{}) + assert.True(t, handled) + assert.Error(t, queryErr) +} diff --git a/pkg/webview/messages.go b/pkg/webview/messages.go index 4681e28..2296838 100644 --- a/pkg/webview/messages.go +++ b/pkg/webview/messages.go @@ -110,6 +110,32 @@ type TaskSetViewport struct { // TaskClearConsole clears captured console messages. Result: nil type TaskClearConsole struct{ Window string `json:"window"` } +// TaskSetURL navigates to a URL (alias for TaskNavigate, preferred for direct URL setting). Result: nil +type TaskSetURL struct { + Window string `json:"window"` + URL string `json:"url"` +} + +// TaskSetZoom sets the page zoom level. Result: nil +// zoom := 1.0 is normal; 1.5 is 150%; 0.5 is 50%. +type TaskSetZoom struct { + Window string `json:"window"` + Zoom float64 `json:"zoom"` +} + +// TaskPrint triggers the browser print dialog or prints to PDF. Result: *PrintResult +// c.PERFORM(TaskPrint{Window: "main"}) // opens print dialog via window.print() +// c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) // returns base64 PDF bytes +type TaskPrint struct { + Window string `json:"window"` + ToPDF bool `json:"toPDF,omitempty"` // true = return PDF bytes; false = open print dialog +} + +// QueryZoom gets the current page zoom level. Result: float64 +// result, _, _ := c.QUERY(QueryZoom{Window: "main"}) +// zoom := result.(float64) +type QueryZoom struct{ Window string `json:"window"` } + // --- Actions (broadcast) --- // ActionConsoleMessage is broadcast when a console message is captured. @@ -169,3 +195,9 @@ type ScreenshotResult struct { Base64 string `json:"base64"` MimeType string `json:"mimeType"` // always "image/png" } + +// PrintResult wraps PDF bytes as base64 when TaskPrint.ToPDF is true. +type PrintResult struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` // always "application/pdf" +} diff --git a/pkg/webview/service.go b/pkg/webview/service.go index a17d0b3..7d0c46d 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -34,6 +34,9 @@ type connector interface { ClearConsole() SetViewport(width, height int) error UploadFile(selector string, paths []string) error + GetZoom() (float64, error) + SetZoom(zoom float64) error + Print(toPDF bool) ([]byte, error) Close() error } @@ -111,7 +114,7 @@ func defaultNewConn(options Options) func(string, string) (connector, error) { if err != nil { return nil, err } - return &realConnector{wv: wv}, nil + return &realConnector{wv: wv, debugURL: debugURL}, nil } } @@ -273,6 +276,13 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) { } html, err := conn.GetHTML(selector) return html, true, err + case QueryZoom: + conn, err := s.getConn(q.Window) + if err != nil { + return nil, true, err + } + zoom, err := conn.GetZoom() + return zoom, true, err default: return nil, false, nil } @@ -362,14 +372,45 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) { } conn.ClearConsole() return nil, true, nil + case TaskSetURL: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + return nil, true, conn.Navigate(t.URL) + case TaskSetZoom: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + return nil, true, conn.SetZoom(t.Zoom) + case TaskPrint: + conn, err := s.getConn(t.Window) + if err != nil { + return nil, true, err + } + pdfBytes, err := conn.Print(t.ToPDF) + if err != nil { + return nil, true, err + } + if !t.ToPDF { + return nil, true, nil + } + return PrintResult{ + Base64: base64.StdEncoding.EncodeToString(pdfBytes), + MimeType: "application/pdf", + }, true, nil default: return nil, false, nil } } // realConnector wraps *gowebview.Webview, converting types at the boundary. +// debugURL is retained so that PDF printing can issue a Page.printToPDF CDP call +// via a fresh CDPClient, since go-webview v0.1.7 does not expose a PrintToPDF helper. type realConnector struct { - wv *gowebview.Webview + wv *gowebview.Webview + debugURL string // Chrome debug HTTP endpoint (e.g., http://localhost:9222) for direct CDP calls } func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) } @@ -385,6 +426,83 @@ func (r *realConnector) Close() error { return r.wv.C func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) } func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) } +// GetZoom returns the current CSS zoom level as a float64. +// zoom, _ := conn.GetZoom() // 1.0 = 100%, 1.5 = 150% +func (r *realConnector) GetZoom() (float64, error) { + raw, err := r.wv.Evaluate("parseFloat(document.documentElement.style.zoom) || 1.0") + if err != nil { + return 0, core.E("realConnector.GetZoom", "failed to get zoom", err) + } + switch v := raw.(type) { + case float64: + return v, nil + case int: + return float64(v), nil + default: + return 1.0, nil + } +} + +// SetZoom sets the CSS zoom level on the document root element. +// conn.SetZoom(1.5) // 150% +// conn.SetZoom(1.0) // reset to normal +func (r *realConnector) SetZoom(zoom float64) error { + script := "document.documentElement.style.zoom = '" + strconv.FormatFloat(zoom, 'g', -1, 64) + "'; undefined" + _, err := r.wv.Evaluate(script) + if err != nil { + return core.E("realConnector.SetZoom", "failed to set zoom", err) + } + return nil +} + +// Print triggers window.print() or exports to PDF via Page.printToPDF. +// When toPDF is false the browser print dialog is opened (via window.print()) and nil bytes are returned. +// When toPDF is true a fresh CDPClient is opened against the stored WebSocket URL to issue +// Page.printToPDF, which returns raw PDF bytes. +func (r *realConnector) Print(toPDF bool) ([]byte, error) { + if !toPDF { + _, err := r.wv.Evaluate("window.print(); undefined") + if err != nil { + return nil, core.E("realConnector.Print", "failed to open print dialog", err) + } + return nil, nil + } + + if r.debugURL == "" { + return nil, core.E("realConnector.Print", "no debug URL stored; cannot issue Page.printToPDF", nil) + } + + // Open a dedicated CDPClient for the single Page.printToPDF call. + // NewCDPClient connects to the first page target at the debug endpoint. + client, err := gowebview.NewCDPClient(r.debugURL) + if err != nil { + return nil, core.E("realConnector.Print", "failed to connect for PDF export", err) + } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result, err := client.Call(ctx, "Page.printToPDF", map[string]any{ + "printBackground": true, + }) + if err != nil { + return nil, core.E("realConnector.Print", "Page.printToPDF failed", err) + } + + dataStr, ok := result["data"].(string) + if !ok { + return nil, core.E("realConnector.Print", "Page.printToPDF returned no data", nil) + } + + pdfBytes, err := base64.StdEncoding.DecodeString(dataStr) + if err != nil { + return nil, core.E("realConnector.Print", "failed to decode PDF data", err) + } + + return pdfBytes, nil +} + func (r *realConnector) Hover(sel string) error { return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv) } diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 45a8f20..17d142c 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -35,6 +35,13 @@ type mockConnector struct { lastViewportW int lastViewportH int consoleClearCalled bool + + zoom float64 + lastZoomSet float64 + printToPDF bool + printCalled bool + printPDFBytes []byte + printErr error } func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } @@ -66,6 +73,25 @@ func (m *mockConnector) QuerySelectorAll(sel string) ([]*ElementInfo, error) { func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console } +func (m *mockConnector) GetZoom() (float64, error) { + if m.zoom == 0 { + return 1.0, nil + } + return m.zoom, nil +} + +func (m *mockConnector) SetZoom(zoom float64) error { + m.lastZoomSet = zoom + m.zoom = zoom + return nil +} + +func (m *mockConnector) Print(toPDF bool) ([]byte, error) { + m.printCalled = true + m.printToPDF = toPDF + return m.printPDFBytes, m.printErr +} + func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) { t.Helper() factory := Register() @@ -190,3 +216,146 @@ func TestQueryURL_Bad_NoService(t *testing.T) { _, handled, _ := c.QUERY(QueryURL{Window: "main"}) assert.False(t, handled) } + +// --- SetURL --- + +func TestTaskSetURL_Good(t *testing.T) { + mock := &mockConnector{} + _, c := newTestService(t, mock) + _, handled, err := c.PERFORM(TaskSetURL{Window: "main", URL: "https://example.com/page"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "https://example.com/page", mock.lastNavURL) +} + +func TestTaskSetURL_Bad_UnknownWindow(t *testing.T) { + _, c := newTestService(t, &mockConnector{}) + // Inject a connector factory that errors + svc := core.MustServiceFor[*Service](c, "webview") + svc.newConn = func(_, _ string) (connector, error) { + return nil, core.E("test", "no connection", nil) + } + _, _, err := c.PERFORM(TaskSetURL{Window: "bad", URL: "https://example.com"}) + assert.Error(t, err) +} + +func TestTaskSetURL_Ugly_EmptyURL(t *testing.T) { + mock := &mockConnector{} + _, c := newTestService(t, mock) + _, handled, err := c.PERFORM(TaskSetURL{Window: "main", URL: ""}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "", mock.lastNavURL) +} + +// --- Zoom --- + +func TestQueryZoom_Good(t *testing.T) { + mock := &mockConnector{zoom: 1.5} + _, c := newTestService(t, mock) + result, handled, err := c.QUERY(QueryZoom{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.InDelta(t, 1.5, result.(float64), 0.001) +} + +func TestQueryZoom_Good_DefaultsToOne(t *testing.T) { + mock := &mockConnector{} // zoom not set → GetZoom returns 1.0 + _, c := newTestService(t, mock) + result, handled, err := c.QUERY(QueryZoom{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + assert.InDelta(t, 1.0, result.(float64), 0.001) +} + +func TestQueryZoom_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.QUERY(QueryZoom{Window: "main"}) + assert.False(t, handled) +} + +func TestTaskSetZoom_Good(t *testing.T) { + mock := &mockConnector{} + _, c := newTestService(t, mock) + _, handled, err := c.PERFORM(TaskSetZoom{Window: "main", Zoom: 2.0}) + require.NoError(t, err) + assert.True(t, handled) + assert.InDelta(t, 2.0, mock.lastZoomSet, 0.001) +} + +func TestTaskSetZoom_Good_Reset(t *testing.T) { + mock := &mockConnector{zoom: 1.5} + _, c := newTestService(t, mock) + _, _, err := c.PERFORM(TaskSetZoom{Window: "main", Zoom: 1.0}) + require.NoError(t, err) + assert.InDelta(t, 1.0, mock.zoom, 0.001) +} + +func TestTaskSetZoom_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskSetZoom{Window: "main", Zoom: 1.5}) + assert.False(t, handled) +} + +func TestTaskSetZoom_Ugly_ZeroZoom(t *testing.T) { + mock := &mockConnector{} + _, c := newTestService(t, mock) + // Zero zoom is technically valid input; the connector accepts it. + _, handled, err := c.PERFORM(TaskSetZoom{Window: "main", Zoom: 0}) + require.NoError(t, err) + assert.True(t, handled) + assert.InDelta(t, 0.0, mock.lastZoomSet, 0.001) +} + +// --- Print --- + +func TestTaskPrint_Good_Dialog(t *testing.T) { + mock := &mockConnector{} + _, c := newTestService(t, mock) + result, handled, err := c.PERFORM(TaskPrint{Window: "main", ToPDF: false}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) + assert.True(t, mock.printCalled) + assert.False(t, mock.printToPDF) +} + +func TestTaskPrint_Good_PDF(t *testing.T) { + pdfHeader := []byte{0x25, 0x50, 0x44, 0x46} // %PDF + mock := &mockConnector{printPDFBytes: pdfHeader} + _, c := newTestService(t, mock) + result, handled, err := c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) + require.NoError(t, err) + assert.True(t, handled) + pr, ok := result.(PrintResult) + require.True(t, ok) + assert.Equal(t, "application/pdf", pr.MimeType) + assert.NotEmpty(t, pr.Base64) + assert.True(t, mock.printToPDF) +} + +func TestTaskPrint_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskPrint{Window: "main"}) + assert.False(t, handled) +} + +func TestTaskPrint_Bad_Error(t *testing.T) { + mock := &mockConnector{printErr: core.E("test", "print failed", nil)} + _, c := newTestService(t, mock) + _, _, err := c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) + assert.Error(t, err) +} + +func TestTaskPrint_Ugly_EmptyPDF(t *testing.T) { + // toPDF=true but connector returns zero bytes — should still wrap as PrintResult + mock := &mockConnector{printPDFBytes: []byte{}} + _, c := newTestService(t, mock) + result, handled, err := c.PERFORM(TaskPrint{Window: "main", ToPDF: true}) + require.NoError(t, err) + assert.True(t, handled) + pr, ok := result.(PrintResult) + require.True(t, ok) + assert.Equal(t, "application/pdf", pr.MimeType) + assert.Equal(t, "", pr.Base64) // empty PDF encodes to empty base64 +}