aboutsummaryrefslogtreecommitdiffstats
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/privacy_dots.sh199
-rwxr-xr-xbin/vms_dots.sh89
-rwxr-xr-xbin/wb-icon171
-rwxr-xr-xbin/wb-idle45
-rwxr-xr-xbin/wb-lang46
5 files changed, 550 insertions, 0 deletions
diff --git a/bin/privacy_dots.sh b/bin/privacy_dots.sh
new file mode 100755
index 0000000..2074631
--- /dev/null
+++ b/bin/privacy_dots.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+# A dot per active capture: microphone, camera, location, screen sharing.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# dependencies: pipewire (pw-dump), jq, psmisc (fuser)
+#
+# Shows nothing when everything is off, which is the point: the pill only
+# appears when something is actually capturing.
+#
+# The colours come from the palette, not from here: install.sh writes
+# ~/.config/waybar-udt/dots.colors from the selected udt scheme, and this
+# sources it. The defaults below are only what a missing file falls back to.
+set -euo pipefail
+
+JQ_BIN="${JQ:-jq}"
+PW_DUMP_CMD="${PW_DUMP:-pw-dump}"
+
+# Palette. Sourced, with fallbacks, so this script works standalone.
+DOT_SUCCESS="#a6da95"
+DOT_WARNING="#eed49f"
+DOT_CRITICAL="#ed8796"
+DOT_INFO="#8bd5ca"
+DOT_ACCENT="#b7bdf8"
+DOT_FAINT="#6e738d"
+colors="$HOME/.config/waybar-udt/dots.colors"
+# shellcheck source=/dev/null
+[[ -r "$colors" ]] && . "$colors"
+
+mic=0
+cam=0
+loc=0
+scr=0
+
+mic_app=""
+cam_app=""
+loc_app=""
+scr_app=""
+
+# mic & camera
+if command -v "$PW_DUMP_CMD" >/dev/null 2>&1 && command -v "$JQ_BIN" >/dev/null 2>&1; then
+ dump="$($PW_DUMP_CMD 2>/dev/null || true)"
+
+ mic="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Audio/Source" or .info.props."media.class"=="Audio/Source/Virtual"))
+ | select((.info.state=="running") or (.state=="running"))
+ ] | (if length>0 then 1 else 0 end)
+ ' 2>/dev/null || echo 0
+ )"
+
+ if [[ "$mic" -eq 1 ]]; then
+ mic_app="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Stream/Input/Audio"))
+ | select((.info.state=="running") or (.state=="running"))
+ | .info.props["node.name"]
+ ] | unique | join(", ")
+ ' 2>/dev/null || echo ""
+ )"
+ fi
+
+ if command -v fuser >/dev/null 2>&1; then
+ cam=0
+ for dev in /dev/video*; do
+ if [ -e "$dev" ] && fuser "$dev" >/dev/null 2>&1; then
+ cam=1
+ break
+ fi
+ done
+ else
+ cam=0
+ fi
+
+ if command -v fuser >/dev/null 2>&1; then
+ for dev in /dev/video*; do
+ if [ -e "$dev" ] && fuser "$dev" >/dev/null 2>&1; then
+ pids=$(fuser "$dev" 2>/dev/null)
+ for pid in $pids; do
+ pname=$(ps -p "$pid" -o comm=)
+ if [[ -n "$pname" ]]; then
+ cam_app+="$pname, "
+ fi
+ done
+ fi
+ done
+ cam_app="${cam_app%, }"
+ fi
+
+fi
+
+# location
+if command -v gdbus >/dev/null 2>&1; then
+ if pids=$(pgrep -x geoclue); then
+ loc=1
+ for pid in $pids; do
+ pname=$(ps -p "$pid" -o comm=)
+ [[ -n "$pname" ]] && loc_app+="$pname, "
+ done
+ loc_app="${loc_app%, }"
+ else
+ loc=0
+ fi
+fi
+
+# screen sharing
+if command -v "$PW_DUMP_CMD" >/dev/null 2>&1 && command -v "$JQ_BIN" >/dev/null 2>&1; then
+ if [[ -z "${dump:-}" ]]; then
+ dump="$($PW_DUMP_CMD 2>/dev/null || true)"
+ fi
+
+ scr="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -e '
+ [ .[]
+ | select(.info?.props?)
+ | select(
+ (.info.props["media.name"]? // "")
+ | test("^(xdph-streaming|gsr-default)")
+ )
+ ]
+ | (if length > 0 then true else false end)
+ ' >/dev/null && echo 1 || echo 0
+ )"
+fi
+
+if [[ "$scr" -eq 1 ]]; then
+ scr_app="$(
+ printf '%s' "$dump" \
+ | $JQ_BIN -r '
+ [ .[]
+ | select(.type=="PipeWire:Interface:Node")
+ | select((.info.props."media.class"=="Stream/Input/Video") or (.info.props."media.name"=="gsr-default_output"))
+ | select((.info.state=="running") or (.state=="running"))
+ | .info.props["media.name"]
+ ] | unique | join(", ")
+ ' 2>/dev/null || echo ""
+ )"
+fi
+
+# output. Capture is a state worth noticing, so these are the alert roles:
+# the microphone is the quiet one, the camera and the screen are not.
+dot() {
+ local on="$1" color="$2"
+ if [[ "$on" -eq 1 ]]; then
+ printf '<span foreground="%s">●</span>' "$color"
+ else
+ printf ''
+ fi
+}
+
+dots=()
+mic_dot="$(dot "$mic" "$DOT_SUCCESS")"; [[ -n "$mic_dot" ]] && dots+=("$mic_dot")
+cam_dot="$(dot "$cam" "$DOT_WARNING")"; [[ -n "$cam_dot" ]] && dots+=("$cam_dot")
+loc_dot="$(dot "$loc" "$DOT_INFO")"; [[ -n "$loc_dot" ]] && dots+=("$loc_dot")
+scr_dot="$(dot "$scr" "$DOT_CRITICAL")"; [[ -n "$scr_dot" ]] && dots+=("$scr_dot")
+
+text="${dots[*]}"
+
+if [[ -n "$mic_app" ]]; then
+ mic_status="Mic: $mic_app"
+else
+ mic_status="Mic: off"
+fi
+
+if [[ -n "$cam_app" ]]; then
+ cam_status="Cam: $cam_app"
+else
+ cam_status="Cam: off"
+fi
+
+if [[ -n "$loc_app" ]]; then
+ loc_status="Location: $loc_app"
+else
+ loc_status="Location: off"
+fi
+
+if [[ -n "$scr_app" ]]; then
+ scr_status="Screen sharing: $scr_app"
+else
+ scr_status="Screen sharing: off"
+fi
+
+tooltip="$mic_status | $cam_status | $loc_status | $scr_status"
+
+classes="privacydot"
+[[ $mic -eq 1 ]] && classes="$classes mic-on" || classes="$classes mic-off"
+[[ $cam -eq 1 ]] && classes="$classes cam-on" || classes="$classes cam-off"
+[[ $loc -eq 1 ]] && classes="$classes loc-on" || classes="$classes loc-off"
+[[ $scr -eq 1 ]] && classes="$classes scr-on" || classes="$classes scr-off"
+
+$JQ_BIN -c -n --arg text "$text" --arg tooltip "$tooltip" --arg class "$classes" \
+ '{text:$text, tooltip:$tooltip, class:$class}'
diff --git a/bin/vms_dots.sh b/bin/vms_dots.sh
new file mode 100755
index 0000000..bf95070
--- /dev/null
+++ b/bin/vms_dots.sh
@@ -0,0 +1,89 @@
+#! /bin/bash
+# One dot per defined VM, coloured by state.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# dependencies: libvirt (virsh), jq
+#
+# The colours come from the palette, not from here: install.sh writes
+# ~/.config/waybar-udt/dots.colors from the selected udt scheme, and this
+# sources it. The defaults below are only what a missing file falls back to,
+# so the bar still draws if the theme has not been installed yet.
+set -euo pipefail
+
+JQ_BIN="${JQ:-jq}"
+
+# Palette. Sourced, with fallbacks, so this script works standalone.
+DOT_SUCCESS="#a6da95"
+DOT_WARNING="#eed49f"
+DOT_CRITICAL="#ed8796"
+DOT_INFO="#8bd5ca"
+DOT_ACCENT="#b7bdf8"
+DOT_FAINT="#6e738d"
+colors="$HOME/.config/waybar-udt/dots.colors"
+# shellcheck source=/dev/null
+[[ -r "$colors" ]] && . "$colors"
+
+# Require virsh
+if ! command -v virsh >/dev/null 2>&1; then
+ $JQ_BIN -cn '{text: "", tooltip: "virsh not found", class: "vmdot"}'
+ exit 0
+fi
+
+# Collect all defined VMs and their states.
+# virsh list --all columns: Id Name State (state may be multi-word, e.g. "shut off")
+declare -A vm_states
+while IFS= read -r line; do
+ # Skip header lines and separator lines
+ [[ "$line" =~ ^[[:space:]]*Id[[:space:]] ]] && continue
+ [[ "$line" =~ ^[-[:space:]]+$ ]] && continue
+ [[ -z "${line// }" ]] && continue
+
+ # Fields: id (may be "-"), name, state (rest of line)
+ read -r _id name state_rest <<< "$line"
+ vm_states["$name"]="$state_rest"
+done < <(virsh list --all 2>/dev/null)
+
+if [[ ${#vm_states[@]} -eq 0 ]]; then
+ $JQ_BIN -cn '{text: "", tooltip: "No VMs defined", class: "vmdot"}'
+ exit 0
+fi
+
+# libvirt state → palette role.
+declare -A state_color=(
+ ["running"]="$DOT_SUCCESS"
+ ["idle"]="$DOT_WARNING" # running but idle
+ ["paused"]="$DOT_WARNING"
+ ["pmsuspended"]="$DOT_ACCENT" # PM sleep / ACPI S3
+ ["saved"]="$DOT_INFO" # managed save
+ ["crashed"]="$DOT_CRITICAL"
+ ["shut off"]="$DOT_FAINT"
+)
+fallback_color="$DOT_FAINT" # any unknown future state
+
+dots=()
+tooltip_parts=()
+classes="vmdot"
+
+# Sort VM names for stable ordering
+mapfile -t sorted_names < <(printf '%s\n' "${!vm_states[@]}" | sort)
+
+for name in "${sorted_names[@]}"; do
+ state="${vm_states[$name]}"
+ color="${state_color[$state]:-$fallback_color}"
+ # Derive a CSS-safe class token (spaces → hyphens)
+ state_class="${state// /-}"
+
+ dots+=("<span foreground=\"${color}\">●</span>")
+ classes="$classes vm-${state_class}"
+ tooltip_parts+=("${name}: ${state}")
+done
+
+text="${dots[*]}"
+tooltip="$(IFS=' | '; echo "${tooltip_parts[*]}")"
+
+$JQ_BIN -cn \
+ --arg text "$text" \
+ --arg tooltip "$tooltip" \
+ --arg class "$classes" \
+ '{text: $text, tooltip: $tooltip, class: $class}'
diff --git a/bin/wb-icon b/bin/wb-icon
new file mode 100755
index 0000000..9c29c60
--- /dev/null
+++ b/bin/wb-icon
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+# Resolve a module state to an icon path from the desktop's icon theme.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's `image` module runs a command and reads "$path\n$tooltip" from its
+# stdout. That is the only way a themed icon reaches a module that is not the
+# taskbar or the tray, so volume, microphone and presentation mode all come
+# through here.
+#
+# wb-icon volume # reads wpctl, prints the matching icon
+# wb-icon mic
+# wb-icon idle <activated|deactivated>
+# wb-icon --selftest # every name this bar asks for must resolve
+#
+# Icons keep their own colours: this desktop's icon theme draws them with
+# baked-in gradients, so they are not recolourable and are not meant to be.
+# Only the two icons this repo ships are monochrome.
+
+import subprocess
+import sys
+
+import gi
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk # noqa: E402 (must follow require_version)
+
+# The icon theme to draw from. Kept in one place because changing it is a
+# single edit, and because --selftest needs the same value the modules use.
+THEME = "Material-Black-Plum-Suru"
+
+SIZE = 22
+
+# State to icon name. Every name here is checked by --selftest, so a theme that
+# drops one fails at install time rather than rendering an empty pill.
+ICONS = {
+ "volume": {
+ "muted": "audio-volume-muted",
+ "low": "audio-volume-low",
+ "medium": "audio-volume-medium",
+ "high": "audio-volume-high",
+ },
+ "mic": {
+ "muted": "microphone-sensitivity-muted",
+ "on": "audio-input-microphone",
+ },
+ "idle": {
+ "activated": "x-office-presentation",
+ "deactivated": "preferences-desktop-screensaver",
+ },
+}
+
+
+def lookup(name):
+ """Absolute path of an icon in THEME, or None."""
+ theme = Gtk.IconTheme.new()
+ theme.set_custom_theme(THEME)
+ info = theme.lookup_icon(name, SIZE, 0)
+ return info.get_filename() if info else None
+
+
+def wpctl(node):
+ """(volume percent, muted) for a wireplumber node, or (None, False).
+
+ wpctl prints e.g. "Volume: 0.62" or "Volume: 0.62 [MUTED]".
+ """
+ try:
+ out = subprocess.run(
+ ["wpctl", "get-volume", node],
+ capture_output=True, text=True, timeout=2,
+ ).stdout
+ except (OSError, subprocess.SubprocessError):
+ return None, False
+ if "Volume:" not in out:
+ return None, False
+ muted = "MUTED" in out
+ try:
+ return round(float(out.split("Volume:")[1].split()[0]) * 100), muted
+ except (IndexError, ValueError):
+ return None, muted
+
+
+def volume_state(pct, muted):
+ if muted or pct == 0:
+ return "muted"
+ if pct < 34:
+ return "low"
+ if pct < 67:
+ return "medium"
+ return "high"
+
+
+def emit(name, tooltip):
+ """Print what the image module expects: a path, then a tooltip."""
+ path = lookup(name)
+ if not path:
+ # An unresolved icon is a broken bar, not a warning: say so on stderr
+ # and print nothing, so the module stays empty rather than showing a
+ # stale icon.
+ print(f"wb-icon: no icon named {name!r} in {THEME}", file=sys.stderr)
+ return 1
+ print(path)
+ print(tooltip)
+ return 0
+
+
+def selftest():
+ """Every icon this bar asks for, including the ones set in CSS."""
+ # The workspace icons live in styles/modules.css rather than here, because
+ # waybar's format-icons takes text and not paths. They are still this
+ # bar's icons, so they are checked here too.
+ workspace_icons = [
+ "web-browser", "utilities-terminal", "text-editor", "network-server",
+ "document-edit", "applications-graphics", "internet-chat",
+ "input-gaming",
+ ]
+ clock_icons = ["x-office-calendar", "clock"]
+
+ names = sorted(
+ {n for group in ICONS.values() for n in group.values()}
+ | set(workspace_icons) | set(clock_icons)
+ )
+ missing = [n for n in names if not lookup(n)]
+ for n in names:
+ print(f" {'ok ' if n not in missing else 'MISS'} {n}")
+ if missing:
+ print(f"\n{len(missing)} icon(s) missing from {THEME}", file=sys.stderr)
+ return 1
+ print(f"\nall {len(names)} icons resolve in {THEME}")
+ return 0
+
+
+def main(argv):
+ if len(argv) < 2 or argv[1] in ("-h", "--help"):
+ print(__doc__ or "usage: wb-icon <volume|mic|idle|--selftest> [state]")
+ return 0
+
+ what = argv[1]
+
+ if what == "--selftest":
+ return selftest()
+
+ if what == "volume":
+ pct, muted = wpctl("@DEFAULT_AUDIO_SINK@")
+ if pct is None:
+ return emit(ICONS["volume"]["muted"], "Volume: unavailable")
+ state = volume_state(pct, muted)
+ label = "muted" if state == "muted" else f"{pct}%"
+ return emit(ICONS["volume"][state], f"Volume: {label}")
+
+ if what == "mic":
+ pct, muted = wpctl("@DEFAULT_AUDIO_SOURCE@")
+ if pct is None:
+ return emit(ICONS["mic"]["muted"], "Microphone: unavailable")
+ state = "muted" if muted or pct == 0 else "on"
+ label = "muted" if state == "muted" else f"{pct}%"
+ return emit(ICONS["mic"][state], f"Microphone: {label}")
+
+ if what == "idle":
+ state = argv[2] if len(argv) > 2 else "deactivated"
+ state = state if state in ICONS["idle"] else "deactivated"
+ on = state == "activated"
+ return emit(ICONS["idle"][state],
+ f"Presentation mode: {'on' if on else 'off'}")
+
+ print(f"wb-icon: unknown subject {what!r}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))
diff --git a/bin/wb-idle b/bin/wb-idle
new file mode 100755
index 0000000..05c4a8c
--- /dev/null
+++ b/bin/wb-idle
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+# Presentation mode: keep the screen awake, and say so with a themed icon.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's own idle_inhibitor module draws its state with format-icons, which
+# is text. This drives the same idea through hypridle and reports the state as
+# an icon path for an `image` module instead.
+#
+# wb-idle status # print "$path\n$tooltip" for the image module
+# wb-idle toggle # flip the inhibitor, then refresh the module
+#
+# The inhibitor is hypridle's own pause, so nothing else has to be running.
+set -euo pipefail
+
+state_file="${XDG_RUNTIME_DIR:-/tmp}/wb-idle.state"
+
+active() { [[ -f "$state_file" ]]; }
+
+case "${1:-status}" in
+toggle)
+ if active; then
+ rm -f "$state_file"
+ # Resume idling. Guarded: hypridle may not be running, and that is
+ # not an error worth failing a click over.
+ pkill -USR2 -x hypridle 2>/dev/null || true
+ else
+ touch "$state_file"
+ pkill -USR1 -x hypridle 2>/dev/null || true
+ fi
+ # Refresh the module now rather than waiting for its interval.
+ pkill -RTMIN+9 -x waybar 2>/dev/null || true
+ ;;
+status)
+ if active; then
+ wb-icon idle activated
+ else
+ wb-icon idle deactivated
+ fi
+ ;;
+*)
+ echo "usage: wb-idle <status|toggle>" >&2
+ exit 2
+ ;;
+esac
diff --git a/bin/wb-lang b/bin/wb-lang
new file mode 100755
index 0000000..5a0cd5d
--- /dev/null
+++ b/bin/wb-lang
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Keyboard layout, as a flag.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+# Licensed under the GNU General Public License v2 only.
+#
+# waybar's hyprland/language module styles as #language and nothing else: it
+# has no per-language CSS class, so a flag cannot be selected by a selector.
+# format-<lang> exists but takes text, not a path. So the flag arrives the
+# same way every other icon on this bar does, through an `image` module.
+#
+# wb-lang # print "$path\n$tooltip" for the image module
+#
+# The flags live next to this bar's config because the icon theme ships 13 and
+# neither Italian nor British is among them.
+set -euo pipefail
+
+flags="$HOME/.config/waybar-udt"
+
+# hyprctl names the layout in full ("Italian", "English (US)"), which is what
+# the keymap reports; match on that rather than on the short code, because the
+# short code is not in this output.
+layout="$(hyprctl devices -j 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ d = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+for k in d.get("keyboards", []):
+ if k.get("name") == "2.4g-dongle":
+ print(k.get("active_keymap", ""))
+ break' 2>/dev/null || true)"
+
+case "$layout" in
+Italian*) flag="flag-it.svg"; name="Italian" ;;
+English*) flag="flag-gb.svg"; name="English" ;;
+"") flag="flag-it.svg"; name="unknown" ;;
+*) flag="flag-gb.svg"; name="$layout" ;;
+esac
+
+if [[ ! -r "$flags/$flag" ]]; then
+ echo "wb-lang: missing $flags/$flag" >&2
+ exit 1
+fi
+
+echo "$flags/$flag"
+echo "Layout: $name"