mirror of
https://github.com/wailsapp/wails.git
synced 2025-05-08 09:50:26 +08:00
601 lines
15 KiB
Go
601 lines
15 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
|
|
activeNotifs map[uint32]string
|
|
notificationMeta map[uint32]map[string]interface{}
|
|
activeNotifsLock sync.RWMutex
|
|
appName 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),
|
|
activeNotifs: make(map[uint32]string),
|
|
notificationMeta: make(map[uint32]map[string]interface{}),
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
if err := ln.setupSignalHandling(); 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 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{}
|
|
|
|
// Use subtitle as part of the body if provided
|
|
body := options.Body
|
|
if options.Subtitle != "" {
|
|
body = options.Subtitle + "\n" + body
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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))
|
|
|
|
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,
|
|
)
|
|
|
|
if call.Err != nil {
|
|
return fmt.Errorf("failed to send notification: %w", call.Err)
|
|
}
|
|
|
|
var notifID uint32
|
|
if err := call.Store(¬ifID); err != nil {
|
|
return fmt.Errorf("failed to store notification ID: %w", 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 {
|
|
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)
|
|
}
|
|
|
|
// Use subtitle as part of the body if provided
|
|
body := options.Body
|
|
if options.Subtitle != "" {
|
|
body = options.Subtitle + "\n" + body
|
|
}
|
|
|
|
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,
|
|
)
|
|
|
|
if call.Err != nil {
|
|
return fmt.Errorf("failed to send notification: %w", call.Err)
|
|
}
|
|
|
|
var notifID uint32
|
|
if err := call.Store(¬ifID); err != nil {
|
|
return fmt.Errorf("failed to store notification ID: %w", err)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
defer ln.categoriesLock.Unlock()
|
|
|
|
ln.categories[category.ID] = category
|
|
|
|
go ln.saveCategories()
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveNotificationCategory removes a previously registered NotificationCategory.
|
|
func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error {
|
|
ln.categoriesLock.Lock()
|
|
defer ln.categoriesLock.Unlock()
|
|
|
|
delete(ln.categories, categoryId)
|
|
|
|
go ln.saveCategories()
|
|
|
|
return 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 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 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)
|
|
}
|
|
|
|
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: %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() 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()
|
|
}
|