From e86cf68b179b013b497ffd1d4e8c3f3ea49ae774 Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 25 Feb 2025 17:58:24 -0800 Subject: [PATCH] linux? --- .../notifications/notifications_linux.go | 566 ++++++++++++++---- 1 file changed, 455 insertions(+), 111 deletions(-) diff --git a/v3/pkg/services/notifications/notifications_linux.go b/v3/pkg/services/notifications/notifications_linux.go index c6734e83f..3d575dda6 100644 --- a/v3/pkg/services/notifications/notifications_linux.go +++ b/v3/pkg/services/notifications/notifications_linux.go @@ -4,24 +4,79 @@ package notifications import ( "context" - "encoding/base64" "encoding/json" + "errors" "fmt" "os" + "os/exec" "path/filepath" "sync" - "git.sr.ht/~whereswaldon/shout" "github.com/godbus/dbus/v5" "github.com/wailsapp/wails/v3/pkg/application" ) -var NotificationLock sync.RWMutex -var NotificationCategories = make(map[string]NotificationCategory) -var Notifier shout.Notifier -var 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" -// Creates a new Notifications Service. + MethodNotifySend = "notify-send" + MethodDbus = "dbus" + MethodKdialog = "kdialog" + + 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 + SystemID uint32 + Actions map[string]string // Maps action keys to display labels + UserData map[string]interface{} // The original user data +} + +type internalNotifier struct { + 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 +} + +var notifier *internalNotifier + +// New creates a new Notifications Service func New() *Service { if NotificationService == nil { NotificationService = &Service{} @@ -37,78 +92,236 @@ func (ns *Service) ServiceStartup(ctx context.Context, options application.Servi fmt.Printf("Failed to load notification categories: %v\n", err) } - conn, err := dbus.SessionBus() - if err != nil { - return fmt.Errorf("failed to connect to D-Bus session bus: %v", err) + notifier = &internalNotifier{ + activeNotifs: make(map[string]uint32), + contexts: make(map[string]*notificationContext), } - var iconPath string - - Notifier, err = shout.NewNotifier(conn, appName, iconPath, func(notificationID, action string, platformData map[string]dbus.Variant, target, notifierResponse dbus.Variant, err error) { - if err != nil { - return - } - - fmt.Printf("\n---------- NOTIFICATION RESPONSE DEBUG ----------\n") - fmt.Printf("notificationID: %s\n", notificationID) - fmt.Printf("action: %s\n", action) - fmt.Printf("platformData: %+v\n", platformData) - fmt.Printf("target: %+v (signature: %s)\n", target, target.Signature()) - fmt.Printf("response: %+v (signature: %s)\n", notifierResponse, notifierResponse.Signature()) - fmt.Printf("----------------------------------------------\n") - - response := NotificationResponse{ - ID: notificationID, - ActionIdentifier: action, - } - - if target.Signature().String() == "s" { - var targetStr string - if err := target.Store(&targetStr); err == nil { - var userInfo map[string]interface{} - userInfoStr, err := base64.StdEncoding.DecodeString(targetStr) - if err != nil { - if err := json.Unmarshal([]byte(targetStr), &userInfo); err == nil { - response.UserInfo = userInfo - } - } - if err := json.Unmarshal(userInfoStr, &userInfo); err == nil { - response.UserInfo = userInfo - } - } - } - - // if notifierResponse.Signature().String() == "s" { - // var userText string - // if err := notifierResponse.Store(&userText); err == nil { - // response.UserText = userText - // } - // } - - if NotificationService != nil { - NotificationService.handleNotificationResponse(response) - } + var err error + initOnce.Do(func() { + err = notifier.init() }) - if err != nil { - return fmt.Errorf("failed to create notifier: %v", err) - } - - return nil + return err } // ServiceShutdown is called when the service is unloaded func (ns *Service) ServiceShutdown() error { + if notifier != nil && notifier.dbusConn != nil { + notifier.dbusConn.Close() + } return saveCategories() } +// Initialize the notifier and choose the best available notification method +func (n *internalNotifier) init() error { + var err error + + checkDbus := func() (*dbus.Conn, error) { + conn, err := dbus.SessionBusPrivate() + if err != nil { + return conn, err + } + + if err = conn.Auth(nil); err != nil { + return conn, err + } + + if err = conn.Hello(); err != nil { + return conn, err + } + + obj := conn.Object(dbusNotificationsInterface, dbusObjectPath) + call := obj.Call(callGetCapabilities, 0) + if call.Err != nil { + return conn, call.Err + } + + var ret []string + err = call.Store(&ret) + if err != nil { + return conn, err + } + + // Add a listener for notification signals + err = conn.AddMatchSignal( + dbus.WithMatchObjectPath(dbusObjectPath), + dbus.WithMatchInterface(dbusNotificationsInterface), + ) + if err != nil { + return nil, err + } + + return conn, nil + } + + // Try dbus first + n.dbusConn, err = checkDbus() + if err == nil { + n.method = MethodDbus + // Start the dbus signal listener + go n.startDBusListener() + return nil + } + if n.dbusConn != nil { + n.dbusConn.Close() + n.dbusConn = nil + } + + // Try notify-send + send, err := exec.LookPath("notify-send") + if err == nil { + n.sendPath = send + n.method = MethodNotifySend + return nil + } + + // Try sw-notify-send + send, err = exec.LookPath("sw-notify-send") + if err == nil { + n.sendPath = send + n.method = MethodNotifySend + return nil + } + + // No method available + n.method = "none" + n.sendPath = "" + + return errors.New("no notification method is available") +} + +// startDBusListener listens for DBus signals for notification actions and closures +func (n *internalNotifier) startDBusListener() { + signal := make(chan *dbus.Signal, notifyChannelBufferSize) + n.dbusConn.Signal(signal) + + for s := range signal { + if s == nil || len(s.Body) < 2 { + continue + } + + switch s.Name { + case signalNotificationClosed: + systemID := s.Body[0].(uint32) + reason := closedReason(s.Body[1].(uint32)).string() + n.handleNotificationClosed(systemID, reason) + case signalActionInvoked: + systemID := s.Body[0].(uint32) + actionKey := s.Body[1].(string) + n.handleActionInvoked(systemID, actionKey) + } + } +} + +// handleNotificationClosed processes notification closed signals +func (n *internalNotifier) handleNotificationClosed(systemID uint32, reason string) { + // Find our notification ID for this system ID + var notifID string + var userData map[string]interface{} + + n.Lock() + for id, sysID := range n.activeNotifs { + if sysID == systemID { + notifID = id + // Get the user data from context if available + if ctx, exists := n.contexts[id]; exists { + userData = ctx.UserData + } + break + } + } + n.Unlock() + + if notifID != "" && NotificationService != nil { + 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 + } + + NotificationService.handleNotificationResponse(response) + + // Clean up the context + n.Lock() + delete(n.contexts, notifID) + delete(n.activeNotifs, notifID) + n.Unlock() + } +} + +// handleActionInvoked processes action invoked signals +func (n *internalNotifier) handleActionInvoked(systemID uint32, actionKey string) { + // Find our notification ID and context for this system ID + var notifID string + var ctx *notificationContext + + n.Lock() + for id, sysID := range n.activeNotifs { + if sysID == systemID { + notifID = id + ctx = n.contexts[id] + break + } + } + n.Unlock() + + if notifID != "" && NotificationService != nil { + // 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 + } + + NotificationService.handleNotificationResponse(response) + + // 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" + + NotificationService.handleNotificationResponse(closeResponse) + + // Clean up the context + n.Lock() + delete(n.contexts, notifID) + delete(n.activeNotifs, notifID) + n.Unlock() + } +} + // CheckBundleIdentifier is a Linux stub that always returns true. // (bundle identifiers are macOS-specific) -func CheckBundleIdentifier() bool { +func (ns *Service) CheckBundleIdentifier() bool { return true } -// RequestUserNotificationAuthorization is a Linux stub that always returns true, nil. +// RequestUserNotificationAuthorization is a Linux stub that always returns true. // (user authorization is macOS-specific) func (ns *Service) RequestUserNotificationAuthorization() (bool, error) { return true, nil @@ -122,77 +335,191 @@ func (ns *Service) CheckNotificationAuthorization() (bool, error) { // SendNotification sends a basic notification with a unique identifier, title, subtitle, and body. func (ns *Service) SendNotification(options NotificationOptions) error { - notification := shout.Notification{ - Title: options.Title, - Body: options.Body, - Priority: shout.Normal, - DefaultAction: DefaultActionIdentifier, + if notifier == nil { + return errors.New("notification service not initialized") } - if options.Data != nil { - jsonData, err := json.Marshal(options.Data) - if err == nil { - notification.DefaultActionTarget = dbus.MakeVariant(base64.StdEncoding.EncodeToString(jsonData)) + notifier.Lock() + defer notifier.Unlock() + + var ( + systemID uint32 + err error + ) + + switch notifier.method { + case MethodDbus: + systemID, err = notifier.sendViaDbus(options, nil) + case MethodNotifySend: + systemID, err = notifier.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 + + // Create and store the notification context + ctx := ¬ificationContext{ + ID: options.ID, + SystemID: systemID, + UserData: options.Data, } + notifier.contexts[options.ID] = ctx } - return Notifier.Send(options.ID, notification) + return err } -// SendNotificationWithActions sends a notification with additional actions and inputs. +// SendNotificationWithActions sends a notification with additional actions. func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { - NotificationLock.RLock() - category, exists := NotificationCategories[options.CategoryID] - NotificationLock.RUnlock() + if notifier == nil { + return errors.New("notification service not initialized") + } + + notificationLock.RLock() + category, exists := notificationCategories[options.CategoryID] + notificationLock.RUnlock() if !exists { return ns.SendNotification(options) } - notification := shout.Notification{ - Title: options.Title, - Body: options.Body, - Priority: shout.Normal, - DefaultAction: DefaultActionIdentifier, + notifier.Lock() + defer notifier.Unlock() + + var ( + systemID uint32 + err error + ) + + switch notifier.method { + case MethodDbus: + systemID, err = notifier.sendViaDbus(options, &category) + case MethodNotifySend: + // notify-send doesn't support actions, fall back to basic notification + systemID, err = notifier.sendViaNotifySend(options) + default: + err = errors.New("no notification method is available") } - if options.Data != nil { - jsonData, err := json.Marshal(options.Data) - if err == nil { - notification.DefaultActionTarget = dbus.MakeVariant(base64.StdEncoding.EncodeToString(jsonData)) + if err == nil && systemID > 0 { + // Store the system ID mapping + notifier.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), } - } - for _, action := range category.Actions { - var targetStr string = "" - if options.Data != nil { - if jsonData, err := json.Marshal(options.Data); err == nil { - targetStr = base64.StdEncoding.EncodeToString(jsonData) + // Store action mappings + if exists { + for _, action := range category.Actions { + ctx.Actions[action.ID] = action.Title } } - notification.Buttons = append(notification.Buttons, shout.Button{ - Label: action.Title, - Action: action.ID, - Target: targetStr, - }) + + notifier.contexts[options.ID] = ctx } - return Notifier.Send(options.ID, notification) + return err +} + +// sendViaDbus sends a notification via dbus +func (n *internalNotifier) 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 := n.dbusConn.Object(dbusNotificationsInterface, dbusObjectPath) + dbusArgs := []interface{}{ + 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) + } + + err = call.Store(&result) + if err != nil { + return 0, err + } + + return result, nil +} + +// sendViaNotifySend sends a notification via notify-send command +func (n *internalNotifier) sendViaNotifySend(options NotificationOptions) (uint32, error) { + args := []string{ + options.Title, + options.Body, + } + + // Add icon if eventually supported + // if options.Icon != "" { ... } + + // Add urgency (normal by default) + args = append(args, "--urgency=normal") + + // Execute the command + cmd := exec.Command(n.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 to be used with SendNotificationWithActions. func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { - NotificationLock.Lock() - NotificationCategories[category.ID] = category - NotificationLock.Unlock() + notificationLock.Lock() + notificationCategories[category.ID] = category + notificationLock.Unlock() + return saveCategories() } // RemoveNotificationCategory removes a previously registered NotificationCategory. func (ns *Service) RemoveNotificationCategory(categoryId string) error { - NotificationLock.Lock() - delete(NotificationCategories, categoryId) - NotificationLock.Unlock() + notificationLock.Lock() + delete(notificationCategories, categoryId) + notificationLock.Unlock() + return saveCategories() } @@ -222,7 +549,24 @@ func (ns *Service) RemoveDeliveredNotification(_ string) error { // RemoveNotification removes a notification by ID (Linux-specific) func (ns *Service) RemoveNotification(identifier string) error { - return Notifier.Remove(identifier) + if notifier == nil || notifier.method != MethodDbus || notifier.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() + + if !exists { + return nil // Already closed or unknown + } + + // Call CloseNotification on dbus + obj := notifier.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 @@ -247,9 +591,9 @@ func saveCategories() error { return err } - NotificationLock.RLock() - data, err := json.Marshal(NotificationCategories) - NotificationLock.RUnlock() + notificationLock.RLock() + data, err := json.Marshal(notificationCategories) + notificationLock.RUnlock() if err != nil { return fmt.Errorf("failed to marshal notification categories: %v", err) @@ -287,9 +631,9 @@ func loadCategories() error { return fmt.Errorf("failed to unmarshal notification categories: %v", err) } - NotificationLock.Lock() - NotificationCategories = categories - NotificationLock.Unlock() + notificationLock.Lock() + notificationCategories = categories + notificationLock.Unlock() return nil }