diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 13:16:02 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 13:16:02 +0200 |
| commit | ff9612c809e609e0ca1004531523083ce857ce26 (patch) | |
| tree | e9c784a1e129ce64c1d51468b7d0f8f892340510 /docs/superpowers | |
| parent | 661d477e7806d322d3499c19537541c5dd4e7d82 (diff) | |
| download | quickshell-ff9612c809e609e0ca1004531523083ce857ce26.tar.gz quickshell-ff9612c809e609e0ca1004531523083ce857ce26.zip | |
docs: add the notification daemon design
The second of the two specs the status registry named. A Go daemon owns
org.freedesktop.Notifications and holds the state; quickshell renders it.
The daemon and notifyctl live in a separate repo, the balloon shell and the
drawer's notification centre live here.
Rendering is split because quickshell has no generic D-Bus module, so
something outside QML has to own the bus name. Files are the interface in
the direction the renderers read, the same convention the registry set, and
notifyctl is the one surface back.
DND suppression lives in the balloon shell, not the daemon: the drawer's
reserved space lists every live notification including the suppressed ones,
because a list the user opened is not an interruption. This supersedes the
registry spec's assumption that the daemon would read dnd.
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. |
