5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-04 01:50:09 +08:00
wails/v3/pkg/services/notifications/notifications_linux.go
2025-03-24 21:26:02 -07:00

566 lines
13 KiB
Go

//go:build linux
package notifications
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/godbus/dbus/v5"
"github.com/wailsapp/wails/v3/pkg/application"
)
type linuxNotifier struct {
conn *dbus.Conn
categories map[string]NotificationCategory
categoriesLock sync.RWMutex
notifications map[uint32]*notificationData
notificationsLock sync.RWMutex
appName string
cancel context.CancelFunc
}
type notificationData struct {
ID string
Title string
Subtitle string
Body string
CategoryID string
Data map[string]interface{}
DBusID uint32
ActionMap map[string]string
}
const (
dbusNotificationInterface = "org.freedesktop.Notifications"
dbusNotificationPath = "/org/freedesktop/Notifications"
)
// Creates a new Notifications Service.
func New() *Service {
notificationServiceOnce.Do(func() {
impl := &linuxNotifier{
categories: make(map[string]NotificationCategory),
notifications: make(map[uint32]*notificationData),
}
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
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 signalCtx context.Context
signalCtx, ln.cancel = context.WithCancel(context.Background())
if err := ln.setupSignalHandling(signalCtx); err != nil {
return fmt.Errorf("failed to set up notification signal handling: %w", err)
}
return nil
}
// Shutdown will save categories and close the D-Bus connection when the service unloads.
func (ln *linuxNotifier) Shutdown() error {
if ln.cancel != nil {
ln.cancel()
}
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
}
if ln.conn != nil {
return ln.conn.Close()
}
return nil
}
// 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 {
hints := map[string]dbus.Variant{}
body := options.Body
if options.Subtitle != "" {
body = options.Subtitle + "\n" + body
}
defaultActionID := "default"
actions := []string{defaultActionID, "Default"}
actionMap := map[string]string{
defaultActionID: DefaultActionIdentifier,
}
hints["x-notification-id"] = dbus.MakeVariant(options.ID)
if options.Data != nil {
userData, err := json.Marshal(options.Data)
if err == nil {
hints["x-user-data"] = dbus.MakeVariant(string(userData))
}
}
// 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,
int32(-1),
)
if call.Err != nil {
return fmt.Errorf("failed to send notification: %w", call.Err)
}
var dbusID uint32
if err := call.Store(&dbusID); err != nil {
return fmt.Errorf("failed to store notification ID: %w", err)
}
notification := &notificationData{
ID: options.ID,
Title: options.Title,
Subtitle: options.Subtitle,
Body: options.Body,
Data: options.Data,
DBusID: dbusID,
ActionMap: actionMap,
}
ln.notificationsLock.Lock()
ln.notifications[dbusID] = notification
ln.notificationsLock.Unlock()
return nil
}
// SendNotificationWithActions sends a notification with additional actions.
func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions) error {
ln.categoriesLock.RLock()
category, exists := ln.categories[options.CategoryID]
ln.categoriesLock.RUnlock()
if options.CategoryID == "" || !exists {
// Fall back to basic notification
return ln.SendNotification(options)
}
body := options.Body
if options.Subtitle != "" {
body = options.Subtitle + "\n" + body
}
var actions []string
actionMap := make(map[string]string)
defaultActionID := "default"
actions = append(actions, defaultActionID, "Default")
actionMap[defaultActionID] = DefaultActionIdentifier
for _, action := range category.Actions {
actions = append(actions, action.ID, action.Title)
actionMap[action.ID] = action.ID
}
hints := map[string]dbus.Variant{}
hints["x-notification-id"] = dbus.MakeVariant(options.ID)
hints["x-category-id"] = dbus.MakeVariant(options.CategoryID)
if options.Data != nil {
userData, err := json.Marshal(options.Data)
if err == nil {
hints["x-user-data"] = dbus.MakeVariant(string(userData))
}
}
obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(
dbusNotificationInterface+".Notify",
0,
ln.appName,
uint32(0),
"", // Icon
options.Title,
body,
actions,
hints,
int32(-1),
)
if call.Err != nil {
return fmt.Errorf("failed to send notification: %w", call.Err)
}
var dbusID uint32
if err := call.Store(&dbusID); err != nil {
return fmt.Errorf("failed to store notification ID: %w", err)
}
notification := &notificationData{
ID: options.ID,
Title: options.Title,
Subtitle: options.Subtitle,
Body: options.Body,
CategoryID: options.CategoryID,
Data: options.Data,
DBusID: dbusID,
ActionMap: actionMap,
}
ln.notificationsLock.Lock()
ln.notifications[dbusID] = notification
ln.notificationsLock.Unlock()
return nil
}
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
func (ln *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error {
ln.categoriesLock.Lock()
ln.categories[category.ID] = category
ln.categoriesLock.Unlock()
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
}
return nil
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error {
ln.categoriesLock.Lock()
delete(ln.categories, categoryId)
ln.categoriesLock.Unlock()
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
}
return nil
}
// RemoveAllPendingNotifications attempts to remove all active notifications.
func (ln *linuxNotifier) RemoveAllPendingNotifications() error {
ln.notificationsLock.Lock()
dbusIDs := make([]uint32, 0, len(ln.notifications))
for id := range ln.notifications {
dbusIDs = append(dbusIDs, id)
}
ln.notificationsLock.Unlock()
for _, id := range dbusIDs {
ln.closeNotification(id)
}
return nil
}
// RemovePendingNotification removes a pending notification.
func (ln *linuxNotifier) RemovePendingNotification(identifier string) error {
var dbusID uint32
found := false
ln.notificationsLock.Lock()
for id, notif := range ln.notifications {
if notif.ID == identifier {
dbusID = id
found = true
break
}
}
ln.notificationsLock.Unlock()
if !found {
return nil
}
return ln.closeNotification(dbusID)
}
// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux.
func (ln *linuxNotifier) RemoveAllDeliveredNotifications() error {
return ln.RemoveAllPendingNotifications()
}
// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux.
func (ln *linuxNotifier) RemoveDeliveredNotification(identifier string) error {
return ln.RemovePendingNotification(identifier)
}
// RemoveNotification removes a notification by identifier.
func (ln *linuxNotifier) RemoveNotification(identifier string) error {
return ln.RemovePendingNotification(identifier)
}
// 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)
}
return nil
}
func (ln *linuxNotifier) getConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
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 app config directory: %w", err)
}
return appConfigDir, nil
}
// Save notification categories.
func (ln *linuxNotifier) saveCategories() error {
configDir, err := ln.getConfigDir()
if err != nil {
return err
}
categoriesFile := filepath.Join(configDir, "notification-categories.json")
ln.categoriesLock.RLock()
categoriesData, err := json.MarshalIndent(ln.categories, "", " ")
ln.categoriesLock.RUnlock()
if err != nil {
return fmt.Errorf("failed to marshal notification categories: %w", err)
}
if err := os.WriteFile(categoriesFile, categoriesData, 0644); err != nil {
return fmt.Errorf("failed to write notification categories to disk: %w", err)
}
return nil
}
// Load notification categories.
func (ln *linuxNotifier) loadCategories() error {
configDir, err := ln.getConfigDir()
if err != nil {
return err
}
categoriesFile := filepath.Join(configDir, "notification-categories.json")
if _, err := os.Stat(categoriesFile); os.IsNotExist(err) {
return nil
}
categoriesData, err := os.ReadFile(categoriesFile)
if err != nil {
return fmt.Errorf("failed to read notification categories from disk: %w", err)
}
categories := make(map[string]NotificationCategory)
if err := json.Unmarshal(categoriesData, &categories); err != nil {
return fmt.Errorf("failed to unmarshal notification categories: %w", err)
}
ln.categoriesLock.Lock()
ln.categories = categories
ln.categoriesLock.Unlock()
return nil
}
// Setup signal handling for notification actions.
func (ln *linuxNotifier) setupSignalHandling(ctx context.Context) 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
}
c := make(chan *dbus.Signal, 10)
ln.conn.Signal(c)
go ln.handleSignals(ctx, c)
return nil
}
// Handle incoming D-Bus signals.
func (ln *linuxNotifier) handleSignals(ctx context.Context, c chan *dbus.Signal) {
for {
select {
case <-ctx.Done():
return
case signal, ok := <-c:
if !ok {
return
}
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
}
dbusID, ok := signal.Body[0].(uint32)
if !ok {
return
}
actionID, ok := signal.Body[1].(string)
if !ok {
return
}
ln.notificationsLock.Lock()
notification, exists := ln.notifications[dbusID]
if exists {
delete(ln.notifications, dbusID)
}
ln.notificationsLock.Unlock()
if !exists {
return
}
appActionID, ok := notification.ActionMap[actionID]
if !ok {
appActionID = actionID
}
response := NotificationResponse{
ID: notification.ID,
ActionIdentifier: appActionID,
Title: notification.Title,
Subtitle: notification.Subtitle,
Body: notification.Body,
CategoryID: notification.CategoryID,
UserInfo: notification.Data,
}
result := NotificationResult{
Response: response,
}
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
}
// Handle NotificationClosed signal.
// Reason codes:
// 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) < 2 {
return
}
dbusID, ok := signal.Body[0].(uint32)
if !ok {
return
}
reason, ok := signal.Body[1].(uint32)
if !ok {
reason = 0 // Unknown reason
}
ln.notificationsLock.Lock()
notification, exists := ln.notifications[dbusID]
if exists {
delete(ln.notifications, dbusID)
}
ln.notificationsLock.Unlock()
if !exists {
return
}
if reason == 2 {
response := NotificationResponse{
ID: notification.ID,
ActionIdentifier: DefaultActionIdentifier,
Title: notification.Title,
Subtitle: notification.Subtitle,
Body: notification.Body,
CategoryID: notification.CategoryID,
UserInfo: notification.Data,
}
result := NotificationResult{
Response: response,
}
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
}
}