aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/notifyctl/main.go127
-rw-r--r--internal/notify/control.go35
-rwxr-xr-xtest-notifyctl.sh65
3 files changed, 225 insertions, 2 deletions
diff --git a/cmd/notifyctl/main.go b/cmd/notifyctl/main.go
new file mode 100644
index 0000000..ca1db86
--- /dev/null
+++ b/cmd/notifyctl/main.go
@@ -0,0 +1,127 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "danix.xyz/notifyd/internal/notify"
+ "github.com/godbus/dbus/v5"
+)
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `usage: %s <verb>
+ list the live queue as JSON
+ history [n] the history ring as JSON, default 20
+ close <id> dismiss one notification
+ close-all dismiss every notification
+ action <id> <key> invoke an action on a live notification
+ clear-history empty the history ring
+`, filepath.Base(os.Args[0]))
+ os.Exit(2)
+}
+
+func main() {
+ if len(os.Args) < 2 {
+ usage()
+ }
+ verb := os.Args[1]
+
+ switch verb {
+ case "list":
+ printFile("queue.json", 0)
+ case "history":
+ limit := 20
+ if len(os.Args) >= 3 {
+ n, err := strconv.Atoi(os.Args[2])
+ if err != nil {
+ usage()
+ }
+ limit = n
+ }
+ printFile("history.json", limit)
+ case "close":
+ need(3)
+ id := parseID(os.Args[2])
+ callControl("Dismiss", id)
+ case "close-all":
+ callControl("CloseAll")
+ case "action":
+ need(4)
+ id := parseID(os.Args[2])
+ callControl("InvokeAction", id, os.Args[3])
+ case "clear-history":
+ callControl("ClearHistory")
+ default:
+ usage()
+ }
+}
+
+func need(n int) {
+ if len(os.Args) < n {
+ usage()
+ }
+}
+
+func parseID(s string) uint32 {
+ id, err := strconv.ParseUint(s, 10, 32)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s: bad id %q\n", filepath.Base(os.Args[0]), s)
+ os.Exit(1)
+ }
+ return uint32(id)
+}
+
+// printFile reads a published file, because the files are the interface.
+func printFile(name string, limit int) {
+ data, err := os.ReadFile(filepath.Join(notify.RuntimeDir(), name))
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err)
+ os.Exit(1)
+ }
+ if limit > 0 {
+ var all []notify.Popup
+ if err := json.Unmarshal(data, &all); err != nil {
+ fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err)
+ os.Exit(1)
+ }
+ if len(all) > limit {
+ all = all[:limit]
+ }
+ out, _ := json.MarshalIndent(all, "", " ")
+ fmt.Println(string(out))
+ return
+ }
+ var pretty any
+ json.Unmarshal(data, &pretty)
+ out, _ := json.MarshalIndent(pretty, "", " ")
+ fmt.Println(string(out))
+}
+
+// callControl drives the daemon over the private interface. It is the only
+// thing that mutates state.
+func callControl(method string, args ...any) {
+ conn, err := dbus.SessionBus()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "notifyctl: session bus: %v\n", err)
+ os.Exit(1)
+ }
+ obj := conn.Object(notify.Name, dbus.ObjectPath("/xyz/danix/Notifyd"))
+ if err := obj.Call("xyz.danix.Notifyd."+method, 0, args...).Err; err != nil {
+ fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/internal/notify/control.go b/internal/notify/control.go
index 4783711..a42a14b 100644
--- a/internal/notify/control.go
+++ b/internal/notify/control.go
@@ -11,14 +11,45 @@
package notify
-import "github.com/godbus/dbus/v5"
+import (
+ "errors"
-// control is the private interface notifyctl drives.
+ "github.com/godbus/dbus/v5"
+)
+
+// control is the private interface notifyctl drives. It exists because the
+// freedesktop spec has no close-all, no way to invoke an action, and no
+// history to clear.
type control struct {
svc *Service
}
+// CloseAll dismisses every live notification, reason 2.
func (c *control) CloseAll() *dbus.Error {
c.svc.store.DismissAll()
return nil
}
+
+// Dismiss closes one live notification, reason 2, which is what a click on the
+// X means.
+func (c *control) Dismiss(id uint32) *dbus.Error {
+ c.svc.stopTimer(id)
+ c.svc.store.Dismiss(id, 2)
+ return nil
+}
+
+// InvokeAction emits ActionInvoked for a live notification. An inert entry has
+// no client left, so it is an error rather than a silent no-op.
+func (c *control) InvokeAction(id uint32, key string) *dbus.Error {
+ if !c.svc.store.Actionable(id) {
+ return dbus.MakeFailedError(errors.New("notification is no longer live"))
+ }
+ c.svc.conn.Emit(dbus.ObjectPath(objPath), iface+".ActionInvoked", id, key)
+ return nil
+}
+
+// ClearHistory empties the history ring.
+func (c *control) ClearHistory() *dbus.Error {
+ c.svc.store.ClearHistory()
+ return nil
+}
diff --git a/test-notifyctl.sh b/test-notifyctl.sh
new file mode 100755
index 0000000..7018e2a
--- /dev/null
+++ b/test-notifyctl.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for notifyctl. It runs the daemon and the CLI on a
+# private session bus with a temporary runtime directory, so nothing here
+# touches the live notification state.
+#
+# Usage: ./test-notifyctl.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+pass=0
+fail=0
+check() {
+ local label="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ printf 'ok %s\n' "$label"
+ pass=$((pass + 1))
+ else
+ printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got"
+ fail=$((fail + 1))
+ fi
+}
+
+go build -o "$tmp/notifyd" "$here/cmd/notifyd" || exit 1
+go build -o "$tmp/notifyctl" "$here/cmd/notifyctl" || exit 1
+
+mkdir -p "$tmp/run"
+export XDG_RUNTIME_DIR="$tmp/run"
+export PATH="$tmp:$PATH"
+
+dbus-run-session -- bash -c '
+ set -u
+ "$1/notifyd" >"$1/daemon.log" 2>&1 & daemon=$!
+ sleep 0.5
+ notifyctl close-all >/dev/null 2>&1
+ notify-send -a test -u normal "t1" "b1" || exit 3
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ notifyctl close-all
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ kill $daemon
+' _ "$tmp" > "$tmp/out" 2>"$tmp/err"
+
+check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")"
+check "close-all empties the live queue" "0" "$(sed -n 2p "$tmp/out")"
+check "no errors on stderr" "" "$(cat "$tmp/err")"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]