aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-15-notifyd.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 13:38:17 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 13:38:17 +0200
commitc38ed6e97eb10621ce59a6d548babc7510051c2f (patch)
tree5ab1246834b986c7bf04564f2658e208049036e4 /docs/superpowers/plans/2026-09-15-notifyd.md
parent320813f256a7d141d130fda036baa9691aa90716 (diff)
downloadquickshell-c38ed6e97eb10621ce59a6d548babc7510051c2f.tar.gz
quickshell-c38ed6e97eb10621ce59a6d548babc7510051c2f.zip
docs: add the notifyd implementation plan
Plan one of two for the notification daemon: the Go daemon and notifyctl in their own repo. The renderer plan is separate because the daemon is working, testable software on its own, exercised through notifyctl. The policy is pure and tested without a bus; the store's expiry, dismissal and eviction are separate lifetimes, which the store tests pin; and test-notifyctl.sh runs the daemon and CLI on a private session bus against a temporary runtime directory.
Diffstat (limited to 'docs/superpowers/plans/2026-09-15-notifyd.md')
-rw-r--r--docs/superpowers/plans/2026-09-15-notifyd.md1780
1 files changed, 1780 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-15-notifyd.md b/docs/superpowers/plans/2026-09-15-notifyd.md
new file mode 100644
index 0000000..711a86d
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-notifyd.md
@@ -0,0 +1,1780 @@
+# notifyd Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A Go daemon that owns `org.freedesktop.Notifications`, applies notification policy, and publishes its state as files that quickshell renderers read, plus a `notifyctl` CLI.
+
+**Architecture:** The daemon is the D-Bus service of the freedesktop notification spec. It keeps a live queue and a history ring in memory, publishes both to `$XDG_RUNTIME_DIR/notifyd/` on every change with an atomic write, and serves `notifyctl` over a private D-Bus interface. Rendering is a separate plan; this plan produces a daemon that works and is testable on its own through `notifyctl`.
+
+**Tech Stack:** Go, `github.com/godbus/dbus/v5`, `dbus-run-session` for the integration tests, bash for the CLI check.
+
+**Spec:** `docs/superpowers/specs/2026-09-15-notification-daemon-design.md` (in the quickshell repo; read it before starting). The renderer plan is separate.
+
+## Global Constraints
+
+- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library.
+- GPLv2 only. Ship `LICENSE` with the full GPLv2 text and the standard per-file header notice on every `.go` and `.sh` file.
+- The file contract is exact and lives in the spec: `$XDG_RUNTIME_DIR/notifyd/queue.json`, `history.json`, `drawer`, `snooze`. `created` and `expires` are epoch milliseconds, `0` meaning never.
+- D-Bus: well-known name `org.freedesktop.Notifications`, object `/org/freedesktop/Notifications`, interface `org.freedesktop.Notifications`. Private control interface `xyz.danix.Notifyd` at object `/xyz/danix/Notifyd`.
+- Identity is `danix`; spec version `1.2`; capabilities `actions`, `body-markup`, `icon-static`, `persistence`.
+- Close reasons: `1` expired, `2` dismissed, `3` closed by a `CloseNotification` call.
+- No home paths in committed files. `gofmt` clean. `go vet ./...` clean.
+- Test commands: `go test ./...` for pure logic, `dbus-run-session -- go test ./internal/notify` for the bus test, `bash test-notifyctl.sh` for the end to end check.
+- Work in the `notifyd` repo, not the quickshell repo.
+
+---
+
+## File Structure
+
+ notifyd/
+ go.mod, go.sum
+ LICENSE
+ README.md
+ cmd/notifyd/main.go claims the bus name, starts the service
+ cmd/notifyctl/main.go the CLI
+ internal/notify/
+ policy.go urgency, timeout, stack tag, actions (pure)
+ policy_test.go
+ store.go the live queue and history ring (pure state)
+ store_test.go
+ files.go runtime dir and atomic JSON publish
+ files_test.go
+ service.go the D-Bus service and its timers
+ service_test.go session-bus integration test
+ control.go the private control interface
+ scripts/notify-snooze.sh
+ test-notifyctl.sh
+ test-notify-snooze.sh
+
+---
+
+### Task 1: The repository, the module, and the policy functions
+
+**Files:**
+- Create: the `notifyd` repo (user step), `go.mod`, `LICENSE`, `README.md`
+- Create: `internal/notify/policy.go`
+- Test: `internal/notify/policy_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `type Urgency string` with `Low`, `Normal`, `Critical`; `UrgencyFromHints(map[string]dbus.Variant) Urgency`; `EffectiveTimeoutMS(expire int32, u Urgency) int64`; `StackTagFromHints(map[string]dbus.Variant) string`; `ParseActions(flat []string) [][2]string`. Every later task uses these.
+
+- [ ] **Step 1: Create the repo on the server and clone it**
+
+Outward-facing, and phase 1 needs the smartcard, so this is a user step. Ask the user to run:
+
+```bash
+gitctl -y repo create notifyd --private --desc "Desktop notification daemon"
+git clone danix_git:notifyd ~/Programming/GIT/notifyd
+```
+
+`--private` keeps it off the public cgit index; to publish it instead, drop `--private` and add `--section "<name>"` from `gitctl sections list`. Expected: the repo exists on the server and is cloned. All later steps run in `~/Programming/GIT/notifyd`.
+
+- [ ] **Step 2: Initialise the module and the license**
+
+```bash
+cd ~/Programming/GIT/notifyd
+go mod init danix.xyz/notifyd
+go get github.com/godbus/dbus/v5@latest
+```
+
+Fetch the GPLv2 text into `LICENSE`:
+
+```bash
+curl -fsSL https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt -o LICENSE
+head -3 LICENSE
+```
+
+Expected: `GNU GENERAL PUBLIC LICENSE` and `Version 2, June 1991`.
+
+- [ ] **Step 3: Write the failing test for the policy functions**
+
+Create `internal/notify/policy_test.go`:
+
+```go
+package notify
+
+import (
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func TestUrgencyFromHints(t *testing.T) {
+ cases := []struct {
+ name string
+ hints map[string]dbus.Variant
+ want Urgency
+ }{
+ {"missing is normal", map[string]dbus.Variant{}, Normal},
+ {"low byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(0))}, Low},
+ {"normal byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}, Normal},
+ {"critical byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2))}, Critical},
+ {"critical int32", map[string]dbus.Variant{"urgency": dbus.MakeVariant(int32(2))}, Critical},
+ {"wrong type is normal", map[string]dbus.Variant{"urgency": dbus.MakeVariant("2")}, Normal},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := UrgencyFromHints(c.hints); got != c.want {
+ t.Errorf("UrgencyFromHints = %q, want %q", got, c.want)
+ }
+ })
+ }
+}
+
+func TestEffectiveTimeoutMS(t *testing.T) {
+ cases := []struct {
+ name string
+ expire int32
+ u Urgency
+ want int64
+ }{
+ {"zero uses low default", 0, Low, 10_000},
+ {"zero uses normal default", 0, Normal, 10_000},
+ {"zero critical never", 0, Critical, 0},
+ {"minus one never", -1, Normal, 0},
+ {"explicit wins", 3_000, Normal, 3_000},
+ {"sub second exact", 1, Normal, 1},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := EffectiveTimeoutMS(c.expire, c.u); got != c.want {
+ t.Errorf("EffectiveTimeoutMS(%d, %q) = %d, want %d", c.expire, c.u, got, c.want)
+ }
+ })
+ }
+}
+
+func TestStackTagFromHints(t *testing.T) {
+ dunst := map[string]dbus.Variant{"x-dunst-stack-tag": dbus.MakeVariant("mail-a")}
+ danix := map[string]dbus.Variant{"x-danix-stack-tag": dbus.MakeVariant("mail-b")}
+ both := map[string]dbus.Variant{
+ "x-dunst-stack-tag": dbus.MakeVariant("mail-a"),
+ "x-danix-stack-tag": dbus.MakeVariant("mail-b"),
+ }
+ if got := StackTagFromHints(dunst); got != "mail-a" {
+ t.Errorf("dunst tag = %q, want mail-a", got)
+ }
+ if got := StackTagFromHints(danix); got != "mail-b" {
+ t.Errorf("danix tag = %q, want mail-b", got)
+ }
+ if got := StackTagFromHints(both); got != "mail-a" {
+ t.Errorf("dunst wins when both present = %q, want mail-a", got)
+ }
+ if got := StackTagFromHints(map[string]dbus.Variant{}); got != "" {
+ t.Errorf("empty = %q, want empty", got)
+ }
+}
+
+func TestParseActions(t *testing.T) {
+ got := ParseActions([]string{"default", "open", "other", "do the thing"})
+ want := [][2]string{{"default", "open"}, {"other", "do the thing"}}
+ if len(got) != len(want) {
+ t.Fatalf("len = %d, want %d", len(got), len(want))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("action %d = %v, want %v", i, got[i], want[i])
+ }
+ }
+ if got := ParseActions(nil); len(got) != 0 {
+ t.Errorf("nil actions = %v, want empty", got)
+ }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it fails**
+
+Run: `go test ./internal/notify`
+Expected: FAIL, the package does not compile because the functions are undefined.
+
+- [ ] **Step 5: Write the implementation**
+
+Create `internal/notify/policy.go`:
+
+```go
+// 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 notify
+
+import "github.com/godbus/dbus/v5"
+
+// Urgency is the freedesktop urgency level.
+type Urgency string
+
+const (
+ Low Urgency = "low"
+ Normal Urgency = "normal"
+ Critical Urgency = "critical"
+)
+
+// DefaultTimeoutMS is the balloon lifetime for an urgency when the client sends
+// expire_timeout 0. Critical never expires, which is 0 here.
+func DefaultTimeoutMS(u Urgency) int64 {
+ switch u {
+ case Low, Normal:
+ return 10_000
+ default:
+ return 0
+ }
+}
+
+// EffectiveTimeoutMS resolves the client's expire_timeout: 0 means the urgency
+// default, -1 means never, anything positive is milliseconds and wins.
+func EffectiveTimeoutMS(expire int32, u Urgency) int64 {
+ switch {
+ case expire == -1:
+ return 0
+ case expire == 0:
+ return DefaultTimeoutMS(u)
+ default:
+ return int64(expire)
+ }
+}
+
+// UrgencyFromHints reads the urgency byte: 0 low, 1 normal, 2 critical. A
+// missing or malformed value is normal, the same default libnotify uses.
+func UrgencyFromHints(hints map[string]dbus.Variant) Urgency {
+ v, ok := hints["urgency"]
+ if !ok {
+ return Normal
+ }
+ switch n := v.Value().(type) {
+ case uint8:
+ switch n {
+ case 0:
+ return Low
+ case 2:
+ return Critical
+ }
+ case int32:
+ switch n {
+ case 0:
+ return Low
+ case 2:
+ return Critical
+ }
+ }
+ return Normal
+}
+
+// StackTagFromHints reads the dunst stack tag, then the danix spelling. Both
+// mean the same thing and dunst wins when a client sends both.
+func StackTagFromHints(hints map[string]dbus.Variant) string {
+ for _, key := range []string{"x-dunst-stack-tag", "x-danix-stack-tag"} {
+ if v, ok := hints[key]; ok {
+ if s, ok := v.Value().(string); ok && s != "" {
+ return s
+ }
+ }
+ }
+ return ""
+}
+
+// ParseActions turns the spec's flat [key, label, key, label] array into pairs.
+func ParseActions(flat []string) [][2]string {
+ out := make([][2]string, 0, len(flat)/2)
+ for i := 0; i+1 < len(flat); i += 2 {
+ out = append(out, [2]string{flat[i], flat[i+1]})
+ }
+ return out
+}
+```
+
+- [ ] **Step 6: Run the test to verify it passes**
+
+Run: `go test ./internal/notify`
+Expected: PASS.
+
+- [ ] **Step 7: Write `README.md`**
+
+```markdown
+# notifyd
+
+A freedesktop notification daemon for this desktop, replacing dunst.
+
+The daemon owns `org.freedesktop.Notifications` and holds the state. It
+publishes the live queue and the history ring as JSON under
+`$XDG_RUNTIME_DIR/notifyd/` for a quickshell renderer to draw, and `notifyctl`
+is the only thing that talks back over D-Bus.
+
+ go build ./...
+
+The design and the exact file contract are in the quickshell repo, at
+`docs/superpowers/specs/2026-09-15-notification-daemon-design.md`.
+
+## Development Approach
+
+This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback.
+
+All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions.
+
+The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability.
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+gofmt -w . && go vet ./... && go test ./...
+git add .
+git commit -m "feat: add the module and the notification policy
+
+The policy functions are pure so they are tested without a bus: urgency from
+the hints, the timeout rule with its urgency defaults, the two stack tag
+spellings, and the action pair parse."
+```
+
+---
+
+### Task 2: The store
+
+**Files:**
+- Create: `internal/notify/store.go`
+- Test: `internal/notify/store_test.go`
+
+**Interfaces:**
+- Consumes: `Urgency`, `Popup` (defined here).
+- Produces: `type Popup struct { ID uint32; App, Summary, Body string; Urgency Urgency; Icon string; Actions [][2]string; Created, Expires int64 }` with the exact JSON tags; `type Store`; `NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store`; `(*Store) Add(n *Popup, stack string, replacesID uint32) (id uint32, replaced bool)`; `(*Store) Expire(id uint32)`; `(*Store) Dismiss(id, reason uint32)`; `(*Store) DismissAll()`; `(*Store) ClearHistory()`; `(*Store) Actionable(id uint32) bool`; `(*Store) Reset()`. Tasks 3 to 6 use all of these.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/store_test.go`:
+
+```go
+package notify
+
+import "testing"
+
+type event struct {
+ id uint32
+ reason uint32
+}
+
+func newTestStore() (*Store, *[]event) {
+ emitted := &[]event{}
+ s := NewStore(
+ func(id, reason uint32) { *emitted = append(*emitted, event{id, reason}) },
+ func(live, history []Popup) {},
+ )
+ return s, emitted
+}
+
+func popup(app string) *Popup {
+ return &Popup{App: app, Urgency: Normal, Created: 1}
+}
+
+func TestAddAssignsIdsFromOne(t *testing.T) {
+ s, _ := newTestStore()
+ a, replaced := s.Add(popup("a"), "", 0)
+ b, _ := s.Add(popup("b"), "", 0)
+ if replaced {
+ t.Fatal("first add reported replaced")
+ }
+ if a != 1 || b != 2 {
+ t.Errorf("ids = %d, %d, want 1, 2", a, b)
+ }
+}
+
+func TestReplaceByIDReusesTheIDAndEmitsNothing(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ *emitted = nil
+ again, replaced := s.Add(popup("a2"), "", id)
+ if !replaced {
+ t.Fatal("replace by id not reported")
+ }
+ if again != id {
+ t.Errorf("replace id = %d, want %d", again, id)
+ }
+ if len(*emitted) != 0 {
+ t.Errorf("replace emitted %v, want none", *emitted)
+ }
+}
+
+func TestReplaceByStackTag(t *testing.T) {
+ s, _ := newTestStore()
+ first, _ := s.Add(popup("mail"), "mail-account", 0)
+ second, replaced := s.Add(popup("mail"), "mail-account", 0)
+ if !replaced || second != first {
+ t.Errorf("stack replace = id %d replaced %v, want id %d replaced true", second, replaced, first)
+ }
+}
+
+func TestExpireClosesOnceAndKeepsTheEntry(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Expire(id)
+ s.Expire(id)
+ if len(*emitted) != 1 || (*emitted)[0].reason != 1 {
+ t.Fatalf("emitted = %v, want one reason 1", *emitted)
+ }
+ if s.Actionable(id) {
+ t.Error("expired entry still actionable")
+ }
+}
+
+func TestDismissFilesHistoryAndEmitsDismissed(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Dismiss(id, 2)
+ if len(*emitted) != 1 || (*emitted)[0].reason != 2 {
+ t.Fatalf("emitted = %v, want one reason 2", *emitted)
+ }
+ if s.Actionable(id) {
+ t.Error("dismissed entry still live")
+ }
+}
+
+func TestDismissAfterExpireEmitsNothingMore(t *testing.T) {
+ s, emitted := newTestStore()
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Expire(id)
+ *emitted = nil
+ s.Dismiss(id, 2)
+ if len(*emitted) != 0 {
+ t.Errorf("dismiss after expire emitted %v, want none", *emitted)
+ }
+}
+
+func TestQueueCapEvictsToHistory(t *testing.T) {
+ s, emitted := newTestStore()
+ for i := 0; i < 21; i++ {
+ s.Add(popup("a"), "", 0)
+ }
+ if len(*emitted) == 0 {
+ t.Fatal("eviction emitted nothing")
+ }
+}
+
+func TestHistoryRingCapsAtTwenty(t *testing.T) {
+ s, _ := newTestStore()
+ for i := 0; i < 30; i++ {
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Dismiss(id, 2)
+ }
+ if got := len(s.historySnapshot()); got != 20 {
+ t.Errorf("history length = %d, want 20", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run TestAdd`
+Expected: FAIL, `Store` is undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/store.go`:
+
+```go
+// 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 notify
+
+import "sync"
+
+// Popup is one notification as the renderers see it. The JSON tags are the
+// file contract in the spec.
+type Popup struct {
+ ID uint32 `json:"id"`
+ App string `json:"app"`
+ Summary string `json:"summary"`
+ Body string `json:"body"`
+ Urgency Urgency `json:"urgency"`
+ Icon string `json:"icon"`
+ Actions [][2]string `json:"actions"`
+ Created int64 `json:"created"`
+ Expires int64 `json:"expires"`
+}
+
+// live rounds out a Popup with the state the renderers do not need: the stack
+// tag it replaces on, and whether the D-Bus client has already been closed.
+type live struct {
+ Popup
+ Stack string
+ Closed bool
+}
+
+const (
+ liveCap = 20
+ historyCap = 20
+)
+
+// Store holds the live queue and the history ring. Time, signals and file
+// writes are injected, so the whole thing is tested without a bus or a clock.
+type Store struct {
+ mu sync.Mutex
+ nextID uint32
+ order []uint32
+ entries map[uint32]*live
+ history []*live
+ emit func(id, reason uint32)
+ publish func(live, history []Popup)
+}
+
+func NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store {
+ return &Store{
+ nextID: 1,
+ entries: map[uint32]*live{},
+ emit: emit,
+ publish: publish,
+ }
+}
+
+// Add inserts n, replacing the entry named by replacesID or stack when one
+// matches. A replace reuses the id and emits nothing: the old client is told
+// nothing because a new client owns the id now.
+func (s *Store) Add(n *Popup, stack string, replacesID uint32) (uint32, bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ var old *live
+ if replacesID != 0 {
+ old = s.entries[replacesID]
+ }
+ if old == nil && stack != "" {
+ for _, id := range s.order {
+ if s.entries[id].Stack == stack {
+ old = s.entries[id]
+ break
+ }
+ }
+ }
+ add := &live{Popup: *n, Stack: stack}
+ if old != nil {
+ add.ID = old.ID
+ s.entries[add.ID] = add
+ s.moveToFrontLocked(add.ID)
+ s.evictLocked()
+ s.publishLocked()
+ return add.ID, true
+ }
+ add.ID = s.nextID
+ s.nextID++
+ s.entries[add.ID] = add
+ s.order = append([]uint32{add.ID}, s.order...)
+ s.evictLocked()
+ s.publishLocked()
+ return add.ID, false
+}
+
+// Expire is the balloon timeout: tell the client, keep the entry as inert.
+func (s *Store) Expire(id uint32) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok || n.Closed {
+ return
+ }
+ n.Closed = true
+ s.emit(id, 1)
+}
+
+// Dismiss is an explicit close from either renderer. The client is told only
+// if expiry has not already told it, then the entry is filed.
+func (s *Store) Dismiss(id, reason uint32) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok {
+ return
+ }
+ s.removeLocked(id)
+ s.fileLocked(n, reason)
+ s.publishLocked()
+}
+
+// DismissAll dismisses every live entry.
+func (s *Store) DismissAll() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, id := range append([]uint32(nil), s.order...) {
+ n := s.entries[id]
+ if n == nil {
+ continue
+ }
+ s.removeLocked(id)
+ s.fileLocked(n, 2)
+ }
+ s.publishLocked()
+}
+
+// ClearHistory empties the history ring.
+func (s *Store) ClearHistory() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.history = nil
+ s.publishLocked()
+}
+
+// Actionable reports whether a client is still listening on the id.
+func (s *Store) Actionable(id uint32) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ return ok && !n.Closed
+}
+
+// Reset makes the state empty, which is what a start needs: nothing from a
+// previous run is resurrected.
+func (s *Store) Reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.order = nil
+ s.entries = map[uint32]*live{}
+ s.history = nil
+ s.publishLocked()
+}
+
+// historySnapshot is for tests.
+func (s *Store) historySnapshot() []*live {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]*live(nil), s.history...)
+}
+
+func (s *Store) removeLocked(id uint32) {
+ delete(s.entries, id)
+ for i, v := range s.order {
+ if v == id {
+ s.order = append(s.order[:i], s.order[i+1:]...)
+ return
+ }
+ }
+}
+
+func (s *Store) moveToFrontLocked(id uint32) {
+ for i, v := range s.order {
+ if v == id {
+ s.order = append(s.order[:i], s.order[i+1:]...)
+ break
+ }
+ }
+ s.order = append([]uint32{id}, s.order...)
+}
+
+func (s *Store) fileLocked(n *live, reason uint32) {
+ if !n.Closed {
+ n.Closed = true
+ s.emit(n.ID, reason)
+ }
+ s.history = append([]*live{n}, s.history...)
+ if len(s.history) > historyCap {
+ s.history = s.history[:historyCap]
+ }
+}
+
+func (s *Store) evictLocked() {
+ for len(s.order) > liveCap {
+ id := s.order[len(s.order)-1]
+ n := s.entries[id]
+ s.removeLocked(id)
+ s.fileLocked(n, 1)
+ }
+}
+
+func (s *Store) publishLocked() {
+ liveList := make([]Popup, 0, len(s.order))
+ for _, id := range s.order {
+ liveList = append(liveList, s.entries[id].Popup)
+ }
+ historyList := make([]Popup, 0, len(s.history))
+ for _, n := range s.history {
+ historyList = append(historyList, n.Popup)
+ }
+ s.publish(liveList, historyList)
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```go
+gofmt -w . && go vet ./... && go test ./...
+git add .
+git commit -m "feat: add the notification store
+
+The store holds the live queue and the history ring as pure state. Expiry
+tells the client and keeps the entry inert; dismissal and eviction file it
+in history. Replacing reuses the id and emits nothing."
+```
+
+---
+
+### Task 3: Atomic file publishing
+
+**Files:**
+- Create: `internal/notify/files.go`
+- Test: `internal/notify/files_test.go`
+
+**Interfaces:**
+- Consumes: `Popup`.
+- Produces: `RuntimeDir() string`; `Publish(dir string, live, history []Popup) error`. Task 5 uses both.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/files_test.go`:
+
+```go
+package notify
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPublishWritesBothFiles(t *testing.T) {
+ dir := t.TempDir()
+ live := []Popup{{ID: 1, App: "a", Urgency: Normal, Created: 1, Expires: 2}}
+ history := []Popup{{ID: 2, App: "b", Urgency: Low, Created: 3}}
+ if err := Publish(dir, live, history); err != nil {
+ t.Fatalf("Publish: %v", err)
+ }
+ var gotLive []Popup
+ data, err := os.ReadFile(filepath.Join(dir, "queue.json"))
+ if err != nil {
+ t.Fatalf("read queue: %v", err)
+ }
+ if err := json.Unmarshal(data, &gotLive); err != nil {
+ t.Fatalf("queue not JSON: %v", err)
+ }
+ if len(gotLive) != 1 || gotLive[0].ID != 1 {
+ t.Errorf("queue = %+v, want one id 1", gotLive)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "history.json")); err != nil {
+ t.Errorf("history.json missing: %v", err)
+ }
+}
+
+func TestPublishEmptyIsAnEmptyArray(t *testing.T) {
+ dir := t.TempDir()
+ if err := Publish(dir, nil, nil); err != nil {
+ t.Fatalf("Publish: %v", err)
+ }
+ data, _ := os.ReadFile(filepath.Join(dir, "queue.json"))
+ var got []Popup
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("empty queue not JSON array: %v (%s)", err, data)
+ }
+ if string(data) != "[]" {
+ t.Errorf("empty queue encoded as %q, want []", data)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run TestPublish`
+Expected: FAIL, `Publish` undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/files.go`:
+
+```go
+// 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 notify
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// RuntimeDir is where the daemon publishes. It is tmpfs, so a reboot clears
+// every file and there is no cleanup code.
+func RuntimeDir() string {
+ if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
+ return filepath.Join(d, "notifyd")
+ }
+ return filepath.Join(os.TempDir(), "notifyd")
+}
+
+// Publish writes the live queue and the history ring, each whole, through a
+// temporary file and a rename. A reader never sees a half-written value, the
+// same atomic write the status registry relies on.
+func Publish(dir string, live, history []Popup) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ if live == nil {
+ live = []Popup{}
+ }
+ if history == nil {
+ history = []Popup{}
+ }
+ if err := writeJSON(filepath.Join(dir, "queue.json"), live); err != nil {
+ return err
+ }
+ return writeJSON(filepath.Join(dir, "history.json"), history)
+}
+
+func writeJSON(path string, v any) error {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
+ if err != nil {
+ return err
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(tmp.Name())
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmp.Name())
+ return err
+ }
+ return os.Rename(tmp.Name(), path)
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -w . && go vet ./... && go test ./...
+git add .
+git commit -m "feat: publish the queue and history atomically
+
+Both files are written whole through a temporary file and a rename, so a
+renderer never reads a half-written value. An empty queue is [] rather than
+null, because the renderer parses it as an array."
+```
+
+---
+
+### Task 4: The D-Bus service
+
+**Files:**
+- Create: `internal/notify/service.go`
+- Create: `cmd/notifyd/main.go`
+- Test: `internal/notify/service_test.go`
+
+**Interfaces:**
+- Consumes: `Store`, `Publish`, `RuntimeDir`, the policy functions.
+- Produces: `const Name = "org.freedesktop.Notifications"`; `NewService(conn *dbus.Conn, dir string) *Service`; `(*Service) Start() error`; the service methods `Notify`, `CloseNotification`, `GetCapabilities`, `GetServerInformation`; `(*Service) emitClosed` and `(*Service) arm`. Task 5 adds the control interface against the same `Service`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/service_test.go`:
+
+```go
+package notify
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// The integration tests need a session bus this process can own the name on.
+// Run them with dbus-run-session -- go test ./internal/notify
+func busOrSkip(t *testing.T) *dbus.Conn {
+ t.Helper()
+ conn, err := dbus.SessionBus()
+ if err != nil {
+ t.Skipf("no session bus: %v", err)
+ }
+ reply, err := conn.RequestName(Name, dbus.NameFlagDoNotQueue)
+ if err != nil {
+ t.Skipf("cannot request the name: %v", err)
+ }
+ // AlreadyOwner happens on the second bus test in one process, because
+ // dbus.SessionBus is a shared connection. That is fine: the name is ours.
+ if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner {
+ t.Skipf("cannot own the name, run under dbus-run-session (reply %v)", reply)
+ }
+ return conn
+}
+
+func TestIdentityAndCapabilities(t *testing.T) {
+ s := &Service{}
+ name, vendor, _, spec, err := s.GetServerInformation()
+ if err != nil {
+ t.Fatalf("GetServerInformation: %v", err)
+ }
+ if name != "danix" || vendor != "danix" || spec != "1.2" {
+ t.Errorf("identity = %q/%q spec %q, want danix/danix 1.2", name, vendor, spec)
+ }
+ caps, err := s.GetCapabilities()
+ if err != nil {
+ t.Fatalf("GetCapabilities: %v", err)
+ }
+ for _, want := range []string{"actions", "body-markup", "icon-static", "persistence"} {
+ found := false
+ for _, c := range caps {
+ if c == want {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("missing capability %q in %v", want, caps)
+ }
+ }
+}
+
+func TestNotifyReturnsAnIDAndPublishes(t *testing.T) {
+ conn := busOrSkip(t)
+ dir := t.TempDir()
+ svc := NewService(conn, dir)
+ if err := svc.Start(); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+
+ obj := conn.Object(Name, dbus.ObjectPath(objPath))
+ call := obj.Call(iface+".Notify", 0,
+ "app", uint32(0), "icon", "summary", "body",
+ []string{"default", "open"},
+ map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))},
+ int32(0))
+ if call.Err != nil {
+ t.Fatalf("Notify: %v", call.Err)
+ }
+ var id uint32
+ if err := call.Store(&id); err != nil {
+ t.Fatalf("Notify reply: %v", err)
+ }
+ if id == 0 {
+ t.Fatal("Notify returned id 0")
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "queue.json"))
+ if err != nil {
+ t.Fatalf("read queue: %v", err)
+ }
+ var live []Popup
+ if err := json.Unmarshal(data, &live); err != nil {
+ t.Fatalf("queue not JSON: %v", err)
+ }
+ if len(live) != 1 || live[0].Summary != "summary" {
+ t.Fatalf("queue = %+v, want one summary", live)
+ }
+ if live[0].Expires == 0 {
+ t.Error("expires is zero, want a normal timeout")
+ }
+}
+
+func TestCloseNotificationEmitsReasonThree(t *testing.T) {
+ conn := busOrSkip(t)
+ dir := t.TempDir()
+ svc := NewService(conn, dir)
+ if err := svc.Start(); err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ signals := make(chan *dbus.Signal, 4)
+ conn.Signal(signals)
+ if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(objPath))); err != nil {
+ t.Fatalf("AddMatchSignal: %v", err)
+ }
+
+ obj := conn.Object(Name, dbus.ObjectPath(objPath))
+ call := obj.Call(iface+".Notify", 0, "app", uint32(0), "", "s", "b", []string{}, map[string]dbus.Variant{}, int32(-1))
+ var id uint32
+ if err := call.Store(&id); err != nil {
+ t.Fatalf("Notify reply: %v", err)
+ }
+ if err := obj.Call(iface+".CloseNotification", 0, id).Err; err != nil {
+ t.Fatalf("CloseNotification: %v", err)
+ }
+
+ select {
+ case sig := <-signals:
+ if sig.Name != iface+".NotificationClosed" {
+ t.Fatalf("signal %q, want NotificationClosed", sig.Name)
+ }
+ if sig.Body[0].(uint32) != id || sig.Body[1].(uint32) != 3 {
+ t.Errorf("signal body = %v, want id %d reason 3", sig.Body, id)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("no NotificationClosed signal")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `dbus-run-session -- go test ./internal/notify -run TestNotify`
+Expected: FAIL, `Service` is undefined.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/service.go`:
+
+```go
+// 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 notify
+
+import (
+ "log"
+ "sync"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// Name is the well-known bus name the daemon owns.
+const Name = "org.freedesktop.Notifications"
+
+const (
+ objPath = "/org/freedesktop/Notifications"
+ iface = "org.freedesktop.Notifications"
+ ctrlPath = "/xyz/danix/Notifyd"
+ ctrlIface = "xyz.danix.Notifyd"
+)
+
+// Service is the org.freedesktop.Notifications object.
+type Service struct {
+ conn *dbus.Conn
+ store *Store
+ dir string
+
+ mu sync.Mutex
+ timers map[uint32]*time.Timer
+}
+
+func NewService(conn *dbus.Conn, dir string) *Service {
+ s := &Service{conn: conn, dir: dir, timers: map[uint32]*time.Timer{}}
+ s.store = NewStore(s.emitClosed, func(live, history []Popup) {
+ if err := Publish(s.dir, live, history); err != nil {
+ log.Printf("notifyd: publish: %v", err)
+ }
+ })
+ return s
+}
+
+// Start exports the interfaces and empties the state. Nothing from a previous
+// run is resurrected.
+func (s *Service) Start() error {
+ if err := s.conn.Export(s, dbus.ObjectPath(objPath), iface); err != nil {
+ return err
+ }
+ if err := s.conn.Export(&control{s}, dbus.ObjectPath(ctrlPath), ctrlIface); err != nil {
+ return err
+ }
+ s.store.Reset()
+ return nil
+}
+
+func (s *Service) emitClosed(id, reason uint32) {
+ s.conn.Emit(dbus.ObjectPath(objPath), iface+".NotificationClosed", id, reason)
+}
+
+// GetCapabilities tells clients what the daemon understands. actions and
+// body-markup are load-bearing: mail-notify sends actions and escapes its body
+// because the running dunst advertises markup.
+func (s *Service) GetCapabilities() ([]string, *dbus.Error) {
+ return []string{"actions", "body-markup", "icon-static", "persistence"}, nil
+}
+
+func (s *Service) GetServerInformation() (string, string, string, string, *dbus.Error) {
+ return "danix", "danix", "0.1", "1.2", nil
+}
+
+// Notify is the spec's entry point. The id is returned to the client; a
+// replace reuses the id of what it replaced.
+func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, body string, actions []string, hints map[string]dbus.Variant, expireTimeout int32) (uint32, *dbus.Error) {
+ u := UrgencyFromHints(hints)
+ tag := StackTagFromHints(hints)
+ now := time.Now().UnixMilli()
+ ms := EffectiveTimeoutMS(expireTimeout, u)
+ n := &Popup{
+ App: appName,
+ Summary: summary,
+ Body: body,
+ Urgency: u,
+ Icon: appIcon,
+ Actions: ParseActions(actions),
+ Created: now,
+ }
+ if ms > 0 {
+ n.Expires = now + ms
+ }
+ id, _ := s.store.Add(n, tag, replacesID)
+ s.arm(id, ms)
+ return id, nil
+}
+
+// CloseNotification is the spec's programmatic close, reason 3.
+func (s *Service) CloseNotification(id uint32) *dbus.Error {
+ s.stopTimer(id)
+ s.store.Dismiss(id, 3)
+ return nil
+}
+
+func (s *Service) arm(id uint32, ms int64) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.stopTimerLocked(id)
+ if ms <= 0 {
+ return
+ }
+ s.timers[id] = time.AfterFunc(time.Duration(ms)*time.Millisecond, func() {
+ s.store.Expire(id)
+ })
+}
+
+func (s *Service) stopTimer(id uint32) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.stopTimerLocked(id)
+}
+
+func (s *Service) stopTimerLocked(id uint32) {
+ if t, ok := s.timers[id]; ok {
+ t.Stop()
+ delete(s.timers, id)
+ }
+}
+```
+
+Create `cmd/notifyd/main.go`:
+
+```go
+// 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 (
+ "log"
+
+ "danix.xyz/notifyd/internal/notify"
+ "github.com/godbus/dbus/v5"
+)
+
+func main() {
+ conn, err := dbus.SessionBus()
+ if err != nil {
+ log.Fatalf("notifyd: session bus: %v", err)
+ }
+ reply, err := conn.RequestName(notify.Name, dbus.NameFlagDoNotQueue)
+ if err != nil {
+ log.Fatalf("notifyd: request %s: %v", notify.Name, err)
+ }
+ if reply != dbus.RequestNameReplyPrimaryOwner {
+ log.Fatalf("notifyd: %s is already owned (is dunst running?)", notify.Name)
+ }
+ svc := notify.NewService(conn, notify.RuntimeDir())
+ if err := svc.Start(); err != nil {
+ log.Fatalf("notifyd: %v", err)
+ }
+ log.Printf("notifyd: listening on %s", notify.Name)
+ select {}
+}
+```
+
+- [ ] **Step 4: Write a minimal control object so the package compiles**
+
+Create `internal/notify/control.go` with the struct only; Task 5 fills the methods:
+
+```go
+// 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 notify
+
+import "github.com/godbus/dbus/v5"
+
+// control is the private interface notifyctl drives.
+type control struct {
+ svc *Service
+}
+
+func (c *control) CloseAll() *dbus.Error {
+ c.svc.store.DismissAll()
+ return nil
+}
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `dbus-run-session -- go test ./internal/notify`
+Expected: PASS. Also `go test ./internal/notify` without a bus skips the bus tests and passes.
+
+- [ ] **Step 6: Commit**
+
+```bash
+gofmt -w . && go vet ./... && dbus-run-session -- go test ./...
+git add .
+git commit -m "feat: add the D-Bus service and the daemon
+
+Notify assigns an id and publishes; a replaces_id or stack tag reuses the id.
+CloseNotification closes with reason 3. The daemon claims
+org.freedesktop.Notifications and exits non-zero if it cannot, which is what
+happens while dunst still holds it."
+```
+
+---
+
+### Task 5: The control interface and notifyctl
+
+**Files:**
+- Modify: `internal/notify/control.go`
+- Modify: `internal/notify/store.go` (add nothing unless needed; `Actionable` and `Dismiss` exist)
+- Create: `cmd/notifyctl/main.go`
+- Test: `test-notifyctl.sh`
+
+**Interfaces:**
+- Consumes: `Service`, `Store`, `RuntimeDir`, `Popup`.
+- Produces: control methods `CloseAll`, `Dismiss(id)`, `InvokeAction(id, key)`, `ClearHistory`; the `notifyctl` verbs `list`, `history [n]`, `close <id>`, `close-all`, `action <id> <key>`, `clear-history`.
+
+- [ ] **Step 1: Extend the control interface**
+
+Replace `internal/notify/control.go` with:
+
+```go
+// 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 notify
+
+import (
+ "errors"
+
+ "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
+}
+```
+
+- [ ] **Step 2: Write `cmd/notifyctl/main.go`**
+
+```go
+// 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)
+ }
+}
+```
+
+- [ ] **Step 3: Write the failing check `test-notifyctl.sh`**
+
+```bash
+#!/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
+
+export XDG_RUNTIME_DIR="$tmp"
+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 ]]
+```
+
+The daemon's log goes to its own file, so `$tmp/err` only carries real errors. `notify-send` comes from libnotify, which is installed. The sleeps give the daemon time to claim the name before the first send.
+
+- [ ] **Step 4: Run the check**
+
+Run: `bash test-notifyctl.sh`
+Expected: `3 passed, 0 failed`. (Timing is generous: the daemon needs a moment to claim the name before the first `notify-send`, hence the sleeps.)
+
+- [ ] **Step 5: Run everything**
+
+Run: `gofmt -w . && go vet ./... && go test ./... && dbus-run-session -- go test ./... && bash test-notifyctl.sh`
+Expected: all pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+chmod +x test-notifyctl.sh
+git add .
+git commit -m "feat: add the control interface and notifyctl
+
+The private interface carries what the spec cannot: close-all, invoke action
+and clear history. notifyctl reads the published files for list and history,
+because the files are the interface, and uses D-Bus only for the mutations."
+```
+
+---
+
+### Task 6: notify-snooze.sh
+
+**Files:**
+- Create: `scripts/notify-snooze.sh`
+- Test: `test-notify-snooze.sh`
+
+**Interfaces:**
+- Consumes: `$XDG_RUNTIME_DIR/notifyd/` (writes `snooze`).
+- Produces: `notify-snooze.sh <minutes|off>`, and the last used value in `~/.local/state/notify-snooze.minutes`. The renderer plan reads both files.
+
+- [ ] **Step 1: Write the failing check**
+
+Create `test-notify-snooze.sh`:
+
+```bash
+#!/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.
+#
+# Usage: ./test-notify-snooze.sh
+
+set -u
+here="$(cd "$(dirname "$0")" && pwd)"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+export XDG_RUNTIME_DIR="$tmp"
+export HOME="$tmp/home"
+mkdir -p "$HOME/.local/state"
+
+pass=0; fail=0
+check() { if [[ "$2" == "$3" ]]; then echo "ok $1"; pass=$((pass+1)); else echo "FAIL $1: want $2 got $3"; fail=$((fail+1)); fi; }
+
+before=$(date +%s)
+bash "$here/scripts/notify-snooze.sh" 30
+snooze="$tmp/notifyd/snooze"
+check "writes the snooze file" "yes" "$([[ -f "$snooze" ]] && echo yes)"
+delta=$(( $(cat "$snooze") - before ))
+check "is about 30 minutes out" "yes" "$([[ $delta -ge 1700 && $delta -le 1900 ]] && echo yes)"
+check "remembers the minutes" "30" "$(cat "$HOME/.local/state/notify-snooze.minutes")"
+bash "$here/scripts/notify-snooze.sh" off
+check "off removes the file" "no" "$([[ -f "$snooze" ]] && echo yes || echo no)"
+bash "$here/scripts/notify-snooze.sh" nope >/dev/null 2>&1
+check "rejects a bad argument" "1" "$?"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
+```
+
+- [ ] **Step 2: Run the check to verify it fails**
+
+Run: `bash test-notify-snooze.sh`
+Expected: FAIL, the script does not exist.
+
+- [ ] **Step 3: Write the script**
+
+Create `scripts/notify-snooze.sh`:
+
+```bash
+#!/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.
+#
+# Suppress every notification balloon for a number of minutes. The balloon
+# renderer reads the file and withholds them; the daemon is untouched. This is
+# deliberate: the notification still arrives, still enters the drawer's list
+# and still reaches history, only its balloon is held back.
+#
+# notify-snooze.sh 30
+# notify-snooze.sh off
+
+set -u
+
+dir="${XDG_RUNTIME_DIR:-/tmp}/notifyd"
+last="${HOME}/.local/state/notify-snooze.minutes"
+
+case "${1:-}" in
+ off)
+ rm -f "$dir/snooze"
+ exit 0
+ ;;
+ ''|*[!0-9]*)
+ printf 'usage: %s <minutes|off>\n' "${0##*/}" >&2
+ exit 1
+ ;;
+esac
+
+mkdir -p "$dir" "$(dirname "$last")"
+printf '%s\n' "$(( $(date +%s) + $1 * 60 ))" > "$dir/snooze.tmp"
+mv "$dir/snooze.tmp" "$dir/snooze"
+printf '%s\n' "$1" > "$last.tmp"
+mv "$last.tmp" "$last"
+```
+
+- [ ] **Step 4: Run the check to verify it passes**
+
+Run: `bash test-notify-snooze.sh`
+Expected: `4 passed, 0 failed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+chmod +x scripts/notify-snooze.sh test-notify-snooze.sh
+git add .
+git commit -m "feat: add notify-snooze.sh
+
+Snooze is a file the balloon renderer reads, not daemon state: the
+notification still arrives, lists and files, only its balloon is withheld.
+The last used value is kept under XDG state so a reboot does not forget it."
+```
+
+---
+
+### Task 7: Install and handover
+
+**Files:**
+- Modify: `README.md` (add the install and handover section)
+
+**Interfaces:**
+- Consumes: the built binaries and scripts.
+- Produces: the user runbook that switches the desktop off dunst.
+
+- [ ] **Step 1: Add the install and handover section to `README.md`**
+
+```markdown
+## Install and handover
+
+Build and install the two binaries and the script, beside the statusctl CLI:
+
+ go build -o ~/bin/notifyd ./cmd/notifyd
+ go build -o ~/bin/notifyctl ./cmd/notifyctl
+ install -m 755 scripts/notify-snooze.sh ~/bin/notify-snooze.sh
+
+dunst is not removed until this proves itself. To switch:
+
+1. Stop dunst (`pkill -x dunst` or its service) so the bus name is free.
+2. Add `hl.exec_cmd("notifyd")` to `~/.config/hypr/sections/autostart.lua`,
+ beside the quickshell lines.
+3. Change `rofipass`'s one `dunstctl close-all` to `notifyctl close-all`.
+4. Start `notifyd` and send a test notification.
+
+The renderer that draws the balloons is a separate plan; until it ships the
+queue is visible through `notifyctl list`.
+```
+
+- [ ] **Step 2: Verify a real notification round trip by hand**
+
+Ask the user to run, with `notifyd` started in another terminal:
+
+```bash
+notifyd & sleep 1
+notify-send -a test -u normal "hello" "world"
+notifyctl list
+notifyctl history
+```
+
+Expected: `notifyctl list` shows the notification; after it times out or is closed, it is in `notifyctl history`.
+
+- [ ] **Step 3: Run the full suite once more**
+
+Run: `gofmt -w . && go vet ./... && go test ./... && dbus-run-session -- go test ./... && bash test-notifyctl.sh && bash test-notify-snooze.sh`
+Expected: all pass.
+
+- [ ] **Step 4: Commit and push**
+
+```bash
+git add .
+git commit -m "docs: add the install and handover runbook"
+git push
+```
+
+---
+
+## Notes for the implementer
+
+**The two lifetimes are the point.** Expiry emits `NotificationClosed(id, 1)` and keeps the entry; dismissal and eviction file it in history. Do not collapse those: a `--wait` client must be freed on time, and the drawer's list must outlive the balloon.
+
+**The file JSON is the interface.** A renderer in another plan parses `queue.json` and `history.json`. Changing a key name breaks it; the spec is the authority.
+
+**`notifyctl` reads files for queries and uses D-Bus only for mutations.** That is deliberate, so a query works even if the bus call would be pointless, and so the files stay the single source the renderers read.
+
+**`replaces_id` reuse emits nothing.** A client that receives a new id owns it; the old client is not told anything, because the notification it sent was replaced, not closed.