From 448c78b226a461c3c5e29d5d1d12f6e6a77f349a Mon Sep 17 00:00:00 2001 From: popaprozac Date: Sat, 22 Mar 2025 22:36:29 -0700 Subject: [PATCH] rewrite and simplify linux impl --- .../notifications/notifications_linux.go | 1079 +++++++---------- .../notifications/notifications_windows.go | 4 +- 2 files changed, 433 insertions(+), 650 deletions(-) diff --git a/v3/pkg/services/notifications/notifications_linux.go b/v3/pkg/services/notifications/notifications_linux.go index 0fc21275a..911bba8c4 100644 --- a/v3/pkg/services/notifications/notifications_linux.go +++ b/v3/pkg/services/notifications/notifications_linux.go @@ -5,77 +5,37 @@ package notifications import ( "context" "encoding/json" - "errors" "fmt" "os" - "os/exec" "path/filepath" "sync" - "time" "github.com/godbus/dbus/v5" "github.com/wailsapp/wails/v3/pkg/application" ) type linuxNotifier struct { - // Categories - categories map[string]NotificationCategory - categoriesLock sync.RWMutex - - // App info - appName string - - // Notification system - sync.Mutex - method string - dbusConn *dbus.Conn - sendPath string - activeNotifs map[string]uint32 // Maps our notification IDs to system IDs - contexts map[string]*notificationContext // Stores notification contexts by our ID - - // Listener management - listenerCtx context.Context - listenerCancel context.CancelFunc - listenerRunning bool - - // Initialization - initOnce sync.Once - initialized bool - - monitorCtx context.Context - monitorCancel context.CancelFunc -} - -type notificationContext struct { - ID string - SystemID uint32 - Actions map[string]string // Maps action keys to display labels - UserData map[string]interface{} // The original user data + conn *dbus.Conn + categories map[string]NotificationCategory + categoriesLock sync.RWMutex + activeNotifs map[uint32]string + notificationMeta map[uint32]map[string]interface{} + activeNotifsLock sync.RWMutex + appName string } const ( - dbusObjectPath = "/org/freedesktop/Notifications" - dbusNotificationsInterface = "org.freedesktop.Notifications" - signalNotificationClosed = "org.freedesktop.Notifications.NotificationClosed" - signalActionInvoked = "org.freedesktop.Notifications.ActionInvoked" - callGetCapabilities = "org.freedesktop.Notifications.GetCapabilities" - callCloseNotification = "org.freedesktop.Notifications.CloseNotification" - - MethodNotifySend = "notify-send" - MethodDbus = "dbus" - - notifyChannelBufferSize = 25 + dbusNotificationInterface = "org.freedesktop.Notifications" + dbusNotificationPath = "/org/freedesktop/Notifications" ) -type closedReason uint32 - -// New creates a new Notifications Service +// Creates a new Notifications Service. func New() *Service { notificationServiceOnce.Do(func() { impl := &linuxNotifier{ - categories: make(map[string]NotificationCategory), - activeNotifs: make(map[string]uint32), - contexts: make(map[string]*notificationContext), + categories: make(map[string]NotificationCategory), + activeNotifs: make(map[uint32]string), + notificationMeta: make(map[uint32]map[string]interface{}), } NotificationService = &Service{ @@ -88,708 +48,373 @@ func New() *Service { // Startup is called when the service is loaded func (ln *linuxNotifier) Startup(ctx context.Context, options application.ServiceOptions) error { - ln.appName = application.Get().Config().Name + app := application.Get() + if app != nil && app.Config().Name != "" { + ln.appName = app.Config().Name + } + + conn, err := dbus.ConnectSessionBus() + if err != nil { + return fmt.Errorf("failed to connect to session bus: %w", err) + } + ln.conn = conn if err := ln.loadCategories(); err != nil { fmt.Printf("Failed to load notification categories: %v\n", err) } - var err error - ln.initOnce.Do(func() { - err = ln.initNotificationSystem() - ln.initialized = err == nil - }) + if err := ln.setupSignalHandling(); err != nil { + return fmt.Errorf("failed to set up notification signal handling: %w", err) + } - return err + return nil } -// initNotificationSystem initializes the notification system -func (ln *linuxNotifier) initNotificationSystem() error { - ln.Lock() - defer ln.Unlock() - - ln.monitorCtx, ln.monitorCancel = context.WithCancel(context.Background()) - - // Cancel any existing listener - if ln.listenerCancel != nil { - ln.listenerCancel() - ln.listenerCancel = nil - } - - // Create a new context for the listener - ln.listenerCtx, ln.listenerCancel = context.WithCancel(context.Background()) - - // Reset state - ln.activeNotifs = make(map[string]uint32) - ln.contexts = make(map[string]*notificationContext) - ln.listenerRunning = false - - // Try dbus first - dbusConn, err := ln.initDBus() - if err == nil { - ln.dbusConn = dbusConn - ln.method = MethodDbus - - // Start the dbus signal listener - go ln.startDBusListener(ln.listenerCtx) - ln.listenerRunning = true - return nil - } - - // Try notify-send as fallback - sendPath, err := ln.initNotifySend() - if err == nil { - ln.sendPath = sendPath - ln.method = MethodNotifySend - return nil - } - - // No method available - ln.method = "" - ln.sendPath = "" - return errors.New("no notification method is available") -} - -// initDBus attempts to initialize D-Bus notifications -func (ln *linuxNotifier) initDBus() (*dbus.Conn, error) { - conn, err := dbus.SessionBusPrivate() - if err != nil { - return nil, err - } - - if err = conn.Auth(nil); err != nil { - conn.Close() - return nil, err - } - - if err = conn.Hello(); err != nil { - conn.Close() - return nil, err - } - - obj := conn.Object(dbusNotificationsInterface, dbusObjectPath) - call := obj.Call(callGetCapabilities, 0) - if call.Err != nil { - conn.Close() - return nil, call.Err - } - - var ret []string - err = call.Store(&ret) - if err != nil { - conn.Close() - return nil, err - } - - // Add a listener for notification signals - err = conn.AddMatchSignal( - dbus.WithMatchObjectPath(dbusObjectPath), - dbus.WithMatchInterface(dbusNotificationsInterface), - ) - if err != nil { - conn.Close() - return nil, err - } - - return conn, nil -} - -// initNotifySend attempts to find notify-send binary -func (ln *linuxNotifier) initNotifySend() (string, error) { - // Try standard notify-send - send, err := exec.LookPath("notify-send") - if err == nil { - return send, nil - } - - // Try sw-notify-send (in some distros) - send, err = exec.LookPath("sw-notify-send") - if err == nil { - return send, nil - } - - return "", errors.New("notify-send not found") -} - -// Shutdown is called when the service is unloaded +// Shutdown will save categories and close the D-Bus connection when the service unloads func (ln *linuxNotifier) Shutdown() error { - ln.Lock() - - // Cancel monitor goroutine first - if ln.monitorCancel != nil { - ln.monitorCancel() - ln.monitorCancel = nil + if err := ln.saveCategories(); err != nil { + fmt.Printf("Failed to save notification categories: %v\n", err) } - // Cancel the listener context if it's running - if ln.listenerCancel != nil { - ln.listenerCancel() - ln.listenerCancel = nil + if ln.conn != nil { + return ln.conn.Close() } - // Close the connection - if ln.dbusConn != nil { - ln.dbusConn.Close() - ln.dbusConn = nil - } - - // Clear state - ln.activeNotifs = make(map[string]uint32) - ln.contexts = make(map[string]*notificationContext) - ln.method = "" - ln.sendPath = "" - ln.initialized = false - - ln.Unlock() - - return ln.saveCategories() + return nil } -// startDBusListener listens for DBus signals for notification actions and closures -func (ln *linuxNotifier) startDBusListener(ctx context.Context) { - signal := make(chan *dbus.Signal, notifyChannelBufferSize) - ln.dbusConn.Signal(signal) - - defer func() { - ln.Lock() - ln.listenerRunning = false - ln.Unlock() - ln.dbusConn.RemoveSignal(signal) // Remove signal handler - close(signal) // Clean up channel - }() - - // Create a separate goroutine to monitor the D-Bus connection - disconnected := ln.monitorDBusConnection(ctx) - - for { - select { - case <-ctx.Done(): - // Context was cancelled, exit gracefully - return - - case <-disconnected: - // D-Bus connection lost - fmt.Println("D-Bus connection lost, attempting to reconnect...") - ln.Lock() - - // Attempt to reconnect - var err error - newConn, err := ln.initDBus() - if err != nil { - fmt.Printf("Failed to reconnect to D-Bus: %v\n", err) - ln.dbusConn = nil - ln.method = "" // No longer using D-Bus - - // Try fallback to notify-send - sendPath, err := ln.initNotifySend() - if err == nil { - fmt.Println("Falling back to notify-send method") - ln.sendPath = sendPath - ln.method = MethodNotifySend - } else { - fmt.Println("No notification methods available after D-Bus disconnect") - } - - ln.Unlock() - return // Exit listener as we can't continue without D-Bus - } - - // Successfully reconnected - ln.dbusConn = newConn - ln.Unlock() - - // Re-register for signals - signal = make(chan *dbus.Signal, notifyChannelBufferSize) - ln.dbusConn.Signal(signal) - - // Restart the monitor goroutine - disconnected = ln.monitorDBusConnection(ctx) - fmt.Println("Successfully reconnected to D-Bus") - continue - - case s := <-signal: - if s == nil { - // Channel closed or nil signal - continue - } - - if len(s.Body) < 2 { - continue - } - - switch s.Name { - case signalNotificationClosed: - systemID := s.Body[0].(uint32) - reason := closedReason(s.Body[1].(uint32)).string() - ln.handleNotificationClosed(systemID, reason) - case signalActionInvoked: - systemID := s.Body[0].(uint32) - actionKey := s.Body[1].(string) - ln.handleActionInvoked(systemID, actionKey) - } - } - } -} - -// handleNotificationClosed processes notification closed signals -func (ln *linuxNotifier) handleNotificationClosed(systemID uint32, reason string) { - // Find our notification ID for this system ID - var notifID string - var userData map[string]interface{} - - ln.Lock() - for id, sysID := range ln.activeNotifs { - if sysID == systemID { - notifID = id - // Get the user data from context if available - if ctx, exists := ln.contexts[id]; exists { - userData = ctx.UserData - } - break - } - } - ln.Unlock() - - if notifID != "" { - response := NotificationResponse{ - ID: notifID, - ActionIdentifier: DefaultActionIdentifier, - UserInfo: userData, - } - - // Add reason to UserInfo or create it if none exists - if response.UserInfo == nil { - response.UserInfo = map[string]interface{}{ - "reason": reason, - } - } else { - response.UserInfo["reason"] = reason - } - - result := NotificationResult{} - result.Response = response - if ns := getNotificationService(); ns != nil { - ns.handleNotificationResult(result) - } - - // Clean up the context - ln.Lock() - delete(ln.contexts, notifID) - delete(ln.activeNotifs, notifID) - ln.Unlock() - } -} - -// handleActionInvoked processes action invoked signals -func (ln *linuxNotifier) handleActionInvoked(systemID uint32, actionKey string) { - // Find our notification ID and context for this system ID - var notifID string - var ctx *notificationContext - - ln.Lock() - for id, sysID := range ln.activeNotifs { - if sysID == systemID { - notifID = id - ctx = ln.contexts[id] - break - } - } - ln.Unlock() - - if notifID != "" { - if actionKey == "default" { - actionKey = DefaultActionIdentifier - } - - // First, send the action response with the user data - response := NotificationResponse{ - ID: notifID, - ActionIdentifier: actionKey, - } - - // Include the user data if we have it - if ctx != nil { - response.UserInfo = ctx.UserData - } - - result := NotificationResult{} - result.Response = response - if ns := getNotificationService(); ns != nil { - ns.handleNotificationResult(result) - } - - // Then, trigger a closed event with "activated-by-user" reason - closeResponse := NotificationResponse{ - ID: notifID, - ActionIdentifier: DefaultActionIdentifier, - } - - // Include the same user data in the close response - if ctx != nil { - closeResponse.UserInfo = ctx.UserData - } else { - closeResponse.UserInfo = map[string]interface{}{} - } - - // Add the reason to the user info - closeResponse.UserInfo["reason"] = closedReason(5).string() // "activated-by-user" - - closeResult := NotificationResult{} - closeResult.Response = closeResponse - if ns := getNotificationService(); ns != nil { - ns.handleNotificationResult(closeResult) - } - - // Clean up the context - ln.Lock() - delete(ln.contexts, notifID) - delete(ln.activeNotifs, notifID) - ln.Unlock() - } -} - -// CheckBundleIdentifier is a Linux stub that always returns true. -func (ln *linuxNotifier) CheckBundleIdentifier() bool { - return true -} - -// RequestNotificationAuthorization is a Linux stub that always returns true. +// RequestNotificationAuthorization is a Linux stub that always returns true, nil. +// (authorization is macOS-specific) func (ln *linuxNotifier) RequestNotificationAuthorization() (bool, error) { return true, nil } // CheckNotificationAuthorization is a Linux stub that always returns true. +// (authorization is macOS-specific) func (ln *linuxNotifier) CheckNotificationAuthorization() (bool, error) { return true, nil } // SendNotification sends a basic notification with a unique identifier, title, subtitle, and body. func (ln *linuxNotifier) SendNotification(options NotificationOptions) error { - if !ln.initialized { - return errors.New("notification service not initialized") + hints := map[string]dbus.Variant{} + + // Use subtitle as part of the body if provided + body := options.Body + if options.Subtitle != "" { + body = options.Subtitle + "\n" + body } - if err := validateNotificationOptions(options); err != nil { - return err + if options.Data != nil { + jsonData, err := json.Marshal(options.Data) + if err != nil { + return fmt.Errorf("failed to marshal notification data: %w", err) + } + hints["x-user-info"] = dbus.MakeVariant(string(jsonData)) } - ln.Lock() - defer ln.Unlock() + metadataJSON, err := json.Marshal(map[string]interface{}{ + "id": options.ID, + "title": options.Title, + "subtitle": options.Subtitle, + "body": options.Body, + "data": options.Data, + }) + if err != nil { + return fmt.Errorf("failed to marshal notification metadata: %w", err) + } + hints["x-wails-metadata"] = dbus.MakeVariant(string(metadataJSON)) - var ( - systemID uint32 - err error + actions := []string{} + timeout := int32(0) // Timeout in milliseconds (5 seconds) + + // Call the Notify method on the D-Bus interface + obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath) + call := obj.Call( + dbusNotificationInterface+".Notify", + 0, + ln.appName, + uint32(0), + "", // Icon + options.Title, + body, + actions, + hints, + timeout, ) - switch ln.method { - case MethodDbus: - systemID, err = ln.sendViaDbus(options, nil) - case MethodNotifySend: - systemID, err = ln.sendViaNotifySend(options) - default: - err = errors.New("no notification method is available") + if call.Err != nil { + return fmt.Errorf("failed to send notification: %w", call.Err) } - if err == nil && systemID > 0 { - // Store the system ID mapping - ln.activeNotifs[options.ID] = systemID - - // Create and store the notification context - ctx := ¬ificationContext{ - ID: options.ID, - SystemID: systemID, - UserData: options.Data, - } - ln.contexts[options.ID] = ctx + var notifID uint32 + if err := call.Store(¬ifID); err != nil { + return fmt.Errorf("failed to store notification ID: %w", err) } - return err + ln.activeNotifsLock.Lock() + ln.activeNotifs[notifID] = options.ID + + ln.notificationMeta[notifID] = map[string]interface{}{ + "id": options.ID, + "title": options.Title, + "subtitle": options.Subtitle, + "body": options.Body, + "data": options.Data, + } + ln.activeNotifsLock.Unlock() + + return nil } // SendNotificationWithActions sends a notification with additional actions. +// (Inputs are only supported on macOS and Windows) func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions) error { - if !ln.initialized { - return errors.New("notification service not initialized") - } - - if err := validateNotificationOptions(options); err != nil { - return err - } - ln.categoriesLock.RLock() category, exists := ln.categories[options.CategoryID] ln.categoriesLock.RUnlock() - if !exists { + if options.CategoryID == "" || !exists { + // Fall back to basic notification return ln.SendNotification(options) } - ln.Lock() - defer ln.Unlock() + // Use subtitle as part of the body if provided + body := options.Body + if options.Subtitle != "" { + body = options.Subtitle + "\n" + body + } - var ( - systemID uint32 - err error + var actions []string + for _, action := range category.Actions { + actions = append(actions, action.ID, action.Title) + } + + hints := map[string]dbus.Variant{} + + if options.Data != nil { + jsonData, err := json.Marshal(options.Data) + if err != nil { + return fmt.Errorf("failed to marshal notification data: %w", err) + } + hints["x-user-info"] = dbus.MakeVariant(string(jsonData)) + } + + hints["category"] = dbus.MakeVariant(options.CategoryID) + + metadataJSON, err := json.Marshal(map[string]interface{}{ + "id": options.ID, + "title": options.Title, + "subtitle": options.Subtitle, + "body": options.Body, + "categoryId": options.CategoryID, + "data": options.Data, + }) + if err != nil { + return fmt.Errorf("failed to marshal notification metadata: %w", err) + } + hints["x-wails-metadata"] = dbus.MakeVariant(string(metadataJSON)) + + timeout := int32(0) + + obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath) + call := obj.Call( + dbusNotificationInterface+".Notify", + 0, + ln.appName, + uint32(0), + "", // Icon + options.Title, + body, + actions, + hints, + timeout, ) - switch ln.method { - case MethodDbus: - systemID, err = ln.sendViaDbus(options, &category) - case MethodNotifySend: - // notify-send doesn't support actions, fall back to basic notification - systemID, err = ln.sendViaNotifySend(options) - default: - err = errors.New("no notification method is available") - } - - if err == nil && systemID > 0 { - // Store the system ID mapping - ln.activeNotifs[options.ID] = systemID - - // Create and store the notification context with actions - ctx := ¬ificationContext{ - ID: options.ID, - SystemID: systemID, - UserData: options.Data, - Actions: make(map[string]string), - } - - // Store action mappings - if exists { - for _, action := range category.Actions { - ctx.Actions[action.ID] = action.Title - } - } - - ln.contexts[options.ID] = ctx - } - - return err -} - -// monitorDBusConnection creates a goroutine to monitor the D-Bus connection -// Returns a channel that will be closed if the connection is lost -func (ln *linuxNotifier) monitorDBusConnection(ctx context.Context) chan struct{} { - disconnected := make(chan struct{}) - - go func() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return // Parent context cancelled - case <-ln.monitorCtx.Done(): - return // Monitor specifically cancelled - case <-ticker.C: - ln.Lock() - connected := ln.dbusConn != nil && ln.dbusConn.Connected() - ln.Unlock() - - if !connected { - close(disconnected) - return - } - } - } - }() - - return disconnected -} - -// sendViaDbus sends a notification via dbus -func (ln *linuxNotifier) sendViaDbus(options NotificationOptions, category *NotificationCategory) (result uint32, err error) { - // Prepare actions - var actions []string - if category != nil { - for _, action := range category.Actions { - actions = append(actions, action.ID, action.Title) - } - } - - // Default timeout (-1 means use system default) - timeout := int32(-1) - - // Prepare hints - hints := map[string]dbus.Variant{ - // Normal urgency by default - "urgency": dbus.MakeVariant(byte(1)), - } - - // Add user data to hints if available - if options.Data != nil { - if userData, err := json.Marshal(options.Data); err == nil { - hints["x-wails-user-data"] = dbus.MakeVariant(string(userData)) - } - } - - // Send the notification - obj := ln.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) - dbusArgs := []interface{}{ - ln.appName, // App name - uint32(0), // Replaces ID (0 means new notification) - "", // App icon (empty for now) - options.Title, // Title - options.Body, // Body - actions, // Actions - hints, // Hints - timeout, // Timeout - } - - call := obj.Call("org.freedesktop.Notifications.Notify", 0, dbusArgs...) if call.Err != nil { - return 0, fmt.Errorf("dbus notification error: %v", call.Err) + return fmt.Errorf("failed to send notification: %w", call.Err) } - err = call.Store(&result) - if err != nil { - return 0, err + var notifID uint32 + if err := call.Store(¬ifID); err != nil { + return fmt.Errorf("failed to store notification ID: %w", err) } - return result, nil + ln.activeNotifsLock.Lock() + + ln.activeNotifs[notifID] = options.ID + + metadata := map[string]interface{}{ + "id": options.ID, + "title": options.Title, + "subtitle": options.Subtitle, + "body": options.Body, + "categoryId": options.CategoryID, + "data": options.Data, + } + + ln.notificationMeta[notifID] = metadata + ln.activeNotifsLock.Unlock() + + return nil } -// sendViaNotifySend sends a notification via notify-send command -func (ln *linuxNotifier) sendViaNotifySend(options NotificationOptions) (uint32, error) { - args := []string{ - options.Title, - options.Body, - "--urgency=normal", - } - - // Execute the command - cmd := exec.Command(ln.sendPath, args...) - err := cmd.Run() - if err != nil { - return 0, fmt.Errorf("notify-send error: %v", err) - } - - // notify-send doesn't return IDs, so we use 0 - return 0, nil -} - -// RegisterNotificationCategory registers a new NotificationCategory +// 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 (ln *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error { ln.categoriesLock.Lock() - ln.categories[category.ID] = category - ln.categoriesLock.Unlock() + defer ln.categoriesLock.Unlock() - return ln.saveCategories() + ln.categories[category.ID] = category + + go ln.saveCategories() + + return nil } -// RemoveNotificationCategory removes a previously registered NotificationCategory +// RemoveNotificationCategory removes a previously registered NotificationCategory. func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error { ln.categoriesLock.Lock() + defer ln.categoriesLock.Unlock() + delete(ln.categories, categoryId) - ln.categoriesLock.Unlock() - return ln.saveCategories() + go ln.saveCategories() + + return nil } -// RemoveAllPendingNotifications is a Linux stub that always returns nil +// RemoveAllPendingNotifications is not directly supported in Linux D-Bus +// but we can try to remove all active notifications. func (ln *linuxNotifier) RemoveAllPendingNotifications() error { + ln.activeNotifsLock.RLock() + notifIDs := make([]uint32, 0, len(ln.activeNotifs)) + for id := range ln.activeNotifs { + notifIDs = append(notifIDs, id) + } + ln.activeNotifsLock.RUnlock() + + for _, id := range notifIDs { + ln.closeNotification(id) + } + return nil } -// RemovePendingNotification is a Linux stub that always returns nil -func (ln *linuxNotifier) RemovePendingNotification(_ string) error { - return nil +// RemovePendingNotification removes a pending notification. +func (ln *linuxNotifier) RemovePendingNotification(identifier string) error { + var notifID uint32 + found := false + + ln.activeNotifsLock.RLock() + for id, ident := range ln.activeNotifs { + if ident == identifier { + notifID = id + found = true + break + } + } + ln.activeNotifsLock.RUnlock() + + if !found { + return nil + } + + return ln.closeNotification(notifID) } -// RemoveAllDeliveredNotifications is a Linux stub that always returns nil +// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux. func (ln *linuxNotifier) RemoveAllDeliveredNotifications() error { - return nil + return ln.RemoveAllPendingNotifications() } -// RemoveDeliveredNotification is a Linux stub that always returns nil -func (ln *linuxNotifier) RemoveDeliveredNotification(_ string) error { - return nil +// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux. +func (ln *linuxNotifier) RemoveDeliveredNotification(identifier string) error { + return ln.RemovePendingNotification(identifier) } -// RemoveNotification removes a notification by ID (Linux-specific) +// RemoveNotification removes a notification by identifier. func (ln *linuxNotifier) RemoveNotification(identifier string) error { - ln.Lock() - defer ln.Unlock() - - if !ln.initialized || ln.method != MethodDbus || ln.dbusConn == nil { - return errors.New("dbus not available for closing notifications") - } - - // Get the system ID for this notification - systemID, exists := ln.activeNotifs[identifier] - - if !exists { - return nil // Already closed or unknown - } - - // Call CloseNotification on dbus - obj := ln.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) - call := obj.Call(callCloseNotification, 0, systemID) - - return call.Err + return ln.RemovePendingNotification(identifier) } -// getConfigFilePath returns the path to the configuration file -func (ln *linuxNotifier) getConfigFilePath() (string, error) { +// Helper method to close a notification. +func (ln *linuxNotifier) closeNotification(id uint32) error { + obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath) + call := obj.Call(dbusNotificationInterface+".CloseNotification", 0, id) + + if call.Err != nil { + return fmt.Errorf("failed to close notification: %w", call.Err) + } + + ln.activeNotifsLock.Lock() + delete(ln.activeNotifs, id) + delete(ln.notificationMeta, id) + ln.activeNotifsLock.Unlock() + + return nil +} + +// Get the config directory for the app. +func (ln *linuxNotifier) getConfigDir() (string, error) { configDir, err := os.UserConfigDir() if err != nil { - return "", fmt.Errorf("failed to get user config directory: %v", err) + return "", fmt.Errorf("failed to get user config directory: %w", err) } appConfigDir := filepath.Join(configDir, ln.appName) if err := os.MkdirAll(appConfigDir, 0755); err != nil { - return "", fmt.Errorf("failed to create config directory: %v", err) + return "", fmt.Errorf("failed to create app config directory: %w", err) } - return filepath.Join(appConfigDir, "notification-categories.json"), nil + return appConfigDir, nil } -// saveCategories saves the notification categories to a file +// Save notification categories. func (ln *linuxNotifier) saveCategories() error { - filePath, err := ln.getConfigFilePath() + configDir, err := ln.getConfigDir() if err != nil { return err } + categoriesFile := filepath.Join(configDir, "notification-categories.json") + ln.categoriesLock.RLock() - data, err := json.Marshal(ln.categories) + categoriesData, err := json.MarshalIndent(ln.categories, "", " ") ln.categoriesLock.RUnlock() if err != nil { - return fmt.Errorf("failed to marshal notification categories: %v", err) + return fmt.Errorf("failed to marshal notification categories: %w", err) } - if err := os.WriteFile(filePath, data, 0644); err != nil { - return fmt.Errorf("failed to write notification categories to file: %v", err) + if err := os.WriteFile(categoriesFile, categoriesData, 0644); err != nil { + return fmt.Errorf("failed to write notification categories to disk: %w", err) } return nil } -// loadCategories loads notification categories from a file +// Load notification categories. func (ln *linuxNotifier) loadCategories() error { - filePath, err := ln.getConfigFilePath() + configDir, err := ln.getConfigDir() if err != nil { return err } - if _, err := os.Stat(filePath); os.IsNotExist(err) { + categoriesFile := filepath.Join(configDir, "notification-categories.json") + + if _, err := os.Stat(categoriesFile); os.IsNotExist(err) { return nil } - data, err := os.ReadFile(filePath) + categoriesData, err := os.ReadFile(categoriesFile) if err != nil { - return fmt.Errorf("failed to read notification categories file: %v", err) - } - - if len(data) == 0 { - return nil + return fmt.Errorf("failed to read notification categories from disk: %w", err) } categories := make(map[string]NotificationCategory) - if err := json.Unmarshal(data, &categories); err != nil { - return fmt.Errorf("failed to unmarshal notification categories: %v", err) + if err := json.Unmarshal(categoriesData, &categories); err != nil { + return fmt.Errorf("failed to unmarshal notification categories: %w", err) } ln.categoriesLock.Lock() @@ -799,19 +424,177 @@ func (ln *linuxNotifier) loadCategories() error { 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" +// Setup signal handling for notification actions. +func (ln *linuxNotifier) setupSignalHandling() error { + if err := ln.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusNotificationInterface), + dbus.WithMatchMember("ActionInvoked"), + ); err != nil { + return err + } + + if err := ln.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusNotificationInterface), + dbus.WithMatchMember("NotificationClosed"), + ); err != nil { + return err + } + + go ln.handleSignals() + + return nil +} + +// Handle incoming D-Bus signals. +func (ln *linuxNotifier) handleSignals() { + c := make(chan *dbus.Signal, 10) + ln.conn.Signal(c) + + for signal := range c { + switch signal.Name { + case dbusNotificationInterface + ".ActionInvoked": + ln.handleActionInvoked(signal) + case dbusNotificationInterface + ".NotificationClosed": + ln.handleNotificationClosed(signal) + } } } + +// Handle ActionInvoked signal. +func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) { + if len(signal.Body) < 2 { + return + } + + notifID, ok := signal.Body[0].(uint32) + if !ok { + return + } + + actionID, ok := signal.Body[1].(string) + if !ok { + return + } + + ln.activeNotifsLock.RLock() + identifier, idExists := ln.activeNotifs[notifID] + metadata, metaExists := ln.notificationMeta[notifID] + ln.activeNotifsLock.RUnlock() + + if !idExists || !metaExists { + return + } + + response := NotificationResponse{ + ID: identifier, + ActionIdentifier: actionID, + } + + if title, ok := metadata["title"].(string); ok { + response.Title = title + } + + if subtitle, ok := metadata["subtitle"].(string); ok { + response.Subtitle = subtitle + } + + if body, ok := metadata["body"].(string); ok { + response.Body = body + } + + if categoryID, ok := metadata["categoryId"].(string); ok { + response.CategoryID = categoryID + } + + if userData, ok := metadata["data"].(map[string]interface{}); ok { + response.UserInfo = userData + } + + if actionID == DefaultActionIdentifier { + response.ActionIdentifier = DefaultActionIdentifier + } + + result := NotificationResult{ + Response: response, + } + + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + + ln.activeNotifsLock.Lock() + delete(ln.activeNotifs, notifID) + delete(ln.notificationMeta, notifID) + ln.activeNotifsLock.Unlock() +} + +// Handle NotificationClosed signal. +// The second parameter contains the close reason: +// 1 - expired timeout +// 2 - dismissed by user (click on X) +// 3 - closed by CloseNotification call +// 4 - undefined/reserved +func (ln *linuxNotifier) handleNotificationClosed(signal *dbus.Signal) { + if len(signal.Body) < 1 { + return + } + + notifID, ok := signal.Body[0].(uint32) + if !ok { + return + } + + var reason uint32 = 0 + if len(signal.Body) > 1 { + if r, ok := signal.Body[1].(uint32); ok { + reason = r + } + } + + if reason != 1 && reason != 3 { + ln.activeNotifsLock.RLock() + identifier, idExists := ln.activeNotifs[notifID] + metadata, metaExists := ln.notificationMeta[notifID] + ln.activeNotifsLock.RUnlock() + + if idExists && metaExists { + response := NotificationResponse{ + ID: identifier, + ActionIdentifier: DefaultActionIdentifier, + } + + if title, ok := metadata["title"].(string); ok { + response.Title = title + } + + if subtitle, ok := metadata["subtitle"].(string); ok { + response.Subtitle = subtitle + } + + if body, ok := metadata["body"].(string); ok { + response.Body = body + } + + if categoryID, ok := metadata["categoryId"].(string); ok { + response.CategoryID = categoryID + } + + if userData, ok := metadata["data"].(map[string]interface{}); ok { + response.UserInfo = userData + } + + result := NotificationResult{ + Response: response, + } + + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + } + } + + ln.activeNotifsLock.Lock() + delete(ln.activeNotifs, notifID) + delete(ln.notificationMeta, notifID) + ln.activeNotifsLock.Unlock() +} diff --git a/v3/pkg/services/notifications/notifications_windows.go b/v3/pkg/services/notifications/notifications_windows.go index 30abd61b1..dde22ec30 100644 --- a/v3/pkg/services/notifications/notifications_windows.go +++ b/v3/pkg/services/notifications/notifications_windows.go @@ -141,7 +141,7 @@ func (wn *windowsNotifier) CheckNotificationAuthorization() (bool, error) { } // SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows. -// (subtitle is only available on macOS) +// (subtitle is only available on macOS and Linux) func (wn *windowsNotifier) SendNotification(options NotificationOptions) error { if err := wn.saveIconToDir(); err != nil { fmt.Printf("Error saving icon: %v\n", err) @@ -167,7 +167,7 @@ func (wn *windowsNotifier) 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 is only available on macOS) +// (subtitle is only available on macOS and Linux) func (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptions) error { if err := wn.saveIconToDir(); err != nil { fmt.Printf("Error saving icon: %v\n", err)