chore: init scripts bd014d0a
Steve · 2026-08-19 23:32 23 file(s) · +1008 −0
scripts/audio-menu (added) +38 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl), gum
3 +
# One menu over the vol-* / audio-output scripts.
4 +
set -u
5 +
6 +
DIR=$(dirname "$0")
7 +
8 +
CHOICE=$(gum choose \
9 +
    "Volume up" \
10 +
    "Volume down" \
11 +
    "Set volume" \
12 +
    "Toggle mute" \
13 +
    "Toggle mic mute" \
14 +
    "Select output" \
15 +
    "Show status" \
16 +
    "Quit") || exit 0
17 +
18 +
case "$CHOICE" in
19 +
    "Volume up")       "$DIR/vol-up" ;;
20 +
    "Volume down")     "$DIR/vol-down" ;;
21 +
    "Set volume")
22 +
        PCT=$(gum input --placeholder "0-100" --prompt "Volume % ") || exit 0
23 +
        case "$PCT" in
24 +
            ""|*[!0-9]*) gum style --foreground 214 "Not a number: $PCT"; exit 1 ;;
25 +
        esac
26 +
        [ "$PCT" -gt 100 ] && PCT=100
27 +
        wpctl set-volume @DEFAULT_AUDIO_SINK@ "${PCT}%" && "$DIR/vol-status" -n
28 +
        ;;
29 +
    "Toggle mute")     "$DIR/vol-mute" ;;
30 +
    "Toggle mic mute") "$DIR/vol-mute" -s ;;
31 +
    "Select output")   "$DIR/audio-output" ;;
32 +
    "Show status")
33 +
        gum style --border normal --padding "1 2" --border-foreground 212 \
34 +
            "Volume: $("$DIR/vol-status")" \
35 +
            "$("$DIR/audio-output" --list)"
36 +
        ;;
37 +
    *) exit 0 ;;
38 +
esac
scripts/audio-output (added) +83 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl), gum
3 +
# Pick which sink audio plays out of.
4 +
#   audio-output           interactive picker
5 +
#   audio-output --list    print sinks (* marks the current default)
6 +
#   audio-output --auto    clear the pin, let wireplumber pick by priority
7 +
#   audio-output ID|NAME   select by wpctl id, or by a substring of the name
8 +
set -u
9 +
10 +
# wpctl has no machine-readable listing, so carve the Sinks block out of `status`.
11 +
# Output: "<*|space>\t<id>\t<name>"
12 +
sinks() {
13 +
    wpctl status 2>/dev/null | awk '
14 +
        /^Audio/                                { audio = 1; next }
15 +
        /^[A-Z]/                                { audio = 0 }
16 +
        audio && /Sinks:/ && !/endpoints:/      { insec = 1; next }
17 +
        audio && /Devices:|Sources:|Streams:|endpoints:|Filters:/ { insec = 0; next }
18 +
        insec {
19 +
            line = $0
20 +
            prefix = line; sub(/[0-9].*$/, "", prefix)
21 +
            def = (prefix ~ /\*/) ? "*" : " "
22 +
            sub(/^[^0-9]*/, "", line)
23 +
            if (line !~ /^[0-9]+\./) next
24 +
            id = line;   sub(/\..*$/, "", id)
25 +
            name = line; sub(/^[0-9]+\.[[:space:]]*/, "", name)
26 +
            sub(/[[:space:]]*\[vol:.*$/, "", name)
27 +
            sub(/[[:space:]]+$/, "", name)
28 +
            print def "\t" id "\t" name
29 +
        }
30 +
    '
31 +
}
32 +
33 +
name_of() {
34 +
    printf '%s\n' "$SINKS" | awk -F'\t' -v id="$1" '$2 == id { print $3; exit }'
35 +
}
36 +
37 +
SINKS=$(sinks)
38 +
if [ -z "$SINKS" ]; then
39 +
    echo "audio-output: no sinks found (is pipewire/wireplumber running?)" >&2
40 +
    exit 1
41 +
fi
42 +
43 +
use_auto() {
44 +
    wpctl clear-default || exit 1
45 +
    gum style --foreground 212 "Output: automatic (wireplumber picks)"
46 +
    exit 0
47 +
}
48 +
49 +
use_sink() {
50 +
    wpctl set-default "$1" || exit 1
51 +
    gum style --foreground 212 "Output: $(name_of "$1")"
52 +
    exit 0
53 +
}
54 +
55 +
case "${1:-}" in
56 +
    --list|-l)
57 +
        printf '%s\n' "$SINKS" | awk -F'\t' '{ printf "%s %-6s %s\n", $1, $2, $3 }'
58 +
        exit 0
59 +
        ;;
60 +
    --auto|-a) use_auto ;;
61 +
    "") ;;
62 +
    *)
