mirror of
https://github.com/wailsapp/wails.git
synced 2025-05-04 01:50:09 +08:00
566 lines
13 KiB
Go
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 := ¬ificationData{
|
|
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 := ¬ificationData{
|
|
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)
|
|
}
|
|
}
|
|
}
|