diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 10:47:02 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 10:51:16 +0200 |
| commit | f6143e312cf9f20bc2b9be126eaa583f638538e0 (patch) | |
| tree | 4128a950deaa74e72151bb124d2bf4be336aaf29 /docs/superpowers | |
| parent | f43f31a8efabcf160d6897191f99d173aa9f5c64 (diff) | |
| download | quickshell-f6143e312cf9f20bc2b9be126eaa583f638538e0.tar.gz quickshell-f6143e312cf9f20bc2b9be126eaa583f638538e0.zip | |
docs: add the status registry design
Desktop modes as state: do not disturb and presentation mode, with one
place that owns them. First of two specs; the notification daemon that
replaces dunst is the second and consumes the registry's dnd mode rather
than owning it. The registry comes first because DND belongs to the
desktop rather than to the notification daemon, and because the daemon is
the larger and riskier component.
The design that needed deciding was not the toggle but the coupling.
Presentation mode sets DND, so DND has two writers, and turning
presentation mode off has to restore what the user had rather than
clearing it. That rule is why this is a registry and not two booleans.
State is one file per mode in XDG_RUNTIME_DIR, matching the convention
breaktimer already writes on this machine. That directory is /run/user/1000,
a tmpfs, so a reboot clears every mode with no cleanup code. Logout without
reboot is less certain and the design does not lean on it: elogind runs
here and its pam module documents removing the runtime directory at last
logout, but the same manual says the module does nothing when the system
was not booted with elogind as init, which on Slackware it is not. If
removal does not happen, a mode survives a logout, which is the same
behaviour as surviving a shell restart.
FileView covers both directions: atomicWrites for the write, watchChanges
for the watch, no shell-out. External watchers need close_write,moved_to
because an atomic write arrives as a rename, the same trap the mail watcher
hit with Xapian.
Confirmed while designing: the compositor advertises
zwp_idle_inhibit_manager_v1 and waybar's built-in idle_inhibitor already
drives it here, so the inhibitor is asserted over Wayland rather than D-Bus.
That module is replaced rather than demoted, because it owns its inhibitor
object and cannot display state owned by anything else; left alongside the
registry it would assert a second inhibitor and idle would resume only when
both released.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7ThHHh5iTYbVfp3rNAkw2
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/specs/2026-09-15-status-registry-design.md | 229 |
1 files changed, 229 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-15-status-registry-design.md b/docs/superpowers/specs/2026-09-15-status-registry-design.md new file mode 100644 index 0000000..34e5945 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-status-registry-design.md @@ -0,0 +1,229 @@ +# Status Registry + +Desktop modes as state: do not disturb, presentation mode, and whatever +follows. One place that owns them, one way to read them, one way to set them. + +This is the first of two specs. The notification daemon that replaces dunst is +the second, and it consumes the registry's `dnd` mode rather than owning it. +The registry comes first because DND belongs to the desktop, not to the +notification daemon, and because the notification daemon is the larger and +riskier component. + +## What a mode is + +A named boolean plus a set of effects asserted while it is true. + +Two modes in this project: + +**`dnd`** has no effects of its own. It is state that the notification daemon +reads. On its own it is a flag; the behaviour lives in the consumer. + +**`presentation`** has three effects: + +| effect | mechanism | +|---|---| +| do not disturb | sets the `dnd` mode | +| idle inhibited | `IdleInhibitor` on the drawer's keepalive window | +| breaktimer paused | `breaktimer.sh pause`, restored with `resume` | + +Not in this project: any further mode. Gaming, focus, and the rest are one file +and one row each when they arrive, which is the point of building the registry +rather than two toggles. + +## Why a registry rather than two toggles + +Because the second mode is where the coupling appears. Presentation mode sets +DND, so DND has two writers: the user, and presentation mode. Turning +presentation mode off must restore DND to what the user had, not unconditionally +clear it, or an afternoon of hand-set DND vanishes when a talk ends. That rule +has to live somewhere, and a registry is that somewhere. + +## State + +One file per mode in `$XDG_RUNTIME_DIR`, containing `0` or `1`: + + status.dnd + status.presentation + +Flat, one value per file, matching the convention already on disk: +`breaktimer.pid`, `breaktimer.state`, `breaktimer.phase`, `breaktimer.remain` +are written exactly this way by the breaktimer daemon. + +A missing file means the mode is off. This is not a fallback, it is the +mechanism: `$XDG_RUNTIME_DIR` is `/run/user/1000`, a tmpfs, so a reboot clears +every mode with no cleanup code and no persistence logic. A mode surviving a +reboot would need code; a mode resetting is the absence of it. + +Logout without reboot is less certain and the design does not rely on it. +`elogind` runs here and `pam_elogind.so` is in the PAM stack, and its manual +says the runtime directory and its contents are removed when a user's last +concurrent session ends. The same manual says the module does nothing if the +system was not booted with elogind as its init, which on Slackware it is not. +Session tracking demonstrably works, so the removal probably happens, but it is +not guaranteed by the documentation for this configuration. If it does not, a +mode survives a logout, which is the same behaviour as surviving a shell +restart, described next. + +A shell restart is a different event. The files live in the runtime directory, +not in the shell, so a `qs` restart, a hot reload failure or a crash leaves +modes intact and the registry reads them back at startup. A presentation that +outlives a shell crash is the desired behaviour; a presentation that outlives a +reboot is not. + +### Reading and writing + +`FileView` covers both halves with no shell-out: + +- `atomicWrites: true` (the default) writes a temporary file and renames it over + the target, so no reader ever sees a half-written value. +- `watchChanges: true` with `onFileChanged: reload()` means an external writer + changes the file and the drawer repaints. No polling, in either direction. + +Because atomic writes arrive as a rename rather than a write, any external +watcher must watch for `close_write,moved_to`, not `close_write` alone. This is +the same trap the mail watcher hit with Xapian, recorded in AGENTS.md: a watch +that sees only writes never fires on a file that is replaced. + +### Ownership + +The registry writes `status.*` and nothing else. `breaktimer.state` belongs to +the breaktimer daemon and is driven only through `breaktimer.sh pause|resume`. +Two writers on one file is a race, and breaktimer's own daemon loop rewrites +that file on every phase change. + +## Components + + shared/Status.qml the singleton: modes, effects, files + desktop/Status.qml symlink to the above + desktop/modules/status/ + Module.qml registration + Tile.qml active mode count, click opens the page + Page.qml one row per mode, a switch each + ~/bin/statusctl the CLI + +`Status.qml` is a `pragma Singleton` rather than a module service because modes +outlive any page, and because the notification daemon needs to read `dnd` at +startup, before any page is instantiated. A directory import resolves it with no +`qmldir`, the same way `Theme.qml` resolves. + +The symlink rather than a shared import path follows the existing convention: a +singleton outside the config directory needs a `qmldir`, which is the friction +that keeps `Theme.qml` symlinked into each component. + +## The CLI + + statusctl <mode> get prints 0 or 1 + statusctl <mode> set 0|1 + statusctl <mode> toggle + statusctl <mode> watch JSON on change, for waybar + +`get`, `set` and `toggle` read and write the file directly. They do not go +through the shell, so they work when the shell is down, and they cost no +process spawn beyond the script itself. + +`watch` runs `inotifywait -m -e close_write,moved_to` on the file's directory, +debounced, and prints a waybar JSON line on each change. One long-lived process, +no polling. When the file is absent it prints `class: "down"` rather than +`deactivated`, so a dead registry is visibly different from a mode that is off. + +Setting a mode by file rather than through the shell means the effects do not +fire. `statusctl presentation set 1` writes the file; the shell sees the change +through its watch and asserts the inhibitor and pauses breaktimer. If the shell +is down, the file changes and nothing else happens, which is the correct failure: +the state is recorded and reasserted when the shell returns. + +## Effects + +Effects are asserted by the singleton when a mode turns on and released when it +turns off. + +**Idle inhibit** is `IdleInhibitor` bound to the drawer's keepalive +`PanelWindow`, which is the one window guaranteed to exist for the shell's +lifetime. The property needs a non-null `window` to do anything. + +The compositor advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed +by `wayland-info`, and waybar's built-in `idle_inhibitor` module already drives +it on this machine. So hypridle honours the Wayland protocol here, which is why +the inhibitor is asserted that way rather than over D-Bus. `elogind` does run on +this system, so a D-Bus inhibit path exists, but the Wayland one is confirmed +working and needs no extra service. + +**Breaktimer** is paused with `breaktimer.sh pause` and restored with `resume`. +The verbs exist, are backed by the daemon's own state file, and survive the +daemon restarting. The exit status is readable, so a failure to pause is +detectable, unlike the inhibitor. + +**DND from presentation mode** records the user's DND value when presentation +mode turns on, sets DND on, and restores the recorded value when presentation +mode turns off. The recorded value is held in the singleton, not in a file: it +is meaningful only while presentation mode is on, and presentation mode does not +survive a reboot. + +## Waybar + +The built-in `idle_inhibitor` module is replaced by a `custom/presentation` +module reading the registry. + +This is a replacement rather than a demotion because the built-in module has no +input mode: it owns an inhibitor object, its `activated` state is that object's +state, and it cannot display state owned by anything else. Left in place +alongside the registry it would assert a second, independent inhibitor, and idle +would resume only when both were released. "I turned presentation mode off and +the screen still will not lock" is the failure that produces. + + "custom/presentation": { + "exec": "~/bin/statusctl presentation watch", + "return-type": "json", + "on-click": "~/bin/statusctl presentation toggle", + "format": "{icon}", + "format-icons": { "activated": " ", "deactivated": " " } + } + +The glyphs, the slot and the CSS ids carry over from the built-in module, whose +own tooltip already reads "Presentation Mode". The name is settled by precedent. + +A `custom/dnd` module is available the same way but is not part of this project: +the notification daemon's own waybar presence is the second spec's business. + +## Drawer + +A Status tile in the grid, showing the number of active modes, and a page with +one row per mode and a switch each. Adding a mode is one file and one row. + +## Failure + +The registry cannot lose data: every mode is a boolean that resets at reboot by +design. What it can do is misreport. + +If the shell dies, the `status.*` files remain and waybar keeps showing the last +known state while nothing enforces it. The inhibitor is released, because it is +a Wayland object owned by the dead process, and breaktimer stays paused, because +nothing told it otherwise. So a crash during presentation mode leaves the screen +able to lock and breaktimer still quiet, which is the safer half of each pair. + +`statusctl watch` prints `class: "down"` when a file is missing, which +distinguishes a dead registry from a mode that is off, but only for the missing +case. A crash leaves the file present and stale. That is the honest limit of a +file as a liveness signal, and the alternative, a heartbeat, is more machinery +than two booleans justify. + +## Verification + +One runnable check, `test-statusctl.sh`, exercising the file contract without +the shell: + +- `statusctl dnd set 1`, assert the file contains `1`, assert `get` prints `1` +- `statusctl dnd toggle`, assert `0` +- `statusctl presentation watch` in the background, change the file, assert a + JSON line appears, assert it carries `activated` +- remove the file, assert the next line carries `down` + +That covers the parse, the write, the atomic rename and the watch together, and +it fails if any of them break. + +The effects need the shell and are confirmed by hand, once, because each is +observable: `hyprctl clients` counts inhibitors, `breaktimer.sh status` reports +`paused`, and the notification daemon's own behaviour under DND is the second +spec's verification. + +Per AGENTS.md, anything visual is confirmed by the user rather than screenshotted. |