63 +
        ID=$(printf '%s\n' "$SINKS" | awk -F'\t' -v q="$1" '
64 +
            $2 == q                         { print $2; found = 1; exit }
65 +
            index(tolower($3), tolower(q))  { print $2; found = 1; exit }')
66 +
        if [ -z "$ID" ]; then
67 +
            echo "audio-output: no sink matching '$1'" >&2
68 +
            exit 1
69 +
        fi
70 +
        use_sink "$ID"
71 +
        ;;
72 +
esac
73 +
74 +
CHOICE=$(printf '%s\n' "$SINKS" \
75 +
    | awk -F'\t' '{ printf "%s %-6s %s\n", $1, $2, $3 }' \
76 +
    | { cat; echo "  auto   (let wireplumber choose)"; } \
77 +
    | gum choose --header "Select audio output") || exit 0
78 +
79 +
[ -n "$CHOICE" ] || exit 0
80 +
81 +
ID=$(printf '%s\n' "$CHOICE" | awk '{ print $2 }')
82 +
[ "$ID" = "auto" ] && use_auto
83 +
use_sink "$ID"
scripts/dm-eth0-connect (added) +41 −0
1 +
#!/bin/sh
2 +
# Requires: dmenu, libnotify, ifupdown-ng, openrc, doas
3 +
# dmenu counterpart of eth0-connect. The confirm prompt runs as the calling
4 +
# user; only the switch itself is handed to doas.
5 +
set -u
6 +
7 +
IFACE="${1:-eth0}"
8 +
WIFI="${2:-wlan0}"
9 +
NOTIFY_ID=7303
10 +
11 +
notify() {
12 +
    if command -v dunstify >/dev/null 2>&1; then
13 +
        dunstify -r "$NOTIFY_ID" ${2:+-u "$2"} "Network" "$1"
14 +
    else
15 +
        notify-send ${2:+-u "$2"} "Network" "$1"
16 +
    fi
17 +
}
18 +
19 +
fail() { notify "$1" critical; exit 1; }
20 +
21 +
doas -n true 2>/dev/null || fail "doas needs a password; add a nopass rule for this user."
22 +
23 +
[ "$(printf 'No\nYes\n' | dmenu -c -i -l 2 -p "Switch to $IFACE and disable $WIFI?")" = "Yes" ] || exit 0
24 +
25 +
notify "Switching to wired..."
26 +
27 +
doas sh -c '
28 +
    ifdown "$2" 2>/dev/null
29 +
    ip link set "$2" down 2>/dev/null
30 +
    # Same reason as dm-wifi-disable: the supervisor respawns the daemon
31 +
    # after a bare kill, so stop the service instead of pkill-ing it.
32 +
    rc-service -s -q wpa_supplicant stop 2>/dev/null
33 +
    ifup "$1"
34 +
' _ "$IFACE" "$WIFI"
35 +
36 +
if ip addr show "$IFACE" | grep -q "inet "; then
37 +
    IP=$(ip -4 addr show "$IFACE" | awk '/inet /{print $2; exit}')
38 +
    notify "✓ $IFACE up — $IP"
39 +
else
40 +
    fail "✗ $IFACE failed to come up"
41 +
fi
scripts/dm-eth0-disable (added) +30 −0
1 +
#!/bin/sh
2 +
# Requires: dmenu, libnotify, ifupdown-ng, doas
3 +
# dmenu counterpart of eth0-disable.
4 +
set -u
5 +
6 +
IFACE="${1:-eth0}"
7 +
NOTIFY_ID=7304
8 +
9 +
notify() {
10 +
    if command -v dunstify >/dev/null 2>&1; then
11 +
        dunstify -r "$NOTIFY_ID" ${2:+-u "$2"} "Network" "$1"
12 +
    else
13 +
        notify-send ${2:+-u "$2"} "Network" "$1"
14 +
    fi
15 +
}
16 +
17 +
fail() { notify "$1" critical; exit 1; }
18 +
19 +
doas -n true 2>/dev/null || fail "doas needs a password; add a nopass rule for this user."
20 +
21 +
[ "$(printf 'No\nYes\n' | dmenu -c -i -l 2 -p "Disable $IFACE?")" = "Yes" ] || exit 0
22 +
23 +
notify "Disabling $IFACE..."
24 +
25 +
doas sh -c '
26 +
    ifdown "$1" 2>/dev/null
27 +
    ip link set "$1" down
28 +
' _ "$IFACE"
29 +
30 +
notify "$IFACE disabled"
scripts/dm-net-menu (added) +52 −0
1 +
#!/bin/sh
2 +
# Requires: dmenu, libnotify
3 +
# dmenu counterpart of net-menu. Everything here runs as the calling user;
4 +
# the individual scripts escalate with doas only for the parts that need it.
5 +
set -u
6 +
7 +
# Launched from a keybinding, PATH may not include this directory — resolve
8 +
# the sibling scripts relative to this one instead.
9 +
DIR=$(dirname "$0")
10 +
11 +
menu() { dmenu -c -i "$@"; }
12 +
13 +
# BusyBox's `ip` has no `-br`, so build the brief view by hand.
14 +
net_status() {
15 +
    ip -4 addr show | awk '
16 +
        function flush() {
17 +
            if (iface != "") printf "%-16s %-8s %s\n", iface, state, addrs
18 +
        }
19 +
        /^[0-9]+: / {
20 +
            flush()
21 +
            iface = $2; sub(/:$/, "", iface)
22 +
            addrs = ""
23 +
            state = "DOWN"
24 +
            if ($0 ~ /state UP/) state = "UP"
25 +
            else if ($0 ~ /state UNKNOWN/) state = "UNKNOWN"
26 +
        }
27 +
        /^[[:space:]]*inet / { addrs = addrs (addrs ? " " : "") $2 }
28 +
        END { flush() }
29 +
    '
30 +
}
31 +
32 +
CHOICE=$(printf '%s\n' \
33 +
    "Connect to WiFi" \
34 +
    "Disable WiFi" \
35 +
    "Connect eth0" \
36 +
    "Disable eth0" \
37 +
    "Show status" \
38 +
    "Quit" | menu -l 6 -p "Network:")
39 +
40 +
case "$CHOICE" in
41 +
    "Connect to WiFi") "$DIR/dm-wifi-connect" ;;
42 +
    "Disable WiFi")    "$DIR/dm-wifi-disable" ;;
43 +
    "Connect eth0")    "$DIR/dm-eth0-connect" ;;
44 +
    "Disable eth0")    "$DIR/dm-eth0-disable" ;;
45 +
    "Show status")
46 +
        # dmenu is the only display surface we have; the selection is ignored.
47 +
        STATUS=$(net_status)
48 +
        LINES=$(printf '%s\n' "$STATUS" | grep -c .)
49 +
        printf '%s\n' "$STATUS" | menu -l "$LINES" -p "Status:" >/dev/null
50 +
        ;;
51 +
    *) exit 0 ;;
