aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-15-status-registry-design.md
blob: 34e5945da7635b1a641e2de82588ea530c33504c (plain)
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
# 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.