From 5ee570557f4463af43ce64f6e04c6340191f09bb Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 6 Oct 2026 10:10:42 -0400 Subject: [PATCH] cli/notify: add priority options --- core/cmd/dms/commands_notify.go | 265 ++++++++++++++++-- core/internal/notify/notify.go | 187 +++++++----- .../qmlchecks/lockscreen_input_test.go | 7 + .../internal/server/notifyactions/handlers.go | 1 + 4 files changed, 363 insertions(+), 97 deletions(-) diff --git a/core/cmd/dms/commands_notify.go b/core/cmd/dms/commands_notify.go index be18b9813..99cc4b154 100644 --- a/core/cmd/dms/commands_notify.go +++ b/core/cmd/dms/commands_notify.go @@ -3,30 +3,50 @@ package main import ( "fmt" "os" + "strconv" + "strings" + "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" "github.com/AvengeMedia/dankgo/ipc" + "github.com/godbus/dbus/v5" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) var ( - notifyAppName string - notifyIcon string - notifyFile string - notifyTimeout int + notifyAppName string + notifyAppIcon string + notifyIcon string + notifyFile string + notifyTimeout int + notifyUrgency string + notifyCategory string + notifyTransient bool + notifyHints []string + notifyActions []string + notifyPrintID bool + notifyReplaceID uint32 + notifyWait bool + notifyIDFd int + notifyActionFd int ) var notifyCmd = &cobra.Command{ Use: "notify [body]", Short: "Send a desktop notification", - Long: `Send a desktop notification with optional actions. + Long: `Send a desktop notification. Flags follow notify-send. -If --file is provided, the notification will have "Open" and "Open Folder" actions. +If --file is provided, the notification gets "Open" and "Open Folder" actions +that open the file or its directory when clicked. Examples: dms notify "Hello" "World" - dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone - dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`, + dms notify -u critical -t 0 "Disk almost full" + dms notify -a "Backup" -i drive-harddisk -c transfer.complete "Backup finished" + dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg + dms notify -A retry=Retry -A "Dismiss" "Build failed" + dms notify -h string:x-dms-tag:build -h int:value:42 "Hinted"`, Args: cobra.MinimumNArgs(1), Run: runNotify, } @@ -39,38 +59,229 @@ var genericNotifyActionCmd = &cobra.Command{ }, } +var notifyFlagAliases = map[string]string{ + "app": "app-name", + "timeout": "expire-time", +} + func init() { - notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name") - notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path") - notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)") - notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds") + f := notifyCmd.Flags() + f.SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName { + if alias, ok := notifyFlagAliases[name]; ok { + return pflag.NormalizedName(alias) + } + return pflag.NormalizedName(name) + }) + // Frees -h for --hint like notify-send; cobra skips its own help flag when one exists. + f.Bool("help", false, "Show help") + f.StringVarP(¬ifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical") + f.IntVarP(¬ifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)") + f.StringVarP(¬ifyAppName, "app-name", "a", "DMS", "Application name") + f.StringVarP(¬ifyIcon, "icon", "i", "", "Icon name or path") + f.StringVarP(¬ifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image") + f.StringVar(¬ifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)") + f.BoolVarP(¬ifyTransient, "transient", "e", false, "Do not keep the notification in history") + f.StringArrayVarP(¬ifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)") + f.BoolVarP(¬ifyPrintID, "print-id", "p", false, "Print the notification id") + f.IntVar(¬ifyIDFd, "id-fd", -1, "File descriptor to write the notification id to") + f.Uint32VarP(¬ifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace") + f.BoolVarP(¬ifyWait, "wait", "w", false, "Wait until the notification closes") + f.StringArrayVarP(¬ifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)") + f.IntVar(¬ifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to") + f.StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)") } func runNotify(cmd *cobra.Command, args []string) { - summary := args[0] - body := "" - if len(args) > 1 { - body = args[1] + n, err := buildNotification(args) + if err != nil { + fail(err) } - n := notify.Notification{ - AppName: notifyAppName, - Icon: notifyIcon, - Summary: summary, - Body: body, - FilePath: notifyFile, - Timeout: int32(notifyTimeout), + wait := notifyWait || len(n.Actions) > 0 + var waiter *notify.Waiter + if wait { + waiter, err = notify.NewWaiter() + if err != nil { + fail(err) + } + defer waiter.Close() } id, err := notify.Send(n) if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + fail(err) } - if n.FilePath == "" { + reportID(id) + + if n.FilePath != "" { + watchNotificationAction(id, n.FilePath) + } + if !wait { return } - watchNotificationAction(id, n.FilePath) + if id == 0 { + return + } + res := waiter.Wait(id, waitTimeout()) + if res.TimedOut { + fmt.Fprintln(os.Stderr, "Wait timeout expired") + return + } + if !res.Invoked { + return + } + fmt.Println(res.Action) + writeFd(notifyActionFd, res.Action) +} + +func buildNotification(args []string) (notify.Notification, error) { + n := notify.Notification{ + AppName: notifyAppName, + AppIcon: notifyAppIcon, + Icon: notifyIcon, + Summary: args[0], + FilePath: notifyFile, + Timeout: int32(notifyTimeout), + ReplaceID: notifyReplaceID, + Hints: map[string]dbus.Variant{}, + } + if len(args) > 1 { + n.Body = args[1] + } + if notifyFile != "" && len(notifyActions) > 0 { + return n, fmt.Errorf("--file and --action cannot be combined") + } + if notifyUrgency != "" { + level, err := parseUrgency(notifyUrgency) + if err != nil { + return n, err + } + n.Hints["urgency"] = dbus.MakeVariant(level) + } + if notifyCategory != "" { + n.Hints["category"] = dbus.MakeVariant(notifyCategory) + } + if notifyTransient { + n.Hints["transient"] = dbus.MakeVariant(true) + } + for _, spec := range notifyHints { + name, value, err := parseHint(spec) + if err != nil { + return n, err + } + n.Hints[name] = value + } + n.Actions = parseActions(notifyActions) + return n, nil +} + +func parseUrgency(s string) (byte, error) { + switch strings.ToLower(s) { + case "low", "0": + return 0, nil + case "normal", "1": + return 1, nil + case "critical", "2": + return 2, nil + } + return 0, fmt.Errorf("invalid urgency %q: use low, normal or critical", s) +} + +func parseHint(spec string) (string, dbus.Variant, error) { + parts := strings.SplitN(spec, ":", 3) + if len(parts) != 3 || parts[1] == "" { + return "", dbus.Variant{}, fmt.Errorf("invalid hint %q: expected TYPE:NAME:VALUE", spec) + } + typ, name, value := strings.ToLower(parts[0]), parts[1], parts[2] + switch typ { + case "string": + return name, dbus.MakeVariant(value), nil + case "int": + v, err := strconv.ParseInt(value, 10, 32) + if err != nil { + return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid int %q", name, value) + } + return name, dbus.MakeVariant(int32(v)), nil + case "double": + v, err := strconv.ParseFloat(value, 64) + if err != nil { + return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid double %q", name, value) + } + return name, dbus.MakeVariant(v), nil + case "byte": + v, err := strconv.ParseUint(value, 0, 8) + if err != nil { + return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid byte %q", name, value) + } + return name, dbus.MakeVariant(byte(v)), nil + case "boolean": + v, err := parseBoolean(value) + if err != nil { + return "", dbus.Variant{}, fmt.Errorf("hint %s: invalid boolean %q", name, value) + } + return name, dbus.MakeVariant(v), nil + } + return "", dbus.Variant{}, fmt.Errorf("invalid hint type %q: use boolean, int, double, string or byte", parts[0]) +} + +func parseBoolean(s string) (bool, error) { + switch strings.ToLower(s) { + case "true": + return true, nil + case "false": + return false, nil + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return false, err + } + return v != 0, nil +} + +func parseActions(specs []string) []string { + if len(specs) == 0 { + return nil + } + actions := make([]string, 0, len(specs)*2) + for i, spec := range specs { + name, label, hasName := strings.Cut(spec, "=") + if !hasName { + name, label = strconv.Itoa(i), spec + } + actions = append(actions, name, label) + } + return actions +} + +func waitTimeout() time.Duration { + if notifyTimeout <= 0 { + return 0 + } + return time.Duration(notifyTimeout) * time.Millisecond +} + +func reportID(id uint32) { + if notifyPrintID { + fmt.Println(id) + } + writeFd(notifyIDFd, strconv.FormatUint(uint64(id), 10)) +} + +func writeFd(fd int, line string) { + if fd < 0 { + return + } + f := os.NewFile(uintptr(fd), "") + if f == nil { + return + } + fmt.Fprintln(f, line) + _ = f.Sync() +} + +func fail(err error) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } func watchNotificationAction(id uint32, path string) { diff --git a/core/internal/notify/notify.go b/core/internal/notify/notify.go index 8bcd38a97..40e327faf 100644 --- a/core/internal/notify/notify.go +++ b/core/internal/notify/notify.go @@ -22,21 +22,26 @@ const ( notifyPath = "/org/freedesktop/Notifications" notifyInterface = "org.freedesktop.Notifications" - maxSummaryLen = 29 - maxBodyLen = 80 + signalClosed = notifyInterface + ".NotificationClosed" + signalActionInvoked = notifyInterface + ".ActionInvoked" listenerMaxLifetime = time.Hour ) type Notification struct { - AppName string + AppName string + // Notify app_icon argument. Falls back to Icon. + AppIcon string Icon string Summary string Body string FilePath string - Timeout int32 + // Milliseconds; 0 never expires, -1 lets the server decide. + Timeout int32 + ReplaceID uint32 // Pairs of id, label; ignored when FilePath is set. Actions []string + Hints map[string]dbus.Variant } func Send(n Notification) (uint32, error) { @@ -51,12 +56,6 @@ func Send(n Notification) (uint32, error) { if n.Icon == "" && n.AppName == appName { n.Icon = AppID } - if n.Timeout == 0 { - n.Timeout = 5000 - } - - n.Summary = truncate(n.Summary, maxSummaryLen) - n.Body = truncate(n.Body, maxBodyLen) actions := n.Actions if n.FilePath != "" { @@ -74,11 +73,17 @@ func Send(n Notification) (uint32, error) { hints["desktop-entry"] = dbus.MakeVariant(AppID) } if n.FilePath != "" { - imgPath := n.FilePath - if !strings.HasPrefix(imgPath, "file://") { - imgPath = "file://" + imgPath + hints["image_path"] = dbus.MakeVariant(fileURI(n.FilePath)) + } + appIcon := n.Icon + if n.AppIcon != "" { + appIcon = n.AppIcon + if n.Icon != "" { + hints["image-path"] = dbus.MakeVariant(n.Icon) } - hints["image_path"] = dbus.MakeVariant(imgPath) + } + for k, v := range n.Hints { + hints[k] = v } obj := conn.Object(notifyDest, notifyPath) @@ -86,8 +91,8 @@ func Send(n Notification) (uint32, error) { notifyInterface+".Notify", 0, n.AppName, - uint32(0), - n.Icon, + n.ReplaceID, + appIcon, n.Summary, n.Body, actions, @@ -107,6 +112,97 @@ func Send(n Notification) (uint32, error) { return notificationID, nil } +func fileURI(path string) string { + if strings.HasPrefix(path, "file://") { + return path + } + return "file://" + path +} + +// Waiter subscribes to notification signals. Create it before Send so an +// instant action or close cannot slip past the subscription. +type Waiter struct { + conn *dbus.Conn + signals chan *dbus.Signal +} + +func NewWaiter() (*Waiter, error) { + conn, err := dbus.SessionBus() + if err != nil { + return nil, fmt.Errorf("dbus session failed: %w", err) + } + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(notifyPath), + dbus.WithMatchInterface(notifyInterface), + ); err != nil { + return nil, fmt.Errorf("dbus match failed: %w", err) + } + w := &Waiter{conn: conn, signals: make(chan *dbus.Signal, 10)} + conn.Signal(w.signals) + return w, nil +} + +func (w *Waiter) Close() { + w.conn.RemoveSignal(w.signals) + _ = w.conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(notifyPath), + dbus.WithMatchInterface(notifyInterface), + ) +} + +type WaitResult struct { + Action string + Invoked bool + TimedOut bool +} + +// Blocks until the notification closes or one of its actions fires. +// A timeout of zero or less waits forever. +func (w *Waiter) Wait(id uint32, timeout time.Duration) WaitResult { + var deadline <-chan time.Time + if timeout > 0 { + deadline = time.After(timeout) + } + for { + select { + case <-deadline: + return WaitResult{TimedOut: true} + case sig, ok := <-w.signals: + if !ok { + return WaitResult{} + } + res, done := matchSignal(sig, id) + if done { + return res + } + } + } +} + +func matchSignal(sig *dbus.Signal, id uint32) (WaitResult, bool) { + if sig == nil || len(sig.Body) < 1 { + return WaitResult{}, false + } + sigID, ok := sig.Body[0].(uint32) + if !ok || sigID != id { + return WaitResult{}, false + } + switch sig.Name { + case signalClosed: + return WaitResult{}, true + case signalActionInvoked: + if len(sig.Body) < 2 { + return WaitResult{}, false + } + action, ok := sig.Body[1].(string) + if !ok { + return WaitResult{}, false + } + return WaitResult{Action: action, Invoked: true}, true + } + return WaitResult{}, false +} + func SpawnActionListener(notificationID uint32, filePath string) { exe, err := os.Executable() if err != nil { @@ -132,57 +228,17 @@ func RunActionListener(args []string) { filePath := args[1] - conn, err := dbus.SessionBus() + w, err := NewWaiter() if err != nil { return } + defer w.Close() - if err := conn.AddMatchSignal( - dbus.WithMatchObjectPath(notifyPath), - dbus.WithMatchInterface(notifyInterface), - ); err != nil { + res := w.Wait(uint32(notificationID), listenerMaxLifetime) + if !res.Invoked { return } - - signals := make(chan *dbus.Signal, 10) - conn.Signal(signals) - deadline := time.After(listenerMaxLifetime) - - for { - select { - case <-deadline: - return - case sig := <-signals: - if sig == nil || handleSignal(sig, uint32(notificationID), filePath) { - return - } - } - } -} - -func handleSignal(sig *dbus.Signal, notificationID uint32, filePath string) bool { - if len(sig.Body) < 1 { - return false - } - id, ok := sig.Body[0].(uint32) - if !ok || id != notificationID { - return false - } - switch sig.Name { - case notifyInterface + ".NotificationClosed": - return true - case notifyInterface + ".ActionInvoked": - if len(sig.Body) < 2 { - return false - } - action, ok := sig.Body[1].(string) - if !ok { - return false - } - handleAction(action, filePath) - return true - } - return false + handleAction(res.Action, filePath) } func handleAction(action, filePath string) { @@ -201,12 +257,3 @@ func openPath(path string) { } cmd.Start() } - -// Cuts on rune boundaries; a byte slice mid-character makes the dbus encoder reject the string. -func truncate(s string, maxRunes int) string { - r := []rune(s) - if len(r) <= maxRunes { - return s - } - return string(r[:maxRunes-3]) + "..." -} diff --git a/core/internal/qmlchecks/lockscreen_input_test.go b/core/internal/qmlchecks/lockscreen_input_test.go index 560dc12de..a407e4ccf 100644 --- a/core/internal/qmlchecks/lockscreen_input_test.go +++ b/core/internal/qmlchecks/lockscreen_input_test.go @@ -33,6 +33,13 @@ func TestLockScreenPasswordFieldBypassesTextInputIME(t *testing.T) { if !strings.Contains(content, "Qt.ImhSensitiveData") { t.Fatalf("imeCommitSink must advertise hidden-text hints so IMEs treat it as a password field") } + + // fcitx5-qt parents its candidate xdg_popup to the focus window, and an + // ext-session-lock surface cannot parent popups (#3669). + sinkGate := regexp.MustCompile(`(?s)Loader\s*\{[^{}]*active:\s*KeyUtils\.compositorTextInputSelected\([^{}]*sourceComponent:\s*TextInput\s*\{\s*id:\s*imeCommitSink`) + if !sinkGate.MatchString(content) { + t.Fatalf("imeCommitSink must only load when Qt uses its wayland text-input context (#3669)") + } } func TestLockScreenFprintProfileMatchesPamQml(t *testing.T) { diff --git a/core/internal/server/notifyactions/handlers.go b/core/internal/server/notifyactions/handlers.go index 82f833370..52345c34b 100644 --- a/core/internal/server/notifyactions/handlers.go +++ b/core/internal/server/notifyactions/handlers.go @@ -47,6 +47,7 @@ func handleSend(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { Summary: summary, Body: params.StringOpt(req.Params, "body", ""), Icon: params.StringOpt(req.Params, "icon", ""), + Timeout: 5000, } label := params.StringOpt(req.Params, "actionLabel", "") var argv []string