diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 11:24:46 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 11:24:46 +0200 |
| commit | 3e9189a9a8bc6875b1ad3cd5a32959b7895c3e7e (patch) | |
| tree | 51698a0683bd2687b1fe7f6f987f06c6283d65b5 /desktop | |
| parent | 76bc1699eeb45d22907a872ee51ee32bcf379516 (diff) | |
| download | quickshell-3e9189a9a8bc6875b1ad3cd5a32959b7895c3e7e.tar.gz quickshell-3e9189a9a8bc6875b1ad3cd5a32959b7895c3e7e.zip | |
feat(desktop): add the statusctl CLI and its check
statusctl reads and writes the mode files directly rather than going
through the shell, so it works while quickshell is down. Setting a mode
that way records the state without firing its effects; the shell sees the
change through its own watch and reasserts them.
The watch listens on the directory, not the file: an atomic write replaces
the file, so a watch held on the old inode dies with it. Same trap the mail
watcher hit with Xapian, and the reason moved_to is in the event list.
An unknown mode exits non-zero rather than reading as off, so a typo
cannot masquerade as a mode that happens to be disabled.
The watch loop runs inotifywait through process substitution, held open on
fd 3, with the PID captured and trapped on EXIT/TERM/INT. Piped straight
into the while loop, inotifywait would be a pipeline sibling rather than a
child, so a plain kill on statusctl leaves it running, watching a directory
nobody reads anymore. Task 7 wires this CLI into waybar's exec, and waybar
kills and respawns exec children on every reload the same plain way, so
every reload would otherwise leak another watcher for the rest of the
session. Process substitution makes inotifywait a real child whose PID the
trap can hold and kill. A SIGKILL still orphans it, since KILL cannot be
trapped, but nothing in the described path sends one.
A failed rename exits non-zero instead of being swallowed. Reporting
success on a write that did not land would leave the caller and the shell
disagreeing about the mode, with an orphan temp file as the only trace, and
this script is the interface the whole registry is read and written through.
Verified against an unwritable directory: exit 1, no orphan left, prior
value intact.
Reading uses a redirect rather than cat piped into tr, so that an unreadable
file falling back to off is explicit rather than a side effect of a pipeline
discarding cat's exit status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7ThHHh5iTYbVfp3rNAkw2
Diffstat (limited to 'desktop')
| -rwxr-xr-x | desktop/modules/status/statusctl | 132 | ||||
| -rwxr-xr-x | desktop/modules/status/test-statusctl.sh | 87 |
2 files changed, 219 insertions, 0 deletions
diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl new file mode 100755 index 0000000..71ada07 --- /dev/null +++ b/desktop/modules/status/statusctl @@ -0,0 +1,132 @@ +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# Read, set and watch desktop modes. The modes are files under +# XDG_RUNTIME_DIR holding 0 or 1; a missing file means off. +# +# This talks to the files, not to the shell, so it works while quickshell is +# down. Setting a mode that way records the state without firing its effects; +# the shell sees the change through its own watch and reasserts them. +# +# statusctl <mode> get prints 0 or 1 +# statusctl <mode> set 0|1 +# statusctl <mode> toggle +# statusctl <mode> watch waybar JSON on every change + +set -u + +MODES="dnd presentation" +DIR="${XDG_RUNTIME_DIR:-/tmp}" + +usage() { + printf 'usage: %s <%s> <get|set 0|1|toggle|watch>\n' \ + "${0##*/}" "$(printf '%s' "$MODES" | tr ' ' '|')" >&2 + exit 1 +} + +[[ $# -ge 2 ]] || usage + +mode="$1" +action="$2" + +# A typo must fail loudly rather than read as a mode that happens to be off. +case " $MODES " in + *" $mode "*) ;; + *) printf '%s: unknown mode: %s\n' "${0##*/}" "$mode" >&2; exit 1 ;; +esac + +file="$DIR/status.$mode" + +read_mode() { + local v + # An unreadable file reads as off, deliberately: a missing mode file is + # the normal state before anything has written one, and the redirect + # makes that fallback explicit rather than a side effect of a pipeline + # swallowing cat's exit status. + v="$(tr -d '[:space:]' < "$file" 2>/dev/null)" + [[ "$v" == "1" ]] && printf '1' || printf '0' +} + +# Write through a temporary file and rename, so no reader ever sees a +# half-written value. This is also what FileView does on the QML side, and it +# is why a watcher has to listen for moved_to as well as close_write. +write_mode() { + local want="$1" tmp + tmp="$(mktemp "$DIR/.status.$mode.XXXXXX")" || exit 1 + printf '%s\n' "$want" > "$tmp" + # The temp file is made in the same directory as the target, so this is a + # rename rather than a copy, and therefore atomic. A failure here has to + # be loud: reporting success on a write that did not land would leave the + # caller and the shell disagreeing about the mode, with an orphan temp + # file as the only trace. + mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; } +} + +emit() { + local state="$1" + printf '{"text": "", "alt": "%s", "class": "%s", "tooltip": "%s"}\n' \ + "$state" "$state" "$(tooltip "$state")" +} + +tooltip() { + case "$1" in + activated) printf '%s: on' "$mode" ;; + deactivated) printf '%s: off' "$mode" ;; + down) printf '%s: no state file' "$mode" ;; + esac +} + +state_now() { + [[ -e "$file" ]] || { printf 'down'; return; } + [[ "$(read_mode)" == "1" ]] && printf 'activated' || printf 'deactivated' +} + +case "$action" in + get) + read_mode + printf '\n' + ;; + set) + [[ $# -eq 3 ]] || usage + case "$3" in + 0|1) write_mode "$3" ;; + *) usage ;; + esac + ;; + toggle) + [[ "$(read_mode)" == "1" ]] && write_mode 0 || write_mode 1 + ;; + watch) + emit "$(state_now)" + # Watch the directory rather than the file: an atomic write replaces + # the file, so a watch held on the old inode dies with it. This is the + # same trap the mail watcher hit with Xapian. + # + # inotifywait must die with us. Piped straight into the while loop it + # would be a pipeline sibling, not a child, so a plain kill on this + # process (which is exactly how waybar stops and respawns its exec + # children on every reload) leaves it running, watching a directory + # nobody reads anymore. Process substitution makes it a real child + # whose PID we can hold and kill from a trap. + exec 3< <(inotifywait -q -m -e close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null) + watcher=$! + trap 'kill "$watcher" 2>/dev/null' EXIT TERM INT + while read -r changed <&3; do + [[ "$changed" == "status.$mode" ]] || continue + emit "$(state_now)" + done + ;; + *) + usage + ;; +esac diff --git a/desktop/modules/status/test-statusctl.sh b/desktop/modules/status/test-statusctl.sh new file mode 100755 index 0000000..3b8f7ba --- /dev/null +++ b/desktop/modules/status/test-statusctl.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# The one runnable check for statusctl. It points XDG_RUNTIME_DIR at a +# temporary directory, so nothing here touches the live modes. +# +# Usage: ./test-statusctl.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +ctl="$here/statusctl" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export XDG_RUNTIME_DIR="$tmp" + +pass=0 +fail=0 + +check() { + local label="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + printf 'ok %s\n' "$label" + pass=$((pass + 1)) + else + printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got" + fail=$((fail + 1)) + fi +} + +# A mode with no file reads as off. +check "missing file reads 0" "0" "$("$ctl" dnd get)" + +# set writes the file and get reads it back. +"$ctl" dnd set 1 +check "set 1 writes the file" "1" "$(cat "$tmp/status.dnd" | tr -d '[:space:]')" +check "get after set 1" "1" "$("$ctl" dnd get)" + +# toggle flips it. +"$ctl" dnd toggle +check "toggle from 1" "0" "$("$ctl" dnd get)" +"$ctl" dnd toggle +check "toggle from 0" "1" "$("$ctl" dnd get)" + +# set 0 writes rather than removing, so a reader sees an explicit off. +"$ctl" dnd set 0 +check "set 0 writes the file" "0" "$("$ctl" dnd get)" + +# An unknown mode is an error, not a silent success: a typo must not look +# like a mode that is off. +"$ctl" nosuch get >/dev/null 2>&1 +check "unknown mode exits non-zero" "1" "$?" + +# watch prints a line on change, and the class reflects the value. The +# atomic write arrives as a rename, which is why the watch needs moved_to. +out="$tmp/watch.out" +"$ctl" presentation watch > "$out" 2>/dev/null & +watcher=$! +sleep 0.3 +"$ctl" presentation set 1 +sleep 0.5 +kill "$watcher" 2>/dev/null +wait "$watcher" 2>/dev/null +check "watch reports activated" "1" "$(grep -c '"class": *"activated"' "$out")" + +# A missing file is reported as down, distinct from a mode that is off. +rm -f "$tmp/status.presentation" +out2="$tmp/watch2.out" +"$ctl" presentation watch > "$out2" 2>/dev/null & +watcher2=$! +sleep 0.5 +kill "$watcher2" 2>/dev/null +wait "$watcher2" 2>/dev/null +check "watch reports down when absent" "1" "$(grep -c '"class": *"down"' "$out2")" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] |