52 +
esac
scripts/dm-wifi-connect (added) +127 −0
1 +
#!/bin/sh
2 +
# Requires: dmenu, libnotify, iw, wpa_supplicant, udhcpc, openrc, doas
3 +
# dmenu counterpart of wifi-connect. All prompting happens as the calling
4 +
# user so dmenu keeps the X session and notifications keep the user's bus;
5 +
# only scanning, the config write and the bring-up are handed to doas.
6 +
set -u
7 +
8 +
CONF=/etc/wpa_supplicant/wpa_supplicant.conf
9 +
NOTIFY_ID=7301
10 +
11 +
menu() { dmenu -c -i "$@"; }
12 +
13 +
# Typed text with no dmenu-visible input still has to be hidden, and this
14 +
# build has no password patch — paint the prompt's foreground onto its
15 +
# background so the passphrase never renders.
16 +
menu_secret() {
17 +
    dmenu -c -p "$1" -nb '#000000' -nf '#000000' -sb '#000000' -sf '#000000' </dev/null
18 +
}
19 +
20 +
notify() {
21 +
    # dunstify can replace an earlier notification in place, so the progress
22 +
    # line becomes the result line instead of stacking up.
23 +
    if command -v dunstify >/dev/null 2>&1; then
24 +
        dunstify -r "$NOTIFY_ID" ${2:+-u "$2"} "WiFi" "$1"
25 +
    else
26 +
        notify-send ${2:+-u "$2"} "WiFi" "$1"
27 +
    fi
28 +
}
29 +
30 +
fail() { notify "$1" critical; exit 1; }
31 +
32 +
# doas asks for a passphrase on a tty, and launched from a keybinding there
33 +
# is none — fail with something readable instead of hanging invisibly.
34 +
doas -n true 2>/dev/null || fail "doas needs a password; add a nopass rule for this user."
35 +
36 +
# Ask the kernel which interfaces are wireless rather than assuming wlan0.
37 +
IFACES=$(iw dev | awk '$1 == "Interface" { print $2 }')
38 +
39 +
case $(printf '%s' "$IFACES" | grep -c .) in
40 +
    0) fail "No wireless interfaces found." ;;
41 +
    1) IFACE=$IFACES ;;
42 +
    *) IFACE=$(printf '%s\n' "$IFACES" | menu -l 5 -p "Interface:") ;;
43 +
esac
44 +
45 +
[ -n "$IFACE" ] || exit 1
46 +
47 +
doas ip link set "$IFACE" up 2>/dev/null
48 +
49 +
notify "Scanning for networks on $IFACE..."
50 +
51 +
# `scan dump` reuses cached results if the card is busy (already associated),
52 +
# which is when a live scan tends to fail.
53 +
SCAN=$(doas iw dev "$IFACE" scan 2>/dev/null || doas iw dev "$IFACE" scan dump 2>/dev/null)
54 +
55 +
# Only real SSID lines: bare "SSID: " is a hidden network, and "SSID List"
56 +
# under extended capabilities is not a network at all.
57 +
SSIDS=$(printf '%s\n' "$SCAN" | sed -n 's/^[[:space:]]*SSID: \(..*\)$/\1/p' | sort -u)
58 +
59 +
if [ -n "$SSIDS" ]; then
60 +
    # dmenu returns whatever was typed when nothing matches, so a hidden or
61 +
    # out-of-range network can just be typed into the same prompt.
62 +
    SSID=$(printf '%s\n' "$SSIDS" | menu -l 10 -p "Network:")
63 +
else
64 +
    notify "No networks found on $IFACE — type one in."
65 +
    SSID=$(menu -p "Network:" </dev/null)
66 +
fi
67 +
68 +
[ -n "$SSID" ] || exit 1
69 +
70 +
PASS=$(menu_secret "Password (empty = open):")
71 +
72 +
# Build the new config before touching anything: a rejected passphrase must
73 +
# not blank out the config that is currently working. The PSK never lands on
74 +
# disk unprivileged — it goes to the root shell over a pipe.
75 +
if [ -z "$PASS" ]; then
76 +
    NEW=$(printf 'network={\n\tssid="%s"\n\tkey_mgmt=NONE\n}\n' "$SSID")
77 +
else
78 +
    NEW=$(wpa_passphrase "$SSID" "$PASS" 2>/dev/null | grep -v '^[[:space:]]*#psk=') ||
79 +
        fail "Rejected by wpa_passphrase — password must be 8-63 characters."
80 +
fi
81 +
82 +
[ -n "$NEW" ] || fail "Could not generate a config for $SSID."
83 +
84 +
printf '%s\n' "$NEW" | doas sh -c '
85 +
    [ -f "$1" ] && cp "$1" "$1.bak"
86 +
    cat > "$1"
87 +
    chmod 600 "$1"
88 +
' _ "$CONF" || fail "Could not write $CONF."
89 +
90 +
notify "Connecting to $SSID..."
91 +
92 +
doas sh -c '
93 +
    # wpa_supplicant is a supervised OpenRC service on this host and will not
94 +
    # reread its config on its own, so restart it rather than signalling it.
95 +
    # This also covers the case where dm-wifi-disable stopped it outright.
96 +
    # ifup is no help here: wlan0 has no stanza in /etc/network/interfaces.
97 +
    rc-service wpa_supplicant restart >/dev/null 2>&1
98 +
99 +
    # Give the supplicant time to associate before asking for a lease — a
100 +
    # DHCP request sent before association just burns its whole timeout.
101 +
    i=0
102 +
    while [ "$i" -lt 20 ]; do
103 +
        iw dev "$1" link 2>/dev/null | grep -q "^Connected to" && break
104 +
        sleep 1
105 +
        i=$((i + 1))
106 +
    done
107 +
108 +
    # Replace any DHCP client left over from a previous connect; without this
109 +
    # they stack up, one per run, all renewing the same lease.
110 +
    PIDFILE=/var/run/udhcpc.$1.pid
111 +
    if [ -f "$PIDFILE" ]; then
112 +
        kill "$(cat "$PIDFILE")" 2>/dev/null
113 +
        rm -f "$PIDFILE"
114 +
    fi
115 +
    ip addr flush dev "$1" 2>/dev/null
116 +
117 +
    # Same invocation ifupdown-ng uses for a dhcp stanza. udhcpc daemonises
118 +
    # once it has the lease, so this returns with the address already set.
119 +
    udhcpc -b -R -p "$PIDFILE" -i "$1" -x "hostname:$(hostname)" >/dev/null 2>&1
120 +
' _ "$IFACE"
121 +
122 +
if ip addr show "$IFACE" | grep -q "inet "; then
123 +
    IP=$(ip -4 addr show "$IFACE" | awk '/inet /{print $2; exit}')
124 +
    notify "✓ Connected to $SSID on $IFACE — $IP"
125 +
else
126 +
    fail "✗ Failed to connect to $SSID"
