diff --git a/v3/go.mod b/v3/go.mod index 3067f25ff..fd4383baa 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -3,6 +3,7 @@ module github.com/wailsapp/wails/v3 go 1.24.0 require ( + git.sr.ht/~jackmordaunt/go-toast v1.1.2 github.com/Masterminds/semver v1.5.0 github.com/adrg/xdg v0.5.3 github.com/atterpac/refresh v0.8.4 diff --git a/v3/go.sum b/v3/go.sum index 672302267..faf9d7aff 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -8,6 +8,8 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= diff --git a/v3/pkg/services/notifications/notifications.go b/v3/pkg/services/notifications/notifications.go index 8f700daf5..5e0ea6910 100644 --- a/v3/pkg/services/notifications/notifications.go +++ b/v3/pkg/services/notifications/notifications.go @@ -1,22 +1,5 @@ -//go:build darwin - package notifications -/* -#cgo CFLAGS: -mmacosx-version-min=10.14 -x objective-c -#cgo LDFLAGS: -framework Cocoa -mmacosx-version-min=10.14 -framework UserNotifications -#import "./notifications_darwin.h" -*/ -import "C" -import ( - "context" - "encoding/json" - "fmt" - "unsafe" - - "github.com/wailsapp/wails/v3/pkg/application" -) - type Service struct { } @@ -47,158 +30,18 @@ type NotificationOptions = struct { Data map[string]interface{} `json:"data,omitempty"` } -func New() *Service { - return &Service{} +type NotificationResponseData = struct { + ID string `json:"id,omitempty"` + ActionIdentifier string `json:"actionIdentifier,omitempty"` + CategoryID string `json:"categoryIdentifier,omitempty"` + Title string `json:"title,omitempty"` + Subtitle string `json:"subtitle,omitempty"` + Body string `json:"body,omitempty"` + UserText string `json:"userText,omitempty"` + UserInfo map[string]interface{} `json:"userInfo,omitempty"` } -func CheckBundleIdentifier() bool { - return bool(C.checkBundleIdentifier()) -} - -// ServiceName returns the name of the service -func (ns *Service) ServiceName() string { - return "github.com/wailsapp/wails/v3/services/notifications" -} - -// ServiceStartup is called when the service is loaded -func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - return nil -} - -// ServiceShutdown is called when the service is unloaded -func (ns *Service) ServiceShutdown() error { - return nil -} - -// RequestUserNotificationAuthorization requests permission for notifications. -func (ns *Service) RequestUserNotificationAuthorization() (bool, error) { - if !CheckBundleIdentifier() { - return false, fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - result := C.requestUserNotificationAuthorization(nil) - return result == true, nil -} - -// CheckNotificationAuthorization checks current permission status -func (ns *Service) CheckNotificationAuthorization() (bool, error) { - if !CheckBundleIdentifier() { - return false, fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - return bool(C.checkNotificationAuthorization()), nil -} - -// SendNotification sends a notification with the given identifier, title, subtitle, and body. -func (ns *Service) SendNotification(identifier, title, subtitle, body string) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - cIdentifier := C.CString(identifier) - cTitle := C.CString(title) - cSubtitle := C.CString(subtitle) - cBody := C.CString(body) - defer C.free(unsafe.Pointer(cIdentifier)) - defer C.free(unsafe.Pointer(cTitle)) - defer C.free(unsafe.Pointer(cSubtitle)) - defer C.free(unsafe.Pointer(cBody)) - - C.sendNotification(cIdentifier, cTitle, cSubtitle, cBody, nil) - return nil -} - -// SendNotificationWithActions sends a notification with the specified actions -func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - cIdentifier := C.CString(options.ID) - cTitle := C.CString(options.Title) - cSubtitle := C.CString(options.Subtitle) - cBody := C.CString(options.Body) - cCategoryID := C.CString(options.CategoryID) - defer C.free(unsafe.Pointer(cIdentifier)) - defer C.free(unsafe.Pointer(cTitle)) - defer C.free(unsafe.Pointer(cSubtitle)) - defer C.free(unsafe.Pointer(cBody)) - defer C.free(unsafe.Pointer(cCategoryID)) - - var cActionsJSON *C.char - if options.Data != nil { - jsonData, err := json.Marshal(options.Data) - if err == nil { - cActionsJSON = C.CString(string(jsonData)) - defer C.free(unsafe.Pointer(cActionsJSON)) - } - } - - C.sendNotificationWithActions(cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cActionsJSON, nil) - return nil -} - -// RegisterNotificationCategory registers a category with actions and optional reply field -func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - cCategoryID := C.CString(category.ID) - defer C.free(unsafe.Pointer(cCategoryID)) - - actionsJSON, err := json.Marshal(category.Actions) - if err != nil { - return err - } - cActionsJSON := C.CString(string(actionsJSON)) - defer C.free(unsafe.Pointer(cActionsJSON)) - - var cReplyPlaceholder, cReplyButtonTitle *C.char - if category.HasReplyField { - cReplyPlaceholder = C.CString(category.ReplyPlaceholder) - cReplyButtonTitle = C.CString(category.ReplyButtonTitle) - defer C.free(unsafe.Pointer(cReplyPlaceholder)) - defer C.free(unsafe.Pointer(cReplyButtonTitle)) - } - - C.registerNotificationCategory(cCategoryID, cActionsJSON, C.bool(category.HasReplyField), - cReplyPlaceholder, cReplyButtonTitle) - return nil -} - -// RemoveAllPendingNotifications removes all pending notifications -func (ns *Service) RemoveAllPendingNotifications() error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - C.removeAllPendingNotifications() - return nil -} - -// RemovePendingNotification removes a specific pending notification -func (ns *Service) RemovePendingNotification(identifier string) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - cIdentifier := C.CString(identifier) - defer C.free(unsafe.Pointer(cIdentifier)) - C.removePendingNotification(cIdentifier) - return nil -} - -func (ns *Service) RemoveAllDeliveredNotifications() error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - C.removeAllDeliveredNotifications() - return nil -} - -func (ns *Service) RemoveDeliveredNotification(identifier string) error { - if !CheckBundleIdentifier() { - return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") - } - cIdentifier := C.CString(identifier) - defer C.free(unsafe.Pointer(cIdentifier)) - C.removeDeliveredNotification(cIdentifier) - return nil +type NotificationResponse = struct { + Name string `json:"name"` + Data NotificationResponseData `json:"data"` } diff --git a/v3/pkg/services/notifications/notifications_darwin.go b/v3/pkg/services/notifications/notifications_darwin.go new file mode 100644 index 000000000..5fc72b235 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_darwin.go @@ -0,0 +1,176 @@ +//go:build darwin + +package notifications + +/* +#cgo CFLAGS: -mmacosx-version-min=10.14 -x objective-c +#cgo LDFLAGS: -framework Cocoa -mmacosx-version-min=10.14 -framework UserNotifications +#import "./notifications_darwin.h" +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func New() *Service { + return &Service{} +} + +// ServiceName returns the name of the service +func (ns *Service) ServiceName() string { + return "github.com/wailsapp/wails/v3/services/notifications" +} + +// ServiceStartup is called when the service is loaded +func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + return nil +} + +// ServiceShutdown is called when the service is unloaded +func (ns *Service) ServiceShutdown() error { + return nil +} + +func CheckBundleIdentifier() bool { + return bool(C.checkBundleIdentifier()) +} + +// RequestUserNotificationAuthorization requests permission for notifications. +func (ns *Service) RequestUserNotificationAuthorization() (bool, error) { + if !CheckBundleIdentifier() { + return false, fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + result := C.requestUserNotificationAuthorization(nil) + return result == true, nil +} + +// CheckNotificationAuthorization checks current permission status +func (ns *Service) CheckNotificationAuthorization() (bool, error) { + if !CheckBundleIdentifier() { + return false, fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + return bool(C.checkNotificationAuthorization()), nil +} + +// SendNotification sends a notification with the given identifier, title, subtitle, and body. +func (ns *Service) SendNotification(identifier, title, subtitle, body string) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + cIdentifier := C.CString(identifier) + cTitle := C.CString(title) + cSubtitle := C.CString(subtitle) + cBody := C.CString(body) + defer C.free(unsafe.Pointer(cIdentifier)) + defer C.free(unsafe.Pointer(cTitle)) + defer C.free(unsafe.Pointer(cSubtitle)) + defer C.free(unsafe.Pointer(cBody)) + + C.sendNotification(cIdentifier, cTitle, cSubtitle, cBody, nil) + return nil +} + +// SendNotificationWithActions sends a notification with the specified actions +func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + cIdentifier := C.CString(options.ID) + cTitle := C.CString(options.Title) + cSubtitle := C.CString(options.Subtitle) + cBody := C.CString(options.Body) + cCategoryID := C.CString(options.CategoryID) + defer C.free(unsafe.Pointer(cIdentifier)) + defer C.free(unsafe.Pointer(cTitle)) + defer C.free(unsafe.Pointer(cSubtitle)) + defer C.free(unsafe.Pointer(cBody)) + defer C.free(unsafe.Pointer(cCategoryID)) + + var cActionsJSON *C.char + if options.Data != nil { + jsonData, err := json.Marshal(options.Data) + if err == nil { + cActionsJSON = C.CString(string(jsonData)) + defer C.free(unsafe.Pointer(cActionsJSON)) + } + } + + C.sendNotificationWithActions(cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cActionsJSON, nil) + return nil +} + +// RegisterNotificationCategory registers a category with actions and optional reply field +func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + cCategoryID := C.CString(category.ID) + defer C.free(unsafe.Pointer(cCategoryID)) + + actionsJSON, err := json.Marshal(category.Actions) + if err != nil { + return err + } + cActionsJSON := C.CString(string(actionsJSON)) + defer C.free(unsafe.Pointer(cActionsJSON)) + + var cReplyPlaceholder, cReplyButtonTitle *C.char + if category.HasReplyField { + cReplyPlaceholder = C.CString(category.ReplyPlaceholder) + cReplyButtonTitle = C.CString(category.ReplyButtonTitle) + defer C.free(unsafe.Pointer(cReplyPlaceholder)) + defer C.free(unsafe.Pointer(cReplyButtonTitle)) + } + + C.registerNotificationCategory(cCategoryID, cActionsJSON, C.bool(category.HasReplyField), + cReplyPlaceholder, cReplyButtonTitle) + return nil +} + +// RemoveAllPendingNotifications removes all pending notifications +func (ns *Service) RemoveAllPendingNotifications() error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + C.removeAllPendingNotifications() + return nil +} + +// RemovePendingNotification removes a specific pending notification +func (ns *Service) RemovePendingNotification(identifier string) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + cIdentifier := C.CString(identifier) + defer C.free(unsafe.Pointer(cIdentifier)) + C.removePendingNotification(cIdentifier) + return nil +} + +// RemoveAllDeliveredNotifications removes all delivered notifications +func (ns *Service) RemoveAllDeliveredNotifications() error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + C.removeAllDeliveredNotifications() + return nil +} + +// RemoveDeliveredNotification removes a specific delivered notification +func (ns *Service) RemoveDeliveredNotification(identifier string) error { + if !CheckBundleIdentifier() { + return fmt.Errorf("Notifications require a bundled application with a unique bundle identifier") + } + cIdentifier := C.CString(identifier) + defer C.free(unsafe.Pointer(cIdentifier)) + C.removeDeliveredNotification(cIdentifier) + return nil +} diff --git a/v3/pkg/services/notifications/notifications_darwin.m b/v3/pkg/services/notifications/notifications_darwin.m index f4e9b1404..f3eaec1a1 100644 --- a/v3/pkg/services/notifications/notifications_darwin.m +++ b/v3/pkg/services/notifications/notifications_darwin.m @@ -34,6 +34,10 @@ didReceiveNotificationResponse:(UNNotificationResponse *)response if (response.notification.request.content.categoryIdentifier) { [payload setObject:response.notification.request.content.categoryIdentifier forKey:@"categoryIdentifier"]; } + + if (response.notification.request.content.subtitle) { + [payload setObject:response.notification.request.content.subtitle forKey:@"subtitle"]; + } if (response.notification.request.content.userInfo) { [payload setObject:response.notification.request.content.userInfo forKey:@"userInfo"]; diff --git a/v3/pkg/services/notifications/notifications_windows.go b/v3/pkg/services/notifications/notifications_windows.go new file mode 100644 index 000000000..e942568d6 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_windows.go @@ -0,0 +1,150 @@ +//go:build windows + +package notifications + +import ( + "context" + + "git.sr.ht/~jackmordaunt/go-toast" + "github.com/wailsapp/wails/v3/pkg/application" +) + +var NotificationCategories map[string]NotificationCategory = make(map[string]NotificationCategory) + +func New() *Service { + return &Service{} +} + +// ServiceName returns the name of the service +func (ns *Service) ServiceName() string { + return "github.com/wailsapp/wails/v3/services/notifications" +} + +// ServiceStartup is called when the service is loaded +func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + toast.SetActivationCallback(func(args string, data []toast.UserData) { + response := NotificationResponse{ + Name: "notification", + Data: NotificationResponseData{ + ActionIdentifier: args, + }, + } + + if userText, found := getUserText(data); found { + response.Data.UserText = userText + } + + application.Get().EmitEvent("notificationResponse", response) + }) + return nil +} + +// ServiceShutdown is called when the service is unloaded +func (ns *Service) ServiceShutdown() error { + return nil +} + +// On Windows this does not apply, return true +func CheckBundleIdentifier() bool { + return true +} + +// On Windows this does not apply, return true +func (ns *Service) RequestUserNotificationAuthorization() (bool, error) { + return true, nil +} + +// On Windows this does not apply, return true +func (ns *Service) CheckNotificationAuthorization() bool { + return true +} + +func (ns *Service) SendNotification(identifier, title, _, body string) error { + n := toast.Notification{ + AppID: identifier, + Title: title, + Body: body, + } + + err := n.Push() + if err != nil { + return err + } + return nil +} + +func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { + nCategory := NotificationCategories[options.CategoryID] + + n := toast.Notification{ + AppID: options.ID, + Title: options.Title, + Body: options.Body, + } + + for _, action := range nCategory.Actions { + n.Actions = append(n.Actions, toast.Action{ + Content: action.Title, + Arguments: action.ID, + }) + } + + if nCategory.HasReplyField { + n.Inputs = append(n.Inputs, toast.Input{ + ID: "userText", + Title: nCategory.ReplyButtonTitle, + Placeholder: nCategory.ReplyPlaceholder, + }) + + n.Actions = append(n.Actions, toast.Action{ + Content: nCategory.ReplyButtonTitle, + }) + } + + err := n.Push() + if err != nil { + return err + } + return nil +} + +func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { + NotificationCategories[category.ID] = NotificationCategory{ + ID: category.ID, + Actions: category.Actions, + HasReplyField: bool(category.HasReplyField), + ReplyPlaceholder: category.ReplyPlaceholder, + ReplyButtonTitle: category.ReplyButtonTitle, + } + + return nil +} + +// RemoveAllPendingNotifications removes all pending notifications +func (ns *Service) RemoveAllPendingNotifications() error { + return nil +} + +// RemovePendingNotification removes a specific pending notification +func (ns *Service) RemovePendingNotification(_ string) error { + return nil +} + +// RemoveAllDeliveredNotifications removes all delivered notifications +func (ns *Service) RemoveAllDeliveredNotifications() error { + return nil +} + +// RemoveDeliveredNotification removes a specific delivered notification +func (ns *Service) RemoveDeliveredNotification(_ string) error { + return nil +} + +func getUserText(data []toast.UserData) (string, bool) { + for _, d := range data { + if d.Key == "userText" { + return d.Value, true + } + } + return "", false +}