aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/status/statusctl
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/status/statusctl')
-rwxr-xr-xdesktop/modules/status/statusctl132
1 files changed, 132 insertions, 0 deletions
diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl
new file mode 100755
index 0000000..6fbb689
--- /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=""
+ # A missing file is the normal state before anything has written one and
+ # reads as off. Guarding on existence keeps the shell's redirection error
+ # off stderr: `2>/dev/null` on the command cannot suppress a failure of
+ # its own input redirect.
+ [[ -e "$file" ]] && 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