aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rwxr-xr-xdesktop/modules/status/statusctl132
-rwxr-xr-xdesktop/modules/status/test-statusctl.sh87
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 ]]