From c5434026a860455e79dcea938945adb2e5648636 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 19 Sep 2026 23:57:12 -0400 Subject: [PATCH] Vendor the command set the menu and shell depend on --- bin/blob-audio-availability | 54 ++ bin/blob-audio-input-mute | 13 + bin/blob-audio-input-set | 20 + bin/blob-audio-restart | 225 ++++++ bin/blob-audio-sink | 55 ++ bin/blob-audio-sink-set | 31 + bin/blob-audio-sink-switch | 59 ++ bin/blob-audio-source-switch | 20 + bin/blob-audio-tuning | 357 ++++++++++ bin/blob-audio-volume | 86 +++ bin/blob-bar | 403 +++++++++++ bin/blob-bar-color | 125 ++++ bin/blob-battery-low | 17 + bin/blob-battery-status | 137 ++++ bin/blob-bluetooth-device | 52 ++ bin/blob-bluetooth-power | 89 +++ bin/blob-bluetooth-restart | 7 + bin/blob-boot | 640 ++++++++++++++++++ bin/blob-branding-about | 28 + bin/blob-branding-screensaver | 28 + bin/blob-brightness-ddc | 168 +++++ bin/blob-brightness-display | 122 ++++ bin/blob-brightness-display-apple | 107 +++ bin/blob-brightness-keyboard | 58 ++ bin/blob-brightness-keyboard-mute | 14 + bin/blob-capture-qr | 36 + bin/blob-capture-record | 288 ++++++++ bin/blob-capture-record-webcam | 21 + bin/blob-capture-region | 370 ++++++++++ bin/blob-capture-screenshot | 82 +++ bin/blob-capture-text | 26 + bin/blob-capture-webcam-list | 33 + bin/blob-capture-webcam-resize | 149 ++++ bin/blob-clipboard-file | 33 + bin/blob-clipboard-open | 70 ++ bin/blob-clipboard-text | 63 ++ bin/blob-crash-watch | 77 +++ bin/blob-disk-speedtest | 232 +++++++ bin/blob-display-size | 236 +++++++ bin/blob-display-state | 23 + bin/blob-drive-info | 50 ++ bin/blob-drive-password | 32 + bin/blob-drive-select | 18 + bin/blob-font-current | 9 + bin/blob-font-list | 6 + bin/blob-font-set | 84 +++ bin/blob-git-url-check | 50 ++ bin/blob-hibernation-available | 19 + bin/blob-hw-dell-xps-haptic-touchpad | 5 + bin/blob-hw-fingerprint | 56 ++ bin/blob-hw-hybrid-gpu | 24 + bin/blob-hw-laptop | 17 + bin/blob-hw-nvidia | 16 + bin/blob-hw-nvidia-gsp | 20 + bin/blob-hw-nvidia-without-gsp | 23 + bin/blob-hw-touchpad | 6 + bin/blob-hw-touchscreen | 6 + bin/blob-hw-webcam | 5 + bin/blob-hypr-monitor-focused-apple | 12 + bin/blob-hypr-monitor-modeless | 21 + bin/blob-hypr-monitor-watch | 114 ++++ bin/blob-hypr-reload-guard | 115 ++++ bin/blob-hypr-window-gaps-toggle | 5 + bin/blob-hypr-window-pop | 40 ++ ...ob-hypr-window-single-square-aspect-toggle | 8 + bin/blob-hypr-window-tiled-fullscreen-toggle | 16 + bin/blob-hypr-window-transparency-toggle | 7 + bin/blob-hypr-window-width | 168 +++++ bin/blob-hypr-workspace-layout-toggle | 21 + bin/blob-install-font | 21 + bin/blob-launch-about | 181 +++++ bin/blob-launch-config-editor | 15 + bin/blob-launch-docker-tui | 20 + bin/blob-launch-or-focus | 19 + bin/blob-launch-or-focus-tui | 14 + bin/blob-launch-screensaver | 77 +++ bin/blob-launcher-remove | 5 - bin/blob-menu-emoji | 6 + bin/blob-menu-emoji-insert | 20 + bin/blob-menu-file | 48 ++ bin/blob-menu-images | 313 +++++++++ bin/blob-menu-keybindings | 597 ++++++++++++++++ bin/blob-menu-plugin | 46 ++ bin/blob-menu-share | 50 ++ bin/blob-menu-timezone | 12 + bin/blob-network-band | 192 ++++++ bin/blob-network-dns | 319 +++++++++ bin/blob-network-iwd | 112 +++ bin/blob-network-password | 34 + bin/blob-network-qr | 95 +++ bin/blob-network-restart | 11 + bin/blob-network-speedtest | 130 ++++ bin/blob-network-status | 142 ++++ bin/blob-notification-battery | 5 + bin/blob-notification-time | 5 + bin/blob-notification-wait | 30 + bin/blob-notification-weather | 5 + bin/blob-pkg-aur-install | 29 + bin/blob-pkg-drop | 24 + bin/blob-pkg-install | 26 + bin/blob-pkg-remove | 24 + bin/blob-plugin-add | 175 +++++ bin/blob-plugin-catalog | 62 ++ bin/blob-plugin-clone | 168 +++++ bin/blob-plugin-enable | 100 +++ bin/blob-plugin-list | 46 ++ bin/blob-plugin-remove | 123 ++++ bin/blob-plugin-validate | 118 ++++ bin/blob-plymouth-set | 381 +++++++++++ bin/blob-power-list | 17 + bin/blob-power-set | 63 ++ bin/blob-powerprofiles-init | 5 + bin/blob-refresh-config | 43 ++ bin/blob-refresh-hyprland | 14 + bin/blob-refresh-hyprsunset | 6 + bin/blob-refresh-plymouth | 8 + bin/blob-refresh-shell | 11 + bin/blob-reminder | 201 ++++++ bin/blob-remove-security-fido2 | 40 ++ bin/blob-remove-security-sudoless-docker | 34 + bin/blob-restart-app | 7 + bin/blob-restart-gum | 18 + bin/blob-restart-hyprsunset | 5 + bin/blob-restart-trackpad | 20 + bin/blob-restart-xcompose | 10 + bin/blob-screensaver | 48 ++ bin/blob-secret | 59 ++ bin/blob-setup-direct-boot | 61 ++ bin/blob-setup-security-fido2 | 148 ++++ bin/blob-setup-security-fingerprint | 111 +++ bin/blob-setup-security-sshd | 174 +++++ bin/blob-setup-security-sudoless-docker | 49 ++ bin/blob-show-done | 18 + bin/blob-show-logo | 9 + bin/blob-sudo-docker | 44 ++ bin/blob-sudo-keepalive | 9 + bin/blob-sudo-passwordless | 71 ++ bin/blob-system-lid | 20 + bin/blob-system-lock | 26 + bin/blob-system-logout | 13 + bin/blob-system-reboot | 17 + bin/blob-system-shutdown | 17 + bin/blob-system-sleep | 124 ++++ bin/blob-system-stats | 59 ++ bin/blob-system-wake | 10 + bin/blob-tablet-follow | 72 ++ bin/blob-theme-update | 10 + bin/blob-toggle | 42 ++ bin/blob-toggle-bar | 13 + bin/blob-toggle-crash-capture | 18 + bin/blob-toggle-enabled | 6 + bin/blob-toggle-glass | 43 ++ bin/blob-toggle-hybrid-gpu | 119 ++++ bin/blob-toggle-idle | 67 ++ bin/blob-toggle-input | 76 +++ bin/blob-toggle-nightlight | 59 ++ bin/blob-toggle-screensaver | 11 + bin/blob-toggle-silencing | 6 + bin/blob-toggle-tablet | 147 ++++ bin/blob-toggle-touchpad | 6 + bin/blob-toggle-touchscreen | 6 + bin/blob-transcode | 207 ++++++ bin/blob-transcode-ascii | 243 +++++++ bin/blob-update | 36 + bin/blob-update-available | 17 + bin/blob-update-firmware | 18 + bin/blob-update-time | 7 + bin/blob-weather-icon | 45 ++ bin/blob-weather-location | 46 ++ bin/blob-weather-status | 24 + docs/menu.md | 13 +- docs/upstream.md | 42 ++ hypr/default/autostart.lua | 1 - hypr/default/bindings/utilities.lua | 1 - session/env-bootstrap | 4 + shell/plugins/bar/indicators/Dictation.qml | 38 -- .../bar/widgets/Indicators.manifest.json | 5 - shell/plugins/bar/widgets/Indicators.qml | 2 +- shell/plugins/emojis/Emojis.qml | 2 +- 179 files changed, 12414 insertions(+), 54 deletions(-) create mode 100755 bin/blob-audio-availability create mode 100755 bin/blob-audio-input-mute create mode 100755 bin/blob-audio-input-set create mode 100755 bin/blob-audio-restart create mode 100755 bin/blob-audio-sink create mode 100755 bin/blob-audio-sink-set create mode 100755 bin/blob-audio-sink-switch create mode 100755 bin/blob-audio-source-switch create mode 100755 bin/blob-audio-tuning create mode 100755 bin/blob-audio-volume create mode 100755 bin/blob-bar create mode 100755 bin/blob-bar-color create mode 100755 bin/blob-battery-low create mode 100755 bin/blob-battery-status create mode 100755 bin/blob-bluetooth-device create mode 100755 bin/blob-bluetooth-power create mode 100755 bin/blob-bluetooth-restart create mode 100755 bin/blob-boot create mode 100755 bin/blob-branding-about create mode 100755 bin/blob-branding-screensaver create mode 100755 bin/blob-brightness-ddc create mode 100755 bin/blob-brightness-display create mode 100755 bin/blob-brightness-display-apple create mode 100755 bin/blob-brightness-keyboard create mode 100755 bin/blob-brightness-keyboard-mute create mode 100755 bin/blob-capture-qr create mode 100755 bin/blob-capture-record create mode 100755 bin/blob-capture-record-webcam create mode 100755 bin/blob-capture-region create mode 100755 bin/blob-capture-screenshot create mode 100755 bin/blob-capture-text create mode 100755 bin/blob-capture-webcam-list create mode 100755 bin/blob-capture-webcam-resize create mode 100755 bin/blob-clipboard-file create mode 100755 bin/blob-clipboard-open create mode 100755 bin/blob-clipboard-text create mode 100755 bin/blob-crash-watch create mode 100755 bin/blob-disk-speedtest create mode 100755 bin/blob-display-size create mode 100755 bin/blob-display-state create mode 100755 bin/blob-drive-info create mode 100755 bin/blob-drive-password create mode 100755 bin/blob-drive-select create mode 100755 bin/blob-font-current create mode 100755 bin/blob-font-list create mode 100755 bin/blob-font-set create mode 100755 bin/blob-git-url-check create mode 100755 bin/blob-hibernation-available create mode 100755 bin/blob-hw-dell-xps-haptic-touchpad create mode 100755 bin/blob-hw-fingerprint create mode 100755 bin/blob-hw-hybrid-gpu create mode 100755 bin/blob-hw-laptop create mode 100755 bin/blob-hw-nvidia create mode 100755 bin/blob-hw-nvidia-gsp create mode 100755 bin/blob-hw-nvidia-without-gsp create mode 100755 bin/blob-hw-touchpad create mode 100755 bin/blob-hw-touchscreen create mode 100755 bin/blob-hw-webcam create mode 100755 bin/blob-hypr-monitor-focused-apple create mode 100755 bin/blob-hypr-monitor-modeless create mode 100755 bin/blob-hypr-monitor-watch create mode 100755 bin/blob-hypr-reload-guard create mode 100755 bin/blob-hypr-window-gaps-toggle create mode 100755 bin/blob-hypr-window-pop create mode 100755 bin/blob-hypr-window-single-square-aspect-toggle create mode 100755 bin/blob-hypr-window-tiled-fullscreen-toggle create mode 100755 bin/blob-hypr-window-transparency-toggle create mode 100755 bin/blob-hypr-window-width create mode 100755 bin/blob-hypr-workspace-layout-toggle create mode 100755 bin/blob-install-font create mode 100755 bin/blob-launch-about create mode 100755 bin/blob-launch-config-editor create mode 100755 bin/blob-launch-docker-tui create mode 100755 bin/blob-launch-or-focus create mode 100755 bin/blob-launch-or-focus-tui create mode 100755 bin/blob-launch-screensaver create mode 100755 bin/blob-menu-emoji create mode 100755 bin/blob-menu-emoji-insert create mode 100755 bin/blob-menu-file create mode 100755 bin/blob-menu-images create mode 100755 bin/blob-menu-keybindings create mode 100755 bin/blob-menu-plugin create mode 100755 bin/blob-menu-share create mode 100755 bin/blob-menu-timezone create mode 100755 bin/blob-network-band create mode 100755 bin/blob-network-dns create mode 100755 bin/blob-network-iwd create mode 100755 bin/blob-network-password create mode 100755 bin/blob-network-qr create mode 100755 bin/blob-network-restart create mode 100755 bin/blob-network-speedtest create mode 100755 bin/blob-network-status create mode 100755 bin/blob-notification-battery create mode 100755 bin/blob-notification-time create mode 100755 bin/blob-notification-wait create mode 100755 bin/blob-notification-weather create mode 100755 bin/blob-pkg-aur-install create mode 100755 bin/blob-pkg-drop create mode 100755 bin/blob-pkg-install create mode 100755 bin/blob-pkg-remove create mode 100755 bin/blob-plugin-add create mode 100755 bin/blob-plugin-catalog create mode 100755 bin/blob-plugin-clone create mode 100755 bin/blob-plugin-enable create mode 100755 bin/blob-plugin-list create mode 100755 bin/blob-plugin-remove create mode 100755 bin/blob-plugin-validate create mode 100755 bin/blob-plymouth-set create mode 100755 bin/blob-power-list create mode 100755 bin/blob-power-set create mode 100755 bin/blob-powerprofiles-init create mode 100755 bin/blob-refresh-config create mode 100755 bin/blob-refresh-hyprland create mode 100755 bin/blob-refresh-hyprsunset create mode 100755 bin/blob-refresh-plymouth create mode 100755 bin/blob-refresh-shell create mode 100755 bin/blob-reminder create mode 100755 bin/blob-remove-security-fido2 create mode 100755 bin/blob-remove-security-sudoless-docker create mode 100755 bin/blob-restart-app create mode 100755 bin/blob-restart-gum create mode 100755 bin/blob-restart-hyprsunset create mode 100755 bin/blob-restart-trackpad create mode 100755 bin/blob-restart-xcompose create mode 100755 bin/blob-screensaver create mode 100755 bin/blob-secret create mode 100755 bin/blob-setup-direct-boot create mode 100755 bin/blob-setup-security-fido2 create mode 100755 bin/blob-setup-security-fingerprint create mode 100755 bin/blob-setup-security-sshd create mode 100755 bin/blob-setup-security-sudoless-docker create mode 100755 bin/blob-show-done create mode 100755 bin/blob-show-logo create mode 100755 bin/blob-sudo-docker create mode 100755 bin/blob-sudo-keepalive create mode 100755 bin/blob-sudo-passwordless create mode 100755 bin/blob-system-lid create mode 100755 bin/blob-system-lock create mode 100755 bin/blob-system-logout create mode 100755 bin/blob-system-reboot create mode 100755 bin/blob-system-shutdown create mode 100755 bin/blob-system-sleep create mode 100755 bin/blob-system-stats create mode 100755 bin/blob-system-wake create mode 100755 bin/blob-tablet-follow create mode 100755 bin/blob-theme-update create mode 100755 bin/blob-toggle create mode 100755 bin/blob-toggle-bar create mode 100755 bin/blob-toggle-crash-capture create mode 100755 bin/blob-toggle-enabled create mode 100755 bin/blob-toggle-glass create mode 100755 bin/blob-toggle-hybrid-gpu create mode 100755 bin/blob-toggle-idle create mode 100755 bin/blob-toggle-input create mode 100755 bin/blob-toggle-nightlight create mode 100755 bin/blob-toggle-screensaver create mode 100755 bin/blob-toggle-silencing create mode 100755 bin/blob-toggle-tablet create mode 100755 bin/blob-toggle-touchpad create mode 100755 bin/blob-toggle-touchscreen create mode 100755 bin/blob-transcode create mode 100755 bin/blob-transcode-ascii create mode 100755 bin/blob-update create mode 100755 bin/blob-update-available create mode 100755 bin/blob-update-firmware create mode 100755 bin/blob-update-time create mode 100755 bin/blob-weather-icon create mode 100755 bin/blob-weather-location create mode 100755 bin/blob-weather-status create mode 100644 docs/upstream.md delete mode 100644 shell/plugins/bar/indicators/Dictation.qml diff --git a/bin/blob-audio-availability b/bin/blob-audio-availability new file mode 100755 index 0000000..9a4004e --- /dev/null +++ b/bin/blob-audio-availability @@ -0,0 +1,54 @@ +#!/bin/bash + +# blob:summary=Print PulseAudio sink availability for the shell +# blob:group=audio + +# A speaker tuning is a virtual sink in front of the real speakers. Both exist +# in the graph, but selecting the physical one would only bypass the tuning, so +# report it unavailable and keep it out of the output list. +fronted="$(blob-audio-tuning fronted-sink 2>/dev/null || true)" + +pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' + function emit_sink() { + if (name == "") return + if (fronted != "" && name == fronted) { + print name "\t0" + return + } + print name "\t" ((port_count == 0 || available) ? 1 : 0) + } + + /^Sink #/ { + emit_sink() + name = "" + in_ports = 0 + port_count = 0 + available = 0 + next + } + + /^[[:space:]]*Name:/ { + name = $2 + next + } + + /^[[:space:]]*Ports:$/ { + in_ports = 1 + next + } + + in_ports && /^\tActive Port:/ { + in_ports = 0 + next + } + + in_ports && /^\t\t/ { + port_count++ + if ($0 !~ /not available/) available = 1 + next + } + + END { + emit_sink() + } +' diff --git a/bin/blob-audio-input-mute b/bin/blob-audio-input-mute new file mode 100755 index 0000000..1f92637 --- /dev/null +++ b/bin/blob-audio-input-mute @@ -0,0 +1,13 @@ +#!/bin/bash + +# blob:summary=Toggle microphone mute. Drives the hardware mic-mute LED on laptops that expose one. + +wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null + +if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then + blob-brightness-keyboard-mute on + blob-osd -i microphone-muted -m "Microphone muted" +else + blob-brightness-keyboard-mute off + blob-osd -i microphone -m "Microphone on" +fi diff --git a/bin/blob-audio-input-set b/bin/blob-audio-input-set new file mode 100755 index 0000000..d5f5378 --- /dev/null +++ b/bin/blob-audio-input-set @@ -0,0 +1,20 @@ +#!/bin/bash + +# blob:summary=Set the default audio input and move active streams +# blob:args= +# blob:examples=blob audio input set default 43 alsa_input.pci-0000_00_1f.3.analog-stereo + +node_id=${1:-} +source_name=${2:-} + +if [[ -z $node_id || -z $source_name ]]; then + echo "Usage: blob-audio-input-set " >&2 + exit 1 +fi + +wpctl set-default "$node_id" 2>/dev/null || true +pactl set-default-source "$source_name" 2>/dev/null || true + +pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do + [[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true +done diff --git a/bin/blob-audio-restart b/bin/blob-audio-restart new file mode 100755 index 0000000..dd613b1 --- /dev/null +++ b/bin/blob-audio-restart @@ -0,0 +1,225 @@ +#!/bin/bash + +# blob:summary=Restart audio services and recover stuck USB audio devices. +# blob:examples=blob restart audio | blob-audio-restart + +services=(wireplumber.service pipewire.service pipewire-pulse.service) + +restart_audio_services() { + echo -e "Restarting audio services...\n" + + if timeout 25s systemctl --user restart "${services[@]}"; then + return 0 + fi + + echo -e "\nAudio services did not restart cleanly. Forcing stuck services down...\n" + systemctl --user cancel >/dev/null 2>&1 || true + systemctl --user kill --kill-whom=all --signal=KILL "${services[@]}" >/dev/null 2>&1 || true + systemctl --user reset-failed "${services[@]}" >/dev/null 2>&1 || true + + timeout 25s systemctl --user start pipewire.service pipewire-pulse.service wireplumber.service +} + +wpctl_healthy() { + timeout 5s wpctl status >/dev/null 2>&1 +} + +usb_device_for_card() { + local card="$1" + local path + + path=$(readlink -f "/sys/class/sound/card$card" 2>/dev/null) || return 1 + + while [[ $path != "/" ]]; do + if [[ -f $path/idVendor && -f $path/idProduct && -f $path/busnum && -f $path/devnum ]]; then + basename "$path" + return 0 + fi + + path=$(dirname "$path") + done + + return 1 +} + +append_unique() { + local value="$1" + shift + local existing + + [[ -z $value ]] && return + + for existing in "$@"; do + [[ $existing == "$value" ]] && return + done + + printf '%s\n' "$value" +} + +audio_process_blocked_on_usb() { + local pid + local name + local wchan + + for pid in /proc/[0-9]*; do + [[ -r $pid/comm && -r $pid/wchan ]] || continue + + name=$(cat "$pid/comm" 2>/dev/null || true) + [[ $name == "wireplumber" || $name == "pipewire" ]] || continue + + wchan=$(cat "$pid/wchan" 2>/dev/null || true) + [[ $wchan == usb_* || $wchan == *usb* ]] && return 0 + done + + return 1 +} + +usb_audio_devices() { + local card + local device + local devices=() + local unique + + for card in /sys/class/sound/card*; do + [[ -e $card && $card =~ /card([0-9]+)$ ]] || continue + + device=$(usb_device_for_card "${BASH_REMATCH[1]}" || true) + unique=$(append_unique "$device" "${devices[@]}") + [[ -n $unique ]] && devices+=("$unique") + done + + printf '%s\n' "${devices[@]}" +} + +clear_usb_audio_defaults() { + local state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/wireplumber" + + [[ -d $state_dir ]] || return 0 + + if [[ -f $state_dir/default-nodes ]]; then + cp "$state_dir/default-nodes" "$state_dir/default-nodes.bak.$(date +%s)" 2>/dev/null || true + sed -i \ + -e '/^default\.configured\.audio\.sink=alsa_output\.usb-/d' \ + -e '/^default\.configured\.audio\.sink\.[0-9]\+=alsa_output\.usb-/d' \ + "$state_dir/default-nodes" 2>/dev/null || true + fi + + if [[ -f $state_dir/default-routes ]]; then + cp "$state_dir/default-routes" "$state_dir/default-routes.bak.$(date +%s)" 2>/dev/null || true + sed -i '/^alsa_card\.usb-/d' "$state_dir/default-routes" 2>/dev/null || true + fi +} + +stuck_usb_audio_devices() { + local status + local state + local owner_pid + local owner_name + local card + local device + local devices=() + local unique + + for status in /proc/asound/card*/pcm*p/sub*/status; do + [[ -e $status ]] || continue + + state=$(awk '/^state:/ { print $2; exit }' "$status") + [[ $state == "SETUP" ]] || continue + + owner_pid=$(awk '/^owner_pid[[:space:]]*:/ { print $3; exit }' "$status") + [[ -n $owner_pid && -r /proc/$owner_pid/comm ]] || continue + + owner_name=$(cat "/proc/$owner_pid/comm" 2>/dev/null || true) + [[ $owner_name == "wireplumber" || $owner_name == "pipewire" ]] || continue + [[ $status =~ /card([0-9]+)/ ]] || continue + + card="${BASH_REMATCH[1]}" + device=$(usb_device_for_card "$card" || true) + unique=$(append_unique "$device" "${devices[@]}") + [[ -n $unique ]] && devices+=("$unique") + done + + printf '%s\n' "${devices[@]}" +} + +reset_usb_audio_device() { + local device="$1" + local sysfs="/sys/bus/usb/devices/$device" + local busnum + local devnum + local bus_device + local product + + [[ -d $sysfs ]] || return 1 + + if ! blob-cmd-present usbreset; then + echo "usbreset is not installed; replug or power-cycle $device to recover it." + return 1 + fi + + busnum=$(cat "$sysfs/busnum" 2>/dev/null) || return 1 + devnum=$(cat "$sysfs/devnum" 2>/dev/null) || return 1 + product=$(cat "$sysfs/product" 2>/dev/null || echo "$device") + bus_device=$(printf '%03d/%03d' "$busnum" "$devnum") + + echo -e "\nResetting stuck USB audio device: $product ($bus_device)..." + + if timeout 25s sudo usbreset "$bus_device"; then + return 0 + fi + + echo "Could not reset $product automatically. Replug or power-cycle it, then run blob-audio-restart again." + return 1 +} + +recover_stuck_usb_audio() { + local devices=() + local device + local detected=() + local reset_count=0 + + while IFS= read -r device; do + [[ -n $device ]] && detected+=("$device") + done < <(stuck_usb_audio_devices) + + if (( ${#detected[@]} == 0 )) && audio_process_blocked_on_usb; then + echo "Audio service is blocked in USB I/O; resetting USB audio devices..." + clear_usb_audio_defaults + while IFS= read -r device; do + [[ -n $device ]] && detected+=("$device") + done < <(usb_audio_devices) + fi + + for device in "${detected[@]}"; do + [[ -n $(append_unique "$device" "${devices[@]}") ]] && devices+=("$device") + done + + (( ${#devices[@]} > 0 )) || return 1 + + for device in "${devices[@]}"; do + reset_usb_audio_device "$device" && reset_count=$((reset_count + 1)) + done + + (( reset_count > 0 )) +} + +restart_audio_services || true + +if ! wpctl_healthy; then + echo -e "\nPipeWire is still not responding. Checking for stuck USB audio devices..." + + if recover_stuck_usb_audio; then + echo -e "\nRestarting audio services after USB audio reset...\n" + restart_audio_services || true + else + echo "No stuck USB audio device was detected automatically." + fi +fi + +sleep 2 + +echo -e "\nAudio status:\n" +if ! timeout 5s wpctl status; then + echo "Audio services are still not responding. Try replugging or power-cycling the selected USB audio device." + exit 1 +fi diff --git a/bin/blob-audio-sink b/bin/blob-audio-sink new file mode 100755 index 0000000..4152a65 --- /dev/null +++ b/bin/blob-audio-sink @@ -0,0 +1,55 @@ +#!/bin/bash + +# blob:summary=Print the sink whose volume and mute a given output really uses +# blob:args=[sink-name] +# blob:group=audio +# blob:examples=blob audio output sink | blob audio output sink blob_speaker_tuning + +set -uo pipefail + +# A DSP sink -- a speaker tuning filter-chain, or EasyEffects -- can be the +# selected output without being where loudness lives. Changing its volume alters +# the level going *into* the processing: the display moves while the speakers do +# not, and on a chain with a compressor or limiter the tone changes too. Resolve +# through it to the physical sink it feeds. +# +# With no argument this resolves the current default output, so when headphones or +# HDMI are selected it returns those, not the speakers a tuning happens to front. +# Callers that need to describe some *other* output -- an output switcher naming +# the next one in the rotation -- pass that sink explicitly. + +sink="${1:-$(pactl get-default-sink 2>/dev/null)}" + +if [[ -z $sink || $sink == alsa_output.* ]]; then + printf '%s\n' "$sink" + exit 0 +fi + +# A DSP sink feeds its physical output through a stream of its own; follow that +# stream down to the sink underneath. +downstream="$(pactl list sink-inputs 2>/dev/null | + awk -v virt="$sink" ' + /^Sink Input #/ {target = ""} + /^[[:space:]]*Sink:/ {target = $2} + /node\.name = / { + name = $0 + sub(/.*node\.name = "/, "", name) + sub(/"$/, "", name) + if (index(name, virt) == 1 && target != "") {print target; exit} + } + /application\.name = "EasyEffects"/ { + if (virt == "easyeffects_sink" && target != "") {print target; exit} + }')" + +if [[ -n $downstream ]]; then + name="$(pactl list sinks short 2>/dev/null | + awk -v id="$downstream" '$1 == id {print $2; exit}')" + if [[ -n $name ]]; then + printf '%s\n' "$name" + exit 0 + fi +fi + +# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked. +# Fall back to the sink itself so callers still have something to act on. +printf '%s\n' "$sink" diff --git a/bin/blob-audio-sink-set b/bin/blob-audio-sink-set new file mode 100755 index 0000000..6b2aa03 --- /dev/null +++ b/bin/blob-audio-sink-set @@ -0,0 +1,31 @@ +#!/bin/bash + +# blob:summary=Set the default audio output and move active streams +# blob:args= +# blob:examples=blob audio output set default 42 alsa_output.pci-0000_00_1f.3.analog-stereo + +node_id=${1:-} +sink_name=${2:-} + +if [[ -z $node_id || -z $sink_name ]]; then + echo "Usage: blob-audio-sink-set " >&2 + exit 1 +fi + +timeout 2 wpctl set-default "$node_id" 2>/dev/null || true +timeout 2 pactl set-default-sink "$sink_name" 2>/dev/null || true + +# Move only real application streams. A DSP filter-chain's own output is also a +# sink input but carries no application.name, and moving it would rewire the +# processing itself -- onto headphones, or into its own virtual sink, which is a +# cycle. EasyEffects' output stream must stay put for the same reason. +timeout 2 pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' | while read -r input; do + [[ -n $input ]] && timeout 2 pactl move-sink-input "$input" "$sink_name" 2>/dev/null || true +done diff --git a/bin/blob-audio-sink-switch b/bin/blob-audio-sink-switch new file mode 100755 index 0000000..6e1b6f3 --- /dev/null +++ b/bin/blob-audio-sink-switch @@ -0,0 +1,59 @@ +#!/bin/bash + +# blob:summary=Switch between audio outputs while preserving the mute status + +# Skip the physical sink an active speaker tuning fronts: rotating onto it would +# silently bypass the tuning rather than pick a different output. +fronted=$(blob-audio-tuning fronted-sink 2>/dev/null || true) + +sinks=$(timeout 2 pactl -f json list sinks | + jq --arg fronted "$fronted" '[.[] + | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any)) + | select($fronted == "" or .name != $fronted)]') +sinks_count=$(jq 'length' <<<"$sinks") + +if (( sinks_count == 0 )); then + blob-osd -m "No audio devices found" + exit 1 +fi + +current_sink_name=$(timeout 2 pactl get-default-sink) +current_sink_index=$(jq -r --arg name "$current_sink_name" 'map(.name) | index($name)' <<<"$sinks") + +if [[ $current_sink_index != "null" ]]; then + next_sink_index=$(((current_sink_index + 1) % sinks_count)) +else + next_sink_index=0 +fi + +next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks") +next_sink_name=$(jq -r '.name' <<<"$next_sink") +next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink") +# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the +# physical sink beneath it, so read the level from whichever sink actually carries +# it or the OSD contradicts the volume keys. +next_sink_effective=$(blob-audio-sink "$next_sink_name") +next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null | + awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}') +[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") +if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then + next_sink_is_muted=true +else + next_sink_is_muted=false +fi + +if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then + icon_state="muted" +elif (( next_sink_volume <= 33 )); then + icon_state="low" +elif (( next_sink_volume <= 66 )); then + icon_state="medium" +else + icon_state="high" +fi + +if [[ $next_sink_name != $current_sink_name ]]; then + blob-audio-sink-set "$(jq -r '.index' <<<"$next_sink")" "$next_sink_name" +fi + +blob-osd -i "volume-${icon_state}" -m "$next_sink_description" diff --git a/bin/blob-audio-source-switch b/bin/blob-audio-source-switch new file mode 100755 index 0000000..008c96c --- /dev/null +++ b/bin/blob-audio-source-switch @@ -0,0 +1,20 @@ +#!/bin/bash + +# blob:summary=Cycle to the next media source and transfer playback when the current source is playing +# blob:args=[next|previous] +# blob:examples=blob audio source switch | blob-audio-source-switch previous + +direction="${1:-next}" + +case "$direction" in + next) + blob-shell media sourceSwitch + ;; + previous) + blob-shell media sourceSwitchPrevious + ;; + *) + echo "Usage: blob-audio-source-switch [next|previous]" >&2 + exit 1 + ;; +esac diff --git a/bin/blob-audio-tuning b/bin/blob-audio-tuning new file mode 100755 index 0000000..0c7a051 --- /dev/null +++ b/bin/blob-audio-tuning @@ -0,0 +1,357 @@ +#!/bin/bash + +# blob:summary=Manage the speaker tuning for this laptop +# blob:args= [--force] +# blob:group=audio +# blob:examples=blob audio tuning status | blob audio tuning on | blob audio tuning off + +set -uo pipefail + +tunings_dir="$BLOB_PATH/default/audio/tunings" +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + +# The tuning is hosted by its own PipeWire client, under its own config name, so +# switching it needs no audio restart -- a restart drops every PulseAudio client's +# connection, and applications that do not reconnect (Spotify) then have to be +# restarted by hand. The name is deliberately not PipeWire's stock +# filter-chain.conf, which merges every fragment in filter-chain.conf.d/ and would +# make this service host unrelated user filters too. +host_config_name=blob-speaker-tuning.conf +host_config="$config_home/pipewire/$host_config_name" +host_source="$BLOB_PATH/default/audio/filter-chain-host.conf" +fragment="$config_home/pipewire/$host_config_name.d/90-tuning.conf" +unit_name=blob-speaker-tuning.service +unit="$config_home/systemd/user/$unit_name" +unit_source="$BLOB_PATH/default/systemd/user/$unit_name" + +# Earlier revisions loaded the tuning into the daemon, as a WirePlumber smart +# filter, or into the shared filter-chain.conf.d namespace. Remove all three so +# they cannot be loaded alongside the current one. +stale_daemon="$config_home/pipewire/pipewire.conf.d/90-blob-speaker-tuning.conf" +stale_wireplumber="$config_home/wireplumber/wireplumber.conf.d/90-blob-speaker-tuning.conf" +stale_shared="$config_home/pipewire/filter-chain.conf.d/90-blob-speaker-tuning.conf" + +sink_name=blob_speaker_tuning + +action="${1:-status}" +force=0 +[[ ${2:-} == "--force" ]] && force=1 + +sink_matching() { + pactl list sinks short 2>/dev/null | awk -v p="$1" '$2 ~ p {print $2; exit}' +} + +# Dell keys its Cirrus speaker firmware on the DMI product SKU, which makes it the +# most precise identifier available for these machines -- narrower than a product +# name, and it distinguishes models whose names differ only by marketing. Compared +# case-insensitively against an exact SKU, never a substring, so a tuning cannot +# accidentally widen to a whole product line. +sku_matches() { + local sku want + sku="$(cat /sys/class/dmi/id/product_sku 2>/dev/null)" + [[ -n $sku ]] || return 1 + for want in "$@"; do + [[ ${sku,,} == "${want,,}" ]] && return 0 + done + return 1 +} + +dmi_matches() { + local want + for want in "$@"; do + blob-hw-match "$want" 2>/dev/null && return 0 + done + return 1 +} + +# Print the tuning directory matching this laptop, if any. Matching is data, not +# code: a tuning declares the DMI string it belongs to and the sink it expects, so +# most tunings can be added as a directory with no new script. A tuning whose +# hardware needs a sharper test can set match_command to any predicate instead. +tuning_match() { + local dir + for dir in "$tunings_dir"/*/; do + [[ -r $dir/tuning.conf ]] || continue + + unset match_dmi match_sku match_command sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + + # Deliberately does not look at the live audio graph. The install hooks run in + # the ISO chroot with no audio server, and a match that depended on a present + # sink would come back empty there -- so the machine would get neither the LV2 + # dependency nor the tuning, and nothing would retry. + # A tuning may list several models it has been validated on. match_dmi and + # match_sku are arrays, so a plain string still works as a single entry. + if [[ -n ${match_command:-} ]]; then + "$match_command" 2>/dev/null || continue + elif [[ -n ${match_sku:-} ]]; then + sku_matches "${match_sku[@]}" || continue + elif [[ -n ${match_dmi:-} ]]; then + dmi_matches "${match_dmi[@]}" || continue + else + continue + fi + + # Required whichever way the tuning matched: the graph's target sink is + # substituted from it, so a tuning without one cannot be installed and must + # not be reported as a match. + [[ -n ${sink_pattern:-} ]] || continue + + printf '%s\n' "${dir%/}" + return 0 + done + return 1 +} + +# The physical sink the matched tuning is built for, taken from the tuning's own +# sink_pattern rather than a hard-coded regex, so hardware with a different sink +# name needs no change here. +tuned_hardware_sink() { + local dir found + dir="$(tuning_match)" || return 1 + unset sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + [[ -n ${sink_pattern:-} ]] || return 1 + found="$(sink_matching "$sink_pattern")" + [[ -n $found ]] || return 1 + printf '%s\n' "$found" +} + +tuning_present() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x "$sink_name" >/dev/null +} + +# Only real application streams may be moved. A filter-chain's own output is also +# a sink input but carries no application.name, and moving it would rewire the +# tuning itself. +app_streams() { + pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' +} + +move_apps_to() { + local target="$1" id + for id in $(app_streams); do + pactl move-sink-input "$id" "$target" 2>/dev/null || true + done +} + +# WirePlumber can link the output elsewhere if the target is missing when the host +# starts. node.dont-fallback guards against it, but verify rather than assume. +tuning_downstream_sink() { + blob-audio-sink "$sink_name" 2>/dev/null +} + +easyeffects_running() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -x easyeffects_sink >/dev/null || + pgrep -u "$(id -u)" -x easyeffects >/dev/null 2>&1 || + systemctl --user is-active --quiet easyeffects.service 2>/dev/null +} + +# Unloading a daemon-loaded drop-in is the one case that still needs an audio +# restart, because the daemon only reads its own config at startup. +drop_stale_daemon_config() { + [[ -e $stale_daemon || -e $stale_wireplumber ]] || return 0 + rm -f "$stale_daemon" "$stale_wireplumber" + blob-audio-restart >/dev/null 2>&1 + local _ + for _ in {1..40}; do + pactl info >/dev/null 2>&1 && break + sleep 0.25 + done +} + +case "$action" in + match) + tuning_match + ;; + + fronted-sink) + # The tuning is a virtual sink in front of the real speakers, so both exist in + # the graph. Selecting the physical one would only bypass the tuning, so + # callers keep it out of the output list while the tuning is up. This answers + # "is a tuning in place", not "where should volume go" -- for the latter see + # blob-audio-sink, which follows the current default output. + tuning_present || exit 1 + tuned_hardware_sink + ;; + + status) + if [[ -r $fragment ]]; then + echo "Installed: yes ($fragment)" + else + echo "Installed: no" + fi + # Both is-active and is-enabled print their answer *and* exit non-zero when + # negative, so a "|| echo" fallback prints it twice. + host_state="$(systemctl --user is-active "$unit_name" 2>/dev/null)" + host_enabled="$(systemctl --user is-enabled "$unit_name" 2>/dev/null)" + echo "Host service: ${host_state:-inactive} (${host_enabled:-disabled})" + if tuning_present; then + echo "Tuning sink: present" + else + echo "Tuning sink: absent" + fi + echo "Default sink: $(pactl get-default-sink 2>/dev/null)" + if dir="$(tuning_match)"; then + unset description + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + echo "Matches: ${description:-?} ($(basename "$dir"))" + else + echo "Matches: nothing ships for this laptop" + fi + ;; + + off) + if [[ ! -r $fragment && ! -r $unit && ! -r $stale_daemon && ! -r $stale_wireplumber && + ! -r $stale_shared ]]; then + echo "No speaker tuning installed." + exit 0 + fi + + speakers="$(tuned_hardware_sink)" || speakers="" + + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" "$stale_shared" + rmdir "$config_home/pipewire/$host_config_name.d" 2>/dev/null + systemctl --user daemon-reload >/dev/null 2>&1 + drop_stale_daemon_config + + for _ in {1..20}; do + tuning_present || break + sleep 0.25 + done + + if [[ -n $speakers ]]; then + pactl set-default-sink "$speakers" >/dev/null 2>&1 + # Streams left on the vanished tuning sink reconnect wherever PipeWire puts + # them, which is not necessarily the speakers. + move_apps_to "$speakers" + fi + echo "Speaker tuning removed." + ;; + + on) + [[ -d $tunings_dir ]] || { + echo "No tunings shipped at $tunings_dir" >&2 + exit 1 + } + + selected="$(tuning_match)" || { + echo "No speaker tuning matches this laptop." + exit 0 + } + + unset description sink_pattern + # shellcheck disable=SC1090 + source "$selected/tuning.conf" + + # At first-run the session is up but the sink can still be settling. + for _ in {1..20}; do + speaker_sink="$(sink_matching "$sink_pattern")" + [[ -n $speaker_sink ]] && break + sleep 0.5 + done + [[ -n ${speaker_sink:-} ]] || { + echo "A tuning applies to this laptop but no sink matching $sink_pattern" >&2 + echo "is present, so there is no audio server yet. Re-run after login:" >&2 + echo " blob audio tuning on" >&2 + exit 1 + } + + if easyeffects_running; then + cat >&2 <<'EOF' +EasyEffects is running. It moves any stream that follows the default sink to its +own sink, so a tuning installed now would be bypassed. + +Stop it first: systemctl --user disable --now easyeffects.service +EOF + exit 1 + fi + + # Every tuning ends in a limiter, which is an LV2 plugin. Without it the graph + # fails to instantiate and the tuning sink never appears. + ls /usr/lib/lv2/lsp-plugins.lv2/limiter_stereo.ttl >/dev/null 2>&1 || { + echo "lsp-plugins-lv2 is required for the tuning limiter." >&2 + exit 1 + } + + rendered="$(mktemp)" + trap 'rm -f "$rendered"' EXIT + sed "s|@SPEAKER_SINK@|$speaker_sink|g" "$selected/filter-chain.conf" >"$rendered" + + # Everything that makes the tuning current has to match, not just the graph: + # an active-but-disabled service disappears at next login, and a stale unit + # file would shadow later fixes to the shipped one indefinitely. + if ((!force)) && [[ -r $fragment ]] && cmp -s "$rendered" "$fragment" && + [[ -r $host_config ]] && cmp -s "$host_source" "$host_config" && + [[ -r $unit ]] && cmp -s "$unit_source" "$unit" && + systemctl --user is-active --quiet "$unit_name" 2>/dev/null && + systemctl --user is-enabled --quiet "$unit_name" 2>/dev/null && + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]]; then + echo "Speaker tuning already current: $description" + exit 0 + fi + + drop_stale_daemon_config + + rm -f "$stale_shared" + install -Dm644 "$host_source" "$host_config" + install -Dm644 "$rendered" "$fragment" + install -Dm644 "$unit_source" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + systemctl --user enable "$unit_name" >/dev/null 2>&1 + systemctl --user restart "$unit_name" >/dev/null 2>&1 + echo "Installed speaker tuning: $description" + + for _ in {1..40}; do + tuning_present && break + sleep 0.25 + done + if ! tuning_present; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "Tuning sink never appeared, so it was removed. Audio is untouched." >&2 + echo "Check: systemctl --user status $unit_name" >&2 + exit 1 + fi + + # Confirm the output really landed on the sink this tuning was measured for. + for _ in {1..20}; do + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]] && break + sleep 0.25 + done + downstream="$(tuning_downstream_sink)" + if [[ $downstream != "$speaker_sink" ]]; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "The tuning output linked to ${downstream:-nothing} instead of" >&2 + echo "$speaker_sink, so it was removed rather than left tuning the wrong" >&2 + echo "device. Audio is untouched." >&2 + exit 1 + fi + + pactl set-default-sink "$sink_name" >/dev/null 2>&1 + # A default sink only captures newly created streams, so anything already + # playing would keep bypassing the tuning until its app was restarted. + move_apps_to "$sink_name" + + echo "Speakers now play through the tuning." + ;; + + *) + echo "Usage: blob-audio-tuning [--force]" >&2 + exit 2 + ;; +esac diff --git a/bin/blob-audio-volume b/bin/blob-audio-volume new file mode 100755 index 0000000..8d79068 --- /dev/null +++ b/bin/blob-audio-volume @@ -0,0 +1,86 @@ +#!/bin/bash + +# blob:summary=Adjust output volume and show the Blob OSD +# blob:args= +# blob:examples=blob audio output volume raise | blob audio output volume lower | blob audio output volume mute-toggle | blob audio output volume +1 + +action="${1:-}" + +if [[ -z $action ]]; then + echo "Usage: blob-audio-volume " + exit 1 +fi + +# Resolve through any DSP sink to the physical one, so the keys always move real +# loudness and the processing always sees full-scale input. Shared with the audio +# panel and the output switcher. +sink="$(blob-audio-sink)" +if [[ -z $sink ]]; then + echo "Could not resolve an audio sink to control." >&2 + exit 1 +fi + +# pactl reports the same percentage scale wpctl does (both are the raw volume +# over PA_VOLUME_NORM), so the OSD reads identically either way. +volume_percent() { + pactl get-sink-volume "$sink" 2>/dev/null | + awk 'NR == 1 { + for (i = 1; i <= NF; i++) + if ($i ~ /%$/) {sub("%", "", $i); print $i; exit} + }' +} + +volume_muted() { + [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]] +} + +case "$action" in + raise) action="+5" ;; + lower) action="-5" ;; +esac + +if [[ $action == "mute-toggle" ]]; then + runtime_dir="${XDG_RUNTIME_DIR:-/tmp}" + debounce_file="$runtime_dir/blob-audio-volume-mute-toggle.last" + now=$(date +%s%3N) + last=0 + [[ -r $debounce_file ]] && read -r last <"$debounce_file" || true + if ((now - last < 250)); then + exit 0 + fi + printf '%s\n' "$now" >"$debounce_file" + + pactl set-sink-mute "$sink" toggle +elif [[ $action =~ ^([+-])([0-9]+)$ ]]; then + direction="${BASH_REMATCH[1]}" + step="${BASH_REMATCH[2]}" + + current="$(volume_percent)" + if [[ -z $current ]]; then + echo "Could not read volume for $sink." >&2 + exit 1 + fi + + if [[ $direction == "+" ]]; then + next=$((current + step)) + ((next <= 100)) || next=100 + else + next=$((current - step)) + ((next >= 0)) || next=0 + fi + + pactl set-sink-mute "$sink" 0 + pactl set-sink-volume "$sink" "${next}%" +else + echo "Unknown volume action: $action" + exit 1 +fi + +percent=$(volume_percent) +if volume_muted || ((${percent:-0} == 0)); then + icon="volume-muted" +else + icon="volume-high" +fi + +blob-osd -i "$icon" -p "${percent:-0}" diff --git a/bin/blob-bar b/bin/blob-bar new file mode 100755 index 0000000..8d912f7 --- /dev/null +++ b/bin/blob-bar @@ -0,0 +1,403 @@ +#!/bin/bash + +# blob:summary=Configure the bar and its widget layout +# blob:group=bar +# blob:args=use | reset | defaults | position | transparent | put [placement] | move [placement] | set [--json] [placement] +# blob:examples=blob bar use local.neon-bar | blob bar put blob.keyboard-layout --after blob.clock | blob bar move blob.clock --section center --index 0 | blob bar set blob.clock format HH:mm + +set -euo pipefail + +source blob-shell-config + +usage() { + cat < [args...] + + use Use a bar option as the active bar + reset Return to the built-in Blob bar + defaults Restore the default bar and service widgets + position Bar position + transparent Bar transparency + put [placement] Put a widget on the bar, leaving one + that is already there where it is + move [placement] Move a widget within or between sections + set [--json] [placement] + Set a per-widget option + +Placement: + --section Target section + --index Target index + --before Insert before a widget + --after Insert after a widget + --from-section
Source section + --from-index Source index + +Enable and disable widgets with 'blob plugin enable' and +'blob plugin disable'. + +'put' places a widget the way 'plugin enable' does, but leaves one that is +already on the bar where it is, and falls back to the widget's usual spot when +--before / --after names a widget the bar does not carry. + +Examples: + blob bar use local.neon-bar + blob bar put blob.keyboard-layout --after blob.clock + blob bar move blob.media left + blob bar move blob.clock --section center --index 0 + blob bar set blob.clock format HH:mm +USAGE +} + +# ------------------------------------------------------------------ validation + +bar_option_exists() { + blob-plugin-catalog | jq -e --arg id "$1" ' + any(.[]; (.kinds | index("bar")) and .barPath != null and .id == $id) + ' >/dev/null +} + +validate_section() { + [[ $1 =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right" +} + +validate_index() { + [[ $1 =~ ^[0-9]+$ ]] || fail "index must be a non-negative integer" +} + +# --------------------------------------------------------------------- placement + +PLACEMENT_SECTION="" +PLACEMENT_INDEX="" +PLACEMENT_BEFORE="" +PLACEMENT_AFTER="" +PLACEMENT_FROM_SECTION="" +PLACEMENT_FROM_INDEX="" + +parse_placement() { + while (( $# > 0 )); do + case "$1" in + --section) + PLACEMENT_SECTION="${2:-}" + validate_section "$PLACEMENT_SECTION" + shift 2 + ;; + --index) + PLACEMENT_INDEX="${2:-}" + validate_index "$PLACEMENT_INDEX" + shift 2 + ;; + --before) + PLACEMENT_BEFORE="${2:-}" + [[ -n $PLACEMENT_BEFORE ]] || fail "--before requires a widget id" + shift 2 + ;; + --after) + PLACEMENT_AFTER="${2:-}" + [[ -n $PLACEMENT_AFTER ]] || fail "--after requires a widget id" + shift 2 + ;; + --from-section) + PLACEMENT_FROM_SECTION="${2:-}" + validate_section "$PLACEMENT_FROM_SECTION" + shift 2 + ;; + --from-index) + PLACEMENT_FROM_INDEX="${2:-}" + validate_index "$PLACEMENT_FROM_INDEX" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + done + + [[ -z $PLACEMENT_BEFORE || -z $PLACEMENT_AFTER ]] || fail "use only one of --before or --after" +} + +placement_json() { + jq -cn \ + --arg section "$PLACEMENT_SECTION" \ + --arg index "$PLACEMENT_INDEX" \ + --arg before "$PLACEMENT_BEFORE" \ + --arg after "$PLACEMENT_AFTER" \ + --arg fromSection "$PLACEMENT_FROM_SECTION" \ + --arg fromIndex "$PLACEMENT_FROM_INDEX" ' + {} + + (if $section == "" then {} else {section: $section} end) + + (if $index == "" then {} else {index: ($index | tonumber)} end) + + (if $before == "" then {} else {before: $before} end) + + (if $after == "" then {} else {after: $after} end) + + (if $fromSection == "" then {} else {fromSection: $fromSection} end) + + (if $fromIndex == "" then {} else {fromIndex: ($fromIndex | tonumber)} end) + ' +} + +# -------------------------------------------------------------------- commands + +cmd_use() { + local plugin="${1:-}" + [[ -n $plugin ]] || fail "bar option id is required" + (( $# == 1 )) || fail "use takes a single bar option id" + if [[ $plugin == "default" || $plugin == "built-in" ]]; then + plugin="blob.bar" + fi + bar_option_exists "$plugin" || fail "$plugin is not a known bar option; run 'blob plugin list'" + + if [[ $plugin == "blob.bar" ]]; then + commit "$NORMALIZE | del(.bar.id)" + else + commit "$NORMALIZE | .bar.id = \$plugin" --arg plugin "$plugin" + fi + echo "Using $plugin as the active bar" +} + +cmd_defaults() { + (( $# == 0 )) || fail "defaults does not take arguments" + + local optional_widgets="[]" + local service widget + for service in dropbox tailscale; do + if "blob-installed-service-$service"; then + widget=$(jq -cn \ + --arg id "blob.$service" \ + --arg section "$(bar_widget_default_section "blob.$service")" \ + '{id: $id, section: $section}') + optional_widgets=$(jq -c --argjson widget "$widget" '. + [$widget]' <<<"$optional_widgets") + fi + done + + # This remains one file mutation so it also works during the headless + # Quattro upgrade and cannot race the shell's in-memory config. + commit "$NORMALIZE + | .bar = \$defaults[0].bar + | def entry_id: if type == \"object\" then (.id // \"\" | tostring) else tostring end; + def anchor_for(\$section): { left: \"blob.workspaces\", center: \"blob.weather\", right: \"blob.tray\" }[\$section]; + reduce \$widgets[] as \$widget (.; + .bar.layout.left = (.bar.layout.left | map(select(entry_id != \$widget.id))) + | .bar.layout.center = (.bar.layout.center | map(select(entry_id != \$widget.id))) + | .bar.layout.right = (.bar.layout.right | map(select(entry_id != \$widget.id))) + | (.bar.layout[\$widget.section] | map(entry_id) | index(anchor_for(\$widget.section))) as \$anchor + | (\$anchor | if . == null then (.bar.layout[\$widget.section] | length) else . + 1 end) as \$index + | .bar.layout[\$widget.section] = ( + .bar.layout[\$widget.section][0:\$index] + + [{id: \$widget.id}] + + .bar.layout[\$widget.section][\$index:] + ) + ) + " \ + --slurpfile defaults "$DEFAULTS_FILE" \ + --argjson widgets "$optional_widgets" + echo "Restored the default Blob bar" +} + +cmd_position() { + local position="${1:-}" + [[ -n $position ]] || fail "position is required" + (( $# == 1 )) || fail "position takes a single value" + [[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right" + commit "$NORMALIZE | .bar.position = \$position" --arg position "$position" + echo "Bar position set to $position" +} + +cmd_transparent() { + local transparent="${1:-}" + [[ -n $transparent ]] || fail "transparent is required" + (( $# == 1 )) || fail "transparent takes a single value" + [[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle" + if [[ $transparent == "toggle" ]]; then + commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)" + echo "Bar transparency toggled" + else + commit "$NORMALIZE | .bar.transparent = \$transparent" --argjson transparent "$transparent" + echo "Bar transparency set to $transparent" + fi +} + +bar_widget_default_section() { + local catalog + catalog=$(blob-plugin-catalog 2>/dev/null) || { + echo "center" + return 0 + } + jq -r --arg id "$1" ' + map(select(.id == $id))[0].barWidget.defaultSection // "center" + | if IN("left", "center", "right") then . else "center" end + ' <<<"$catalog" +} + +# Asks the shell to place a widget, waiting out one still coming up. Answers 0 +# with the reply in PUT_RESULT, 1 when there was no shell to ask. Only an +# absent shell is carried on from: a 0 return marks this as done. +PUT_RESULT="" +SHELL_ANSWERED=0 +ask_to_put() { + local id="$1" placement="$2" attempt absent=0 + for (( attempt = 0; attempt < ${BLOB_SHELL_READY_ATTEMPTS:-50}; attempt++ )); do + if PUT_RESULT=$(blob-shell shell putBarWidget "$id" "$placement" 2>&1); then + SHELL_ANSWERED=1 + [[ $PUT_RESULT == "not ready" ]] || return 0 + elif [[ $PUT_RESULT == *"not ready"* ]]; then + SHELL_ANSWERED=1 + elif [[ $PUT_RESULT == *"is not running"* ]]; then + # Answered once and now gone: it stopped mid-request. + if (( SHELL_ANSWERED )); then + fail "blob-shell did not become ready; $id was not put on the bar" + fi + # A shell being spawned has no socket yet, and nothing says a launch is + # under way, so give one a few seconds to turn up. + if (( ++absent >= ${BLOB_SHELL_ABSENT_ATTEMPTS:-30} )); then + echo "blob-shell is not running; $id was not put on the bar" >&2 + return 1 + fi + else + fail "could not put $id on the bar: $PUT_RESULT" + fi + sleep 0.1 + done + fail "blob-shell did not become ready; $id was not put on the bar" +} + +# Placement lives in the shell, which owns the config it has in memory. Putting +# a widget therefore asks the shell rather than editing the file behind it. +cmd_put() { + local id="${1:-}" + [[ -n $id ]] || fail "put requires a widget id" + shift + + local positional_section="" + if (( $# > 0 )) && [[ $1 != --* ]]; then + positional_section="$1" + validate_section "$positional_section" + shift + fi + + parse_placement "$@" + [[ -z $positional_section || -z $PLACEMENT_SECTION ]] || + fail "specify a section positionally or with --section, not both" + [[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section" + [[ -z $PLACEMENT_FROM_SECTION && -z $PLACEMENT_FROM_INDEX ]] || + fail "put does not accept --from-section or --from-index" + + local placement + placement=$(placement_json) + ask_to_put "$id" "$placement" || return 0 + + # An update runs migrations before it restarts the shell, so this one can + # predate the fallback. Ask it again without the neighbour it cannot find. + if [[ $PUT_RESULT == "could not find target widget"* ]]; then + PLACEMENT_BEFORE="" + PLACEMENT_AFTER="" + ask_to_put "$id" "$(placement_json)" || return 0 + fi + + [[ $PUT_RESULT != "unknown" ]] || fail "$id is not a known widget; run 'blob plugin list'" + [[ $PUT_RESULT == "ok" ]] || fail "$PUT_RESULT" + # Says nothing about whether it had to be placed: a widget already on the bar + # is left where it is, and both outcomes are the same answer to the caller. + echo "$id is on the bar" +} + +cmd_move() { + local id="${1:-}" + [[ -n $id ]] || fail "move requires a widget id" + shift + + local positional_section="" + if (( $# > 0 )) && [[ $1 != --* ]]; then + positional_section="$1" + validate_section "$positional_section" + shift + fi + + parse_placement "$@" + [[ -z $positional_section || -z $PLACEMENT_SECTION ]] || + fail "specify a section positionally or with --section, not both" + [[ -z $positional_section || -z $PLACEMENT_INDEX ]] || + fail "specify a section positionally or use --index, not both" + [[ -z $positional_section || -z $PLACEMENT_BEFORE ]] || + fail "specify a section positionally or use --before, not both" + [[ -z $positional_section || -z $PLACEMENT_AFTER ]] || + fail "specify a section positionally or use --after, not both" + [[ -z $positional_section ]] || PLACEMENT_SECTION="$positional_section" + + local result + result=$(blob-shell shell moveBarWidget "$id" "$(placement_json)") + [[ $result == "ok" ]] || fail "$result" + echo "Moved $id" +} + +cmd_set() { + local id="${1:-}" + local key="${2:-}" + local value="${3:-}" + [[ -n $id ]] || fail "set requires a widget id" + [[ -n $key ]] || fail "set requires a setting key" + (( $# >= 3 )) || fail "set requires a value" + shift 3 + + local value_is_json="false" + if (( $# > 0 )) && [[ $1 == "--json" ]]; then + value_is_json="true" + shift + fi + parse_placement "$@" + [[ -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]] || + fail "set does not accept --before or --after" + + local value_json + if [[ $value_is_json == "true" ]]; then + value_json=$(jq -cn --argjson value "$value" '$value') || + fail "invalid JSON value: $value" + else + value_json=$(jq -cn --arg value "$value" '$value') + fi + + local result + result=$(blob-shell shell setBarWidget "$id" "$key" "$value_json" "$(placement_json)") + [[ $result == "ok" ]] || fail "$result" + echo "Set $key on $id" +} + +# --------------------------------------------------------------------- dispatch + +command="${1:-}" +(( $# > 0 )) && shift || true + +case "$command" in + use) + cmd_use "$@" + ;; + reset) + (( $# == 0 )) || fail "reset does not take arguments" + cmd_use blob.bar + ;; + defaults) + cmd_defaults "$@" + ;; + position) + cmd_position "$@" + ;; + transparent) + cmd_transparent "$@" + ;; + put) + cmd_put "$@" + ;; + move) + cmd_move "$@" + ;; + set) + cmd_set "$@" + ;; + -h | --help | help | "") + usage + ;; + *) + fail "unknown command: $command" + ;; +esac diff --git a/bin/blob-bar-color b/bin/blob-bar-color new file mode 100755 index 0000000..22d0f92 --- /dev/null +++ b/bin/blob-bar-color @@ -0,0 +1,125 @@ +#!/bin/bash + +# blob:summary=Choose a legible transparent bar text color +# blob:hidden=true + +set -e + +position=${1:-top} +bar_size=${2:-} +text_color=${3:-} +background_color=${4:-} +background_path="" +screen_size="" + +shift 4 2>/dev/null || true +while (($# > 0)); do + case "$1" in + --background) + background_path=${2:-} + shift 2 + ;; + --screen) + screen_size=${2:-} + shift 2 + ;; + *) + shift + ;; + esac +done + +valid_hex() { + [[ $1 =~ ^#[0-9A-Fa-f]{6}$ ]] +} + +fallback() { + printf '%s\n' "$text_color" + exit 0 +} + +contrast() { + local color="$1" + local sample="$2" + + awk -v fg="$color" -v bg="$sample" ' + function channel(hex, start) { + return strtonum("0x" substr(hex, start, 2)) / 255 + } + function linear(c) { + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4 + } + function luminance(hex, r, g, b) { + r = linear(channel(hex, 2)) + g = linear(channel(hex, 4)) + b = linear(channel(hex, 6)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + BEGIN { + l1 = luminance(fg) + l2 = luminance(bg) + if (l1 < l2) { + t = l1 + l1 = l2 + l2 = t + } + printf "%.6f\n", (l1 + 0.05) / (l2 + 0.05) + } + ' +} + +valid_hex "$text_color" || fallback +valid_hex "$background_color" || fallback +[[ $position =~ ^(top|bottom|left|right)$ ]] || fallback +[[ $bar_size =~ ^[0-9]+$ ]] || fallback +blob-cmd-present magick || fallback + +if [[ -z $background_path ]]; then + background_path=$(readlink -f "$HOME/.local/state/blob/current/background" 2>/dev/null || true) +fi +[[ -f $background_path ]] || fallback + +if [[ -z $screen_size ]]; then + if blob-cmd-present hyprctl && blob-cmd-present jq; then + screen_size=$(hyprctl monitors -j 2>/dev/null | jq -r '.[0] | "\(.width)x\(.height)"' 2>/dev/null || true) + fi +fi +[[ $screen_size =~ ^([0-9]+)x([0-9]+)$ ]] || fallback + +screen_width=${BASH_REMATCH[1]} +screen_height=${BASH_REMATCH[2]} +((screen_width > 0 && screen_height > 0 && bar_size > 0)) || fallback + +case "$position" in +top) + crop="${screen_width}x${bar_size}+0+0" + ;; +bottom) + crop_y=$((screen_height - bar_size)) + ((crop_y >= 0)) || fallback + crop="${screen_width}x${bar_size}+0+${crop_y}" + ;; +left) + crop="${bar_size}x${screen_height}+0+0" + ;; +right) + crop_x=$((screen_width - bar_size)) + ((crop_x >= 0)) || fallback + crop="${bar_size}x${screen_height}+${crop_x}+0" + ;; +esac + +pixel=$(magick "$background_path" -auto-orient \ + -resize "${screen_width}x${screen_height}^" \ + -gravity center -extent "${screen_width}x${screen_height}" \ + -gravity NorthWest -crop "$crop" +repage \ + -resize '1x1!' -format '%[fx:int(255*r)],%[fx:int(255*g)],%[fx:int(255*b)]' info:- 2>/dev/null || true) +[[ $pixel =~ ^([0-9]+),([0-9]+),([0-9]+)$ ]] || fallback + +sample=$(printf '#%02x%02x%02x' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}") +text_contrast=$(contrast "$text_color" "$sample") +background_contrast=$(contrast "$background_color" "$sample") + +awk -v text="$text_contrast" -v background="$background_contrast" 'BEGIN { exit !(background > text) }' \ + && printf '%s\n' "$background_color" \ + || printf '%s\n' "$text_color" diff --git a/bin/blob-battery-low b/bin/blob-battery-low new file mode 100755 index 0000000..5d6841c --- /dev/null +++ b/bin/blob-battery-low @@ -0,0 +1,17 @@ +#!/bin/bash + +# blob:summary=Send the low battery warning notification and run battery-low hooks. +# blob:args= +# blob:hidden=true + +set -euo pipefail + +if (($# != 1)); then + echo "Usage: blob-battery-low " >&2 + exit 1 +fi + +level=$1 + +blob-notify-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000 +blob-hook battery-low "$level" diff --git a/bin/blob-battery-status b/bin/blob-battery-status new file mode 100755 index 0000000..837bf9e --- /dev/null +++ b/bin/blob-battery-status @@ -0,0 +1,137 @@ +#!/bin/bash + +# blob:summary=Returns a formatted battery status string with percentage and power draw/charge. +# blob:args=[--shell] + +shell_output=false +power_supply_path="${BLOB_POWER_SUPPLY_PATH:-/sys/class/power_supply}" + +case "${1:-}" in + "") + ;; + --shell) + shell_output=true + ;; + *) + echo "Usage: blob-battery-status [--shell]" >&2 + exit 2 + ;; +esac + +battery=$(upower -e 2>/dev/null | grep BAT | head -n 1) +[[ -z $battery ]] && exit 0 + +battery_info=$(upower -i "$battery") + +percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info") +capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info") +time_remaining=$(awk '/time to (empty|full)/ { + value = $4 + unit = $5 + if (unit ~ /^minute/) { + printf "%dm", int(value) + } else { + hours = int(value) + minutes = int((value - hours) * 60) + if (minutes > 0) { + printf "%dh %dm", hours, minutes + } else { + printf "%dh", hours + } + } + exit +}' <<<"$battery_info") +power_rate_raw=$(awk '/energy-rate/ { print $2; exit }' <<<"$battery_info") +native_path=$(awk '/native-path/ { print $2; exit }' <<<"$battery_info") +battery_path="$power_supply_path/$native_path" + +# UPower's energy-rate can lag the kernel telemetry by tens of seconds. Use +# the instantaneous sysfs reading when available so the open panel stays live. +if [[ -r $battery_path/power_now ]]; then + power_rate_raw=$(awk -v microwatts="$(<"$battery_path/power_now")" 'BEGIN { print microwatts / 1000000 }') +elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then + power_rate_raw=$(awk \ + -v microamps="$(<"$battery_path/current_now")" \ + -v microvolts="$(<"$battery_path/voltage_now")" \ + 'BEGIN { print microamps * microvolts / 1000000000000 }') +fi + +power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN { + rounded = sprintf("%.1f", rate) + sub(/\.0$/, "", rounded) + print rounded +}') +state=$(awk '/state/ { print $2; exit }' <<<"$battery_info") +threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") +threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") + +[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) +[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) + +ac_online=false +for supply in "$power_supply_path"/*; do + [[ -r $supply/type ]] || continue + [[ $(<"$supply/type") == "Mains" ]] || continue + [[ -r $supply/online ]] || continue + + if [[ $(<"$supply/online") == "1" ]]; then + ac_online=true + break + fi +done + +charge_idle=false +if awk -v rate="${power_rate_raw:-0}" 'BEGIN { exit !(rate <= 0.2) }'; then + charge_idle=true +fi + +charge_holding=false +if [[ $ac_online == "true" && -n $threshold_end ]]; then + if [[ $state == "pending-charge" ]]; then + charge_holding=true + elif [[ $state == "fully-charged" ]] && (( percentage < 99 )); then + charge_holding=true + elif [[ $state == "charging" && $charge_idle == "true" ]] && (( threshold_end < 99 && percentage >= threshold_end )); then + charge_holding=true + fi +fi + +if [[ $shell_output == "true" ]]; then + printf 'percentage\t%s\n' "${percentage}%" + if [[ $charge_holding == "true" ]]; then + printf 'state\tholding\n' + else + printf 'state\t%s\n' "$state" + fi + printf 'rate\t%s\n' "${power_rate}W" + printf 'size\t%s\n' "${capacity}Wh" + printf 'time\t%s\n' "$time_remaining" + + cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) + + [[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles" + + if [[ -n $threshold_end ]]; then + if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then + printf 'threshold\t%s-%s%%\n' "$threshold_start" "$threshold_end" + else + printf 'threshold\t%s%%\n' "$threshold_end" + fi + fi + + exit 0 +fi + +if [[ $charge_holding == "true" ]]; then + if [[ -n $threshold_start && $threshold_start != $threshold_end ]]; then + threshold_label="${threshold_start}-${threshold_end}%" + else + threshold_label="${threshold_end}%" + fi + + echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh" +elif [[ $state == "charging" ]]; then + echo "Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh" +else + echo "Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh" +fi diff --git a/bin/blob-bluetooth-device b/bin/blob-bluetooth-device new file mode 100755 index 0000000..1630916 --- /dev/null +++ b/bin/blob-bluetooth-device @@ -0,0 +1,52 @@ +#!/bin/bash + +# blob:summary=Control a Bluetooth device +# blob:group=bluetooth +# blob:args=[pair|connect|disconnect|forget]
+# blob:examples=blob bluetooth device connect 00:11:22:33:44:55 + +set -e + +usage() { + echo "Usage: blob-bluetooth-device [pair|connect|disconnect|forget]
" >&2 + exit 1 +} + +action=${1:-} +address=${2:-} + +[[ $action == "pair" || $action == "connect" || $action == "disconnect" || $action == "forget" ]] || usage +[[ $address =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] || usage + +power_on() { + [[ $(timeout 2s bluetoothctl show 2>/dev/null) == *"Powered: yes"* ]] && return + # Not bluetoothctl directly: Bluetooth is turned off by an rfkill soft block, + # and BlueZ refuses to power an adapter up while one is set. + blob-bluetooth-power on || true +} + +trust_device() { + bluetoothctl trust "$address" >/dev/null 2>&1 || true +} + +case "$action" in + pair) + power_on + timeout 20s bluetoothctl pair "$address" >/dev/null 2>&1 || true + trust_device + timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true + ;; + connect) + power_on + trust_device + timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true + ;; + disconnect) + timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true + ;; + forget) + power_on + timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true + timeout 10s bluetoothctl remove "$address" >/dev/null 2>&1 || true + ;; +esac diff --git a/bin/blob-bluetooth-power b/bin/blob-bluetooth-power new file mode 100755 index 0000000..bf2b6a3 --- /dev/null +++ b/bin/blob-bluetooth-power @@ -0,0 +1,89 @@ +#!/bin/bash + +# blob:summary=Turn Bluetooth on or off, remembered across reboots +# blob:group=bluetooth +# blob:args= + +# BlueZ never persists an adapter's Powered property, so turning Bluetooth off +# through bluetoothctl lasts only until the next boot. The rfkill soft block does +# persist: systemd-rfkill saves every switch under /var/lib/systemd/rfkill and +# restores it early on the next boot, which is its entire job. Blocking is also +# what the kernel hands every radio at once, so a machine with two controllers +# gets both, where bluetoothctl only ever addresses the default one. +# +# So the block is the state, and BlueZ follows it: unblocking leaves AutoEnable +# at its stock default and bluetoothd powers the adapter up by itself. Every +# Blob path that turns Bluetooth on or off goes through here, because a plain +# `bluetoothctl power on` fails outright while the block is set. + +POWER_WAIT_SECONDS=${BLOB_BLUETOOTH_POWER_WAIT_SECONDS:-2} + +controllers() { + timeout 2s bluetoothctl list 2>/dev/null | awk '{print $2}' +} + +# Any controller counts. The block is all-or-nothing across the radios, so the +# state has to be read the same way; a bare `bluetoothctl show` would report the +# default controller and miss a powered dongle sitting behind it. +powered() { + local controller + + for controller in $(controllers); do + [[ $(timeout 2s bluetoothctl show "$controller" 2>/dev/null) == *"Powered: yes"* ]] && return 0 + done + + return 1 +} + +# One deadline around the whole wait rather than a fixed number of probes: every +# probe can sit on its own timeout when D-Bus is wedged, and counting probes then +# stretches a two-second wait into half a minute. +wait_powered() { + local deadline=$((SECONDS + POWER_WAIT_SECONDS)) + + while :; do + powered && return 0 + ((SECONDS < deadline)) || return 1 + sleep 0.2 + done +} + +power_on() { + rfkill unblock bluetooth + + # Usually all it takes: with AutoEnable at its default, bluetoothd powers the + # adapter up on its own once the block is gone. It will not do that for an + # adapter powered down without a block, so ask directly before giving up. + wait_powered && return 0 + + timeout 5s bluetoothctl power on >/dev/null 2>&1 + wait_powered && return 0 + + echo "blob-bluetooth-power: adapter did not come up" >&2 + return 1 +} + +case "${1:-}" in + on) + power_on + ;; + off) + # No bluetoothctl power off to go with this: the block already drops the + # adapter to Powered: no, and it is the half that survives the reboot. + rfkill block bluetooth + ;; + toggle) + if powered; then + rfkill block bluetooth + else + power_on + fi + ;; + is-on) + powered + ;; + *) + echo "Usage: blob-bluetooth-power " >&2 + exit 1 + ;; +esac diff --git a/bin/blob-bluetooth-restart b/bin/blob-bluetooth-restart new file mode 100755 index 0000000..2ea227b --- /dev/null +++ b/bin/blob-bluetooth-restart @@ -0,0 +1,7 @@ +#!/bin/bash + +# blob:summary=Unblock and restart the bluetooth service. + +echo -e "Unblocking bluetooth...\n" +rfkill unblock bluetooth +rfkill list bluetooth diff --git a/bin/blob-boot b/bin/blob-boot new file mode 100755 index 0000000..4240e03 --- /dev/null +++ b/bin/blob-boot @@ -0,0 +1,640 @@ +#!/bin/bash +# +# blob-boot - boot splash and bootloader management. +# +# blob-boot [image] Apply a Plymouth boot splash image (default action) +# blob-boot grub Migrate from Limine back to GRUB, detecting every OS +# --purge removes Limine in the same run instead of +# keeping it for one fallback boot +# blob-boot detect Rescan for other operating systems, rebuild the menu +# blob-boot cleanup Retire Limine once GRUB has been confirmed working +# blob-boot status Show what this machine currently boots with +# +# This machine has a 256 MB EFI System Partition, which is why Blob switched +# it to a single unified kernel image. Going back to GRUB means going back to a +# separate kernel and initramfs, so every step here is careful about space. + +set -euo pipefail + +DEFAULT_IMAGE="$HOME/Documents/dotfiles/branding/boot_flash.png" +PLYMOUTH_LOGO="/usr/share/plymouth/themes/blob/logo.png" + +log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +warn() { printf '\033[1;33m %s\033[0m\n' "$*"; } +ok() { printf '\033[1;32m==> %s\033[0m\n' "$*"; } +die() { printf '\n\033[1;31m!!! %s\033[0m\n' "$*" >&2; exit 1; } + +require_sudo() { + echo "This requires sudo privileges." + sudo -v || die "Could not obtain sudo." +} + +# -------------------------------------------------------------------------- +# Boot splash +# -------------------------------------------------------------------------- + +apply_splash() { + local image_path + if [ -z "${1:-}" ]; then + image_path="$DEFAULT_IMAGE" + else + image_path=$(realpath "$1") + fi + + [ -f "$image_path" ] || die "File '$image_path' does not exist." + [ -d "$(dirname "$PLYMOUTH_LOGO")" ] || die "Plymouth blob theme is not installed." + + log "Applying boot splash image: $image_path" + require_sudo + + sudo cp "$image_path" "$PLYMOUTH_LOGO" + sudo chmod 644 "$PLYMOUTH_LOGO" + + log "Rebuilding initramfs" + rebuild_initramfs + + # Under GRUB the menu references the initramfs by path, so it only needs + # regenerating if the file set changed - but it is cheap and keeps the menu + # honest after a kernel or os-prober change. + if using_grub; then + log "Refreshing the GRUB menu" + sudo grub-mkconfig -o /boot/grub/grub.cfg + fi + + ok "Boot splash successfully updated!" +} + +# Call mkinitcpio directly: limine-mkinitcpio-hook installs a wrapper at +# /usr/local/bin/mkinitcpio that stops to ask whether to rebuild Limine +# entries, which is useless in a script. While Limine is still the active +# bootloader its own tool is the one that actually updates what boots. +rebuild_initramfs() { + if command -v limine-mkinitcpio >/dev/null && ! using_grub; then + info "Limine is still the bootloader; rebuilding through limine-mkinitcpio" + sudo limine-mkinitcpio + else + sudo /usr/bin/mkinitcpio -P + fi +} + +using_grub() { + [ -f /boot/grub/grub.cfg ] && [ -f /boot/EFI/GRUB/grubx64.efi ] +} + +# -------------------------------------------------------------------------- +# Limine -> GRUB migration +# -------------------------------------------------------------------------- + +migrate_to_grub() { + local purge=0 + [ "${1:-}" = "--purge" ] && purge=1 + + [ -d /sys/firmware/efi ] || die "Not booted in UEFI mode; this script assumes UEFI." + mountpoint -q /boot || die "/boot is not mounted." + command -v grub-install >/dev/null || die "The 'grub' package is not installed." + command -v os-prober >/dev/null || die "The 'os-prober' package is not installed." + + require_sudo + + local machine_id esp_uuid backup + machine_id=$(/dev/null || true + done + sudo efibootmgr -v | sudo tee "$backup/efibootmgr-before.txt" >/dev/null 2>&1 || true + info "saved." + + reclaim_esp_space "$machine_id" "$backup" + restore_standard_initramfs + configure_grub_scripts + install_grub "$esp_uuid" + fix_failing_boot_units + + if (( purge )); then + purge_limine + set_boot_order + enable_fallback_initramfs + generate_menu + else + # Test GRUB with a one-shot BootNext rather than reordering BootOrder. + # If GRUB fails to boot, a power cycle falls straight back to Limine on + # its own - no boot-menu keypress, no timing, nothing to get right while + # staring at a broken screen. + set_boot_next + fi + + log "Result" + df -h /boot | tail -1 | sed 's/^/ /' + echo + info "GRUB menu entries:" + list_menu_entries + echo + if (( purge )); then + ok "GRUB is the only bootloader. Limine is gone and its space is reclaimed." + else + ok "GRUB is installed and set to boot ONCE on the next restart." + info "If it works: run '$0 cleanup' to delete Limine and reclaim its 51 MB." + info "If it fails: hold the power button, then power on - the firmware falls" + info " back to Limine by itself. Nothing to press, nothing lost." + fi + info "Backups: $backup" +} + +# The 'blob' metapackage hard-depends on limine, limine-mkinitcpio-hook and +# limine-snapper-sync, so they can only be forced out with -Rdd - and the next +# blob upgrade will resolve those dependencies and pull them straight back in. +# +# NoExtract makes that harmless: pacman may reinstall the packages, but it will +# never write the files that actually do anything. Only the active parts are +# listed - the pacman hooks that rebuild unified kernel images and redeploy +# Limine onto the ESP, plus the /usr/local/bin/mkinitcpio wrapper that shadows +# the real one. Delete these lines from /etc/pacman.conf to undo it. +guard_limine_files() { + local marker="# Limine neutralised by blob-boot" + if grep -q "$marker" /etc/pacman.conf; then + info "pacman.conf guard already present" + return 0 + fi + + sudo cp /etc/pacman.conf /etc/pacman.conf.bak + + # These are [options] directives, so they have to go inside that section. + # Appending to the end of the file would land them in the last repo block, + # where pacman would ignore them. + sudo awk -v marker="$marker" ' + /^\[options\]/ && !done { + print + print marker + print "NoExtract = etc/pacman.d/hooks/90-mkinitcpio-install.hook" + print "NoExtract = usr/local/bin/mkinitcpio" + print "NoExtract = usr/share/libalpm/hooks/60-limine-mkinitcpio-remove-pre.hook" + print "NoExtract = usr/share/libalpm/hooks/80-limine-efi-deploy.hook" + print "NoExtract = usr/share/libalpm/hooks/90-limine-mkinitcpio-remove-post.hook" + done = 1 + next + } + { print } + ' /etc/pacman.conf.bak | sudo tee /etc/pacman.conf >/dev/null + + grep -q "$marker" /etc/pacman.conf \ + || die "Failed to write the NoExtract guard. Your original is at /etc/pacman.conf.bak - restore it before doing anything else." + info "added NoExtract guard to /etc/pacman.conf (backup: /etc/pacman.conf.bak)" +} + +# pacman refuses a plain -R because blob depends on these. Force it, but only +# once the guard is in place, so a later reinstall cannot resurrect the hooks. +drop_limine_pkgs() { + local present=() pkg + for pkg in "$@"; do + pacman -Qq "$pkg" &>/dev/null && present+=("$pkg") + done + if (( ${#present[@]} == 0 )); then + info "nothing to remove" + return 0 + fi + + guard_limine_files + + if sudo pacman -Rn --noconfirm "${present[@]}" 2>/dev/null; then + info "removed: ${present[*]}" + else + warn "the 'blob' metapackage depends on these; forcing removal with -Rdd" + sudo pacman -Rddn --noconfirm "${present[@]}" + info "removed: ${present[*]}" + warn "'blob' will now report unsatisfied dependencies. That is expected and" + warn "harmless - nothing checks them outside of a pacman transaction. If a" + warn "future blob upgrade reinstalls them, the guard keeps them inert." + fi +} + +# The ESP is 95% full. Reclaim only files that are provably unreachable, and +# leave Limine's own UKI alone so the machine keeps a working fallback. +reclaim_esp_space() { + local machine_id=$1 backup=$2 + log "Reclaiming space on the ESP ($(df -h --output=avail /boot | tail -1 | tr -d ' ') free)" + + # Limine's entry tool writes one kernel directory per machine-id. A + # directory keyed by a different machine-id is left over from a previous + # install and nothing in NVRAM or limine.conf can reach it. + local dir name + shopt -s nullglob + for dir in /boot/[0-9a-f]*; do + name=${dir##*/} + [[ ${#name} -eq 32 && $name =~ ^[0-9a-f]+$ ]] || continue + [[ $name == "$machine_id" ]] && { info "keeping $name (this machine)"; continue; } + info "removing stale kernel dir $name ($(sudo du -sh "$dir" | cut -f1))" + sudo rm -rf "$dir" + done + shopt -u nullglob + + # arch-linux.efi is what the mkinitcpio preset wrote; limine.conf boots + # blob_linux.efi and never reads it. Only drop it while the UKI that + # Limine actually boots is present, so a fallback always survives. + if [ -f /boot/EFI/Linux/arch-linux.efi ]; then + if [ -f /boot/EFI/Linux/blob_linux.efi ] && ! grep -q "arch-linux.efi" /boot/limine.conf 2>/dev/null; then + info "removing unreferenced UKI arch-linux.efi ($(sudo du -sh /boot/EFI/Linux/arch-linux.efi | cut -f1))" + sudo rm -f /boot/EFI/Linux/arch-linux.efi + else + warn "arch-linux.efi is still referenced; keeping it" + fi + fi + + local avail_kb + avail_kb=$(df --output=avail -k /boot | tail -1 | tr -d ' ') + info "ESP now has $((avail_kb / 1024)) MB free" + (( avail_kb > 61440 )) || die "Only $((avail_kb / 1024)) MB free; an initramfs needs ~60 MB. Nothing further has been changed." +} + +restore_standard_initramfs() { + log "Restoring a standard kernel + initramfs layout" + + # btrfs-overlayfs ships with limine-mkinitcpio-hook and is about to vanish. + # blob_hooks.conf assigns HOOKS wholesale, so filter the hook out from a + # drop-in that sorts after it instead of editing an Blob-managed file. + sudo tee /etc/mkinitcpio.conf.d/zz-no-limine.conf >/dev/null <<'EOF' +# Written when this machine was migrated from Limine back to GRUB. +# btrfs-overlayfs comes from limine-mkinitcpio-hook, which is no longer +# installed, and this root filesystem is ext4 - so drop the hook rather than +# let mkinitcpio fail on a missing one. +_hooks=() +for _hook in "${HOOKS[@]}"; do + [[ $_hook == "btrfs-overlayfs" ]] || _hooks+=("$_hook") +done +HOOKS=("${_hooks[@]}") +unset _hooks _hook +EOF + info "wrote /etc/mkinitcpio.conf.d/zz-no-limine.conf" + + # Only the 'default' preset: Limine's 51 MB UKI is still on the ESP as a + # fallback and a fallback initramfs will not fit beside it. The cleanup + # step turns the fallback on once that space comes back. + sudo tee /etc/mkinitcpio.d/linux.preset >/dev/null <<'EOF' +# mkinitcpio preset file for the 'linux' package + +#ALL_config="/etc/mkinitcpio.conf" +ALL_kver="/boot/vmlinuz-linux" + +PRESETS=('default') + +#default_config="/etc/mkinitcpio.conf" +default_image="/boot/initramfs-linux.img" +#default_uki="/boot/EFI/Linux/arch-linux.efi" +default_options="" + +#fallback_config="/etc/mkinitcpio.conf" +fallback_image="/boot/initramfs-linux-fallback.img" +#fallback_uki="/boot/EFI/Linux/arch-linux-fallback.efi" +fallback_options="-S autodetect" +EOF + info "wrote /etc/mkinitcpio.d/linux.preset (image, not UKI)" + + # The 'limine' package itself stays for now - EFI/limine plus limine.conf + # remain a working fallback. Only the pieces that hijack mkinitcpio go. + log "Removing Limine's mkinitcpio integration" + drop_limine_pkgs limine-mkinitcpio-hook limine-snapper-sync + [ -e /usr/local/bin/mkinitcpio ] && warn "/usr/local/bin/mkinitcpio still shadows /usr/bin/mkinitcpio" + + log "Building the initramfs" + rebuild_initramfs + [ -f /boot/initramfs-linux.img ] || die "mkinitcpio produced no /boot/initramfs-linux.img. Limine is still bootable - do NOT reboot into GRUB." + [ -f /boot/vmlinuz-linux ] || die "/boot/vmlinuz-linux is missing." + info "initramfs: $(du -h /boot/initramfs-linux.img | cut -f1)" +} + +# The previous GRUB setup booted a UKI: 10_linux had been made non-executable +# and a custom 15_uki emitted a bare 'uki' command in its place. That is why the +# old menu listed Windows and Ubuntu but no Arch entry at all. Back on a normal +# kernel + initramfs, that has to be undone or the menu cannot boot this system. +configure_grub_scripts() { + log "Fixing the GRUB menu generators" + + if [ -f /etc/grub.d/10_linux ] && [ ! -x /etc/grub.d/10_linux ]; then + sudo chmod +x /etc/grub.d/10_linux + info "enabled 10_linux (generates the Arch entries)" + fi + + if [ -x /etc/grub.d/15_uki ]; then + sudo chmod -x /etc/grub.d/15_uki + info "disabled 15_uki (no unified kernel image any more)" + fi + + [ -x /etc/grub.d/30_os-prober ] || { sudo chmod +x /etc/grub.d/30_os-prober; info "enabled 30_os-prober"; } +} + +install_grub() { + local esp_uuid=$1 + log "Configuring GRUB" + + set_grub_key() { + local key=$1 val=$2 + if grep -q "^${key}=" /etc/default/grub; then + sudo sed -i "s|^${key}=.*|${key}=${val}|" /etc/default/grub + elif grep -qE "^#\s*${key}=" /etc/default/grub; then + sudo sed -i "0,/^#\s*${key}=.*/s||${key}=${val}|" /etc/default/grub + else + echo "${key}=${val}" | sudo tee -a /etc/default/grub >/dev/null + fi + info "${key}=${val}" + } + + # Carry over the exact kernel command line Limine was booting, so Plymouth + # and the quiet splash behave the way they do today. + set_grub_key GRUB_CMDLINE_LINUX_DEFAULT '"rtc_cmos.use_acpi_alarm=1 initramfs_async=0 quiet splash loglevel=0 systemd.show_status=false rd.udev.log_level=0 vt.global_cursor_default=0"' + set_grub_key GRUB_DISABLE_OS_PROBER 'false' + set_grub_key GRUB_TIMEOUT '5' + set_grub_key GRUB_TIMEOUT_STYLE 'menu' + set_grub_key GRUB_GFXPAYLOAD_LINUX 'keep' + + log "Installing GRUB to the ESP" + sudo grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --recheck + [ -f /boot/EFI/GRUB/grubx64.efi ] || die "grub-install produced no /boot/EFI/GRUB/grubx64.efi." + + generate_menu "$esp_uuid" +} + +# -------------------------------------------------------------------------- +# OS detection +# -------------------------------------------------------------------------- + +generate_menu() { + local esp_uuid=${1:-$(findmnt -no UUID /boot)} + + log "Generating the GRUB menu (os-prober scans for other systems)" + sudo grub-mkconfig -o /boot/grub/grub.cfg + + # os-prober can miss Windows when its EFI System Partition is the very one + # mounted at /boot, as it is here. Chainload it explicitly rather than ship + # a menu with no Windows in it. + if grep -qi "bootmgfw.efi" /boot/grub/grub.cfg; then + info "os-prober found Windows" + elif [ -f /boot/EFI/Microsoft/Boot/bootmgfw.efi ]; then + warn "os-prober missed Windows; adding an explicit chainload entry" + sudo tee /etc/grub.d/40_custom >/dev/null </dev/null \ + && [ "$(findmnt -no FSTYPE /)" != "btrfs" ]; then + sudo systemctl disable --now snapper-cleanup.timer snapper-timeline.timer &>/dev/null || true + sudo systemctl reset-failed snapper-cleanup.service &>/dev/null || true + info "disabled snapper timers (root is $(findmnt -no FSTYPE /), not btrfs)" + fi +} + +# -------------------------------------------------------------------------- +# Boot order +# -------------------------------------------------------------------------- + +# efibootmgr prints "Boot0000* GRUBHD(1,GPT,...)" on this machine - the +# device path is there even without -v - so match the label field exactly +# rather than anchoring on end of line. +efi_entry_num() { + sudo efibootmgr | awk -F'\t' -v want="$1" ' + $1 ~ /^Boot[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]/ { + num = substr($1, 5, 4) + label = $1 + sub(/^Boot[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]\*? +/, "", label) + if (label == want) { print num; exit } + }' +} + +# Boot GRUB exactly once, leaving Limine as the standing default. A failed +# GRUB boot then needs no user intervention at all to recover. +set_boot_next() { + log "Arming GRUB for a one-shot test boot" + local grub_num limine_num + grub_num=$(efi_entry_num GRUB) + [ -n "$grub_num" ] || die "No GRUB entry in NVRAM after grub-install. BootOrder is untouched, so this machine still boots Limine." + + # grub-install prepends its own entry to BootOrder, which would make GRUB the + # standing default and defeat the whole point of a one-shot test. Put Limine + # back in front so a failed GRUB boot recovers on a plain power cycle. + limine_num=$(efi_entry_num Limine) + if [ -n "$limine_num" ]; then + local current_order new_order n + current_order=$(sudo efibootmgr | sed -n 's/^BootOrder: //p') + new_order=$limine_num + for n in ${current_order//,/ }; do + [ "$n" = "$limine_num" ] || new_order+=",$n" + done + sudo efibootmgr -o "$new_order" >/dev/null + info "BootOrder: $new_order (Limine first - the standing default)" + else + warn "no Limine entry in NVRAM; GRUB will be the standing default with no automatic fallback" + fi + + # Set BootNext last: it must survive the BootOrder rewrite above. + sudo efibootmgr -n "$grub_num" >/dev/null + info "BootNext=$grub_num (GRUB) - next restart only" +} + +set_boot_order() { + log "Setting the firmware boot order" + + local grub_num limine_num current_order new_order n + grub_num=$(efi_entry_num GRUB) + limine_num=$(efi_entry_num Limine) + + [ -n "$grub_num" ] || die "No GRUB entry in NVRAM after grub-install. Limine is still first in the boot order, so the machine remains bootable." + + current_order=$(sudo efibootmgr | sed -n 's/^BootOrder: //p') + new_order=$grub_num + [ -n "$limine_num" ] && new_order+=",$limine_num" + for n in ${current_order//,/ }; do + [ "$n" = "$grub_num" ] && continue + [ "$n" = "${limine_num:-}" ] && continue + new_order+=",$n" + done + + sudo efibootmgr -o "$new_order" >/dev/null + info "BootOrder: $new_order (GRUB=$grub_num, Limine=${limine_num:-none})" +} + +# -------------------------------------------------------------------------- +# Retire Limine +# -------------------------------------------------------------------------- + +# Delete every trace of Limine and hand its EFI fallback path to GRUB. +purge_limine() { + log "Removing Limine" + + drop_limine_pkgs limine limine-mkinitcpio-hook limine-snapper-sync + + # limine-snapper-sync leaves units behind that would fail on every boot once + # there is no limine.conf for them to write into. + sudo systemctl disable --now limine-snapper-sync.service limine-snapper-sync.timer &>/dev/null || true + + # /boot/EFI/Linux holds only the unified kernel images Limine booted; with + # GRUB on a normal kernel + initramfs nothing reads them any more. This is + # where the 51 MB comes back. + sudo rm -rf /boot/EFI/limine /boot/EFI/Linux + sudo rm -f /boot/limine.conf /boot/limine.conf.bak /boot/limine.conf.old + sudo rm -rf /etc/limine-entry-tool.d /etc/limine-entry-tool.conf /var/lib/limine + info "removed Limine's files from the ESP" + + local limine_num + limine_num=$(efi_entry_num Limine) + [ -n "$limine_num" ] && { sudo efibootmgr -b "$limine_num" -B >/dev/null; info "removed the Limine NVRAM entry"; } + + # EFI/BOOT/BOOTX64.EFI is still Limine's copy. Overwrite it with GRUB so the + # firmware's default fallback path keeps working if the NVRAM entry is ever + # lost - otherwise deleting Limine leaves a dead pointer there. + log "Claiming the removable EFI fallback path for GRUB" + sudo grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable --recheck +} + +# Only worth attempting once Limine's UKI has freed up room on the ESP. +enable_fallback_initramfs() { + log "Enabling the fallback initramfs" + sudo sed -i "s|^PRESETS=.*|PRESETS=('default' 'fallback')|" /etc/mkinitcpio.d/linux.preset + if ! rebuild_initramfs; then + warn "the fallback initramfs did not build - reverting to the default preset only" + sudo sed -i "s|^PRESETS=.*|PRESETS=('default')|" /etc/mkinitcpio.d/linux.preset + sudo rm -f /boot/initramfs-linux-fallback.img + rebuild_initramfs + fi +} + +cleanup_limine() { + using_grub || die "GRUB is not installed. Run: $0 grub" + require_sudo + + # Only safe once the machine has actually come up through GRUB. + local current + current=$(sudo efibootmgr | sed -n 's/^BootCurrent: //p') + [ -n "$current" ] || die "Cannot determine the current boot entry." + sudo efibootmgr | grep -qE "^Boot${current}\*?[[:space:]]+GRUB" \ + || die "This session did not boot through GRUB (BootCurrent=$current). Reboot first - GRUB is armed for the next restart - then run this again." + + purge_limine + set_boot_order + enable_fallback_initramfs + generate_menu + + echo + df -h /boot | tail -1 | sed 's/^/ /' + echo + info "GRUB menu entries:" + list_menu_entries + ok "Limine is gone. GRUB is now the only bootloader." +} + +# -------------------------------------------------------------------------- +# Status +# -------------------------------------------------------------------------- + +show_status() { + log "Bootloader" + if using_grub; then + info "GRUB installed at /boot/EFI/GRUB/grubx64.efi" + else + warn "GRUB is not installed" + fi + pacman -Qq limine &>/dev/null && warn "limine is still installed" + [ -e /usr/local/bin/mkinitcpio ] && warn "/usr/local/bin/mkinitcpio shadows /usr/bin/mkinitcpio" + + log "Kernel images" + ls -lh /boot/vmlinuz-linux /boot/initramfs-linux*.img /boot/EFI/Linux/*.efi 2>/dev/null \ + | awk '{print " " $5 "\t" $9}' + + log "ESP usage" + df -h /boot | tail -1 | sed 's/^/ /' + + if [ -f /boot/grub/grub.cfg ]; then + log "GRUB menu entries" + list_menu_entries + fi + + log "Firmware boot order" + sudo efibootmgr 2>/dev/null | grep -E "^(BootCurrent|BootOrder|Boot[0-9A-F]{4})" \ + | grep -viE "USB|Setup|Boot Menu|Diagnostics|NVMe:" | sed 's/^/ /' + + log "Failed units" + systemctl --failed --no-pager --no-legend | sed 's/^/ /' || info "none" +} + +# -------------------------------------------------------------------------- + +case "${1:-}" in + grub|migrate) migrate_to_grub "${2:-}" ;; + detect|osprobe) detect_os ;; + cleanup) cleanup_limine ;; + status) show_status ;; + -h|--help|help) + sed -n '3,11p' "$0" | sed 's/^# \?//' + ;; + *) apply_splash "${1:-}" ;; +esac diff --git a/bin/blob-branding-about b/bin/blob-branding-about new file mode 100755 index 0000000..29b987b --- /dev/null +++ b/bin/blob-branding-about @@ -0,0 +1,28 @@ +#!/bin/bash + +# blob:summary=Edit, set, or reset About branding +# blob:group=branding +# blob:name=about +# blob:args= +# blob:examples=blob branding about image | blob branding about text | blob branding about reset + +set -euo pipefail + +case "${1:-}" in +image) + image=$(blob-file-select --title "Pick PNG or SVG for About" --extensions "png svg") + if blob-transcode-ascii "$image" ~/.config/blob/branding/about.txt --width 54 --height 26; then + blob-launch-about >/dev/null 2>&1 + fi + ;; +text) + blob-launch-editor ~/.config/blob/branding/about.txt >/dev/null 2>&1 && blob-launch-about >/dev/null 2>&1 + ;; +reset) + cp "$BLOB_PATH/icon.txt" ~/.config/blob/branding/about.txt && blob-launch-about >/dev/null 2>&1 + ;; +*) + echo "Usage: blob-branding-about " >&2 + exit 1 + ;; +esac diff --git a/bin/blob-branding-screensaver b/bin/blob-branding-screensaver new file mode 100755 index 0000000..3e126cc --- /dev/null +++ b/bin/blob-branding-screensaver @@ -0,0 +1,28 @@ +#!/bin/bash + +# blob:summary=Edit, set, or reset screensaver branding +# blob:group=branding +# blob:name=screensaver +# blob:args= +# blob:examples=blob branding screensaver image | blob branding screensaver text | blob branding screensaver reset + +set -euo pipefail + +case "${1:-}" in +image) + image=$(blob-file-select --title "Pick PNG or SVG for screensaver" --extensions "png svg") + if blob-transcode-ascii "$image" ~/.config/blob/branding/screensaver.txt; then + blob-launch-screensaver force >/dev/null 2>&1 + fi + ;; +text) + blob-launch-editor ~/.config/blob/branding/screensaver.txt >/dev/null 2>&1 && blob-launch-screensaver force >/dev/null 2>&1 + ;; +reset) + cp "$BLOB_PATH/logo.txt" ~/.config/blob/branding/screensaver.txt && blob-launch-screensaver force >/dev/null 2>&1 + ;; +*) + echo "Usage: blob-branding-screensaver " >&2 + exit 1 + ;; +esac diff --git a/bin/blob-brightness-ddc b/bin/blob-brightness-ddc new file mode 100755 index 0000000..fc7424b --- /dev/null +++ b/bin/blob-brightness-ddc @@ -0,0 +1,168 @@ +#!/bin/bash + +# blob:summary=Show or adjust DDC/CI display brightness for a Hyprland monitor. +# blob:args= [+N%|N%-|N%] +# blob:examples=blob-brightness-ddc DP-1 | blob-brightness-ddc DP-1 50% + +monitor="${1:-}" +step="${2:-}" + +[[ -n $monitor ]] || exit 1 + +cache_dir="${XDG_RUNTIME_DIR:-/tmp}/blob-brightness-ddc" +cache_name="${monitor//[^[:alnum:]_.-]/_}" +cache_file="$cache_dir/$cache_name.bus" +unavailable_cache_seconds=60 +range_cache_seconds=10 + +cache_unavailable() { + mkdir -p "$cache_dir" 2>/dev/null || true + printf 'unavailable %s\n' "$(date +%s)" >"$cache_file" 2>/dev/null || true +} + +detect_bus() { + ddcutil --skip-ddc-checks detect --brief 2>/dev/null | awk -v monitor="$monitor" ' + /I2C bus:/ { + bus = $NF + sub(/^.*\/i2c-/, "", bus) + } + + /DRM connector:/ { + connector = $NF + sub(/^card[0-9]+-/, "", connector) + if (connector == monitor && bus != "") { + print bus + exit + } + bus = "" + } + ' +} + +find_bus() { + local bus="" + local cached_value="" + local now=0 + + if [[ -r $cache_file ]]; then + read -r bus cached_value <"$cache_file" || true + fi + + if [[ $bus == "unavailable" ]]; then + now=$(date +%s) + if [[ $cached_value =~ ^[0-9]+$ ]] && (( now - cached_value < unavailable_cache_seconds )); then + return 1 + fi + bus="" + rm -f "$cache_file" + fi + + if [[ -z $bus ]]; then + bus="$(detect_bus)" || return 1 + if [[ ! $bus =~ ^[0-9]+$ ]]; then + cache_unavailable + return 1 + fi + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s\n' "$bus" >"$cache_file" 2>/dev/null || true + fi + + printf '%s\n' "$bus" +} + +read_vcp() { + local bus="$1" + + ddcutil --bus "$bus" --skip-ddc-checks getvcp 10 --brief 2>/dev/null | awk ' + $1 == "VCP" && toupper($2) == "10" && $3 == "C" && $4 ~ /^[0-9]+$/ && $5 ~ /^[0-9]+$/ && $5 > 0 { + print $4, $5 + found = 1 + exit + } + + END { exit !found } + ' +} + +read_brightness() { + local bus="" + local now=0 + local values="" + + bus="$(find_bus)" || return 1 + values="$(read_vcp "$bus")" || { + rm -f "$cache_file" + return 1 + } + + now=$(date +%s) + mkdir -p "$cache_dir" 2>/dev/null || true + printf '%s %s %s\n' "$bus" "${values##* }" "$now" >"$cache_file" 2>/dev/null || true + printf '%s %s\n' "$bus" "$values" +} + +bus="" +current="" +maximum="" +percent="" +range_cached_at="" + +if [[ -z $step ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + printf '%s\n' "$percent" + exit 0 +fi + +if [[ $step =~ ^([0-9]+)%$ ]]; then + target="${BASH_REMATCH[1]}" + range_cache_fresh=0 + if [[ -r $cache_file ]]; then + read -r bus maximum range_cached_at <"$cache_file" || true + fi + + if [[ $bus =~ ^[0-9]+$ && $maximum =~ ^[0-9]+$ && $range_cached_at =~ ^[0-9]+$ ]] && (( maximum > 0 )); then + now=$(date +%s) + if (( range_cached_at <= now && now - range_cached_at < range_cache_seconds )); then + range_cache_fresh=1 + fi + fi + + if (( ! range_cache_fresh )); then + read -r bus current maximum < <(read_brightness) || exit 1 + fi +elif [[ $step =~ ^\+([0-9]+)%$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent < 5 )); then + (( target = percent + 1 )) + else + (( target = percent + amount )) + fi +elif [[ $step =~ ^([0-9]+)%-$ ]]; then + read -r bus current maximum < <(read_brightness) || exit 1 + [[ -n ${bus:-} && -n ${current:-} && -n ${maximum:-} ]] || exit 1 + (( percent = (current * 100 + maximum / 2) / maximum )) + amount="${BASH_REMATCH[1]}" + if (( amount == 5 && percent <= 5 )); then + (( target = percent - 1 )) + else + (( target = percent - amount )) + fi +else + exit 1 +fi + +(( target < 1 )) && target=1 +(( target > 100 )) && target=100 +(( raw_target = (target * maximum + 50) / 100 )) + +if ! ddcutil --bus "$bus" --skip-ddc-checks --noverify setvcp 10 "$raw_target" >/dev/null 2>&1; then + rm -f "$cache_file" + exit 1 +fi + +printf '%s\n' "$target" diff --git a/bin/blob-brightness-display b/bin/blob-brightness-display new file mode 100755 index 0000000..2e44d88 --- /dev/null +++ b/bin/blob-brightness-display @@ -0,0 +1,122 @@ +#!/bin/bash + +# blob:summary=Show or adjust brightness on the focused display. +# blob:args=[--no-osd] [--monitor name] [+N%|N%-|N%|off|on] +# blob:examples=blob brightness display | blob brightness display +5% | blob brightness display --monitor DP-1 50% | blob brightness display off | blob brightness display on + +no_osd=0 +monitor="" + +while (( $# > 0 )); do + case "$1" in + --no-osd) + no_osd=1 + shift + ;; + --monitor) + (( $# >= 2 )) || exit 1 + monitor="$2" + shift 2 + ;; + *) + break + ;; + esac +done + +# Get the brightness of the passed display +backlight_brightness() { + brightnessctl -d "$1" -m 2>/dev/null | awk -F, '{ gsub("%", "", $4); print $4; found=1 } END{ exit !found }' +} + +[[ -n $monitor ]] || monitor="$(blob-hypr-monitor-focused 2>/dev/null || true)" + +monitor_is_internal() { + [[ $monitor =~ ^(eDP|LVDS|DSI)- ]] +} + +use_apple_display() { + blob-hypr-monitor-focused-apple "$monitor" +} + +use_ddc_display() { + [[ -n $monitor ]] && ! monitor_is_internal +} + +if (( $# == 0 )); then + if use_apple_display; then + blob-brightness-display-apple + exit + elif use_ddc_display; then + blob-brightness-ddc "$monitor" + exit + fi + + device="$(blob-hw-display)" || exit 1 + backlight_brightness "$device" + exit +fi + +step="$1" + +if [[ $step == "off" ]]; then + hyprctl dispatch 'hl.dsp.dpms({ action = "disable" })' >/dev/null 2>&1 + exit 0 +elif [[ $step == "on" ]]; then + # Skip the dispatch when every active display is already lit: a redundant + # DPMS enable right after system resume forces another modeset, which blanks + # the panel for a beat (visible flash at the unlock screen). + hyprctl monitors -j 2>/dev/null | jq -e '[.[] | select(.disabled == false)] | length > 0 and all(.dpmsStatus)' >/dev/null 2>&1 && exit 0 + hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 + exit 0 +fi + +# Drop overlapping brightness key events so concurrent invocations do not race. +# Hardware key repeat present on some devices can otherwise glitch OSD rendering. +exec {lock_fd}>"${XDG_RUNTIME_DIR:-/tmp}/blob-brightness-display.lock" +flock -n "$lock_fd" || exit 0 + +if use_apple_display; then + if (( no_osd )); then + blob-brightness-display-apple --no-osd "$step" + else + blob-brightness-display-apple "$step" + fi +elif use_ddc_display; then + brightness="$(blob-brightness-ddc "$monitor" "$step")" || exit 1 + (( no_osd )) || blob-osd -i brightness -p "$brightness" +else + # Current device highlighted + device="$(blob-hw-display)" || exit 1 + + # Current brightness percentage + current=$(backlight_brightness "$device") || exit 1 + + # Apply non-uniform step size: 1% steps if at or below 5%, otherwise set an + # absolute target percentage to avoid raw backlight rounding causing uneven OSD steps. + if [[ $step == "+5%" ]]; then + if (( current < 5 )); then + (( target = current + 1 )) + else + (( target = current + 5 )) + fi + + (( target > 100 )) && target=100 + step="$target%" + elif [[ $step == "5%-" ]]; then + if (( current <= 5 )); then + (( target = current - 1 )) + else + (( target = current - 5 )) + fi + + (( target < 1 )) && target=1 + step="$target%" + fi + + # Set brightness of the display device. + brightnessctl -d "$device" set "$step" >/dev/null + + # Show the new brightness in OSD + (( no_osd )) || blob-osd -i brightness -p "$(backlight_brightness "$device")" +fi diff --git a/bin/blob-brightness-display-apple b/bin/blob-brightness-display-apple new file mode 100755 index 0000000..1b834ea --- /dev/null +++ b/bin/blob-brightness-display-apple @@ -0,0 +1,107 @@ +#!/bin/bash + +# blob:summary=Show or adjust Apple Studio Display and Apple XDR Display brightness using asdcontrol. +# blob:args=[--no-osd] [+N%|N%-|N%] +# blob:examples=blob brightness display apple | blob brightness display apple +5% | blob brightness display apple --no-osd 50% + +# Only cache under the user-private runtime dir. With no XDG_RUNTIME_DIR we skip +# caching (detect every run) rather than fall back to a predictable, world-writable +# /tmp path another user could pre-create. +device_cache="" +if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + device_cache="$XDG_RUNTIME_DIR/blob-brightness-display-apple.device" +fi +no_osd=0 +if [[ ${1:-} == "--no-osd" ]]; then + no_osd=1 + shift +fi + +detect_apple_display_device() { + local devices=() + local path="" + + for path in /dev/usb/hiddev* /dev/hiddev*; do + [[ -e $path ]] && devices+=("$path") + done + + (( ${#devices[@]} > 0 )) || return 1 + + sudo asdcontrol --detect "${devices[@]}" 2>/dev/null | awk -F: '/^\/dev\/(usb\/)?hiddev/{ print $1; exit }' +} + +find_apple_display_device() { + local cached="" + local device="" + + if [[ -n $device_cache && -r $device_cache ]]; then + read -r cached <"$device_cache" || true + # Trust a cached value only if it still names a hiddev character device. A + # stale or unexpected cache (a regular file, a non-hiddev node) is ignored and + # we re-detect instead of handing an arbitrary path to asdcontrol. The globs + # are left unquoted on purpose: [[ ]] pattern-matches an unquoted right side, + # and quoting them would turn the match into a literal string comparison. + if [[ ( $cached == /dev/hiddev* || $cached == /dev/usb/hiddev* ) && -c $cached ]]; then + printf '%s\n' "$cached" + return 0 + fi + fi + + device="$(detect_apple_display_device)" || return 1 + [[ -n $device ]] || return 1 + + if [[ -n $device_cache ]]; then + printf '%s\n' "$device" >"$device_cache" + fi + printf '%s\n' "$device" +} + +current_brightness() { + local device="$1" + + sudo asdcontrol "$device" 2>/dev/null | awk -F= ' + /BRIGHTNESS=/ { + print int($2 * 100 / 60000) + found = 1 + } + + END { exit !found } + ' +} + +retry_with_fresh_device() { + rm -f "$device_cache" + device="$(find_apple_display_device || true)" + if [[ -z $device ]]; then + echo "No Apple Display HID device found" >&2 + exit 1 + fi +} + +device="$(find_apple_display_device || true)" +if [[ -z $device ]]; then + echo "No Apple Display HID device found" >&2 + exit 1 +fi + +if (( $# == 0 )); then + if current_brightness "$device"; then + exit + else + retry_with_fresh_device + current_brightness "$device" + exit + fi +fi + +step="$1" +if [[ $step =~ ^([0-9]+)%-$ ]]; then + step="-${BASH_REMATCH[1]}%" +fi + +if ! sudo asdcontrol "$device" -- "$step" >/dev/null; then + retry_with_fresh_device + sudo asdcontrol "$device" -- "$step" >/dev/null +fi + +(( no_osd )) || blob-osd -i brightness -p "$(current_brightness "$device")" diff --git a/bin/blob-brightness-keyboard b/bin/blob-brightness-keyboard new file mode 100755 index 0000000..c8bda9c --- /dev/null +++ b/bin/blob-brightness-keyboard @@ -0,0 +1,58 @@ +#!/bin/bash + +# blob:summary=Adjust keyboard backlight brightness using available steps. +# blob:args=[--no-osd] + +no_osd=0 +if [[ ${1:-} == "--no-osd" ]]; then + no_osd=1 + shift +fi + +direction="${1:-up}" + +# Find keyboard backlight device (look for *kbd_backlight* pattern in leds class). +device="" +for candidate in /sys/class/leds/*kbd_backlight*; do + if [[ -e $candidate ]]; then + device="$(basename "$candidate")" + break + fi +done + +if [[ -z $device ]]; then + echo "No keyboard backlight device found" >&2 + exit 1 +fi + +if [[ $direction == "off" ]]; then + brightnessctl -sd "$device" set 0 >/dev/null + exit 0 +elif [[ $direction == "restore" ]]; then + brightnessctl -rd "$device" >/dev/null + exit 0 +fi + +# Get current and max brightness to determine step size. +max_brightness="$(brightnessctl -d "$device" max)" +current_brightness="$(brightnessctl -d "$device" get)" + +# Calculate step as 10% of max brightness. Keyboards with many levels (e.g. 512) +# need larger steps; keyboards with few levels (e.g. 3) fall back to step=1. +step=$(( max_brightness / 10 )) +(( step < 1 )) && step=1 + +if [[ $direction == "cycle" ]]; then + new_brightness=$(( current_brightness + step )) + (( new_brightness > max_brightness )) && new_brightness=0 +elif [[ $direction == "up" ]]; then + new_brightness=$(( current_brightness + step )) + (( new_brightness > max_brightness )) && new_brightness=$max_brightness +else + new_brightness=$(( current_brightness - step )) + (( new_brightness < 0 )) && new_brightness=0 +fi + +# Set the new brightness. +brightnessctl -d "$device" set "$new_brightness" >/dev/null +(( no_osd )) || blob-osd -i keyboard -p "$(( new_brightness * 100 / max_brightness ))" diff --git a/bin/blob-brightness-keyboard-mute b/bin/blob-brightness-keyboard-mute new file mode 100755 index 0000000..4f8da91 --- /dev/null +++ b/bin/blob-brightness-keyboard-mute @@ -0,0 +1,14 @@ +#!/bin/bash + +# blob:summary=Set the mic-mute indicator LED on laptops that expose a platform::micmute LED node. +# blob:args= + +if [[ -e /sys/class/leds/platform::micmute/brightness ]]; then + case "$1" in + on) value=1 ;; + off) value=0 ;; + *) echo "Usage: $(basename "$0") " >&2; exit 1 ;; + esac + + brightnessctl --device="platform::micmute" set "$value" >/dev/null 2>&1 || true +fi diff --git a/bin/blob-capture-qr b/bin/blob-capture-qr new file mode 100755 index 0000000..8a48427 --- /dev/null +++ b/bin/blob-capture-qr @@ -0,0 +1,36 @@ +#!/bin/bash + +# blob:summary=Decode a QR code from a screenshot region +# blob:group=capture +# blob:examples=blob capture qr + +# Keep hyprpicker alive until after grim captures so the screenshot sees the +# frozen overlay rather than live content shifting during teardown. +cleanup_freeze() { + [[ -n $PID ]] && kill $PID 2>/dev/null +} +trap cleanup_freeze EXIT + +hyprpicker -r -z >/dev/null 2>&1 & +PID=$! +sleep .1 +SELECTION=$(slurp 2>/dev/null) + +[[ -z $SELECTION ]] && exit 0 + +# Decode QR codes only. Leaving the other symbologies enabled lets dense screen +# content false-positive as an EAN or Code 39 barcode and take over the clipboard. +RESULT=$(grim -g "$SELECTION" - | zbarimg -q --raw -Sdisable -Sqrcode.enable - 2>/dev/null) + +if [[ -z $RESULT ]]; then + blob-notify-send -g 󰐲 -u critical "No QR code found" "Select a region containing a QR code" + exit 1 +fi + +# QR codes routinely carry secrets, like the otpauth:// URIs behind 2FA setup +# codes, so the decoded value goes to the clipboard and nowhere else. Printing it +# or putting it in the notification would leak it to the session journal and to +# the notification history, and an unmarked copy would be retained by clipboard +# history. Pasting still works; only the recorded copy is given up. +printf '%s' "$RESULT" | wl-copy --sensitive +blob-notify-send -g 󰐲 "QR code copied to clipboard" diff --git a/bin/blob-capture-record b/bin/blob-capture-record new file mode 100755 index 0000000..31826e1 --- /dev/null +++ b/bin/blob-capture-record @@ -0,0 +1,288 @@ +#!/bin/bash + +# blob:summary=Start or stop screen recording +# blob:group=capture +# blob:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] +# blob:examples=blob screenrecord | blob capture screenrecording --with-desktop-audio +# blob:aliases=blob screenrecord +# +# Env: BLOB_SCREENRECORD_USE_PORTAL=true skips the built-in slurp picker and +# uses gpu-screen-recorder's xdg-desktop-portal capture backend instead. The +# portal backend was originally added (PR #3401) for HDR-aware capture, support +# for monitors driven by external GPUs, and window capture — enable it if any +# of those matter to you. Off by default because the portal path can fail EGL +# DMA-BUF modifier import on some configurations, leaving recording unable to +# start. +# +# Env: BLOB_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and +# the picker target it was launched with) to /tmp/blob-screenrecord.log so +# users can attach a log when reporting capture failures. + +[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs +OUTPUT_DIR="${BLOB_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" + +if [[ ! -d $OUTPUT_DIR ]]; then + blob-notify-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" + exit 1 +fi + +DESKTOP_AUDIO="false" +MICROPHONE_AUDIO="false" +WEBCAM="false" +WEBCAM_DEVICE="" +WEBCAM_SIZE="medium" +RESOLUTION="" +FULLSCREEN="false" +STOP_RECORDING="false" +RECORDING_FILE="/tmp/blob-screenrecord-filename" +REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/blob-screenrecord-region" +LOG_FILE=$([[ ${BLOB_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/blob-screenrecord.log" || echo "/dev/null") + +for arg in "$@"; do + case "$arg" in + --with-desktop-audio) DESKTOP_AUDIO="true" ;; + --with-microphone-audio) MICROPHONE_AUDIO="true" ;; + --with-webcam) WEBCAM="true" ;; + --webcam-device=*) WEBCAM_DEVICE="${arg#*=}" ;; + --webcam-size=*) WEBCAM_SIZE="${arg#*=}" ;; + --resolution=*) RESOLUTION="${arg#*=}" ;; + --fullscreen) FULLSCREEN="true" ;; + --stop-recording) STOP_RECORDING="true" ;; + esac +done + +case $WEBCAM_SIZE in +small | medium | large) ;; +*) + echo "Invalid webcam size: $WEBCAM_SIZE (expected small, medium, or large)" >&2 + exit 1 + ;; +esac + +start_webcam_overlay() { + cleanup_webcam + + # Auto-detect first available webcam if none specified + if [[ -z $WEBCAM_DEVICE ]]; then + WEBCAM_DEVICE=$(blob-capture-webcam-list | sed -n '1s/[[:space:]].*//p') + if [[ -z $WEBCAM_DEVICE ]]; then + blob-notify-send -u critical -t 3000 "No webcam devices found" + return 1 + fi + fi + + # Try preferred 16:9 resolutions in order, use first available + local preferred_resolutions=("640x360" "1280x720" "1920x1080") + local capture_options="framerate=30" + local available_formats=$(v4l2-ctl --list-formats-ext -d "$WEBCAM_DEVICE" 2>/dev/null) + + for resolution in "${preferred_resolutions[@]}"; do + if echo "$available_formats" | grep -q "$resolution"; then + capture_options="video_size=$resolution,$capture_options" + break + fi + done + + mpv "av://v4l2:$WEBCAM_DEVICE" \ + --profile=low-latency --untimed --no-cache \ + --demuxer-lavf-o="$capture_options" \ + '--vf=lavfi=[crop=ih*8/9:ih]' \ + --title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \ + --no-border --no-audio --no-osc --osd-level=0 \ + --really-quiet &>/dev/null & + + # The move has to settle before gpu-screen-recorder starts, or the camera is + # recorded sliding into its corner. Waiting for the map is what the blind + # second was partly guessing at, so the remainder is trimmed to hold the + # pre-capture delay where it was: starting later costs the first words spoken. + local waited=0 + while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do + sleep 0.05 + ((waited++)) + done + + [[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE" + blob-capture-webcam-resize "$WEBCAM_SIZE" + + sleep 0.6 +} + +cleanup_webcam() { + pkill -f "WebcamOverlay" 2>/dev/null + rm -f "$REGION_FILE" +} + +default_resolution() { + local width height + read -r width height < <(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | "\(.width) \(.height)"') + if ((width > 3840 || height > 2160)); then + echo "3840x2160" + else + echo "0x0" + fi +} + +# Echoes "monitor:NAME" when the selection matches an entire monitor (prefer +# -w over a region capture — same kms backend, but no scaling math +# and full native res), otherwise "region:WxH+X+Y". Returns non-zero if the +# user cancelled the picker. +select_capture_target() { + local target + target=$(blob-capture-region smart --match-monitor) || return 1 + + if [[ $target == monitor:* ]]; then + echo "$target" + return + fi + + [[ $target =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1 + + # gpu-screen-recorder wants region geometry in the compositor's logical + # coordinate space — same space slurp returns — so pass the values through + # untouched. (gsr scales to physical pixels itself based on the monitor.) + echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}" +} + +start_screenrecording() { + local capture_args=() + local target + + # Opt-in path for HDR, external-GPU monitors, and window capture (all things + # the portal backend supports and the kms backend doesn't). Default flow uses + # slurp + the kms backend, which avoids the EGL DMA-BUF modifier import + # failures the portal path can hit on some configurations. + if [[ $FULLSCREEN == "true" ]]; then + target="monitor:$(blob-hypr-monitor-focused)" + capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}") + elif [[ ${BLOB_SCREENRECORD_USE_PORTAL:-false} == "true" ]]; then + target="portal" + capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}") + else + target=$(select_capture_target) || return 1 + + case $target in + monitor:*) + capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}") + ;; + region:*) + capture_args=(-w "${target#region:}") + [[ -n $RESOLUTION ]] && capture_args+=(-s "$RESOLUTION") + ;; + esac + fi + + [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" + + local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" + local audio_devices="" + local audio_args=() + + [[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output" + + if [[ $MICROPHONE_AUDIO == "true" ]]; then + # Merge audio tracks into one - separate tracks only play one at a time in most players + [[ -n $audio_devices ]] && audio_devices+="|" + audio_devices+="default_input" + fi + + [[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac) + + echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE" + gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" & + local pid=$! + + while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do + sleep 0.2 + done + + if kill -0 $pid 2>/dev/null; then + echo "$filename" >"$RECORDING_FILE" + toggle_screenrecording_indicator + fi +} + +stop_screenrecording() { + pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly + + # Wait a maximum of 5 seconds to finish before hard killing + local count=0 + while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do + sleep 0.1 + count=$((count + 1)) + done + + toggle_screenrecording_indicator + cleanup_webcam + + if pgrep -f "^gpu-screen-recorder" >/dev/null; then + pkill -9 -f "^gpu-screen-recorder" + blob-notify-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." + else + finalize_recording + local filename=$(cat "$RECORDING_FILE" 2>/dev/null) + echo "$filename" + local preview="${filename%.mp4}-preview.png" + + # Generate a preview thumbnail from the first frame + ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null + + blob-notify-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ + -t 10000 --image "${preview:-$filename}" \ + --exec mpv -- "$filename" + + # The shell loads the thumbnail into memory when the toast appears and never + # re-reads the file, so the preview only has to outlive that load -- not the + # toast. Clear it out of the recordings directory a moment later. + ( + sleep 2 + rm -f "$preview" + ) & + fi + + rm -f "$RECORDING_FILE" +} + +toggle_screenrecording_indicator() { + blob-shell -q blob.indicators refresh +} + +screenrecording_active() { + pgrep -f "^gpu-screen-recorder" >/dev/null +} + +finalize_recording() { + local latest + latest=$(cat "$RECORDING_FILE" 2>/dev/null) + [[ -f $latest ]] || return + + # Re-encode only when the first GOP contains discardable warmup packets — stream copy can't + # trim those (it rewinds to the keyframe). Clean recordings stay on the fast stream-copy path. + local video_codec=(-c:v copy) + if ffprobe -v error -select_streams v:0 -read_intervals %+0.2 -show_entries packet=flags -of csv=p=0 "$latest" 2>/dev/null | grep -q D; then + video_codec=(-c:v libx264 -preset veryfast -crf 20) + fi + + # Trim the first frame, and normalize audio to -14 LUFS if present, in a single pass + local args=(-y -ss 0.1 -i "$latest" "${video_codec[@]}") + if ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$latest" 2>/dev/null | grep -q audio; then + # Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping + # transient around 130-200ms that a gentle fade-in can't attenuate enough), then a + # 50ms fade avoids a click at the boundary before loudnorm normalizes the rest. + args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11") + fi + + local processed="${latest%.mp4}-processed.mp4" + if ffmpeg "${args[@]}" "$processed" -loglevel quiet 2>/dev/null; then + mv "$processed" "$latest" + else + rm -f "$processed" + fi +} + +if screenrecording_active; then + stop_screenrecording +elif [[ $STOP_RECORDING == "true" ]]; then + exit 1 +else + start_screenrecording || cleanup_webcam +fi diff --git a/bin/blob-capture-record-webcam b/bin/blob-capture-record-webcam new file mode 100755 index 0000000..77ecad2 --- /dev/null +++ b/bin/blob-capture-record-webcam @@ -0,0 +1,21 @@ +#!/bin/bash + +# blob:summary=Pick a webcam and start a screen recording with it +# blob:examples=blob capture screenrecording-with-webcam + +mapfile -t devices < <(blob-capture-webcam-list) +if (( ${#devices[@]} == 0 )); then + blob-notify-send "No webcam devices found" -u critical -t 3000 + exit 1 +fi + +if (( ${#devices[@]} == 1 )); then + device="${devices[0]%%[[:space:]]*}" +else + selection=$(blob-menu-select "Select Webcam" "${devices[@]}" -- --width 520 --maxheight 520) || exit 1 + device="${selection%%[[:space:]]*}" +fi + +exec blob-capture-record \ + --with-desktop-audio --with-microphone-audio \ + --with-webcam --webcam-device="$device" diff --git a/bin/blob-capture-region b/bin/blob-capture-region new file mode 100755 index 0000000..d694002 --- /dev/null +++ b/bin/blob-capture-region @@ -0,0 +1,370 @@ +#!/bin/bash + +# blob:summary=Pick a screen region over frozen screen content +# blob:args=[region|windows|smart|fullscreen] [--keep-freeze] [--match-monitor] | --take-fullscreen | --take-window | --select-window +# blob:hidden=true + +# Prints the picked geometry in slurp's "X,Y WxH" format, or exits 1 when the +# pick is cancelled. Shared by screenshot and screen recording so the picker +# UX stays identical. +# +# region freeform selection +# windows snap selection to a monitor or window rectangle +# smart freeform with window/monitor rects hinted; a bare click +# (area < 20px^2) snaps to the rectangle it landed in +# fullscreen the focused monitor, no interaction +# +# --keep-freeze leave the hyprpicker screen freeze running and print its +# PID as the first output line (empty when no freeze was +# started); the caller owns killing it +# --match-monitor print "monitor:NAME" instead when the picked geometry +# exactly matches a monitor + +FULLSCREEN_MARKER="${XDG_RUNTIME_DIR:-/tmp}/blob-capture-region-fullscreen" +WINDOW_MARKER="${XDG_RUNTIME_DIR:-/tmp}/blob-capture-region-window" + +# accounting for portrait/transformed displays +JQ_MONITOR_GEO=' + def format_geo: + .x as $x | .y as $y | + (.width / .scale | floor) as $w | + (.height / .scale | floor) as $h | + .transform as $t | + if $t == 1 or $t == 3 then + "\($x),\($y) \($h)x\($w)" + else + "\($x),\($y) \($w)x\($h)" + end; +' + +active_workspace() { + hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .activeWorkspace.id' +} + +# Hidden group members and windows stacked at identical geometry collapse to +# one rectangle: slurp cannot tell them apart, and duplicates would stall the +# Tab cycle on the first copy. +window_rects() { + hyprctl clients -j | jq -r --arg ws "$(active_workspace)" \ + '[.[] | select(.workspace.id == ($ws | tonumber) and .hidden != true) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"] | unique[]' +} + +monitor_rects() { + hyprctl monitors -j | jq -r --arg ws "$(active_workspace)" "${JQ_MONITOR_GEO} .[] | select(.activeWorkspace.id == (\$ws | tonumber)) | format_geo" +} + +get_rectangles() { + monitor_rects + window_rects +} + +focused_monitor_geo() { + hyprctl monitors -j | jq -r "${JQ_MONITOR_GEO} .[] | select(.focused == true) | format_geo" +} + +# slurp highlights the smallest box containing the point and keeps the first +# one on a tie, so overlapping rectangles resolve the same way here (e.g. +# floating over tiled). Reads candidates on stdin and leaves the answer in +# RESOLVED_RECT; returns 1 when no candidate contains the point. Assigning to +# a global rather than printing keeps the probing in warp_point_in fork-free. +resolve_rect_at() { + local x=$1 y=$2 + local rect area + local smallest_area=0 + + RESOLVED_RECT="" + + while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + ((x >= BASH_REMATCH[1] && x < BASH_REMATCH[1] + BASH_REMATCH[3] && y >= BASH_REMATCH[2] && y < BASH_REMATCH[2] + BASH_REMATCH[4])) || continue + + area=$((BASH_REMATCH[3] * BASH_REMATCH[4])) + if [[ -z $RESOLVED_RECT ]] || ((area < smallest_area)); then + RESOLVED_RECT=$rect + smallest_area=$area + fi + done + + [[ -n $RESOLVED_RECT ]] +} + +# A rectangle whose center is covered by a smaller one cannot be selected by +# warping to that center: slurp would go on highlighting the coverer. Probe +# points inside the rectangle, nearest its center first, for one that resolves +# back to it, and leave that point in WARP_X / WARP_Y. Returns 1 when the +# rectangle is buried well enough that no point resolves to it, in which case +# hovering could not reach it either. +warp_point_in() { + local rect=$1 candidates=$2 + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1 + local rect_x=${BASH_REMATCH[1]} rect_y=${BASH_REMATCH[2]} + local rect_width=${BASH_REMATCH[3]} rect_height=${BASH_REMATCH[4]} + local probe x y + + for probe in "${WARP_PROBES[@]}"; do + x=$((rect_x + rect_width * ${probe% *} / 8)) + y=$((rect_y + rect_height * ${probe#* } / 8)) + + resolve_rect_at "$x" "$y" <<<"$candidates" || continue + [[ $RESOLVED_RECT == "$rect" ]] || continue + + WARP_X=$x + WARP_Y=$y + return 0 + done + + return 1 +} + +# Whatever slurp is highlighting under the cursor. It is fed monitor rects as +# well as window rects, so a cursor in a gap or over the bar highlights the +# monitor rather than any window. +geo_at_cursor() { + local pos=$(hyprctl cursorpos) + local x=${pos%,*} + local y=${pos#*, } + + if resolve_rect_at "$x" "$y" < <(window_rects) || resolve_rect_at "$x" "$y" < <(monitor_rects); then + echo "$RESOLVED_RECT" + else + focused_monitor_geo + fi +} + +# Keyboard control while slurp is open: binds scoped to slurp's layer +# surface (default/hypr/bindings/utilities.lua) invoke these modes. The +# --take-* modes flag the intent with a marker file and dismiss slurp. +if [[ ${1:-} == "--take-fullscreen" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$FULLSCREEN_MARKER" + pkill -x slurp + exit 0 +fi + +if [[ ${1:-} == "--take-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + touch "$WINDOW_MARKER" + pkill -x slurp + exit 0 +fi + +# Warps the cursor to another window's center, so slurp's own hover +# highlight tracks the selection. +if [[ ${1:-} == "--select-window" ]]; then + pgrep -x slurp >/dev/null || exit 0 + + direction=${2:-} + pos=$(hyprctl cursorpos) + origin_x=${pos%,*} + origin_y=${pos#*, } + + candidates=$(window_rects) + + # Eighth fractions of a rectangle's width and height, ordered by distance + # from its center so warp_point_in prefers the most central point it can use. + mapfile -t WARP_PROBES < <( + for fx in {1..7}; do + for fy in {1..7}; do + printf '%d %d %d\n' $(((fx - 4) * (fx - 4) + (fy - 4) * (fy - 4))) "$fx" "$fy" + done + done | sort -n | cut -d' ' -f2- + ) + + # Only rectangles that hovering could actually reach take part in navigation, + # each paired with the point to warp to. + declare -A warp_points + reachable="" + while IFS= read -r rect; do + warp_point_in "$rect" "$candidates" || continue + warp_points[$rect]="$WARP_X $WARP_Y" + reachable+="$rect"$'\n' + done <<<"$candidates" + [[ -n $reachable ]] || exit 0 + + # Reading order: top-to-bottom, then left-to-right. + rects=$(while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + printf '%d\t%d\t%s\n' "${BASH_REMATCH[2]}" "${BASH_REMATCH[1]}" "$rect" + done <<<"$reachable" | sort -n -k1,1 -k2,2 | cut -f3-) + + # The selection to move from is the one slurp highlights, resolved from the + # same list in the same order as --take-window so navigation and capture + # never disagree. Measure from its center. + current="" + resolve_rect_at "$origin_x" "$origin_y" <<<"$candidates" && current=$RESOLVED_RECT + + if [[ $current =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]]; then + origin_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + origin_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + fi + + target="" + + case $direction in + next | prev) + mapfile -t ordered <<<"$rects" + count=${#ordered[@]} + current_index=-1 + + for i in "${!ordered[@]}"; do + if [[ -n $current && ${ordered[i]} == "$current" ]]; then + current_index=$i + break + fi + done + + if [[ $direction == next ]]; then + target=${ordered[$(((current_index + 1) % count))]} + elif ((current_index == -1)); then + target=${ordered[count - 1]} + else + target=${ordered[$(((current_index - 1 + count) % count))]} + fi + ;; + left | right | up | down) + best_score="" + + while IFS= read -r rect; do + [[ $rect == "$current" ]] && continue + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + center_x=$((BASH_REMATCH[1] + BASH_REMATCH[3] / 2)) + center_y=$((BASH_REMATCH[2] + BASH_REMATCH[4] / 2)) + + case $direction in + left) + primary=$((origin_x - center_x)) + perp=$((center_y - origin_y)) + ;; + right) + primary=$((center_x - origin_x)) + perp=$((center_y - origin_y)) + ;; + up) + primary=$((origin_y - center_y)) + perp=$((center_x - origin_x)) + ;; + down) + primary=$((center_y - origin_y)) + perp=$((center_x - origin_x)) + ;; + esac + + ((primary > 0)) || continue + ((perp < 0)) && perp=$((-perp)) + score=$((primary + perp * 2)) + + if [[ -z $best_score ]] || ((score < best_score)); then + best_score=$score + target=$rect + fi + done <<<"$rects" + ;; + *) + exit 1 + ;; + esac + + if [[ -n $target && -n ${warp_points[$target]} ]]; then + read -r target_x target_y <<<"${warp_points[$target]}" + hyprctl eval "hl.dispatch(hl.dsp.cursor.move({ x = $target_x, y = $target_y }))" >/dev/null + fi + exit 0 +fi + +MODE=smart +KEEP_FREEZE=false +MATCH_MONITOR=false + +for arg in "$@"; do + case $arg in + --keep-freeze) KEEP_FREEZE=true ;; + --match-monitor) MATCH_MONITOR=true ;; + *) MODE=$arg ;; + esac +done + +# Runs slurp; an empty result with a marker present means one of the --take-* +# binds was pressed, so the highlighted rectangle or the monitor is the +# selection. +pick() { + local selection + rm -f "$FULLSCREEN_MARKER" "$WINDOW_MARKER" + selection=$(slurp "$@" 2>/dev/null) + + if [[ -z $selection && -e $FULLSCREEN_MARKER ]]; then + rm -f "$FULLSCREEN_MARKER" + selection=$(focused_monitor_geo) + elif [[ -z $selection && -e $WINDOW_MARKER ]]; then + rm -f "$WINDOW_MARKER" + selection=$(geo_at_cursor) + fi + + printf '%s' "$selection" +} + +FREEZE_PID="" +freeze_screen() { + hyprpicker -r -z >/dev/null 2>&1 & + FREEZE_PID=$! + sleep .1 +} + +cleanup_freeze() { + [[ $KEEP_FREEZE == true ]] && return + [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null +} +trap cleanup_freeze EXIT + +case "$MODE" in +region) + freeze_screen + SELECTION=$(pick) + ;; +windows) + freeze_screen + SELECTION=$(get_rectangles | pick -r) + ;; +fullscreen) + SELECTION=$(focused_monitor_geo) + ;; +smart | *) + RECTS=$(get_rectangles) + freeze_screen + SELECTION=$(echo "$RECTS" | pick) + + # A bare click (area < 20px^2) snaps to whichever rectangle it landed in, + # so users don't end up with accidental 2px captures. X and Y can be + # negative (Hyprland monitor positions in multi-display layouts). + if [[ $SELECTION =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] && ((BASH_REMATCH[3] * BASH_REMATCH[4] < 20)); then + click_x=${BASH_REMATCH[1]} + click_y=${BASH_REMATCH[2]} + + while IFS= read -r rect; do + [[ $rect =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || continue + rect_x=${BASH_REMATCH[1]} + rect_y=${BASH_REMATCH[2]} + rect_width=${BASH_REMATCH[3]} + rect_height=${BASH_REMATCH[4]} + + if ((click_x >= rect_x && click_x < rect_x + rect_width && click_y >= rect_y && click_y < rect_y + rect_height)); then + SELECTION="${rect_x},${rect_y} ${rect_width}x${rect_height}" + break + fi + done <<<"$RECTS" + fi + ;; +esac + +[[ $KEEP_FREEZE == true ]] && echo "$FREEZE_PID" + +[[ -n $SELECTION ]] || exit 1 + +if [[ $MATCH_MONITOR == true ]]; then + monitor=$(hyprctl monitors -j | jq -r --arg geo "$SELECTION" "${JQ_MONITOR_GEO} .[] | select(format_geo == \$geo) | .name" | head -1) + if [[ -n $monitor ]]; then + echo "monitor:$monitor" + exit 0 + fi +fi + +echo "$SELECTION" diff --git a/bin/blob-capture-screenshot b/bin/blob-capture-screenshot new file mode 100755 index 0000000..c6da903 --- /dev/null +++ b/bin/blob-capture-screenshot @@ -0,0 +1,82 @@ +#!/bin/bash + +# blob:summary=Take a screenshot +# blob:group=capture +# blob:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=] +# blob:examples=blob screenshot | blob capture screenshot region +# blob:aliases=blob screenshot + +[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs +OUTPUT_DIR="${BLOB_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}" + +if [[ ! -d $OUTPUT_DIR ]]; then + mkdir -p "$OUTPUT_DIR" + blob-notify-send "Created screenshot directory: $OUTPUT_DIR" -t 2000 +fi + +pkill slurp && exit 0 + +SCREENSHOT_EDITOR="${BLOB_SCREENSHOT_EDITOR:-tensaku-edit}" + +# Parse --editor flag from any position +ARGS=() +for arg in "$@"; do + if [[ $arg == --editor=* ]]; then + SCREENSHOT_EDITOR="${arg#--editor=}" + else + ARGS+=("$arg") + fi +done +set -- "${ARGS[@]}" + +MODE="${1:-smart}" +PROCESSING="${2:-slurp}" + +# The picker leaves the screen freeze running (PID on its first output line) +# so grim captures the frozen overlay rather than live content shifting +# during teardown. +# +# Software-composited cursors (Hyprland's fallback on GPUs without working +# hardware cursors) are baked into the frames grim captures, so force +# hardware cursors until after grim runs and restore the setting on exit. +NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int') + +set_no_hw_cursors() { + hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null || + hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null +} + +cleanup() { + [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null + set_no_hw_cursors "$NO_HW_CURSORS" +} +trap cleanup EXIT + +set_no_hw_cursors 0 +{ read -r FREEZE_PID; read -r SELECTION; } < <(blob-capture-region "$MODE" --keep-freeze) + +[[ -z $SELECTION ]] && exit 0 + +FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png" +FILEPATH="$OUTPUT_DIR/$FILENAME" + +case "$PROCESSING" in + slurp) + grim -g "$SELECTION" "$FILEPATH" || exit 1 + echo "$FILEPATH" + wl-copy --type image/png <"$FILEPATH" + + # Best-effort: the screenshot is already saved and on the clipboard, so a + # notification outage must not report the capture itself as failed. + blob-notify-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ + --image "$FILEPATH" \ + --exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true + ;; + copy) + grim -g "$SELECTION" - | wl-copy --type image/png + ;; + save) + grim -g "$SELECTION" "$FILEPATH" || exit 1 + echo "$FILEPATH" + ;; +esac diff --git a/bin/blob-capture-text b/bin/blob-capture-text new file mode 100755 index 0000000..4e87189 --- /dev/null +++ b/bin/blob-capture-text @@ -0,0 +1,26 @@ +#!/bin/bash + +# blob:summary=Extract text from a screenshot region with OCR +# blob:group=capture +# blob:examples=blob capture text + +# Keep hyprpicker alive until after grim captures so the screenshot sees the +# frozen overlay rather than live content shifting during teardown. +cleanup_freeze() { + [[ -n $PID ]] && kill $PID 2>/dev/null +} +trap cleanup_freeze EXIT + +hyprpicker -r -z >/dev/null 2>&1 & +PID=$! +sleep .1 +SELECTION=$(slurp 2>/dev/null) + +[[ -z $SELECTION ]] && exit 0 + +TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${BLOB_OCR_LANGS:-eng}" --dpi 300 -c preserve_interword_spaces=1 2>/dev/null) || exit 1 + +[[ -z $TEXT ]] && exit 1 + +printf "%s" "$TEXT" | wl-copy +blob-notify-send -g 󰴑 "Copied text from selection to clipboard" diff --git a/bin/blob-capture-webcam-list b/bin/blob-capture-webcam-list new file mode 100755 index 0000000..7a8bd51 --- /dev/null +++ b/bin/blob-capture-webcam-list @@ -0,0 +1,33 @@ +#!/bin/bash + +# blob:summary=List webcam devices that support video capture +# blob:hidden=true + +capture_capable() { + local device="$1" + + v4l2-ctl --device "$device" --info 2>/dev/null | awk ' + /^[[:space:]]*Device Caps[[:space:]]*:/ { inspect = 1; next } + inspect && /^[[:space:]]*Video Capture/ { found = 1 } + END { exit !found } + ' +} + +name="" +emitted=0 + +while IFS= read -r line; do + if [[ -n $line && $line != [[:space:]]* ]]; then + name="$line" + emitted=0 + elif (( ! emitted )); then + device="${line#"${line%%[![:space:]]*}"}" + + if [[ $device == /dev/video* ]] && capture_capable "$device"; then + emitted=1 + printf '%s %s\n' "$device" "$name" + fi + fi +done < <(v4l2-ctl --list-devices 2>/dev/null) + +exit 0 diff --git a/bin/blob-capture-webcam-resize b/bin/blob-capture-webcam-resize new file mode 100755 index 0000000..51be400 --- /dev/null +++ b/bin/blob-capture-webcam-resize @@ -0,0 +1,149 @@ +#!/bin/bash + +# blob:summary=Resize the active webcam recording overlay +# blob:group=capture +# blob:args= +# blob:examples=blob capture webcam resize smaller | blob-capture-webcam-resize reset + +set -euo pipefail + +readonly MARGIN=40 +readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/blob-screenrecord-region" + +usage() { + echo "Usage: blob-capture-webcam-resize " >&2 + exit 1 +} + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +action=${1:-} +case $action in +smaller | larger | reset | small | medium | large) ;; +*) usage ;; +esac + +if ! client=$(hyprctl clients -j 2>/dev/null | jq -cer 'first(.[] | select(.title == "WebcamOverlay")) // empty' 2>/dev/null); then + exit 0 +fi + +read -r address current_width current_height monitor_id < <( + jq -r '[.address, .size[0], .size[1], .monitor] | @tsv' <<<"$client" +) + +[[ -n $address && $current_width =~ ^[0-9]+$ && $current_height =~ ^[0-9]+$ && $monitor_id =~ ^[0-9]+$ ]] || exit 0 +((current_width > 0 && current_height > 0)) || exit 0 + +if ! monitor=$(hyprctl monitors -j 2>/dev/null | jq -cer --argjson id "$monitor_id" 'first(.[] | select(.id == $id)) // empty' 2>/dev/null); then + exit 0 +fi + +read -r monitor_x monitor_y monitor_width monitor_height < <( + jq -r ' + . as $monitor | + (($monitor.transform // 0) % 2 == 1) as $rotated | + [ + .x, + .y, + (((if $rotated then .height else .width end) / .scale) | floor), + (((if $rotated then .width else .height end) / .scale) | floor) + ] | @tsv + ' <<<"$monitor" +) + +[[ $monitor_x =~ ^-?[0-9]+$ && $monitor_y =~ ^-?[0-9]+$ && $monitor_width =~ ^[0-9]+$ && $monitor_height =~ ^[0-9]+$ ]] || exit 0 + +# Anchor to the recorded region when there is one, so a window picked on a wide +# display keeps the camera in its own corner. Full-monitor captures, the portal +# backend, and resizes outside a recording publish none and fall back here. +anchor_x=$monitor_x +anchor_y=$monitor_y +anchor_width=$monitor_width +anchor_height=$monitor_height + +if [[ -f $REGION_FILE ]] && region=$(<"$REGION_FILE"); then + if [[ $region =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]]; then + anchor_width=${BASH_REMATCH[1]} + anchor_height=${BASH_REMATCH[2]} + anchor_x=${BASH_REMATCH[3]} + anchor_y=${BASH_REMATCH[4]} + fi +fi + +# A tall, narrow region can't fit presets scaled from its own height, so cap the +# height they scale from to what the width allows — the large preset is the +# widest at 3/10 of it. Scaling the ladder as a whole leaves small, medium and +# large distinct sizes for smaller and larger to step between. +scale_height=$anchor_height +available_width=$((anchor_width - 2 * MARGIN)) +((available_width > 0 && scale_height * 3 / 10 > available_width)) && + scale_height=$((available_width * 10 / 3)) + +# Scale the 8:9 portrait presets from that height so they occupy the same +# proportion of a 1080p, HiDPI, ultrawide, or 6K recording. +small_height=$(((scale_height * 9 + 25) / 50)) +small_width=$(((small_height * 8 + 4) / 9)) +medium_height=$(((scale_height + 2) / 4)) +medium_width=$(((medium_height * 8 + 4) / 9)) +large_height=$(((scale_height * 27 + 40) / 80)) +large_width=$(((large_height * 8 + 4) / 9)) + +target_width=$current_width +target_height=$current_height +case $action in +small) + target_width=$small_width + target_height=$small_height + ;; +medium | reset) + target_width=$medium_width + target_height=$medium_height + ;; +large) + target_width=$large_width + target_height=$large_height + ;; +smaller) + if ((large_width < current_width)); then + target_width=$large_width + target_height=$large_height + elif ((medium_width < current_width)); then + target_width=$medium_width + target_height=$medium_height + elif ((small_width < current_width)); then + target_width=$small_width + target_height=$small_height + fi + ;; +larger) + if ((small_width > current_width)); then + target_width=$small_width + target_height=$small_height + elif ((medium_width > current_width)); then + target_width=$medium_width + target_height=$medium_height + elif ((large_width > current_width)); then + target_width=$large_width + target_height=$large_height + fi + ;; +esac + +target_x=$((anchor_x + anchor_width - target_width - MARGIN)) +target_y=$((anchor_y + anchor_height - target_height - MARGIN)) + +((target_x < anchor_x + MARGIN)) && target_x=$((anchor_x + MARGIN)) +((target_y < anchor_y + MARGIN)) && target_y=$((anchor_y + MARGIN)) + +window="address:$address" +hypr_dispatch \ + "hl.dsp.window.resize({ window = \"$window\", x = $target_width, y = $target_height })" \ + resizewindowpixel "exact $target_width $target_height,$window" +hypr_dispatch \ + "hl.dsp.window.move({ window = \"$window\", x = $target_x, y = $target_y })" \ + movewindowpixel "exact $target_x $target_y,$window" diff --git a/bin/blob-clipboard-file b/bin/blob-clipboard-file new file mode 100755 index 0000000..4a4f544 --- /dev/null +++ b/bin/blob-clipboard-file @@ -0,0 +1,33 @@ +#!/bin/bash + +# blob:summary=Copy a file to the clipboard and paste it +# blob:group=clipboard +# blob:args=[--copy-only] +# blob:examples=blob clipboard paste file image/png /tmp/screenshot.png +# blob:hidden=true + +copy_only=false + +if [[ ${1:-} == "--copy-only" ]]; then + copy_only=true + shift +fi + +mime=${1:-} +path=${2:-} + +if [[ -z $mime || -z $path ]]; then + echo "Usage: blob-clipboard-file [--copy-only] " >&2 + exit 1 +fi + +[[ -r $path ]] || exit 1 + +wl-copy --type "$mime" < "$path" + +if [[ $copy_only == "true" ]]; then + exit +fi + +sleep 0.15 +wtype -M shift -k Insert -m shift 2>/dev/null || true diff --git a/bin/blob-clipboard-open b/bin/blob-clipboard-open new file mode 100755 index 0000000..dbb5b90 --- /dev/null +++ b/bin/blob-clipboard-open @@ -0,0 +1,70 @@ +#!/bin/bash + +# blob:summary=Open a clipboard history entry +# blob:group=clipboard +# blob:args=--history-index +# blob:hidden=true + +history_index="" +history_path="$HOME/.local/state/blob/clipboard-history.json" + +while (( $# > 0 )); do + case "$1" in + --history-index) + history_index="${2:-}" + shift 2 + ;; + *) + echo "Usage: blob-clipboard-open --history-index " >&2 + exit 1 + ;; + esac +done + +[[ $history_index =~ ^[0-9]+$ ]] || exit 1 +[[ -r $history_path ]] || exit 1 + +entry_type=$(jq -er --argjson index "$history_index" '.[$index].type' "$history_path") || exit 1 + +open_image() { + local path="$1" + + [[ -r $path ]] || exit 1 + exec tensaku-edit "$path" +} + +open_text() { + local text="$1" + local url="" + local open_dir="" + local open_file="" + + url=$(grep -Eom1 'https?://[^[:space:]"'\''<>]+' <<<"$text" || true) + if [[ -z $url && $text =~ ^[[:space:]]*([[:alnum:]][[:alnum:].-]+\.[[:alpha:]]{2,})(/[^[:space:]]*)?[[:space:]]*$ ]]; then + url="https://${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + fi + + if [[ -n $url ]]; then + exec blob-launch-browser "$url" + fi + + open_dir="${XDG_STATE_HOME:-$HOME/.local/state}/blob/clipboard-open" + mkdir -p "$open_dir" + open_file=$(mktemp --tmpdir="$open_dir" clipboard.XXXXXX.txt) || exit 1 + printf '%s' "$text" >"$open_file" + exec blob-launch-editor "$open_file" +} + +case "$entry_type" in + image) + path=$(jq -er --argjson index "$history_index" '.[$index].path' "$history_path") || exit 1 + open_image "$path" + ;; + text) + text=$(jq -er --argjson index "$history_index" '.[$index].text' "$history_path") || exit 1 + open_text "$text" + ;; + *) + exit 1 + ;; +esac diff --git a/bin/blob-clipboard-text b/bin/blob-clipboard-text new file mode 100755 index 0000000..5a2f10e --- /dev/null +++ b/bin/blob-clipboard-text @@ -0,0 +1,63 @@ +#!/bin/bash + +# blob:summary=Copy text to the clipboard and type or paste it +# blob:group=clipboard +# blob:args=[--shift-insert] [--copy-only] [--history-index |] +# blob:examples=blob clipboard paste text "hello" | blob clipboard paste text --shift-insert "hello" +# blob:hidden=true + +use_shift_insert=false +copy_only=false +history_index="" +text="" + +while (( $# > 0 )); do + case "$1" in + --shift-insert) + use_shift_insert=true + shift + ;; + --copy-only) + copy_only=true + shift + ;; + --history-index) + history_index="${2:-}" + shift 2 + ;; + *) + break + ;; + esac +done + +copy_history_entry() { + local history_path="$HOME/.local/state/blob/clipboard-history.json" + + [[ $history_index =~ ^[0-9]+$ ]] || exit + jq -e --argjson index "$history_index" '.[$index].type == "text" and (.[$index].text | type == "string")' "$history_path" >/dev/null || exit + jq -j --argjson index "$history_index" '.[$index].text' "$history_path" | wl-copy +} + +if [[ -n $history_index ]]; then + copy_history_entry + if [[ $copy_only != "true" ]]; then + use_shift_insert=true + fi +else + text=${1:-} + [[ -n $text ]] || exit + printf '%s' "$text" | wl-copy +fi + +if [[ $copy_only == "true" ]]; then + exit +fi + +sleep 0.15 + +if [[ $use_shift_insert == "true" ]]; then + wtype -M shift -k Insert -m shift 2>/dev/null || true +else + wtype "$text" 2>/dev/null || true +fi diff --git a/bin/blob-crash-watch b/bin/blob-crash-watch new file mode 100755 index 0000000..11ab9cc --- /dev/null +++ b/bin/blob-crash-watch @@ -0,0 +1,77 @@ +#!/bin/bash + +# blob:summary=Watch for process crashes and offer an AI diagnosis +# blob:hidden=true + +# systemd-coredump journals every core dump under a known MESSAGE_ID with +# structured COREDUMP_* fields, which carry more than the core filenames do. + +set -uo pipefail + +# See systemd.journal-fields(7). +readonly COREDUMP_MESSAGE_ID=fc2e22bc6ee647b6b90729ab34a250b1 + +# nf-md-robot_dead, escaped so this file reads without a Nerd Font. +readonly CRASH_GLYPH=$'\U000f16a1' + +# Crash loops dump core repeatedly, so announce each program at most once a +# window. +readonly dedupe_seconds=${BLOB_CRASH_DEDUPE_SECONDS:-60} + +# Extended regex of process names never worth announcing. +readonly ignore_pattern=${BLOB_CRASH_IGNORE:-} + +declare -A last_notified + +announce() { + local comm=$1 pid=$2 exe=$3 signal=$4 + + # The shell owns org.freedesktop.Notifications, so a shell crash takes the + # notification server down with it and a toast sent into that gap is lost. + # Wait for the restarted shell to claim the name again: the crash least + # likely to be delivered is the one most worth reporting. + blob-notification-wait || return 1 + + # Keeps the default "blob-action" app name, the only one shouldBypassDnd() + # lets through. + blob-notify-send \ + --urgency critical \ + --glyph "$CRASH_GLYPH" \ + "Process crashed: $comm" \ + "$signal from pid $pid" +} + +# -n 0 so a restart does not re-announce crashes already dealt with. +journalctl -f -n 0 -o json "MESSAGE_ID=$COREDUMP_MESSAGE_ID" 2>/dev/null | + while IFS= read -r entry; do + IFS=$'\t' read -r uid comm pid exe signal < <( + jq -r '[(._UID // "-"), + (.COREDUMP_COMM // "-"), + (.COREDUMP_PID // "-"), + (.COREDUMP_EXE // "-"), + (.COREDUMP_SIGNAL_NAME // "-")] | @tsv' <<<"$entry" 2>/dev/null + ) + + [[ $pid =~ ^[0-9]+$ ]] || continue + + # Only this user's crashes; a daemon dumping core is a sysadmin's problem. + [[ $uid =~ ^[0-9]+$ ]] || continue + ((uid == UID)) || continue + + # comm is truncated to 15 characters, so prefer the executable's basename. + name=$comm + [[ $exe == /* ]] && name=${exe##*/} + + [[ -n $ignore_pattern && $name =~ $ignore_pattern ]] && continue + + # Never announce our own machinery, or it notifies about itself. + [[ $name == blob-crash-* ]] && continue + + now=$EPOCHSECONDS + (((now - ${last_notified[$name]:-0}) < dedupe_seconds)) && continue + + # Only a delivered toast starts the dedupe window. A failed send that + # counted would suppress the rest of a crash loop for a minute, and + # `journalctl -n 0` never replays what was missed. + announce "$name" "$pid" "$exe" "$signal" && last_notified[$name]=$now + done diff --git a/bin/blob-disk-speedtest b/bin/blob-disk-speedtest new file mode 100755 index 0000000..473f68e --- /dev/null +++ b/bin/blob-disk-speedtest @@ -0,0 +1,232 @@ +#!/bin/bash + +# blob:summary=Measure live disk read and write speed +# blob:args=[target-dir] + +set -e + +if [[ -n ${1:-} && ! -d $1 ]]; then + echo "Usage: blob-disk-speedtest [target-dir]" >&2 + exit 2 +fi + +target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/blob}" +phase_seconds=8 +parallel=4 +chunk_mb=4 +file_mb=256 + +mkdir -p "$target_dir" + +worker_pids=() +chunk_file="" +test_files=() + +stop_workers() { + local pid + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + pkill -TERM -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + done + for pid in "${worker_pids[@]}"; do + [[ -n $pid ]] || continue + wait "$pid" 2>/dev/null || true + done + worker_pids=() +} + +alive_workers() { + local pid count=0 + for pid in "${worker_pids[@]}"; do + kill -0 "$pid" 2>/dev/null && count=$((count + 1)) + done + echo "$count" +} + +cleanup() { + # Unlink before stopping the workers, so even a cleanup cut short by an + # impatient SIGKILL has already taken the names off the filesystem. A live + # write worker's next dd pass recreates its file by name, so sweep again + # once they are gone. + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" + stop_workers + rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}" +} +# Armed before any scratch file exists, so a failed preflight check below +# cannot leak them. +trap cleanup EXIT +trap 'exit 143' TERM INT + +# Exclusive per-invocation scratch files: predictable names could clobber a +# user's file, follow a planted symlink, or let overlapping runs delete each +# other's active files out from under the measurement. Each worker gets its +# own on-disk file so the phases run at a queue depth the device can actually +# stretch out on, like the network test's parallel curl workers. +# +# The files are marked NOCOW where the filesystem supports it (btrfs), which +# turns off copy-on-write, checksums, and compression for them. That is what +# makes O_DIRECT truly direct on btrfs -- with checksums on it silently falls +# back to the page cache -- and it makes every rewrite land in place instead +# of churning the extent allocator, which run-to-run reproducibility depends +# on. +chunk_file=$(mktemp /dev/shm/blob-disk-speedtest-XXXXXX.src) +for (( i = 0; i < parallel; i++ )); do + file=$(mktemp "$target_dir/disk-speedtest-XXXXXX.dat") + chattr +C "$file" 2>/dev/null || true + test_files+=("$file") +done + +format_rate() { + awk -v value="$1" 'BEGIN { + if (value <= 0) print "0.0" + else if (value < 10) printf "%.1f\n", value + else printf "%.0f\n", value + }' +} + +# Resolve the block device backing the target directory, so throughput can be +# sampled from its kernel I/O counters the same way the network speed test +# samples the interface counters. +source_dev=$(findmnt -no SOURCE --target "$target_dir" 2>/dev/null) +source_dev=${source_dev%%\[*} # Strip btrfs subvolume suffix: /dev/sda2[/@home] + +if [[ $source_dev != /dev/* ]]; then + echo "Cannot find a disk behind $target_dir" >&2 + exit 1 +fi + +dev=$(readlink -f "$source_dev") +dev=${dev##*/} + +if [[ ! -r /sys/class/block/$dev/stat ]]; then + echo "No I/O statistics for $dev" >&2 + exit 1 +fi + +available_mb=$(df --output=avail -m "$target_dir" | tail -1 | tr -d ' ') +if (( available_mb < parallel * file_mb * 2 )); then + echo "Need at least $((parallel * file_mb * 2))MB free on $target_dir" >&2 + exit 1 +fi + +# Name the physical disk under test, walking dm-crypt/LVM layers and the +# partition table up to the whole device that carries the hardware model. +disk=$dev +while slave=$(ls "/sys/class/block/$disk/slaves" 2>/dev/null | head -1); [[ -n $slave ]]; do + disk=$slave +done +if [[ -f /sys/class/block/$disk/partition ]]; then + parent=$(readlink -f "/sys/class/block/$disk") + parent=${parent%/*} + disk=${parent##*/} +fi +model=$(lsblk -dno MODEL "/dev/$disk" 2>/dev/null | sed 's/^ *//; s/ *$//') +echo "disk ${model:-$disk}" + +# The stress data must be incompressible so nothing between the write call +# and the flash can shrink it. Staging a urandom chunk in RAM also keeps the +# source out of the measurement -- reading tmpfs is a memcpy. +dd if=/dev/urandom of="$chunk_file" bs=${chunk_mb}M count=$((file_mb / chunk_mb)) status=none + +# Workers loop only while the main script lives: if cleanup ever loses the +# race with a kill, an orphaned worker finishes its current pass and stops +# instead of hammering the disk forever. +write_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null || return + done +} + +read_worker() { + local file=$1 + while kill -0 $$ 2>/dev/null; do + dd if="$file" of=/dev/null bs=${chunk_mb}M iflag=direct status=none 2>/dev/null || return + done +} + +device_sectors() { + local -a stats + read -r -a stats < "/sys/class/block/$dev/stat" + if [[ $1 == "read" ]]; then + echo "${stats[2]}" + else + echo "${stats[6]}" + fi +} + +run_phase() { + local phase=$1 + local file before after deadline rate alive samples=0 + local baseline_sectors baseline_time end_time + + for file in "${test_files[@]}"; do + "${phase}_worker" "$file" 2>/dev/null & + worker_pids+=("$!") + done + + before=$(device_sectors "$phase") + deadline=$((SECONDS + phase_seconds)) + + while (( SECONDS < deadline )) && (( $(alive_workers) > 0 )); do + sleep 1 + after=$(device_sectors "$phase") + end_time=$EPOCHREALTIME + rate=$(awk -v before="$before" -v after="$after" 'BEGIN { + if (after < before) print 0 + else print (after - before) * 512 / 1000000 + }') + echo "$phase $(format_rate "$rate")" + samples=$((samples + 1)) + # The first second is warm-up -- governor ramp, crypt workers spinning + # up -- so the steady-state average starts after it. + if (( samples == 1 )); then + baseline_sectors=$after + baseline_time=$end_time + fi + before=$after + done + + # The workers only stop on their own when dd fails (quota, I/O error, full + # disk), so any worker gone before the deadline is a failed measurement, + # not a finished one. + alive=$(alive_workers) + stop_workers + if (( alive < parallel )); then + echo "Disk $phase test failed before finishing" >&2 + exit 1 + fi + + # The figure the dial settles on is the steady-state mean over the whole + # phase, not whatever rate the final second happened to catch. + if (( samples > 1 )); then + rate=$(awk -v before="$baseline_sectors" -v after="$after" -v start="$baseline_time" -v end="$end_time" 'BEGIN { + secs = end - start + if (secs <= 0 || after < before) print 0 + else print (after - before) * 512 / 1000000 / secs + }') + echo "$phase $(format_rate "$rate")" + fi +} + +# The read phase runs first, so its data must be staged before any measuring +# starts. Direct I/O leaves nothing in the page cache to serve reads from. +for file in "${test_files[@]}"; do + dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null & + worker_pids+=("$!") +done + +stage_failed=0 +for pid in "${worker_pids[@]}"; do + wait "$pid" || stage_failed=1 +done +worker_pids=() + +if (( stage_failed )) || [[ ! -s ${test_files[0]} ]]; then + echo "Direct disk I/O is not available on $target_dir" >&2 + exit 1 +fi + +run_phase read +run_phase write diff --git a/bin/blob-display-size b/bin/blob-display-size new file mode 100755 index 0000000..b8f9be2 --- /dev/null +++ b/bin/blob-display-size @@ -0,0 +1,236 @@ +#!/bin/bash + +# blob:summary=Scale text everywhere — blob shell, GTK apps, and terminals +# blob:args=[size|reset] +# blob:examples=blob display text size | blob display text size 16 | blob display text size reset + +# One knob for apparent text size across the desktop. It drives three settings +# in lockstep, all anchored to the shell default of 12px: +# • the blob shell's font base-size (~/.config/blob/shell.toml [font]) +# • GNOME/GTK's text-scaling-factor (12px -> 1.0, quantized so the GTK +# interface font lands on a whole point size, so 16 -> 15pt/11pt = 1.3636) +# • the terminal font point size (12px -> 9pt, so terminal_pt = px * 9/12) +# The shell override layers on top of the active theme (so the size survives +# theme switches) and the shell watches the file, so shell text re-flows live. +# Accepts an integer from 9 to 20 (px). + +MIN=9 +MAX=20 +GKEY_SCHEMA="org.gnome.desktop.interface" +GKEY_NAME="text-scaling-factor" + +# Anchors: 12px shell base == factor 1.0 == 9pt terminal font. +TERM_DEFAULT_PT=9 +SHELL_DEFAULT_PX=12 + +shell_config="$HOME/.config/blob/shell.toml" + +usage() { + echo "Usage: blob-display-size [size|reset]" + echo " (no args) print the current text size, GTK factor, and terminal size" + echo " set text size in px ($MIN–$MAX); shell + GTK + terminals together" + echo " reset return all three to their defaults (12px / 1.0 / 9pt)" +} + +# ---- shell base-size: the rem root every shell type size derives from ---- + +# Print the base-size currently set under [font], or nothing if unset. +current_base_size() { + [[ -f $shell_config ]] || return 0 + awk ' + /^[[:space:]]*\[/ { in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/); next } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { + v = $0 + sub(/^[^=]*=[[:space:]]*/, "", v) + sub(/[[:space:]]*(#.*)?$/, "", v) + print v + exit + } + ' "$shell_config" +} + +# Upsert base-size under [font]: replace it in place if present, insert it into +# an existing [font] section, or append a fresh [font] section otherwise. Other +# sections and keys in the user override are left untouched. +set_base_size() { + local size="$1" + mkdir -p "$(dirname "$shell_config")" + + if [[ ! -f $shell_config ]]; then + printf '[font]\nbase-size = %s\n' "$size" >"$shell_config" + return + fi + + local tmp + tmp="$(mktemp)" + awk -v val="$size" ' + function emit_base() { print "base-size = " val; done = 1 } + /^[[:space:]]*\[/ { + if (in_font && !done) emit_base() + in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/) + print + next + } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { + if (!done) emit_base() + next + } + { print } + END { + if (in_font && !done) emit_base() + if (!done) { + if (NR > 0) print "" + print "[font]" + emit_base() + } + } + ' "$shell_config" >"$tmp" + mv "$tmp" "$shell_config" +} + +# Drop the base-size line, returning the shell to the theme/default size. +reset_base_size() { + [[ -f $shell_config ]] || return 0 + local tmp + tmp="$(mktemp)" + awk ' + /^[[:space:]]*\[/ { in_font = ($0 ~ /^[[:space:]]*\[font\]([[:space:]]|$)/) } + in_font && /^[[:space:]]*base-size[[:space:]]*=/ { next } + { print } + ' "$shell_config" >"$tmp" + mv "$tmp" "$shell_config" +} + +# ---- GTK text-scaling-factor ---- + +# Point size of the GTK interface font (font-name), used to quantize the +# scaling factor. Falls back to the GNOME default when unreadable. +gtk_font_pt() { + local name pt + name="$(gsettings get "$GKEY_SCHEMA" font-name 2>/dev/null)" + pt="${name%\'}" + pt="${pt##* }" + if [[ $pt =~ ^[0-9]+(\.[0-9]+)?$ ]]; then + echo "$pt" + else + echo 11 + fi +} + +set_factor() { + gsettings set "$GKEY_SCHEMA" "$GKEY_NAME" "$1" 2>/dev/null || true +} + +# ---- terminal font point size ---- + +# px base-size -> terminal point size, rounded to the nearest integer. +term_pt_for() { + awk -v s="$1" -v p="$TERM_DEFAULT_PT" -v b="$SHELL_DEFAULT_PX" \ + 'BEGIN { printf "%d", int(s * p / b + 0.5) }' +} + +# Set the font point size in terminal configs, creating Kitty overrides when +# it inherits its size from the system config. Family is left +# untouched — that is blob-font-set's job. Live-reload signals mirror +# blob-font-set; foot has no reload signal, so running instances are nudged. +set_terminal_size() { + local pt="$1" + + if [[ -f ~/.config/alacritty/alacritty.toml ]]; then + sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml + fi + + if [[ -f ~/.config/kitty/kitty.conf ]] || blob-cmd-present kitty; then + mkdir -p ~/.config/kitty + if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then + sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf + else + printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf + fi + pkill -USR1 kitty 2>/dev/null || true + fi + + if [[ -f ~/.config/ghostty/config ]]; then + sed -i -E "s/^font-size = .*/font-size = $pt/" ~/.config/ghostty/config + pkill -SIGUSR2 ghostty 2>/dev/null || true + fi + + if [[ -f ~/.config/foot/foot.ini ]]; then + sed -i -E "s/(:size=)[0-9.]+/\1$pt/" ~/.config/foot/foot.ini + # Foot has no config-reload signal, so a running instance keeps its startup + # size until relaunched (new windows pick up the change). Nudge the user — + # but reuse the same notification id (freedesktop replaces_id) so dragging + # through several sizes refreshes one toast instead of stacking a pile. + if pgrep -x foot >/dev/null 2>&1; then + local id_file="${XDG_RUNTIME_DIR:-/tmp}/blob-display-size.foot-notif-id" + local prev_id="" + [[ -f $id_file ]] && read -r prev_id <"$id_file" 2>/dev/null + local replace=() + [[ $prev_id =~ ^[0-9]+$ ]] && replace=(-r "$prev_id") + local new_id + new_id="$(blob-notify-send \ + "Restart Foot to apply the new terminal font size" \ + "${replace[@]}" -p 2>/dev/null)" || true + [[ $new_id =~ ^[0-9]+$ ]] && printf '%s\n' "$new_id" >"$id_file" + fi + fi +} + +# Report the current terminal point size from whichever config we find first. +term_current_pt() { + if [[ -f ~/.config/ghostty/config ]]; then + grep -oP '^font-size = \K[0-9.]+' ~/.config/ghostty/config | head -1 + elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then + grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1 + elif [[ -f ~/.config/kitty/kitty.conf ]]; then + local pt + pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1) + echo "${pt:-$TERM_DEFAULT_PT}" + elif [[ -f ~/.config/foot/foot.ini ]]; then + grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1 + elif blob-cmd-present kitty; then + echo "$TERM_DEFAULT_PT" + fi +} + +case "${1:-}" in + -h | --help) + usage + exit 0 + ;; + "") + cur="$(current_base_size)" + size="${cur:-12 (default)}" + factor="$(gsettings get "$GKEY_SCHEMA" "$GKEY_NAME" 2>/dev/null)" + term="$(term_current_pt)" + printf 'text size: %s px\ngtk text-scaling-factor: %s\nterminal font: %s pt\n' \ + "$size" "$factor" "${term:-n/a}" + exit 0 + ;; + reset | default) + reset_base_size + gsettings reset "$GKEY_SCHEMA" "$GKEY_NAME" 2>/dev/null || true + set_terminal_size "$TERM_DEFAULT_PT" + exit 0 + ;; +esac + +size="$1" +if [[ ! $size =~ ^[0-9]+$ ]] || ((size < MIN || size > MAX)); then + echo "Size must be an integer between $MIN and $MAX (px)." >&2 + usage >&2 + exit 1 +fi + +# Shell side: base-size in px (the blob shell's rem root). +set_base_size "$size" + +# GTK side: multiplier anchored so 12px == 1.0, quantized so the interface +# font renders at a whole point size. Raw ratios yield fractional point sizes, +# which GTK4 menus clip at the ascenders on scale-1 monitors. +factor="$(awk -v s="$size" -v b="$SHELL_DEFAULT_PX" -v f="$(gtk_font_pt)" \ + 'BEGIN { printf "%.4f", int(f * s / b + 0.5) / f }')" +set_factor "$factor" + +# Terminal side: point size anchored so 12px == 9pt. +set_terminal_size "$(term_pt_for "$size")" diff --git a/bin/blob-display-state b/bin/blob-display-state new file mode 100755 index 0000000..6e92927 --- /dev/null +++ b/bin/blob-display-state @@ -0,0 +1,23 @@ +#!/bin/bash + +# blob:summary=Print monitor panel state for the shell +# blob:group=monitor + +monitors_json=$(hyprctl monitors all -j) +focused_monitor=$(printf '%s\n' "$monitors_json" | jq -r '[.[] | select(.focused == true)][0].name // ""') + +{ blob-brightness-display --monitor "$focused_monitor" 2>/dev/null; echo; } | head -n 1 + +printf '%s\n' "$monitors_json" | jq -r ' + def internal: test("^(eDP|LVDS|DSI)-"); + ([.[] | select(.name | internal)][0].name // ""), + ([.[] | select((.name | internal) | not)][0].name // ""), + ([.[] | select((.name | internal) and .disabled != true)][0].name // ""), + ([.[] | select(.mirrorOf != "none") | if (.name | internal) then .mirrorOf else .name end][0] // "") +' + +printf '%s\n' "$focused_monitor" +blob-hypr-monitor-scaling 2>/dev/null || echo + +printf '%s\n' "$monitors_json" | jq -c \ + '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true), width, height}]' diff --git a/bin/blob-drive-info b/bin/blob-drive-info new file mode 100755 index 0000000..b17f7f0 --- /dev/null +++ b/bin/blob-drive-info @@ -0,0 +1,50 @@ +#!/bin/bash + +# blob:summary=Print drive information such as size, model, and mount details +# blob:args= + +if (($# == 0)); then + echo "Usage: blob-drive-info [/dev/drive]" + exit 1 +else + drive="$1" +fi + +# Find the root drive in case we are looking at partitions +root_drive=$(lsblk -no PKNAME "$drive" 2>/dev/null | tail -n1) +if [[ -n $root_drive ]]; then + root_drive="/dev/$root_drive" +else + root_drive="$drive" +fi + +# Get basic disk information +size=$(lsblk -dno SIZE "$drive" 2>/dev/null) +vendor=$(lsblk -dno VENDOR "$root_drive" 2>/dev/null | sed 's/ *$//') +model=$(lsblk -dno MODEL "$root_drive" 2>/dev/null | sed 's/ *$//') + +# Combine vendor and model, avoiding duplication +label="" +if [[ -n $vendor && -n $model ]]; then + if [[ $model == *$vendor* ]]; then + label="$model" + else + label="$vendor $model" + fi +elif [[ -n $model ]]; then + label="$model" +elif [[ -n $vendor ]]; then + label="$vendor" +fi + +# Format display string +display="$drive" +[[ -n $size ]] && display="$display ($size)" +[[ -n $label ]] && display="$display - $label" + +# Append compact partition summary +part_summary=$(lsblk -nro TYPE,NAME,FSTYPE,MOUNTPOINT "$root_drive" 2>/dev/null | \ + awk '$1=="part" { printf "%s%s%s", s, ($3==""?"unknown":$3), ($4==""?"":"("$4")"); s=", " }') +[[ -n $part_summary ]] && display+=" [$part_summary]" + +echo "$display" diff --git a/bin/blob-drive-password b/bin/blob-drive-password new file mode 100755 index 0000000..24e8cbe --- /dev/null +++ b/bin/blob-drive-password @@ -0,0 +1,32 @@ +#!/bin/bash + +# blob:summary=Set a new encryption password for a drive selected. +# blob:requires-sudo=true + +encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device) + +if [[ -n $encrypted_drives ]]; then + if (( $(wc -l <<<"$encrypted_drives") == 1 )); then + drive_to_change="$encrypted_drives" + else + drive_to_change="$(blob-drive-select "$encrypted_drives")" + fi + + if [[ -n $drive_to_change ]]; then + new_password=$(gum input --password --header "New encryption password") || exit 1 + [[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; } + + confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1 + [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; } + + echo "Changing full-disk encryption password for $drive_to_change" + # The new key travels over stdin and reaches cryptsetup as a keyfile via + # <(cat), leaving the tty free for the current-passphrase prompt. + printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) +# blob:examples=blob font list | blob font set "CaskaydiaMono Nerd Font" + +usage() { + echo "Usage: blob-font-set " +} + +font_name="${1:-}" + +case "$font_name" in + -h|--help) + usage + exit 0 + ;; + "") + usage >&2 + exit 1 + ;; +esac + +if ! fc-list | grep -Fqi -- "$font_name"; then + echo "Font '$font_name' not found." + exit 1 +fi + +if [[ -f ~/.config/alacritty/alacritty.toml ]]; then + sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml +fi + +if [[ -f ~/.config/kitty/kitty.conf ]] || blob-cmd-present kitty; then + mkdir -p ~/.config/kitty + if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then + kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g') + sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf + else + printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf + fi + pkill -USR1 kitty +fi + +if [[ -f ~/.config/ghostty/config ]]; then + sed -i "s/font-family = \".*\"/font-family = \"$font_name\"/g" ~/.config/ghostty/config + pkill -SIGUSR2 ghostty +fi + +if [[ -f ~/.config/foot/foot.ini ]]; then + sed -i "s/^font=.*/font=$font_name:size=9/g" ~/.config/foot/foot.ini +fi + +# fontconfig is the canonical source of truth — the blob shell, Qt apps, +# and anything resolving "monospace" all read from here. This file is loaded +# after the package-owned default, and prepend_first puts the chosen family at +# the head of the list so it wins over the family that default prefers. +fontconfig_file="$HOME/.config/fontconfig/fonts.conf" +mkdir -p "$(dirname "$fontconfig_file")" +cat >"$fontconfig_file" < + + + + + monospace + + + $font_name + + + +XML + +blob-shell-restart + +if pgrep -x ghostty; then + blob-notify-send -g "You must restart Ghostty to see font change" +fi + +if pgrep -x foot; then + blob-notify-send -g "You must restart Foot to see font change" +fi + +blob-hook font-set "$font_name" diff --git a/bin/blob-git-url-check b/bin/blob-git-url-check new file mode 100755 index 0000000..290400c --- /dev/null +++ b/bin/blob-git-url-check @@ -0,0 +1,50 @@ +#!/bin/bash + +# blob:summary=Check that a git URL names a repository, not a transport helper +# blob:args= +# blob:hidden=true + +set -euo pipefail + +# git picks a remote helper -- an executable it runs at clone time -- out of a URL +# in exactly two shapes, and no others: `::
`, and +# `://
` for any scheme git does not handle itself. A single +# colon is always scp-style ssh, and a bare path is always a path; neither can +# reach a helper. So constraining those two shapes covers the whole surface. +# +# The `::` shape is refused outright, because no helper reachable that way is one +# a theme or plugin URL has business naming, and `ext::` runs a shell command. +# The `://` shape cannot be refused the same way, since it is also how every +# legitimate URL arrives -- so it is allowlisted instead. The list is the +# transports git still connects itself, `git+ssh` and `ssh+git` included: those +# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are +# left out deliberately -- git ships a helper for each, and `ext` runs whatever +# command the URL carries. +TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file) + +fail() { + echo "blob-git-url-check: $*" >&2 + exit 1 +} + +url="${1-}" + +if [[ -z $url ]]; then + fail "a git URL is required" +fi + +if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then + fail "'$url' names a git option or transport helper, not a repository." +fi + +if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then + scheme="${BASH_REMATCH[1]}" + + for transport in "${TRANSPORTS[@]}"; do + if [[ $scheme == "$transport" ]]; then + exit 0 + fi + done + + fail "'$url' names the '$scheme' transport, which Blob does not clone from." +fi diff --git a/bin/blob-hibernation-available b/bin/blob-hibernation-available new file mode 100755 index 0000000..05c8227 --- /dev/null +++ b/bin/blob-hibernation-available @@ -0,0 +1,19 @@ +#!/bin/bash + +# blob:summary=Check if hibernation is supported + +if [[ ! -f /sys/power/image_size ]]; then + exit 1 +fi + +# Sum all swap sizes (excluding zram) +SWAPSIZE_KB=$(awk '!/Filename|zram/ {sum += $3} END {print sum+0}' /proc/swaps) +SWAPSIZE=$(( 1024 * ${SWAPSIZE_KB:-0} )) + +HIBERNATION_IMAGE_SIZE=$(cat /sys/power/image_size) + +if (( SWAPSIZE > HIBERNATION_IMAGE_SIZE )) && [[ -f /etc/mkinitcpio.conf.d/blob_resume.conf ]]; then + exit 0 +else + exit 1 +fi diff --git a/bin/blob-hw-dell-xps-haptic-touchpad b/bin/blob-hw-dell-xps-haptic-touchpad new file mode 100755 index 0000000..a4f1bd5 --- /dev/null +++ b/bin/blob-hw-dell-xps-haptic-touchpad @@ -0,0 +1,5 @@ +#!/bin/bash + +# blob:summary=Match Dell XPS systems with the Synaptics haptic touchpad. + +blob-hw-match "XPS" && [[ -e /sys/bus/i2c/devices/i2c-VEN_06CB:00 ]] diff --git a/bin/blob-hw-fingerprint b/bin/blob-hw-fingerprint new file mode 100755 index 0000000..9269711 --- /dev/null +++ b/bin/blob-hw-fingerprint @@ -0,0 +1,56 @@ +#!/bin/bash + +# blob:summary=Returns true when a fingerprint reader is present +# blob:hidden=true + +# Detect straight from sysfs so this works before fprintd/usbutils are +# installed (the fingerprint setup pulls those in). USB vendor IDs listed here +# ship fingerprint readers; multi-purpose vendors (e.g. Elan/STMicro, which +# also make USB touchscreens) are left out to avoid nagging laptops with no +# reader — those still match on the product string below when present. +fingerprint_vendors=" 27c6 138a 06cb 08ff 1c7a 147e " +usb_devices_path="${BLOB_USB_DEVICES_PATH:-/sys/bus/usb/devices}" + +# libfprint drives every reader it supports from userspace over libusb, so a +# real reader sits there with no kernel driver bound to any of its interfaces. +# The other things these vendors build — Synaptics webcam bridges (usbio-bridge +# on the Dell XPS 14), touchpads and touchscreens (usbhid), cameras (uvcvideo) +# — all bind one. Only the vendor-ID guess needs this; a device that names +# itself a fingerprint reader is trusted outright. +has_kernel_driver() { + local intf driver + for intf in "$1"/*:*; do + [[ -e $intf/driver ]] || continue + # usbfs is the exception: libusb claims an interface through it, so a reader + # fprintd is enrolling or verifying against binds a driver for as long as it + # holds the claim. That is userspace driving the device — what a reader is + # supposed to look like — so it must not read as a kernel driver here. + driver=$(readlink -f "$intf/driver") + [[ ${driver##*/} == "usbfs" ]] || return 0 + done + return 1 +} + +for dev in "$usb_devices_path"/*; do + # The device's own product descriptor usually names it, e.g. "Goodix + # Fingerprint USB Device" — driver-independent and vendor-agnostic. + if [[ -r $dev/product ]]; then + product=$(<"$dev/product") + product=${product,,} + # Elan's match-on-chip readers report "ELAN:ARM-M4" and Fingerprint Cards' + # report "FPC Sensor Controller" or "FPC L:0000 FW:1425046" — the family or + # the manufacturer rather than the function. Both vendors are left out of + # the list above on purpose (Elan also makes touchscreens), so without these + # they match nothing. FPC leads the string on every reader on record, and + # three letters are little to match on, so require the prefix. + [[ $product == *fingerprint* || $product == *biometric* || $product == *elan:arm-m4* || $product == "fpc "* ]] && exit 0 + fi + + if [[ -r $dev/idVendor ]]; then + vendor=$(<"$dev/idVendor") + [[ $fingerprint_vendors == *" $vendor "* ]] && + ! has_kernel_driver "$dev" && exit 0 + fi +done + +exit 1 diff --git a/bin/blob-hw-hybrid-gpu b/bin/blob-hw-hybrid-gpu new file mode 100755 index 0000000..8ec30b9 --- /dev/null +++ b/bin/blob-hw-hybrid-gpu @@ -0,0 +1,24 @@ +#!/bin/bash + +# blob:summary=Detect whether the system has an active hybrid GPU configuration + +multiple_gpus() { + (($(lspci | grep -cE 'VGA|3D|Display') >= 2)) +} + +if blob-cmd-present supergfxctl; then + # A wedged supergfxd blocks its clients forever, and this gate runs while + # the menu renders. Bound the query, and treat a daemon that cannot answer + # like a machine without supergfxctl: count GPUs instead of hiding hardware + # that is really there. + modes=$(timeout --kill-after=1s 1s supergfxctl -s 2>/dev/null) + status=$? + + if ((status == 124 || status == 137)); then + multiple_gpus + else + grep -qw Hybrid <<<"$modes" + fi +else + multiple_gpus +fi diff --git a/bin/blob-hw-laptop b/bin/blob-hw-laptop new file mode 100755 index 0000000..69d2ef6 --- /dev/null +++ b/bin/blob-hw-laptop @@ -0,0 +1,17 @@ +#!/bin/bash + +# blob:summary=Returns true when running on a laptop (has a lid or laptop chassis). + +# A lid switch is the definitive signal for clamshell-capable hardware. +for state in /proc/acpi/button/lid/*/state; do + [[ -e $state ]] && exit 0 +done + +# Fall back to the DMI chassis type for laptops that don't expose an ACPI lid +# button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet +# 31=Convertible 32=Detachable. +case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in +8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;; +esac + +exit 1 diff --git a/bin/blob-hw-nvidia b/bin/blob-hw-nvidia new file mode 100755 index 0000000..3d658da --- /dev/null +++ b/bin/blob-hw-nvidia @@ -0,0 +1,16 @@ +#!/bin/bash + +# blob:summary=Detect whether the computer has an NVIDIA GPU. + +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${BLOB_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] && exit 0 +done + +exit 1 diff --git a/bin/blob-hw-nvidia-gsp b/bin/blob-hw-nvidia-gsp new file mode 100755 index 0000000..94556b6 --- /dev/null +++ b/bin/blob-hw-nvidia-gsp @@ -0,0 +1,20 @@ +#!/bin/bash + +# blob:summary=Detect whether the computer has an NVIDIA GPU with GSP firmware (Turing or newer). + +# Turing is the first generation with GSP firmware, and the first to use device +# IDs at 0x1e00 or above; Maxwell, Pascal, and Volta all sit below that line. +# +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${BLOB_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] || continue + (( $(< "$device/device") >= 0x1e00 )) && exit 0 +done + +exit 1 diff --git a/bin/blob-hw-nvidia-without-gsp b/bin/blob-hw-nvidia-without-gsp new file mode 100755 index 0000000..7edaa34 --- /dev/null +++ b/bin/blob-hw-nvidia-without-gsp @@ -0,0 +1,23 @@ +#!/bin/bash + +# blob:summary=Detect whether the computer has an NVIDIA GPU without GSP firmware (Maxwell/Pascal/Volta). + +# Bounded at both ends, because the callers install the 580xx driver on a match +# and it supports exactly this span. GSP firmware arrived with Turing, which is +# also where device IDs cross 0x1e00. Maxwell opens at 0x1340, one ID after the +# last Kepler part; anything older needs a legacy driver we don't package. +# +# Read the cached sysfs IDs rather than lspci, which reads PCI config space and +# resumes runtime-suspended GPUs. +pci_devices_path="${BLOB_PCI_DEVICES_PATH:-/sys/bus/pci/devices}" + +shopt -s nullglob + +for device in "$pci_devices_path"/*; do + [[ $(< "$device/vendor") == "0x10de" ]] || continue + [[ $(< "$device/class") == 0x03* ]] || continue + device_id=$(< "$device/device") + (( device_id >= 0x1340 && device_id < 0x1e00 )) && exit 0 +done + +exit 1 diff --git a/bin/blob-hw-touchpad b/bin/blob-hw-touchpad new file mode 100755 index 0000000..3111cbf --- /dev/null +++ b/bin/blob-hw-touchpad @@ -0,0 +1,6 @@ +#!/bin/bash + +# blob:summary=Print the detected Hyprland touchpad or trackpad device name + +device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty') +[[ -n $device ]] && echo "$device" diff --git a/bin/blob-hw-touchscreen b/bin/blob-hw-touchscreen new file mode 100755 index 0000000..bf3033e --- /dev/null +++ b/bin/blob-hw-touchscreen @@ -0,0 +1,6 @@ +#!/bin/bash + +# blob:summary=Print the detected Hyprland touchscreen or tablet device name + +device=$(hyprctl devices -j | jq -r '[.touch[]?.name, .tablets[]?.name] | first // empty') +[[ -n $device ]] && echo "$device" diff --git a/bin/blob-hw-webcam b/bin/blob-hw-webcam new file mode 100755 index 0000000..c7c5e74 --- /dev/null +++ b/bin/blob-hw-webcam @@ -0,0 +1,5 @@ +#!/bin/bash + +# blob:summary=Check whether a webcam is available + +[[ -n $(blob-capture-webcam-list) ]] diff --git a/bin/blob-hypr-monitor-focused-apple b/bin/blob-hypr-monitor-focused-apple new file mode 100755 index 0000000..742f71f --- /dev/null +++ b/bin/blob-hypr-monitor-focused-apple @@ -0,0 +1,12 @@ +#!/bin/bash + +# blob:summary=Return success if the focused or named Hyprland monitor is an Apple display. +# blob:args=[monitor] + +monitor="${1:-}" + +hyprctl monitors -j | jq -e --arg monitor "$monitor" ' + .[] + | select(if $monitor == "" then .focused == true else .name == $monitor end) + | select(.make == "Apple Computer Inc" and (.model | test("StudioDisplay|ProDisplayXDR|Studio XDR"))) +' >/dev/null diff --git a/bin/blob-hypr-monitor-modeless b/bin/blob-hypr-monitor-modeless new file mode 100755 index 0000000..a8be214 --- /dev/null +++ b/bin/blob-hypr-monitor-modeless @@ -0,0 +1,21 @@ +#!/bin/bash + +# blob:summary=Returns true when Hyprland has an enabled monitor with no mode +# blob:hidden=true + +# A monitor powered off at boot answers with a partial EDID carrying no video +# modes, so Hyprland brings it up at 0x0 and the screen stays black. Mirrors are +# absent from plain `monitors`, hence `all` plus an explicit disabled filter. +# +# Exits 0 modeless, 1 not, 2 when the compositor cannot say. Nothing fires an +# event for this state, so a caller that gave up on an unanswered query would +# leave the screen black for good. +monitors=$(hyprctl monitors all -j 2>/dev/null) || exit 2 + +state=$(jq 'if any(.[]; .disabled != true and (.width == 0 or .height == 0)) then 0 else 1 end' \ + <<<"$monitors" 2>/dev/null) + +case $state in + 0 | 1) exit "$state" ;; + *) exit 2 ;; +esac diff --git a/bin/blob-hypr-monitor-watch b/bin/blob-hypr-monitor-watch new file mode 100755 index 0000000..3081531 --- /dev/null +++ b/bin/blob-hypr-monitor-watch @@ -0,0 +1,114 @@ +#!/bin/bash + +# blob:summary=Watch Hyprland monitor events and recover monitor toggles when a monitor is removed + +SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" +LOCK="${XDG_RUNTIME_DIR:-/tmp}/blob-monitor-clamshell.lock" +MODELESS_LOCK="${XDG_RUNTIME_DIR:-/tmp}/blob-monitor-modeless.lock" + +sync_clamshell() { + ( + flock -n 9 || exit 0 + blob-hypr-monitor-clamshell + ) 9>"$LOCK" +} + +sync_clamshell_after_monitor_change() { + sync_clamshell + + ( + for delay in 1 3 7; do + sleep "$delay" + sync_clamshell + done + ) & +} + +# Powering the monitor on fires no DRM hotplug, and forcing a re-probe needs +# root, so only a reload re-reads the EDID and only a reload reveals whether it +# worked. Back off: the machine can sit like this all night. The lock keeps one +# loop running across the events that call this; the wait is for an event landing +# in the moment one is exiting, which would otherwise be the last one to come. +recover_modeless() { + ( + flock -w 1 9 || exit 0 + + local delay=3 state reloaded unanswered=0 + + while true; do + blob-hypr-monitor-modeless + state=$? + + # A monitor reporting a mode is recovered. One the compositor cannot speak + # for is not an answer, and nothing else will ask again -- but a compositor + # that stays silent has gone, taking the session and this loop's reason + # with it. + (( state == 1 )) && break + (( state == 2 )) && (( ++unanswered > 20 )) && break + (( state == 0 )) && unanswered=0 + + reloaded=0 + # Reloading into half-replaced package config is what the guard prevents. + if (( state == 0 )) && ! blob-hypr-reload-guard paused; then + hyprctl reload >/dev/null 2>&1 || true + reloaded=1 + fi + + sleep "$delay" + (( reloaded )) && (( delay = delay * 2 > 60 ? 60 : delay * 2 )) + done + ) 9>"$MODELESS_LOCK" & +} + +poll_clamshell_state() { + while true; do + sleep 2 + sync_clamshell + done +} + +# The internal panel is only ever disabled while a laptop is docked (lid shut +# with an external monitor active), so the reconciliation poll only has anything +# to reconcile in that window. Run it while docked, stop it when undocked, and +# never on a machine without a lid. Lid open/close itself is handled by the +# Hyprland "switch:*:Lid Switch" binds; this poll is the recovery backstop for +# drift those binds can miss (e.g. across suspend/resume). +poll_pid="" +sync_poll_state() { + if blob-hw-laptop && blob-hypr-monitor-external; then + if [[ -z $poll_pid ]] || ! kill -0 "$poll_pid" 2>/dev/null; then + poll_clamshell_state & + poll_pid=$! + fi + elif [[ -n $poll_pid ]]; then + kill "$poll_pid" 2>/dev/null + poll_pid="" + fi +} + +sync_clamshell_after_monitor_change +sync_poll_state +recover_modeless + +# Process substitution (not a pipe) keeps this loop in the main shell, so +# sync_poll_state can start and stop the background poll as monitors come and go. +while read -r event; do + case "$event" in + monitoradded\>\>*|monitoraddedv2\>\>*) + sync_clamshell_after_monitor_change + sync_poll_state + recover_modeless + ;; + monitorremoved\>\>*|monitorremovedv2\>\>*) + sync_clamshell_after_monitor_change + sync_poll_state + recover_modeless + ;; + # A reload while the monitor is unpowered leaves it at 0x0 with no hotplug + # to notice, same as at boot. Our own recovery reload lands here too, and is + # a no-op while its loop still holds the pid. + configreloaded\>\>*) + recover_modeless + ;; + esac +done < <(socat -U - "UNIX-CONNECT:$SOCKET") diff --git a/bin/blob-hypr-reload-guard b/bin/blob-hypr-reload-guard new file mode 100755 index 0000000..2c9c536 --- /dev/null +++ b/bin/blob-hypr-reload-guard @@ -0,0 +1,115 @@ +#!/bin/bash + +# blob:summary=Pause or resume Hyprland config auto-reload around package transactions. +# blob:hidden=true + +set -euo pipefail + +command="${1:-}" +case "$command" in + pause | resume | paused) ;; + *) + echo "Usage: blob-hypr-reload-guard pause|resume|paused" >&2 + exit 1 + ;; +esac + +run_root="${BLOB_HYPRLAND_RELOAD_GUARD_RUN_ROOT:-/run/user}" +state_dir="${BLOB_HYPRLAND_RELOAD_GUARD_STATE_DIR:-/run/blob/hyprland-reload-guard}" +hyprctl_bin="${HYPRCTL:-/usr/bin/hyprctl}" + +hyprctl_instance() { + local runtime_dir="$1" + local signature="$2" + shift 2 + + XDG_RUNTIME_DIR="$runtime_dir" "$hyprctl_bin" --instance "$signature" "$@" +} + +option_bool() { + local runtime_dir="$1" + local signature="$2" + local option="$3" + + # A dead instance makes hyprctl print "Couldn't connect ..." on stdout, so + # silence jq too and let the failed pipeline skip the instance. + hyprctl_instance "$runtime_dir" "$signature" -j getoption "$option" 2>/dev/null | jq -r '.bool' 2>/dev/null +} + +instances() { + local instance_dir signature hypr_dir runtime_dir uid + + [[ -d $run_root ]] || return 0 + + for instance_dir in "$run_root"/*/hypr/*; do + [[ -d $instance_dir ]] || continue + + signature="${instance_dir##*/}" + hypr_dir="${instance_dir%/*}" + runtime_dir="${hypr_dir%/*}" + uid="${runtime_dir##*/}" + + [[ $uid =~ ^[0-9]+$ ]] || continue + + printf '%s\t%s\n' "$runtime_dir" "$signature" + done +} + +pause_instance() { + local runtime_dir="$1" + local signature="$2" + local disable_autoreload suppress_errors state_file="$state_dir/$signature" + + mkdir -p "$state_dir" + + if [[ ! -f $state_file ]]; then + disable_autoreload=$(option_bool "$runtime_dir" "$signature" misc.disable_autoreload) || return 0 + suppress_errors=$(option_bool "$runtime_dir" "$signature" debug.suppress_errors) || return 0 + printf '%s\t%s\t%s\n' "$runtime_dir" "$disable_autoreload" "$suppress_errors" >"$state_file" + fi + + hyprctl_instance "$runtime_dir" "$signature" eval \ + 'hl.config({ misc = { disable_autoreload = true }, debug = { suppress_errors = true } })' \ + >/dev/null 2>&1 || true +} + +resume_instance() { + local state_file="$1" + local signature="${state_file##*/}" + local runtime_dir disable_autoreload suppress_errors + + IFS=$'\t' read -r runtime_dir disable_autoreload suppress_errors <"$state_file" || return 0 + + if [[ -d $runtime_dir ]]; then + hyprctl_instance "$runtime_dir" "$signature" eval \ + "hl.config({ debug = { suppress_errors = $suppress_errors } })" \ + >/dev/null 2>&1 || true + + hyprctl_instance "$runtime_dir" "$signature" reload >/dev/null 2>&1 || true + + hyprctl_instance "$runtime_dir" "$signature" eval \ + "hl.config({ misc = { disable_autoreload = $disable_autoreload }, debug = { suppress_errors = $suppress_errors } })" \ + >/dev/null 2>&1 || true + fi + + rm -f "$state_file" +} + +case "$command" in + paused) + compgen -G "$state_dir/*" >/dev/null + ;; + pause) + while IFS=$'\t' read -r runtime_dir signature; do + pause_instance "$runtime_dir" "$signature" + done < <(instances) + ;; + resume) + [[ -d $state_dir ]] || exit 0 + for state_file in "$state_dir"/*; do + [[ -f $state_file ]] || continue + resume_instance "$state_file" + done + rmdir "$state_dir" 2>/dev/null || true + ;; +esac diff --git a/bin/blob-hypr-window-gaps-toggle b/bin/blob-hypr-window-gaps-toggle new file mode 100755 index 0000000..02b0008 --- /dev/null +++ b/bin/blob-hypr-window-gaps-toggle @@ -0,0 +1,5 @@ +#!/bin/bash + +# blob:summary=Toggles the window gaps globally between no gaps and the default. + +blob-hypr-toggle window-no-gaps diff --git a/bin/blob-hypr-window-pop b/bin/blob-hypr-window-pop new file mode 100755 index 0000000..7ba1c45 --- /dev/null +++ b/bin/blob-hypr-window-pop @@ -0,0 +1,40 @@ +#!/bin/bash + +# blob:summary=Toggle to pop-out a tile to stay fixed on a display basis. +# blob:args=[width height x y] + +width=${1:-1300} +height=${2:-900} +x=${3:-} +y=${4:-} + +active=$(hyprctl activewindow -j) +pinned=$(echo "$active" | jq ".pinned") +addr=$(echo "$active" | jq -r ".address") +window="address:$addr" + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +if [[ $pinned == "true" ]]; then + hypr_dispatch "hl.dsp.window.pin({ window = \"$window\" })" pin "$window" + hypr_dispatch "hl.dsp.window.float({ window = \"$window\", action = \"toggle\" })" togglefloating "$window" + hypr_dispatch "hl.dsp.window.tag({ window = \"$window\", tag = \"-pop\" })" tagwindow -pop "$window" +elif [[ -n $addr ]]; then + hypr_dispatch "hl.dsp.window.float({ window = \"$window\", action = \"toggle\" })" togglefloating "$window" + hypr_dispatch "hl.dsp.window.resize({ window = \"$window\", x = $width, y = $height })" resizeactive exact "$width" "$height" "$window" + + if [[ -n $x && -n $y ]]; then + hypr_dispatch "hl.dsp.window.move({ window = \"$window\", x = $x, y = $y })" moveactive "$x" "$y" "$window" + else + hypr_dispatch "hl.dsp.window.center({ window = \"$window\" })" centerwindow "$window" + fi + + hypr_dispatch "hl.dsp.window.pin({ window = \"$window\" })" pin "$window" + hypr_dispatch "hl.dsp.window.alter_zorder({ window = \"$window\", mode = \"top\" })" alterzorder top "$window" + hypr_dispatch "hl.dsp.window.tag({ window = \"$window\", tag = \"+pop\" })" tagwindow +pop "$window" +fi diff --git a/bin/blob-hypr-window-single-square-aspect-toggle b/bin/blob-hypr-window-single-square-aspect-toggle new file mode 100755 index 0000000..908ff32 --- /dev/null +++ b/bin/blob-hypr-window-single-square-aspect-toggle @@ -0,0 +1,8 @@ +#!/bin/bash + +# blob:summary=Toggle single-window square aspect ratio. + +case $(blob-hypr-toggle single-window-aspect-ratio) in + on) blob-notify-send -g  "Enable single-window square aspect ratio" ;; + off) blob-notify-send -g  "Disable single-window square aspect ratio" ;; +esac diff --git a/bin/blob-hypr-window-tiled-fullscreen-toggle b/bin/blob-hypr-window-tiled-fullscreen-toggle new file mode 100755 index 0000000..86f57ba --- /dev/null +++ b/bin/blob-hypr-window-tiled-fullscreen-toggle @@ -0,0 +1,16 @@ +#!/bin/bash + +# blob:summary=Toggle tiled fullscreen for the focused Hyprland window + +set -euo pipefail + +active=$(hyprctl activewindow -j) +fullscreen_client=$(jq -r '.fullscreenClient // 0' <<<"$active") + +if [[ $fullscreen_client == "2" ]]; then + hyprctl dispatch 'hl.dsp.window.fullscreen_state({ internal = 0, client = 0 })' >/dev/null 2>&1 || \ + hyprctl dispatch fullscreenstate 0 0 >/dev/null +else + hyprctl dispatch 'hl.dsp.window.fullscreen_state({ internal = 0, client = 2 })' >/dev/null 2>&1 || \ + hyprctl dispatch fullscreenstate 0 2 >/dev/null +fi diff --git a/bin/blob-hypr-window-transparency-toggle b/bin/blob-hypr-window-transparency-toggle new file mode 100755 index 0000000..4964a4e --- /dev/null +++ b/bin/blob-hypr-window-transparency-toggle @@ -0,0 +1,7 @@ +#!/bin/bash + +# blob:summary=Toggles transparency for the currently focused window. + +addr=$(hyprctl activewindow -j | jq -r '.address') +hyprctl dispatch "hl.dsp.window.set_prop({ window = \"address:$addr\", prop = \"opaque\", value = \"toggle\" })" >/dev/null 2>&1 || \ + hyprctl dispatch setprop "address:$addr" opaque toggle diff --git a/bin/blob-hypr-window-width b/bin/blob-hypr-window-width new file mode 100755 index 0000000..b4624cb --- /dev/null +++ b/bin/blob-hypr-window-width @@ -0,0 +1,168 @@ +#!/bin/bash + +# blob:summary=Save or restore the focused Hyprland window width +# blob:args= +# blob:examples=blob hyprland window width save | blob-hypr-window-width restore + +set -euo pipefail + +STATE_DIR="$HOME/.local/state/blob/windows" + +usage() { + echo "Usage: blob-hypr-window-width save|restore" >&2 + exit 1 +} + +active_window() { + hyprctl activewindow -j 2>/dev/null +} + +window_key() { + jq -r '[.class, .initialClass, .title] | map(select(. != null and . != "")) | first // empty' <<<"$1" +} + +workspace_key() { + jq -r '.workspace.id // .workspace.name // empty' <<<"$1" +} + +state_file_for() { + local key="$1" + local workspace="$2" + local filename="workspace-${workspace}-${key}" + + filename="${filename//\//_}" + filename="${filename//$'\n'/_}" + + printf '%s/%s.width' "$STATE_DIR" "$filename" +} + +notify_missing_width() { + local key="$1" + local workspace="$2" + + blob-notify-send -g  "No saved width found for $key on workspace $workspace" "Use Super + Alt + Home to save one for this workspace." +} + +notify_saved_width() { + local key="$1" + local workspace="$2" + + blob-notify-send -g  "Saved width for $key on workspace $workspace" "Restore using Super + Home on this workspace." +} + +hypr_dispatch() { + local lua="$1" + shift + + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +window_width() { + local address="$1" + + hyprctl clients -j | jq -er --arg address "$address" '.[] | select(.address == $address) | .size[0]' +} + +resize_width_by() { + local window="$1" + local delta="$2" + + hypr_dispatch "hl.dsp.window.resize({ window = \"$window\", x = $delta, y = 0, relative = true })" resizeactive "$delta" 0 "$window" +} + +save_width() { + local active="$1" + local key="$2" + local workspace="$3" + local state_file="$4" + local tmp="" + local width="" + + width=$(jq -er '.size[0]' <<<"$active") + + mkdir -p "$STATE_DIR" + tmp=$(mktemp "$STATE_DIR/.width.XXXXXX") + printf '%s\n' "$width" >"$tmp" + mv "$tmp" "$state_file" + + notify_saved_width "$key" "$workspace" + echo "Saved width for $key on workspace $workspace" +} + +restore_width() { + local active="$1" + local key="$2" + local workspace="$3" + local state_file="$4" + local address="" + local current_width="" + local delta="" + local direction="" + local next_width="" + local probe="" + local probe_delta="" + local width="" + local window="" + + if [[ ! -f $state_file ]]; then + notify_missing_width "$key" "$workspace" + exit 1 + fi + + width=$(<"$state_file") + [[ $width =~ ^[0-9]+$ ]] || exit 1 + + address=$(jq -r '.address // empty' <<<"$active") + [[ -n $address ]] || exit 1 + + window="address:$address" + + current_width=$(window_width "$address") + ((current_width == width)) && return + + for probe in 10 -10; do + resize_width_by "$window" "$probe" + next_width=$(window_width "$address") + + if ((next_width != current_width)); then + if (((next_width - current_width) * probe > 0)); then + direction=1 + else + direction=-1 + fi + + current_width=$next_width + break + fi + done + + [[ -n $direction ]] || exit 1 + + for _ in {1..6}; do + delta=$((width - current_width)) + ((delta == 0)) && break + + probe_delta=$((delta * direction)) + resize_width_by "$window" "$probe_delta" + next_width=$(window_width "$address") + + ((next_width == current_width)) && break + current_width=$next_width + done +} + +action=${1:-} +[[ $action == "save" || $action == "restore" ]] || usage + +active=$(active_window) +key=$(window_key "$active") +[[ -n $key ]] || exit 1 +workspace=$(workspace_key "$active") +[[ -n $workspace ]] || exit 1 + +state_file=$(state_file_for "$key" "$workspace") + +case "$action" in +save) save_width "$active" "$key" "$workspace" "$state_file" ;; +restore) restore_width "$active" "$key" "$workspace" "$state_file" ;; +esac diff --git a/bin/blob-hypr-workspace-layout-toggle b/bin/blob-hypr-workspace-layout-toggle new file mode 100755 index 0000000..5a7aa64 --- /dev/null +++ b/bin/blob-hypr-workspace-layout-toggle @@ -0,0 +1,21 @@ +#!/bin/bash + +# blob:summary=Toggle the layout on the current active workspace between dwindle and scrolling + +ACTIVE_WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id') +[[ $ACTIVE_WORKSPACE =~ ^-?[0-9]+$ ]] || exit 1 +CURRENT_LAYOUT=$(hyprctl activeworkspace -j | jq -r '.tiledLayout') +LAYOUTS_DIR="$HOME/.local/state/blob/workspace-layouts" +LAYOUT_FILE="$LAYOUTS_DIR/$ACTIVE_WORKSPACE.lua" + +case "$CURRENT_LAYOUT" in + dwindle) NEW_LAYOUT=scrolling ;; + *) NEW_LAYOUT=dwindle ;; +esac + +mkdir -p "$LAYOUTS_DIR" +printf 'hl.workspace_rule({ workspace = "%s", layout = "%s" })\n' "$ACTIVE_WORKSPACE" "$NEW_LAYOUT" >"$LAYOUT_FILE" + +hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \ + hyprctl keyword workspace "$ACTIVE_WORKSPACE, layout:$NEW_LAYOUT" +blob-notify-send -g 󱂬 "Workspace layout set to $NEW_LAYOUT" diff --git a/bin/blob-install-font b/bin/blob-install-font new file mode 100755 index 0000000..5e59d73 --- /dev/null +++ b/bin/blob-install-font @@ -0,0 +1,21 @@ +#!/bin/bash + +# blob:summary=Install a Nerd Font package and switch the system to it +# blob:args= +# blob:examples=blob install font 'Cascadia Mono' ttf-cascadia-mono-nerd 'CaskaydiaMono Nerd Font' + +name="${1-}" +package="${2-}" +family="${3-}" + +if [[ -z $name || -z $package || -z $family ]]; then + echo "Usage: blob-install-font " >&2 + exit 1 +fi + +printf -v install_message '%q' "Installing ${name}..." +printf -v package_arg '%q' "$package" +printf -v family_arg '%q' "$family" + +exec blob-launch-floating \ + "echo ${install_message}; blob-pkg-add ${package_arg} && sleep 2 && blob-font-set ${family_arg}" diff --git a/bin/blob-launch-about b/bin/blob-launch-about new file mode 100755 index 0000000..50c3148 --- /dev/null +++ b/bin/blob-launch-about @@ -0,0 +1,181 @@ +#!/bin/bash + +# blob:summary=Launch the fastfetch TUI that gives information about the current system. + +# The size that hugs the About content depends on the terminal font and the user's +# logo, so it can only be measured from inside the terminal. We remember the size +# that fit and apply it as a window rule before launching, so the window opens at +# it instead of resizing after the first paint. Bash defers WINCH traps while read +# blocks, so poll for size changes and re-render fastfetch whenever it is resized. + +LOGO_FILE="$HOME/.config/blob/branding/about.txt" +FIT_FILE="$HOME/.local/state/blob/windows/about.fit" + +# A user-level fastfetch config can relocate or restyle the logo in ways this +# measurement cannot see, so leave sizing to the float rule in that case. +custom_fastfetch_config() { + [[ -f $HOME/.config/fastfetch/config.jsonc ]] +} + +# wc -L counts display columns only in a UTF-8 locale. A session that never set +# one counts every box-drawing and Nerd Font glyph in the About layout as +# nothing, which measures the content narrower than it renders. +display_columns() { + LC_ALL=C.UTF-8 wc -L +} + +logo_dimensions() { + [[ -f $LOGO_FILE ]] || return 1 + printf '%s %s' "$(display_columns <"$LOGO_FILE")" "$(wc -l <"$LOGO_FILE")" +} + +hypr_dispatch() { + local lua="$1" + shift + hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null +} + +remember_fit() { + local directory tmp + directory=$(dirname "$FIT_FILE") + mkdir -p "$directory" + tmp=$(mktemp "$directory/.about.fit.XXXXXX") + printf '%s %s %s %s\n' "$1" "$2" "$3" "$4" >"$tmp" + mv "$tmp" "$FIT_FILE" +} + +# Replaces the rule from the last launch so a remembered size never outlives the +# fit it came from. Called without one it only clears, leaving the float rule's +# starting size — where a Hyprland without the Lua API stays too. +apply_size_rule() { + local rule="" + (( $# == 2 )) && rule="blob_about_size_rule = hl.window_rule({ match = { class = \"org.blob.about\" }, size = { $1, $2 } })" + + hyprctl eval "if blob_about_size_rule then blob_about_size_rule:set_enabled(false) end; blob_about_size_rule = nil; $rule" >/dev/null 2>&1 +} + +# Sized before the terminal is spawned, so the window maps at its final size. +presize_window() { + local logo_w logo_h fit_logo_w fit_logo_h fit_w fit_h + + if ! custom_fastfetch_config && [[ -r $FIT_FILE ]]; then + read -r logo_w logo_h <<<"$(logo_dimensions)" + read -r fit_logo_w fit_logo_h fit_w fit_h <"$FIT_FILE" + + # A different logo needs a different window, so leave that launch to the + # float rule and let the fit measure the new size. + if [[ -n ${logo_w:-} && $logo_w == "$fit_logo_w" && $logo_h == "$fit_logo_h" ]] && + [[ $fit_w =~ ^[0-9]+$ && $fit_h =~ ^[0-9]+$ ]]; then + apply_size_rule "$fit_w" "$fit_h" + return + fi + fi + + apply_size_rule +} + +# Hyprland animates a resize and the terminal reflows to every step of it, so +# wait for the grid to hold still before measuring it. +settle_grid() { + local current previous="" held=0 + + for _ in {1..20}; do + current=$(stty size) + if [[ $current == $previous ]]; then + (( ++held == 3 )) && break + else + held=0 + previous=$current + fi + sleep 0.05 + done +} + +fit_window() { + custom_fastfetch_config && return 0 + + local logo_w logo_h + read -r logo_w logo_h <<<"$(logo_dimensions)" + [[ -n ${logo_w:-} ]] || return 1 + + # The guard character keeps command substitution from eating the trailing + # break line, which provides the bottom padding row. + local modules module_w module_h + modules=$(fastfetch --logo none | sed 's/\x1b\[[0-9;?]*[a-zA-Z]//g'; printf X) + modules=${modules%X} + module_w=$(printf '%s' "$modules" | display_columns) + module_h=$(printf '%s' "$modules" | wc -l) + + # Mirror the logo block in the fastfetch config: 2 columns of padding left of + # the logo, 6 between logo and modules, 2 rows above it. Then 2 columns of + # right padding to match, and a row for the cursor so the trailing break shows. + local target_c=$(( 2 + logo_w + 6 + module_w + 2 )) + local target_r=$(( (logo_h + 2 > module_h ? logo_h + 2 : module_h) + 1 )) + + local nudges=0 rows cols address width height shift_w shift_h target_w target_h + while :; do + read -r rows cols <<<"$(stty size)" + read -r address width height <<<"$(hyprctl clients -j | jq -r '.[] | select(.class == "org.blob.about") | "\(.address) \(.size[0]) \(.size[1])"')" + [[ -n ${address:-} ]] || return 1 + (( cols > 0 && rows > 0 && width > 0 && height > 0 )) || return 1 + + # A window has to land on the terminal's cell boundaries, so take a cell of + # slack over chasing an exact grid, and remember where it came to rest. + if (( cols >= target_c && cols <= target_c + 1 && rows >= target_r && rows <= target_r + 1 )); then + remember_fit "$logo_w" "$logo_h" "$width" "$height" + return 0 + fi + + # Two nudges is the budget, and each one is measured before the next is spent. + (( ++nudges <= 2 )) || return 1 + + # Move by the cells the window is off by, rather than scaling it to the grid, + # which would multiply up the terminal's padding along with them. Dividing a + # window that carries that padding still leaves the cell a touch generous, so + # round the move away from the grid that would clip. + shift_w=$(( (target_c - cols) * width )) + shift_h=$(( (target_r - rows) * height )) + target_w=$(( width + (shift_w >= 0 ? (shift_w + cols - 1) / cols : shift_w / cols) )) + target_h=$(( height + (shift_h >= 0 ? (shift_h + rows - 1) / rows : shift_h / rows) )) + + hypr_dispatch "hl.dsp.window.resize({ window = \"address:$address\", x = $target_w, y = $target_h })" resizewindowpixel "exact $target_w $target_h,address:$address" + hypr_dispatch "hl.dsp.window.center({ window = \"address:$address\" })" centerwindow + settle_grid + done + + return 1 +} + +if [[ ${1:-} == "--render" ]]; then + printf '\e[?25l' + + # Give the compositor a moment to apply the window rules before measuring cells. + settle_grid + + fitted=false + passes=0 + while :; do + size=$(stty size) + logo_stamp=$(stat -c %Y "$LOGO_FILE" 2>/dev/null) + clear + fastfetch + # A second pass picks up a fit that could not measure the window the first + # time. Beyond that, a window that will not settle would be fitted again on + # every repaint. + if [[ $fitted == false ]] && (( ++passes <= 2 )); then + fit_window && fitted=true + fi + while [[ $(stty size) == $size && $(stat -c %Y "$LOGO_FILE" 2>/dev/null) == $logo_stamp ]]; do + read -t 0.5 -n 1 -s && exit + (( $? > 128 )) || exit + done + # A rebranded logo changes the content dimensions, so measure again. + if [[ $(stat -c %Y "$LOGO_FILE" 2>/dev/null) != $logo_stamp ]]; then + fitted=false + passes=0 + fi + done +fi + +presize_window +exec blob-launch-or-focus-tui --app-id=org.blob.about blob-launch-about --render diff --git a/bin/blob-launch-config-editor b/bin/blob-launch-config-editor new file mode 100755 index 0000000..f18f131 --- /dev/null +++ b/bin/blob-launch-config-editor @@ -0,0 +1,15 @@ +#!/bin/bash + +# blob:summary=Open a config file in the user's editor and surface a toast +# blob:args= +# blob:examples=blob launch config-editor ~/.config/hypr/hyprland.lua + +path="${1-}" + +if [[ -z $path ]]; then + echo "Usage: blob-launch-config-editor " >&2 + exit 1 +fi + +blob-notify-send -u low "Editing config file" "$path" +exec blob-launch-editor "$path" diff --git a/bin/blob-launch-docker-tui b/bin/blob-launch-docker-tui new file mode 100755 index 0000000..91dc685 --- /dev/null +++ b/bin/blob-launch-docker-tui @@ -0,0 +1,20 @@ +#!/bin/bash + +# blob:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon +# blob:hidden=true + +# By default the install user is NOT in the docker group: membership is +# root-equivalent (a container can bind-mount / and rewrite the host as root), +# so a single process running as the user could otherwise escalate to root with +# no prompt. lazydocker needs the root-owned Docker socket, so when the group is +# absent, gate that access behind a polkit prompt. If the user has opted into +# sudoless Docker (blob-setup-security-sudoless-docker), the socket is already +# reachable, so run lazydocker directly — blob-sudo-docker answers that for +# this session, so the prompt stays until the reboot that grants the group. +# pkexec sanitizes the environment, so carry TERM through for the TUI to render +# and run lazydocker from root's PATH. +if blob-sudo-docker; then + exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker +else + exec lazydocker +fi diff --git a/bin/blob-launch-or-focus b/bin/blob-launch-or-focus new file mode 100755 index 0000000..63393ae --- /dev/null +++ b/bin/blob-launch-or-focus @@ -0,0 +1,19 @@ +#!/bin/bash + +# blob:summary=Launch an app or focus an existing window matching a pattern +# blob:args= + +if (($# == 0)); then + echo "Usage: blob-launch-or-focus [window-pattern] [launch-command]" + exit 1 +fi + +WINDOW_PATTERN="$1" +LAUNCH_COMMAND="${2:-"uwsm-app -- $WINDOW_PATTERN"}" +WINDOW_ADDRESS=$(hyprctl clients -j | jq -r --arg p "$WINDOW_PATTERN" '.[]|select((.class|test("\\b" + $p + "\\b";"i")) or (.title|test("\\b" + $p + "\\b";"i")))|.address' | head -n1) + +if [[ -n $WINDOW_ADDRESS ]]; then + hyprctl dispatch "hl.dsp.focus({ window = \"address:$WINDOW_ADDRESS\" })" >/dev/null 2>&1 || hyprctl dispatch focuswindow "address:$WINDOW_ADDRESS" +else + eval exec setsid $LAUNCH_COMMAND +fi diff --git a/bin/blob-launch-or-focus-tui b/bin/blob-launch-or-focus-tui new file mode 100755 index 0000000..7336a73 --- /dev/null +++ b/bin/blob-launch-or-focus-tui @@ -0,0 +1,14 @@ +#!/bin/bash + +# blob:summary=Launch a TUI or focus an existing terminal window for it +# blob:args=[--app-id=] [args...] + +if [[ ${1:-} == --app-id=* ]]; then + APP_ID="${1#--app-id=}" +else + APP_ID="org.blob.$(basename "$1")" +fi + +LAUNCH_COMMAND="blob-launch-tui $@" + +exec blob-launch-or-focus "$APP_ID" "$LAUNCH_COMMAND" diff --git a/bin/blob-launch-screensaver b/bin/blob-launch-screensaver new file mode 100755 index 0000000..51a03f1 --- /dev/null +++ b/bin/blob-launch-screensaver @@ -0,0 +1,77 @@ +#!/bin/bash + +# blob:summary=Launch the Blob screensaver in the default terminal on the system with the correct font configuration. + +if blob-cmd-missing ttfx; then + exit 1 +fi + +# Exit early if screensaver is already running +pgrep -f '[o]rg.blob.screensaver' && exit 0 + +# Allow screensaver to be turned off but also force started +if blob-toggle-enabled screensaver-off && [[ $1 != "force" ]]; then + exit 1 +fi + +focused=$(blob-hypr-monitor-focused) +terminal=$(xdg-terminal-exec --print-id) + +case $terminal in +*Alacritty* | *ghostty* | *foot* | *kitty*) ;; +*) + blob-notify-send -g ✋ "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + exit 1 + ;; +esac + +hypr_focus_monitor() { + hyprctl dispatch "hl.dsp.focus({ monitor = \"$1\" })" >/dev/null 2>&1 || hyprctl dispatch focusmonitor "$1" >/dev/null +} + +hypr_exec() { + local command + printf -v command '%q ' "$@" + + hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null +} + +SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + +# Open Hyprland's event stream before spawning anything, so a terminal that maps +# quickly can't emit its openwindow event before we are listening for it. +exec {events}< <(socat -U - "UNIX-CONNECT:$SOCKET") + +# hypr_exec is async and a new window maps on whatever monitor is focused at that +# moment. Block until this monitor's screensaver actually opens before moving +# focus on -- otherwise slow-starting terminals all pile onto the last monitor. +# The deadline is a safety net in case the window never appears. +wait_for_screensaver_window() { + local line deadline=$((SECONDS + 5)) + while ((SECONDS < deadline)) && IFS= read -r -t $((deadline - SECONDS)) -u "$events" line; do + [[ $line == openwindow\>\>*,org.blob.screensaver,* ]] && return 0 + done +} + +for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do + hypr_focus_monitor "$m" + + case $terminal in + *Alacritty*) + hypr_exec alacritty --class=org.blob.screensaver --config-file "$BLOB_PATH/default/alacritty/screensaver.toml" -e blob-screensaver + ;; + *ghostty*) + hypr_exec ghostty --class=org.blob.screensaver --config-file="$BLOB_PATH/default/ghostty/screensaver" --font-size=18 -e blob-screensaver + ;; + *foot*) + hypr_exec foot --app-id=org.blob.screensaver --config="$BLOB_PATH/default/foot/screensaver.ini" -e blob-screensaver + ;; + *kitty*) + hypr_exec kitty --class=org.blob.screensaver --override font_size=18 --override window_padding_width=0 -e blob-screensaver + ;; + esac + + wait_for_screensaver_window +done + +hypr_focus_monitor "$focused" diff --git a/bin/blob-launcher-remove b/bin/blob-launcher-remove index db71058..676fe78 100755 --- a/bin/blob-launcher-remove +++ b/bin/blob-launcher-remove @@ -67,11 +67,6 @@ desktop_name="${desktop_file##*/}" desktop_name="${desktop_name%.desktop}" exec_line="$(sed -n 's/^Exec=//p' "$desktop_file" | head -1)" -if [[ $exec_line =~ blob-launch-webapp|blob-webapp-handler ]]; then - BLOB_REMOVE_NOTIFY=false blob-webapp-remove "$desktop_name" - exit 0 -fi - if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name" exit 0 diff --git a/bin/blob-menu-emoji b/bin/blob-menu-emoji new file mode 100755 index 0000000..a24a935 --- /dev/null +++ b/bin/blob-menu-emoji @@ -0,0 +1,6 @@ +#!/bin/bash +# blob:summary=Launch emojis +# blob:group=menu +# blob:examples=blob menu emoji + +blob-shell shell toggle blob.emojis diff --git a/bin/blob-menu-emoji-insert b/bin/blob-menu-emoji-insert new file mode 100755 index 0000000..901fa2a --- /dev/null +++ b/bin/blob-menu-emoji-insert @@ -0,0 +1,20 @@ +#!/bin/bash + +# blob:summary=Insert an emoji into the focused application +# blob:group=menu +# blob:args= +# blob:hidden=true + +emoji="${1:-}" +copy_pid="" + +[[ -n $emoji ]] || exit + +printf '%s' "$emoji" | wl-copy --type text/plain --sensitive --foreground & +copy_pid=$! + +sleep 0.15 +wtype -M shift -k Insert -m shift 2>/dev/null || true +sleep 0.2 + +kill "$copy_pid" 2>/dev/null || true diff --git a/bin/blob-menu-file b/bin/blob-menu-file new file mode 100755 index 0000000..2761741 --- /dev/null +++ b/bin/blob-menu-file @@ -0,0 +1,48 @@ +#!/bin/bash + +# blob:summary=Pick a file from a menu +# blob:group=menu +# blob:name=file +# blob:args=label paths formats [menu args...] +# blob:examples=blob menu file "Select image" "$HOME/Pictures" "jpg png webp"|blob-menu-file "Select media" "$HOME/Pictures:$HOME/Videos" "jpg png mp4 mov" --width 800 + +set -euo pipefail + +if (( $# < 3 )); then + echo "Usage: blob-menu-file