127 +
fi
scripts/dm-wifi-disable (added) +51 −0
1 +
#!/bin/sh
2 +
# Requires: dmenu, libnotify, openrc, ifupdown-ng, doas
3 +
# dmenu counterpart of wifi-disable. The confirm prompt runs as the calling
4 +
# user; only the teardown is handed to doas.
5 +
set -u
6 +
7 +
IFACE="${1:-wlan0}"
8 +
NOTIFY_ID=7302
9 +
10 +
notify() {
11 +
    if command -v dunstify >/dev/null 2>&1; then
12 +
        dunstify -r "$NOTIFY_ID" ${2:+-u "$2"} "WiFi" "$1"
13 +
    else
14 +
        notify-send ${2:+-u "$2"} "WiFi" "$1"
15 +
    fi
16 +
}
17 +
18 +
fail() { notify "$1" critical; exit 1; }
19 +
20 +
# doas asks for a passphrase on a tty, and launched from a keybinding there
21 +
# is none — fail with something readable instead of hanging invisibly.
22 +
doas -n true 2>/dev/null || fail "doas needs a password; add a nopass rule for this user."
23 +
24 +
# Default to No so a stray Enter never takes the link down.
25 +
[ "$(printf 'No\nYes\n' | dmenu -c -i -l 2 -p "Disable WiFi on $IFACE?")" = "Yes" ] || exit 0
26 +
27 +
notify "Disabling $IFACE..."
28 +
29 +
doas sh -c '
30 +
    # wpa_supplicant runs under supervise-daemon (supervisor= in
31 +
    # /etc/init.d/wpa_supplicant), which respawns the daemon a couple of
32 +
    # seconds after any kill. Stopping the service is what takes the
33 +
    # supervisor down with it; pkill only ever kills the child.
34 +
    rc-service -s -q wpa_supplicant stop
35 +
36 +
    # wlan0 has no stanza in /etc/network/interfaces, so ifdown is a no-op on
37 +
    # this host and leaves the DHCP client behind. Try it anyway for hosts
38 +
    # where the interface *is* configured, then clean up by pidfile.
39 +
    ifdown "$1" 2>/dev/null
40 +
41 +
    PIDFILE=/var/run/udhcpc.$1.pid
42 +
    if [ -f "$PIDFILE" ]; then
43 +
        kill "$(cat "$PIDFILE")" 2>/dev/null
44 +
        rm -f "$PIDFILE"
45 +
    fi
46 +
47 +
    ip addr flush dev "$1" 2>/dev/null
48 +
    ip link set "$1" down
49 +
' _ "$IFACE"
50 +
51 +
notify "$IFACE disabled"
scripts/eth0-connect (added) +27 −0
1 +
#!/bin/sh
2 +
# Requires: gum, ifupdown-ng
3 +
# Bringing interfaces up/down needs root.
4 +
set -u
5 +
6 +
IFACE="${1:-eth0}"
7 +
WIFI="${2:-wlan0}"
8 +
9 +
if [ "$(id -u)" -ne 0 ]; then
10 +
    exec doas "$0" "$@"
11 +
fi
12 +
13 +
gum confirm "Switch to $IFACE and disable $WIFI?" || exit 0
14 +
15 +
gum spin --spinner dot --title "Switching to wired..." -- sh -c '
16 +
    ifdown "$2" 2>/dev/null
17 +
    ip link set "$2" down 2>/dev/null
18 +
    pkill wpa_supplicant 2>/dev/null
19 +
    ifup "$1"
20 +
' _ "$IFACE" "$WIFI"
21 +
22 +
if ip addr show "$IFACE" | grep -q "inet "; then
23 +
    IP=$(ip -4 addr show "$IFACE" | grep inet | awk '{print $2}')
24 +
    gum style --foreground 46 --bold "✓ $IFACE up — $IP"
25 +
else
26 +
    gum style --foreground 196 --bold "✗ $IFACE failed to come up"
27 +
fi
scripts/eth0-disable (added) +19 −0
1 +
#!/bin/sh
2 +
# Requires: gum, ifupdown-ng
3 +
# Bringing interfaces down needs root.
4 +
set -u
5 +
6 +
IFACE="${1:-eth0}"
7 +
8 +
if [ "$(id -u)" -ne 0 ]; then
9 +
    exec doas "$0" "$@"
10 +
fi
11 +
12 +
gum confirm "Disable $IFACE?" || exit 0
13 +
14 +
gum spin --spinner dot --title "Disabling $IFACE..." -- sh -c '
15 +
    ifdown "$1" 2>/dev/null
16 +
    ip link set "$1" down
17 +
' _ "$IFACE"
18 +
19 +
gum style --foreground 214 "$IFACE disabled"
scripts/get-forcast-url.sh (added) +8 −0
1 +
#!/usr/bin/env bash
2 +
3 +
LOCATION=$1
4 +
5 +
# data=$(curl -sSL https://api.weather.gov/points/$LOCATION)
6 +
# echo $data
7 +
8 +
curl -sSL https://api.weather.gov/points/$LOCATION | jq -r '.properties.forecast'
scripts/net-menu (added) +36 −0
1 +
#!/bin/sh
2 +
# Resolve the sibling scripts relative to this one rather than through PATH.
3 +
DIR=$(dirname "$0")
4 +
5 +
# BusyBox's `ip` has no `-br`, so build the brief view by hand.
6 +
net_status() {
7 +
    ip -4 addr show | awk '
8 +
        function flush() {
9 +
            if (iface != "") printf "%-16s %-8s %s\n", iface, state, addrs
10 +
        }
11 +
        /^[0-9]+: / {
12 +
            flush()
13 +
            iface = $2; sub(/:$/, "", iface)
14 +
            addrs = ""
15 +
            state = "DOWN"
16 +
            if ($0 ~ /state UP/) state = "UP"
17 +
            else if ($0 ~ /state UNKNOWN/) state = "UNKNOWN"
18 +
        }
19 +
        /^[[:space:]]*inet / { addrs = addrs (addrs ? " " : "") $2 }
20 +
        END { flush() }
21 +
    '
22 +
}
23 +
24 +
CHOICE=$(gum choose "Connect to WiFi" "Disable WiFi" "Connect eth0" "Disable eth0" "Show status" "Quit")
25 +
26 +
case "$CHOICE" in
27 +
    "Connect to WiFi") "$DIR/wifi-connect" ;;
28 +
    "Disable WiFi")    "$DIR/wifi-disable" ;;
29 +
    "Connect eth0")    "$DIR/eth0-connect" ;;
30 +
    "Disable eth0")    "$DIR/eth0-disable" ;;
31 +
    "Show status")
32 +
        gum style --border normal --padding "1 2" --border-foreground 212 \
33 +
            "$(net_status)"
34 +
        ;;
35 +
    *) exit 0 ;;
