Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
Executable
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
set -e
blob_bin_dir="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
command_file() {
local name="$1"
if [[ -x "$blob_bin_dir/$name" ]]; then
printf '%s\n' "$blob_bin_dir/$name"
else
command -v "$name" 2>/dev/null
fi
}
resolve_command() {
local words=("$@")
local word_count=${#words[@]}
local words_used name joined
for (( words_used = word_count; words_used > 0; words_used-- )); do
joined=$(printf '%s-' "${words[@]:0:words_used}")
name="blob-${joined%-}"
if [[ -n $(command_file "$name") ]]; then
printf '%s %s\n' "$words_used" "$name"
return 0
fi
done
return 1
}
metadata_value() {
sed -n "s/^# blob:$2=//p" "$1" | head -1
}
is_hidden() {
grep -q '^# blob:hidden=true' "$1" 2>/dev/null
}
list_commands() {
local file base summary
for file in "$blob_bin_dir"/blob-*; do
[[ -f $file ]] || continue
is_hidden "$file" && continue
base=$(basename "$file")
summary=$(metadata_value "$file" summary)
printf ' %-28s %s\n' "${base#blob-}" "$summary"
done
}
show_command_help() {
local name="$1"
local file
file=$(command_file "$name")
printf '%s\n' "$(metadata_value "$file" summary)"
local args
args=$(metadata_value "$file" args)
printf '\nUsage: %s %s\n' "${name//-/ }" "$args"
local examples
examples=$(metadata_value "$file" examples)
[[ -n $examples ]] && printf '\nExample: %s\n' "$examples"
return 0
}
show_usage() {
cat <<USAGE
Usage: blob <command> [args...]
Commands are files named blob-<area>-<verb>. Both forms work:
blob theme set tokyo-night
blob-theme-set tokyo-night
Available commands:
USAGE
list_commands
printf '\nRun "blob help <command>" for details on one.\n'
}
case "${1-}" in
"" | help | --help | -h)
shift || true
if (( $# == 0 )); then
show_usage
exit 0
fi
if resolution=$(resolve_command "$@"); then
show_command_help "${resolution#* }"
exit 0
fi
echo "Unknown command: $*" >&2
exit 1
;;
esac
if ! resolution=$(resolve_command "$@"); then
echo "Unknown command: $*" >&2
echo 'Run "blob" to list commands.' >&2
exit 1
fi
words_used="${resolution%% *}"
command_name="${resolution#* }"
shift "$words_used"
exec "$(command_file "$command_name")" "$@"
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# blob:summary=Cache background switcher thumbnails for the current theme
theme_name=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
blob-menu-images \
--cache-only \
"$HOME/.local/state/blob/current/theme/backgrounds" \
"$HOME/.config/blob/backgrounds/$theme_name"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Show current background
# blob:examples=blob theme bg current
BACKGROUND_PATH=$(readlink -f "$HOME/.local/state/blob/current/background" 2>/dev/null)
if [[ -n $BACKGROUND_PATH ]]; then
basename -- "$BACKGROUND_PATH" | perl -pe 's/\.[^.]+$//; s/^\d+-//; s/-/ /g; s/\b(\w)/\U$1/g'
else
echo "Unknown"
fi
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# blob:summary=Open the current theme's user background folder
CURRENT_THEME_NAME=$(cat "$HOME/.local/state/blob/current/theme.name")
THEME_USER_BACKGROUNDS="$HOME/.config/blob/backgrounds/$CURRENT_THEME_NAME"
mkdir -p "$THEME_USER_BACKGROUNDS"
nautilus "$THEME_USER_BACKGROUNDS"
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# blob:summary=Cycle to the next background for the current theme
# blob:examples=blob theme bg next
THEME_NAME=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
THEME_BACKGROUNDS_PATH="$HOME/.local/state/blob/current/theme/backgrounds/"
USER_BACKGROUNDS_PATH="$HOME/.config/blob/backgrounds/$THEME_NAME/"
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
mapfile -d '' -t BACKGROUNDS < <(
find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGROUNDS_PATH" -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
)
TOTAL=${#BACKGROUNDS[@]}
if (( TOTAL == 0 )); then
blob-notify-send "No background was found for theme" -t 2000
else
# Get current background from symlink
if [[ -L $CURRENT_BACKGROUND_LINK ]]; then
CURRENT_BACKGROUND=$(readlink "$CURRENT_BACKGROUND_LINK")
else
# Default to first background if no symlink exists
CURRENT_BACKGROUND=""
fi
# Find current background index
INDEX=-1
for i in "${!BACKGROUNDS[@]}"; do
if [[ ${BACKGROUNDS[$i]} == "$CURRENT_BACKGROUND" ]]; then
INDEX=$i
break
fi
done
# Get next background (wrap around)
if (( INDEX == -1 )); then
# Use the first background when no match was found
NEW_BACKGROUND="${BACKGROUNDS[0]}"
else
NEXT_INDEX=$(((INDEX + 1) % TOTAL))
NEW_BACKGROUND="${BACKGROUNDS[$NEXT_INDEX]}"
fi
blob-bg-set "$NEW_BACKGROUND"
fi
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# blob:summary=Set the current background image
# blob:args=<path-to-image>
# blob:examples=blob theme bg set ~/Pictures/background.png
if [[ -z $1 ]]; then
echo "Usage: blob-bg-set <path-to-image>" >&2
exit 1
fi
BACKGROUND="$(realpath "$1")"
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
if [[ ! -f $BACKGROUND ]]; then
echo "File does not exist: $BACKGROUND" >&2
exit 1
fi
# Create symlink to the new background
ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK"
# Update the live shell background immediately when it is running. The
# background plugin also polls this symlink, but IPC avoids the visible delay.
blob-shell -q background set "$BACKGROUND"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# blob:summary=Open the Blob background switcher
# blob:group=theme
# blob:name=bg-switcher
# blob:aliases=blob background
theme_name=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
current_background=$(readlink -f "$HOME/.local/state/blob/current/background" 2>/dev/null)
blob-menu-images \
--selected "$current_background" \
"$HOME/.local/state/blob/current/theme/backgrounds" \
"$HOME/.config/blob/backgrounds/$theme_name"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# blob:summary=Print the current working directory of the active terminal window
# blob:hidden=true
terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}')
kitty_socket="$XDG_RUNTIME_DIR/blob-kitty-$terminal_pid"
cwd=""
if [[ -S $kitty_socket ]]; then
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
jq -r '.[].tabs[].windows[].cwd // empty')
else
shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
if [[ -n $shell_pid ]]; then
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
shell=$(readlink -f "/proc/$shell_pid/exe" 2>/dev/null)
grep -Fqsx "$shell" /etc/shells || cwd=""
fi
fi
if [[ -d $cwd ]]; then
echo "$cwd"
else
echo "$HOME"
fi
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# blob:summary=Check whether any required commands are missing
for cmd in "$@"; do
if ! command -v "$cmd" &>/dev/null; then
exit 0
fi
done
exit 1
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# blob:summary=Check whether all required commands are available
for cmd in "$@"; do
command -v "$cmd" &>/dev/null || exit 1
done
exit 0
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# blob:summary=Set the default browser for Blob and XDG handlers
# blob:args=[chromium|chrome|brave|brave-origin|edge|firefox|zen]
# blob:examples=blob default browser firefox | blob default browser brave
if (($# == 0)); then
case "$(env -u BROWSER xdg-settings get default-web-browser)" in
chromium.desktop) echo "chromium" ;;
google-chrome.desktop) echo "chrome" ;;
brave-browser.desktop) echo "brave" ;;
brave-origin.desktop) echo "brave-origin" ;;
microsoft-edge.desktop) echo "edge" ;;
firefox.desktop) echo "firefox" ;;
zen.desktop) echo "zen" ;;
*) env -u BROWSER xdg-settings get default-web-browser ;;
esac
exit 0
fi
case "$1" in
chromium) desktop_id="chromium.desktop"; name="Chromium"; glyph= ;;
chrome) desktop_id="google-chrome.desktop"; name="Chrome"; glyph=󰊯 ;;
brave) desktop_id="brave-browser.desktop"; name="Brave"; glyph=󰖟 ;;
brave-origin) desktop_id="brave-origin.desktop"; name="Brave Origin"; glyph=󰖟 ;;
edge) desktop_id="microsoft-edge.desktop"; name="Edge"; glyph=󰇩 ;;
firefox) desktop_id="firefox.desktop"; name="Firefox"; glyph=󰈹 ;;
zen) desktop_id="zen.desktop"; name="Zen"; glyph=󰖟 ;;
*)
echo "Usage: blob-default-browser <chromium|chrome|brave|brave-origin|edge|firefox|zen>"
exit 1
;;
esac
env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1
blob-notify-send -g $glyph "$name is now the default browser"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# blob:summary=Set the default editor used by blob-launch-editor
# blob:args=[code|cursor|zed|sublime_text|helix|vim|emacs|nvim]
# blob:examples=blob default editor | blob default editor code | blob default editor helix
editor_file="$HOME/.local/state/blob/defaults/editor"
if (($# == 0)); then
if [[ -f $editor_file ]]; then
read -r editor <"$editor_file"
fi
[[ -n $editor ]] && echo "$editor" || echo "nvim"
exit 0
fi
case "$1" in
code) editor="code"; name="VSCode"; glyph= ;;
cursor) editor="cursor"; name="Cursor"; glyph= ;;
zed | zeditor) editor="zeditor"; name="Zed"; glyph= ;;
sublime_text) editor="sublime_text"; name="Sublime Text"; glyph= ;;
helix) editor="helix"; name="Helix"; glyph= ;;
vim) editor="vim"; name="Vim"; glyph= ;;
emacs) editor="emacs"; name="Emacs"; glyph= ;;
nvim) editor="nvim"; name="Neovim"; glyph= ;;
*)
echo "Usage: blob-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
exit 1
;;
esac
mkdir -p "$(dirname "$editor_file")"
printf '%s\n' "$editor" >"$editor_file"
blob-notify-send -g $glyph "$name is now the default editor"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# blob:summary=Set the default terminal used by xdg-terminal-exec
# blob:args=[alacritty|foot|ghostty|kitty]
# blob:examples=blob default terminal ghostty | blob default terminal kitty
if (($# == 0)); then
desktop_id=$(xdg-terminal-exec --print-id 2>/dev/null || true)
desktop_id=${desktop_id%%:*}
case "$desktop_id" in
Alacritty.desktop) echo "alacritty" ;;
foot.desktop) echo "foot" ;;
com.mitchellh.ghostty.desktop) echo "ghostty" ;;
kitty.desktop) echo "kitty" ;;
*) echo "$desktop_id" ;;
esac
exit 0
fi
case "$1" in
alacritty) desktop_id="Alacritty.desktop"; name="Alacritty"; glyph= ;;
foot) desktop_id="foot.desktop"; name="Foot"; glyph= ;;
ghostty) desktop_id="com.mitchellh.ghostty.desktop"; name="Ghostty"; glyph= ;;
kitty) desktop_id="kitty.desktop"; name="Kitty"; glyph= ;;
*)
echo "Usage: blob-default-terminal <alacritty|foot|ghostty|kitty>"
exit 1
;;
esac
cat >~/.config/xdg-terminals.list <<EOF
# Terminal emulator preference order for xdg-terminal-exec
# The first found and valid terminal will be used
$desktop_id
EOF
blob-notify-send -g $glyph "$name is now the default terminal"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/python3
# blob:summary=Pick files with the desktop file chooser
# blob:args=[--title <title>] [--multiple] [--directory] [--extensions "<ext ext...>"]
# blob:examples=blob file select --title "Send with Tailscale" --multiple | blob file select --title "Pick image" --extensions "png svg" | blob file select --title "Share folder" --directory
# Python rather than bash, alone among the commands here, because the portal
# answers a request with a Response signal addressed to the connection that
# asked, and D-Bus delivers a directed signal only to that connection. Every
# shell-callable client — gdbus call, busctl call, dbus-send — opens its own
# connection and exits before the answer arrives, and gdbus monitor registers
# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding
# a single connection across both the call and the wait is the whole job, and
# bash has no way to hold one.
import argparse
import os
import sys
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib
# A dialog nobody ever answers would otherwise keep this process, and whatever
# waits on its output, alive forever.
ANSWER_TIMEOUT_SEC = 600
# Callers act on these: nothing picked is a decision, a chooser that never ran
# is a fault, and the two want different handling.
EXIT_NOTHING_PICKED = 1
EXIT_CHOOSER_FAILED = 2
def main():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--title", default="Select file")
parser.add_argument("--multiple", action="store_true")
parser.add_argument("--directory", action="store_true")
parser.add_argument("--extensions", default="")
args, unknown = parser.parse_known_args()
if unknown:
print("blob-file-select: unknown option %s" % unknown[0], file=sys.stderr)
return EXIT_CHOOSER_FAILED
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
loop = GLib.MainLoop()
uris = []
def on_response(connection, sender, path, interface, signal, params):
code, results = params.unpack()
if code == 0:
uris.extend(results.get("uris", []))
loop.quit()
def subscribe(path):
bus.signal_subscribe(
"org.freedesktop.portal.Desktop",
"org.freedesktop.portal.Request",
"Response",
path,
None,
Gio.DBusSignalFlags.NONE,
on_response,
)
# The request path is derived from our bus name and the token we pass, so it
# can be subscribed to up front. Asking first would race a dialog that gets
# answered immediately.
token = "blob%d" % os.getpid()
sender = bus.get_unique_name()[1:].replace(".", "_")
predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token)
subscribe(predicted)
options = {
"handle_token": GLib.Variant("s", token),
"multiple": GLib.Variant("b", args.multiple),
}
if args.directory:
options["directory"] = GLib.Variant("b", True)
# Filters name file formats, which a directory chooser has no use for.
if args.extensions and not args.directory:
# Glob matching in the chooser is case-sensitive, so cover both cases.
exts = [ext.lstrip(".").lower() for ext in args.extensions.split()]
patterns = [(0, "*." + ext) for ext in exts] + [(0, "*." + ext.upper()) for ext in exts]
label = " ".join("*." + ext for ext in exts)
filters = GLib.Variant("a(sa(us))", [(label, patterns)])
options["filters"] = filters
options["current_filter"] = GLib.Variant("(sa(us))", (label, patterns))
handle = bus.call_sync(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.FileChooser",
"OpenFile",
GLib.Variant("(ssa{sv})", ("", args.title, options)),
None,
Gio.DBusCallFlags.NONE,
-1,
None,
).unpack()[0]
# Portals predating the token convention answer on a path of their choosing.
if handle != predicted:
subscribe(handle)
GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit)
loop.run()
for uri in uris:
print(GLib.filename_from_uri(uri)[0])
return 0 if uris else EXIT_NOTHING_PICKED
if __name__ == "__main__":
try:
sys.exit(main())
except GLib.Error as error:
print("blob-file-select: %s" % error.message, file=sys.stderr)
sys.exit(EXIT_CHOOSER_FAILED)
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# blob:summary=Run a named hook from ~/.config/blob/hooks/<name> and ~/.config/blob/hooks/<name>.d/.
# blob:args=[name] [args...]
set -e
if (( $# < 1 )); then
echo "Usage: blob-hook [name] [args...]"
exit 1
fi
HOOK=$1
HOOK_PATH="$HOME/.config/blob/hooks/$1"
HOOK_DIR="$HOOK_PATH.d"
shift
if [[ -f $HOOK_PATH ]]; then
bash "$HOOK_PATH" "$@" || echo "Hook failed: $HOOK_PATH"
fi
if [[ -d $HOOK_DIR ]]; then
for hook in "$HOOK_DIR"/*; do
[[ -f $hook ]] || continue
[[ $hook == *.sample ]] && continue
bash "$hook" "$@" || echo "Hook failed: $hook"
done
fi
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# blob:summary=Returns true when clamshell mode is active
# blob:hidden=true
# Clamshell = lid closed while driving one or more external monitors.
blob-hw-laptop-closed && blob-hw-external
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# blob:summary=Print the most likely display backlight device.
# blob:examples=blob-hw-display
backlight_path="${BLOB_BACKLIGHT_PATH:-/sys/class/backlight}"
# Start with the first possible output, then refine to the most likely given an order heuristic.
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)"
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends.
for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
if [[ -e $candidate ]]; then
device="${candidate##*/}"
break
fi
done
if [[ -n $device ]]; then
printf '%s\n' "$device"
else
exit 1
fi
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Returns true when an external monitor is physically connected.
drm_path="${BLOB_DRM_PATH:-/sys/class/drm}"
for status in "$drm_path"/card*-*/status; do
[[ -e $status ]] || continue
[[ $status =~ -(eDP|LVDS|DSI)-[^/]+/status$ ]] && continue
[[ $(< $status) == "connected" ]] && exit 0
done
exit 1
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# blob:summary=Returns true when the laptop lid is closed
# blob:hidden=true
for state in /proc/acpi/button/lid/*/state; do
[[ -r $state ]] || continue
[[ $(< "$state") == *"closed"* ]] && exit 0
done
exit 1
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
# blob:summary=Match against the computer's DMI product name or product family (case-insensitive).
# blob:args=<pattern>
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# blob:summary=Focus a Hyprland window by application identity
# blob:args=<app-name>
# blob:examples=blob hyprland focus app Slack
usage() {
echo "Usage: blob-hypr-focus <app-name>" >&2
exit 1
}
app=${1:-}
[[ -n $app ]] || usage
# Agent terminals notify as kitty/foot/etc. while their shared window class is
# org.blob.agent, leaving the terminal name only in initialTitle. So match
# by class first, then fall back to the launch-time title of agent windows.
address=$(
hyprctl clients -j 2>/dev/null |
jq -r --arg pattern "$app" \
'def matches($value): ($value // "") | test($pattern; "i");
first(
(.[] | select(matches(.class))),
(.[] | select(.initialClass == "org.blob.agent" and matches(.initialTitle)))
).address // empty'
)
[[ -n $address ]] || exit 1
hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || \
hyprctl dispatch focuswindow "address:$address" >/dev/null
+252
View File
@@ -0,0 +1,252 @@
#!/bin/bash
# blob:summary=Apply clamshell display state to Hyprland monitors
# blob:hidden=true
TOGGLES_DIR="$HOME/.local/state/blob/toggles/hypr"
CLAMSHELL_FLAG="$TOGGLES_DIR/internal-monitor-clamshell.lua"
MANUAL_DISABLE_FLAG="$TOGGLES_DIR/internal-monitor-disable.lua"
SCALE_STATE="$TOGGLES_DIR/internal-monitor-scale"
MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
INTERNAL=$(blob-hypr-monitor-laptop)
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
# name that is not a plain connector string could execute on the next reload.
# Names come from hyprctl; a user-created headless output can carry anything.
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe internal monitor name" >&2
exit 1
fi
valid_scale() {
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
}
scales_match() {
local left="$1"
local right="$2"
valid_scale "$left" && valid_scale "$right" || return 1
awk -v left="$left" -v right="$right" 'BEGIN {
diff = left - right
if (diff < 0) diff = -diff
exit(diff < 0.001 ? 0 : 1)
}'
}
lua_identifier() {
[[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]
}
# Value assigned by `local <name> = ...`, without its quotes or trailing comment.
# Only a lone scalar counts: an expression like `1080 / 720` must stay unresolved
# so the caller falls back rather than applying the first half of the sum.
lua_local_value() {
local name="$1" value
lua_identifier "$name" && [[ -f $MONITOR_LUA ]] || return 0
value=$(sed -nE 's/^[[:space:]]*local[[:space:]]+'"$name"'[[:space:]]*=[[:space:]]*("[^"]*"|[^"[:space:]]+)[[:space:]]*(--.*)?$/\1/p' "$MONITOR_LUA" | head -1)
[[ $value == \"*\" ]] && value="${value:1:-1}"
printf '%s\n' "$value"
}
# A quoted capture is a Lua string and stands for itself; a bare word may instead
# name a local the rule refers to, as the shipped scale = blob_monitor_scale
# does. A bare word naming no local is left alone to fail validation.
lua_scalar() {
local value="$1" resolved
if [[ $value == \"*\" ]]; then
printf '%s\n' "${value:1:-1}"
return
fi
resolved=$(lua_local_value "$value")
printf '%s\n' "${resolved:-$value}"
}
# The config with its comments cut away, so commented-out text can pose neither
# as a rule nor as one of its keys.
monitor_rules() {
[[ -f $MONITOR_LUA ]] || return 0
sed -E -e 's/--\[\[[^]]*\]\]//g' -e 's/--.*$//' "$MONITOR_LUA"
}
monitor_rule_regex() {
printf '^[[:space:]]*hl\\.monitor\\(\\{.*output[[:space:]]*=[[:space:]]*"%s"' "$1"
}
# A key is preceded by a table separator, so a longer key cannot stand in for it,
# and its value is the whole of what sits between the `=` and the next separator.
# Anything else is an expression this cannot evaluate.
configured_monitor_value() {
local output="$1" key="$2" value
value=$(monitor_rules | sed -nE '/'"$(monitor_rule_regex "$output")"'/s/.*[{,;[:space:]]'"$key"'[[:space:]]*=[[:space:]]*("[^"]*"|[^,;}[:space:]]+)[[:space:]]*([,;}].*)?$/\1/p' | head -1)
lua_scalar "$value"
}
configured_internal_monitor_value() {
[[ -n $INTERNAL ]] || return 0
configured_monitor_value "$INTERNAL" "$1"
}
configured_monitor_scale() {
local scale
scale=$(configured_internal_monitor_value scale)
# An internal rule that names a scale settles it, even when the name resolves
# to nothing usable. Only a rule that names none at all defers to the catch-all,
# which is the scale Blob has always applied for that config.
[[ -n $scale ]] || scale=$(configured_monitor_value "" scale)
# No rule carries a scale at all: fall back to Blob's own knob.
[[ -n $scale ]] || scale=$(lua_local_value blob_monitor_scale)
printf '%s\n' "$scale"
}
current_internal_scale() {
[[ -n $INTERNAL ]] || return 0
hyprctl monitors all -j | jq -r --arg internal "$INTERNAL" '.[] | select(.name == $internal and .disabled != true) | .scale' | head -1
}
store_internal_scale() {
local scale="$1"
valid_scale "$scale" || return 0
mkdir -p "$TOGGLES_DIR"
printf '%s\n' "$scale" >"$SCALE_STATE"
}
remember_internal_scale() {
local scale
scale=$(current_internal_scale)
store_internal_scale "$scale"
}
# $1 is the configured scale when the caller has already read it, so one sync
# does not parse the config twice.
read_monitor_scale() {
local scale
if (( $# )); then
scale="$1"
else
scale=$(configured_monitor_scale)
fi
if valid_scale "$scale"; then
echo "$scale"
return
fi
if [[ -f $SCALE_STATE ]]; then
scale=$(<"$SCALE_STATE")
if valid_scale "$scale"; then
echo "$scale"
return
fi
fi
echo 2
}
read_monitor_position() {
local position
position=$(configured_internal_monitor_value position)
if [[ $position =~ ^[-[:alnum:]_.+]+$ ]]; then
echo "$position"
return
fi
echo auto
}
enable_internal_output() {
[[ -n $INTERNAL ]] || return 0
local scale="${1:-}"
local position
[[ -n $scale ]] || scale=$(read_monitor_scale)
position=$(read_monitor_position)
hyprctl eval "hl.monitor({ output = \"$INTERNAL\", mode = \"preferred\", position = \"$position\", scale = $scale })" >/dev/null 2>&1 || true
}
sync_internal_scale() {
[[ -n $INTERNAL ]] || return 0
local configured_scale
local desired_scale
local active_scale
configured_scale=$(configured_monitor_scale)
active_scale=$(current_internal_scale)
# A config without a usable number -- the default "auto", or an expression
# only Hyprland's Lua can evaluate -- delegates the scale to the compositor,
# so whatever it resolved for the enabled panel IS the configured scale.
# There is no number to correct it toward: substituting one makes the scale
# flap between that number and the compositor's own value on every idle-wake.
# Only a panel that is off altogether still gets a hand below, from the
# remembered scale.
if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then
return 0
fi
desired_scale=$(read_monitor_scale "$configured_scale")
scales_match "$active_scale" "$desired_scale" && return 0
enable_internal_output "$desired_scale"
}
dpms_internal() {
local action="$1"
[[ -n $INTERNAL ]] || return 0
hyprctl dispatch "hl.dsp.dpms({ action = \"$action\", monitor = \"$INTERNAL\" })" >/dev/null 2>&1 || true
}
enable_internal() {
local changed=0
if [[ -f $CLAMSHELL_FLAG ]]; then
rm -f "$CLAMSHELL_FLAG"
changed=1
fi
if (( changed )); then
hyprctl reload >/dev/null 2>&1 || true
fi
[[ -f $MANUAL_DISABLE_FLAG ]] && blob-hypr-monitor-external && return 0
sync_internal_scale
if (( changed )); then
dpms_internal enable
fi
}
disable_internal() {
[[ -n $INTERNAL ]] || exit 0
[[ -f $MANUAL_DISABLE_FLAG ]] && return 0
mkdir -p "$TOGGLES_DIR"
remember_internal_scale
local config
config=$(printf 'hl.monitor({ output = "%s", disabled = true })' "$INTERNAL")
if [[ ! -f $CLAMSHELL_FLAG ]] || [[ $(< "$CLAMSHELL_FLAG") != $config ]]; then
printf '%s\n' "$config" >"$CLAMSHELL_FLAG"
hyprctl reload >/dev/null 2>&1 || true
fi
}
blob-hypr-monitor-internal recover >/dev/null 2>&1 || true
blob-hypr-monitor-mirror recover >/dev/null 2>&1 || true
if blob-hw-clamshell && blob-hypr-monitor-external; then
disable_internal
else
enable_internal
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Returns true when Hyprland has an active external monitor
# blob:hidden=true
hyprctl monitors all -j | jq -e '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not) | select(.disabled == false)' >/dev/null 2>&1
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Print the name of the currently focused Hyprland monitor.
hyprctl monitors -j | jq -r '.[] | select(.focused == true).name'
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# blob:summary=Enable, disable, toggle, or recover the internal laptop display
# blob:args=<on|off|toggle|recover>
TOGGLE="internal-monitor-disable"
TOGGLE_FLAG="$HOME/.local/state/blob/toggles/hypr/$TOGGLE.lua"
MIRROR_TOGGLE="internal-monitor-mirror"
INTERNAL=$(blob-hypr-monitor-laptop)
wake() {
hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 || true
}
on() {
if blob-hypr-toggle-enabled $TOGGLE; then
blob-hypr-toggle $TOGGLE off
blob-notify-send -g 󰍹 "Laptop display enabled"
fi
wake
}
off() {
if [[ -z $INTERNAL ]]; then
blob-notify-send -g 󰍹 "No laptop display found"
exit 1
fi
# The name is written into generated Lua below, so only a plain connector
# name may pass; anything else could execute on the next reload.
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
blob-notify-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
if ! blob-hypr-monitor-external; then
blob-notify-send -g 󰍹 "Can't disable the only active display"
exit 1
fi
if blob-hypr-toggle-disabled $TOGGLE && blob-hypr-toggle-disabled $MIRROR_TOGGLE; then
printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG"
blob-notify-send -g 󰍹 "Laptop display disabled"
hyprctl reload
fi
}
recover() {
# Runs from the clamshell watcher every few seconds, so it must be a no-op
# unless it actually re-enables a display: an unconditional wake here undoes
# lock-screen blanking and races the resume modeset into a visible flash.
blob-hypr-monitor-external && return 0
blob-hypr-toggle-enabled $TOGGLE || return 0
blob-hypr-toggle $TOGGLE off
wake
}
toggle() {
if blob-hypr-toggle-enabled $TOGGLE; then
on
else
off
fi
}
case "$1" in
on) on ;;
off) off ;;
toggle) toggle ;;
recover) recover ;;
*)
echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2
exit 1
;;
esac
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Print the name of the built-in laptop display, including disabled outputs.
hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# blob:summary=Enable, disable, toggle, or recover mirroring the internal display to an external monitor
# blob:args=<on|off|toggle|recover>
TOGGLE="internal-monitor-mirror"
TOGGLE_FLAG="$HOME/.local/state/blob/toggles/hypr/$TOGGLE.lua"
DISABLE_TOGGLE="internal-monitor-disable"
INTERNAL=$(blob-hypr-monitor-laptop)
# The first active external monitor
EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not).name' | head -n 1)
on() {
if [[ -z $EXTERNAL ]]; then
blob-notify-send -g 󰍹 "No external monitors found for mirror"
exit 1
fi
if [[ -z $INTERNAL ]]; then
blob-notify-send -g 󰍹 "No laptop monitor found to mirror"
exit 1
fi
# Both names are written into generated Lua below, so only plain connector
# names may pass; a user-created headless output can carry any name.
for output in "$INTERNAL" "$EXTERNAL"; do
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
blob-notify-send -g 󰍹 "Refusing unsafe monitor name"
exit 1
fi
done
blob-hypr-toggle $DISABLE_TOGGLE off
if blob-hypr-toggle-disabled $TOGGLE; then
printf 'hl.monitor({ output = "%s", mode = "preferred", position = "auto", scale = 1, mirror = "%s" })\n' "$EXTERNAL" "$INTERNAL" >"$TOGGLE_FLAG"
blob-notify-send -g 󰍹 "Mirroring enabled ($EXTERNAL)"
hyprctl reload
fi
}
off() {
if blob-hypr-toggle-enabled $TOGGLE; then
blob-hypr-toggle $TOGGLE off
blob-notify-send -g 󰍹 "Extended mode restored"
fi
}
toggle() {
if blob-hypr-toggle-enabled $TOGGLE; then
off
else
on
fi
}
recover() {
if ! blob-hypr-monitor-external && blob-hypr-toggle-enabled $TOGGLE; then
blob-hypr-toggle $TOGGLE off
fi
}
case "$1" in
on) on ;;
off) off ;;
toggle) toggle ;;
recover) recover ;;
*)
echo "Usage: $(basename "$0") {on|off|toggle|recover}" >&2
exit 1
;;
esac
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
# blob:summary=Show, set, or adjust focused Hyprland monitor scaling
# blob:args=[up|down|SCALE]
# blob:examples=blob hyprland monitor scaling | blob hyprland monitor scaling 1.6 | blob hyprland monitor scaling up | blob hyprland monitor scaling down
SCALES=(1 1.25 1.6 2 3 4)
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blob"
SCALE_LOG="$STATE_DIR/monitor-scaling.log"
usage() {
echo "Usage: blob-hypr-monitor-scaling [up|down|SCALE]"
}
focused_monitor_scale() {
hyprctl monitors -j | jq -er '.[] | select(.focused == true) | .scale'
}
cmdline_for_pid() {
local pid="$1"
[[ -r /proc/$pid/cmdline ]] || return 0
tr '\0\t\n' ' ' <"/proc/$pid/cmdline" | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]+$//'
}
audit_scale_change() {
local requested="$1"
local active_monitor="$2"
local current_scale="$3"
local new_scale="$4"
local parent_pid="$PPID"
local grandparent_pid
local parent_cmd
local grandparent_cmd
mkdir -p "$STATE_DIR" || return 0
grandparent_pid=$(ps -o ppid= -p "$parent_pid" 2>/dev/null | tr -d ' ')
parent_cmd=$(cmdline_for_pid "$parent_pid")
grandparent_cmd=$(cmdline_for_pid "$grandparent_pid")
printf 'at=%s\trequested=%s\tcurrent=%s\tnew=%s\tmonitor=%s\tpid=%s\tppid=%s\tparent=%s\tgppid=%s\tgrandparent=%s\n' \
"$(date --iso-8601=seconds)" \
"$requested" \
"$current_scale" \
"$new_scale" \
"$active_monitor" \
"$$" \
"$parent_pid" \
"$parent_cmd" \
"$grandparent_pid" \
"$grandparent_cmd" >>"$SCALE_LOG"
}
# Hyprland only accepts scales where the mode divides into whole logical
# pixels (in 1/120 steps), so clean scales are divisors of gcd(w*120, h*120).
# Round the requested scale up to the nearest clean value.
clean_scale() {
awk -v scale="$1" -v width="$2" -v height="$3" '
function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a }
BEGIN {
g = gcd(width * 120, height * 120)
k = int(scale * 120 + 0.5)
if (k > g) k = g
while (g % k != 0) k++
printf "%g\n", k / 120
}'
}
normalize_scale() {
awk 'NR == 1 { printf "%g\n", $0 }'
}
set_scale() {
local requested_scale="$1"
local requested="${2:-$requested_scale}"
local monitor_info="$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')"
local active_monitor="$(echo "$monitor_info" | jq -r '.name')"
local current_scale="$(echo "$monitor_info" | jq -r '.scale')"
local width="$(echo "$monitor_info" | jq -r '.width')"
local height="$(echo "$monitor_info" | jq -r '.height')"
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
# active_monitor is written into the Lua string eval'd below, so only a plain
# connector name may pass; a hostile output name could execute otherwise.
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "Refusing unsafe monitor name" >&2
exit 1
fi
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
# factor even when the monitor scale itself is fractional.
local new_gdk_scale="$(awk -v scale="$new_scale" 'BEGIN { printf "%d", int(scale + 0.5) }')"
local monitor_lua="$HOME/.config/hypr/monitors.lua"
hyprctl eval "hl.monitor({ output = \"$active_monitor\", mode = \"${width}x${height}@${refresh_rate}\", position = \"auto\", scale = $new_scale })" >/dev/null
audit_scale_change "$requested" "$active_monitor" "$current_scale" "$new_scale"
# Persist to monitors.lua if the user still has Blob's generic catch-all
# defaults, so the scale survives reboots.
if [[ -f $monitor_lua ]] && grep -q '^local blob_monitor_scale = ' "$monitor_lua"; then
sed -i -E \
-e "s|^local blob_monitor_scale = .*|local blob_monitor_scale = ${new_scale}|" \
-e "s|^local blob_gdk_scale = .*|local blob_gdk_scale = ${new_gdk_scale}|" \
"$monitor_lua"
elif [[ -f $monitor_lua ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$monitor_lua"; then
sed -i -E \
-e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${new_scale}\\3|" \
-e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$new_gdk_scale"'")|' \
"$monitor_lua"
fi
}
scale_from_current() {
local direction="${1:-}"
local width="${2:-}"
local height="${3:-}"
awk -v direction="$direction" -v list="${SCALES[*]}" -v width="$width" -v height="$height" '
function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a }
function clean(scale, g, k) {
g = gcd(width * 120, height * 120)
k = int(scale * 120 + 0.5)
if (k > g) k = g
while (g % k != 0) k++
return k / 120
}
NR == 1 { scale = $0; found = 1 }
END {
if (!found) exit 1
preset_count = split(list, presets, " ")
for (i = 1; i <= preset_count; i++) {
effective = clean(presets[i])
key = sprintf("%.8f", effective)
distance = presets[i] - effective
if (distance < 0) distance = -distance
# Multiple presets can collapse to the same clean scale. Keep only the
# closest label so stepping always moves to a distinct effective value.
if (!(key in effective_index)) {
effective_index[key] = ++n
effective_scales[n] = effective
scales[n] = presets[i]
distances[n] = distance
} else {
idx = effective_index[key]
if (distance < distances[idx]) {
scales[idx] = presets[i]
distances[idx] = distance
}
}
}
# Snap to the nearest effective scale first. Hyprland reports floating
# point values, so exact comparisons can otherwise get stuck.
best = 1; best_diff = 1e9
for (i = 1; i <= n; i++) {
diff = scale - effective_scales[i]; if (diff < 0) diff = -diff
if (diff < best_diff) { best_diff = diff; best = i }
}
if (direction == "next") {
print scales[(best < n ? best + 1 : n)]
} else if (direction == "previous") {
print scales[(best > 1 ? best - 1 : 1)]
} else {
print scales[best]
}
}'
}
case "${1:-}" in
"")
focused_monitor_scale | normalize_scale
;;
-h | --help)
usage
;;
up)
monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')
set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current next \
"$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "up"
;;
down)
monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)')
set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current previous \
"$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "down"
;;
1 | 1.25 | 1.6 | 2 | 3 | 4)
set_scale "$1" "$1"
;;
*)
if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] &&
awk -v scale="$1" 'BEGIN { exit !(scale >= 1 && scale <= 4) }'; then
set_scale "$1" "$1"
else
usage >&2
exit 1
fi
;;
esac
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Reload hyprland configuration (used by the Blob theme switching).
hyprctl reload >/dev/null
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# blob:summary=Returns true when the compositor holds a session lock
# blob:hidden=true
# Hyprland reports no lock state directly, but an active ext-session-lock is one
# of the reasons a monitor cannot go solitary: LOCK in solitaryBlockedBy. It
# stays set once the lock's client dies, which is the case worth detecting.
#
# Exits 0 locked, 1 unlocked, 2 undetermined. Hyprland stops at the first reason
# on a monitor with no workspace yet, before it ever reaches the lock, so a
# missing LOCK there means nothing was asked. Callers branching only on success
# treat 2 as unlocked.
monitors=$(hyprctl -j monitors 2>/dev/null) || exit 2
state=$(jq '
def blockers: .solitaryBlockedBy // [];
def readable: blockers | index("WORKSPACE") | not;
if any(.[]; blockers | index("LOCK")) then 0
elif any(.[]; readable) then 1
else 2
end
' <<<"$monitors" 2>/dev/null)
case $state in
0 | 1) exit "$state" ;;
*) exit 2 ;;
esac
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# blob:summary=Toggle permanent Hyprland flags by copying them into a directory that's sourced entirely.
# blob:args=<flag-name> [on|off|toggle]
usage() {
echo "Usage: blob-hypr-toggle <flag-name> [on|off|toggle]" >&2
}
if (($# < 1)); then
usage
exit 1
fi
FLAG_NAME="$1"
ACTION="${2:-toggle}"
FLAG_FILE="$HOME/.local/state/blob/toggles/hypr/$FLAG_NAME.lua"
FLAG_SOURCE="$BLOB_PATH/default/hypr/toggles/$FLAG_NAME.lua"
on() {
if [[ -f $FLAG_SOURCE ]]; then
mkdir -p "$(dirname "$FLAG_FILE")"
cp "$FLAG_SOURCE" "$FLAG_FILE"
else
echo "Flag not found: $FLAG_NAME" >&2
exit 1
fi
}
off() {
rm -f "$FLAG_FILE"
}
toggle() {
if [[ -f $FLAG_FILE ]]; then
off
echo "off"
else
on
echo "on"
fi
}
case $ACTION in
on) on ;;
off) off ;;
toggle) toggle ;;
*)
usage
exit 1
;;
esac
hyprctl reload >/dev/null
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Check if a Hyprland toggle is currently disabled (missing).
# blob:args=<flag-name>
[[ ! -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]]
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Check if a Hyprland toggle is currently enabled.
# blob:args=<flag-name>
[[ -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Close all open windows
hyprctl clients -j | \
jq -r ".[].address" | \
while read -r addr; do
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$addr\" })" >/dev/null
done
# Move to first workspace
hyprctl dispatch 'hl.dsp.focus({ workspace = "1" })' >/dev/null 2>&1 || hyprctl dispatch workspace 1
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# blob:summary=Install a packaged app and launch it once it finishes
# blob:args=<display-name> <packages> <desktop-id>
# blob:examples=blob install and launch Cursor cursor-bin cursor
name="${1-}"
packages="${2-}"
desktop_id="${3-}"
if [[ -z $name || -z $packages || -z $desktop_id ]]; then
echo "Usage: blob-install-launch <display-name> <packages> <desktop-id>" >&2
exit 1
fi
printf -v install_message '%q' "Installing ${name}..."
printf -v desktop_id_arg '%q' "$desktop_id"
# The list has to reach blob-pkg-add as several words, so each word is quoted
# rather than the whole string; -d '' reads past newlines and always ends at EOF.
read -r -d '' -a package_list <<<"$packages" || true
printf -v packages_arg '%q ' "${package_list[@]}"
packages_arg="${packages_arg% }"
# The subshell keeps & from backgrounding the package installation too.
exec blob-launch-floating \
"echo ${install_message}; blob-pkg-add ${packages_arg} && (setsid uwsm-app -- gtk-launch ${desktop_id_arg} >/dev/null 2>&1 &)"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# blob:summary=Launch the default browser as determined by xdg-settings.
# blob:args=[url]
default_browser=$(env -u BROWSER xdg-settings get default-web-browser)
if [[ -z $default_browser ]]; then
default_browser=$(xdg-mime query default x-scheme-handler/https)
fi
browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1)
if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then
private_flag="--private-window"
elif [[ $browser_exec =~ edge ]]; then
private_flag="--inprivate"
else
private_flag="--incognito"
fi
systemd-run --user --quiet --collect --unit="blob-browser-$(date +%s%N)" \
--property=StandardOutput=null --property=StandardError=null \
uwsm-app -- "$browser_exec" "${@/--private/$private_flag}"
url=""
for argument in "$@"; do
if [[ $argument != "--private" ]]; then
url=$argument
break
fi
done
if [[ -n $url && -n ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
blob-hypr-focus "^$(basename "$browser_exec" -stable).*$" || true
fi
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# blob:summary=Launch the default editor selected via Blob defaults.
# blob:args=[--inline] <path>
default_editor="$HOME/.local/state/blob/defaults/editor"
if [[ ${1:-} == "--inline" ]]; then
inline=true
shift
else
inline=false
fi
if [[ -f $default_editor ]]; then
read -r editor <"$default_editor"
else
editor="nvim"
fi
blob-cmd-present "$editor" || editor="nvim"
case "${editor##*/}" in
nvim | vim | nano | micro | hx | helix | fresh)
if [[ $inline == "true" ]]; then
exec "$editor" "$@"
else
exec blob-launch-tui "$editor" "$@"
fi
;;
*)
exec setsid uwsm-app -- "$editor" "$@"
;;
esac
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# blob:summary=Launch a floating terminal with the Blob presentation wrapper
# blob:args=<command>
# Export the current theme's gum styling so gum widgets match the active theme
# even after a theme switch (the inherited environment is captured at login).
source blob-restart-gum
cmd="$*"
presentation_script="blob-show-logo; $cmd; if (( \$? != 130 )); then blob-show-done; fi"
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.blob.terminal --title=Blob -e bash -c "$presentation_script"
+91
View File
@@ -0,0 +1,91 @@
#!/bin/bash
# blob:summary=Launch the Blob shell with its log kept in the journal
# blob:hidden=true
# Quickshell only logs to its instance runtime dir (tmpfs), so when the shell
# dies the idle/lock event trail is gone after a reboot. The journal keeps it
# across sessions, bounded and timestamped, under the blob-shell tag.
#
# Backgrounded because bash defers a trap until a foreground command returns but
# interrupts wait. systemd-cat execs, so the job is Quickshell itself.
#
# Quickshell's own reloading is off; Blob restarts the shell deliberately.
# A package upgrade rewriting $BLOB_PATH/shell would otherwise reload it
# against a half-written tree, and that failed reload leaves a second engine
# generation behind that turns the next restart's IPC kill into a crash.
run_shell() {
QS_DISABLE_FILE_WATCHER=1 QS_NO_RELOAD_POPUP=1 \
systemd-cat -t blob-shell -- quickshell -n -p "$BLOB_PATH/shell" &
shell_pid=$!
local status
while true; do
wait "$shell_pid"
status=$?
# An interrupted wait and a shell killed by that signal report alike.
kill -0 "$shell_pid" 2>/dev/null || break
done
shell_pid=""
return $status
}
# A compositor busy reconfiguring outputs can miss a query without being gone,
# and that is when the shell dies.
compositor_alive() {
local attempt
for attempt in 1 2 3; do
hyprctl -j monitors >/dev/null 2>&1 && return 0
(( attempt < 3 )) && sleep 0.5
done
return 1
}
# Quickshell relaunches itself from its signal handlers, but Qt leaves through
# _exit() when the Wayland connection fails, raising no signal: no crash report,
# no relaunch, no bar. Supervise those deaths. A clean exit is a deliberate stop
# (blob-shell-restart starts its own replacement); a signal here means the
# session is going, and has to reach the shell the launcher used to exec.
terminating=0
shell_pid=""
stop() {
terminating=1
[[ -n $shell_pid ]] && kill -TERM "$shell_pid" 2>/dev/null
return 0
}
trap stop HUP INT TERM
attempts=0
window_started=$SECONDS
while true; do
# A signal during the backoff only reaches the trap once the sleep is over.
(( terminating )) && exit 0
run_shell
status=$?
(( terminating )) && exit 0
(( status == 0 )) && exit 0
# Relaunching into a session already tearing down burns the attempt budget.
compositor_alive || exit 0
if (( SECONDS - window_started > 60 )); then
attempts=0
window_started=$SECONDS
fi
if (( ++attempts > 5 )); then
logger -t blob-shell "Giving up on the Blob shell after $attempts relaunches in under a minute."
exit 1
fi
logger -t blob-shell "Blob shell exited with status $status; relaunching."
sleep 1
done
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# blob:summary=Launch a TUI command in the default terminal with Blob styling
# blob:args=[--app-id=<app-id>] <command> [args...]
if [[ ${1:-} == --app-id=* ]]; then
APP_ID="${1#--app-id=}"
shift
else
APP_ID="org.blob.$(basename $1)"
fi
exec setsid uwsm-app -- xdg-terminal-exec --app-id=$APP_ID -e "$1" "${@:2}"
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
# blob:summary=Remove or uninstall the selected launcher entry
# blob:args=<desktop-id> <name>
# blob:hidden=true
# blob:requires-sudo=true
set -e
desktop_id="${1-}"
entry_name="${2-}"
if [[ -z $desktop_id ]]; then
echo "Usage: blob-launcher-remove <desktop-id> <name>" >&2
exit 1
fi
desktop_file_name="$desktop_id"
if [[ $desktop_file_name != *.desktop ]]; then
desktop_file_name="$desktop_file_name.desktop"
fi
user_desktop_dirs=()
desktop_dirs=()
if [[ -n ${XDG_DATA_HOME-} ]]; then
user_desktop_dirs+=("$XDG_DATA_HOME/applications")
else
user_desktop_dirs+=("$HOME/.local/share/applications")
fi
desktop_dirs+=("${user_desktop_dirs[@]}")
if [[ -n ${XDG_DATA_DIRS-} ]]; then
IFS=: read -ra data_dirs <<<"$XDG_DATA_DIRS"
for data_dir in "${data_dirs[@]}"; do
desktop_dirs+=("$data_dir/applications")
done
else
desktop_dirs+=("/usr/local/share/applications" "/usr/share/applications")
fi
desktop_file=""
for dir in "${desktop_dirs[@]}"; do
candidate="$dir/$desktop_file_name"
if [[ -f $candidate ]]; then
desktop_file="$candidate"
break
fi
done
if [[ -z $desktop_file ]]; then
echo "Could not find launcher entry: $desktop_file_name" >&2
exit 1
fi
is_user_desktop_file() {
local dir desktop_dir
desktop_dir="${desktop_file%/*}"
for dir in "${user_desktop_dirs[@]}"; do
[[ $desktop_dir == $dir ]] && return 0
done
return 1
}
desktop_name="${desktop_file##*/}"
desktop_name="${desktop_name%.desktop}"
exec_line="$(sed -n 's/^Exec=//p' "$desktop_file" | head -1)"
if [[ $exec_line =~ blob-launch-webapp|blob-webapp-handler ]]; then
BLOB_REMOVE_NOTIFY=false blob-webapp-remove "$desktop_name"
exit 0
fi
if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then
BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name"
exit 0
fi
if is_user_desktop_file; then
display_name="${entry_name:-$desktop_name}"
rm -f "$desktop_file"
update-desktop-database "${desktop_file%/*}" &>/dev/null || true
exit 0
fi
if package_name="$(pacman -Qqo "$desktop_file" 2>/dev/null | head -1)" && [[ -n $package_name ]]; then
display_name="${entry_name:-$desktop_name}"
quoted_package="$(printf '%q' "$package_name")"
quoted_display="$(printf '%q' "$display_name")"
exec blob-launch-floating "echo Uninstalling $quoted_display...; sudo pacman -Rns $quoted_package"
fi
if blob-cmd-present flatpak && flatpak info "${desktop_file_name%.desktop}" >/dev/null 2>&1; then
flatpak_id="${desktop_file_name%.desktop}"
quoted_flatpak="$(printf '%q' "$flatpak_id")"
exec blob-launch-floating "flatpak uninstall $quoted_flatpak"
fi
echo "Don't know how to uninstall $desktop_file_name" >&2
exit 1
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# blob:summary=Control the Blob menu (toggle / summon / close / refresh)
# blob:args=[toggle|summon|close|refresh|ping] [route]
# blob:examples=blob menu | blob menu toggle system | blob menu summon style.theme | blob menu refresh
# Thin wrapper around the standard plugin IPC surface. The menu is the
# first-party `blob.menu` plugin; routes are passed as JSON payload.
set -euo pipefail
verb="${1-toggle}"
route="${2-root}"
menu_payload() {
jq -nc --arg menu "$1" '{ menu: $menu }'
}
case "$verb" in
toggle)
exec blob-shell shell toggle blob.menu "$(menu_payload "$route")"
;;
summon)
exec blob-shell shell summon blob.menu "$(menu_payload "$route")"
;;
close)
exec blob-shell shell hide blob.menu
;;
refresh | ping)
exec blob-shell shell call blob.menu "$verb" "{}"
;;
-h | --help | help)
cat <<USAGE
Usage: blob menu [verb] [route]
Verbs:
toggle [route] Open the menu at <route>, or close it if already open. Default verb.
summon [route] Always open the menu (no close-if-visible toggle).
close Close the menu if it is visible.
refresh Re-parse the menu JSONC files.
ping Health check.
Route is an item id (e.g. setup.power) or alias (e.g. power). Defaults to
"root", which opens the top-level menu.
USAGE
exit 0
;;
*)
echo "blob-menu: unknown verb '$verb'. Try 'blob menu --help'." >&2
exit 2
;;
esac
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
# blob:summary=Pick one option from a menu
# blob:group=menu
# blob:name=select
# blob:args=prompt [option...] [-- menu args...]
# blob:examples=blob menu select Format jpg png|blob-menu-select Resolution 4k 1080p 720p -- --width 400
# An option may lead with an icon, as "<glyph><TAB><label>", and may trail a
# subtext shown under the label, as "<glyph><TAB><label><TAB><subtext>". The
# menu shows the glyph but never returns it. A plain option returns the label
# alone; an option with a subtext returns "<label><TAB><subtext>", so callers
# with same-named rows get the subtext back as the stable key.
set -euo pipefail
if (( $# < 1 )); then
echo "Usage: blob-menu-select <prompt> [option...] [-- menu args...]" >&2
exit 1
fi
prompt="$1"
shift
options=()
menu_width=""
menu_maxheight=""
while (( $# > 0 )); do
if [[ $1 == "--" ]]; then
shift
while (( $# > 0 )); do
case "$1" in
--width)
shift
if (( $# == 0 )); then
echo "blob-menu-select: --width requires a value" >&2
exit 1
fi
menu_width="$1"
;;
--height|--maxheight)
arg="$1"
shift
if (( $# == 0 )); then
echo "blob-menu-select: $arg requires a value" >&2
exit 1
fi
menu_maxheight="$1"
;;
esac
shift
done
break
fi
options+=("$1")
shift
done
if (( ${#options[@]} == 0 )) && [[ ! -t 0 ]]; then
mapfile -t options
fi
if (( ${#options[@]} == 0 )); then
echo "Usage: blob-menu-select <prompt> [option...] [-- menu args...]" >&2
exit 1
fi
selection_file=$(mktemp)
done_file=$(mktemp)
rm -f "$done_file"
trap 'rm -f "$selection_file" "$done_file"' EXIT
options_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${options[@]}")
payload=$(perl -MEncode=decode -MJSON::PP=encode_json,decode_json -e '
my $payload = {
mode => "select",
prompt => decode("UTF-8", $ARGV[0]),
options => decode_json($ARGV[1]),
selectionFile => decode("UTF-8", $ARGV[2]),
doneFile => decode("UTF-8", $ARGV[3])
};
$payload->{width} = int($ARGV[4]) if length($ARGV[4] // "");
$payload->{maxHeight} = int($ARGV[5]) if length($ARGV[5] // "");
print encode_json($payload)
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight")
blob-shell shell summon blob.menu "$payload" >/dev/null
while [[ ! -e $done_file ]]; do
sleep 0.05
done
if [[ -s $selection_file ]]; then
cat "$selection_file"
else
exit 1
fi
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# blob:summary=Dismiss a notification by summary substring. Used by the first-run notifications to dismiss them after clicking for action.
# blob:args=<summary>
if (($# == 0)); then
echo "Usage: blob-notify-dismiss <summary>"
exit 1
fi
blob-shell -q notifications dismiss "$1"
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
# blob:summary=Send an Blob desktop notification
# blob:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
# blob:examples=blob notification send "Reminder" "5 minutes are up" -g 󰢌
set -euo pipefail
headline=""
description=""
glyph=
urgency="low"
app_name="blob-action"
app_icon=""
image=
expire_timeout=-1
replaces_id=0
print_id=0
exec_args=()
exec_present=0
parsed_option_args=0
usage() {
echo "Usage: blob-notify-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
}
# Recognize a known option, in both `--flag value` and `--flag=value` forms.
# Returns 1 for anything unrecognized so the caller can decide (headline, or a
# hard error in option position).
parse_blob_option() {
local opt val nargs
if [[ $1 == --?*=* ]]; then
opt=${1%%=*}
val=${1#*=}
nargs=1
else
opt=$1
val=${2-}
nargs=2
fi
# -p/--print-id is a flag; it takes no value.
if [[ $opt == -p || $opt == --print-id ]]; then
print_id=1
parsed_option_args=1
return 0
fi
case $opt in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
*) return 1 ;;
esac
if ((nargs == 2)) && (($# < 2)); then
echo "Missing value for $opt" >&2
exit 1
fi
case $opt in
-g | --glyph) glyph=$val ;;
-u | --urgency) urgency=$val ;;
--app-name) app_name=$val ;;
-i | --icon) app_icon=$val ;;
--image) image=$val ;;
-r | --replace-id)
[[ $val =~ ^[0-9]+$ ]] || {
echo "Invalid $opt value (numeric id expected): $val" >&2
exit 1
}
replaces_id=$val
;;
-t | --expire-time)
[[ $val =~ ^-?[0-9]+$ ]] || {
echo "Invalid $opt value (milliseconds expected): $val" >&2
exit 1
}
expire_timeout=$val
;;
esac
parsed_option_args=$nargs
return 0
}
while (($# > 0)); do
if parse_blob_option "$@"; then
shift "$parsed_option_args"
else
break
fi
done
if (($# < 1)); then
usage
exit 1
fi
headline=$1
shift
# The description is the next positional, taken as text even when it begins with
# a dash — a body like "-50% off" or a negative number is content, not options.
# Only a recognized option flag or --exec in that slot is not the description.
known_flag() {
case $1 in
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
esac
return 1
}
if (($# > 0)) && ! known_flag "$1"; then
description=$1
shift
fi
while (($# > 0)); do
if [[ $1 == "--exec" ]]; then
# --exec consumes the rest of the line as the click command's argv. The
# caller's shell already tokenized those words into discrete arguments, and
# the shell runs them as-is (never re-parsed), so untrusted data in an
# argument is only ever one argument and can never become a command.
# Detected only here, after the headline/description positionals are
# captured, so an untrusted headline that is literally "--exec" is taken as
# text and can't be mistaken for the delimiter. --exec therefore comes last.
shift
exec_args=("$@")
exec_present=1
break
elif parse_blob_option "$@"; then
shift "$parsed_option_args"
else
echo "Unknown option: $1" >&2
usage
exit 1
fi
done
case $urgency in
low) urgency_byte=0 ;;
normal) urgency_byte=1 ;;
critical) urgency_byte=2 ;;
*)
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
exit 1
;;
esac
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
# the rest are strings. The click command rides here as blob-exec, built
# only from --exec below.
hints=(urgency y "$urgency_byte")
if [[ -n $glyph ]]; then
hints+=(blob-glyph s "$glyph")
fi
if [[ -n $image ]]; then
hints+=(image-path s "$image")
fi
if ((exec_present)); then
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
echo "--exec needs a command: --exec <program> [args...]" >&2
exit 1
fi
# A single word with a space is almost always a whole command passed as one
# quoted string — which would run a program literally named that. Splitting it
# ourselves is exactly the injection we avoid, so reject it and point at the
# unquoted form instead.
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
echo "--exec takes the command as separate words, not one quoted string." >&2
echo "Write: --exec ${exec_args[0]}" >&2
exit 1
fi
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat
# a bare "--", and a newline in an arg must not split the vector.
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
hints+=(blob-exec s "$exec_argv_json")
fi
hint_count=$((${#hints[@]} / 3))
# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
# argv parsing is the surface that reinterprets a relayed headline like
# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
# D-Bus parameter instead, and the leading `--` keeps a dash-leading value
# (headline, description, a negative timeout) positional rather than a busctl
# option. So the summary and body are strings that can never become a hint, and
# blob-exec is set only from --exec.
#
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
# in place; -p prints the returned id so a caller can reuse it.
notify_cmd=(
busctl --user -- call
org.freedesktop.Notifications /org/freedesktop/Notifications
org.freedesktop.Notifications Notify susssasa{sv}i
"$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
0
"$hint_count" "${hints[@]}"
"$expire_timeout"
)
if ((print_id)); then
# busctl prints the UINT32 return as "u <id>"; emit just the id.
out=$("${notify_cmd[@]}")
printf '%s\n' "${out##* }"
else
"${notify_cmd[@]}" >/dev/null
fi
Executable
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# blob:summary=Show the Blob Quickshell on-screen display
# blob:args=[-i|--icon <icon>] [-m|--message <text>] [-p|--progress <0-100>] [-d|--duration <ms>]
# blob:examples=blob osd -i brightness -p 50 | blob osd -m "Hello"
set -euo pipefail
icon=""
message=""
progress=""
progress_text=""
max="100"
duration=""
while (($#)); do
case $1 in
-i|--icon) icon="${2:-}"; shift 2 ;;
-m|--message) message="${2:-}"; shift 2 ;;
-p|--progress) progress="${2:-}"; shift 2 ;;
-d|--duration) duration="${2:-}"; shift 2 ;;
-h|--help) blob osd --help; exit 0 ;;
*) echo "Unknown OSD option: $1" >&2; exit 1 ;;
esac
done
if [[ -n $progress ]]; then
progress_text="${progress}%"
fi
payload=$(jq -cn \
--arg icon "$icon" \
--arg message "$message" \
--arg value "$progress" \
--arg progressText "$progress_text" \
--arg max "$max" \
--arg duration "$duration" \
'{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration}')
blob-shell -q osd show "$payload"
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# blob:summary=Install Arch packages if they are missing
# blob:args=<packages...>
# blob:examples=blob pkg add jq ripgrep
# blob:requires-sudo=true
if blob-pkg-missing "$@"; then
if (( EUID == 0 )); then
pacman -S --noconfirm --needed "$@" || exit 1
else
sudo pacman -S --noconfirm --needed "$@" || exit 1
fi
fi
for pkg in "$@"; do
# Secondary check to handle states where pacman doesn't actually register an error
if ! pacman -Q "$pkg" &>/dev/null; then
echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2
exit 1
fi
done
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Returns true if the AUR is up and available.
curl -sf --connect-timeout 30 --retry 3 --retry-delay 3 -A "blob-update" \
"https://aur.archlinux.org/rpc/?v=5&type=info&arg=base" >/dev/null
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Returns true if any of the named packages are missing from the system (or false if they're all there).
# blob:args=<packages...>
for pkg in "$@"; do
if ! pacman -Q "$pkg" &>/dev/null; then
exit 0
fi
done
exit 1
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
# blob:summary=Returns true if all of the named packages are installed on the system (or false if any of them are missing).
# blob:args=<packages...>
for pkg in "$@"; do
pacman -Q "$pkg" &>/dev/null || exit 1
done
exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# blob:summary=Reload btop configuration (used by the Blob theme switching).
pkill -SIGUSR2 btop
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# blob:summary=Reload supported terminal emulators after config changes
if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
touch ~/.config/alacritty/alacritty.toml
fi
if pgrep -x kitty >/dev/null; then
killall -SIGUSR1 kitty >/dev/null
fi
if pgrep -x ghostty >/dev/null; then
killall -SIGUSR2 ghostty
fi
Executable
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# blob:summary=Send an IPC call to the running Blob shell
# blob:args=[-q] <target> <method> [args...]
# blob:examples=blob shell shell ping | blob-shell shell toggle blob.menu '{"menu":"root"}'
QUIET=0
if [[ ${1:-} == "-q" ]]; then
QUIET=1
shift
fi
fail() {
(( QUIET )) && exit 0
echo "$1" >&2
exit 1
}
if (( $# == 0 )) || [[ $1 == "-h" || $1 == "--help" ]]; then
cat <<USAGE
Usage: blob-shell [-q] <target> <method> [args...]
Forwards an IPC call to the running Blob shell. The shell is expected
to already be running; this command does not start it.
Options:
-q Quiet best-effort mode. Suppress output and return success even when
the shell, target, method, or arguments are unavailable.
Examples:
blob-shell shell ping
blob-shell -q blob.indicators refresh
blob-shell shell listPlugins
blob-shell shell toggle blob.menu '{"menu":"root"}'
USAGE
exit 0
fi
(( $# >= 2 )) || fail "Usage: blob-shell <target> <method> [args...]"
[[ -n ${BLOB_PATH:-} ]] || fail "BLOB_PATH is not set"
[[ -f $BLOB_PATH/shell/shell.qml ]] || fail "blob-shell config not found: $BLOB_PATH/shell/shell.qml"
# qs matches instances by display, and a caller from outside the session (an
# ssh or TTY blob-shell-restart, and the migrations it runs for) has none,
# so recover it from the compositor socket.
if [[ -z ${WAYLAND_DISPLAY:-} ]]; then
socket=$(ls -t "${XDG_RUNTIME_DIR:-/run/user/$UID}"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | head -n1)
[[ -n $socket ]] && export WAYLAND_DISPLAY=${socket##*/}
fi
if [[ $1 == "shell" && ( $2 == "summon" || $2 == "toggle" ) ]] && (( $# == 3 )); then
set -- "$1" "$2" "$3" "{}"
fi
# The -- keeps function names that shadow qs subcommands (e.g. show) as
# positionals. qs reports connection failures with a nonzero exit, but IPC-level
# failures (unknown target/function, bad arguments) go to stdout with exit 0.
ipc_timeout=${BLOB_SHELL_IPC_TIMEOUT:-2s}
output=$(timeout --kill-after=1s "$ipc_timeout" qs ipc -n -p "$BLOB_PATH/shell" call -- "$@" 2>/dev/null)
ipc_status=$?
if (( ipc_status == 124 || ipc_status == 137 )); then
fail "blob-shell is not responding"
elif (( ipc_status != 0 )); then
fail "blob-shell is not running"
fi
case $output in
"Target not found." | "Function not found." | "Too few arguments provided"* | "Too many arguments provided"*)
fail "$output"
;;
# A starting shell answers on stdout and exits 0, so a ping reads it as up
# and the next call's answer as a result. It is as unreachable as none.
"Not ready to accept queries yet"*)
fail "blob-shell is not ready"
;;
esac
if (( !QUIET )) && [[ -n $output ]]; then
echo "$output"
fi
exit 0
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# blob:summary=Shared helpers for editing ~/.config/blob/shell.json (source this, don't run it).
# blob:hidden=true
CONFIG_FILE="$HOME/.config/blob/shell.json"
DEFAULTS_FILE="$BLOB_PATH/config/blob/shell.json"
fail() {
echo "${0##*/}: $*" >&2
exit 1
}
refresh_shell_config() {
if ! blob-shell shell reloadConfig >/dev/null 2>&1; then
blob-shell -q shell rescanPlugins >/dev/null 2>&1 || true
fi
}
source_file() {
if [[ -s $CONFIG_FILE ]]; then
printf '%s\n' "$CONFIG_FILE"
else
printf '%s\n' "$DEFAULTS_FILE"
fi
}
# jq pipeline that normalizes shell.json into a well-shaped object with
# version=1, bar.layout.{left,center,right} arrays, and plugins array. Every
# mutation pipes through this so downstream jq can assume structure.
NORMALIZE='
def object_or_empty: if type == "object" then . else {} end;
def array_or_empty: if type == "array" then . else [] end;
object_or_empty
| .version = 1
| .bar = (.bar | object_or_empty)
| .bar.layout = (.bar.layout | object_or_empty)
| .bar.layout.left = (.bar.layout.left | array_or_empty)
| .bar.layout.center = (.bar.layout.center | array_or_empty)
| .bar.layout.right = (.bar.layout.right | array_or_empty)
| .plugins = (.plugins | array_or_empty)
'
# Apply a jq program to the source file and atomically write the result to the
# user config, then refresh the running shell. Extra args after the program are
# forwarded to jq (e.g. --arg/--argjson).
_SHELL_CONFIG_TMP=""
cleanup_shell_config_tmp() {
if [[ -n $_SHELL_CONFIG_TMP ]]; then rm -f "$_SHELL_CONFIG_TMP"; fi
}
trap cleanup_shell_config_tmp EXIT
commit() {
local program="$1"
shift
mkdir -p "$(dirname "$CONFIG_FILE")"
_SHELL_CONFIG_TMP=$(mktemp)
jq -S -e "$@" "$program" "$(source_file)" >"$_SHELL_CONFIG_TMP" || fail "could not update shell config"
mv "$_SHELL_CONFIG_TMP" "$CONFIG_FILE"
_SHELL_CONFIG_TMP=""
refresh_shell_config
}
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# blob:summary=Restart the Blob shell
# blob:examples=blob restart shell
# A caller opened after dev link/unlink may disagree with the still-running
# desktop. The user manager receives Hyprland's environment at session start.
session_blob_path=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^BLOB_PATH=//p' | tail -n 1)
: "${session_blob_path:=$BLOB_PATH}"
CONFIG_DIR="$session_blob_path/shell"
[[ -f $CONFIG_DIR/shell.qml ]] || { echo "Blob shell config not found: $CONFIG_DIR" >&2; exit 1; }
# Allow running from outside the session (e.g. over ssh) by deriving the
# Hyprland instance signature from the newest instance runtime dir.
if [[ -z ${HYPRLAND_INSTANCE_SIGNATURE:-} ]]; then
hypr_dir=$(find "${XDG_RUNTIME_DIR:-/run/user/$UID}/hypr" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -n 1 | cut -d' ' -f2-)
[[ -n $hypr_dir ]] && export HYPRLAND_INSTANCE_SIGNATURE=${hypr_dir##*/}
fi
# Restarting a live lock client would kill the lock screen and strand the
# session behind Hyprland's failsafe. But a LOCK session without an active
# locker — the shell died, or its crash handler re-execed a fresh instance
# that holds no lock — sits in that failsafe with no way to authenticate,
# and a restart plus re-lock is the only way back in without a reboot. So
# ask the lock service rather than merely pinging the shell: only a locker
# that reports the lock secure or in progress is worth preserving.
relock=0
if blob-hypr-session-locked; then
locking=$(BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock status 2>/dev/null |
jq -r '.secure or .requested' 2>/dev/null)
if [[ $locking == "true" ]]; then
echo "Refusing to restart Blob shell while the session is locked." >&2
exit 1
fi
relock=1
fi
# The lock plugin loads asynchronously, so a fresh shell answers ping before
# it can lock, and may even refuse early lock requests while its plugins or
# PAM config are still loading. Mirror blob-system-sleep: request the
# lock and poll until the session reports secure, re-requesting as needed, so
# recovery never claims success while the failsafe is still up. The deadline
# is generous because slow plugin discovery delays the lock IPC target.
relock_session() {
local state deadline=$((SECONDS + 30))
while (( SECONDS < deadline )); do
state=$(BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock status 2>/dev/null |
jq -r 'if .secure == true then "secure" elif .requested == true then "locking" else "idle" end' 2>/dev/null)
case $state in
secure) return 0 ;;
locking) ;;
*) BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell lock lock >/dev/null 2>&1 ;;
esac
sleep 0.1
done
return 1
}
# Each kill stops the oldest matching instance and only returns once it has
# fully exited, so the no-duplicate launch below can't race a dying shell.
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
# Spawn from Hyprland so the shell inherits the canonical session environment,
# not transient variables from a terminal, SSH connection, or development tool.
hyprctl dispatch 'hl.dsp.exec_cmd("blob-launch-shell")' >/dev/null
for (( attempt = 0; attempt < 20; attempt++ )); do
if BLOB_PATH="$session_blob_path" BLOB_SHELL_IPC_TIMEOUT=0.5s blob-shell shell ping >/dev/null 2>&1; then
# The session stays compositor-locked after the old lock client died, so
# re-acquire the lock and let the user authenticate out of it.
if (( relock )) && ! relock_session; then
echo "Blob shell restarted, but the session lock was not re-secured." >&2
exit 1
fi
# Invitation toasts (like Voxtype/fingerprint setup) die with the old
# shell, and their notify-send waiters hang forever: the dying server
# never emits NotificationClosed. A still-running blob-*-invitation
# unit is therefore an unanswered invitation — re-run it so its toast
# reappears on the new shell. Answered invitations have already exited
# and been collected, so the glob no longer matches them.
systemctl --user try-restart 'blob-*-invitation.service' 2>/dev/null || true
exit 0
fi
sleep 0.1
done
echo "Blob shell did not become ready after restart." >&2
exit 1
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# blob:summary=Manage persistent state files for Blob toggles and settings.
# blob:args=<set|clear> <state-name-or-pattern>
# blob:hidden=true
STATE_DIR="$HOME/.local/state/blob"
mkdir -p "$STATE_DIR"
COMMAND="$1"
STATE_NAME="$2"
if [[ -z $COMMAND ]]; then
echo "Usage: blob-state <set|clear> <state-name-or-pattern>"
exit 1
fi
if [[ -z $STATE_NAME ]]; then
echo "Usage: blob-state $COMMAND <state-name>"
exit 1
fi
case "$COMMAND" in
set) touch "$STATE_DIR/$STATE_NAME" ;;
clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;;
esac
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# blob:summary=Apply the current theme color to Chromium, Chrome, Edge, and Brave
# blob:hidden=true
source "$BLOB_PATH/install/helpers/browser-policy.sh"
CHROMIUM_THEME=$HOME/.local/state/blob/current/theme/chromium.theme
THEME_HEX_COLOR=$BROWSER_POLICY_DEFAULT_COLOR
if [[ -f $CHROMIUM_THEME ]]; then
THEME_HEX_COLOR=$(browser_policy_theme_hex "$(<$CHROMIUM_THEME)")
fi
refresh_running_browser() {
local process="$1"
local command="$2"
local pgrep_args="${3:--x}"
if blob-cmd-present "$command" && pgrep $pgrep_args "$process" >/dev/null; then
"$command" --refresh-platform-policy --no-startup-window &>/dev/null
fi
}
failed=0
blob-theme-browser-policy "${THEME_HEX_COLOR#\#}" || failed=1
refresh_running_browser chromium chromium
refresh_running_browser chrome google-chrome-stable || refresh_running_browser chrome google-chrome
refresh_running_browser msedge microsoft-edge-stable
refresh_running_browser brave brave
# Match on the binary path: the running process is named plain "brave", and a
# bare -f brave-origin pattern would also match the installer's own terminal.
refresh_running_browser /opt/brave-origin-bin/ brave-origin -f
exit "$failed"
+123
View File
@@ -0,0 +1,123 @@
#!/bin/bash
# blob:summary=Write the current theme color into the browser policy directories
# blob:args=<rrggbb>
# blob:hidden=true
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 (printf's builtin aside: install, mktemp, rm) 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
# Enterprise policy trust roots. The list is fixed here rather than taken from
# the caller: the caller chooses a color, never a path.
POLICY_DIRS=(
/etc/chromium/policies/managed
/etc/opt/chrome/policies/managed
/etc/opt/edge/policies/managed
/etc/brave/policies/managed
)
# The path etc/sudoers.d/blob-theme-browser 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-theme-browser-policy
usage() {
echo "Usage: blob-theme-browser-policy <rrggbb>" >&2
}
if (( $# != 1 )); then
usage
exit 1
fi
color="$1"
# Six lowercase hex digits is the whole of what this accepts. The leading "#"
# is added when the JSON is written rather than passed in: "#" opens a comment
# in sudoers, and keeping it out of argv lets the sudoers rule spell the
# argument as a plain six-character glob.
if [[ ! $color =~ ^[0-9a-f]{6}$ ]]; then
echo "blob-theme-browser-policy: expected six lowercase hex digits, got '$color'" >&2
exit 1
fi
# 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-theme-browser and nothing else. Listing runs nothing
# and, under -n, prompts for nothing.
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
exec sudo "$PACKAGED_PATH" "$@"
else
exec pkexec "$PACKAGED_PATH" "$@"
fi
}
require_root "$color"
failed=0
staged=""
# Bash 5.3 makes the EXIT trap's last command decide the script's exit status,
# so this handler must not end on a false test. Every successful run clears
# staged, and a trailing `[[ -n $staged ]] && ...` would report that as failure.
cleanup() {
if [[ -n $staged ]]; then
rm -f "$staged"
fi
}
trap cleanup EXIT
for policy_dir in "${POLICY_DIRS[@]}"; do
# Only browsers Blob has installed have a policy directory. Creating one
# here would hand a browser a managed-policy root it does not otherwise have.
[[ -d $policy_dir && ! -L $policy_dir ]] || continue
dest=$policy_dir/color.json
staged=$(mktemp) || {
failed=1
continue
}
printf '{"BrowserThemeColor": "#%s", "BrowserColorScheme": "device"}\n' "$color" >"$staged"
if [[ -L $dest || -d $dest ]]; then
if ! rm -rf -- "$dest"; then
rm -f "$staged"
staged=""
echo "blob-theme-browser-policy: cannot replace $dest" >&2
failed=1
continue
fi
fi
if ! install -m 0644 -o root -g root -T "$staged" "$dest"; then
rm -f "$staged"
staged=""
echo "blob-theme-browser-policy: cannot write $dest" >&2
failed=1
continue
fi
rm -f "$staged"
staged=""
done
exit "$failed"
+304
View File
@@ -0,0 +1,304 @@
#!/bin/bash
# blob:summary=Resolve semantic colors from an Blob theme colors.toml
# blob:args=[--file <colors.toml>] (--all | --raw | <key> [fallback])
# blob:hidden=true
# Shared colors.toml parser/resolver. The alias/fallback cascade mirrors what
# blob-theme-templates bakes into the generated configs at theme-set
# time, so every consumer (templates, OSC sequences, previews)
# resolves the exact same palette.
#
# --all print every resolved key<TAB>value pair (sorted by key)
# --raw print only the key<TAB>value pairs defined in the file
# <key> [fallback] print one resolved value; the fallback is tried as
# another palette key first, then used verbatim
#
# Theme mode precedence: `mode` key, legacy `theme_type` key, a light.mode
# file beside the colors.toml, background luminance auto-detect, dark.
COLORS_FILE="$HOME/.local/state/blob/current/theme/colors.toml"
OUTPUT=""
QUERY_KEY=""
QUERY_FALLBACK=""
usage() {
echo "Usage: blob-theme-color [--file <colors.toml>] (--all | --raw | <key> [fallback])"
}
while (( $# > 0 )); do
case "$1" in
--file)
COLORS_FILE="${2:-}"
shift 2
;;
--all)
OUTPUT="all"
shift
;;
--raw)
OUTPUT="raw"
shift
;;
-h | --help)
usage
exit 0
;;
*)
if [[ -z $QUERY_KEY ]]; then
QUERY_KEY="$1"
elif [[ -z $QUERY_FALLBACK ]]; then
QUERY_FALLBACK="$1"
else
usage >&2
exit 1
fi
shift
;;
esac
done
if [[ -z $OUTPUT && -z $QUERY_KEY ]] || [[ -n $OUTPUT && -n $QUERY_KEY ]]; then
usage >&2
exit 1
fi
COLORS_DIR=$(dirname "$COLORS_FILE")
declare -A THEME_COLORS
# Mix two hex colors. Amount may be a fraction (0.30) or percentage (30%).
mix_color() {
local start="${1#\#}"
local end="${2#\#}"
local amount="$3"
awk -v start="$start" -v end="$end" -v amount="$amount" '
function hex_value(char) {
return index("0123456789abcdef", tolower(char)) - 1
}
function hex_pair_to_int(hex, idx) {
return hex_value(substr(hex, idx, 1)) * 16 + hex_value(substr(hex, idx + 1, 1))
}
BEGIN {
if (amount ~ /%$/) {
sub(/%$/, "", amount)
amount = amount / 100
} else {
amount += 0
if (amount > 1) amount = amount / 100
}
if (amount < 0) amount = 0
if (amount > 1) amount = 1
start_r = hex_pair_to_int(start, 1)
start_g = hex_pair_to_int(start, 3)
start_b = hex_pair_to_int(start, 5)
end_r = hex_pair_to_int(end, 1)
end_g = hex_pair_to_int(end, 3)
end_b = hex_pair_to_int(end, 5)
red = int(start_r * (1 - amount) + end_r * amount + 0.5)
green = int(start_g * (1 - amount) + end_g * amount + 0.5)
blue = int(start_b * (1 - amount) + end_b * amount + 0.5)
printf "#%02x%02x%02x\n", red, green, blue
}
'
}
alias_theme_color() {
local key="$1"
local fallback="$2"
[[ ${THEME_COLORS[$key]} ]] || THEME_COLORS[$key]="${THEME_COLORS[$fallback]}"
}
resolve_theme_mode() {
local bg_hex lum
[[ ${THEME_COLORS[mode]} ]] || THEME_COLORS[mode]="${THEME_COLORS[theme_type]}"
[[ ${THEME_COLORS[mode]} ]] && return
if [[ -f $COLORS_DIR/light.mode ]]; then
THEME_COLORS[mode]="light"
elif [[ ${THEME_COLORS[background]} =~ ^#[0-9A-Fa-f]{6}$ ]]; then
bg_hex="${THEME_COLORS[background]#\#}"
lum=$(( $(printf "%d" "0x${bg_hex:0:2}") + $(printf "%d" "0x${bg_hex:2:2}") + $(printf "%d" "0x${bg_hex:4:2}") ))
(( lum > 382 )) && THEME_COLORS[mode]="light" || THEME_COLORS[mode]="dark"
else
THEME_COLORS[mode]="dark"
fi
}
parse_colors_file() {
local key value
[[ -f $COLORS_FILE ]] || return 0
while IFS='=' read -r key value; do
key="${key//[\"\' ]/}" # strip quotes and spaces from key
[[ $key && $key != \#* ]] || continue # skip empty lines and comments
if [[ $value == *[\"\']* ]]; then
value="${value#*[\"\']}"
value="${value%%[\"\']*}" # extract value between quotes (ignores inline comments)
else
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}" # trim unquoted values
fi
# Values reach consumers as sed replacement text, so the charset excludes
# the delimiter, backslash, and & while still covering everything a real
# palette holds: hex, rgb()/rgba() lists, gradient angles like -45deg,
# decimals, and bare words. Rejections are announced so a third-party theme
# doesn't lose a key silently and leave a raw {{ placeholder }} behind.
if [[ ! $key =~ ^[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]+$ ]]; then
printf 'blob-theme-color: skipping key with unsupported characters\n' >&2
continue
fi
if [[ ! $value =~ ^[abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#(),._+/%\ -]*$ ]]; then
printf 'blob-theme-color: skipping %s: unsupported characters in value\n' "$key" >&2
continue
fi
THEME_COLORS[$key]="$value"
[[ $OUTPUT == "raw" ]] && printf '%s\t%s\n' "$key" "$value"
done <"$COLORS_FILE"
return 0
}
resolve_theme_colors() {
local key
# Accept the complete legacy short-name palette before applying ANSI
# fallbacks or deriving shades. Canonical names take precedence when a theme
# defines both forms.
declare -A legacy_palette_alias=(
[background]=bg
[dark_background]=dark_bg
[darker_background]=darker_bg
[lighter_background]=lighter_bg
[foreground]=fg
[dark_foreground]=dark_fg
[light_foreground]=light_fg
[bright_foreground]=bright_fg
)
for key in "${!legacy_palette_alias[@]}"; do
alias_theme_color "$key" "${legacy_palette_alias[$key]}"
done
# Themes generated before the semantic palette may only define ANSI names.
[[ ${THEME_COLORS[background]} ]] || THEME_COLORS[background]="${THEME_COLORS[color0]}"
[[ ${THEME_COLORS[foreground]} ]] || THEME_COLORS[foreground]="${THEME_COLORS[color7]}"
[[ ${THEME_COLORS[background]} ]] && THEME_COLORS[color0]="${THEME_COLORS[background]}"
[[ ${THEME_COLORS[foreground]} ]] && THEME_COLORS[color7]="${THEME_COLORS[foreground]}"
# Legacy compatibility: map ANSI color0..color15 to semantic names.
declare -A legacy_alias=(
[red]=color1
[green]=color2
[yellow]=color3
[blue]=color4
[magenta]=color5
[cyan]=color6
[bright_red]=color9
[bright_green]=color10
[bright_yellow]=color11
[bright_blue]=color12
[bright_magenta]=color13
[bright_cyan]=color14
)
for key in "${!legacy_alias[@]}"; do
alias_theme_color "$key" "${legacy_alias[$key]}"
done
alias_theme_color magenta purple
alias_theme_color bright_magenta bright_purple
[[ ${THEME_COLORS[light_foreground]} ]] || THEME_COLORS[light_foreground]="${THEME_COLORS[color7]:-${THEME_COLORS[foreground]}}"
[[ ${THEME_COLORS[bright_foreground]} ]] || THEME_COLORS[bright_foreground]="${THEME_COLORS[color15]:-${THEME_COLORS[foreground]}}"
THEME_COLORS[cursor]="${THEME_COLORS[bright_foreground]}"
[[ ${THEME_COLORS[lighter_background]} ]] || THEME_COLORS[lighter_background]="${THEME_COLORS[color0]:-${THEME_COLORS[background]}}"
[[ ${THEME_COLORS[dark_foreground]} ]] || THEME_COLORS[dark_foreground]="${THEME_COLORS[color8]:-${THEME_COLORS[foreground]}}"
[[ ${THEME_COLORS[muted]} ]] || THEME_COLORS[muted]="${THEME_COLORS[color8]:-${THEME_COLORS[dark_foreground]}}"
[[ ${THEME_COLORS[selection]} ]] || THEME_COLORS[selection]="${THEME_COLORS[selection_background]:-${THEME_COLORS[color8]:-${THEME_COLORS[color0]:-${THEME_COLORS[background]}}}}"
[[ ${THEME_COLORS[selection_background]} ]] || THEME_COLORS[selection_background]="${THEME_COLORS[selection]}"
[[ ${THEME_COLORS[selection_foreground]} ]] || THEME_COLORS[selection_foreground]="${THEME_COLORS[bright_foreground]}"
[[ ${THEME_COLORS[orange]} ]] || THEME_COLORS[orange]="${THEME_COLORS[yellow]}"
[[ ${THEME_COLORS[brown]} ]] || THEME_COLORS[brown]=$(mix_color "${THEME_COLORS[orange]}" "#000000" 50%)
# Auto-derive shades from base accents when not defined and not aliased from colorN
[[ ${THEME_COLORS[dark_background]} ]] || THEME_COLORS[dark_background]=$(mix_color "${THEME_COLORS[background]}" "#000000" 25%)
[[ ${THEME_COLORS[darker_background]} ]] || THEME_COLORS[darker_background]=$(mix_color "${THEME_COLORS[background]}" "#000000" 50%)
[[ ${THEME_COLORS[bright_red]} ]] || THEME_COLORS[bright_red]=$(mix_color "${THEME_COLORS[red]}" "#ffffff" 20%)
[[ ${THEME_COLORS[bright_yellow]} ]] || THEME_COLORS[bright_yellow]=$(mix_color "${THEME_COLORS[yellow]}" "#ffffff" 20%)
[[ ${THEME_COLORS[bright_green]} ]] || THEME_COLORS[bright_green]=$(mix_color "${THEME_COLORS[green]}" "#ffffff" 20%)
[[ ${THEME_COLORS[bright_cyan]} ]] || THEME_COLORS[bright_cyan]=$(mix_color "${THEME_COLORS[cyan]}" "#ffffff" 20%)
[[ ${THEME_COLORS[bright_blue]} ]] || THEME_COLORS[bright_blue]=$(mix_color "${THEME_COLORS[blue]}" "#ffffff" 20%)
[[ ${THEME_COLORS[bright_magenta]} ]] || THEME_COLORS[bright_magenta]=$(mix_color "${THEME_COLORS[magenta]}" "#ffffff" 20%)
alias_theme_color purple magenta
alias_theme_color bright_purple bright_magenta
# Keep semantic themes compatible with consumers that still reference the
# legacy ANSI names directly.
declare -A ansi_alias=(
[color0]=background
[color1]=red
[color2]=green
[color3]=yellow
[color4]=blue
[color5]=magenta
[color6]=cyan
[color7]=foreground
[color8]=muted
[color9]=bright_red
[color10]=bright_green
[color11]=bright_yellow
[color12]=bright_blue
[color13]=bright_magenta
[color14]=bright_cyan
[color15]=bright_foreground
)
for key in "${!ansi_alias[@]}"; do
alias_theme_color "$key" "${ansi_alias[$key]}"
done
# Keep canonical themes compatible with old user templates and consumers
# that still query the short palette names directly.
for key in "${!legacy_palette_alias[@]}"; do
if [[ ${THEME_COLORS[$key]} ]]; then
THEME_COLORS["${legacy_palette_alias[$key]}"]="${THEME_COLORS[$key]}"
fi
done
resolve_theme_mode
THEME_COLORS[theme_type]="${THEME_COLORS[mode]}"
}
parse_colors_file
if [[ $OUTPUT == "raw" ]]; then
exit 0
fi
resolve_theme_colors
if [[ $OUTPUT == "all" ]]; then
while IFS= read -r key; do
printf '%s\t%s\n' "$key" "${THEME_COLORS[$key]}"
done < <(printf '%s\n' "${!THEME_COLORS[@]}" | LC_ALL=C sort)
exit 0
fi
value="${THEME_COLORS[$QUERY_KEY]:-}"
if [[ -z $value && -n $QUERY_FALLBACK ]]; then
value="${THEME_COLORS[$QUERY_FALLBACK]:-$QUERY_FALLBACK}"
fi
[[ -n $value ]] || exit 1
printf '%s\n' "$value"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
blob-theme-contrast - Detects monotone/bland wal color palettes and replaces
accent colors with vibrant, harmonically-spread alternatives.
"""
import sys
import math
import colorsys
from dataclasses import dataclass
MIN_SATURATION = 0.65
MIN_LIGHTNESS = 0.40
MAX_LIGHTNESS = 0.70
HUE_THRESHOLD = 0.15
SAT_THRESHOLD = 0.35
HUE_VARIANCE_THRESHOLD = 0.15
ACCENT_SLICE = slice(1, 7)
BRIGHT_OFFSET = 8
HUE_SHIFTS: list[float] = [
0.0,
0.5,
0.083,
-0.083,
0.416,
-0.416,
]
@dataclass(frozen=True)
class HLS:
h: float
l: float
s: float
def hex_to_rgb(hex_str: str) -> tuple[float, float, float]:
hex_str = hex_str.lstrip("#")
return tuple(int(hex_str[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # type: ignore[return-value]
def rgb_to_hex(r: float, g: float, b: float) -> str:
return "#{:02x}{:02x}{:02x}".format(int(r * 255), int(g * 255), int(b * 255))
def hex_to_hls(hex_str: str) -> HLS:
h, l, s = colorsys.rgb_to_hls(*hex_to_rgb(hex_str))
return HLS(h, l, s)
def vibrant_shift(hex_str: str, hue_shift: float) -> str:
"""Return *hex_str* with its hue rotated by *hue_shift* and vibrancy enforced."""
hls = hex_to_hls(hex_str)
h = (hls.h + hue_shift) % 1.0
s = max(hls.s, MIN_SATURATION)
l = min(max(hls.l, MIN_LIGHTNESS), MAX_LIGHTNESS)
return rgb_to_hex(*colorsys.hls_to_rgb(h, l, s))
def palette_is_bland(accents: list[str]) -> bool:
"""Return True when accent colors are too similar, clustered, or too desaturated."""
stats = [hex_to_hls(c) for c in accents]
hues = [hls.h for hls in stats]
sats = [hls.s for hls in stats]
raw_spread = max(hues) - min(hues)
hue_spread = min(raw_spread, 1.0 - raw_spread)
avg_sat = sum(sats) / len(sats)
if hue_spread < HUE_THRESHOLD or avg_sat < SAT_THRESHOLD:
return True
mean_sin = sum(math.sin(2 * math.pi * h) for h in hues) / len(hues)
mean_cos = sum(math.cos(2 * math.pi * h) for h in hues) / len(hues)
hue_variance = 1.0 - math.sqrt(mean_sin ** 2 + mean_cos ** 2)
if hue_variance < HUE_VARIANCE_THRESHOLD:
return True
return False
def most_saturated(accents: list[str]) -> str:
return max(accents, key=lambda c: hex_to_hls(c).s)
def load_colors(filepath: str) -> list[str]:
try:
with open(filepath) as f:
colors = [line.strip() for line in f if line.strip()]
except OSError as exc:
sys.exit(f"Error reading '{filepath}': {exc}")
if len(colors) < 16:
sys.exit(f"Expected ≥ 16 colors in '{filepath}', found {len(colors)}.")
return colors
def save_colors(filepath: str, colors: list[str]) -> None:
try:
with open(filepath, "w") as f:
f.write("\n".join(colors) + "\n")
except OSError as exc:
sys.exit(f"Error writing '{filepath}': {exc}")
def main() -> None:
if len(sys.argv) < 2:
sys.exit("Usage: blob_color_fixer.py <path_to_wal_colors>")
filepath = sys.argv[1]
colors = load_colors(filepath)
accents = colors[ACCENT_SLICE]
if not palette_is_bland(accents):
print("Palette is already vibrant and diverse — nothing to do.")
return
print("Monotone / bland palette detected. Generating vibrant harmony…")
base = most_saturated(accents)
new_accents = [vibrant_shift(base, shift) for shift in HUE_SHIFTS]
for i, color in enumerate(new_accents):
colors[i + 1] = color
colors[i + 1 + BRIGHT_OFFSET] = color
save_colors(filepath, colors)
print("Done — colors enhanced and written back to file.")
if __name__ == "__main__":
main()
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Show current theme
# blob:examples=blob theme current
THEME_NAME_PATH="$HOME/.local/state/blob/current/theme.name"
if [[ -f $THEME_NAME_PATH ]]; then
sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' "$THEME_NAME_PATH"
else
echo "Unknown"
fi
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# blob:summary=Print the directory holding a theme, preferring a user-installed copy
# blob:args=<theme-name>
# blob:examples=blob theme dir tokyo-night
theme="${1:-}"
if [[ -z $theme ]]; then
echo "Usage: blob-theme-dir <theme-name>" >&2
exit 1
fi
if [[ -d $HOME/.config/blob/themes/$theme ]]; then
echo "$HOME/.config/blob/themes/$theme"
else
echo "$BLOB_PATH/themes/$theme"
fi
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# blob:summary=Apply the pywal-generated theme built from the current wallpaper
dynamic_theme_dir="$HOME/.config/blob/themes/blob-dynamic"
if [ ! -f "$dynamic_theme_dir/colors.toml" ]; then
echo "No dynamic theme yet. Pick a wallpaper first: blob-bg-menu" >&2
exit 1
fi
BLOB_THEME_SKIP_BACKGROUND=1 exec blob-theme-set blob-dynamic
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# blob:summary=List the user-installed themes that came from a git clone
# blob:examples=blob theme extras
# Exits nonzero when there are none, so a caller can ask whether any exist
# without reading the list. A symlinked theme is someone's working copy and a
# `.git` file is a worktree pointing elsewhere; neither is ours to pull.
status=1
for theme in ~/.config/blob/themes/*; do
[[ ! -L $theme && -d $theme/.git ]] || continue
echo "$theme"
status=0
done
exit $status
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# blob:summary=Apply current Blob theme colors to running Foot terminals
# blob:hidden=true
colors_toml=$HOME/.local/state/blob/current/theme/colors.toml
if [[ ! -f $colors_toml ]] || ! pgrep -x foot >/dev/null; then
exit 0
fi
foot_osc=$(blob-theme-osc "$colors_toml")
[[ -n $foot_osc ]] || exit 0
for foot_pid in $(pgrep -x foot); do
for child_pid in $(pgrep -P "$foot_pid"); do
tty=$(readlink "/proc/$child_pid/fd/1" 2>/dev/null)
[[ $tty == /dev/pts/* ]] && printf '%b' "$foot_osc" >"$tty"
done
done
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# blob:summary=Generate a theme's colors.toml from its alacritty.toml palette
# blob:args=<theme-dir>
# blob:hidden=true
set -e
THEME_SOURCE="${1:-}"
COLORS_OUTPUT="$THEME_SOURCE/colors.toml"
ALACRITTY_FILE="$THEME_SOURCE/alacritty.toml"
if [[ -z $THEME_SOURCE ]]; then
echo "Usage: blob-theme-import <theme-dir>" >&2
exit 1
fi
# Skip if colors.toml already exists in source theme
if [[ -f $COLORS_OUTPUT ]]; then
exit 0
fi
# Skip if no alacritty.toml to extract from
if [[ ! -f $ALACRITTY_FILE ]]; then
exit 0
fi
# Parse the alacritty TOML in a single awk pass, emitting one
# "<dotted-path>\t#<hex>" pair per color-valued key. Dotted keys are
# normalized (`normal.black = ...` under [colors] becomes
# colors.normal.black, same as `black = ...` under [colors.normal]),
# with the section form taking precedence and the first valid
# occurrence of a key winning.
declare -A COLORS
while IFS=$'\t' read -r key value; do
COLORS[$key]=$value
done < <(awk '
/^[ \t]*\[/ {
line = $0
sub(/^[ \t]+/, "", line)
if (line ~ /^\[[^]]*\][ \t]*$/) {
section = substr(line, 2, index(line, "]") - 2)
} else {
section = ""
}
next
}
section != "" {
split_pos = index($0, "=")
if (split_pos == 0) {
next
}
key = substr($0, 1, split_pos - 1)
gsub(/^[ \t]+|[ \t]+$/, "", key)
if (key == "" || key ~ /^#/) {
next
}
value = substr($0, split_pos + 1)
gsub(/^[ \t]+|[ \t]+$/, "", value)
# Accept a lone hex color, optionally 0x- or #-prefixed and quoted,
# ignoring any trailing comment. The color always yields the first
# run of six hex digits.
if (value !~ /^["'\''](0[xX]|#)?[0-9a-fA-F]{6}(["'\'']([ \t]*#.*)?)?$/ &&
value !~ /^(0[xX])?[0-9a-fA-F]{6}([ \t]*#.*)?$/) {
next
}
match(value, /[0-9a-fA-F]{6}/)
hex = tolower(substr(value, RSTART, RLENGTH))
path = section "." key
if (section == "colors" && index(key, ".") > 0) {
if (!(path in dotted)) dotted[path] = hex
} else {
if (!(path in direct)) direct[path] = hex
}
}
END {
for (path in direct) print path "\t#" direct[path]
for (path in dotted) if (!(path in direct)) print path "\t#" dotted[path]
}
' "$ALACRITTY_FILE")
names=(black red green yellow blue magenta cyan white)
# Extract normal colors (color0-7)
for i in {0..7}; do
name="${names[$i]}"
printf -v "color$i" "%s" "${COLORS[colors.normal.$name]}"
done
# Validate we have all normal colors (required)
for c in color0 color1 color2 color3 color4 color5 color6 color7; do
if [[ -z ${!c} ]]; then
echo "Warning: Cannot extract all normal colors from $ALACRITTY_FILE, skipping generation" >&2
exit 0
fi
done
# Extract bright colors (color8-15), falling back to normal
for i in {0..7}; do
normal="color$i"
name="${names[$i]}"
val="${COLORS[colors.bright.$name]}"
printf -v "color$((i + 8))" "%s" "${val:-${!normal}}"
done
# Extract primary and selection colors
background="${COLORS[colors.primary.background]}"
foreground="${COLORS[colors.primary.foreground]}"
selection_background="${COLORS[colors.selection.background]}"
# Apply defaults
background=${background:-$color0}
foreground=${foreground:-$color7}
color0=$background
color7=$foreground
selection_background=${selection_background:-$foreground}
accent=$color4
mkdir -p "$THEME_SOURCE"
cat > "$COLORS_OUTPUT" <<EOF
accent = "$accent"
selection = "$selection_background"
background = "$background"
foreground = "$foreground"
color0 = "$color0"
color1 = "$color1"
color2 = "$color2"
color3 = "$color3"
color4 = "$color4"
color5 = "$color5"
color6 = "$color6"
color7 = "$color7"
color8 = "$color8"
color9 = "$color9"
color10 = "$color10"
color11 = "$color11"
color12 = "$color12"
color13 = "$color13"
color14 = "$color14"
color15 = "$color15"
EOF
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# blob:summary=Install a theme from a git repository
# blob:args=[git-repo-url]
# blob:examples=blob theme install https://github.com/example/blob-example-theme.git
# blob:examples=blob theme install git@github.com:example/blob-example-theme.git
if [[ -z $1 ]]; then
echo -e "\e[32mSee https://omarchy.org/themes/\n\e[0m"
REPO_URL=$(gum input --placeholder="Git repo URL (https or git@host:org/repo.git)" --header="")
else
REPO_URL="$1"
fi
if [[ -z $REPO_URL ]]; then
exit 1
fi
# Refuse a URL that names a git option or a transport helper before cloning. The
# check is shared with blob-plugin-add and explains itself; a missing checker
# leaves this non-zero, which refuses the URL rather than cloning it.
blob-git-url-check "$REPO_URL" || exit 1
THEMES_DIR="$HOME/.config/blob/themes"
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path.
# git reads a URL as scp-style when a colon appears before any slash, so the path
# after it need not hold one: `git@host:blob-blue-theme.git` is a repo in that
# user's home, and leaving its prefix on names the theme after the whole URL.
REPO_PATH="$REPO_URL"
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:* && ${REPO_PATH%%:*} != */* ]] && REPO_PATH="${REPO_PATH#*:}"
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^blob-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
THEME_PATH="$THEMES_DIR/$THEME_NAME"
# The name comes from the URL, is joined into a path that is about to be
# removed, and then names a directory the rest of Blob passes around by
# name: Style > Unlock builds a command line out of the one the picker
# returned. So it is held to the characters a theme name needs rather than
# screened for the harm of the day -- a repo called `..` would take
# ~/.config/blob with it, and one called `a';'id` would carry its own
# command into that picker. The leading character is kept out of `.` and `-`,
# which also covers `host:-s/foo.git` leaving basename with `.git`.
# A bracket range follows the locale's collation, not ASCII: `[a-z]` takes in
# `é` under en_US.UTF-8. Pin the locale so the set is the one written here.
if ! (LC_ALL=C; [[ $THEME_NAME =~ ^[a-z0-9_][a-z0-9._+-]*$ ]]); then
echo "Error: '$REPO_URL' does not give a usable theme name."
exit 1
fi
# Remove existing theme if present
if [[ -d $THEME_PATH ]]; then
rm -rf "$THEME_PATH"
fi
# Clone the repo directly to ~/.config/blob/themes
if ! git clone -- "$REPO_URL" "$THEME_PATH"; then
echo "Error: Failed to clone theme repo."
exit 1
fi
# Apply the new theme with blob-theme-set, which stages only the files an
# extra theme is allowed to contribute and names anything it dropped.
blob-theme-set "$THEME_NAME"
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# blob:summary=List available themes
# blob:examples=blob theme list | blob theme set "Tokyo Night"
{
find ~/.config/blob/themes/ -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -printf '%f\n'
find "$BLOB_PATH/themes/" -mindepth 1 -maxdepth 1 -type d -printf '%f\n'
} | sort -u | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g'
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# blob:summary=Pick a color theme from the local and bundled sets
# blob:args=[--mode|--print]
user_themes_dir="$HOME/.config/blob/themes"
current_dir="$HOME/.local/state/blob/current"
prettify() {
echo "$1" | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g'
}
current_mode() {
local name
name=$(cat "$current_dir/theme.name" 2>/dev/null)
if [ "$name" = "blob-dynamic" ]; then
echo "dynamic"
else
echo "static"
fi
}
list_theme_rows() {
printf 'Dynamic (from wallpaper)\tblob-dynamic\n'
{
find "$user_themes_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
find "$BLOB_PATH/themes" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
} | while read -r theme_dir; do
[ -f "$theme_dir/colors.toml" ] || continue
slug=$(basename "$theme_dir")
[ "$slug" = "blob-dynamic" ] && continue
printf '%s\t%s\n' "$(prettify "$slug")" "$slug"
done | sort -u -t$'\t' -k2,2
}
print_current_colors() {
local colors_file="$current_dir/theme/colors.toml"
if [ ! -f "$colors_file" ]; then
echo "No active theme colors.toml found at $colors_file" >&2
exit 1
fi
awk '1' "$colors_file"
}
case "$1" in
--mode)
current_mode
;;
--print)
print_current_colors
;;
*)
selection=$(list_theme_rows | blob-menu-select "Select Theme" -- --width 800) || exit 0
slug=$(printf '%s' "$selection" | cut -f2)
[ -n "$slug" ] || exit 0
if [ "$slug" = "blob-dynamic" ]; then
exec blob-theme-dynamic
fi
awww kill --all >/dev/null 2>&1
exec blob-theme-set "$slug"
;;
esac
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# blob:summary=Print OSC sequences for an Blob color theme
# blob:hidden=true
theme="${1:-$HOME/.local/state/blob/current/theme/colors.toml}"
[[ -f $theme ]] || exit 0
declare -A COLORS
while IFS=$'\t' read -r key value; do
COLORS[$key]="$value"
done < <(blob-theme-color --file "$theme" --all)
emit_osc() {
local code="$1"
local key="$2"
[[ -n ${COLORS[$key]:-} ]] || return 0
printf '\033]%s;%s\007' "$code" "${COLORS[$key]}"
}
emit_osc 10 foreground
emit_osc 11 background
emit_osc 12 cursor
emit_osc 17 selection_background
emit_osc 19 selection_foreground
for i in {0..15}; do
[[ -n ${COLORS[color$i]:-} ]] || continue
printf '\033]4;%d;%s\007' "$i" "${COLORS[color$i]}"
done
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# blob:summary=Refresh the current theme from its templates.
THEME_NAME_PATH="$HOME/.local/state/blob/current/theme.name"
if [[ -f $THEME_NAME_PATH ]]; then
BLOB_THEME_SKIP_BACKGROUND=1 blob-theme-set "$(cat $THEME_NAME_PATH)"
fi
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# blob:summary=Remove a user-installed theme
# blob:args=[theme-name]
# blob:examples=blob theme remove "Tokyo Night"
if [[ -z $1 ]]; then
mapfile -t extra_themes < <(find ~/.config/blob/themes -mindepth 1 -maxdepth 1 -type d ! -xtype l -printf '%f\n')
if (( ${#extra_themes[@]} > 0 )); then
mapfile -t extra_themes < <(printf '%s\n' "${extra_themes[@]}" | sort)
THEME_NAME=$(blob-menu-select "Remove extra theme" "${extra_themes[@]}" -- --width 520 --maxheight 520)
else
echo "No extra themes installed."
exit 1
fi
else
THEME_NAME="$1"
fi
THEMES_DIR="$HOME/.config/blob/themes"
THEME_PATH="$THEMES_DIR/$THEME_NAME"
# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
# on its way into the rm below.
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
exit 1
fi
# Check if theme exists before attempting removal
if [[ ! -d $THEME_PATH ]]; then
echo "Error: Theme '$THEME_NAME' not found."
exit 1
fi
# Now remove the theme directory for THEME_NAME
rm -rf "$THEME_PATH"
blob-notify-send "Theme removed" "$THEME_NAME" || true
echo "Removed $THEME_NAME"
+337
View File
@@ -0,0 +1,337 @@
#!/bin/bash
# blob:summary=Apply an Blob theme
# blob:args=<theme-name>
# blob:examples=blob theme list | blob theme set "Tokyo Night"
if [[ -z $1 ]]; then
echo "Usage: blob-theme-set <theme-name>"
exit 1
fi
CURRENT_THEME_PATH="$HOME/.local/state/blob/current/theme"
NEXT_THEME_PATH="$HOME/.local/state/blob/current/next-theme"
CURRENT_BACKGROUND_LINK="$HOME/.local/state/blob/current/background"
BACKGROUND_TRANSITION_CACHE="$HOME/.cache/blob/background-transitions"
THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/blob-theme-set.lock"
USER_THEMES_PATH="$HOME/.config/blob/themes"
BLOB_THEMES_PATH="$BLOB_PATH/themes"
# What a theme installed from a git repo may not ship, because these run code.
# Hyprland requires a theme's hyprland.lua at login and Neovim loads its
# neovim.lua at startup, so no .lua from such a theme is staged at all. Each
# terminal config names the program the terminal launches. vscode.json is denied
# even though nothing here reads it, because a theme that ships one is naming a
# VS Code extension, which is arbitrary JavaScript. Everything else a theme
# ships is colour and is kept.
#
# Adding a template for another terminal, or for another editor that loads Lua,
# means adding it here.
INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json)
IGNORED_THEME_FILES=()
run_parallel() {
local pid
local pids=()
for command in "$@"; do
bash -lc "$command" &
pids+=("$!")
done
for pid in "${pids[@]}"; do
wait "$pid"
done
}
shell_ipc() {
timeout 2 blob-shell "$@" >/dev/null 2>&1
}
snapshot_background_path() {
local background="$1"
local name="$2"
local snapshot extension
[[ -f $background ]] || return
mkdir -p "$BACKGROUND_TRANSITION_CACHE"
extension=${background##*.}
snapshot="$BACKGROUND_TRANSITION_CACHE/$name-$$.$extension"
ln "$background" "$snapshot" 2>/dev/null || cp "$background" "$snapshot"
echo "$snapshot"
}
snapshot_current_background() {
local current_background
current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true)
snapshot_background_path "$current_background" "previous"
}
choose_theme_background() {
local backgrounds=()
local current_background index next_index i
CHOSEN_THEME_BACKGROUND=""
mapfile -d '' -t backgrounds < <(
find -L "$HOME/.config/blob/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -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
)
(( ${#backgrounds[@]} > 0 )) || return 1
current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true)
index=-1
for i in "${!backgrounds[@]}"; do
if [[ ${backgrounds[$i]} == $current_background ]]; then
index=$i
break
fi
done
if (( index == -1 )); then
CHOSEN_THEME_BACKGROUND="${backgrounds[0]}"
else
next_index=$(((index + 1) % ${#backgrounds[@]}))
CHOSEN_THEME_BACKGROUND="${backgrounds[$next_index]}"
fi
}
set_theme_background_link() {
choose_theme_background || return 1
ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK"
}
set_theme_background() {
local new_background new_background_snapshot
if ! choose_theme_background; then
blob-notify-send "No background was found for theme" -t 2000
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
return
fi
new_background="$CHOSEN_THEME_BACKGROUND"
new_background_snapshot=$(snapshot_background_path "$new_background" "next")
if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then
shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
(sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot") &
elif [[ -f $new_background_snapshot ]]; then
shell_ipc background themeTransition "" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
(sleep 3; rm -f "$new_background_snapshot") &
else
shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background" "$new_background" "$colors_payload" "$shell_payload" || \
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
if [[ -f $OLD_BACKGROUND_SNAPSHOT ]]; then
(sleep 3; rm -f "$OLD_BACKGROUND_SNAPSHOT") &
fi
fi
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK"
}
is_denied_installed_file() {
local name="$1"
local denied
[[ $name == *.lua ]] && return 0
for denied in "${INSTALLED_THEME_DENIED[@]}"; do
[[ $name == "$denied" ]] && return 0
done
return 1
}
# Themes older than colors.toml still get their palette, but their
# alacritty.toml never reaches the staged theme: an Alacritty config names the
# program the terminal launches.
stage_installed_colors_from_alacritty() {
local source="$1"
local scratch
if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then
return
fi
if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then
return
fi
scratch=$(mktemp -d)
cp "$source/alacritty.toml" "$scratch/alacritty.toml"
blob-theme-import "$scratch"
if [[ -f $scratch/colors.toml ]]; then
cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml"
fi
rm -rf "$scratch"
}
# Copies a directory without ever following a symlink: in an installed theme one
# points wherever the theme author chose, which is how an unlock.png becomes a
# copy of any file the session can read.
stage_installed_dir() {
local source="$1"
local dest="$2"
local entry name
mkdir -p "$dest"
for entry in "$source"/*; do
[[ -e $entry && ! -L $entry ]] || continue
name=${entry##*/}
if [[ -d $entry ]]; then
stage_installed_dir "$entry" "$dest/$name"
else
cp "$entry" "$dest/$name"
fi
done
}
# `blob theme install` clones into ~/.config/blob/themes, so a .git
# directory there means the contents came from a stranger and are held to the
# list above. A directory the user wrote themselves, and a symlink to their own
# working copy, are theirs to fill however they like -- the same distinction
# blob-theme-extras draws when it decides which themes it may pull.
theme_came_from_a_repo() {
local source="$1"
[[ ! -L $source && -d $source/.git ]]
}
stage_installed_theme() {
local source="$1"
local entry name
[[ -d $source ]] || return 0
for entry in "$source"/*; do
[[ -e $entry ]] || continue
name=${entry##*/}
if [[ -L $entry ]] || is_denied_installed_file "$name"; then
case "${name,,}" in
readme* | license* | changelog* | *.md | *.txt) ;;
*) IGNORED_THEME_FILES+=("$name") ;;
esac
elif [[ -d $entry ]]; then
stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name"
else
cp "$entry" "$NEXT_THEME_PATH/$name"
fi
done
stage_installed_colors_from_alacritty "$source"
}
report_ignored_theme_files() {
(( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0
echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2
echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2
}
THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
THEME_HEADLESS=0
if [[ ${BLOB_THEME_HEADLESS:-} == "1" || ${BLOB_THEME_OFFLINE:-} == "1" ]]; then
THEME_HEADLESS=1
fi
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
echo "Invalid theme name: $1"
exit 1
fi
if [[ ! -d $BLOB_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
echo "Theme '$THEME_NAME' does not exist"
exit 1
fi
# Serialize theme changes. Theme switching rebuilds a shared next-theme staging
# directory and updates current theme/background symlinks; concurrent calls can
# otherwise race and make one selection appear to be ignored.
exec 9>"$THEME_SET_LOCK"
flock 9
# Setup clean next theme directory (for atomic theme config swapping)
rm -rf "$NEXT_THEME_PATH"
mkdir -p "$NEXT_THEME_PATH"
# Copy official theme first, then overlay the user's theme on top
cp -r "$BLOB_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then
stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME"
report_ignored_theme_files
else
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
fi
# Generate colors.toml from alacritty.toml if theme is missing colors.toml
if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
blob-theme-import "$NEXT_THEME_PATH"
fi
# Generate dynamic configs
blob-theme-templates
OLD_BACKGROUND_SNAPSHOT=""
if [[ $THEME_HEADLESS != "1" && $BLOB_THEME_SKIP_BACKGROUND != "1" ]]; then
OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background)
fi
# Swap next theme in as current
rm -rf "$CURRENT_THEME_PATH"
mv "$NEXT_THEME_PATH" "$CURRENT_THEME_PATH"
# Store theme name for reference
echo "$THEME_NAME" >"$HOME/.local/state/blob/current/theme.name"
# Make the running shell pick up the new palette immediately while the rest of
# the theme hooks run.
colors_payload=$([[ -f $CURRENT_THEME_PATH/colors.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/colors.toml")
shell_payload=$([[ -f $CURRENT_THEME_PATH/shell.toml ]] && base64 -w 0 "$CURRENT_THEME_PATH/shell.toml")
if [[ $THEME_HEADLESS == "1" ]]; then
# No shell/session bus exists during ISO chroot finalization, but the first
# real login still needs a current background symlink for blob-shell to
# render.
[[ $BLOB_THEME_SKIP_BACKGROUND == "1" ]] || set_theme_background_link || true
elif [[ $BLOB_THEME_SKIP_BACKGROUND == "1" ]]; then
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
else
set_theme_background
fi
# The shared staging/current symlinks are updated and the shell has accepted
# the transition. Let another theme selection queue only behind that critical
# section, not behind slower app-retint hooks and selector cache warmups.
flock -u 9
post_theme_commands=(
blob-restart-terminal
blob-hypr-restart
blob-restart-btop
blob-theme-foot
blob-theme-browser
)
if [[ $THEME_HEADLESS != "1" ]]; then
run_parallel "${post_theme_commands[@]}"
# Call hook on theme set
blob-hook theme-set "$THEME_NAME" >/dev/null
# Warm selector caches after the theme is applied. The shell hot-reloads theme
# colors/backgrounds, so keep the running instance alive and preload the picker
# rows/selection to avoid first-open carousel settling after a theme change.
blob-theme-switcher --preload >/dev/null 2>&1
blob-bg-cache >/dev/null 2>&1 &
fi
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# blob:summary=Fetch a shared theme by link or id and apply it as blob-dynamic
# blob:args=<share-link-or-id>
# blob:examples=blob theme share https://wall-styles.vercel.app/?id=aB3xQ
dynamic_theme_dir="$HOME/.config/blob/themes/blob-dynamic"
base_url="${BLOB_THEME_URL:-https://wall-styles.vercel.app}"
input="$1"
if [ -z "$input" ]; then
echo "Usage: blob-theme-share <share-link-or-id>" >&2
exit 1
fi
if [[ $input == http*://* ]]; then
host=$(echo "$input" | sed -E 's#^(https?://[^/]+).*#\1#')
share_id=$(echo "$input" | grep -oE 'id=[A-Za-z0-9]+' | head -1 | cut -d= -f2)
if [ -z "$share_id" ]; then
echo "Error: no share id found in '$input'." >&2
exit 1
fi
api="$host/api/theme?id=$share_id&format=toml"
else
api="$base_url/api/theme?id=$input&format=toml"
fi
mkdir -p "$dynamic_theme_dir/backgrounds"
echo "Fetching theme..."
downloaded=$(mktemp)
if ! curl -fsSL "$api" -o "$downloaded"; then
echo "Error: could not fetch the theme. The link may be expired (themes last one hour)." >&2
rm -f "$downloaded"
exit 1
fi
if ! grep -q '^background = ' "$downloaded"; then
echo "Error: the response did not look like a valid colors.toml." >&2
rm -f "$downloaded"
exit 1
fi
mv "$downloaded" "$dynamic_theme_dir/colors.toml"
cat >"$dynamic_theme_dir/neovim.lua" <<'NEOVIM'
return {
{
"LazyVim/LazyVim",
opts = {
colorscheme = "tokyonight",
},
},
}
NEOVIM
blob-theme-set blob-dynamic
echo "Applied shared theme to blob-dynamic."
+125
View File
@@ -0,0 +1,125 @@
#!/bin/bash
# blob:summary=Open the Blob theme switcher
preload=false
if [[ $1 == "--preload" ]]; then
preload=true
shift
fi
USER_THEMES_PATH="$HOME/.config/blob/themes"
BLOB_THEMES_PATH="$BLOB_PATH/themes"
CACHE_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/blob/theme-selector"
preview_dir="$CACHE_PATH/previews"
signature_file="$CACHE_PATH/signature"
fast_signature_file="$CACHE_PATH/fast-signature"
mkdir -p "$preview_dir"
find_preview() {
local theme_path="$1"
local preview preview_name
for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp; do
preview=$(find -L "$theme_path" -maxdepth 1 -type f -iname "$preview_name" -print -quit 2>/dev/null)
if [[ -n $preview ]]; then
printf '%s\n' "$preview"
return
fi
done
if [[ -d $theme_path/backgrounds ]]; then
find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1
fi
}
add_theme_preview() {
local theme_name="$1"
local preview="$2"
local extension="${preview##*.}"
extension="${extension,,}"
[[ -n $preview ]] || return
[[ -e $preview_dir/$theme_name.$extension ]] && return
ln -s "$preview" "$preview_dir/$theme_name.$extension"
}
fast_signature="v1"$'\n'
for theme_dir in "$USER_THEMES_PATH" "$BLOB_THEMES_PATH"; do
if [[ -d $theme_dir ]]; then
fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n'
while IFS= read -r -d '' theme_path; do
fast_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n'
done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z)
fi
done
if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then
theme_signature=""
for theme_dir in "$USER_THEMES_PATH" "$BLOB_THEMES_PATH"; do
if [[ -d $theme_dir ]]; then
theme_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n'
while IFS= read -r -d '' theme_path; do
preview=$(find_preview "$theme_path")
theme_signature+="$theme_path:$(stat -Lc '%Y' "$theme_path")"$'\n'
if [[ -n $preview ]]; then
theme_signature+="$preview:$(stat -Lc '%s:%Y' "$preview")"$'\n'
fi
done < <(find -L "$theme_dir" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0 2>/dev/null | sort -z)
fi
done
fi
if [[ ! -f $fast_signature_file ]] || ! cmp -s "$fast_signature_file" <(printf '%s' "$fast_signature"); then
rm -rf "$preview_dir"
mkdir -p "$preview_dir"
while IFS= read -r theme_path; do
theme_name=${theme_path##*/}
preview=$(find_preview "$theme_path")
if [[ -z $preview ]]; then
preview=$(find_preview "$BLOB_THEMES_PATH/$theme_name")
fi
add_theme_preview "$theme_name" "$preview"
done < <(find -L "$USER_THEMES_PATH" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print 2>/dev/null | sort)
while IFS= read -r theme_path; do
theme_name=${theme_path##*/}
preview=$(find_preview "$theme_path")
add_theme_preview "$theme_name" "$preview"
done < <(find -L "$BLOB_THEMES_PATH" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | sort)
printf '%s' "$theme_signature" >"$signature_file"
printf '%s' "$fast_signature" >"$fast_signature_file"
fi
current_theme=$(cat "$HOME/.local/state/blob/current/theme.name" 2>/dev/null)
selected_preview=""
for extension in png jpg jpeg webp gif bmp; do
if [[ -e $preview_dir/$current_theme.$extension ]]; then
selected_preview="$preview_dir/$current_theme.$extension"
break
fi
done
menu_args=(
--print-name
--show-labels
--filterable
--lazy-thumbnails
--selected "$selected_preview"
)
if [[ $preload == true ]]; then
menu_args+=(--preload)
fi
exec blob-menu-images "${menu_args[@]}" "$preview_dir"
+404
View File
@@ -0,0 +1,404 @@
#!/bin/bash
# blob:summary=Generate themed config files from Blob templates
# blob:hidden=true
TEMPLATES_DIR="$BLOB_PATH/default/themed"
USER_TEMPLATES_DIR="$HOME/.config/blob/themed"
NEXT_THEME_DIR="$HOME/.local/state/blob/current/next-theme"
COLORS_FILE="$NEXT_THEME_DIR/colors.toml"
declare -A THEME_COLORS
# Convert hex color to decimal RGB (e.g., "#1e1e2e" -> "30,30,46")
hex_to_rgb() {
local hex="${1#\#}"
printf "%d,%d,%d" "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
# Mix two hex colors. Amount may be a fraction (0.30) or percentage (30%).
mix_color() {
local start="${1#\#}"
local end="${2#\#}"
local amount="$3"
awk -v start="$start" -v end="$end" -v amount="$amount" '
function hex_value(char) {
return index("0123456789abcdef", tolower(char)) - 1
}
function hex_pair_to_int(hex, idx) {
return hex_value(substr(hex, idx, 1)) * 16 + hex_value(substr(hex, idx + 1, 1))
}
BEGIN {
if (amount ~ /%$/) {
sub(/%$/, "", amount)
amount = amount / 100
} else {
amount += 0
if (amount > 1) amount = amount / 100
}
if (amount < 0) amount = 0
if (amount > 1) amount = 1
start_r = hex_pair_to_int(start, 1)
start_g = hex_pair_to_int(start, 3)
start_b = hex_pair_to_int(start, 5)
end_r = hex_pair_to_int(end, 1)
end_g = hex_pair_to_int(end, 3)
end_b = hex_pair_to_int(end, 5)
red = int(start_r * (1 - amount) + end_r * amount + 0.5)
green = int(start_g * (1 - amount) + end_g * amount + 0.5)
blue = int(start_b * (1 - amount) + end_b * amount + 0.5)
printf "#%02x%02x%02x\n", red, green, blue
}
'
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf "%s" "$value"
}
resolve_theme_ref() {
local ref="$1"
local fallback="${2:-}"
if [[ -n ${THEME_COLORS[$ref]+_} ]]; then
printf "%s" "${THEME_COLORS[$ref]}"
elif [[ -n $fallback && -n ${THEME_COLORS[$fallback]+_} ]]; then
printf "%s" "${THEME_COLORS[$fallback]}"
elif [[ -n $fallback ]]; then
printf "%s" "$fallback"
else
printf "%s" "$ref"
fi
}
resolve_gradient_color() {
local color
color=$(trim "$1")
if [[ -n ${THEME_COLORS[$color]+_} ]]; then
color="${THEME_COLORS[$color]}"
fi
printf "%s" "$color"
}
parse_gradient() {
local spec="$1"
local part color
local -a parts
GRADIENT_COLORS=()
GRADIENT_ANGLE=""
read -ra parts <<<"$spec"
for part in "${parts[@]}"; do
[[ -n $part ]] || continue
if [[ $part =~ ^-?[0-9]+([.][0-9]+)?deg$ ]]; then
GRADIENT_ANGLE="${part%deg}"
else
color=$(resolve_gradient_color "$part")
GRADIENT_COLORS+=("$color")
fi
done
}
color_to_shell_hex() {
local color r g b
color=$(resolve_gradient_color "$1")
if [[ $color =~ ^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$ ]]; then
printf "#%s" "${color:1:6}"
elif [[ $color =~ ^[Rr][Gg][Bb][Aa]?\(([0-9A-Fa-f]{6})([0-9A-Fa-f]{2})?\)$ ]]; then
printf "#%s" "${BASH_REMATCH[1]}"
elif [[ $color =~ ^[Rr][Gg][Bb][Aa]?\(([0-9]+),([0-9]+),([0-9]+)(,[0-9.]+)?\)$ ]]; then
r=${BASH_REMATCH[1]}
g=${BASH_REMATCH[2]}
b=${BASH_REMATCH[3]}
(( r > 255 )) && r=255
(( g > 255 )) && g=255
(( b > 255 )) && b=255
printf "#%02x%02x%02x" "$r" "$g" "$b"
elif [[ $color =~ ^0x[0-9A-Fa-f]{8}$ ]]; then
printf "#%s" "${color:4:6}"
else
printf "%s" "$color"
fi
}
hypr_gradient_value() {
local spec color index
spec=$(resolve_theme_ref "$1" "${2:-}")
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
printf '"%s"' "$spec"
elif (( ${#GRADIENT_COLORS[@]} == 1 )); then
printf '"%s"' "${GRADIENT_COLORS[0]}"
else
printf '{ colors = {'
for index in "${!GRADIENT_COLORS[@]}"; do
(( index > 0 )) && printf ','
printf ' "%s"' "${GRADIENT_COLORS[$index]}"
done
printf ' }'
[[ -n $GRADIENT_ANGLE ]] && printf ', angle = %s' "$GRADIENT_ANGLE"
printf ' }'
fi
}
gradient_start_value() {
local spec
spec=$(resolve_theme_ref "$1" "${2:-}")
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
color_to_shell_hex "$spec"
else
color_to_shell_hex "${GRADIENT_COLORS[0]}"
fi
}
shell_gradient_value() {
local spec index
spec=$(resolve_theme_ref "$1" "${2:-}")
parse_gradient "$spec"
if (( ${#GRADIENT_COLORS[@]} == 0 )); then
printf "%s" "$spec"
return
fi
for index in "${!GRADIENT_COLORS[@]}"; do
(( index > 0 )) && printf " "
printf "%s" "${GRADIENT_COLORS[$index]}"
done
[[ -n $GRADIENT_ANGLE ]] && printf " %sdeg" "$GRADIENT_ANGLE"
}
add_template_value() {
local key="$1"
local value="$2"
local rgb
printf 's|{{ %s }}|%s|g\n' "$key" "$value" >>"$sed_script"
printf 's|{{ %s_strip }}|%s|g\n' "$key" "${value#\#}" >>"$sed_script"
if [[ $value =~ ^#[0-9A-Fa-f]{6}$ ]]; then
rgb=$(hex_to_rgb "$value")
printf 's|{{ %s_rgb }}|%s|g\n' "$key" "$rgb" >>"$sed_script"
fi
}
add_mix_value() {
local token="$1"
local content fn start_key end_key amount start end value
content="${token#\{\{}"
content="${content%\}\}}"
read -r fn start_key end_key amount <<<"$content"
start="${THEME_COLORS[$start_key]:-}"
end="${THEME_COLORS[$end_key]:-}"
[[ $start =~ ^#[0-9A-Fa-f]{6}$ && $end =~ ^#[0-9A-Fa-f]{6}$ ]] || return
value=$(mix_color "$start" "$end" "$amount")
case "$fn" in
mix)
;;
mix_strip)
value="${value#\#}"
;;
mix_rgb)
value=$(hex_to_rgb "$value")
;;
*)
return
;;
esac
printf 's|%s|%s|g\n' "$token" "$value" >>"$sed_script"
}
add_mix_values() {
local tpl token
local -A seen=()
for tpl in "${template_files[@]}"; do
while IFS= read -r token; do
[[ -n ${seen[$token]:-} ]] && continue
seen[$token]=1
add_mix_value "$token"
done < <(grep -hEo '\{\{[[:space:]]*mix(_strip|_rgb)?[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[A-Za-z0-9_]+[[:space:]]+[0-9]+([.][0-9]+)?%?[[:space:]]*\}\}' "$tpl" 2>/dev/null || true)
done
}
add_gradient_function_value() {
local token="$1"
local content fn key fallback value
content="${token#\{\{}"
content="${content%\}\}}"
read -r fn key fallback <<<"$content"
case "$fn" in
hypr_gradient)
value=$(hypr_gradient_value "$key" "$fallback")
;;
gradient_start)
value=$(gradient_start_value "$key" "$fallback")
;;
shell_gradient)
value=$(shell_gradient_value "$key" "$fallback")
;;
*)
return
;;
esac
printf 's|%s|%s|g\n' "$token" "$value" >>"$sed_script"
}
add_gradient_function_values() {
local tpl token
local -A seen=()
for tpl in "${template_files[@]}"; do
while IFS= read -r token; do
[[ -n ${seen[$token]:-} ]] && continue
seen[$token]=1
add_gradient_function_value "$token"
done < <(grep -hEo '\{\{[[:space:]]*(hypr_gradient|gradient_start|shell_gradient)[[:space:]]+[^}]+[[:space:]]*\}\}' "$tpl" 2>/dev/null || true)
done
}
strip_shell_section_header() {
local section="$1"
local file="$2"
awk -v section="$section" '
BEGIN { skipping_header = 0 }
/^[[:space:]]*\[[^]]+\][[:space:]]*($|#)/ {
if ($0 ~ "^[[:space:]]*\\[" section "\\][[:space:]]*($|#)") {
skipping_header = 1
next
}
}
{ print }
' "$file"
}
apply_shell_section_override() {
local override="$1"
local section tmp body
[[ -f $NEXT_THEME_DIR/shell.toml ]] || return
[[ -f $override ]] || return
section=$(basename "$override")
section="${section#shell.}"
section="${section%.toml}"
[[ $section =~ ^[A-Za-z0-9_-]+$ ]] || return
tmp=$(mktemp)
body=$(mktemp)
strip_shell_section_header "$section" "$override" >"$body"
awk -v section="$section" -v body="$body" '
function emit_override() {
if (emitted) return
print "[" section "]"
while ((getline line < body) > 0) print line
close(body)
emitted = 1
}
/^[[:space:]]*\[[^]]+\][[:space:]]*($|#)/ {
if (in_section) {
emit_override()
in_section = 0
}
if ($0 ~ "^[[:space:]]*\\[" section "\\][[:space:]]*($|#)") {
in_section = 1
next
}
}
!in_section { print }
END {
if (in_section || !emitted) {
if (NR > 0) print ""
emit_override()
}
}
' "$NEXT_THEME_DIR/shell.toml" >"$tmp"
mv "$tmp" "$NEXT_THEME_DIR/shell.toml"
rm "$body"
}
apply_shell_section_overrides() {
local override
shopt -s nullglob
for override in "$NEXT_THEME_DIR"/shell.*.toml; do
[[ $(basename "$override") != "shell.toml" ]] || continue
apply_shell_section_override "$override"
done
}
# Only generate dynamic templates for themes with a colors.toml definition
if [[ -f $COLORS_FILE ]]; then
sed_script=$(mktemp)
shopt -s nullglob
template_files=("$USER_TEMPLATES_DIR"/*.tpl "$TEMPLATES_DIR"/*.tpl)
# Parsing and alias/fallback resolution (legacy colorN names, derived
# shades, mode/theme_type) is shared with every other colors.toml consumer.
while IFS=$'\t' read -r key value; do
THEME_COLORS[$key]="$value"
done < <(blob-theme-color --file "$COLORS_FILE" --all)
for key in "${!THEME_COLORS[@]}"; do
add_template_value "$key" "${THEME_COLORS[$key]}"
done
add_mix_values
add_gradient_function_values
# Process user templates first, then built-in templates (user overrides built-in)
for tpl in "${template_files[@]}"; do
filename=$(basename "$tpl" .tpl)
output_path="$NEXT_THEME_DIR/$filename"
# Don't overwrite configs already exists in the output directory (copied from theme specific folder)
if [[ ! -f $output_path ]]; then
sed -f "$sed_script" "$tpl" >"$output_path"
fi
done
rm "$sed_script"
fi
apply_shell_section_overrides
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
# blob:summary=Create a desktop launcher for a terminal UI app
# blob:args=[name command window-style icon-url-or-name]
set -e
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
safe_icon_name() {
printf '%s\n' "$1" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//'
}
icon_name_from_ref() {
local ref="$1"
local name
name=$(basename "$ref")
if [[ $name == *.* ]]; then
safe_icon_name "${name%.*}"
else
printf '%s\n' "$name"
fi
}
install_user_icon() {
local source="$1"
local name="$2"
local ext="${source##*.}"
[[ $ext == "$source" ]] && ext="png"
mkdir -p "$ICON_DIR"
cp "$source" "$ICON_DIR/$name.$ext"
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
printf '%s\n' "$name"
}
if (( $# != 4 )); then
echo -e "\e[32mLet's create a TUI shortcut you can start with the app launcher.\n\e[0m"
APP_NAME=$(gum input --prompt "Name> " --placeholder "My TUI")
APP_EXEC=$(gum input --prompt "Launch Command> " --placeholder "lazydocker or bash -c 'dust; read -n 1 -s'")
WINDOW_STYLE=$(gum choose --header "Window style" float tile)
ICON_REF=$(gum input --prompt "Icon URL/name> " --placeholder "See https://dashboardicons.com or enter an installed icon name")
else
APP_NAME="$1"
APP_EXEC="$2"
WINDOW_STYLE="$3"
ICON_REF="$4"
fi
if [[ -z $APP_NAME || -z $APP_EXEC || -z $ICON_REF ]]; then
echo "You must set app name, app command, and icon URL/name!"
exit 1
fi
DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop"
mkdir -p "$(dirname "$DESKTOP_FILE")"
if [[ $ICON_REF =~ ^https?:// ]]; then
ICON_VALUE=$(safe_icon_name "$APP_NAME")
mkdir -p "$ICON_DIR"
if ! curl -sL -o "$ICON_DIR/$ICON_VALUE.png" "$ICON_REF"; then
echo "Error: Failed to download icon."
exit 1
fi
gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true
elif [[ -f $ICON_REF ]]; then
ICON_VALUE=$(install_user_icon "$ICON_REF" "$(safe_icon_name "$APP_NAME")")
else
# Bundled Blob icons are package-owned under /usr/share/icons/hicolor.
ICON_VALUE=$(icon_name_from_ref "$ICON_REF")
fi
if [[ $WINDOW_STYLE == "float" ]]; then
APP_CLASS="TUI.float"
else
APP_CLASS="TUI.tile"
fi
cat >"$DESKTOP_FILE" <<EOF
[Desktop Entry]
Version=1.0
Name=$APP_NAME
Comment=$APP_NAME
Exec=xdg-terminal-exec --app-id=$APP_CLASS -e $APP_EXEC
Terminal=false
Type=Application
Icon=$ICON_VALUE
StartupNotify=true
EOF
chmod +x "$DESKTOP_FILE"
if (( $# != 4 )); then
echo -e "You can now find $APP_NAME using the app launcher (SUPER + SPACE)\n"
fi
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# blob:summary=Remove a terminal UI desktop launcher
# blob:args=[name]
set -e
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
OLD_ICON_DIR="$HOME/.local/share/applications/icons"
DESKTOP_DIR="$HOME/.local/share/applications/"
if (( $# == 0 )); then
# Find all TUIs
while IFS= read -r -d '' file; do
if grep -qE '^Exec=.*(\$TERMINAL|xdg-terminal-exec).*-e' "$file"; then
TUIS+=("$(basename "${file%.desktop}")")
fi
done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0)
if ((${#TUIS[@]})); then
mapfile -t SORTED_TUIS < <(printf '%s\n' "${TUIS[@]}" | sort)
APP_NAME=$(blob-menu-select "Select TUI to remove" "${SORTED_TUIS[@]}" -- --width 520 --maxheight 520)
else
echo "No TUIs to remove."
exit 1
fi
else
APP_NAME="$*"
fi
if [[ -z $APP_NAME ]]; then
echo "You must select a TUI to remove."
exit 1
fi
icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//')
rm -f "$DESKTOP_DIR/$APP_NAME.desktop"
rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png"
if [[ ${BLOB_REMOVE_NOTIFY:-true} != "false" ]]; then
blob-notify-send -g  "TUI removed" "$APP_NAME"
fi
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# blob:summary=Remove all TUIs installed via blob-tui-install.
set -e
APP_DIR="${1:-$HOME/.local/share/applications}"
ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
OLD_ICON_DIR="$HOME/.local/share/applications/icons"
echo "Scanning for TUIs in $APP_DIR..."
tui_desktop_files=()
while IFS= read -r -d '' file; do
if grep -q "Exec=xdg-terminal-exec --app-id=TUI\." "$file" 2>/dev/null; then
tui_desktop_files+=("$file")
fi
done < <(find "$APP_DIR" -maxdepth 1 -name "*.desktop" -print0 2>/dev/null)
if (( ${#tui_desktop_files[@]} == 0 )); then
echo "No TUIs found."
exit 0
fi
for file in "${tui_desktop_files[@]}"; do
app_name=$(basename "$file" .desktop)
icon_name=$(printf '%s\n' "$app_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//')
echo "Removing TUI: $app_name"
rm -f "$file"
rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$app_name.png" "$OLD_ICON_DIR/$app_name.png"
done
if blob-cmd-present update-desktop-database; then
update-desktop-database "$APP_DIR" &>/dev/null || true
fi
echo "TUIs removed successfully."
+146
View File
@@ -0,0 +1,146 @@
#!/bin/bash
# blob:summary=Set a wallpaper from ~/wallpapers and recolor the desktop from it
# blob:args=[--menu|<file>]
WALLPAPER_DIR="$HOME/wallpapers"
THEME_DIR="$HOME/.config/blob/themes/blob-dynamic"
CURRENT_DIR="$HOME/.local/state/blob/current"
# Create the directory if it doesn't exist
mkdir -p "$WALLPAPER_DIR"
mkdir -p "$THEME_DIR/backgrounds"
{
echo "=== $(date '+%F %T') invoked with: $* ==="
echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR"
echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS"
echo "PATH=$PATH"
} >> "$HOME/.cache/blob-wallpaper.log"
# Rows for blob-menu-select, as "<label><TAB><path>". The menu returns the
# label and the subtext, so the full path comes back as a stable key.
list_wallpaper_rows() {
find "$WALLPAPER_DIR" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \
2>/dev/null | sort | while read -r background; do
name=$(basename "$background")
label=$(echo "${name%.*}" | sed -E 's/^[0-9]+//; s/^[-_]//; s/[-_]/ /g; s/(^| )([a-z])/\1\u\2/g')
printf '%s\t%s\n' "$label" "$background"
done
}
if [ -z "$1" ] || [ "$1" = "--menu" ]; then
selection=$(list_wallpaper_rows | blob-menu-select "Select Wallpaper" -- --width 800) || exit 0
IMAGE_PATH=$(printf '%s' "$selection" | cut -f2)
if [ -z "$IMAGE_PATH" ] || [ ! -f "$IMAGE_PATH" ]; then
exit 0
fi
else
# Check if the argument is a file in the wallpapers directory
if [ -f "$WALLPAPER_DIR/$1" ]; then
IMAGE_PATH=$(realpath "$WALLPAPER_DIR/$1")
# Check if the argument is an absolute or relative path
elif [ -f "$1" ]; then
IMAGE_PATH=$(realpath "$1")
else
echo "Error: File '$1' does not exist in $WALLPAPER_DIR or as a valid path."
exit 1
fi
fi
echo "Extracting colors using Pywal..."
wal -i "$IMAGE_PATH" -n -q 2> >(grep -v "deprecated in IMv7" >&2)
# Enhance colors if the palette is too monotone
blob-theme-contrast "$HOME/.cache/wal/colors"
# Clear old backgrounds and copy the new one into the dynamic theme
rm -f "$THEME_DIR/backgrounds/"*
cp "$IMAGE_PATH" "$THEME_DIR/backgrounds/"
# Parse pywal colors and write to colors.toml in the dynamic theme
cat <<EOF > "$THEME_DIR/colors.toml"
accent = "$(sed -n '2p' ~/.cache/wal/colors)"
cursor = "$(sed -n '8p' ~/.cache/wal/colors)"
foreground = "$(sed -n '8p' ~/.cache/wal/colors)"
background = "$(sed -n '1p' ~/.cache/wal/colors)"
selection_foreground = "$(sed -n '1p' ~/.cache/wal/colors)"
selection_background = "$(sed -n '2p' ~/.cache/wal/colors)"
color0 = "$(sed -n '1p' ~/.cache/wal/colors)"
color1 = "$(sed -n '2p' ~/.cache/wal/colors)"
color2 = "$(sed -n '3p' ~/.cache/wal/colors)"
color3 = "$(sed -n '4p' ~/.cache/wal/colors)"
color4 = "$(sed -n '5p' ~/.cache/wal/colors)"
color5 = "$(sed -n '6p' ~/.cache/wal/colors)"
color6 = "$(sed -n '7p' ~/.cache/wal/colors)"
color7 = "$(sed -n '8p' ~/.cache/wal/colors)"
color8 = "$(sed -n '9p' ~/.cache/wal/colors)"
color9 = "$(sed -n '10p' ~/.cache/wal/colors)"
color10 = "$(sed -n '11p' ~/.cache/wal/colors)"
color11 = "$(sed -n '12p' ~/.cache/wal/colors)"
color12 = "$(sed -n '13p' ~/.cache/wal/colors)"
color13 = "$(sed -n '14p' ~/.cache/wal/colors)"
color14 = "$(sed -n '15p' ~/.cache/wal/colors)"
color15 = "$(sed -n '16p' ~/.cache/wal/colors)"
EOF
# Write neovim.lua to satisfy LazyVim's theme symlink requirement
cat <<EOF > "$THEME_DIR/neovim.lua"
return {
{
"LazyVim/LazyVim",
opts = {
colorscheme = "tokyonight",
},
},
}
EOF
# Only switch the active color theme to blob-dynamic if you're already
# in dynamic mode. The palette above and the background below are kept
# up to date regardless, so `blob_theme --dynamic` always reflects the
# latest wallpaper - but picking a wallpaper while on a static theme
# should change the wallpaper, not silently pull you out of it.
#
# When it does apply, skip blob-theme-set's own background step: it
# picks a background out of the theme folder and runs its own transition,
# which would race with the background handling below.
CURRENT_THEME_NAME=$(cat "$CURRENT_DIR/theme.name" 2>/dev/null)
if [ "$CURRENT_THEME_NAME" = "blob-dynamic" ]; then
BLOB_THEME_SKIP_BACKGROUND=1 blob-theme-set "blob-dynamic"
fi
NEW_BACKGROUND="$THEME_DIR/backgrounds/$(basename "$IMAGE_PATH")"
# Stop a gif daemon left over from a previous wallpaper. `awww kill` shuts the
# daemon down via its own IPC so it cleans up its socket, unlike a raw `pkill`.
awww kill --all >/dev/null 2>&1
pkill -x awww-daemon 2>/dev/null
# blob-bg-set updates ~/.local/state/blob/current/background and
# tells the running Blob shell to repaint, which is what draws the desktop
# background in Blob 4 (swaybg is no longer involved).
blob-bg-set "$NEW_BACKGROUND"
if [[ "${IMAGE_PATH,,}" == *.gif ]]; then
# The shell renders a still frame from the symlink above; awww layers the
# animation on top of it.
echo "GIF detected, using awww for animation..."
setsid uwsm-app -- awww-daemon >>"$HOME/.cache/awww-daemon.log" 2>&1 &
# Poll until the daemon's IPC socket is ready instead of guessing a fixed sleep
IMG_ERROR=""
for _ in {1..15}; do
IMG_ERROR=$(awww img "$NEW_BACKGROUND" 2>&1) && break
sleep 0.3
done
if [[ -n "$IMG_ERROR" ]]; then
echo "awww failed to set the wallpaper: $IMG_ERROR"
echo "See $HOME/.cache/awww-daemon.log for daemon output."
fi
fi
echo "Wallpaper and dynamic theme applied successfully: $IMAGE_PATH"