Vendor the command set the menu and shell depend on
This commit is contained in:
Executable
+54
@@ -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()
|
||||||
|
}
|
||||||
|
'
|
||||||
Executable
+13
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+225
@@ -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
|
||||||
Executable
+55
@@ -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"
|
||||||
Executable
+31
@@ -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
|
||||||
Executable
+59
@@ -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"
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+357
@@ -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
|
||||||
Executable
+86
@@ -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
@@ -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
|
||||||
Executable
+125
@@ -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"
|
||||||
Executable
+17
@@ -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"
|
||||||
Executable
+137
@@ -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
|
||||||
Executable
+52
@@ -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
|
||||||
Executable
+89
@@ -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
|
||||||
Executable
+7
@@ -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
@@ -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
|
||||||
Executable
+28
@@ -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
|
||||||
Executable
+28
@@ -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
|
||||||
Executable
+168
@@ -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"
|
||||||
Executable
+122
@@ -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
|
||||||
Executable
+107
@@ -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")"
|
||||||
Executable
+58
@@ -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 ))"
|
||||||
Executable
+14
@@ -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
|
||||||
Executable
+36
@@ -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"
|
||||||
Executable
+288
@@ -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
|
||||||
Executable
+21
@@ -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"
|
||||||
Executable
+370
@@ -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"
|
||||||
Executable
+82
@@ -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
|
||||||
Executable
+26
@@ -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"
|
||||||
Executable
+33
@@ -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
|
||||||
Executable
+149
@@ -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"
|
||||||
Executable
+33
@@ -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
|
||||||
Executable
+70
@@ -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
|
||||||
Executable
+63
@@ -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
|
||||||
Executable
+77
@@ -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
|
||||||
Executable
+232
@@ -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
|
||||||
Executable
+236
@@ -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")"
|
||||||
Executable
+23
@@ -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}]'
|
||||||
Executable
+50
@@ -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"
|
||||||
Executable
+32
@@ -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
|
||||||
Executable
+18
@@ -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}'
|
||||||
Executable
+9
@@ -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
|
||||||
Executable
+6
@@ -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
|
||||||
Executable
+84
@@ -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"
|
||||||
Executable
+50
@@ -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
|
||||||
Executable
+19
@@ -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
|
||||||
Executable
+5
@@ -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 ]]
|
||||||
Executable
+56
@@ -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
|
||||||
Executable
+24
@@ -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
|
||||||
Executable
+17
@@ -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
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+23
@@ -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
|
||||||
Executable
+6
@@ -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"
|
||||||
Executable
+6
@@ -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"
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Check whether a webcam is available
|
||||||
|
|
||||||
|
[[ -n $(blob-capture-webcam-list) ]]
|
||||||
Executable
+12
@@ -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
|
||||||
Executable
+21
@@ -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
|
||||||
Executable
+114
@@ -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")
|
||||||
Executable
+115
@@ -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
|
||||||
Executable
+5
@@ -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
|
||||||
Executable
+40
@@ -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
@@ -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
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+7
@@ -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
|
||||||
Executable
+168
@@ -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
|
||||||
Executable
+21
@@ -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"
|
||||||
Executable
+21
@@ -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}"
|
||||||
Executable
+181
@@ -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
|
||||||
Executable
+15
@@ -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"
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+19
@@ -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
|
||||||
Executable
+14
@@ -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"
|
||||||
Executable
+77
@@ -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"
|
||||||
@@ -67,11 +67,6 @@ desktop_name="${desktop_file##*/}"
|
|||||||
desktop_name="${desktop_name%.desktop}"
|
desktop_name="${desktop_name%.desktop}"
|
||||||
exec_line="$(sed -n 's/^Exec=//p' "$desktop_file" | head -1)"
|
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
|
if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then
|
||||||
BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name"
|
BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
Executable
+6
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# blob:summary=Launch emojis
|
||||||
|
# blob:group=menu
|
||||||
|
# blob:examples=blob menu emoji
|
||||||
|
|
||||||
|
blob-shell shell toggle blob.emojis
|
||||||
Executable
+20
@@ -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
|
||||||
Executable
+48
@@ -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 "$@"
|
||||||
Executable
+313
@@ -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
|
||||||
Executable
+597
@@ -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
|
||||||
Executable
+46
@@ -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
|
||||||
Executable
+50
@@ -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
|
||||||
Executable
+12
@@ -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"
|
||||||
Executable
+192
@@ -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
|
||||||
Executable
+319
@@ -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
|
||||||
Executable
+112
@@ -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
|
||||||
Executable
+34
@@ -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"
|
||||||
Executable
+95
@@ -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"
|
||||||
Executable
+11
@@ -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
|
||||||
Executable
+130
@@ -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
|
||||||
Executable
+142
@@ -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
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Show the current battery status notification
|
||||||
|
|
||||||
|
blob-notify-send -g -u low "$(blob-battery-status)"
|
||||||
Executable
+5
@@ -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")"
|
||||||
Executable
+30
@@ -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
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# blob:summary=Toggle the current weather panel
|
||||||
|
|
||||||
|
blob-shell shell toggle blob.weather
|
||||||
Executable
+29
@@ -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
|
||||||
Executable
+24
@@ -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
|
||||||
Executable
+26
@@ -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
Reference in New Issue
Block a user