# 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 close one, reason 2 notifyctl close-all close every live notification, reason 2 notifyctl action 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` (`%a` 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 ` 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.