mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-16 23:55:52 +01:00
* fix(v3): warm up dialog types in go-json cache to prevent Windows panic Add FileFilter, OpenFileDialogOptions, SaveFileDialogOptions, and MessageDialogOptions to the init() warmup to prevent index out of bounds panic on Windows when these types are first unmarshaled. Fixes goccy/go-json#474 for Wails internal dialog types. * fix(v3): revert goccy/go-json to stdlib encoding/json to fix Windows panic goccy/go-json has a type address calculation bug on Windows that causes index out of bounds panic when decoding user-defined types for the first time. This reverts all runtime usages of goccy/go-json back to stdlib encoding/json. Test and benchmark files are left unchanged. Partially reverts PR #4843.
566 lines
14 KiB
Go
566 lines
14 KiB
Go
//go:build linux
|
|
|
|
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"encoding/json"
|
|
|
|
"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() *NotificationService {
|
|
notificationServiceOnce.Do(func() {
|
|
impl := &linuxNotifier{
|
|
categories: make(map[string]NotificationCategory),
|
|
notifications: make(map[uint32]*notificationData),
|
|
}
|
|
|
|
NotificationService_ = &NotificationService{
|
|
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)
|
|
}
|
|
}
|
|
}
|