diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-09-15-notification-daemon-design.md | 276 |
1 files changed, 276 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..e0aba25 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md @@ -0,0 +1,276 @@ +# 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.** `expire_timeout` from `Notify` is honoured exactly: `0` means use +the urgency default, `-1` means never expire, any positive value is +milliseconds. The urgency defaults are `10s` for low, `10s` for normal and +never for critical, matching the running dunst. `ronema` relies on `-t 0` +meaning never and `-t 1` meaning effectively immediate; both work unchanged. + +**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. + +**History.** A ring of 20, sticky: a notification enters it when it closes or +expires and is not removed by later arrivals beyond the cap. This is the +`history_length` and `sticky_history` of the running dunst. + +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 closed 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": 1758000000, + "expires": 1758000010 + } + +`app`, `summary` and `body` are markup. `icon` is an absolute path or empty. +`actions` is the spec's key and label pairs. `expires` is an epoch second, or +`0` for a notification that never expires. 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 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 has a header holding a **History** button, and one row per +live notification, sharing the balloon's content. 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 actions opens a rofi menu of its labels; + choosing one invokes it (`notifyctl action`). A notification with no actions + 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 that +expired while no renderer was up is simply gone, which is correct. + +`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; 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. |
