aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-09-15-notification-daemon-design.md')
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-daemon-design.md312
1 files changed, 312 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
new file mode 100644
index 0000000..4e2806a
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
@@ -0,0 +1,312 @@
+# Notification Daemon
+
+Replaces dunst. A Go daemon owns `org.freedesktop.Notifications`, a quickshell
+shell draws the popups, and the desktop drawer gains a notification centre. The
+`dnd` mode that gates it belongs to the status registry, not here.
+
+This is the second of the two specs the status registry named. It consumes the
+registry's `dnd` mode rather than owning it.
+
+## Shape
+
+Three pieces, two repos:
+
+ notifyd/ separate repo: the daemon and notifyctl, Go
+ notifications/ this repo: the balloon shell
+ desktop/ this repo: the reserved space and history page
+ shared/Notify.qml this repo: the singleton the renderers share
+
+The daemon owns the D-Bus name and holds the state. The renderers are quickshell
+and read the state from files. `notifyctl` is the one control surface back to
+the daemon, over D-Bus.
+
+## What it is not
+
+It is not the status registry. `dnd` and `presentation` belong to `statusctl`,
+and this never writes them; the balloon shell only reads `status.dnd`.
+
+It is not a dunst wrapper. dunst stays installed until this proves itself, and
+then is removed. Nothing here calls into dunst.
+
+## The daemon
+
+`notifyd` is a Go program using `godbus/v5`. It registers
+`org.freedesktop.Notifications` on the session bus and serves the freedesktop
+notification spec:
+
+ Notify(app_name, replaces_id, app_icon, summary, body,
+ actions, hints, expire_timeout) -> id
+ CloseNotification(id)
+ GetCapabilities() -> capabilities
+ GetServerInformation() -> (name, vendor, version, spec_version)
+
+ NotificationClosed(id, reason) signal
+ ActionInvoked(id, action_key) signal
+
+`GetServerInformation` reports the identity `danix` for name and vendor. The
+spec version is `1.2`.
+
+`GetCapabilities` reports `actions`, `body-markup`, `icon-static` and
+`persistence`. The first two are load-bearing: `mail-notify.sh` sends actions
+and escapes its body because the running dunst advertises `body-markup`.
+`icon-static` means a client may pass an absolute icon path, which every live
+consumer does.
+
+Close reasons are the spec's: `1` expired, `2` dismissed by the user, `3`
+closed by a `CloseNotification` call.
+
+## Policy
+
+All of this is in the daemon and is independent of any renderer.
+
+**Timeout, and the two lifetimes.** `expire_timeout` from `Notify` is honoured
+exactly as the notification's balloon lifetime, in the spec's direction: `-1`
+means the server decides, so it takes the urgency default; `0` means never; any
+positive value is milliseconds and wins. The urgency defaults are `10s` for
+low, `10s` for normal and never for critical, matching the running dunst.
+libnotify sends `-1` by default, so a plain `notify-send` gets the urgency
+default, and `dunst` agrees. `ronema` relies on `-t 0` meaning never and `-t 1`
+meaning effectively immediate; both work unchanged.
+
+At expiry the daemon emits `NotificationClosed(id, 1)` and the balloon goes,
+which is exactly what `notify-send --wait` and `dunstify -b` clients are
+blocked on. The notification is **not** removed from the live queue. It stays
+in the drawer as an inert entry until it is dismissed or evicted. The two
+lifetimes are deliberately separate: the D-Bus lifetime is the balloon's, so a
+waiting client is freed on time, while the drawer's entry outlives it, so a
+notification missed while the drawer was shut is still there when it opens.
+An inert entry has no client left: its actions are gone, clicking it does
+nothing, and only the X removes it, to history.
+
+**Replace.** A new notification replaces an existing one when its `replaces_id`
+matches, or when its stack tag matches. Both `x-dunst-stack-tag` (what
+`mail-notify.sh` sends today) and `x-danix-stack-tag` (the new spelling) are
+honoured. A replace reuses the replaced notification's id and emits no
+`NotificationClosed` for it, which is what a client blocking on that id
+expects. A `replaces_id` that matches nothing is a new notification with a new
+id, and `0` always means new. Replacing resets the timeout and moves the
+notification to the top of the stack, which is what "one notification per
+account" has to mean when mail keeps arriving. A replace that targets an
+inert (already expired) entry re-arms its balloon and gives it a fresh expiry;
+the id is reused, so the new client's actions work and no `NotificationClosed`
+is emitted for the id it inherited.
+
+**History.** A ring of 20, sticky. A notification enters it when it is
+dismissed, in either form, or evicted from the live queue by the cap. Expiry
+alone does not put it in history, because the drawer still lists it. This is
+the `history_length` and `sticky_history` of the running dunst, with the popup
+and the drawer separated.
+
+DND is deliberately absent here. Suppression is a display decision, so it lives
+in the balloon shell (see below), which lets the drawer list a notification that
+DND chose not to pop. The registry spec assumed the daemon would read `dnd`;
+that assumption is superseded.
+
+## The files
+
+The daemon publishes its state under `$XDG_RUNTIME_DIR/notifyd/`, the same
+runtime directory the registry uses, so a reboot clears it and there is no
+cleanup code. Every write is atomic (temporary file, then rename), so a reader
+never sees a half-written value.
+
+ queue.json the live notifications, in stack order, newest first
+ history.json the last 20 dismissed or evicted notifications, newest first
+ drawer "1" while the drawer is open, written by the drawer
+ snooze an epoch second while snoozing; absent means off
+
+A live notification is one object:
+
+ {
+ "id": 12,
+ "app": "New Mail",
+ "summary": "danixland (2)",
+ "body": "Ada Lovelace\nRe: ...\n\n+1 more",
+ "urgency": "normal",
+ "icon": "/home/you/.local/share/icons/.../mail-unread-multiple.svg",
+ "actions": [["default", "open"]],
+ "created": 1758000000000,
+ "expires": 1758000010000
+ }
+
+`app`, `summary` and `body` are markup. `icon` is an absolute path or empty.
+`actions` is the spec's key and label pairs. `created` and `expires` are epoch
+milliseconds, so a sub-second timeout (`ronema` sends `-t 1`) is exact rather
+than rounding to zero, which would read as never. `expires` is `0` for a
+notification that never expires; a value in the past means the balloon has gone
+and the entry is inert. The history objects are the same shape.
+
+`queue.json` is capped at 20; an arrival beyond the cap pushes the oldest into
+history.
+
+The renderers read these files and never write them, except `drawer`, which is
+the drawer's own state and the one file the daemon does not own.
+
+## notifyctl
+
+A second command in the same Go module, installed to `~/bin`. It is the only
+thing that talks to the daemon, and it is what the renderers and rofi drive:
+
+ notifyctl list the live queue, JSON
+ notifyctl history [n] the history ring, JSON, default 20
+ notifyctl close <id> close one, reason 2
+ notifyctl close-all close every live notification, reason 2
+ notifyctl action <id> <key> invoke an action on a live notification
+ notifyctl clear-history empty the history ring
+
+`rofipass` calls `dunstctl close-all` today; it switches to
+`notifyctl close-all`. That is the only change any existing consumer needs.
+
+## The balloon shell
+
+`notifications/` is a quickshell component in the same shape as the others: it
+holds itself open with a 1x1 transparent `PanelWindow` (see AGENTS.md), has its
+own namespace and its own `hl.layer_rule` for blur, and runs for the session.
+
+It watches `queue.json` through `Notify.qml` and draws one balloon per live
+notification, bottom-right on `DP-1`, over conky. `exclusionMode` is
+`ExclusionMode.Ignore`: balloons are an overlay, not a reserved zone, and
+covering conky is intended.
+
+A balloon is drawn only while the notification is inside its `expires`, so the
+shell runs a timer per balloon and drops it at expiry. An entry whose time has
+passed is not the shell's to draw; it is left to the drawer, whose copy of the
+same queue outlives the balloon.
+
+A balloon is the app name in bold, the summary, and the body, with the icon at
+the left when one is present, following the running dunst's `format`
+(`<b>%a</b>` then `%s` then `%b`). Progress bars, hovering, and body images are
+out of scope.
+
+**Suppression is here.** The shell reads `status.dnd` and `snooze`:
+
+- `dnd` on suppresses low and normal balloons; critical still pops.
+- `snooze` active suppresses every balloon, critical included.
+- A suppressed notification is still in the live queue and still expires on
+ schedule. Only its balloon is withheld.
+
+## The drawer
+
+The reserved `Item` in `Drawer.qml`, already present and documented as
+"Reserved for the notification engine", is filled with the live notifications,
+above the grid. There is no tile and no module: the space is part of the grid
+view.
+
+The reserved space is a **scrollable** list, not a fixed strip. The grid below
+it is fixed and never scrolled, so the reserved space absorbs the overflow: it
+is bounded by the grid's top and scrolls when the live queue is long. That
+matters now that an entry outlives its balloon, because the queue can hold up
+to 20 at once.
+
+It has a header holding a **History** button, and one row per live notification,
+sharing the balloon's content. A row whose balloon has expired is inert: it
+still lists and still closes, but it carries no action, because its client is
+gone. The History button opens a QML history page: the ring of 20, newest
+first, each row closable, with a clear all.
+
+The drawer writes `notifyd/drawer` `1` on open and `0` on close. That is how
+the balloon shell knows to withhold its balloons while the drawer is open, so a
+notification appears as a balloon or in the reserved space, never both.
+
+Unlike the balloons, the reserved space lists every live notification,
+including the ones DND or snooze suppressed. A list the user deliberately
+opened is not an interruption, and hiding items from it would make DND
+indistinguishable from a lost notification.
+
+## Interactions
+
+Identical in both forms.
+
+- The **X** on a balloon or row closes that notification (`notifyctl close`).
+- **Clicking** a notification with a live client and actions opens a rofi menu
+ of its labels; choosing one invokes it (`notifyctl action`). A notification
+ with no actions, or an inert one whose balloon has expired, closes on click.
+- **Right click** closes all (`notifyctl close-all`).
+
+Closing in either form removes it from the live queue, and it survives only in
+the history ring, which is the point of the history page.
+
+Clicking an action on a `dunstify -b` notification (mail) is what makes the
+blocked `dunstify` process print its action key and launch `qtmaildir`. That
+round trip goes through `ActionInvoked`, exactly as it does with dunst today.
+
+## Snooze
+
+Snoozing suppresses every balloon, critical included, for a fixed time. It does
+not touch `dnd`.
+
+`notify-snooze.sh <minutes>` writes `$XDG_RUNTIME_DIR/notifyd/snooze` as an
+epoch second and `notify-snooze.sh off` removes it. The script is the entry
+point, so a rofi line or a keybind can snooze without opening the drawer.
+
+The Status page in the drawer gains a third row, **Snooze**, a switch and a
+free text minutes field. Flipping the switch on snoozes for the minutes in the
+field; flipping it off clears the file. The last used value is kept in
+`~/.local/state/notify-snooze.minutes` so it survives a reboot, which the
+runtime file does not.
+
+A snooze outliving a shell restart is desired and automatic: the file is in the
+runtime directory, so the shell reads it back. A snooze outliving a reboot is
+not, and does not happen.
+
+## Handover
+
+dunst is not removed until this is in place. The switch is: stop dunst, start
+`notifyd` (from `autostart.lua`, beside the quickshell lines) and let the
+`notifications/` shell start with the others. `notifyctl` and `notify-snooze.sh`
+install to `~/bin`. `rofipass` changes its one `dunstctl` line. Only then is
+dunst dropped.
+
+## Failure
+
+The daemon cannot lose mail, and it cannot lose a mode: a notification is
+transient by nature. What it can do is misreport.
+
+`notifyd` requests `org.freedesktop.Notifications` at startup. If the name is
+already taken, which is what happens while dunst is still running, it says so and
+exits non-zero rather than starting deaf.
+
+If `notifyd` dies, clients that send a notification fail to connect and say so,
+which is the honest outcome. On restart it writes an empty queue and starts a
+fresh history; it does not resurrect the previous run's notifications, because
+they are transient and their timeouts have passed.
+
+If a renderer dies, the daemon keeps its queue and the other renderer keeps
+working. On restart the renderer reads the current queue. A notification whose
+balloon expired while no renderer was up is still in the queue: the drawer
+lists it as an inert entry, the balloon shell draws nothing for it, and
+nothing is re-shown.
+
+`queue.json` is capped, so a renderer that is down cannot make the daemon grow
+without bound.
+
+## Verification
+
+Pure daemon logic gets Go unit tests: the timeout rule, the replace by id and by
+stack tag, the history ring and its cap, and the DND and snooze policies as the
+renderer computes them. These are table tests over the policy functions, no bus
+and no clock.
+
+`notifyctl` gets one runnable check, `test-notifyctl.sh`, in the same shape as
+`test-statusctl.sh`: it points `XDG_RUNTIME_DIR` at a temporary directory, drives
+the file contract and the CLI, and asserts the parse, the replace, the close and
+the history ring. It fails if any of them break.
+
+The bus and the pixels need a person. The end to end check is: send a
+`notify-send`, confirm a balloon; open the drawer, confirm the same notification
+is in the reserved space and not also a balloon; close it in the drawer, confirm
+the balloon goes too and the item is in the history page; send one and let it
+time out, confirm the balloon goes but the entry is still in the drawer and is
+inert; send enough to overflow the reserved space and confirm it scrolls rather
+than pushing the grid; toggle DND and confirm low and normal balloons stop while
+critical still pops and the drawer still lists; snooze and confirm nothing pops
+at all; click a mail notification's action and confirm `qtmaildir` opens.
+
+## Deferred
+
+Each of these is a real dunst feature and none has a live consumer:
+
+- Pausing a balloon's timeout while the pointer hovers it.
+- The progress bar (`value` and `progress` hints).
+- Resolving an icon *name* through a theme; absolute paths only for now.
+- Body images and hyperlink handling beyond markup.
+- A context menu on left click; rofi replaced it.