From 88f13369b695bd9696ddcafdcb32cad1ccf4aba9 Mon Sep 17 00:00:00 2001 From: popaprozac Date: Thu, 20 Mar 2025 13:47:03 -0700 Subject: [PATCH] reorg platform impl --- .../services/notifications/notifications.go | 99 ++++++++- .../notifications/notifications_darwin.go | 121 ++++++---- .../notifications/notifications_linux.go | 208 +++++++++--------- .../notifications/notifications_windows.go | 152 ++++++------- 4 files changed, 348 insertions(+), 232 deletions(-) diff --git a/v3/pkg/services/notifications/notifications.go b/v3/pkg/services/notifications/notifications.go index 297a21059..bfa0b4489 100644 --- a/v3/pkg/services/notifications/notifications.go +++ b/v3/pkg/services/notifications/notifications.go @@ -13,12 +13,38 @@ package notifications import ( + "context" "fmt" "sync" ) +type notificationImpl interface { + // Lifecycle methods + Startup(ctx context.Context) error + Shutdown() error + + // Core notification methods + RequestNotificationAuthorization() (bool, error) + CheckNotificationAuthorization() (bool, error) + SendNotification(options NotificationOptions) error + SendNotificationWithActions(options NotificationOptions) error + + // Category management + RegisterNotificationCategory(category NotificationCategory) error + RemoveNotificationCategory(categoryID string) error + + // Notification management + RemoveAllPendingNotifications() error + RemovePendingNotification(identifier string) error + RemoveAllDeliveredNotifications() error + RemoveDeliveredNotification(identifier string) error + RemoveNotification(identifier string) error +} + // Service represents the notifications service type Service struct { + impl notificationImpl + // notificationResponseCallback is called when a notification result is received. // Only one callback can be assigned at a time. notificationResultCallback func(result NotificationResult) @@ -83,12 +109,6 @@ func (ns *Service) ServiceName() string { return "github.com/wailsapp/wails/v3/services/notifications" } -func getNotificationService() *Service { - notificationServiceLock.RLock() - defer notificationServiceLock.RUnlock() - return NotificationService -} - // OnNotificationResponse registers a callback function that will be called when // a notification response is received from the user. // @@ -112,6 +132,73 @@ func (ns *Service) handleNotificationResult(result NotificationResult) { } } +// ServiceStartup is called when the service is loaded +func (ns *Service) ServiceStartup(ctx context.Context, options interface{}) error { + return ns.impl.Startup(ctx) +} + +// ServiceShutdown is called when the service is unloaded +func (ns *Service) ServiceShutdown() error { + return ns.impl.Shutdown() +} + +// Public methods that delegate to the implementation +func (ns *Service) RequestNotificationAuthorization() (bool, error) { + return ns.impl.RequestNotificationAuthorization() +} + +func (ns *Service) CheckNotificationAuthorization() (bool, error) { + return ns.impl.CheckNotificationAuthorization() +} + +func (ns *Service) SendNotification(options NotificationOptions) error { + if err := validateNotificationOptions(options); err != nil { + return err + } + return ns.impl.SendNotification(options) +} + +func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { + if err := validateNotificationOptions(options); err != nil { + return err + } + return ns.impl.SendNotificationWithActions(options) +} + +func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { + return ns.impl.RegisterNotificationCategory(category) +} + +func (ns *Service) RemoveNotificationCategory(categoryID string) error { + return ns.impl.RemoveNotificationCategory(categoryID) +} + +func (ns *Service) RemoveAllPendingNotifications() error { + return ns.impl.RemoveAllPendingNotifications() +} + +func (ns *Service) RemovePendingNotification(identifier string) error { + return ns.impl.RemovePendingNotification(identifier) +} + +func (ns *Service) RemoveAllDeliveredNotifications() error { + return ns.impl.RemoveAllDeliveredNotifications() +} + +func (ns *Service) RemoveDeliveredNotification(identifier string) error { + return ns.impl.RemoveDeliveredNotification(identifier) +} + +func (ns *Service) RemoveNotification(identifier string) error { + return ns.impl.RemoveNotification(identifier) +} + +func getNotificationService() *Service { + notificationServiceLock.RLock() + defer notificationServiceLock.RUnlock() + return NotificationService +} + // validateNotificationOptions validates an ID and Title are provided for notifications func validateNotificationOptions(options NotificationOptions) error { if options.ID == "" { diff --git a/v3/pkg/services/notifications/notifications_darwin.go b/v3/pkg/services/notifications/notifications_darwin.go index 05ca217c7..0be1fd10c 100644 --- a/v3/pkg/services/notifications/notifications_darwin.go +++ b/v3/pkg/services/notifications/notifications_darwin.go @@ -17,17 +17,17 @@ import ( "unsafe" ) +type darwinNotifier struct { + channels map[int]chan notificationChannel + channelsLock sync.Mutex + nextChannelID int +} + type notificationChannel struct { Success bool Error error } -var ( - notificationChannels = make(map[int]chan notificationChannel) - notificationChannelsLock sync.Mutex - nextChannelID int -) - const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier" // Creates a new Notifications Service. @@ -43,25 +43,38 @@ func New() *Service { " 3. Run the signed .app bundle") } - if NotificationService == nil { - NotificationService = &Service{} + impl := &darwinNotifier{ + channels: make(map[int]chan notificationChannel), + nextChannelID: 0, + } + + NotificationService = &Service{ + impl: impl, } }) return NotificationService } +func (dn *darwinNotifier) Startup(ctx context.Context) error { + return nil +} + +func (dn *darwinNotifier) Shutdown() error { + return nil +} + func CheckBundleIdentifier() bool { return bool(C.checkBundleIdentifier()) } // RequestNotificationAuthorization requests permission for notifications. // Default timeout is 15 minutes -func (ns *Service) RequestNotificationAuthorization() (bool, error) { +func (dn *darwinNotifier) RequestNotificationAuthorization() (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*900) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() C.requestNotificationAuthorization(C.int(id)) @@ -69,17 +82,17 @@ func (ns *Service) RequestNotificationAuthorization() (bool, error) { case result := <-resultCh: return result.Success, result.Error case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return false, fmt.Errorf("notification authorization timed out after 15 minutes: %w", ctx.Err()) } } // CheckNotificationAuthorization checks current notification permission status. -func (ns *Service) CheckNotificationAuthorization() (bool, error) { +func (dn *darwinNotifier) CheckNotificationAuthorization() (bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() C.checkNotificationAuthorization(C.int(id)) @@ -87,13 +100,13 @@ func (ns *Service) CheckNotificationAuthorization() (bool, error) { case result := <-resultCh: return result.Success, result.Error case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return false, fmt.Errorf("notification authorization timed out after 15s: %w", ctx.Err()) } } // SendNotification sends a basic notification with a unique identifier, title, subtitle, and body. -func (ns *Service) SendNotification(options NotificationOptions) error { +func (dn *darwinNotifier) SendNotification(options NotificationOptions) error { if err := validateNotificationOptions(options); err != nil { return err } @@ -101,7 +114,7 @@ func (ns *Service) SendNotification(options NotificationOptions) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() cIdentifier := C.CString(options.ID) cTitle := C.CString(options.Title) @@ -134,7 +147,7 @@ func (ns *Service) SendNotification(options NotificationOptions) error { } return nil case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return fmt.Errorf("sending notification timed out: %w", ctx.Err()) } } @@ -142,7 +155,7 @@ func (ns *Service) SendNotification(options NotificationOptions) error { // SendNotificationWithActions sends a notification with additional actions and inputs. // A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category. // If a NotificationCategory is not registered a basic notification will be sent. -func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { +func (dn *darwinNotifier) SendNotificationWithActions(options NotificationOptions) error { if err := validateNotificationOptions(options); err != nil { return err } @@ -150,7 +163,7 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() cIdentifier := C.CString(options.ID) cTitle := C.CString(options.Title) @@ -184,18 +197,18 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro } return nil case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return fmt.Errorf("sending notification timed out: %w", ctx.Err()) } } // RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions. // Registering a category with the same name as a previously registered NotificationCategory will override it. -func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { +func (dn *darwinNotifier) RegisterNotificationCategory(category NotificationCategory) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() cCategoryID := C.CString(category.ID) defer C.free(unsafe.Pointer(cCategoryID)) @@ -228,17 +241,17 @@ func (ns *Service) RegisterNotificationCategory(category NotificationCategory) e } return nil case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return fmt.Errorf("category registration timed out: %w", ctx.Err()) } } // RemoveNotificationCategory remove a previously registered NotificationCategory. -func (ns *Service) RemoveNotificationCategory(categoryId string) error { +func (dn *darwinNotifier) RemoveNotificationCategory(categoryId string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - id, resultCh := registerChannel() + id, resultCh := dn.registerChannel() cCategoryID := C.CString(categoryId) defer C.free(unsafe.Pointer(cCategoryID)) @@ -255,19 +268,19 @@ func (ns *Service) RemoveNotificationCategory(categoryId string) error { } return nil case <-ctx.Done(): - cleanupChannel(id) + dn.cleanupChannel(id) return fmt.Errorf("category removal timed out: %w", ctx.Err()) } } // RemoveAllPendingNotifications removes all pending notifications. -func (ns *Service) RemoveAllPendingNotifications() error { +func (dn *darwinNotifier) RemoveAllPendingNotifications() error { C.removeAllPendingNotifications() return nil } // RemovePendingNotification removes a pending notification matching the unique identifier. -func (ns *Service) RemovePendingNotification(identifier string) error { +func (dn *darwinNotifier) RemovePendingNotification(identifier string) error { cIdentifier := C.CString(identifier) defer C.free(unsafe.Pointer(cIdentifier)) C.removePendingNotification(cIdentifier) @@ -275,13 +288,13 @@ func (ns *Service) RemovePendingNotification(identifier string) error { } // RemoveAllDeliveredNotifications removes all delivered notifications. -func (ns *Service) RemoveAllDeliveredNotifications() error { +func (dn *darwinNotifier) RemoveAllDeliveredNotifications() error { C.removeAllDeliveredNotifications() return nil } // RemoveDeliveredNotification removes a delivered notification matching the unique identifier. -func (ns *Service) RemoveDeliveredNotification(identifier string) error { +func (dn *darwinNotifier) RemoveDeliveredNotification(identifier string) error { cIdentifier := C.CString(identifier) defer C.free(unsafe.Pointer(cIdentifier)) C.removeDeliveredNotification(cIdentifier) @@ -295,13 +308,23 @@ func (ns *Service) RemoveDeliveredNotification(identifier string) error { // RemoveAllDeliveredNotifications // RemoveDeliveredNotification // (Linux-specific) -func (ns *Service) RemoveNotification(identifier string) error { +func (dn *darwinNotifier) RemoveNotification(identifier string) error { return nil } //export captureResult func captureResult(channelID C.int, success C.bool, errorMsg *C.char) { - resultCh, exists := getChannel(int(channelID)) + ns := getNotificationService() + if ns != nil { + return + } + + dn, ok := ns.impl.(*darwinNotifier) + if !ok { + return + } + + resultCh, exists := dn.getChannel(int(channelID)) if !exists { return } @@ -361,36 +384,38 @@ func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) { } } -func registerChannel() (int, chan notificationChannel) { - notificationChannelsLock.Lock() - defer notificationChannelsLock.Unlock() +// Helper methods - id := nextChannelID - nextChannelID++ +func (dn *darwinNotifier) registerChannel() (int, chan notificationChannel) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() + + id := dn.nextChannelID + dn.nextChannelID++ resultCh := make(chan notificationChannel, 1) - notificationChannels[id] = resultCh + dn.channels[id] = resultCh return id, resultCh } -func getChannel(id int) (chan notificationChannel, bool) { - notificationChannelsLock.Lock() - defer notificationChannelsLock.Unlock() +func (dn *darwinNotifier) getChannel(id int) (chan notificationChannel, bool) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() - ch, exists := notificationChannels[id] + ch, exists := dn.channels[id] if exists { - delete(notificationChannels, id) + delete(dn.channels, id) } return ch, exists } -func cleanupChannel(id int) { - notificationChannelsLock.Lock() - defer notificationChannelsLock.Unlock() +func (dn *darwinNotifier) cleanupChannel(id int) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() - if ch, exists := notificationChannels[id]; exists { - delete(notificationChannels, id) + if ch, exists := dn.channels[id]; exists { + delete(dn.channels, id) close(ch) } } diff --git a/v3/pkg/services/notifications/notifications_linux.go b/v3/pkg/services/notifications/notifications_linux.go index 54b0d2a99..712b93868 100644 --- a/v3/pkg/services/notifications/notifications_linux.go +++ b/v3/pkg/services/notifications/notifications_linux.go @@ -16,6 +16,15 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) +type linuxNotifier struct { + categories map[string]NotificationCategory + categoriesLock sync.RWMutex + appName string + internal *internalNotifier + notificationInitErr error + initOnce sync.Once +} + const ( dbusObjectPath = "/org/freedesktop/Notifications" dbusNotificationsInterface = "org.freedesktop.Notifications" @@ -31,32 +40,8 @@ const ( notifyChannelBufferSize = 25 ) -var ( - notificationLock sync.RWMutex - notificationCategories = make(map[string]NotificationCategory) - appName string - initOnce sync.Once -) - type closedReason uint32 -func (r closedReason) string() string { - switch r { - case 1: - return "expired" - case 2: - return "dismissed-by-user" - case 3: - return "closed-by-call" - case 4: - return "unknown" - case 5: - return "activated-by-user" - default: - return "other" - } -} - // internalNotifier handles the actual notification sending via dbus or command line type notificationContext struct { ID string @@ -77,34 +62,36 @@ type internalNotifier struct { listenerRunning bool } -var notifier *internalNotifier - // New creates a new Notifications Service func New() *Service { notificationServiceOnce.Do(func() { - if NotificationService == nil { - NotificationService = &Service{} + impl := &linuxNotifier{ + categories: make(map[string]NotificationCategory), + } + + NotificationService = &Service{ + impl: impl, } }) return NotificationService } -// ServiceStartup is called when the service is loaded -func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - appName = application.Get().Config().Name +// Startup is called when the service is loaded +func (ls *linuxNotifier) Startup(ctx context.Context) error { + ls.appName = application.Get().Config().Name - if err := loadCategories(); err != nil { + if err := ls.loadCategories(); err != nil { fmt.Printf("Failed to load notification categories: %v\n", err) } - notifier = &internalNotifier{ + ls.internal = &internalNotifier{ activeNotifs: make(map[string]uint32), contexts: make(map[string]*notificationContext), } var err error - initOnce.Do(func() { - err = notifier.init() + ls.initOnce.Do(func() { + err = ls.internal.init() }) return err @@ -133,12 +120,12 @@ func (n *internalNotifier) shutdown() { n.sendPath = "" } -// ServiceShutdown is called when the service is unloaded -func (ns *Service) ServiceShutdown() error { - if notifier != nil { - notifier.shutdown() +// Shutdown is called when the service is unloaded +func (ls *linuxNotifier) Shutdown() error { + if ls.internal != nil { + ls.internal.shutdown() } - return saveCategories() + return ls.saveCategories() } // Initialize the notifier and choose the best available notification method @@ -395,25 +382,25 @@ func (n *internalNotifier) handleActionInvoked(systemID uint32, actionKey string // CheckBundleIdentifier is a Linux stub that always returns true. // (bundle identifiers are macOS-specific) -func (ns *Service) CheckBundleIdentifier() bool { +func (ls *linuxNotifier) CheckBundleIdentifier() bool { return true } // RequestNotificationAuthorization is a Linux stub that always returns true. // (user authorization is macOS-specific) -func (ns *Service) RequestNotificationAuthorization() (bool, error) { +func (ls *linuxNotifier) RequestNotificationAuthorization() (bool, error) { return true, nil } // CheckNotificationAuthorization is a Linux stub that always returns true. // (user authorization is macOS-specific) -func (ns *Service) CheckNotificationAuthorization() (bool, error) { +func (ls *linuxNotifier) CheckNotificationAuthorization() (bool, error) { return true, nil } // SendNotification sends a basic notification with a unique identifier, title, subtitle, and body. -func (ns *Service) SendNotification(options NotificationOptions) error { - if notifier == nil { +func (ls *linuxNotifier) SendNotification(options NotificationOptions) error { + if ls.internal == nil { return errors.New("notification service not initialized") } @@ -421,26 +408,26 @@ func (ns *Service) SendNotification(options NotificationOptions) error { return err } - notifier.Lock() - defer notifier.Unlock() + ls.internal.Lock() + defer ls.internal.Unlock() var ( systemID uint32 err error ) - switch notifier.method { + switch ls.internal.method { case MethodDbus: - systemID, err = notifier.sendViaDbus(options, nil) + systemID, err = ls.sendViaDbus(options, nil) case MethodNotifySend: - systemID, err = notifier.sendViaNotifySend(options) + systemID, err = ls.sendViaNotifySend(options) default: err = errors.New("no notification method is available") } if err == nil && systemID > 0 { // Store the system ID mapping - notifier.activeNotifs[options.ID] = systemID + ls.internal.activeNotifs[options.ID] = systemID // Create and store the notification context ctx := ¬ificationContext{ @@ -448,15 +435,15 @@ func (ns *Service) SendNotification(options NotificationOptions) error { SystemID: systemID, UserData: options.Data, } - notifier.contexts[options.ID] = ctx + ls.internal.contexts[options.ID] = ctx } return err } // SendNotificationWithActions sends a notification with additional actions. -func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { - if notifier == nil { +func (ls *linuxNotifier) SendNotificationWithActions(options NotificationOptions) error { + if ls.internal == nil { return errors.New("notification service not initialized") } @@ -464,35 +451,35 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro return err } - notificationLock.RLock() - category, exists := notificationCategories[options.CategoryID] - notificationLock.RUnlock() + ls.categoriesLock.RLock() + category, exists := ls.categories[options.CategoryID] + ls.categoriesLock.RUnlock() if !exists { - return ns.SendNotification(options) + return ls.SendNotification(options) } - notifier.Lock() - defer notifier.Unlock() + ls.internal.Lock() + defer ls.internal.Unlock() var ( systemID uint32 err error ) - switch notifier.method { + switch ls.internal.method { case MethodDbus: - systemID, err = notifier.sendViaDbus(options, &category) + systemID, err = ls.sendViaDbus(options, &category) case MethodNotifySend: // notify-send doesn't support actions, fall back to basic notification - systemID, err = notifier.sendViaNotifySend(options) + systemID, err = ls.sendViaNotifySend(options) default: err = errors.New("no notification method is available") } if err == nil && systemID > 0 { // Store the system ID mapping - notifier.activeNotifs[options.ID] = systemID + ls.internal.activeNotifs[options.ID] = systemID // Create and store the notification context with actions ctx := ¬ificationContext{ @@ -509,14 +496,14 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro } } - notifier.contexts[options.ID] = ctx + ls.internal.contexts[options.ID] = ctx } return err } // sendViaDbus sends a notification via dbus -func (n *internalNotifier) sendViaDbus(options NotificationOptions, category *NotificationCategory) (result uint32, err error) { +func (ls *linuxNotifier) sendViaDbus(options NotificationOptions, category *NotificationCategory) (result uint32, err error) { // Prepare actions var actions []string if category != nil { @@ -542,9 +529,9 @@ func (n *internalNotifier) sendViaDbus(options NotificationOptions, category *No } // Send the notification - obj := n.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) + obj := ls.internal.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) dbusArgs := []interface{}{ - appName, // App name + ls.appName, // App name uint32(0), // Replaces ID (0 means new notification) "", // App icon (empty for now) options.Title, // Title @@ -568,7 +555,7 @@ func (n *internalNotifier) sendViaDbus(options NotificationOptions, category *No } // sendViaNotifySend sends a notification via notify-send command -func (n *internalNotifier) sendViaNotifySend(options NotificationOptions) (uint32, error) { +func (ls *linuxNotifier) sendViaNotifySend(options NotificationOptions) (uint32, error) { args := []string{ options.Title, options.Body, @@ -581,7 +568,7 @@ func (n *internalNotifier) sendViaNotifySend(options NotificationOptions) (uint3 args = append(args, "--urgency=normal") // Execute the command - cmd := exec.Command(n.sendPath, args...) + cmd := exec.Command(ls.internal.sendPath, args...) err := cmd.Run() if err != nil { return 0, fmt.Errorf("notify-send error: %v", err) @@ -592,77 +579,77 @@ func (n *internalNotifier) sendViaNotifySend(options NotificationOptions) (uint3 } // RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions. -func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { - notificationLock.Lock() - notificationCategories[category.ID] = category - notificationLock.Unlock() +func (ls *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error { + ls.categoriesLock.Lock() + ls.categories[category.ID] = category + ls.categoriesLock.Unlock() - return saveCategories() + return ls.saveCategories() } // RemoveNotificationCategory removes a previously registered NotificationCategory. -func (ns *Service) RemoveNotificationCategory(categoryId string) error { - notificationLock.Lock() - delete(notificationCategories, categoryId) - notificationLock.Unlock() +func (ls *linuxNotifier) RemoveNotificationCategory(categoryId string) error { + ls.categoriesLock.Lock() + delete(ls.categories, categoryId) + ls.categoriesLock.Unlock() - return saveCategories() + return ls.saveCategories() } // RemoveAllPendingNotifications is a Linux stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveAllPendingNotifications() error { +func (ls *linuxNotifier) RemoveAllPendingNotifications() error { return nil } // RemovePendingNotification is a Linux stub that always returns nil. // (macOS-specific) -func (ns *Service) RemovePendingNotification(_ string) error { +func (ls *linuxNotifier) RemovePendingNotification(_ string) error { return nil } // RemoveAllDeliveredNotifications is a Linux stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveAllDeliveredNotifications() error { +func (ls *linuxNotifier) RemoveAllDeliveredNotifications() error { return nil } // RemoveDeliveredNotification is a Linux stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveDeliveredNotification(_ string) error { +func (ls *linuxNotifier) RemoveDeliveredNotification(_ string) error { return nil } // RemoveNotification removes a notification by ID (Linux-specific) -func (ns *Service) RemoveNotification(identifier string) error { - if notifier == nil || notifier.method != MethodDbus || notifier.dbusConn == nil { +func (ls *linuxNotifier) RemoveNotification(identifier string) error { + if ls.internal == nil || ls.internal.method != MethodDbus || ls.internal.dbusConn == nil { return errors.New("dbus not available for closing notifications") } // Get the system ID for this notification - notifier.Lock() - systemID, exists := notifier.activeNotifs[identifier] - notifier.Unlock() + ls.internal.Lock() + systemID, exists := ls.internal.activeNotifs[identifier] + ls.internal.Unlock() if !exists { return nil // Already closed or unknown } // Call CloseNotification on dbus - obj := notifier.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) + obj := ls.internal.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) call := obj.Call(callCloseNotification, 0, systemID) return call.Err } // getConfigFilePath returns the path to the configuration file for storing notification categories -func getConfigFilePath() (string, error) { +func (ls *linuxNotifier) getConfigFilePath() (string, error) { configDir, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("failed to get user config directory: %v", err) } - appConfigDir := filepath.Join(configDir, appName) + appConfigDir := filepath.Join(configDir, ls.appName) if err := os.MkdirAll(appConfigDir, 0755); err != nil { return "", fmt.Errorf("failed to create config directory: %v", err) } @@ -671,15 +658,15 @@ func getConfigFilePath() (string, error) { } // saveCategories saves the notification categories to a file. -func saveCategories() error { - filePath, err := getConfigFilePath() +func (ls *linuxNotifier) saveCategories() error { + filePath, err := ls.getConfigFilePath() if err != nil { return err } - notificationLock.RLock() - data, err := json.Marshal(notificationCategories) - notificationLock.RUnlock() + ls.categoriesLock.RLock() + data, err := json.Marshal(ls.categories) + ls.categoriesLock.RUnlock() if err != nil { return fmt.Errorf("failed to marshal notification categories: %v", err) @@ -693,8 +680,8 @@ func saveCategories() error { } // loadCategories loads notification categories from a file. -func loadCategories() error { - filePath, err := getConfigFilePath() +func (ls *linuxNotifier) loadCategories() error { + filePath, err := ls.getConfigFilePath() if err != nil { return err } @@ -717,9 +704,26 @@ func loadCategories() error { return fmt.Errorf("failed to unmarshal notification categories: %v", err) } - notificationLock.Lock() - notificationCategories = categories - notificationLock.Unlock() + ls.categoriesLock.Lock() + ls.categories = categories + ls.categoriesLock.Unlock() return nil } + +func (r closedReason) string() string { + switch r { + case 1: + return "expired" + case 2: + return "dismissed-by-user" + case 3: + return "closed-by-call" + case 4: + return "unknown" + case 5: + return "activated-by-user" + default: + return "other" + } +} diff --git a/v3/pkg/services/notifications/notifications_windows.go b/v3/pkg/services/notifications/notifications_windows.go index 020637f87..ea3dc449e 100644 --- a/v3/pkg/services/notifications/notifications_windows.go +++ b/v3/pkg/services/notifications/notifications_windows.go @@ -19,13 +19,13 @@ import ( "golang.org/x/sys/windows/registry" ) -var ( - NotificationCategories = make(map[string]NotificationCategory) - notificationCategoriesLock sync.RWMutex - appName string - appGUID string - iconPath string -) +type windowsNotifier struct { + categories map[string]NotificationCategory + categoriesLock sync.RWMutex + appName string + appGUID string + iconPath string +} const ( ToastRegistryPath = `Software\Classes\AppUserModelId\` @@ -43,31 +43,35 @@ type NotificationPayload struct { // Creates a new Notifications Service. func New() *Service { notificationServiceOnce.Do(func() { - if NotificationService == nil { - NotificationService = &Service{} + impl := &windowsNotifier{ + categories: make(map[string]NotificationCategory), + } + + NotificationService = &Service{ + impl: impl, } }) return NotificationService } -// ServiceStartup is called when the service is loaded +// Startup is called when the service is loaded // Sets an activation callback to emit an event when notifications are interacted with. -func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - appName = application.Get().Config().Name +func (wn *windowsNotifier) Startup(ctx context.Context) error { + wn.appName = application.Get().Config().Name - guid, err := getGUID() + guid, err := wn.getGUID() if err != nil { return err } - appGUID = guid + wn.appGUID = guid - iconPath = filepath.Join(os.TempDir(), appName+appGUID+".png") + wn.iconPath = filepath.Join(os.TempDir(), wn.appName+wn.appGUID+".png") toast.SetAppData(toast.AppData{ - AppID: appName, + AppID: wn.appName, GUID: guid, - IconPath: iconPath, + IconPath: wn.iconPath, }) toast.SetActivationCallback(func(args string, data []toast.UserData) { @@ -89,7 +93,7 @@ func (ns *Service) ServiceStartup(ctx context.Context, options application.Servi response.UserInfo = userInfoMap } - if userText, found := getUserText(data); found { + if userText, found := wn.getUserText(data); found { response.UserText = userText } @@ -99,40 +103,34 @@ func (ns *Service) ServiceStartup(ctx context.Context, options application.Servi } }) - return loadCategoriesFromRegistry() + return wn.loadCategoriesFromRegistry() } -// ServiceShutdown is called when the service is unloaded -func (ns *Service) ServiceShutdown() error { - return saveCategoriesToRegistry() -} - -// CheckBundleIdentifier is a Windows stub that always returns true. -// (bundle identifiers are macOS-specific) -func CheckBundleIdentifier() bool { - return true +// Shutdown will attempt to save the categories to the registry when the service unloads +func (wn *windowsNotifier) Shutdown() error { + return wn.saveCategoriesToRegistry() } // RequestNotificationAuthorization is a Windows stub that always returns true, nil. // (user authorization is macOS-specific) -func (ns *Service) RequestNotificationAuthorization() (bool, error) { +func (wn *windowsNotifier) RequestNotificationAuthorization() (bool, error) { return true, nil } // CheckNotificationAuthorization is a Windows stub that always returns true. // (user authorization is macOS-specific) -func (ns *Service) CheckNotificationAuthorization() bool { - return true +func (wn *windowsNotifier) CheckNotificationAuthorization() (bool, error) { + return true, nil } // SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows. -// (subtitle and category id are only available on macOS) -func (ns *Service) SendNotification(options NotificationOptions) error { +// (subtitle is only available on macOS) +func (wn *windowsNotifier) SendNotification(options NotificationOptions) error { if err := validateNotificationOptions(options); err != nil { return err } - if err := saveIconToDir(); err != nil { + if err := wn.saveIconToDir(); err != nil { fmt.Printf("Error saving icon: %v\n", err) } @@ -143,7 +141,7 @@ func (ns *Service) SendNotification(options NotificationOptions) error { } if options.Data != nil { - encodedPayload, err := encodePayload(DefaultActionIdentifier, options.Data) + encodedPayload, err := wn.encodePayload(DefaultActionIdentifier, options.Data) if err != nil { return fmt.Errorf("failed to encode notification data: %w", err) } @@ -156,19 +154,19 @@ func (ns *Service) SendNotification(options NotificationOptions) error { // SendNotificationWithActions sends a notification with additional actions and inputs. // A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category. // If a NotificationCategory is not registered a basic notification will be sent. -// (subtitle and category id are only available on macOS) -func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { +// (subtitle is only available on macOS) +func (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptions) error { if err := validateNotificationOptions(options); err != nil { return err } - if err := saveIconToDir(); err != nil { + if err := wn.saveIconToDir(); err != nil { fmt.Printf("Error saving icon: %v\n", err) } - notificationCategoriesLock.RLock() - nCategory := NotificationCategories[options.CategoryID] - notificationCategoriesLock.RUnlock() + wn.categoriesLock.RLock() + nCategory := wn.categories[options.CategoryID] + wn.categoriesLock.RUnlock() n := toast.Notification{ Title: options.Title, @@ -197,14 +195,14 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro } if options.Data != nil { - encodedPayload, err := encodePayload(n.ActivationArguments, options.Data) + encodedPayload, err := wn.encodePayload(n.ActivationArguments, options.Data) if err != nil { return fmt.Errorf("failed to encode notification data: %w", err) } n.ActivationArguments = encodedPayload for index := range n.Actions { - encodedPayload, err := encodePayload(n.Actions[index].Arguments, options.Data) + encodedPayload, err := wn.encodePayload(n.Actions[index].Arguments, options.Data) if err != nil { return fmt.Errorf("failed to encode notification data: %w", err) } @@ -217,61 +215,63 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro // RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions. // Registering a category with the same name as a previously registered NotificationCategory will override it. -func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { - notificationCategoriesLock.Lock() - NotificationCategories[category.ID] = NotificationCategory{ +func (wn *windowsNotifier) RegisterNotificationCategory(category NotificationCategory) error { + wn.categoriesLock.Lock() + defer wn.categoriesLock.Unlock() + + wn.categories[category.ID] = NotificationCategory{ ID: category.ID, Actions: category.Actions, HasReplyField: bool(category.HasReplyField), ReplyPlaceholder: category.ReplyPlaceholder, ReplyButtonTitle: category.ReplyButtonTitle, } - notificationCategoriesLock.Unlock() - return saveCategoriesToRegistry() + return wn.saveCategoriesToRegistry() } // RemoveNotificationCategory removes a previously registered NotificationCategory. -func (ns *Service) RemoveNotificationCategory(categoryId string) error { - notificationCategoriesLock.Lock() - delete(NotificationCategories, categoryId) - notificationCategoriesLock.Unlock() +func (wn *windowsNotifier) RemoveNotificationCategory(categoryId string) error { + wn.categoriesLock.Lock() + defer wn.categoriesLock.Unlock() - return saveCategoriesToRegistry() + delete(wn.categories, categoryId) + + return wn.saveCategoriesToRegistry() } // RemoveAllPendingNotifications is a Windows stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveAllPendingNotifications() error { +func (wn *windowsNotifier) RemoveAllPendingNotifications() error { return nil } // RemovePendingNotification is a Windows stub that always returns nil. // (macOS-specific) -func (ns *Service) RemovePendingNotification(_ string) error { +func (wn *windowsNotifier) RemovePendingNotification(_ string) error { return nil } // RemoveAllDeliveredNotifications is a Windows stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveAllDeliveredNotifications() error { +func (wn *windowsNotifier) RemoveAllDeliveredNotifications() error { return nil } // RemoveDeliveredNotification is a Windows stub that always returns nil. // (macOS-specific) -func (ns *Service) RemoveDeliveredNotification(_ string) error { +func (wn *windowsNotifier) RemoveDeliveredNotification(_ string) error { return nil } // RemoveNotification is a Windows stub that always returns nil. // (Linux-specific) -func (ns *Service) RemoveNotification(identifier string) error { +func (wn *windowsNotifier) RemoveNotification(identifier string) error { return nil } // encodePayload combines an action ID and user data into a single encoded string -func encodePayload(actionID string, data map[string]interface{}) (string, error) { +func (wn *windowsNotifier) encodePayload(actionID string, data map[string]interface{}) (string, error) { payload := NotificationPayload{ Action: actionID, Data: data, @@ -315,20 +315,20 @@ func parseNotificationResponse(response string) (action string, data string) { return actionID, "" } -func saveIconToDir() error { +func (wn *windowsNotifier) saveIconToDir() error { icon, err := application.NewIconFromResource(w32.GetModuleHandle(""), uint16(3)) if err != nil { return fmt.Errorf("failed to retrieve application icon: %w", err) } - return saveHIconAsPNG(icon, iconPath) + return saveHIconAsPNG(icon, wn.iconPath) } -func saveCategoriesToRegistry() error { - notificationCategoriesLock.Lock() - defer notificationCategoriesLock.Unlock() +func (wn *windowsNotifier) saveCategoriesToRegistry() error { + wn.categoriesLock.Lock() + defer wn.categoriesLock.Unlock() - registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName) + registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.appName) key, _, err := registry.CreateKey( registry.CURRENT_USER, @@ -340,7 +340,7 @@ func saveCategoriesToRegistry() error { } defer key.Close() - data, err := json.Marshal(NotificationCategories) + data, err := json.Marshal(wn.categories) if err != nil { return err } @@ -348,11 +348,11 @@ func saveCategoriesToRegistry() error { return key.SetStringValue(NotificationCategoriesRegistryKey, string(data)) } -func loadCategoriesFromRegistry() error { - notificationCategoriesLock.Lock() - defer notificationCategoriesLock.Unlock() +func (wn *windowsNotifier) loadCategoriesFromRegistry() error { + wn.categoriesLock.Lock() + defer wn.categoriesLock.Unlock() - registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName) + registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.appName) key, err := registry.OpenKey( registry.CURRENT_USER, @@ -382,12 +382,12 @@ func loadCategoriesFromRegistry() error { return fmt.Errorf("failed to parse notification categories from registry: %w", err) } - NotificationCategories = categories + wn.categories = categories return nil } -func getUserText(data []toast.UserData) (string, bool) { +func (wn *windowsNotifier) getUserText(data []toast.UserData) (string, bool) { for _, d := range data { if d.Key == "userText" { return d.Value, true @@ -396,8 +396,8 @@ func getUserText(data []toast.UserData) (string, bool) { return "", false } -func getGUID() (string, error) { - keyPath := ToastRegistryPath + appName +func (wn *windowsNotifier) getGUID() (string, error) { + keyPath := ToastRegistryPath + wn.appName k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err == nil { @@ -408,7 +408,7 @@ func getGUID() (string, error) { } } - guid := generateGUID() + guid := wn.generateGUID() k, _, err = registry.CreateKey(registry.CURRENT_USER, keyPath, registry.WRITE) if err != nil { @@ -423,7 +423,7 @@ func getGUID() (string, error) { return guid, nil } -func generateGUID() string { +func (wn *windowsNotifier) generateGUID() string { guid := uuid.New() return fmt.Sprintf("{%s}", guid.String()) }