cli/notify: add priority options

This commit is contained in:
bbedward committed 2026-10-06 10:10:42 -04:00
1 parent ca93837149
commit 5ee570557f
4 files changed
+363 -97

No files matched your search

+238 -27
View File
@@ -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 <summary> [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(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "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(&notifyUrgency, "urgency", "u", "", "Urgency level: low, normal, critical")
f.IntVarP(&notifyTimeout, "expire-time", "t", -1, "Timeout in milliseconds (0 never expires, -1 server default)")
f.StringVarP(&notifyAppName, "app-name", "a", "DMS", "Application name")
f.StringVarP(&notifyIcon, "icon", "i", "", "Icon name or path")
f.StringVarP(&notifyAppIcon, "app-icon", "n", "", "Application icon name or path; --icon then becomes the image")
f.StringVar(&notifyCategory, "category", "", "Notification category, comma separated (-c is taken by the global --config)")
f.BoolVarP(&notifyTransient, "transient", "e", false, "Do not keep the notification in history")
f.StringArrayVarP(&notifyHints, "hint", "h", nil, "Extra hint as TYPE:NAME:VALUE (boolean, int, double, string, byte)")
f.BoolVarP(&notifyPrintID, "print-id", "p", false, "Print the notification id")
f.IntVar(&notifyIDFd, "id-fd", -1, "File descriptor to write the notification id to")
f.Uint32VarP(&notifyReplaceID, "replace-id", "r", 0, "Id of the notification to replace")
f.BoolVarP(&notifyWait, "wait", "w", false, "Wait until the notification closes")
f.StringArrayVarP(&notifyActions, "action", "A", nil, "Action as [NAME=]Text; implies --wait, prints the chosen NAME (repeatable)")
f.IntVar(&notifyActionFd, "selected-action-fd", -1, "File descriptor to write the chosen action to")
f.StringVar(&notifyFile, "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) {
+117 -70
View File
@@ -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]) + "..."
}
@@ -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) {
@@ -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