36 +
esac
scripts/notes.sh (added) +40 −0
1 +
#!/usr/bin/env bash
2 +
3 +
folder="$HOME/notes"
4 +
5 +
mkdir -p "$folder"
6 +
7 +
# BusyBox setsid has no -f; background with & instead.
8 +
launch () {
9 +
  setsid "$term" -e nvim "$1" >/dev/null 2>&1 &
10 +
}
11 +
12 +
: "${TERMINAL:=st}"
13 +
term="$(command -v "$TERMINAL")" || {
14 +
  echo "notes: terminal '$TERMINAL' not found" >&2
15 +
  exit 1
16 +
}
17 +
18 +
newnote () { \
19 +
  dir="$(find "$folder" -type d | sort | dmenu -c -l 5 -i -p 'Choose directory: ')" || exit 0
20 +
  : "${dir:=$folder}"
21 +
  name="$(echo "" | dmenu -c -sb "#a3be8c" -nf "#d8dee9" -p "Enter a name: " <&-)" || exit 0
22 +
  : "${name:=$(date +%F_%H-%M-%S)}"
23 +
  launch "${dir%/}/${name}.md"
24 +
}
25 +
26 +
selected () { \
27 +
  choice=$(
28 +
    { echo "New"
29 +
      find "$folder" -type f -name '*.md' -exec stat -c '%Y %n' {} + 2>/dev/null \
30 +
        | sort -nr | cut -d' ' -f2- | sed "s|^$folder/||"
31 +
    } | dmenu -c -l 5 -i -p "Choose note or create new: "
32 +
  )
33 +
  case $choice in
34 +
    New) newnote ;;
35 +
    *.md) launch "$folder/$choice" ;;
36 +
    *) exit ;;
37 +
  esac
38 +
}
39 +
40 +
selected
scripts/stats.sh (added) +40 −0
1 +
#!/usr/bin/env bash
2 +
# basic stats checker script mostly adapted from statusbar, may need changes to work on your system
3 +
4 +
CPU_TEMP=$(sensors | awk '
5 +
    /^Tdie|^Tctl|^Package id|^Core 0|^CPU|^temp1/ {
6 +
        gsub(/[+°C]/, "");
7 +
        for (i=1; i<=NF; i++) {
8 +
            if ($i ~ /^[0-9]+(\.[0-9]+)?$/) {
9 +
                gsub(/\..*/, "", $i);
10 +
                print $i;
11 +
                exit;
12 +
            }
13 +
        }
14 +
    }')
15 +
16 +
MEM_USE=$(free -m | awk '/^Mem/ {print ($3)/1024}'| cut -c-4)
17 +
CPU_USE=$(iostat -c | awk 'NR>=4 && NR <=4' | awk '{print "User CPU: "$1"%\nSystem CPU: ",$3"%"}')
18 +
GPU_TEMP=$(sensors | awk '/^edge/ {print substr($2,2)}')
19 +
20 +
21 +
DISK1=$(df -h ~/ | awk 'NR==2 {print $4}')
22 +
DISK2=$(df -h / | awk 'NR==2 {print $4}')
23 +
24 +
# [[ -n $(amixer get Capture | awk '/\[on\]/') ]] && MIC="on" || MIC="off"
25 +
UPDATE=$(stat -c %y /lib/apk/db/installed | awk '{print $1}')
26 +
KERNEL=$(uname -r)
27 +
28 +
echo -e "Stats $CPU_USE
29 +
30 +
GPU temp: $GPU_TEMP
31 +
CPU temp: $CPU_TEMP°C
32 +
33 +
Memory use: ${MEM_USE}g
34 +
35 +
Home remaining: $DISK1
36 +
Root remaining: $DISK2
37 +
38 +
Last update: $UPDATE       $KERNEL" \
39 +
| bat -l cpuinfo --style=grid
40 +
scripts/sys.sh (added) +9 −0
1 +
#!/usr/bin/env bash
2 +
3 +
case "$(printf "kill\nzzz\nreboot\nshutdown" | dmenu -i -c -l 4)" in
4 +
	kill) ps -u "$USER" -o pid,comm,%cpu,%mem | dmenu -i -c -l 10 -p Kill: | awk '{print $1}' | xargs -r kill ;;
5 +
	zzz) slock systemctl suspend -i ;;
6 +
	reboot) systemctl reboot -i ;;
7 +
	shutdown) shutdown now ;;
8 +
	*) exit 1 ;;
9 +
esac
scripts/txtcliphist.sh (added) +30 −0
1 +
#!/usr/bin/env bash
2 +
3 +
histfile="$HOME/.cache/cliphist"
4 +
placeholder="<NEWLINE>"
5 +
6 +
highlight() {
7 +
  clip=$(xclip -o -selection primary | xclip -i -f -selection clipboard 2>/dev/null) ;}
8 +
9 +
output() {
10 +
  clip=$(xclip -i -f -selection clipboard 2>/dev/null) ;}
11 +
12 +
write() {
13 +
  [ -f "$histfile" ] || notify-send "Creating $histfile"; touch $histfile
14 +
  [ -z "$clip" ] && exit 0
15 +
  multiline=$(echo "$clip" | sed ':a;N;$!ba;s/\n/'"$placeholder"'/g')
16 +
  grep -Fxq "$multiline" "$histfile" || echo "$multiline" >> "$histfile"
17 +
  notification=$(echo \"$multiline\") ;}
18 +
19 +
sel() {
20 +
  selection=$(tac "$histfile" | dmenu -b -l 5 -i -p "Clipboard history:")
21 +
  [ -n "$selection" ] && echo "$selection" | sed "s/$placeholder/\n/g" | xclip -i -selection clipboard && notification="Copied to clipboard!" ;}
22 +
23 +
case "$1" in
24 +
  add) highlight && write ;;
25 +
  out) output && write ;;
26 +
  sel) sel ;;
27 +
  *) printf "$0 | File: $histfile\n\nadd - copies primary selection to clipboard, and adds to history file\nout - pipe commands to copy output to clipboard, and add to history file\nsel - select from history file with dmenu and recopy!\n" ; exit 0 ;;
