diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/privacy_dots.sh | 199 | ||||
| -rwxr-xr-x | bin/vms_dots.sh | 89 | ||||
| -rwxr-xr-x | bin/wb-icon | 171 | ||||
| -rwxr-xr-x | bin/wb-idle | 45 | ||||
| -rwxr-xr-x | bin/wb-lang | 46 |
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" |
