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
|
# 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.
|