Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions go/display/webkit/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
node_modules.bak/
83 changes: 42 additions & 41 deletions go/display/webkit/pkg/keybinding/wails.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,45 @@
package keybinding

import (
"sync"

core "dappco.re/go"
"github.com/wailsapp/wails/v3/pkg/application"
)

// WailsPlatform implements Platform via Wails v3's KeyBindingManager.
//
// Wails owns one global key-binding map keyed by accelerator string;
// when a window receives a key event matching a binding, the registered
// callback fires with that window as its argument. Our gui Platform
// passes a callback that takes no window — we wrap it to ignore the
// argument so the consumer doesn't need to know about Wails Windows.
// WailsPlatform implements Platform via Wails v3's GlobalShortcutManager.
//
// Boot-time bindings configured via application.Options.KeyBindings
// (e.g. ⌘R reload in core-ide's wails_boot.go) live in the same map,
// so this adapter can replace them at runtime — last writer wins.
// Global shortcuts are registered with the operating system and fire even
// when no Wails window is focused. The local callback map preserves the
// adapter's Process method for deterministic tests and command dispatch.
//
// wp := keybinding.NewWailsPlatform(app)
// core.WithService(keybinding.Register(wp))
type WailsPlatform struct {
app *application.App
app *application.App
mu sync.RWMutex
handlers map[string]func()
}

func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
return &WailsPlatform{app: app, handlers: make(map[string]func())}
}

// Add registers a global accelerator. Wails accelerator syntax matches
// Add registers a system-wide accelerator. Wails accelerator syntax matches
// our doc'd format ("Cmd+S" / "Ctrl+S" / "Shift+F1" / "Cmd+Alt+I" etc.).
func (wp *WailsPlatform) Add(accelerator string, handler func()) resultFailure {
if wp == nil || wp.app == nil || handler == nil {
return nil
}
wp.app.KeyBinding.Add(accelerator, func(_ application.Window) {
handler()
})
if wp.app.GlobalShortcut == nil {
return core.E("keybinding.WailsPlatform.Add", "Wails global shortcut manager is unavailable", nil)
}
if err := wp.app.GlobalShortcut.Register(accelerator, handler); err != nil {
return core.E("keybinding.WailsPlatform.Add", "failed to register global shortcut", err)
}
wp.mu.Lock()
wp.handlers[accelerator] = handler
wp.mu.Unlock()
return nil
}

Expand All @@ -44,41 +49,37 @@ func (wp *WailsPlatform) Remove(accelerator string) resultFailure {
if wp == nil || wp.app == nil {
return nil
}
wp.app.KeyBinding.Remove(accelerator)
if wp.app.GlobalShortcut == nil {
return core.E("keybinding.WailsPlatform.Remove", "Wails global shortcut manager is unavailable", nil)
}
if err := wp.app.GlobalShortcut.Unregister(accelerator); err != nil {
return core.E("keybinding.WailsPlatform.Remove", "failed to unregister global shortcut", err)
}
wp.mu.Lock()
delete(wp.handlers, accelerator)
wp.mu.Unlock()
return nil
}

