#!/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