5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-04 18:31:53 +08:00
wails/v3/pkg/services/notifications/notifications_linux.go
2025-03-22 11:51:02 -07:00

731 lines
17 KiB
Go

//go:build linux
package notifications
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"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
}
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
}
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
)
type closedReason uint32
// New 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),
}
NotificationService = &Service{
impl: impl,
}
})
return NotificationService
}
// 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
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
})
return err
}
// initNotificationSystem initializes the notification system
func (ln *linuxNotifier) initNotificationSystem() error {
ln.Lock()
defer ln.Unlock()
// 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
func (ln *linuxNotifier) Shutdown() error {
ln.Lock()
// Cancel the listener context if it's running
if ln.listenerCancel != nil {
ln.listenerCancel()
ln.listenerCancel = nil
}
// 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()
}
// 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
}()
for {
select {
case <-ctx.Done():
// Context was cancelled, exit gracefully
return
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.
func (ln *linuxNotifier) RequestNotificationAuthorization() (bool, error) {
return true, nil
}
// CheckNotificationAuthorization is a Linux stub that always returns true.
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")
}
if err := validateNotificationOptions(options); err != nil {
return err
}
ln.Lock()
defer ln.Unlock()
var (
systemID uint32
err error
)
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 err == nil && systemID > 0 {
// Store the system ID mapping
ln.activeNotifs[options.ID] = systemID
// Create and store the notification context
ctx := &notificationContext{
ID: options.ID,
SystemID: systemID,
UserData: options.Data,
}
ln.contexts[options.ID] = ctx
}
return err
}
// SendNotificationWithActions sends a notification with additional actions.
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 {
return ln.SendNotification(options)
}
ln.Lock()
defer ln.Unlock()
var (
systemID uint32
err error
)
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 := &notificationContext{
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
}
// 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)
}
err = call.Store(&result)
if err != nil {
return 0, err
}
return result, 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
func (ln *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error {
ln.categoriesLock.Lock()
ln.categories[category.ID] = category
ln.categoriesLock.Unlock()
return ln.saveCategories()
}
// RemoveNotificationCategory removes a previously registered NotificationCategory
func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error {
ln.categoriesLock.Lock()
delete(ln.categories, categoryId)
ln.categoriesLock.Unlock()
return ln.saveCategories()
}
// RemoveAllPendingNotifications is a Linux stub that always returns nil
func (ln *linuxNotifier) RemoveAllPendingNotifications() error {
return nil
}
// RemovePendingNotification is a Linux stub that always returns nil
func (ln *linuxNotifier) RemovePendingNotification(_ string) error {
return nil
}
// RemoveAllDeliveredNotifications is a Linux stub that always returns nil
func (ln *linuxNotifier) RemoveAllDeliveredNotifications() error {
return nil
}
// RemoveDeliveredNotification is a Linux stub that always returns nil
func (ln *linuxNotifier) RemoveDeliveredNotification(_ string) error {
return nil
}
// RemoveNotification removes a notification by ID (Linux-specific)
func (ln *linuxNotifier) RemoveNotification(identifier string) error {
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
ln.Lock()
systemID, exists := ln.activeNotifs[identifier]
ln.Unlock()
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
}
// getConfigFilePath returns the path to the configuration file
func (ln *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, ln.appName)
if err := os.MkdirAll(appConfigDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %v", err)
}
return filepath.Join(appConfigDir, "notification-categories.json"), nil
}
// saveCategories saves the notification categories to a file
func (ln *linuxNotifier) saveCategories() error {
filePath, err := ln.getConfigFilePath()
if err != nil {
return err
}
ln.categoriesLock.RLock()
data, err := json.Marshal(ln.categories)
ln.categoriesLock.RUnlock()
if err != nil {
return fmt.Errorf("failed to marshal notification categories: %v", err)
}
if err := os.WriteFile(filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write notification categories to file: %v", err)
}
return nil
}
// loadCategories loads notification categories from a file
func (ln *linuxNotifier) loadCategories() error {
filePath, err := ln.getConfigFilePath()
if err != nil {
return err
}
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read notification categories file: %v", err)
}
if len(data) == 0 {
return nil
}
categories := make(map[string]NotificationCategory)
if err := json.Unmarshal(data, &categories); err != nil {
return fmt.Errorf("failed to unmarshal notification categories: %v", err)
}
ln.categoriesLock.Lock()
ln.categories = categories
ln.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"
}
}