aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/status/statusctl
blob: 87c2cacf16e5f555532c7539ce5580e42cc5f4a6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
#!/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 nolock"
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, and
# create for FileView's first write (see watch below).
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" ;;
        gaming)      printf '%s: on (auto: game or call)' "$mode" ;;
    esac
}

# The shell's game detector owns status.gaming and presentation is the OR of
# the two, so the CLI has to read it to agree with the shell. It is not a mode
# in MODES: nothing here may write it, and only presentation is affected.
gaming_on() {
    local v=""
    [[ -e "$DIR/status.gaming" ]] && v="$(tr -d '[:space:]' < "$DIR/status.gaming" 2>/dev/null)"
    [[ "$v" == "1" ]]
}

# A missing file is the off state, not a distinct condition: read_mode already
# reads it as 0, and this is the same path `get` uses. There is no "down": a
# file cannot report whether a watcher is alive, and a missing one is exactly
# what a fresh session looks like.
#
# A running game reports its own state rather than plain activated, so the
# widget can show why the mode is on and that a click will not turn it off.
state_now() {
    if [[ "$mode" == "presentation" ]] && gaming_on; then
        printf 'gaming'
    elif [[ "$(read_mode)" == "1" ]]; then
        printf 'activated'
    else
        printf 'deactivated'
    fi
}

case "$action" in
    get)
        read_mode
        printf '\n'
        ;;
    set)
        [[ $# -eq 3 ]] || usage
        case "$3" in
            0|1) write_mode "$3" ;;
            *) usage ;;
        esac
        ;;
    toggle)
        # A running game pins presentation on and the detector reasserts it
        # within its poll, so a toggle here would flip back on its own. Refuse
        # it instead of writing a value that does not stick. `set` still works:
        # it is the explicit verb, and the manual half is worth setting for
        # when the game exits.
        if [[ "$mode" == "presentation" ]] && gaming_on; then
            printf '%s: presentation held on by a running game\n' "${0##*/}" >&2
            exit 1
        fi
        [[ "$(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.
        #
        # create is needed as well: Qt's atomic write builds the file as an
        # unnamed O_TMPFILE, and when the target does not exist yet it links
        # that straight into place. The only event naming the file is then
        # IN_CREATE, so without it the first write of every session, the one
        # that matters most, goes unseen until some later write.
        #
        # 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 create,close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null)
        watcher=$!
        trap 'kill "$watcher" 2>/dev/null' EXIT TERM INT
        # Presentation also follows status.gaming, so a game starting or
        # ending redraws the widget. Emitting only on a real change keeps a
        # write of an unchanged value from firing a duplicate line.
        last="$(state_now)"
        while read -r changed <&3; do
            case "$changed" in
                "status.$mode") ;;
                status.gaming) [[ "$mode" == "presentation" ]] || continue ;;
                *) continue ;;
            esac
            now="$(state_now)"
            [[ "$now" == "$last" ]] && continue
            last="$now"
            emit "$now"
        done
        ;;
    *)
        usage
        ;;
esac