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
|
# status
Desktop modes as state: `dnd`, `presentation`, `nolock` and `quiet`, owned by
the `Status` singleton and stored as files under `$XDG_RUNTIME_DIR`. A fifth,
`status.gaming`, is written only by the shell's game detector; it is not a
mode `statusctl` exposes.
## The files are the interface
$XDG_RUNTIME_DIR/status.dnd
$XDG_RUNTIME_DIR/status.presentation
$XDG_RUNTIME_DIR/status.nolock
$XDG_RUNTIME_DIR/status.quiet
$XDG_RUNTIME_DIR/status.gaming
Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a
reboot resets every mode and there is no cleanup code. A shell restart does
not: the files outlive the process and the singleton reads them back.
`nolock` is stored as "auto-lock disabled", which is the inverse of the switch
the page shows. The file keeps the registry's "missing means off" rule, and the
page inverts it so the label reads as the default: screen lock on.
Anything can read a mode with `cat`. `statusctl` is the convenience, not the
mechanism, which is why it keeps working while quickshell is down.
## statusctl
statusctl <mode> get prints 0 or 1
statusctl <mode> set 0|1
statusctl <mode> toggle
statusctl <mode> watch JSON on every change (no consumer now)
The repo copy is the source; `~/bin/statusctl` is a symlink to it. `watch` watches
the directory rather than the file, because an atomic write replaces the file
and a watch on the old inode dies with it.
Setting a mode with `statusctl` records the state without firing its effects.
The shell sees the change through its own `FileView` watch and asserts them,
so the effects follow either way. If the shell is down, the state is recorded
and reasserted when it returns.
## Effects
`dnd` has none of its own. It is state the notification daemon reads.
`presentation` sets `dnd`, `nolock` and `quiet`, asserts a Wayland idle
inhibitor, and pauses breaktimer. Turning it off restores the three to the
values they had before rather than clearing them, so hand-set DND or a
hand-set disable survives a presentation.
`nolock` disables auto-lock by holding the idle inhibitor. That is the only
thing that stops hypridle's `loginctl lock-session`, so the mode governs
idle-triggered locking and nothing else: the `SUPER+l` bind and the lock on
suspend are separate paths and stay live. The page can set it permanently or
for a number of minutes, in which case the singleton's timer re-enables it.
The timer is shell-lifetime: a restart during a timed disable leaves the lock
off until it is toggled, the same class of limit as `dndBeforePresentation`.
`quiet` mutes the Claude and opencode sounds and leaves their popups alone.
The shell does nothing with it: both tools play through `notify-sound`, which
asks `statusctl quiet get` and plays nothing on a 1. `~/bin/notify-sound` is a
symlink to the repo copy, like `statusctl`.
notify-sound claude Claude Code's Notification hook
notify-sound <event> opencode-notifier's command hook
The opencode notifier runs its command for every event, `session_started` and
`user_message` included, so an argument with no sound plays nothing; only
`permission`, `complete`, `error` and `question` do. The notifier's own sounds
are off in `opencode-notifier.json`, and it rereads that file on every event,
so neither change needs an opencode restart.
breaktimer is paused and resumed by verb, never by writing its state file. See
Breaktimer below, which is also the read direction.
## Breaktimer
The traffic runs both ways, and only one way writes.
**Reading.** The daemon publishes three files the singleton watches:
$XDG_RUNTIME_DIR/breaktimer.state running | paused | stopped
$XDG_RUNTIME_DIR/breaktimer.phase working | breaking | longbreak | stopped
$XDG_RUNTIME_DIR/breaktimer.remain seconds left in the phase
exposed as `Status.btState`, `btPhase` and `btRemain`, with `btRunning` and
`btPaused` derived from the first. The old bar script read the same three
files; the two consumers never knew about each other.
**Writing: never.** The daemon owns those files and rewrites state and phase on
every transition, so a second writer would race its loop. Every control calls a
verb through `runBreaktimer()`, which is why presentation mode has always
called `pause` rather than writing `breaktimer.state`.
**A stopped daemon** is read from the state file, not probed. Both paths that
end the daemon write `stopped` there: `stop_daemon`, and the `cleanup` trap on
`TERM`. A daemon lost to `KILL` leaves a stale `running` and the drawer shows a
frozen countdown, a visible wrong answer the Start button resolves, which is
cheaper than a liveness probe on every repaint. QML cannot send a signal, so
the `kill -0` check the old bar module used is not available here anyway.
**The countdown counts in five second steps**, because that is the daemon's
tick and the shell does not interpolate between its writes. A local one second
timer would be a second clock drifting against the first, correcting itself
with a visible jump every five seconds, and it would keep counting while the
daemon is frozen outside the work window or paused.
**The tile** shows breaktimer below every mode, so an active mode still owns
the line and breaktimer replaces only the idle `All clear`. It does not count
toward `activeCount`: a running daemon is not a mode the user switched on.
The daemon's own configuration lives in `~/.config/breaktimer.conf` and is not
edited from here; `breaktimer.sh config` prints what is in effect.
## Game detection
Presentation is set automatically while a game runs, so a fullscreen game does
not get locked or interrupted by the idle timer, DND or breaktimer. The shell
polls every five seconds for a `gamescope` process, a running Steam binary, or
the DuckStation or PCSX2 emulators, and any one is enough:
pgrep -x gamescope || pgrep -f 'steamapps/commo[n]' || pgrep -x duckstation-qt || pgrep -x pcsx2-qt
The steam pattern is bracketed so it cannot match the check's own command
line: `pgrep -f` reads the whole argv, and a literal `steamapps/common` would
match the `sh` running the check and report a game forever. `gamescope` and
the emulators use `pgrep -x`, an exact match on the process name, which
cannot self-match.
The result is written to `status.gaming`, a file separate from
`status.presentation`, and presentation is the OR of the two. That separation
is the point: a game ending clears only the gaming half, so a presentation the
user set by hand survives for the whole game session instead of being clobbered
on exit. It also means presentation cannot be turned off by hand while a game
runs, since `status.gaming` holds it on until the game exits.
`status.gaming` is never set by `statusctl`; the detector owns it. The effects
are unchanged, because they key off the combined `presentation` value.
## The watch (no consumer now)
`statusctl presentation watch` fed waybar's `custom/presentation` module, which
replaced waybar's built-in `idle_inhibitor`. Waybar is gone, so the watch has
no consumer; the command stays. Presentation is toggled from the drawer's
status page, and later from the control panel. The island shows no presentation
state at rest.
The watch emits three classes, not two. `status.gaming` turns the mode on
without touching the manual half, so a bar widget would otherwise read
`deactivated` while the effects were asserted. `gaming` is its own
class with its own icon, and `toggle` is refused while it holds: the detector
reasserts the file within its poll, so the write would not stick. `set` is
still allowed, since the manual half is worth setting for when the game exits.
The watch therefore follows `status.gaming` as well as its own file, and emits
only on a real change so an unchanged rewrite draws nothing.
## The checks
./test-statusctl.sh
./test-notify-sound.sh
Points `XDG_RUNTIME_DIR` at a temporary directory, so it never touches live
modes. Covers the file format, the atomic write, the toggle, the unknown-mode
error, both the activated report and the absent-file off report.
`test-notify-sound.sh` puts a fake `paplay` first on `PATH` and a temporary
`XDG_RUNTIME_DIR` under it, so it plays nothing and leaves the live mode
alone. It covers the file per argument, silence for an event with no sound,
and silence while `quiet` is on.
|