diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notifyd.md | 1780 |
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. |
