From 3e9189a9a8bc6875b1ad3cd5a32959b7895c3e7e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 11:24:46 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_01A7ThHHh5iTYbVfp3rNAkw2 --- desktop/modules/status/statusctl | 132 +++++++++++++++++++++++++++++++ desktop/modules/status/test-statusctl.sh | 87 ++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100755 desktop/modules/status/statusctl create mode 100755 desktop/modules/status/test-statusctl.sh (limited to 'desktop/modules/status') 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. +# +# 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 get prints 0 or 1 +# statusctl set 0|1 +# statusctl toggle +# statusctl watch waybar JSON on every change + +set -u + +MODES="dnd presentation" +DIR="${XDG_RUNTIME_DIR:-/tmp}" + +usage() { + printf 'usage: %s <%s> \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. +# +# 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 ]] -- cgit v1.2.3