28 +
esac
29 +
30 +
[ -z "$notification" ] || notify-send "$notification"
scripts/vol-down (added) +9 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl)
3 +
# Lower the default sink. Usage: vol-down [STEP_PERCENT]   (default 5)
4 +
set -u
5 +
6 +
STEP="${1:-5}"
7 +
8 +
wpctl set-volume @DEFAULT_AUDIO_SINK@ "${STEP}%-" || exit 1
9 +
exec "$(dirname "$0")/vol-status" -n
scripts/vol-mute (added) +27 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl)
3 +
# Mute the default sink. Usage: vol-mute [toggle|on|off]   (default toggle)
4 +
# Pass -s / --source to act on the default microphone instead.
5 +
set -u
6 +
7 +
TARGET="@DEFAULT_AUDIO_SINK@"
8 +
case "${1:-}" in
9 +
    -s|--source) TARGET="@DEFAULT_AUDIO_SOURCE@"; shift ;;
10 +
esac
11 +
12 +
case "${1:-toggle}" in
13 +
    toggle|"") ARG=toggle ;;
14 +
    on|1|mute)     ARG=1 ;;
15 +
    off|0|unmute)  ARG=0 ;;
16 +
    *) echo "usage: vol-mute [-s] [toggle|on|off]" >&2; exit 2 ;;
17 +
esac
18 +
19 +
wpctl set-mute "$TARGET" "$ARG" || exit 1
20 +
21 +
if [ "$TARGET" = "@DEFAULT_AUDIO_SOURCE@" ]; then
22 +
    STATE=$(wpctl get-volume @DEFAULT_AUDIO_SOURCE@ 2>/dev/null)
23 +
    case "$STATE" in *MUTED*) echo "mic muted" ;; *) echo "mic live" ;; esac
24 +
    exit 0
25 +
fi
26 +
27 +
exec "$(dirname "$0")/vol-status" -n
scripts/vol-status (added) +47 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl), optionally libnotify
3 +
# Prints the default sink's volume. Shared by vol-up / vol-down / vol-mute.
4 +
# Usage: vol-status [-n]   -n also fires a desktop notification
5 +
set -u
6 +
7 +
NOTIFY=0
8 +
[ "${1:-}" = "-n" ] && NOTIFY=1
9 +
10 +
RAW=$(wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null)
11 +
if [ -z "$RAW" ]; then
12 +
    echo "vol-status: no default sink (is pipewire/wireplumber running?)" >&2
13 +
    exit 1
14 +
fi
15 +
16 +
# "Volume: 0.45" or "Volume: 0.45 [MUTED]"
17 +
PCT=$(printf '%s\n' "$RAW" | awk '{ printf "%d", $2 * 100 + 0.5 }')
18 +
case "$RAW" in
19 +
    *MUTED*) MUTED=1 ;;
20 +
    *)       MUTED=0 ;;
21 +
esac
22 +
23 +
if [ "$MUTED" -eq 1 ]; then
24 +
    TEXT="muted ($PCT%)"
25 +
    ICON="audio-volume-muted"
26 +
else
27 +
    TEXT="$PCT%"
28 +
    if   [ "$PCT" -ge 66 ]; then ICON="audio-volume-high"
29 +
    elif [ "$PCT" -ge 33 ]; then ICON="audio-volume-medium"
30 +
    elif [ "$PCT" -gt 0 ];  then ICON="audio-volume-low"
31 +
    else                         ICON="audio-volume-muted"
32 +
    fi
33 +
fi
34 +
35 +
echo "$TEXT"
36 +
37 +
# Only bother with notifications when there is a session to show them in.
38 +
[ "$NOTIFY" -eq 1 ] || exit 0
39 +
[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] || exit 0
40 +
command -v notify-send >/dev/null 2>&1 || exit 0
41 +
42 +
notify-send \
43 +
    -a volume \
44 +
    -i "$ICON" \
45 +
    -h string:x-canonical-private-synchronous:volume \
46 +
    -h "int:value:$PCT" \
47 +
    "Volume" "$TEXT"
scripts/vol-up (added) +11 −0
1 +
#!/bin/sh
2 +
# Requires: wireplumber (wpctl)
3 +
# Raise the default sink. Usage: vol-up [STEP_PERCENT]   (default 5)
4 +
# VOL_MAX caps the ceiling (1.0 = 100%); raise it if you want overamplification.
5 +
set -u
6 +
7 +
STEP="${1:-5}"
8 +
MAX="${VOL_MAX:-1.0}"
9 +
10 +
wpctl set-volume -l "$MAX" @DEFAULT_AUDIO_SINK@ "${STEP}%+" || exit 1
11 +
exec "$(dirname "$0")/vol-status" -n
scripts/weather.sh (added) +98 −0
1 +
#!/usr/bin/env bash
2 +
3 +
set -euo pipefail
4 +
5 +
gridpoint=${WEATHER_GRIDPOINT:-MRX/23,5}
6 +
base_url="https://api.weather.gov/gridpoints/${gridpoint}/forecast"
7 +
8 +
cloudy=󰖐
9 +
fog=󰖑
10 +
hail=󰖒
11 +
hazy=󰼰
12 +
storm=󰖓
13 +
clear_night=󰖔
14 +
rain=󰖖
15 +
snow=󰖘
16 +
clear_day=󰖙
17 +
18 +
usage() {
19 +
	cat <<'USAGE'
20 +
usage: weather.sh [-h|--hourly] [-j|--json]
21 +
22 +
  -h, --hourly   use the hourly forecast instead of the daily one
23 +
  -j, --json     print { "text": ..., "tooltip": ... } (waybar friendly)
24 +
25 +
env:
26 +
  WEATHER_GRIDPOINT   NWS gridpoint, default MRX/23,5
27 +
USAGE
28 +
}
29 +
30 +
hourly=false
31 +
json=false
32 +
while (($#)); do
33 +
	case $1 in
34 +
	-h | --hourly) hourly=true ;;
35 +
	-j | --json) json=true ;;
36 +
	--help) usage && exit 0 ;;
37 +
	*) usage >&2 && exit 1 ;;
38 +
	esac
39 +
	shift
