diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/bin/blob b/bin/blob new file mode 100755 index 0000000..76ce920 --- /dev/null +++ b/bin/blob @@ -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 < [args...] + +Commands are files named blob--. Both forms work: + + blob theme set tokyo-night + blob-theme-set tokyo-night + +Available commands: +USAGE + list_commands + printf '\nRun "blob help " 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")" "$@" diff --git a/bin/blob-bg-cache b/bin/blob-bg-cache new file mode 100755 index 0000000..ed49c1d --- /dev/null +++ b/bin/blob-bg-cache @@ -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" diff --git a/bin/blob-bg-current b/bin/blob-bg-current new file mode 100755 index 0000000..476ae7a --- /dev/null +++ b/bin/blob-bg-current @@ -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 diff --git a/bin/blob-bg-install b/bin/blob-bg-install new file mode 100755 index 0000000..06568ae --- /dev/null +++ b/bin/blob-bg-install @@ -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" diff --git a/bin/blob-bg-next b/bin/blob-bg-next new file mode 100755 index 0000000..4ddb135 --- /dev/null +++ b/bin/blob-bg-next @@ -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 diff --git a/bin/blob-bg-set b/bin/blob-bg-set new file mode 100755 index 0000000..8fb75ff --- /dev/null +++ b/bin/blob-bg-set @@ -0,0 +1,25 @@ +#!/bin/bash + +# blob:summary=Set the current background image +# blob:args= +# blob:examples=blob theme bg set ~/Pictures/background.png + +if [[ -z $1 ]]; then + echo "Usage: blob-bg-set " >&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" diff --git a/bin/blob-bg-switcher b/bin/blob-bg-switcher new file mode 100755 index 0000000..9422666 --- /dev/null +++ b/bin/blob-bg-switcher @@ -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" diff --git a/bin/blob-cmd-cwd b/bin/blob-cmd-cwd new file mode 100755 index 0000000..7d54179 --- /dev/null +++ b/bin/blob-cmd-cwd @@ -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 diff --git a/bin/blob-cmd-missing b/bin/blob-cmd-missing new file mode 100755 index 0000000..fcd4290 --- /dev/null +++ b/bin/blob-cmd-missing @@ -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 diff --git a/bin/blob-cmd-present b/bin/blob-cmd-present new file mode 100755 index 0000000..23b4402 --- /dev/null +++ b/bin/blob-cmd-present @@ -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 diff --git a/bin/blob-default-browser b/bin/blob-default-browser new file mode 100755 index 0000000..2bde65f --- /dev/null +++ b/bin/blob-default-browser @@ -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 " + 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" diff --git a/bin/blob-default-editor b/bin/blob-default-editor new file mode 100755 index 0000000..cf1906a --- /dev/null +++ b/bin/blob-default-editor @@ -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 " + 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" diff --git a/bin/blob-default-terminal b/bin/blob-default-terminal new file mode 100755 index 0000000..e0d910e --- /dev/null +++ b/bin/blob-default-terminal @@ -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 " + exit 1 + ;; +esac + +cat >~/.config/xdg-terminals.list <] [--multiple] [--directory] [--extensions ""] +# 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) diff --git a/bin/blob-hook b/bin/blob-hook new file mode 100755 index 0000000..a7b59b0 --- /dev/null +++ b/bin/blob-hook @@ -0,0 +1,28 @@ +#!/bin/bash + +# blob:summary=Run a named hook from ~/.config/blob/hooks/ and ~/.config/blob/hooks/.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 diff --git a/bin/blob-hw-clamshell b/bin/blob-hw-clamshell new file mode 100755 index 0000000..25803e5 --- /dev/null +++ b/bin/blob-hw-clamshell @@ -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 diff --git a/bin/blob-hw-display b/bin/blob-hw-display new file mode 100755 index 0000000..37c83a3 --- /dev/null +++ b/bin/blob-hw-display @@ -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 diff --git a/bin/blob-hw-external b/bin/blob-hw-external new file mode 100755 index 0000000..1255464 --- /dev/null +++ b/bin/blob-hw-external @@ -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 diff --git a/bin/blob-hw-laptop-closed b/bin/blob-hw-laptop-closed new file mode 100755 index 0000000..a9e8bb1 --- /dev/null +++ b/bin/blob-hw-laptop-closed @@ -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 diff --git a/bin/blob-hw-match b/bin/blob-hw-match new file mode 100755 index 0000000..9975e63 --- /dev/null +++ b/bin/blob-hw-match @@ -0,0 +1,7 @@ +#!/bin/bash + +# blob:summary=Match against the computer's DMI product name or product family (case-insensitive). +# blob:args= + +grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null || +grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null diff --git a/bin/blob-hypr-focus b/bin/blob-hypr-focus new file mode 100755 index 0000000..71a5346 --- /dev/null +++ b/bin/blob-hypr-focus @@ -0,0 +1,31 @@ +#!/bin/bash + +# blob:summary=Focus a Hyprland window by application identity +# blob:args= +# blob:examples=blob hyprland focus app Slack + +usage() { + echo "Usage: blob-hypr-focus " >&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 diff --git a/bin/blob-hypr-monitor-clamshell b/bin/blob-hypr-monitor-clamshell new file mode 100755 index 0000000..0281ebd --- /dev/null +++ b/bin/blob-hypr-monitor-clamshell @@ -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 = ...`, 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 diff --git a/bin/blob-hypr-monitor-external b/bin/blob-hypr-monitor-external new file mode 100755 index 0000000..77fe337 --- /dev/null +++ b/bin/blob-hypr-monitor-external @@ -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 diff --git a/bin/blob-hypr-monitor-focused b/bin/blob-hypr-monitor-focused new file mode 100755 index 0000000..7b4f01a --- /dev/null +++ b/bin/blob-hypr-monitor-focused @@ -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' diff --git a/bin/blob-hypr-monitor-internal b/bin/blob-hypr-monitor-internal new file mode 100755 index 0000000..356aae9 --- /dev/null +++ b/bin/blob-hypr-monitor-internal @@ -0,0 +1,78 @@ +#!/bin/bash + +# blob:summary=Enable, disable, toggle, or recover the internal laptop display +# blob:args= + +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 diff --git a/bin/blob-hypr-monitor-laptop b/bin/blob-hypr-monitor-laptop new file mode 100755 index 0000000..abba8dd --- /dev/null +++ b/bin/blob-hypr-monitor-laptop @@ -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 diff --git a/bin/blob-hypr-monitor-mirror b/bin/blob-hypr-monitor-mirror new file mode 100755 index 0000000..c52e8a0 --- /dev/null +++ b/bin/blob-hypr-monitor-mirror @@ -0,0 +1,73 @@ +#!/bin/bash + +# blob:summary=Enable, disable, toggle, or recover mirroring the internal display to an external monitor +# blob:args= + +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 diff --git a/bin/blob-hypr-monitor-scaling b/bin/blob-hypr-monitor-scaling new file mode 100755 index 0000000..fb6348a --- /dev/null +++ b/bin/blob-hypr-monitor-scaling @@ -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 diff --git a/bin/blob-hypr-restart b/bin/blob-hypr-restart new file mode 100755 index 0000000..946dfc5 --- /dev/null +++ b/bin/blob-hypr-restart @@ -0,0 +1,5 @@ +#!/bin/bash + +# blob:summary=Reload hyprland configuration (used by the Blob theme switching). + +hyprctl reload >/dev/null diff --git a/bin/blob-hypr-session-locked b/bin/blob-hypr-session-locked new file mode 100755 index 0000000..8d5328c --- /dev/null +++ b/bin/blob-hypr-session-locked @@ -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 diff --git a/bin/blob-hypr-toggle b/bin/blob-hypr-toggle new file mode 100755 index 0000000..ec42480 --- /dev/null +++ b/bin/blob-hypr-toggle @@ -0,0 +1,54 @@ +#!/bin/bash + +# blob:summary=Toggle permanent Hyprland flags by copying them into a directory that's sourced entirely. +# blob:args= [on|off|toggle] + +usage() { + echo "Usage: blob-hypr-toggle [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 diff --git a/bin/blob-hypr-toggle-disabled b/bin/blob-hypr-toggle-disabled new file mode 100755 index 0000000..7ce1961 --- /dev/null +++ b/bin/blob-hypr-toggle-disabled @@ -0,0 +1,6 @@ +#!/bin/bash + +# blob:summary=Check if a Hyprland toggle is currently disabled (missing). +# blob:args= + +[[ ! -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]] diff --git a/bin/blob-hypr-toggle-enabled b/bin/blob-hypr-toggle-enabled new file mode 100755 index 0000000..f9fc4fe --- /dev/null +++ b/bin/blob-hypr-toggle-enabled @@ -0,0 +1,6 @@ +#!/bin/bash + +# blob:summary=Check if a Hyprland toggle is currently enabled. +# blob:args= + +[[ -f "$HOME/.local/state/blob/toggles/hypr/$1.lua" ]] diff --git a/bin/blob-hypr-window-close-all b/bin/blob-hypr-window-close-all new file mode 100755 index 0000000..1b84ea1 --- /dev/null +++ b/bin/blob-hypr-window-close-all @@ -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 diff --git a/bin/blob-install-launch b/bin/blob-install-launch new file mode 100755 index 0000000..9d29fca --- /dev/null +++ b/bin/blob-install-launch @@ -0,0 +1,27 @@ +#!/bin/bash + +# blob:summary=Install a packaged app and launch it once it finishes +# blob:args= +# 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 " >&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 &)" diff --git a/bin/blob-launch-browser b/bin/blob-launch-browser new file mode 100755 index 0000000..8f9c46a --- /dev/null +++ b/bin/blob-launch-browser @@ -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 diff --git a/bin/blob-launch-editor b/bin/blob-launch-editor new file mode 100755 index 0000000..94a5811 --- /dev/null +++ b/bin/blob-launch-editor @@ -0,0 +1,34 @@ +#!/bin/bash + +# blob:summary=Launch the default editor selected via Blob defaults. +# blob:args=[--inline] + +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 diff --git a/bin/blob-launch-floating b/bin/blob-launch-floating new file mode 100755 index 0000000..c6da911 --- /dev/null +++ b/bin/blob-launch-floating @@ -0,0 +1,13 @@ +#!/bin/bash + +# blob:summary=Launch a floating terminal with the Blob presentation wrapper +# blob:args= + +# 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" diff --git a/bin/blob-launch-shell b/bin/blob-launch-shell new file mode 100755 index 0000000..b64d3ec --- /dev/null +++ b/bin/blob-launch-shell @@ -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 diff --git a/bin/blob-launch-tui b/bin/blob-launch-tui new file mode 100755 index 0000000..b3373a6 --- /dev/null +++ b/bin/blob-launch-tui @@ -0,0 +1,13 @@ +#!/bin/bash + +# blob:summary=Launch a TUI command in the default terminal with Blob styling +# blob:args=[--app-id=] [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}" diff --git a/bin/blob-launcher-remove b/bin/blob-launcher-remove new file mode 100755 index 0000000..db71058 --- /dev/null +++ b/bin/blob-launcher-remove @@ -0,0 +1,101 @@ +#!/bin/bash + +# blob:summary=Remove or uninstall the selected launcher entry +# blob:args= +# 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 " >&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 diff --git a/bin/blob-menu b/bin/blob-menu new file mode 100755 index 0000000..248f2fb --- /dev/null +++ b/bin/blob-menu @@ -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 <, 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 diff --git a/bin/blob-menu-select b/bin/blob-menu-select new file mode 100755 index 0000000..40850fc --- /dev/null +++ b/bin/blob-menu-select @@ -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 "