1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
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.
|