diff options
| -rw-r--r-- | cmd/notifyctl/main.go | 127 | ||||
| -rw-r--r-- | internal/notify/control.go | 35 | ||||
| -rwxr-xr-x | test-notifyctl.sh | 65 |
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 ]] |
