# 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. - [x] **Step 1: Create the repo on the server and clone it** (done) Created public under the `Linux` cgit section, and cloned to `~/Programming/GIT/notifyd`: ```bash gitctl -y repo create notifyd --section Linux --desc "Desktop notification daemon for Hyprland: owns org.freedesktop.Notifications, publishes state as files for a quickshell renderer" git clone danix_git:notifyd ~/Programming/GIT/notifyd ``` 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 // Copyright (C) 2026 Danilo M. // // 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 ( "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 }{ {"minus one uses low default", -1, Low, 10_000}, {"minus one uses normal default", -1, Normal, 10_000}, {"minus one critical never", -1, Critical, 0}, {"zero never", 0, 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. // // 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 // leaves the choice to the server (expire_timeout -1). 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 per the freedesktop // spec: -1 means the server decides (the urgency default), 0 means never, and // anything positive is milliseconds and wins. The spec is precise about the // direction and libnotify's default is -1, so getting it backwards would make // every plain notify-send immortal. func EffectiveTimeoutMS(expire int32, u Urgency) int64 { switch { case expire == -1: return DefaultTimeoutMS(u) case expire == 0: return 0 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 // Copyright (C) 2026 Danilo M. // // 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 "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. // // 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 // Copyright (C) 2026 Danilo M. // // 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" "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. // // 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 // Copyright (C) 2026 Danilo M. // // 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" "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. // // 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. // // 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. // // 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 `, `close-all`, `action `, `clear-history`. - [ ] **Step 1: Extend the control interface** Replace `internal/notify/control.go` with: ```go // Copyright (C) 2026 Danilo M. // // 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. // // 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 list the live queue as JSON history [n] the history ring as JSON, default 20 close dismiss one notification close-all dismiss every notification action 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. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # The one runnable check for notifyctl. It runs the daemon and the CLI on a # private session bus with a temporary runtime directory, so nothing here # touches the live notification state. # # Usage: ./test-notifyctl.sh (exit 0 = all passed) set -u here="$(cd "$(dirname "$0")" && pwd)" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT pass=0 fail=0 check() { local label="$1" want="$2" got="$3" if [[ "$want" == "$got" ]]; then printf 'ok %s\n' "$label" pass=$((pass + 1)) else printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got" fail=$((fail + 1)) fi } go build -o "$tmp/notifyd" "$here/cmd/notifyd" || exit 1 go build -o "$tmp/notifyctl" "$here/cmd/notifyctl" || exit 1 mkdir -p "$tmp/run" export XDG_RUNTIME_DIR="$tmp/run" export PATH="$tmp:$PATH" dbus-run-session -- bash -c ' set -u "$1/notifyd" >"$1/daemon.log" 2>&1 & daemon=$! sleep 0.5 notifyctl close-all >/dev/null 2>&1 notify-send -a test -u normal "t1" "b1" || exit 3 sleep 0.3 echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" notifyctl close-all sleep 0.3 echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" kill $daemon ' _ "$tmp" > "$tmp/out" 2>"$tmp/err" check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")" check "close-all empties the live queue" "0" "$(sed -n 2p "$tmp/out")" check "no errors on stderr" "" "$(cat "$tmp/err")" printf '\n%d passed, %d failed\n' "$pass" "$fail" [[ "$fail" -eq 0 ]] ``` 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 `, 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. # # 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. # # 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 \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: `5 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.