40 +
done
41 +
42 +
$hourly && weather_url="$base_url/hourly" || weather_url="$base_url"
43 +
44 +
data=$(curl -sSL --fail --max-time 10 -H 'Accept: application/geo+json' "$weather_url" |
45 +
	jq -r '.properties.periods[0] | [.icon, .temperature, .temperatureUnit, .shortForecast, .isDaytime] | @tsv') || {
46 +
	echo "weather: request failed" >&2
47 +
	exit 1
48 +
}
49 +
50 +
IFS=$'\t' read -r icon_url temp unit short is_day <<<"$data"
51 +
52 +
# https://api.weather.gov/icons/land/night/tsra_hi,20/bkn?size=small
53 +
#                             ^period      ^condition
54 +
icon_path=${icon_url%%\?*}      # drop the query string
55 +
icon_path=${icon_path#*/icons/} # land/night/tsra_hi,20/bkn
56 +
icon_path=${icon_path#*/}       # night/tsra_hi,20/bkn
57 +
period=${icon_path%%/*}         # day|night
58 +
condition=${icon_path#*/}
59 +
condition=${condition%%/*}      # first of the (up to two) conditions
60 +
condition=${condition%%,*}      # drop the ",20" precip chance
61 +
62 +
night=false
63 +
[[ $period == night || $is_day == false ]] && night=true
64 +
65 +
# NWS icon vocabulary -> glyph.
66 +
case $condition in
67 +
skc | few | hot | cold) $night && glyph=$clear_night || glyph=$clear_day ;;
68 +
sct | bkn | ovc | wind_skc | wind_few | wind_sct | wind_bkn | wind_ovc) glyph=$cloudy ;;
69 +
tsra | tsra_sct | tsra_hi | tornado | hurricane | tropical_storm) glyph=$storm ;;
70 +
rain | rain_showers | rain_showers_hi) glyph=$rain ;;
71 +
snow | blizzard | rain_snow | snow_sleet) glyph=$snow ;;
72 +
sleet | fzra | rain_fzra | snow_fzra | rain_sleet) glyph=$hail ;;
73 +
fog) glyph=$fog ;;
74 +
haze | smoke | dust) glyph=$hazy ;;
75 +
*) glyph="" ;;
76 +
esac
77 +
78 +
# Fall back to a keyword match on the forecast text, most specific first.
79 +
if [[ -z $glyph ]]; then
80 +
	case ${short,,} in
81 +
	*thunder* | *tstm* | *tornado* | *hurricane* | *tropical*) glyph=$storm ;;
82 +
	*hail* | *sleet* | *freezing* | *ice*) glyph=$hail ;;
83 +
	*snow* | *flurr* | *blizzard* | *wintry*) glyph=$snow ;;
84 +
	*rain* | *shower* | *drizzle*) glyph=$rain ;;
85 +
	*fog* | *mist*) glyph=$fog ;;
86 +
	*haze* | *hazy* | *smoke* | *dust*) glyph=$hazy ;;
87 +
	*cloud* | *overcast*) glyph=$cloudy ;;
88 +
	*clear* | *sunny* | *fair* | *hot*) $night && glyph=$clear_night || glyph=$clear_day ;;
89 +
	*) $night && glyph=$clear_night || glyph=$clear_day ;;
90 +
	esac
91 +
fi
92 +
93 +
if $json; then
94 +
	jq -cn --arg text "$glyph $temp°$unit" --arg tooltip "$short" \
95 +
		'{text: $text, tooltip: $tooltip}'
96 +
else
97 +
	printf '%s %s°%s\n' "$glyph" "$temp" "$unit"
98 +
fi
scripts/wifi-connect (added) +116 −0
1 +
#!/bin/sh
2 +
# Requires: gum, iw, wpa_supplicant, udhcpc, openrc
3 +
# Scanning and writing wpa_supplicant.conf both need root.
4 +
set -u
5 +
6 +
CONF=/etc/wpa_supplicant/wpa_supplicant.conf
7 +
8 +
if [ "$(id -u)" -ne 0 ]; then
9 +
    exec doas "$0" "$@"
10 +
fi
11 +
12 +
# Ask the kernel which interfaces are wireless rather than assuming wlan0.
13 +
IFACES=$(iw dev | awk '$1 == "Interface" { print $2 }')
14 +
15 +
case $(printf '%s' "$IFACES" | grep -c .) in
16 +
    0) gum style --foreground 196 "No wireless interfaces found."; exit 1 ;;
17 +
    1) IFACE=$IFACES ;;
18 +
    *) IFACE=$(printf '%s\n' "$IFACES" | gum choose --header "Interface:") ;;
19 +
esac
20 +
21 +
if [ -z "$IFACE" ]; then
22 +
    exit 1
23 +
fi
24 +
25 +
ip link set "$IFACE" up 2>/dev/null
26 +
27 +
SCAN=$(mktemp)
28 +
trap 'rm -f "$SCAN"' EXIT INT TERM
29 +
30 +
# The scan has to run inside the spinner, and into a file — gum spin owns
31 +
# stdout while it renders. `scan dump` reuses cached results if the card is
32 +
# busy (already associated), which is when a live scan tends to fail.
33 +
gum spin --spinner dot --title "Scanning for networks..." -- sh -c '
34 +
    iw dev "$1" scan > "$2" 2>/dev/null || iw dev "$1" scan dump > "$2" 2>/dev/null
35 +
' _ "$IFACE" "$SCAN"
36 +
37 +
# Only real SSID lines: bare "SSID: " is a hidden network, and "SSID List"
38 +
# under extended capabilities is not a network at all.
39 +
SSIDS=$(sed -n 's/^[[:space:]]*SSID: \(..*\)$/\1/p' "$SCAN" | sort -u)
40 +
41 +
SSID=""
42 +
if [ -n "$SSIDS" ]; then
43 +
    # Never pipe an empty list into gum filter: with no input it falls back to
44 +
    # listing the current directory, which looks like a network list but isn't.
45 +
    SSID=$(printf '%s\n' "$SSIDS" | gum filter --placeholder "Select a network...")
46 +
else
47 +
    gum style --foreground 214 "No networks found on $IFACE — enter one manually."
48 +
fi
49 +
50 +
if [ -z "$SSID" ]; then
51 +
    SSID=$(gum input --placeholder "SSID" --prompt "Network name: ")
52 +
fi
53 +
54 +
if [ -z "$SSID" ]; then
55 +
    gum style --foreground 196 "No SSID entered, aborting."
56 +
    exit 1
57 +
fi
58 +
59 +
PASS=$(gum input --password --placeholder "leave empty for an open network" --prompt "Password: ")
60 +
61 +
# Build the new config off to the side: a rejected passphrase must not blank
62 +
# out the config that is currently working.
63 +
NEW=$(mktemp)
64 +
trap 'rm -f "$SCAN" "$NEW"' EXIT INT TERM
65 +
66 +
if [ -z "$PASS" ]; then
67 +
    printf 'network={\n\tssid="%s"\n\tkey_mgmt=NONE\n}\n' "$SSID" > "$NEW"
68 +
elif ! wpa_passphrase "$SSID" "$PASS" 2>/dev/null | grep -v '^[[:space:]]*#psk=' > "$NEW"; then
69 +
    gum style --foreground 196 "Rejected by wpa_passphrase — password must be 8-63 characters."
70 +
    exit 1
71 +
fi
72 +
73 +
if [ ! -s "$NEW" ]; then
74 +
    gum style --foreground 196 "Could not generate a config for $SSID, aborting."
75 +
    exit 1
76 +
fi
77 +
78 +
[ -f "$CONF" ] && cp "$CONF" "$CONF.bak"
79 +
cat "$NEW" > "$CONF"
80 +
chmod 600 "$CONF"
81 +
82 +
gum spin --spinner dot --title "Connecting to $SSID..." -- sh -c '
83 +
    # wpa_supplicant is a supervised OpenRC service on this host and will not
84 +
    # reread its config on its own, so restart it rather than signalling it.
85 +
    # This also covers the case where wifi-disable stopped it outright.
86 +
    # ifup is no help here: wlan0 has no stanza in /etc/network/interfaces.
87 +
    rc-service wpa_supplicant restart >/dev/null 2>&1
88 +
89 +
    # Give the supplicant time to associate before asking for a lease — a
90 +
    # DHCP request sent before association just burns its whole timeout.
91 +
    i=0
92 +
    while [ "$i" -lt 20 ]; do
93 +
        iw dev "$1" link 2>/dev/null | grep -q "^Connected to" && break
94 +
        sleep 1
95 +
        i=$((i + 1))
96 +
    done
97 +
98 +
    # Replace any DHCP client left over from a previous connect; without this
99 +
    # they stack up, one per run, all renewing the same lease.
100 +
    PIDFILE=/var/run/udhcpc.$1.pid
101 +
    if [ -f "$PIDFILE" ]; then
102 +
        kill "$(cat "$PIDFILE")" 2>/dev/null
103 +
        rm -f "$PIDFILE"
104 +
    fi
105 +
    ip addr flush dev "$1" 2>/dev/null
106 +
107 +
    # Same invocation ifupdown-ng uses for a dhcp stanza. udhcpc daemonises
108 +
    # once it has the lease, so this returns with the address already set.
109 +
    udhcpc -b -R -p "$PIDFILE" -i "$1" -x "hostname:$(hostname)" >/dev/null 2>&1
110 +
' _ "$IFACE"
111 +
112 +
if ip addr show "$IFACE" | grep -q "inet "; then
113 +
    gum style --foreground 46 --bold "✓ Connected to $SSID on $IFACE"
114 +
else
115 +
    gum style --foreground 196 --bold "✗ Failed to connect to $SSID"
116 +
fi
scripts/wifi-disable (added) +36 −0
1 +
#!/bin/sh
2 +
# Requires: gum, openrc, ifupdown-ng
3 +
# Stopping the wpa_supplicant service and downing the link both need root.
4 +
set -u
5 +
6 +
IFACE="${1:-wlan0}"
7 +
8 +
if [ "$(id -u)" -ne 0 ]; then
9 +
    exec doas "$0" "$@"
10 +
fi
11 +
12 +
gum confirm "Disable WiFi on $IFACE?" || exit 0
13 +
14 +
gum spin --spinner dot --title "Disabling $IFACE..." -- sh -c '
15 +
    # wpa_supplicant runs under supervise-daemon (supervisor= in
16 +
    # /etc/init.d/wpa_supplicant), which respawns the daemon a couple of
17 +
    # seconds after any kill. Stopping the service is what takes the
18 +
    # supervisor down with it; pkill only ever kills the child.
19 +
    rc-service -s -q wpa_supplicant stop
20 +
21 +
    # wlan0 has no stanza in /etc/network/interfaces, so ifdown is a no-op on
22 +
    # this host and leaves the DHCP client behind. Try it anyway for hosts
23 +
    # where the interface *is* configured, then clean up by pidfile.
24 +
    ifdown "$1" 2>/dev/null
25 +
26 +
    PIDFILE=/var/run/udhcpc.$1.pid
27 +
    if [ -f "$PIDFILE" ]; then
28 +
        kill "$(cat "$PIDFILE")" 2>/dev/null
29 +
        rm -f "$PIDFILE"
30 +
    fi
31 +
32 +
    ip addr flush dev "$1" 2>/dev/null
33 +
    ip link set "$1" down
34 +
' _ "$IFACE"
35 +
36 +
gum style --foreground 214 "$IFACE disabled"
scripts/wifi-status (added) +33 −0
1 +
#!/bin/sh
2 +
# Requires: iw
3 +
# Prints the wireless link state, for the status bar and menus.
4 +
# Usage: wifi-status [IFACE]   (default: first wireless interface)
5 +
set -u
6 +
7 +
# Ask the kernel which interfaces are wireless rather than assuming wlan0.
8 +
IFACE="${1:-$(iw dev 2>/dev/null | awk '$1 == "Interface" { print $2; exit }')}"
9 +
10 +
if [ -z "$IFACE" ]; then
11 +
    echo "none"
12 +
    exit 0
13 +
fi
14 +
15 +
# `iw link` prints "Not connected." while the card is up but unassociated, and
16 +
# fails outright once wifi-disable has downed the link.
17 +
LINK=$(iw dev "$IFACE" link 2>/dev/null) || { echo "off"; exit 0; }
18 +
19 +
case "$LINK" in
20 +
    "Connected to"*) ;;
21 +
    *) echo "off"; exit 0 ;;
22 +
esac
23 +
24 +
SSID=$(printf '%s\n' "$LINK" | sed -n 's/^[[:space:]]*SSID: \(..*\)$/\1/p')
25 +
[ -n "$SSID" ] || SSID="unknown"
26 +
27 +
# Associated but without a lease carries no traffic — say so rather than
28 +
# showing a network name that looks connected.
29 +
if ip addr show "$IFACE" 2>/dev/null | grep -q "inet "; then
30 +
    echo "$SSID"
31 +
else
32 +
    echo "$SSID (no ip)"
33 +
fi