aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-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.