diff options
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. |
