Vendor the command set the menu and shell depend on

This commit is contained in:
2026-09-19 23:57:12 -04:00
parent 9501f2bb4d
commit c5434026a8
179 changed files with 12414 additions and 54 deletions
+54
View File
@@ -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()
}
'
+13
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# blob:summary=Set the default audio input and move active streams
# blob:args=<node-id> <source-name>
# 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 <node-id> <source-name>" >&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
+225
View File
@@ -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
+55
View File
@@ -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"
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# blob:summary=Set the default audio output and move active streams
# blob:args=<node-id> <sink-name>
# 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 <node-id> <sink-name>" >&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
+59
View File
@@ -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"
+20
View File
@@ -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
+357
View File
@@ -0,0 +1,357 @@
#!/bin/bash
# blob:summary=Manage the speaker tuning for this laptop
# blob:args=<on|off|status|match|fronted-sink> [--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 <on|off|status|match|fronted-sink> [--force]" >&2
exit 2
;;
esac
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# blob:summary=Adjust output volume and show the Blob OSD
# blob:args=<raise|lower|mute-toggle|+N|-N>
# 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 <raise|lower|mute-toggle|+N|-N>"
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}"
Executable
+403
View File
@@ -0,0 +1,403 @@
#!/bin/bash
# blob:summary=Configure the bar and its widget layout
# blob:group=bar
# blob:args=use <id> | reset | defaults | position <top|bottom|left|right> | transparent <true|false|toggle> | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--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 <<USAGE
Usage: blob bar <command> [args...]
use <id> 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 <top|bottom|left|right> Bar position
transparent <true|false|toggle> Bar transparency
put <id> [placement] Put a widget on the bar, leaving one
that is already there where it is
move <id> [placement] Move a widget within or between sections
set <id> <key> <value> [--json] [placement]
Set a per-widget option
Placement:
--section <left|center|right> Target section
--index <n> Target index
--before <id> Insert before a widget
--after <id> Insert after a widget
--from-section <section> Source section
--from-index <n> 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
+125
View File
@@ -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"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# blob:summary=Send the low battery warning notification and run battery-low hooks.
# blob:args=<percentage>
# blob:hidden=true
set -euo pipefail
if (($# != 1)); then
echo "Usage: blob-battery-low <percentage>" >&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"
+137
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# blob:summary=Control a Bluetooth device
# blob:group=bluetooth
# blob:args=[pair|connect|disconnect|forget] <address>
# blob:examples=blob bluetooth device connect 00:11:22:33:44:55
set -e
usage() {
echo "Usage: blob-bluetooth-device [pair|connect|disconnect|forget] <address>" >&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
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# blob:summary=Turn Bluetooth on or off, remembered across reboots
# blob:group=bluetooth
# blob:args=<on|off|toggle|is-on>
# 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 <on|off|toggle|is-on>" >&2
exit 1
;;
esac
+7
View File
@@ -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
Executable
+640
View File
@@ -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=$(</etc/machine-id)
esp_uuid=$(findmnt -no UUID /boot)
backup=/root/limine-to-grub-$(date +%Y%m%d-%H%M%S)
log "Backing up the current boot configuration to $backup"
sudo mkdir -p "$backup"
for f in /etc/default/grub /etc/mkinitcpio.d/linux.preset /etc/mkinitcpio.conf \
/boot/limine.conf /etc/mkinitcpio.conf.d /etc/grub.d; do
[ -e "$f" ] && sudo cp -a "$f" "$backup/" 2>/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 <<EOF
#!/bin/sh
exec tail -n +3 \$0
# Entries below are added to the end of the GRUB menu.
# os-prober does not reliably detect Windows when its EFI System Partition is
# the same one mounted at /boot, so chainload the Windows boot manager directly.
menuentry "Windows Boot Manager" --class windows --class os {
insmod part_gpt
insmod fat
insmod chain
search --no-floppy --fs-uuid --set=root ${esp_uuid}
chainloader /EFI/Microsoft/Boot/bootmgfw.efi
}
EOF
sudo chmod +x /etc/grub.d/40_custom
sudo grub-mkconfig -o /boot/grub/grub.cfg
fi
verify_menu
}
# Refuse to leave the machine with a menu that cannot boot it.
verify_menu() {
local entries
entries=$(grep -cE "^\s*menuentry " /boot/grub/grub.cfg || true)
grep -qE "^\s*menuentry .*(Arch|Linux)" /boot/grub/grub.cfg \
|| die "grub.cfg contains no Arch entry. Check that /etc/grub.d/10_linux is executable and that /boot/vmlinuz-linux and /boot/initramfs-linux.img both exist. Do NOT reboot into GRUB until this is fixed."
grep -q "initramfs-linux.img" /boot/grub/grub.cfg \
|| warn "no initramfs referenced in grub.cfg - check the Arch entry by hand"
info "$entries menu entries generated"
}
list_menu_entries() {
grep -E "^\s*(menuentry|submenu) '" /boot/grub/grub.cfg \
| sed -E "s/^[[:space:]]*(menuentry|submenu) '([^']*)'.*/ - \2/"
}
detect_os() {
using_grub || die "GRUB is not installed yet. Run: $0 grub"
require_sudo
generate_menu
echo
info "GRUB menu entries:"
list_menu_entries
ok "OS detection complete."
}
# --------------------------------------------------------------------------
# Boot-time failures
# --------------------------------------------------------------------------
# Two units fail on every boot on this machine, both left over from a btrfs
# layout that no longer exists - the root filesystem is ext4.
fix_failing_boot_units() {
log "Clearing boot-time unit failures"
# fstab still lists a btrfs hibernation swapfile that was never created.
# zram provides this machine's swap.
if grep -q "^/swap/swapfile" /etc/fstab && [ ! -f /swap/swapfile ]; then
sudo cp /etc/fstab /etc/fstab.bak
sudo sed -i '/^# Btrfs swapfile for system hibernation$/d; /^\/swap\/swapfile/d' /etc/fstab
sudo systemctl daemon-reload
info "removed the missing /swap/swapfile entry from fstab (backup: /etc/fstab.bak)"
fi
# snapper only manages btrfs subvolumes; on ext4 its timers fail nightly.
if systemctl list-unit-files snapper-cleanup.timer &>/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* GRUB<TAB>HD(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
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# blob:summary=Edit, set, or reset About branding
# blob:group=branding
# blob:name=about
# blob:args=<image|text|reset>
# 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 <image|text|reset>" >&2
exit 1
;;
esac
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# blob:summary=Edit, set, or reset screensaver branding
# blob:group=branding
# blob:name=screensaver
# blob:args=<image|text|reset>
# 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 <image|text|reset>" >&2
exit 1
;;
esac
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# blob:summary=Show or adjust DDC/CI display brightness for a Hyprland monitor.
# blob:args=<monitor> [+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"
+122
View File
@@ -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
+107
View File
@@ -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")"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# blob:summary=Adjust keyboard backlight brightness using available steps.
# blob:args=[--no-osd] <up|down|cycle|off|restore>
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 ))"
+14
View File
@@ -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=<on|off>
if [[ -e /sys/class/leds/platform::micmute/brightness ]]; then
case "$1" in
on) value=1 ;;
off) value=0 ;;
*) echo "Usage: $(basename "$0") <on|off>" >&2; exit 1 ;;
esac
brightnessctl --device="platform::micmute" set "$value" >/dev/null 2>&1 || true
fi
+36
View File
@@ -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"
+288
View File
@@ -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=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--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 <monitor> 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
+21
View File
@@ -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"
+370
View File
@@ -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 <next|prev|left|right|up|down>
# 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"
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# blob:summary=Take a screenshot
# blob:group=capture
# blob:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=<name>]
# 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
+26
View File
@@ -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"
+33
View File
@@ -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
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
# blob:summary=Resize the active webcam recording overlay
# blob:group=capture
# blob:args=<smaller|larger|reset|small|medium|large>
# 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 <smaller|larger|reset|small|medium|large>" >&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"
+33
View 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] <mime-type> <path>
# 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] <mime-type> <path>" >&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
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# blob:summary=Open a clipboard history entry
# blob:group=clipboard
# blob:args=--history-index <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 <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
+63
View File
@@ -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 <index>|<text>]
# 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
+77
View File
@@ -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
+232
View File
@@ -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
+236
View File
@@ -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 " <size> 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")"
+23
View File
@@ -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}]'
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# blob:summary=Print drive information such as size, model, and mount details
# blob:args=<drive>
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"
+32
View File
@@ -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) </dev/tty' bash "$drive_to_change"
else
echo "No drive selected."
fi
else
echo "No encrypted drives available."
exit 1
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# blob:summary=Select a drive from a list with info that includes space and brand. Used by blob-drive-password.
if (($# == 0)); then
drives=$(lsblk -dpno NAME | grep -E '/dev/(sd|hd|vd|nvme|mmcblk|xv)')
else
drives="$@"
fi
drives_with_info=""
while IFS= read -r drive; do
[[ -n $drive ]] || continue
drives_with_info+="$(blob-drive-info "$drive")"$'\n'
done <<<"$drives"
selected_drive="$(printf "%s" "$drives_with_info" | gum choose --header "Select drive")" || exit 1
printf "%s\n" "$selected_drive" | awk '{print $1}'
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# blob:summary=Show current monospace font
# blob:examples=blob font current
# fontconfig is the source of truth. fc-match returns a comma-separated
# alias list (e.g. "JetBrainsMono Nerd Font,JetBrainsMono NF") so take
# the first entry.
fc-match monospace -f '%{family}\n' | head -n1 | cut -d, -f1
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=List available monospace fonts
# blob:examples=blob font list | blob font set "CaskaydiaMono Nerd Font"
fc-list :spacing=100 -f "%{family[0]}\n" | grep -v -i -E 'emoji|signwriting|blob' | sort -u
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# blob:summary=Set the system monospace font
# blob:args=<font-name>
# blob:examples=blob font list | blob font set "CaskaydiaMono Nerd Font"
usage() {
echo "Usage: blob-font-set <font-name>"
}
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" <<XML
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="pattern">
<test name="family" qual="any">
<string>monospace</string>
</test>
<edit name="family" mode="prepend_first" binding="strong">
<string>$font_name</string>
</edit>
</match>
</fontconfig>
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"
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# blob:summary=Check that a git URL names a repository, not a transport helper
# blob:args=<git-url>
# 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: `<helper>::<address>`, and
# `<scheme>://<address>` 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
+19
View File
@@ -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
+5
View File
@@ -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 ]]
+56
View File
@@ -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
+24
View File
@@ -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
+17
View File
@@ -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
+16
View File
@@ -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
+20
View File
@@ -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
+23
View File
@@ -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
+6
View File
@@ -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"
+6
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Check whether a webcam is available
[[ -n $(blob-capture-webcam-list) ]]
+12
View File
@@ -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
+21
View File
@@ -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
+114
View File
@@ -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")
+115
View File
@@ -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
+5
View File
@@ -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
+40
View File
@@ -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
+8
View File
@@ -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
+16
View File
@@ -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
+7
View File
@@ -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
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# blob:summary=Save or restore the focused Hyprland window width
# blob:args=<save|restore>
# 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
+21
View File
@@ -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"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
# blob:summary=Install a Nerd Font package and switch the system to it
# blob:args=<display-name> <package> <family>
# 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 <display-name> <package> <family>" >&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}"
+181
View File
@@ -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
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# blob:summary=Open a config file in the user's editor and surface a toast
# blob:args=<path>
# blob:examples=blob launch config-editor ~/.config/hypr/hyprland.lua
path="${1-}"
if [[ -z $path ]]; then
echo "Usage: blob-launch-config-editor <path>" >&2
exit 1
fi
blob-notify-send -u low "Editing config file" "$path"
exec blob-launch-editor "$path"
+20
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# blob:summary=Launch an app or focus an existing window matching a pattern
# blob:args=<window-pattern> <launch-command>
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
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# blob:summary=Launch a TUI or focus an existing terminal window for it
# blob:args=[--app-id=<app-id>] <command> [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"
+77
View File
@@ -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"
-5
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Launch emojis
# blob:group=menu
# blob:examples=blob menu emoji
blob-shell shell toggle blob.emojis
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# blob:summary=Insert an emoji into the focused application
# blob:group=menu
# blob:args=<emoji>
# 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
+48
View 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 <label> <paths> <formats> [menu args...]" >&2
exit 1
fi
label="$1"
paths_arg="$2"
formats_arg="$3"
shift 3
IFS=: read -r -a paths <<<"$paths_arg"
read -r -a formats <<<"$formats_arg"
if (( ${#paths[@]} == 0 || ${#formats[@]} == 0 )); then
echo "Usage: blob-menu-file <label> <paths> <formats> [menu args...]" >&2
exit 1
fi
for path in "${paths[@]}"; do
[[ -e $path ]] || { echo "Path not found: $path" >&2; exit 1; }
done
find_args=("${paths[@]}" "(" -type d -name ".*" ! -name "." -prune ")" -o -type f ! -name ".*" "(")
first_format=true
for format in "${formats[@]}"; do
if [[ $first_format == "true" ]]; then
first_format=false
else
find_args+=(-o)
fi
format="${format#.}"
find_args+=(-iname "*.$format")
done
find_args+=(")" -printf '%T@\t%p\n')
find "${find_args[@]}" 2>/dev/null | sort -rn | cut -f2- |
blob-menu-select "$label" -- --width 800 --maxheight 500 "$@"
+313
View File
@@ -0,0 +1,313 @@
#!/bin/bash
# blob:summary=Open a generic image selector menu
# blob:args=[--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>...
selected_image=""
print_name=false
show_labels=false
filterable=false
lazy_thumbnails=false
prepare_only=false
preload=false
cache_only=false
image_dirs=()
usage() {
echo "Usage: blob-menu-images [--selected <image>] [--print-name] [--show-labels] [--filterable] [--lazy-thumbnails] [--preload] [--cache-only] <image-dir>..."
}
while (( $# > 0 )); do
case "$1" in
--selected)
if (( $# < 2 )); then
usage >&2
exit 1
fi
selected_image="$2"
shift 2
;;
--print-name)
print_name=true
shift
;;
--show-labels)
show_labels=true
shift
;;
--filterable)
filterable=true
shift
;;
--lazy-thumbnails)
lazy_thumbnails=true
shift
;;
--prepare-only)
prepare_only=true
shift
;;
--preload)
preload=true
shift
;;
--cache-only)
cache_only=true
shift
;;
--help|-h)
usage
exit 0
;;
*)
image_dirs+=("$1")
shift
;;
esac
done
if (( ${#image_dirs[@]} == 0 )); then
usage >&2
exit 1
fi
selection_file=$(mktemp)
done_file=$(mktemp)
pending_file=$(mktemp)
rm -f "$done_file"
trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT
image_dirs_env=""
for dir in "${image_dirs[@]}"; do
if [[ -z $image_dirs_env ]]; then
image_dirs_env="$dir"
else
image_dirs_env+=$'\n'"$dir"
fi
done
current_image=$(readlink -f "$selected_image" 2>/dev/null)
selected_list_image=""
if [[ -n $current_image ]]; then
for dir in "${image_dirs[@]}"; do
if [[ -d $dir && -f $selected_image && ${selected_image%/*} == "$dir" ]]; then
selected_list_image="$selected_image"
break
elif [[ -d $dir ]]; then
selected_list_image=$(find -L "$dir" -maxdepth 1 -type f -samefile "$current_image" -print -quit 2>/dev/null)
[[ -n $selected_list_image ]] && break
fi
done
fi
cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/blob/image-selector
index_file="$cache_dir/index.tsv"
rows=""
mkdir -p "$cache_dir"
cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1)
rows_cache_file="$cache_dir/$cache_key.rows"
rows_signature_file="$cache_dir/$cache_key.signature"
rows_fast_signature_file="$cache_dir/$cache_key.fast-signature"
rows_signature="v3"$'\n'
rows_fast_signature="v2"$'\n'
rows_cacheable=true
rows_cache_hit=false
image_files=()
for dir in "${image_dirs[@]}"; do
[[ -d $dir ]] && rows_fast_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n'
done
if [[ -f $rows_cache_file && -f $rows_fast_signature_file ]] && cmp -s "$rows_fast_signature_file" <(printf '%s' "$rows_fast_signature"); then
rows=$(<"$rows_cache_file")
rows_cache_hit=true
else
for dir in "${image_dirs[@]}"; do
if [[ -d $dir ]]; then
rows_signature+="$dir:$(stat -Lc '%Y' "$dir")"$'\n'
while IFS= read -r -d '' image; do
image_files+=("$image")
image_signature=$(stat -Lc '%s:%Y' "$image") || continue
rows_signature+="$image:$image_signature"$'\n'
done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z)
fi
done
fi
generate_thumbnail() {
local image="$1"
local thumbnail="$2"
local lock="$thumbnail.lock"
local lock_fd
local tmp="$thumbnail.$$.jpg"
# Older releases used directories as locks, which could survive a killed
# generator and block this thumbnail forever. Only reap aged ones, so a
# legacy generator still running through an upgrade keeps its lock.
if [[ -d $lock ]] && (( $(date +%s) - $(stat -c '%Y' "$lock" 2>/dev/null || date +%s) > 120 )); then
rmdir "$lock" 2>/dev/null
fi
exec {lock_fd}>"$lock" || return
flock -w 30 "$lock_fd" || return
# A generator killed mid-write leaves its partial $thumbnail.<pid>.jpg
# behind. Only the lock holder writes these, so any found now are stale.
rm -f "$thumbnail".*.jpg
[[ -f $thumbnail ]] && return
# Callers fan out one generator per image, so keep each vips single-threaded.
# Close the lock fd for vips: an orphaned or hung vips must not keep holding
# the lock after this shell is killed.
if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then
mv -f "$tmp" "$thumbnail"
else
rm -f "$tmp" "$thumbnail"
fi
}
thumbnail_for() {
local image="$1"
local signature hash thumbnail
signature=$(stat -Lc '%s:%Y' "$image") || return
hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null)
if [[ -z $hash ]]; then
hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1)
printf '%s\t%s\t%s\n' "$image" "$signature" "$hash" >>"$index_file"
fi
thumbnail="$cache_dir/$hash.jpg"
if [[ ! -f $thumbnail ]]; then
if [[ $lazy_thumbnails == true && $cache_only != true ]]; then
rows_cacheable=false
if [[ $prepare_only != true ]]; then
generate_thumbnail "$image" "$thumbnail" >/dev/null 2>&1 &
fi
printf '%s' "$image"
return
fi
printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file"
fi
printf '%s' "$thumbnail"
}
# Generate every queued thumbnail at once; each vips run is single-threaded.
drain_pending_thumbnails() {
[[ -s $pending_file ]] || return 0
export -f generate_thumbnail
xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \
bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true
}
if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then
rows=$(<"$rows_cache_file")
printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file"
elif [[ $rows_cache_hit != true ]]; then
for image in "${image_files[@]}"; do
thumbnail=$(thumbnail_for "$image")
[[ -n $thumbnail ]] || continue
if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then
rows_cacheable=false
fi
if [[ -z $rows ]]; then
rows="$image"$'\t'"$thumbnail"
else
rows+=$'\n'"$image"$'\t'"$thumbnail"
fi
done
drain_pending_thumbnails
if [[ -s $pending_file ]]; then
pruned=""
while IFS=$'\t' read -r row_image row_thumbnail; do
if [[ ! -e $row_thumbnail ]]; then
rows_cacheable=false
continue
fi
if [[ -z $pruned ]]; then
pruned="$row_image"$'\t'"$row_thumbnail"
else
pruned+=$'\n'"$row_image"$'\t'"$row_thumbnail"
fi
done <<<"$rows"
rows="$pruned"
fi
# Publish the cache under a lock and via renames: a picker killed mid-write,
# or two pickers interleaving, must never leave truncated or mismatched rows
# behind signatures that still validate. Rows go first so a kill between
# renames leaves signatures that are either older (a harmless miss) or
# describe the same directory state.
if exec {rows_lock_fd}>"$rows_cache_file.lock" && flock -w 30 "$rows_lock_fd"; then
if [[ $rows_cacheable == true ]]; then
rm -f "$cache_dir/$cache_key".*.tmp
printf '%s' "$rows" >"$rows_cache_file.$$.tmp" && mv -f "$rows_cache_file.$$.tmp" "$rows_cache_file"
printf '%s' "$rows_signature" >"$rows_signature_file.$$.tmp" && mv -f "$rows_signature_file.$$.tmp" "$rows_signature_file"
printf '%s' "$rows_fast_signature" >"$rows_fast_signature_file.$$.tmp" && mv -f "$rows_fast_signature_file.$$.tmp" "$rows_fast_signature_file"
else
rm -f "$rows_cache_file" "$rows_signature_file" "$rows_fast_signature_file"
fi
exec {rows_lock_fd}>&-
fi
fi
if [[ $cache_only == true || $prepare_only == true ]]; then
exit 0
fi
# Image rows can contain newlines and tabs, which don't survive positional
# shell IPC arguments. Base64-encode for transit; the ImagePicker plugin
# Qt.atob()s on the other side.
rows_b64=$(printf '%s' "$rows" | base64 -w 0)
if [[ $preload == true ]]; then
blob-shell image-selector preload "$rows_b64" "$selected_list_image" "$show_labels" "$filterable" >/dev/null || true
exit 0
fi
if ! open_result=$(blob-shell image-selector open \
"" \
"$rows_b64" \
"$selected_list_image" \
"$selection_file" \
"$done_file" \
"$show_labels" \
"$filterable"); then
echo "Image selector failed to accept request" >&2
exit 1
fi
if [[ $open_result != "ok" ]]; then
echo "Image selector failed to accept request" >&2
exit 1
fi
while [[ ! -e $done_file ]]; do
sleep 0.01
done
if [[ -s $selection_file ]]; then
if [[ $print_name == true ]]; then
selection=$(<"$selection_file")
selection=${selection##*/}
printf '%s\n' "${selection%.*}"
else
cat "$selection_file"
fi
fi
+597
View File
@@ -0,0 +1,597 @@
#!/bin/bash
# blob:summary=Display Hyprland keybindings defined in your configuration using an interactive search menu.
# Hyprland's Lua config provider currently reports Lua binds as dispatcher
# __lua in `hyprctl binds`. Keep a lightweight source-derived cache so the
# menu can still show and dispatch those bindings.
declare -A LUA_BIND_KEY_MAP
declare -A LUA_BIND_DISPATCHER_MAP
declare -A LUA_BIND_ARG_MAP
# Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols
# via the compiled keymap, with a small fallback for common keys so the menu
# remains readable if xkbcli cannot resolve a symbol.
parse_keycodes() {
awk '
BEGIN {
split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ")
for (i in fallbacks) {
separator = index(fallbacks[i], "=")
keycode_symbol[substr(fallbacks[i], 1, separator - 1)] = substr(fallbacks[i], separator + 1)
}
# </dev/null keeps xkbcli from consuming the binding records on stdin
keymap_cmd = "xkbcli compile-keymap </dev/null"
section = ""
while ((keymap_cmd | getline line) > 0) {
if (line ~ /xkb_keycodes/) { section = "codes"; continue }
if (line ~ /xkb_symbols/) { section = "syms"; continue }
if (section == "codes" && match(line, /<([A-Za-z0-9_]+)>\s*=\s*([0-9]+)\s*;/, m)) code_by_name[m[1]] = m[2]
if (section == "syms" && match(line, /key\s*<([A-Za-z0-9_]+)>\s*\{\s*\[\s*([^, \]]+)/, m)) sym_by_name[m[1]] = m[2]
}
close(keymap_cmd)
for (name in code_by_name) {
code = code_by_name[name]
symbol = sym_by_name[name]
if (code != "" && symbol != "" && symbol != "NoSymbol") keycode_symbol[code] = toupper(symbol)
}
mouse_symbol["272"] = "LEFT MOUSE BUTTON"
mouse_symbol["273"] = "RIGHT MOUSE BUTTON"
mouse_symbol["274"] = "MIDDLE MOUSE BUTTON"
}
{
if (match($0, /code:([0-9]+)/, match_parts)) {
code = match_parts[1]
symbol = keycode_symbol[code]
if (symbol == "") symbol = "code:" code
sub("code:" code, symbol)
} else if (match($0, /mouse:([0-9]+)/, match_parts)) {
code = match_parts[1]
symbol = mouse_symbol[code]
if (symbol == "") symbol = "mouse:" code
sub("mouse:" code, symbol)
}
print
}
'
}
# Supplement `hyprctl binds` for Lua-only binds that Hyprland currently
# reports as dispatcher __lua and without their original key for code: binds.
build_lua_bind_cache() {
local modmask description key dispatcher arg cache_key
blob-cmd-present lua || return 0
while IFS=$'\t' read -r modmask description key dispatcher arg; do
[[ -z $modmask || -z $description || -z $key ]] && continue
LUA_BIND_KEY_MAP["$modmask,$description"]="$key"
cache_key="$modmask,$description,$key"
LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher"
LUA_BIND_ARG_MAP["$cache_key"]="$arg"
done < <(
lua <<'LUA'
local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 }
local function split_keys(keys)
local modmask = 0
local key = ""
for part in string.gmatch(tostring(keys or ""), "[^+]+") do
local value = part:gsub("^%s+", ""):gsub("%s+$", "")
local modifier = modifiers[string.upper(value)]
if modifier then
modmask = modmask + modifier
else
key = value
end
end
return modmask, key
end
local function lua_literal(value)
local value_type = type(value)
if value_type == "string" then
return string.format("%q", value)
elseif value_type == "number" or value_type == "boolean" then
return tostring(value)
elseif value_type == "table" then
local parts = {}
local keys = {}
local array_length = #value
for index = 1, array_length do
parts[#parts + 1] = lua_literal(value[index])
end
for key in pairs(value) do
if not (type(key) == "number" and key >= 1 and key <= array_length and math.floor(key) == key) then
keys[#keys + 1] = key
end
end
table.sort(keys, function(left, right)
return tostring(left) < tostring(right)
end)
for _, key in ipairs(keys) do
local key_prefix
if type(key) == "string" and key:match("^[%a_][%w_]*$") then
key_prefix = key .. " = "
else
key_prefix = "[" .. lua_literal(key) .. "] = "
end
parts[#parts + 1] = key_prefix .. lua_literal(value[key])
end
return "{ " .. table.concat(parts, ", ") .. " }"
elseif value_type == "nil" then
return "nil"
else
return "nil"
end
end
local function call_expression(path, ...)
local args = {}
for index = 1, select("#", ...) do
args[index] = lua_literal(select(index, ...))
end
return path .. "(" .. table.concat(args, ", ") .. ")"
end
local function dispatcher(kind, arg, expr)
return {
__blob_dispatcher = true,
kind = kind or "",
arg = arg or "",
expr = expr or "",
}
end
local function dsp_proxy(path)
return setmetatable({ path = path }, {
__index = function(self, key)
return dsp_proxy(self.path .. "." .. tostring(key))
end,
__call = function(self, ...)
local first_arg = ...
local expr = call_expression(self.path, ...)
if self.path == "hl.dsp.exec_cmd" and type(first_arg) == "string" then
return dispatcher("exec", first_arg, expr)
end
return dispatcher("lua", expr, expr)
end,
})
end
local noop
noop = setmetatable({}, {
__index = function()
return noop
end,
__call = function()
return noop
end,
})
hl = setmetatable({
dsp = dsp_proxy("hl.dsp"),
bind = function(keys, bind_dispatcher, opts)
opts = opts or {}
if opts.description and opts.description ~= "" then
local modmask, key = split_keys(keys)
local kind = ""
local arg = ""
if type(bind_dispatcher) == "table" and bind_dispatcher.__blob_dispatcher then
kind = bind_dispatcher.kind or ""
arg = bind_dispatcher.arg or bind_dispatcher.expr or ""
elseif type(bind_dispatcher) == "string" and bind_dispatcher ~= "" then
kind = "exec"
arg = bind_dispatcher
end
print(table.concat({ tostring(modmask), opts.description, key, kind, arg }, "\t"))
end
return noop
end,
get_config = function()
return nil
end,
}, {
__index = function()
return noop
end,
})
local config = os.getenv("HOME") .. "/.config/hypr/hyprland.lua"
local file = io.open(config, "r")
if file then
file:close()
local ok, err = pcall(dofile, config)
if not ok and os.getenv("DEBUG") == "1" then
io.stderr:write("[DEBUG] lua bind scan failed: " .. tostring(err) .. "\n")
end
end
LUA
)
}
modmask_to_text() {
case "$1" in
0) printf '' ;;
1) printf 'SHIFT' ;;
4) printf 'CTRL' ;;
5) printf 'SHIFT CTRL' ;;
8) printf 'ALT' ;;
9) printf 'SHIFT ALT' ;;
12) printf 'CTRL ALT' ;;
13) printf 'SHIFT CTRL ALT' ;;
64) printf 'SUPER' ;;
65) printf 'SUPER SHIFT' ;;
68) printf 'SUPER CTRL' ;;
69) printf 'SUPER SHIFT CTRL' ;;
72) printf 'SUPER ALT' ;;
73) printf 'SUPER SHIFT ALT' ;;
76) printf 'SUPER CTRL ALT' ;;
77) printf 'SUPER SHIFT CTRL ALT' ;;
*) printf '%s' "$1" ;;
esac
}
# Fetch dynamic keybindings from Hyprland.
#
# Also do some pre-processing:
# - Fill missing Lua code:... keys and __lua dispatch metadata from the Lua source cache
# - Remove standard Blob bin path prefix
# - Map numeric modifier key mask to a textual rendition
# - Output comma-separated values that the parser can understand
dynamic_bindings() {
local modmask key keycode description dispatcher arg modifiers cache_key
# Parse the plain `hyprctl binds` output rather than `hyprctl -j binds`:
# Hyprland 0.56.0 emits invalid JSON for binds (misaligned fields in
# bindsRequest), and older versions broke on quotes in bind args.
hyprctl binds | awk '
function emit() {
if (!seen) return
seen = 0
printf "%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n", f["modmask"], f["key"], f["keycode"], f["description"], f["dispatcher"], f["arg"]
}
/^bind/ { emit(); seen = 1; delete f; next }
seen && match($0, /^\t[a-z]+: /) { f[substr($0, 2, RLENGTH - 3)] = substr($0, RLENGTH + 1) }
END { emit() }
' |
while IFS=$'\x1f' read -r modmask key keycode description dispatcher arg; do
# Lua binds report their full display key ("SUPER + code:20"); the
# modifiers are already carried separately in modmask.
key="${key##* + }"
if [[ -z $key && $keycode != "0" ]]; then
key="code:$keycode"
fi
if [[ -z $key && -n $description ]]; then
key="${LUA_BIND_KEY_MAP["$modmask,$description"]}"
fi
if [[ $dispatcher == "__lua" && -n $description && -n $key ]]; then
cache_key="$modmask,$description,$key"
dispatcher="${LUA_BIND_DISPATCHER_MAP["$cache_key"]}"
arg="${LUA_BIND_ARG_MAP["$cache_key"]}"
fi
[[ -z $description && $dispatcher == "__lua" ]] && continue
# The Copilot key just duplicates an existing binding, so keep it hidden
[[ $key == "code:201" ]] && continue
case "$key" in
comma) key="COMMA" ;;
period) key="PERIOD" ;;
minus) key="MINUS" ;;
equal) key="EQUAL" ;;
slash) key="SLASH" ;;
esac
modifiers=$(modmask_to_text "$modmask")
arg="${arg//~\/.local\/share\/blob\/bin\//}"
printf '%s,%s,%s,%s,%s\n' "$modifiers" "$key" "$description" "$dispatcher" "$arg"
done
}
# Hardcoded bindings, like the copy-url extension and such
static_bindings() {
echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L,"
echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D,"
}
# Parse and format keybindings
#
# `awk` does the heavy lifting:
# - Set the field separator to a comma ','.
# - Joins the key combination (e.g., "SUPER + Q").
# - Joins the command that the key executes.
# - Prints display text and dispatch metadata as tab-separated fields.
parse_binding_records() {
awk -F, '
{
# Combine the modifier and key (first two fields)
key_combo = $1 " + " $2;
# Clean up: strip leading "+" if present, trim spaces
gsub(/^[ \t]*\+?[ \t]*/, "", key_combo);
gsub(/[ \t]+$/, "", key_combo);
# Use description, if set
action = $3;
dispatcher = $4;
# Reconstruct the dispatcher arg from the remaining fields
arg = "";
for (i = 5; i <= NF; i++) {
arg = arg $i (i < NF ? "," : "");
}
if (action == "") {
# Reconstruct the command from the remaining fields
for (i = 4; i <= NF; i++) {
action = action $i (i < NF ? "," : "");
}
# Clean up trailing commas, remove leading "exec, ", and trim
sub(/,$/, "", action);
gsub(/(^|,)[[:space:]]*exec[[:space:]]*,?/, "", action);
gsub(/(^|[[:space:]])uwsm(-app| app)[[:space:]]+--[[:space:]]+/, "", action);
gsub(/^[ \t]+|[ \t]+$/, "", action);
gsub(/[ \t]+/, " ", key_combo); # Collapse multiple spaces to one
}
if (action != "") {
printf "%-35s → %s\t%s\t%s\n", key_combo, action, dispatcher, arg;
}
}'
}
prioritize_entries() {
awk -F '\t' '
{
line = $1
prio = 50
if (match(line, /Keybindings/)) prio = 0
if (match(line, /Blob menu/)) prio = 1
if (match(line, /Terminal/)) prio = 2
if (match(line, /Browser/) && !match(line, /Browser[[:space:]]*\(/) && !match(line, /SUPER SHIFT.*\+.*B.*→.*Browser/)) prio = 3
if (match(line, /File manager/) && !match(line, /File manager \(cwd\)/)) prio = 4
if (match(line, /Launch apps/)) prio = 5
if (match(line, /System menu/)) prio = 6
if (match(line, /Theme menu/)) prio = 7
if (match(line, /Full screen/)) prio = 8
if (match(line, /Full width/)) prio = 9
if (match(line, /Close window/)) prio = 10
if (match(line, /Close all windows/)) prio = 11
if (match(line, /Lock system/)) prio = 12
if (match(line, /Toggle window floating/)) prio = 13
if (match(line, /Toggle window split/)) prio = 14
if (match(line, /Pop window/)) prio = 15
if (match(line, /Universal/)) prio = 16
if (match(line, /Clipboard/)) prio = 17
if (match(line, /Audio controls/)) prio = 18
if (match(line, /Bluetooth controls/)) prio = 19
if (match(line, /Wifi controls/)) prio = 20
if (match(line, /Emojis/)) prio = 21
if (match(line, /Color picker/)) prio = 22
if (match(line, /Screenshot/)) prio = 23
if (match(line, /Screenrecording/)) prio = 24
if (match(line, /Tmux/)) prio = 25
if (match(line, /Herdr/)) prio = 26
if (match(line, /SUPER SHIFT.*\+.*B.*→.*Browser/)) prio = 27
if (match(line, /File manager \(cwd\)/)) prio = 28
if (match(line, /(Switch|Next|Former|Previous).*workspace/)) prio = 29
if (match(line, /Move window to workspace/)) prio = 30
if (match(line, /Move window silently to workspace/)) prio = 31
if (match(line, /Swap window/)) prio = 32
if (match(line, /Focus/)) prio = 33
if (match(line, /Move window$/)) prio = 34
if (match(line, /Resize window/)) prio = 35
if (match(line, /Expand window/)) prio = 36
if (match(line, /Shrink window/)) prio = 37
if (match(line, /scratchpad/)) prio = 38
if (match(line, /notification/)) prio = 39
if (match(line, /Toggle window transparency/)) prio = 40
if (match(line, /Toggle workspace gaps/)) prio = 41
if (match(line, /Toggle nightlight/)) prio = 42
if (match(line, /Toggle locking/)) prio = 43
if (match(line, /group/)) prio = 94
if (match(line, /Scroll active workspace/)) prio = 95
if (match(line, /Cycle to/)) prio = 96
if (match(line, /Reveal active/)) prio = 97
if (match(line, /Apple Display/)) prio = 98
if (match(line, /XF86/)) prio = 99
if (match(line, /Tmux keybindings/)) prio = 100
if (match(line, /Herdr keybindings/)) prio = 101
# print "priority<TAB>record"
printf "%d\t%s\n", prio, $0
}' |
sort -k1,1n -k2,2 |
cut -f2-
}
output_binding_records_uncached() {
local dynamic
build_lua_bind_cache
dynamic=$(dynamic_bindings)
{
[[ -n $dynamic ]] && printf '%s\n' "$dynamic"
static_bindings
} |
sort -u |
parse_keycodes |
parse_binding_records |
prioritize_entries
# Fail when Hyprland reported no binds so the fallout of a broken hyprctl
# is never cached.
[[ -n $dynamic ]]
}
keybindings_cache_key() {
{
printf 'v11\n'
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
hyprctl binds 2>/dev/null
} | sha256sum | awk '{ print $1 }'
}
refresh_keybindings_cache() {
local cache_dir="$1"
local cache_file="$2"
local cache_name="$3"
local tmp_file
tmp_file=$(mktemp "$cache_dir/keybindings.XXXXXX") || return 1
if output_binding_records_uncached >"$tmp_file"; then
mv "$tmp_file" "$cache_file"
find "$cache_dir" -maxdepth 1 -type f -name 'keybindings-*.records' ! -name "$cache_name" -delete 2>/dev/null || true
else
rm -f "$tmp_file"
return 1
fi
}
output_binding_records() {
local cache_dir cache_file cache_name
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/blob"
cache_name="keybindings-$(keybindings_cache_key).records"
cache_file="$cache_dir/$cache_name"
if [[ -s $cache_file ]]; then
cat "$cache_file"
elif mkdir -p "$cache_dir" 2>/dev/null && refresh_keybindings_cache "$cache_dir" "$cache_file" "$cache_name"; then
cat "$cache_file"
else
output_binding_records_uncached
fi
}
output_keybindings() {
output_binding_records | cut -f1
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s\n' "$value"
}
lua_string() {
jq -Rnr --arg value "$1" '$value | @json'
}
dispatch_lua_expression() {
local expression="$1"
local output status
output=$(hyprctl dispatch "$expression" 2>&1)
status=$?
if (( status == 0 )) && [[ -z $output || $output == "ok" ]]; then
[[ -n $output ]] && printf '%s\n' "$output"
return 0
fi
return 1
}
dispatch_exec_binding() {
local command="$1"
dispatch_lua_expression "hl.dsp.exec_cmd($(lua_string "$command"))" || hyprctl dispatch exec "$command"
}
dispatch_sendshortcut_binding() {
local arg="$1"
local mods key window rest
IFS=, read -r mods key window rest <<<"$arg"
mods=$(trim "$mods")
key=$(trim "$key")
window=$(trim "$window")
[[ -z $window ]] && window="activewindow"
if [[ -n $key ]] && dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"down\", window = $(lua_string "$window") })"; then
sleep 0.05
dispatch_lua_expression "hl.dsp.send_key_state({ mods = $(lua_string "$mods"), key = $(lua_string "$key"), state = \"up\", window = $(lua_string "$window") })"
return
fi
hyprctl dispatch sendshortcut "$arg"
}
dispatch_binding() {
local dispatcher="$1"
local arg="$2"
case "$dispatcher" in
exec)
[[ -n $arg ]] && dispatch_exec_binding "$arg"
;;
sendshortcut)
[[ -n $arg ]] && dispatch_sendshortcut_binding "$arg"
;;
lua)
[[ -n $arg ]] && hyprctl dispatch "$arg"
;;
"") return 1 ;;
*)
if [[ -n $arg ]]; then
hyprctl dispatch "$dispatcher" "$arg"
else
hyprctl dispatch "$dispatcher"
fi
;;
esac
}
if [[ $1 == "--print" || $1 == "-p" ]]; then
output_keybindings
else
records=$(output_binding_records)
selection=$(cut -f1 <<<"$records" |
blob-menu-select 'Keybindings' -- --width 800 --height 500)
if [[ -n $selection ]]; then
record=$(awk -F '\t' -v selection="$selection" '$1 == selection { print; exit }' <<<"$records")
dispatcher=$(cut -f2 <<<"$record")
arg=$(cut -f3- <<<"$record")
dispatch_binding "$dispatcher" "$arg"
fi
fi
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# blob:summary=Pick a shell plugin to enable, disable, clone, or remove
# blob:group=menu
# blob:name=plugin
# blob:args=<enable|disable|clone|remove>
set -euo pipefail
PLUGIN_ICON=$'\U000f0431'
case "${1:-}" in
enable) filter='(.enabled | not)' ;;
disable) filter='.canDisable and .enabled' ;;
clone) filter='.firstParty and (.id as $id | ($plugins | map(.clonedFrom // "") | index($id)) == null)' ;;
remove) filter='(.firstParty | not)' ;;
*)
echo "Usage: blob-menu-plugin <enable|disable|clone|remove>" >&2
exit 1
;;
esac
plugins=$(blob-plugin-list --json)
# The id rides along as row subtext: it tells same-named plugins apart on
# screen and comes back with the selection as the key to act on.
rows=$(jq -r --arg icon "$PLUGIN_ICON" \
". as \$plugins
| .[] | select($filter)
| \$icon + \"\\t\" + .name + \"\\t\" + .id" <<<"$plugins")
[[ -n $rows ]] || { blob-notify-send "No plugin to ${1}"; exit 0; }
selection=$(blob-menu-select "${1^} plugin" <<<"$rows") || exit 0
[[ -n $selection ]] || exit 0
id=$(cut -f2 <<<"$selection")
[[ -n $id ]] || exit 1
if [[ $1 == "clone" ]]; then
blob-launch-floating \
"blob-plugin-clone $(printf '%q' "$id") --edit"
elif [[ $1 == "remove" ]]; then
blob-launch-floating "blob-plugin-remove $(printf '%q' "$id")"
else
"blob-plugin-$1" "$id"
fi
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# blob:summary=Share clipboard, files, or folders with LocalSend
# blob:group=share
# blob:name=
# blob:args=<clipboard|file|folder> [path...]
# blob:examples=blob share clipboard | blob share file ~/Downloads/example.txt
if (($# == 0)); then
echo "Usage: blob-menu-share [clipboard|file|folder]"
exit 1
fi
MODE="$1"
shift
if [[ $MODE == "clipboard" ]]; then
TEMP_FILE=$(mktemp --suffix=.txt)
wl-paste >"$TEMP_FILE"
FILE_ARRAY=("$TEMP_FILE")
elif (($# > 0)); then
FILE_ARRAY=("$@")
else
if [[ $MODE == "folder" ]]; then
select_args=(--title "Share folder" --directory)
else
select_args=(--title "Share files" --multiple)
fi
# Command substitution so the chooser's exit status survives: reading it
# through a process substitution reports success for a chooser that never
# opened, which is indistinguishable here from someone deciding not to share.
picked=$(blob-file-select "${select_args[@]}") || status=$?
if ((${status:-0} > 1)); then
blob-notify-send -g "" -u critical "Could not share" "The file chooser did not open"
exit 1
fi
[[ -n $picked ]] || exit 0
readarray -t FILE_ARRAY <<<"$picked"
fi
# Run LocalSend in its own systemd service (detached from terminal)
systemd-run --user --quiet --collect localsend --headless send "${FILE_ARRAY[@]}"
# Note: Temporary file will remain until system cleanup for clipboard mode
# This ensures the file content is available for the LocalSend GUI
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Select and set the system timezone
# blob:group=menu
# blob:name=timezone
set -e
timezone=$(timedatectl list-timezones | blob-menu-select "Set timezone" -- --width 520 --maxheight 520) || exit 1
sudo timedatectl set-timezone "$timezone"
blob-shell -q blob.clock refresh
blob-notify-send "Timezone is now set to $timezone"
+192
View File
@@ -0,0 +1,192 @@
#!/bin/bash
# blob:summary=Show or pin the Wi-Fi band for the active connection
# blob:group=network
# blob:args=[auto|2.4|5|6]
# blob:examples=blob network band | blob network band 5 | blob network band auto
set -euo pipefail
# NetworkManager 1.44+ accepts a third band value, so 5GHz and 6GHz can be
# pinned apart. Pinning the band rather than a BSSID keeps working when the AP
# rotates BSSIDs, and leaves roaming between APs intact.
nm_band_for() {
case "$1" in
2.4) echo "bg" ;;
5) echo "a" ;;
6) echo "6GHz" ;;
*) return 1 ;;
esac
}
band_from_nm() {
case "$1" in
bg) echo "2.4" ;;
a) echo "5" ;;
6GHz) echo "6" ;;
*) echo "auto" ;;
esac
}
# Accepts either nmcli's "2412 MHz" or iw's "5745.0": keep the leading digits
# and ignore whatever unit or fraction follows. The boundaries mirror Model.js
# formatHeaderFreq so the panel's label and this command cannot disagree.
band_for_freq() {
local mhz=${1%%[!0-9]*}
[[ -n $mhz ]] || return 1
if ((mhz >= 2400 && mhz < 2500)); then
echo "2.4"
elif ((mhz >= 4900 && mhz < 5925)); then
echo "5"
elif ((mhz >= 5925 && mhz < 7125)); then
echo "6"
else
return 1
fi
}
# Every value read back from nmcli goes through here. LC_ALL=C because nmcli
# translates state words such as "connected", which would silently stop matching
# under a non-English session; `-e no` because `-g` otherwise escapes ':' and
# '\' in values, and an escaped SSID would never match iw's raw one.
nm_get() {
LC_ALL=C nmcli -e no -g "$@" 2>/dev/null
}
wifi_device() {
nm_get DEVICE,TYPE,STATE device status |
awk -F: '$2 == "wifi" && $3 == "connected" { print $1; exit }'
}
wifi_profile() {
nm_get GENERAL.CONNECTION device show "$1"
}
selected_band() {
band_from_nm "$(nm_get 802-11-wireless.band connection show "$1")"
}
# Sets $ssid and $freq from one `iw dev <device> link`. iw prints the SSID as
# the rest of its line, so an SSID containing ':' needs no special handling.
read_link() {
local link
link=$(iw dev "$1" link 2>/dev/null)
ssid=$(awk '/SSID:/ { sub(/.*SSID: /, ""); print; exit }' <<<"$link")
freq=$(awk '/freq:/ { print $2; exit }' <<<"$link")
}
# Every band the SSID is reachable on, low to high, always including the one
# already in use -- a weak radio gets missed by plenty of scans, and the band we
# are sitting on must never be absent from its own list of options. A band the
# AP does not answer on is never offered: pinning to it would drop the
# connection with nothing to reassociate to.
#
# --rescan no reads NetworkManager's cache, which the panel's own scanner keeps
# warm; forcing a scan here would stall every poll. The kernel's own cache (iw
# scan dump) is pruned far harder and would make bands flicker.
available_bands() {
local device=$1 ssid=$2 current=$3
{
if [[ -n $current ]]; then echo "$current"; fi
# SSID is queried last so one containing ':' can be reassembled verbatim.
# It reaches awk through the environment, not -v, which would expand
# backslash escapes in an SSID that contains one.
nm_get FREQ,SSID dev wifi list ifname "$device" --rescan no |
want="$ssid" awk -F: '
BEGIN { want = ENVIRON["want"] }
{
name = $2
for (i = 3; i <= NF; i++) name = name ":" $i
if (name == want) print $1
}' |
while read -r freq; do band_for_freq "$freq" || true; done
} | sort -u -g | tr '\n' ' ' | sed 's/ $//'
}
print_status() {
local device profile band available
device=$(wifi_device)
[[ -n $device ]] || return 0
read_link "$device"
[[ -n $ssid ]] || return 0
band=$(band_for_freq "$freq" || true)
available=$(available_bands "$device" "$ssid" "$band")
profile=$(wifi_profile "$device")
printf 'band\t%s\n' "$band"
printf 'available\t%s\n' "$available"
if [[ -n $profile ]]; then printf 'selected\t%s\n' "$(selected_band "$profile")"; fi
}
set_band() {
local target=$1
local device profile previous desired
device=$(wifi_device)
if [[ -z $device ]]; then
echo "Error: no connected Wi-Fi device." >&2
exit 1
fi
profile=$(wifi_profile "$device")
if [[ -z $profile ]]; then
echo "Error: no active Wi-Fi connection profile." >&2
exit 1
fi
if [[ $target == "auto" ]]; then
desired=""
else
read_link "$device"
if [[ " $(available_bands "$device" "$ssid" "$(band_for_freq "$freq" || true)") " != *" $target "* ]]; then
echo "Error: ${target}GHz is not available on this network." >&2
exit 1
fi
desired=$(nm_band_for "$target")
fi
previous=$(nm_get 802-11-wireless.band connection show "$profile")
[[ $previous == "$desired" ]] && exit 0
nmcli connection modify "$profile" 802-11-wireless.band "$desired" >/dev/null
# A band change only takes effect on reassociation. If the radio cannot come
# back up on the requested band, put the previous setting back and reconnect
# rather than leaving the machine stranded offline.
if ! nmcli connection up "$profile" >/dev/null 2>&1; then
nmcli connection modify "$profile" 802-11-wireless.band "$previous" >/dev/null
nmcli connection up "$profile" >/dev/null 2>&1 || true
echo "Error: could not connect on ${target}; reverted to previous band." >&2
exit 1
fi
}
usage() {
echo "Usage: blob-network-band [auto|2.4|5|6]" >&2
}
if (($# == 0)); then
print_status
exit 0
fi
if (($# > 1)); then
usage
exit 1
fi
case "$1" in
auto | 2.4 | 5 | 6)
set_band "$1"
;;
*)
usage
exit 1
;;
esac
+319
View File
@@ -0,0 +1,319 @@
#!/bin/bash
# blob:summary=Show or configure the system DNS provider
# blob:args=[Cloudflare|Google|DHCP|Custom]
# blob:examples=blob dns | blob dns Cloudflare | blob dns Custom
set -euo pipefail
# Whenever this runs as root — invoked directly through the passwordless
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
# where a bare helper resolves, and a dev link (etc/sudoers.d/blob-dev-path)
# prepends a user-writable checkout bin/ to it. Every helper this script calls
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
# tool, never an blob-* command, so pin PATH to trusted system directories
# and keep root from resolving one out of that checkout. The unprivileged
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
if (( EUID == 0 )); then
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
fi
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-blob-network-dns.conf
provider_from_arg() {
case "${1:-}" in
Cloudflare | cloudflare)
echo "Cloudflare"
;;
Google | google)
echo "Google"
;;
DHCP | dhcp)
echo "DHCP"
;;
Custom | custom)
echo "Custom"
;;
*)
return 1
;;
esac
}
# The path etc/sudoers.d/blob-network-dns names. The privileged half always runs from
# there rather than from whichever copy was invoked, so the rule matches even
# where $BLOB_PATH points at a checkout.
PACKAGED_PATH=/usr/bin/blob-network-dns
# True when sudo would run this exact command without stopping for a password.
# `sudo -l` on its own reports whether a command is permitted, which the blanket
# %wheel rule answers yes to for everything; the long listing prints the matched
# entry's tags, so !authenticate is the grant in etc/sudoers.d/blob-network-dns and
# nothing else. Listing runs nothing and, under -n, prompts for nothing, so a
# machine whose blob-settings predates that file falls through to polkit
# instead of dying on a password prompt it has no terminal to show.
sudo_grants_passwordless() {
sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate'
}
require_root() {
if (( EUID == 0 )); then
return
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
# A terminal can carry sudo's own password prompt. Without one, sudo is
# right only where the grant reaches; polkit can at least put a prompt on
# screen, and offer to authenticate as someone else.
exec sudo "$PACKAGED_PATH" "$@"
else
exec pkexec "$PACKAGED_PATH" "$@"
fi
}
networkmanager_global_dns() {
[[ -f $NM_DNS_CONF ]] || return 0
awk -F= '
/^[[:space:]]*#/ { next }
/^[[:space:]]*\[global-dns-domain-\*\][[:space:]]*$/ { in_default = 1; next }
/^[[:space:]]*\[/ { in_default = 0 }
in_default && /^[[:space:]]*servers[[:space:]]*=/ {
value = $0
sub(/^[^=]*=/, "", value)
print value
exit
}
' "$NM_DNS_CONF"
}
resolved_dns() {
awk -F= '
/^[[:space:]]*#/ { next }
/^[[:space:]]*DNS[[:space:]]*=/ {
value=$0
sub(/^[^=]*=/, "", value)
print value
exit
}
' /etc/systemd/resolved.conf 2>/dev/null || true
}
current_dns_provider() {
local dns=""
local compact=""
dns=$(networkmanager_global_dns)
if [[ -z $(printf '%s' "$dns" | tr -d '[:space:],') ]]; then
dns=$(resolved_dns)
fi
compact=$(printf '%s' "$dns" | tr -d '[:space:],')
if [[ -z $compact ]]; then
echo "DHCP"
elif [[ $dns == *"cloudflare-dns.com"* || $dns == *"1.1.1.1"* || $dns == *"2606:4700:4700::1111"* ]]; then
echo "Cloudflare"
elif [[ $dns == *"dns.google"* || $dns == *"8.8.8.8"* || $dns == *"2001:4860:4860::8888"* ]]; then
echo "Google"
else
echo "Custom"
fi
}
normalize_servers() {
printf '%s\n' "$*" | tr ',\t\n' ' ' | xargs | tr ' ' ','
}
split_dns_servers() {
local servers="$1"
local server clean
ipv4_dns=""
ipv6_dns=""
for server in ${servers//,/ }; do
clean=${server#dns+tls://}
clean=${clean#dns+udp://}
clean=${clean%%#*}
clean=${clean#[}
clean=${clean%]}
[[ -n $clean ]] || continue
if [[ $clean == *:* ]]; then
ipv6_dns+="${ipv6_dns:+ }$clean"
else
ipv4_dns+="${ipv4_dns:+ }$clean"
fi
done
}
write_networkmanager_dns() {
local servers="$1"
install -d -m 0755 "$(dirname "$NM_DNS_CONF")"
# blob:heredoc-expands paths=none -- $servers is a normalized, single-line
# DNS server list written as data, not a path or command; nothing user-writable
# is resolved or executed from the root-owned drop-in.
cat >"$NM_DNS_CONF" <<EOF
# Managed by blob-network-dns. Remove this file or run blob dns DHCP to use DHCP DNS again.
[global-dns]
[global-dns-domain-*]
servers=$servers
EOF
}
clear_networkmanager_dns() {
rm -f "$NM_DNS_CONF"
}
networkmanager_dns_connection() {
case "$1" in
802-11-wireless|802-3-ethernet) return 0 ;;
*) return 1 ;;
esac
}
set_connection_dns() {
local uuid type
local ipv4_dns="${1:-}"
local ipv6_dns="${2:-}"
while IFS=: read -r uuid type; do
[[ -n $uuid ]] || continue
networkmanager_dns_connection "$type" || continue
nmcli connection modify "$uuid" \
ipv4.ignore-auto-dns yes \
ipv4.dns "$ipv4_dns" \
ipv6.ignore-auto-dns yes \
ipv6.dns "$ipv6_dns" \
>/dev/null
done < <(nmcli -t -f UUID,TYPE connection show)
}
clear_connection_dns() {
local uuid type
while IFS=: read -r uuid type; do
[[ -n $uuid ]] || continue
networkmanager_dns_connection "$type" || continue
nmcli connection modify "$uuid" \
ipv4.ignore-auto-dns no \
ipv4.dns "" \
ipv6.ignore-auto-dns no \
ipv6.dns "" \
>/dev/null
done < <(nmcli -t -f UUID,TYPE connection show)
}
reapply_active_dns_connections() {
local device type state
while IFS=: read -r device type state; do
[[ -n $device && $state == connected ]] || continue
case "$type" in
wifi|ethernet)
nmcli device reapply "$device" >/dev/null 2>&1 || true
;;
esac
done < <(nmcli -t -f DEVICE,TYPE,STATE device status)
}
reload_dns_stack() {
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
# Load the updated NetworkManager config first, then reapply the active
# profiles. A single conf,dns-full reload here pushes the old active DNS
# settings, making the shell toggle appear one selection behind.
nmcli general reload conf >/dev/null 2>&1 || systemctl reload NetworkManager.service 2>/dev/null || true
reapply_active_dns_connections
fi
systemctl reload systemd-resolved.service 2>/dev/null || systemctl restart systemd-resolved.service
if systemctl is-active --quiet NetworkManager.service 2>/dev/null; then
# A resolved reload/restart can leave per-link DNS stale or empty; ask
# NetworkManager to publish DNS after resolved has reread its config.
nmcli general reload dns-full >/dev/null 2>&1 || true
fi
}
usage() {
echo "Usage: blob-network-dns [Cloudflare|Google|DHCP|Custom]" >&2
}
if (( $# == 0 )); then
current_dns_provider
exit 0
fi
if (( $# > 1 )); then
usage
exit 1
fi
if ! provider=$(provider_from_arg "$1"); then
usage
exit 1
fi
require_root "$provider"
case "$provider" in
Cloudflare)
write_networkmanager_dns "1.1.1.1,1.0.0.1,2606:4700:4700::1111,2606:4700:4700::1001"
set_connection_dns "1.1.1.1 1.0.0.1" "2606:4700:4700::1111 2606:4700:4700::1001"
tee /etc/systemd/resolved.conf >/dev/null <<'EOF'
[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com
FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
DNSOverTLS=opportunistic
EOF
;;
Google)
write_networkmanager_dns "8.8.8.8,8.8.4.4,2001:4860:4860::8888,2001:4860:4860::8844"
set_connection_dns "8.8.8.8 8.8.4.4" "2001:4860:4860::8888 2001:4860:4860::8844"
tee /etc/systemd/resolved.conf >/dev/null <<'EOF'
[Resolve]
DNS=8.8.8.8#dns.google 8.8.4.4#dns.google 2001:4860:4860::8888#dns.google 2001:4860:4860::8844#dns.google
FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
DNSOverTLS=opportunistic
EOF
;;
DHCP)
clear_networkmanager_dns
clear_connection_dns
tee /etc/systemd/resolved.conf >/dev/null <<'EOF'
[Resolve]
DNSOverTLS=no
EOF
;;
Custom)
echo "Enter your DNS servers (space-separated, e.g. '192.168.1.1 1.1.1.1'):"
if ! read -r dns_servers; then
dns_servers=""
fi
dns_servers=$(normalize_servers "$dns_servers")
if [[ -z $dns_servers ]]; then
echo "Error: No DNS servers provided." >&2
exit 1
fi
split_dns_servers "$dns_servers"
write_networkmanager_dns "$dns_servers"
set_connection_dns "$ipv4_dns" "$ipv6_dns"
# blob:heredoc-expands paths=none -- $dns_servers is a normalized,
# single-line DNS server list; the //,/ turns its comma separators into the
# spaces resolved.conf wants. It is written as data, not a path or command.
tee /etc/systemd/resolved.conf >/dev/null <<EOF
[Resolve]
DNS=${dns_servers//,/ }
FallbackDNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
EOF
;;
esac
reload_dns_stack
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
if [ "$EUID" -ne 0 ]; then
exec sudo "$0" "$@"
fi
# ==============================================================================
# Arch Linux: NetworkManager -> iwd Migration (GMU Eduroam Edition)
# ==============================================================================
#
# PREREQUISITES:
# 1. Run as root (sudo).
# 2. Know your GMU NetID and password.
#
# WHAT THIS DOES:
# 1. Disables NetworkManager & wpa_supplicant to prevent conflicts.
# 2. Enables iwd with built-in DHCP (network configuration).
# 3. Sets up systemd-resolved for DNS.
# 4. Creates the secure eduroam profile in /var/lib/iwd/
# ==============================================================================
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
echo -e "${GREEN}=== Switching to iwd for GMU Eduroam ===${NC}"
# 1. INSTALL IWD (If missing)
if ! command -v iwctl &> /dev/null; then
echo "iwd not found. Installing..."
pacman -S --noconfirm iwd
fi
# 2. GATHER CREDENTIALS
echo ""
echo "Enter your GMU credentials."
read -p "GMU Email (e.g., jdoe@gmu.edu): " IDENTITY
read -s -p "Password: " PASSWORD
echo ""
# 3. STOP CONFLICTING SERVICES
echo -e "\n${GREEN}[1/5] Stopping NetworkManager & wpa_supplicant...${NC}"
systemctl stop NetworkManager
systemctl disable NetworkManager
pkill wpa_supplicant
# 4. CONFIGURE IWD (Enable Built-in DHCP)
echo -e "${GREEN}[2/5] Configuring iwd main.conf...${NC}"
mkdir -p /etc/iwd
cat > /etc/iwd/main.conf <<EOF
[General]
EnableNetworkConfiguration=true
[Network]
NameResolvingService=systemd
EOF
# 5. CONFIGURE DNS (systemd-resolved)
echo -e "${GREEN}[3/5] Setting up systemd-resolved DNS...${NC}"
systemctl enable --now systemd-resolved
ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
# 6. CREATE EDUROAM CONFIG
echo -e "${GREEN}[4/5] Creating eduroam provisioning file...${NC}"
cat > /var/lib/iwd/eduroam.8021x <<EOF
[Security]
EAP-Method=PEAP
EAP-Identity=$IDENTITY
EAP-PEAP-Phase2-Method=MSCHAPV2
EAP-PEAP-Phase2-Identity=$IDENTITY
EAP-PEAP-Phase2-Password=$PASSWORD
EOF
chmod 600 /var/lib/iwd/eduroam.8021x
# 7. START IWD & CONNECT
echo -e "${GREEN}[5/5] Starting iwd and connecting...${NC}"
systemctl enable --now iwd
sleep 2
IFACE=$(iwctl device list | grep station | awk '{print $2}' | head -n 1)
if [ -z "$IFACE" ]; then
echo -e "${RED}Error: No wireless interface found!${NC}"
echo "Check 'iwctl device list' manually."
exit 1
fi
echo "Detected Interface: $IFACE"
echo "Scanning..."
iwctl station "$IFACE" scan
sleep 2
echo "Connecting to eduroam..."
iwctl station "$IFACE" connect eduroam
# 8. VERIFY
sleep 5
STATUS=$(iwctl station "$IFACE" show | grep "State" | awk '{print $2}')
if [ "$STATUS" == "connected" ]; then
echo -e "\n${GREEN}SUCCESS! Connected to eduroam.${NC}"
echo "Testing connectivity (pinging google.com)..."
if ping -c 1 google.com &> /dev/null; then
echo -e "${GREEN}Internet is WORKING.${NC}"
else
echo -e "${RED}Connected to WiFi, but no Internet.${NC}"
echo "Check DNS: cat /etc/resolv.conf"
fi
else
echo -e "\n${RED}Connection failed.${NC}"
echo "Debug with: iwctl station $IFACE get-networks"
fi
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# blob:summary=Print the active Wi-Fi connection's password
# blob:group=network
# blob:args=<interface>
set -euo pipefail
interface=${1:?Usage: blob-network-password <interface>}
uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1)
[[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; }
mapfile -t fields < <(nmcli --show-secrets --escape no --get-values \
802-11-wireless-security.key-mgmt,802-11-wireless-security.psk,802-11-wireless-security.wep-key0 \
connection show uuid "$uuid")
key_management=${fields[0]:-}
password=${fields[1]:-}
wep_key=${fields[2]:-}
[[ $key_management != *eap* && $key_management != *ieee8021x* ]] || {
echo "Enterprise Wi-Fi has no shareable password" >&2
exit 1
}
if [[ -z $key_management || $key_management == "none" ]]; then
# NetworkManager models WEP as key-mgmt "none" plus a wep-key.
password=$wep_key
[[ -n $password ]] || { echo "This network has no password" >&2; exit 1; }
fi
[[ -n $password ]] || { echo "Could not read the Wi-Fi password" >&2; exit 1; }
# Stdout is a private pipe to the caller; the secret is never an argument, so
# it never shows up in /proc cmdlines.
printf '%s\n' "$password"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
# blob:summary=Generate a Wi-Fi QR matrix for the shell
# blob:group=network
# blob:args=[--meta] [interface]
set -euo pipefail
# --meta is opt-in so pre-existing consumers of the bare matrix (cloned
# network widgets from before the share card became its own plugin) keep
# parsing this output.
interface=""
emit_meta=false
for arg in "$@"; do
case "$arg" in
--meta) emit_meta=true ;;
*) interface=$arg ;;
esac
done
if [[ -z $interface ]]; then
# Prefer the default-route device: it is the connection the panel and the
# menu's visibility gate describe. Fall back to the first connected Wi-Fi
# device. nmcli localizes state names, so pin the locale, and the prefix
# match accepts states like "connected (externally)".
route_device=$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [[ -n $route_device && -d /sys/class/net/$route_device/wireless ]]; then
interface=$route_device
else
interface=$(LC_ALL=C nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null |
awk -F: '$2 == "wifi" && $3 ~ /^connected/ { print $1; exit }')
fi
fi
[[ -n $interface ]] || { echo "No active Wi-Fi connection" >&2; exit 1; }
uuid=$(nmcli --get-values GENERAL.CON-UUID device show "$interface" | head -n 1)
[[ -n $uuid && $uuid != "--" ]] || { echo "No active Wi-Fi connection" >&2; exit 1; }
mapfile -t fields < <(nmcli --show-secrets --escape no --get-values \
802-11-wireless.ssid,802-11-wireless-security.key-mgmt,802-11-wireless-security.psk,802-11-wireless.hidden,802-11-wireless-security.wep-key0 \
connection show uuid "$uuid")
ssid=${fields[0]:-}
key_management=${fields[1]:-}
password=${fields[2]:-}
hidden=${fields[3]:-no}
wep_key=${fields[4]:-}
[[ -n $ssid ]] || { echo "Could not read the Wi-Fi name" >&2; exit 1; }
[[ $key_management != *eap* && $key_management != *ieee8021x* ]] || {
echo "Enterprise Wi-Fi cannot be shared with a password QR code" >&2
exit 1
}
escape_wifi_qr() {
local value=$1
value=${value//\\/\\\\}
value=${value//;/\\;}
value=${value//,/\\,}
value=${value//:/\\:}
printf '%s' "$value"
}
if [[ -n $key_management && $key_management != "none" ]]; then
[[ -n $password ]] || { echo "Could not read the Wi-Fi password" >&2; exit 1; }
security=WPA
elif [[ -n $wep_key ]]; then
# NetworkManager models WEP as key-mgmt "none" plus a wep-key; encoding it
# as an open network would produce a QR that silently fails to join.
password=$wep_key
security=WEP
else
security=nopass
fi
payload="WIFI:T:$security;S:$(escape_wifi_qr "$ssid");P:$(escape_wifi_qr "$password");"
[[ $hidden == "yes" ]] && payload+="H:true;"
payload+=";"
# Metadata header ahead of the matrix: the interface that was shared, the
# security type, and the SSID last so it may contain tabs. The share card
# renders its title and password row from this line, and a self-detected
# summon learns which interface to fetch the password for.
[[ $emit_meta == "true" ]] && printf 'meta\t%s\t%s\t%s\n' "$interface" "$security" "$ssid"
# ASCII uses two characters per module. Collapse each pair to one 0/1 value
# so the shell can render a square matrix directly with native QML rectangles.
# Margin 4 is the spec quiet zone; the card surround is dark, so this white
# border is all the scanner gets.
ascii=$(printf '%s' "$payload" | qrencode --type ASCII --margin 4 --output -)
while IFS= read -r line; do
row=
for ((column = 0; column < ${#line}; column += 2)); do
[[ ${line:column:2} == *#* ]] && row+=1 || row+=0
done
printf '%s\n' "$row"
done <<<"$ascii"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# blob:summary=Unblock and restart the Wi-Fi service.
echo -e "Unblocking wifi...\n"
rfkill unblock wifi
nmcli networking on
nmcli radio wifi on
nmcli device wifi rescan 2>/dev/null || true
rfkill list wifi
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# blob:summary=Measure live internet speed for one direction
# blob:group=network
# blob:args=[down|up]
set -e
direction="${1:-}"
probe=1.1.1.1
parallel=8
url_count=3
case "$direction" in
down | up)
;;
*)
echo "Usage: blob-network-speedtest [down|up]" >&2
exit 2
;;
esac
format_mbps() {
awk -v value="$1" 'BEGIN {
if (value <= 0) print "0.0"
else if (value < 10) printf "%.1f\n", value
else printf "%.0f\n", value
}'
}
iface=$(ip route get "$probe" 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [[ -z $iface || ! -r /sys/class/net/$iface/statistics/rx_bytes || ! -r /sys/class/net/$iface/statistics/tx_bytes ]]; then
echo "No active network interface" >&2
exit 1
fi
if ! blob-cmd-present curl; then
echo "curl is required" >&2
exit 1
fi
fast_token="YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm"
fast_api_url="https://api.fast.com/netflix/speedtest/v2?https=true&token=$fast_token&urlCount=$url_count"
fast_urls=$(curl -fsS "$fast_api_url" 2>/dev/null | jq -r '.targets[]?.url // empty')
if [[ -z $fast_urls ]]; then
echo "Failed to fetch speed test endpoints" >&2
exit 1
fi
traffic_pids=()
cleanup() {
local pid
for pid in "${traffic_pids[@]}"; do
[[ -n $pid ]] || continue
pkill -TERM -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
done
for pid in "${traffic_pids[@]}"; do
[[ -n $pid ]] || continue
wait "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT
# Round-robin across the returned URLs so we spread load across Netflix OCA nodes.
traffic_worker() {
local urls=("$@")
local url_count=${#urls[@]}
local idx=$RANDOM
if [[ $direction == "down" ]]; then
while true; do
url=${urls[$((idx % url_count))]}
curl -fsS -o /dev/null "$url" 2>/dev/null || return
idx=$((idx + 1))
done
else
while true; do
url=${urls[$((idx % url_count))]}
dd if=/dev/zero bs=1M count=64 2>/dev/null | curl -fsS -o /dev/null -X POST --data-binary @- "$url" 2>/dev/null || return
idx=$((idx + 1))
done
fi
}
for (( i = 0; i < parallel; i++ )); do
traffic_worker $fast_urls &
traffic_pids+=("$!")
done
rx_before=$(cat "/sys/class/net/$iface/statistics/rx_bytes")
tx_before=$(cat "/sys/class/net/$iface/statistics/tx_bytes")
any_alive() {
local pid
for pid in "${traffic_pids[@]}"; do
[[ -n $pid ]] || continue
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
done
return 1
}
while any_alive; do
sleep 1
rx_after=$(cat "/sys/class/net/$iface/statistics/rx_bytes")
tx_after=$(cat "/sys/class/net/$iface/statistics/tx_bytes")
if [[ $direction == "down" ]]; then
rate=$(awk -v before="$rx_before" -v after="$rx_after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 8 / 1000000
}')
else
rate=$(awk -v before="$tx_before" -v after="$tx_after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 8 / 1000000
}')
fi
format_mbps "$rate"
rx_before=$rx_after
tx_before=$tx_after
done
wait 2>/dev/null || true
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
# blob:summary=Print active network status for the shell
# blob:group=network
# blob:args=[--verbose]
verbose=false
internet_probe=1.1.1.1
case "${1:-}" in
"")
;;
--verbose)
verbose=true
;;
*)
echo "Usage: blob-network-status [--verbose]" >&2
exit 2
;;
esac
print_status() {
local device nm state ssid signal freq
device=$(ip route get "$internet_probe" 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
if [[ -z $device ]]; then
printf 'disconnected\t\t\t\n'
return
fi
if [[ ! -d /sys/class/net/$device/wireless ]]; then
printf 'ethernet\t%s\t\t\n' "$device"
return
fi
if blob-cmd-present nmcli; then
nm=$(nmcli -t -f GENERAL.STATE,GENERAL.CONNECTION dev show "$device" 2>/dev/null)
state=$(awk -F: '$1 == "GENERAL.STATE" { print $2; exit }' <<<"$nm")
ssid=$(awk -F: '$1 == "GENERAL.CONNECTION" { print $2; exit }' <<<"$nm")
signal=$(nmcli -t -f IN-USE,SIGNAL dev wifi list ifname "$device" --rescan no 2>/dev/null | awk -F: '$1 == "*" { print $2; exit }')
freq=$(iw dev "$device" link 2>/dev/null | awk '/freq:/ { print $2; exit }')
if [[ $state != 100* ]]; then
printf 'disconnected\t\t\t\n'
return
fi
printf 'wifi\t%s\t%s\t%s\n' "${ssid:-$device}" "$signal" "$freq"
return
fi
printf 'wifi\t%s\t\t\n' "$device"
}
ping_latency_ms() {
local host=$1
LC_ALL=C ping -n -c 1 -W 1 "$host" 2>/dev/null | awk -F'time[=<]' '/time[=<]/ { split($2, parts, " "); print parts[1]; exit }'
}
print_ping_samples() {
local gateway=$1
local tmpdir router_file internet_file
local router_pid="" internet_pid=""
blob-cmd-present ping || return
tmpdir=$(mktemp -d) || return
router_file="$tmpdir/router"
internet_file="$tmpdir/internet"
if [[ -n $gateway ]]; then
ping_latency_ms "$gateway" >"$router_file" &
router_pid=$!
fi
ping_latency_ms "$internet_probe" >"$internet_file" &
internet_pid=$!
if [[ -n $router_pid ]]; then
wait "$router_pid"
printf 'router_ping_ms\t%s\n' "$(cat "$router_file")"
fi
wait "$internet_pid"
printf 'internet_ping_ms\t%s\n' "$(cat "$internet_file")"
rm -rf "$tmpdir"
}
print_verbose() {
local route_json iface gw src prefix link
route_json=$(ip -j route get "$internet_probe" 2>/dev/null)
[[ -z $route_json ]] && return
iface=$(jq -r '.[0].dev // ""' <<<"$route_json" 2>/dev/null)
gw=$(jq -r '.[0].gateway // ""' <<<"$route_json" 2>/dev/null)
src=$(jq -r '.[0].prefsrc // ""' <<<"$route_json" 2>/dev/null)
[[ -z $iface ]] && return
prefix=$(ip -j addr show "$iface" 2>/dev/null | jq -r '.[0].addr_info[]? | select(.family == "inet") | .prefixlen // ""' 2>/dev/null | head -n 1)
printf 'iface\t%s\n' "$iface"
printf 'ip\t%s\n' "$src"
printf 'prefix\t%s\n' "$prefix"
printf 'gateway\t%s\n' "$gw"
if [[ -r /sys/class/net/$iface/statistics/rx_bytes ]]; then
printf 'rx_bytes\t%s\n' "$(cat /sys/class/net/$iface/statistics/rx_bytes)"
fi
if [[ -r /sys/class/net/$iface/statistics/tx_bytes ]]; then
printf 'tx_bytes\t%s\n' "$(cat /sys/class/net/$iface/statistics/tx_bytes)"
fi
if [[ -d /sys/class/net/$iface/wireless ]]; then
printf 'type\twifi\n'
if blob-cmd-present iw; then
link=$(iw dev "$iface" link 2>/dev/null)
if [[ -n $link ]]; then
printf 'ssid\t%s\n' "$(awk '/SSID:/ { sub(/.*SSID: /, ""); print; exit }' <<<"$link")"
printf 'signal_dbm\t%s\n' "$(awk '/signal:/ { print $2; exit }' <<<"$link")"
printf 'freq\t%s\n' "$(awk '/freq:/ { print $2; exit }' <<<"$link")"
printf 'bitrate\t%s %s\n' "$(awk '/tx bitrate:/ { print $3; exit }' <<<"$link")" "$(awk '/tx bitrate:/ { print $4; exit }' <<<"$link")"
fi
fi
else
printf 'type\tethernet\n'
[[ -r /sys/class/net/$iface/speed ]] && printf 'speed\t%s\n' "$(cat /sys/class/net/$iface/speed)"
[[ -r /sys/class/net/$iface/duplex ]] && printf 'duplex\t%s\n' "$(cat /sys/class/net/$iface/duplex)"
fi
print_ping_samples "$gw"
}
if [[ $verbose == "true" ]]; then
print_verbose
else
print_status
fi
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Show the current battery status notification
blob-notify-send -g 󰁹 -u low "$(blob-battery-status)"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Show the current time and date notification
blob-notify-send -g  -u low "$(date +"%A %H:%M · %d %B %Y · Week %V")"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# blob:summary=Wait for the desktop notification server to accept notifications
# blob:args=[timeout-seconds]
# blob:hidden=true
set -uo pipefail
timeout=${1:-10}
notification_server_ready() {
busctl --user call \
org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications \
GetServerInformation >/dev/null 2>&1
}
# The shell has to be up to serve the IPC, and it has to have claimed the
# notification bus name before notify-send has anywhere to deliver.
attempts=$((timeout * 10))
while (( attempts > 0 )); do
if blob-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then
exit 0
fi
attempts=$((attempts - 1))
sleep 0.1
done
exit 1
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Toggle the current weather panel
blob-shell shell toggle blob.weather
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# blob:summary=Show a fuzzy-finder TUI for picking new AUR packages to install.
# blob:requires-sudo=true
fzf_args=(
--multi
--preview 'yay -Siia {1}'
--preview-label='alt-p: toggle description, alt-b/B: toggle PKGBUILD, alt-j/k: scroll, tab: multi-select'
--preview-label-pos='bottom'
--preview-window 'down:65%:wrap'
--bind 'alt-p:toggle-preview'
--bind 'alt-d:preview-half-page-down,alt-u:preview-half-page-up'
--bind 'alt-k:preview-up,alt-j:preview-down'
--bind 'alt-b:change-preview:yay -Gpa {1} | tail -n +5'
--bind 'alt-B:change-preview:yay -Siia {1}'
--color 'pointer:green,marker:green'
)
pkg_names=$(yay -Slqa | fzf "${fzf_args[@]}")
if [[ -n $pkg_names ]]; then
# Add aur/ prefix to each package name and convert to space-separated for yay
source blob-sudo-keepalive
echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm
sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots
blob-show-done
fi
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# blob:summary=Remove all the named packages from the system if they're installed (otherwise ignore).
# blob:args=<packages...>
# blob:requires-sudo=true
installed=()
declare -A installed_exact=()
declare -A selected=()
while IFS= read -r package_name; do
installed_exact[$package_name]=1
done < <(pacman -Qq)
for pkg in "$@"; do
if [[ -n ${installed_exact[$pkg]} && -z ${selected[$pkg]} ]]; then
installed+=("$pkg")
selected[$pkg]=1
fi
done
if (( ${#installed[@]} > 0 )); then
sudo pacman -Rns --noconfirm "${installed[@]}"
fi
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# blob:summary=Show a fuzzy-finder TUI for picking new Arch and OPR packages to install.
# blob:requires-sudo=true
fzf_args=(
--multi
--preview 'pacman -Sii {1}'
--preview-label='alt-p: toggle description, alt-j/k: scroll, tab: multi-select'
--preview-label-pos='bottom'
--preview-window 'down:65%:wrap'
--bind 'alt-p:toggle-preview'
--bind 'alt-d:preview-half-page-down,alt-u:preview-half-page-up'
--bind 'alt-k:preview-up,alt-j:preview-down'
--color 'pointer:green,marker:green'
)
pkg_names=$(pacman -Slq | fzf "${fzf_args[@]}")
if [[ -n $pkg_names ]]; then
source blob-sudo-keepalive
# Convert newline-separated selections to space-separated for pacman
echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm
blob-show-done
fi

Some files were not shown because too many files have changed in this diff Show More