// Process triggers the registered handler programmatically. Wails
// requires a Window argument so its callbacks can know the source —
// our wrapped callback ignores it; we still pass the currently focused
// window when possible so any future Wails-aware handlers (e.g. boot-
// time bindings sharing the same map) get the expected context.
// Process triggers the registered handler programmatically.
func (wp *WailsPlatform) Process(accelerator string) bool {
if wp == nil || wp.app == nil {
if wp == nil {
return false
}
var w application.Window
if wp.app.Window != nil {
w = wp.app.Window.Current()
wp.mu.RLock()
handler, ok := wp.handlers[accelerator]
wp.mu.RUnlock()
if !ok || handler == nil {
return false
}
return wp.app.KeyBinding.Process(accelerator, w)
handler()
return true
}

// GetAll returns just the accelerator strings — the gui Platform
// contract is intentionally narrower than Wails's *KeyBinding (which
// also exposes the callback) because the callback is opaque to the
// consumer / MCP surface.
// GetAll returns Wails's canonical, sorted accelerator strings.
func (wp *WailsPlatform) GetAll() []string {
if wp == nil || wp.app == nil {
if wp == nil || wp.app == nil || wp.app.GlobalShortcut == nil {
return nil
}
all := wp.app.KeyBinding.GetAll()
out := make([]string, 0, len(all))
for _, kb := range all {
if kb == nil {
continue
}
out = append(out, kb.Accelerator)
}
return out
return wp.app.GlobalShortcut.GetAll()
}
1 change: 1 addition & 0 deletions go/display/webkit/pkg/notification/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ActionNotificationClicked struct{ ID string }
type ActionNotificationActionTriggered struct {
NotificationID string `json:"notificationId"`
ActionID string `json:"actionId"`
UserText string `json:"userText,omitempty"`
}

// ActionNotificationDismissed is broadcast when the user dismisses a notification.
Expand Down
38 changes: 36 additions & 2 deletions go/display/webkit/pkg/notification/platform.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
Clear(id string) resultFailure
}

type updatePlatform interface {

Check warning on line 18 in go/display/webkit/pkg/notification/platform.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this interface to follow Go naming conventions for single-method interfaces.

See more on https://sonarcloud.io/project/issues?id=dAppCore_go-html&issues=AZ-QIF4r_95_XqQUHx4G&open=AZ-QIF4r_95_XqQUHx4G&pullRequest=39
Update(options NotificationOptions) resultFailure
}

type categoryPlatform interface {

Check warning on line 22 in go/display/webkit/pkg/notification/platform.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this interface to follow Go naming conventions for single-method interfaces.

See more on https://sonarcloud.io/project/issues?id=dAppCore_go-html&issues=AZ-QIF4r_95_XqQUHx4H&open=AZ-QIF4r_95_XqQUHx4H&pullRequest=39
RegisterCategory(category NotificationCategory) resultFailure
}

type responsePlatform interface {

Check warning on line 26 in go/display/webkit/pkg/notification/platform.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this interface to follow Go naming conventions for single-method interfaces.

See more on https://sonarcloud.io/project/issues?id=dAppCore_go-html&issues=AZ-QIF4r_95_XqQUHx4I&open=AZ-QIF4r_95_XqQUHx4I&pullRequest=39
OnResponse(callback func(notificationID, actionID, userText string))
}

// NotificationSeverity indicates the severity for dialog fallback.
type NotificationSeverity int

Expand All @@ -35,8 +47,11 @@
// 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"`
ID string `json:"id"`
Actions []NotificationAction `json:"actions,omitempty"`
HasReplyField bool `json:"hasReplyField,omitempty"`
ReplyPlaceholder string `json:"replyPlaceholder,omitempty"`
ReplyButtonTitle string `json:"replyButtonTitle,omitempty"`
}

// NotificationOptions contains options for sending a notification.
Expand All @@ -48,6 +63,25 @@
Severity NotificationSeverity `json:"severity,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
Data map[string]any `json:"data,omitempty"`
// Silent suppresses the delivery sound. SoundName selects a
// platform sound when Silent is false.
Silent bool `json:"silent,omitempty"`
SoundName string `json:"soundName,omitempty"`
// AttachmentPaths contains absolute media paths. Wails uses the
// first image on Windows/Linux and supports multiple attachments
// on macOS.
AttachmentPaths []string `json:"attachmentPaths,omitempty"`
ThreadID string `json:"threadId,omitempty"`
// InterruptionLevel is passive, active, timeSensitive, or critical.
InterruptionLevel string `json:"interruptionLevel,omitempty"`
// ScheduleDelaySeconds and ScheduleAtUnix are mutually exclusive.
// Windows/Linux schedules are in-process; macOS schedules natively.
ScheduleDelaySeconds int `json:"scheduleDelaySeconds,omitempty"`
ScheduleAtUnix int64 `json:"scheduleAtUnix,omitempty"`
// Update replaces an in-flight notification with the same ID where
// the platform supports replacement.
Update bool `json:"update,omitempty"`
}

// PermissionStatus indicates whether notifications are authorised.
Expand Down
55 changes: 45 additions & 10 deletions go/display/webkit/pkg/notification/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@

func (s *Service) OnStartup(_ context.Context) core.Result {
s.Core().RegisterQuery(s.handleQuery)
if platform, ok := s.platform.(responsePlatform); ok {
platform.OnResponse(s.handleResponse)
}
send := func(_ context.Context, opts core.Options) core.Result {
options, err := notificationOptionsFrom(opts)
if err != nil {
Expand All @@ -56,16 +59,12 @@
s.Core().Action("gui.notification.revokePermission", func(_ context.Context, _ core.Options) core.Result {
return core.Result{Value: nil, OK: true}.New(s.platform.RevokePermission())
})
s.Core().Action("notification.registerCategory", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskRegisterCategory)
s.categories[t.Category.ID] = t.Category
return core.Result{OK: true}
})
s.Core().Action("gui.notification.registerCategory", func(_ context.Context, opts core.Options) core.Result {
registerCategory := func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskRegisterCategory)
s.categories[t.Category.ID] = t.Category
return core.Result{OK: true}
})
return core.Result{Value: nil, OK: true}.New(s.registerCategory(t.Category))
}
s.Core().Action("notification.registerCategory", registerCategory)

Check failure on line 66 in go/display/webkit/pkg/notification/service.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "notification.registerCategory" 3 times.

See more on https://sonarcloud.io/project/issues?id=dAppCore_go-html&issues=AZ-QIF4W_95_XqQUHx4F&open=AZ-QIF4W_95_XqQUHx4F&pullRequest=39
s.Core().Action("gui.notification.registerCategory", registerCategory)
s.Core().Action("notification.clear", func(_ context.Context, opts core.Options) core.Result {
t, _ := opts.Get("task").Value.(TaskClear)
return core.Result{Value: nil, OK: true}.New(s.clear(t.ID))
Expand All @@ -74,7 +73,7 @@
t, _ := opts.Get("task").Value.(TaskClear)
return core.Result{Value: nil, OK: true}.New(s.clear(t.ID))
})
s.Core().Action("notification.send", send)

Check failure on line 76 in go/display/webkit/pkg/notification/service.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "notification.send" 3 times.

See more on https://sonarcloud.io/project/issues?id=dAppCore_go-html&issues=AZ-QIF4W_95_XqQUHx4E&open=AZ-QIF4W_95_XqQUHx4E&pullRequest=39
s.Core().Action("gui.notification.send", send)
return core.Result{OK: true}
}
Expand Down Expand Up @@ -104,7 +103,15 @@
}
options = s.applyCategoryActions(options)

if err := s.platform.Send(options); err != nil {
if options.Update {
platform, ok := s.platform.(updatePlatform)
if !ok {
return core.E("notification.send", "platform does not support notification updates", nil)
}
if err := platform.Update(options); err != nil {
return core.E("notification.send", "native notification update failed", err)
}
} else if err := s.platform.Send(options); err != nil {
// Fallback: show as dialog via IPC
if err := s.fallbackDialog(options); err != nil {
return err
Expand All @@ -116,6 +123,34 @@
return nil
}

func (s *Service) registerCategory(category NotificationCategory) resultFailure {
if category.ID == "" {
return core.E("notification.registerCategory", "category ID is required", nil)
}
if platform, ok := s.platform.(categoryPlatform); ok {
if err := platform.RegisterCategory(category); err != nil {
return core.E("notification.registerCategory", "native category registration failed", err)
}
}
s.mu.Lock()
s.categories[category.ID] = category
s.mu.Unlock()
return nil
}

func (s *Service) handleResponse(notificationID, actionID, userText string) {
switch actionID {
case "", "DEFAULT_ACTION":
coreutil.DispatchAction(s.Core(), "notification.click", ActionNotificationClicked{ID: notificationID})
default:
coreutil.DispatchAction(s.Core(), "notification.action", ActionNotificationActionTriggered{
NotificationID: notificationID,
ActionID: actionID,
UserText: userText,
})
}
}

func (s *Service) applyCategoryActions(options NotificationOptions) NotificationOptions {
if options.CategoryID == "" || len(options.Actions) > 0 {
return options
Expand Down
23 changes: 23 additions & 0 deletions go/display/webkit/pkg/notification/service_behaviour_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,26 @@ func TestServiceBehaviour_notificationOptionsFrom_Good(t *core.T) {
core.AssertNil(t, err)
core.AssertEqual(t, "B", fromOpts.Title)
}

func TestServiceBehaviour_toWailsNotificationOptions_Good(t *core.T) {
got := toWailsNotificationOptions(NotificationOptions{
ID: "n-7",
Title: "Ready",
Message: "Done",
Silent: true,
SoundName: "Ping",
AttachmentPaths: []string{"/tmp/result.png"},
ThreadID: "jobs",
InterruptionLevel: "active",
ScheduleDelaySeconds: 15,
})

core.AssertEqual(t, "n-7", got.ID)
core.RequireTrue(t, got.Sound != nil)
core.AssertTrue(t, got.Sound.Silent)
core.AssertEqual(t, "Ping", got.Sound.Name)
core.AssertLen(t, got.Attachments, 1)
core.AssertEqual(t, "/tmp/result.png", got.Attachments[0].Path)
core.RequireTrue(t, got.Schedule != nil)
core.AssertEqual(t, 15, got.Schedule.DelaySeconds)
}
55 changes: 55 additions & 0 deletions go/display/webkit/pkg/notification/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type mockPlatform struct {
clearID string
lastOpts NotificationOptions
sendCalled bool
updateCalled bool
category NotificationCategory
response func(notificationID, actionID, userText string)
}

func (m *mockPlatform) Send(opts NotificationOptions) resultFailure {
Expand All @@ -37,6 +40,18 @@ func (m *mockPlatform) Clear(id string) resultFailure {
m.clearID = id
return m.clearErr
}
func (m *mockPlatform) Update(opts NotificationOptions) resultFailure {
m.updateCalled = true
m.lastOpts = opts
return m.sendErr
}
func (m *mockPlatform) RegisterCategory(category NotificationCategory) resultFailure {
m.category = category
return nil
}
func (m *mockPlatform) OnResponse(callback func(notificationID, actionID, userText string)) {
m.response = callback
}

// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
type mockDialogPlatform struct {
Expand Down Expand Up @@ -217,6 +232,46 @@ func TestTaskSend_WithActions_GoodCase(t *core.T) {
core.AssertEqual(t, 2, len(mock.lastOpts.Actions))
}

func TestTaskSend_CurrentWailsNotificationFeatures_Good(t *core.T) {
mock, c := newTestService(t)
options := NotificationOptions{
ID: "build-7",
Title: "Build complete",
Message: "The application is ready.",
Silent: true,
AttachmentPaths: []string{"/tmp/build.png"},
ThreadID: "builds",
InterruptionLevel: "timeSensitive",
ScheduleDelaySeconds: 5,
Update: true,
}

r := taskRun(c, "notification.send", TaskSend{Options: options})

core.RequireTrue(t, r.OK)
core.AssertTrue(t, mock.updateCalled)
core.AssertEqual(t, options.AttachmentPaths, mock.lastOpts.AttachmentPaths)
core.AssertEqual(t, "timeSensitive", mock.lastOpts.InterruptionLevel)
}

func TestNotificationResponse_CurrentWailsFeatures_Good(t *core.T) {
mock, c := newTestService(t)
var triggered ActionNotificationActionTriggered
c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result {
if action, ok := msg.(ActionNotificationActionTriggered); ok {
triggered = action
}
return core.Result{OK: true}
})

core.RequireTrue(t, mock.response != nil)
mock.response("message-1", "reply", "On my way")

core.AssertEqual(t, "message-1", triggered.NotificationID)
core.AssertEqual(t, "reply", triggered.ActionID)
core.AssertEqual(t, "On my way", triggered.UserText)
}

func TestTaskSend_RegisteredCategoryActions_GoodCase(t *core.T) {
mock, c := newTestService(t)
core.RequireTrue(t, taskRun(c, "notification.registerCategory", TaskRegisterCategory{
Expand Down
Loading
Loading