diff --git a/README.md b/README.md index 3fdbe96..cb6fe3b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ and nothing to re-apply after somebody else's release. - **[Quickshell](https://quickshell.org/)** as the desktop: `blob-shell` is one process hosting the bar, both menus, the panels, notifications, the lock screen, and the OSD as plugins. -- **A `blob-*` CLI** of 251 commands, dispatched by `blob`. +- **A `blob-*` CLI** of 290 commands, dispatched by `blob`. - **24 themes** with a palette pipeline that retints the terminal, editor, browser, shell, and lock screen from one `colors.toml`. diff --git a/bin/blob-chromium-copy-url-host b/bin/blob-chromium-copy-url-host new file mode 100755 index 0000000..1301f3d --- /dev/null +++ b/bin/blob-chromium-copy-url-host @@ -0,0 +1,50 @@ +#!/bin/bash + +# blob:summary=Native messaging host: copy a Chromium tab URL to the clipboard +# blob:hidden=true + +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" +export BLOB_PATH="${BLOB_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}" +export PATH="$BLOB_PATH/bin:/usr/local/bin:/usr/bin:$PATH" + +parse_url() { + jq -r '.url // empty' 2>/dev/null <<<"$1" || true +} + +copy_url() { + local url="$1" + + [[ -n $url ]] || return 1 + printf '%s' "$url" | wl-copy --type text/plain + blob-notify-send -g 󰅍 "URL copied to clipboard" +} + +reply_copied() { + if [[ $1 == "true" ]]; then + printf '\x0f\x00\x00\x00{"copied":true}' + else + printf '\x10\x00\x00\x00{"copied":false}' + fi +} + +main() { + local length payload url + + length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ') + [[ -n ${length:-} ]] && (( length > 0 )) || exit 0 + + payload=$(head -c "$length") + url=$(parse_url "$payload") + + if copy_url "$url"; then + reply_copied true + else + reply_copied false + fi +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/blob-chromium-ytdlp-host b/bin/blob-chromium-ytdlp-host new file mode 100755 index 0000000..fa24417 --- /dev/null +++ b/bin/blob-chromium-ytdlp-host @@ -0,0 +1,196 @@ +#!/bin/bash + +# blob:summary=Native messaging host: download the URL sent by the yt-dlp Chromium extension +# blob:hidden=true + +set -euo pipefail + +SCRIPT_PATH="${BASH_SOURCE[0]}" + +# The browser launches us without Blob's environment, so locate the repo from +# our own path when BLOB_PATH isn't already set. Export it — blob-shell (used +# by blob-osd) needs it to find the running shell, and silently no-ops without it. +export BLOB_PATH="${BLOB_PATH:-$(cd -- "$(dirname -- "$SCRIPT_PATH")/.." && pwd)}" + +# Make sure the Blob bin and yt-dlp are reachable when launched by the browser. +export PATH="$BLOB_PATH/bin:/usr/local/bin:/usr/bin:$PATH" + +DOWNLOAD_DIR="${BLOB_YTDLP_DIR:-$HOME/Videos}" + +parse_url() { + jq -r '.url // empty' 2>/dev/null <<<"$1" || true +} + +valid_url() { + [[ $1 =~ ^https?:// ]] +} + +# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR. +# Forged records (leading-dash mpv options, paths with control chars, or +# anything that escaped the download directory) must not reach the click command. +resolve_download_file() { + local candidate=$1 file_real dir_real + + [[ -n $candidate ]] || return 1 + [[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1 + [[ -f $candidate ]] || return 1 + + # Read to a NUL: command substitution strips trailing newlines, which would + # resolve a name ending in one to a different file that may well exist. + IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1 + IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1 + + [[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1 + # Trim the slash so a download directory of "/" still leaves a usable prefix. + [[ $file_real == "${dir_real%/}"/* ]] || return 1 + + printf '%s' "$file_real" +} + +# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an +# escape sequence rather than a record boundary. This is toast text, never a command. +decode_title() { + local decoded + + decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1 + decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery + [[ -n $decoded && $decoded != -* ]] || return 1 + + printf '%s' "$decoded" +} + +title_from_file() { + local name=${1##*/} + name=${name%.*} + name=${name//[$'\n\r\t']/} + if [[ -z $name || $name == -* ]]; then + printf '%s' "Video" + else + printf '%s' "$name" + fi +} + +# Drive the Quickshell OSD — a single overlay that updates in place (like the +# volume/brightness bar), so download progress never stacks like notifications. +osd_progress() { + blob-osd -i 󰇚 -p "$1" -d 8000 >/dev/null 2>&1 || true +} + +osd_close() { + blob-shell -q osd close >/dev/null 2>&1 || true +} + +download_url() { + local url="$1" + + mkdir -p "$DOWNLOAD_DIR" + + # Don't show anything until yt-dlp confirms there's actually a video to grab. + if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then + blob-notify-send -u critical -g 󰅖 "No video found for download" "$url" + exit 0 + fi + + osd_progress 0 + + # Stream the download: BLOB_PROG carries the percent (drives the OSD), and + # BLOB_FILE and BLOB_TITLE (printed only after a successful move) carry the + # path and the title. The title is JSON-encoded so metadata cannot forge a record, + # and the file is named after it: yt-dlp strips control characters from a filename + # with or without --restrict-filenames, so a record is still only ever one line. + local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved + while IFS= read -r line; do + case $line in + BLOB_PROG*) + pct=${line#BLOB_PROG$'\t'} + intpct=${pct%%.*} + intpct=${intpct//[^0-9]/} + [[ -n $intpct && $intpct != "$last" ]] || continue # skip no-op repeats + # Throttle to ~4 redraws/sec so fast downloads don't spawn a flurry of processes. + er=$EPOCHREALTIME + nowms=$((${er%[.,]*} * 1000 + 10#${er##*[.,]} / 1000)) + ((nowms - lastms >= 250)) || continue + last=$intpct + lastms=$nowms + osd_progress "$intpct" + ;; + BLOB_FILE*) + resolved=$(resolve_download_file "${line#BLOB_FILE$'\t'}") || continue + filepath=$resolved + ;; + BLOB_TITLE*) + title=$(decode_title "${line#BLOB_TITLE$'\t'}") || title="" + ;; + esac + done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \ + --quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \ + --progress-template $'download:BLOB_PROG\t%(progress._percent_str)s' \ + --paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \ + --print $'after_move:BLOB_FILE\t%(filepath)s' \ + --print $'after_move:BLOB_TITLE\t%(title)j' \ + -- "$url" 2>&1) + + osd_close + + # after_move only prints on a successful download+move, so a captured path == success. + if [[ -n $filepath ]]; then + [[ -n $title ]] || title=$(title_from_file "$filepath") + ((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact + + # Square, center-cropped thumbnail so the notification preview isn't stretched. + local preview + preview="$(mktemp --suffix=.jpg)" + ffmpeg -y -i "$filepath" -ss 00:00:00.1 -vframes 1 \ + -vf "crop='min(iw,ih)':'min(iw,ih)',scale=256:256" -q:v 2 \ + "$preview" -loglevel quiet 2>/dev/null || true + + # Best-effort: the download already succeeded, and under `set -e` a failed + # toast would exit before the thumbnail cleanup below is ever scheduled. + # `--` keeps mpv from parsing a leading-dash filename as an option; the path + # is one discrete argument, so it never reaches a shell. + blob-notify-send -g 󰄬 "Download complete" "$title" \ + -t 10000 --image "${preview:-$filepath}" \ + --exec mpv -- "$filepath" || true + + # The shell loads the thumbnail into memory when the toast appears and never + # re-reads the file, so the preview only has to outlive that load, not the + # toast. + ( + sleep 2 + rm -f "$preview" + ) & + else + blob-notify-send -u critical -g 󰅖 "Download failed" "$url" + fi + + exit 0 +} + +main() { + local length payload url + + # Detached worker: this is what actually runs yt-dlp and fires notifications. + if [[ "${1:-}" == "--download" ]]; then + download_url "$2" + fi + + # Native messaging frame: 4-byte little-endian length prefix, then UTF-8 JSON. + length=$(head -c4 | od -An -v -tu4 --endian=little | tr -d ' ') + [[ -n ${length:-} ]] && ((length > 0)) || exit 0 + + payload=$(head -c "$length") + + # Ack with an empty message so the extension's sendNativeMessage callback resolves cleanly. + printf '\x02\x00\x00\x00{}' + + url=$(parse_url "$payload") + [[ -n $url ]] || exit 0 + valid_url "$url" || exit 0 + + # Detach the download so this host exits promptly and frees the browser's port. + setsid -f "$SCRIPT_PATH" --download "$url" /dev/null 2>&1 +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/blob-games-retro-cores b/bin/blob-games-retro-cores new file mode 100755 index 0000000..6307382 --- /dev/null +++ b/bin/blob-games-retro-cores @@ -0,0 +1,39 @@ +#!/bin/bash + +# blob:summary=List installed RetroArch core names + +set -e + +core_dir="/usr/lib/libretro" +preferred_cores=( + "Amstrad CPC|cap32" + "Arcade FBNeo|fbneo" + "Arcade MAME|mame" + "Commodore Amiga|puae" + "Commodore C128|vice_x128" + "Commodore C64|vice_x64" + "Commodore VIC-20|vice_xvic" + "Nintendo DS|desmume" + "Nintendo Game Boy / Color|gambatte" + "Nintendo Game Boy Advance|mgba" + "Nintendo GameCube / Wii|dolphin" + "Nintendo NES / Famicom|mesen" + "Nintendo 64|parallel_n64" + "Nintendo SNES / SFC|snes9x" + "NEC PC Engine / TurboGrafx-16|mednafen_pce_fast" + "NEC PC Engine CD / TurboGrafx-CD|mednafen_pce" + "NEC PC Engine SuperGrafx|mednafen_supergrafx" + "Sega Dreamcast|flycast" + "Sega Mega Drive / Master System / Game Gear|genesis_plus_gx" + "Sega Saturn|kronos" + "Sony PlayStation|mednafen_psx_hw" + "Sony PlayStation Portable|ppsspp" +) + +[[ -d $core_dir ]] || exit 0 + +for preferred_core in "${preferred_cores[@]}"; do + label="${preferred_core%%|*}" + core="${preferred_core#*|}" + [[ -f $core_dir/${core}_libretro.so ]] && printf '%s (%s)\n' "$label" "$core" +done diff --git a/bin/blob-games-retro-install b/bin/blob-games-retro-install new file mode 100755 index 0000000..e555aa0 --- /dev/null +++ b/bin/blob-games-retro-install @@ -0,0 +1,72 @@ +#!/bin/bash + +# blob:summary=Create a desktop launcher for a RetroArch game +# blob:args=[core path-to-game] +# blob:examples=blob games retro install snes9x ~/Games/roms/snes/game.sfc | blob-games-retro-install /usr/lib/libretro/mgba_libretro.so ~/Games/roms/gba/game.gba + +set -e + +if (( $# == 0 )); then + mapfile -t cores < <(blob-games-retro-cores) + + if (( ${#cores[@]} == 0 )); then + blob-notify-send -g 󰯉 "No RetroArch cores found" "/usr/lib/libretro" + exit 1 + fi + + core=$(blob-menu-select "RetroArch core" "${cores[@]}") || exit 0 + [[ -n $core ]] || exit 0 + core="${core##*(}" + core="${core%)}" + + game_path=$(blob-menu-file "Retro game" "$HOME/Games/roms" "7z bin ccd chd cue dmg elf fds gb gba gbc iso lha m3u md n64 nds nes pbp sfc smc swc zip z64") || exit 0 + [[ -n $game_path ]] || exit 0 +elif (( $# == 2 )); then + core="$1" + game_path="$2" +else + echo "Usage: blob-games-retro-install [core path-to-game]" + echo "Example: blob-games-retro-install snes9x ~/Games/roms/snes/game.sfc" + exit 1 +fi + +if [[ ! -f $game_path ]]; then + echo "Game not found: $game_path" + exit 1 +fi + +if [[ $core == */* ]]; then + core_path="$core" +else + core_path="/usr/lib/libretro/${core}_libretro.so" +fi + +if [[ ! -f $core_path ]]; then + echo "Core not found: $core_path" + exit 1 +fi + +game_name=$(printf '%s' "${game_path##*/}" | sed 's/\.[^.]*$//; s/[[:space:]]*([^)]*)//g; s/[[:space:]]\+/ /g; s/^ //; s/ $//' | perl -Mopen=locale -pe 's/(^|[[:space:]])([^[:space:]])/$1\U$2/g') +desktop_name="$game_name" +desktop_id=$(printf '%s' "$desktop_name" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '-' | sed 's/^-//; s/-$//') +desktop_dir="$HOME/.local/share/applications" +desktop_file="$desktop_dir/$desktop_id.desktop" +mkdir -p "$desktop_dir" + +cat >"$desktop_file" </dev/null || true + +blob-notify-send -g 󰯉 "$game_name installed" "Start it with Super + Space" diff --git a/bin/blob-install-browser b/bin/blob-install-browser new file mode 100755 index 0000000..eb90a3e --- /dev/null +++ b/bin/blob-install-browser @@ -0,0 +1,87 @@ +#!/bin/bash + +# blob:summary=Install a supported browser +# blob:args= +# blob:examples=blob install browser firefox | blob install browser brave + +source "$BLOB_PATH/install/helpers/browser-policy.sh" + +setup_chromium_policy_directory() { + browser_policy_setup_dir "$1" +} + +announce_browser_installed() { + echo "" + echo "$1 browser installed. Make it the default via Setup > Defaults > Browser." +} + +copy_chromium_flags() { + mkdir -p ~/.config + cp -f "$BLOB_PATH/config/chromium-flags.conf" "$1" + blob-install-chromium-copy-url + blob-install-chromium-ytdlp +} + +setup_firefox_wayland() { + mkdir -p ~/.config/environment.d + echo "MOZ_ENABLE_WAYLAND=1" > ~/.config/environment.d/blob-firefox-wayland.conf +} + +case $1 in +chrome) + echo "Installing Chrome..." + blob-pkg-aur-add google-chrome || exit 1 + + setup_chromium_policy_directory /etc/opt/chrome/policies/managed + copy_chromium_flags ~/.config/chrome-flags.conf + blob-theme-browser + announce_browser_installed "Chrome" + ;; +edge) + echo "Installing Edge..." + blob-pkg-aur-add microsoft-edge-stable-bin || exit 1 + + setup_chromium_policy_directory /etc/opt/edge/policies/managed + copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf + blob-theme-browser + announce_browser_installed "Edge" + ;; +brave) + echo "Installing Brave..." + blob-pkg-aur-add brave-bin || exit 1 + + setup_chromium_policy_directory /etc/brave/policies/managed + copy_chromium_flags ~/.config/brave-flags.conf + blob-theme-browser + announce_browser_installed "Brave" + ;; +brave-origin) + echo "Installing Brave Origin..." + blob-pkg-aur-add brave-origin-bin || exit 1 + + setup_chromium_policy_directory /etc/brave/policies/managed + copy_chromium_flags ~/.config/brave-origin-flags.conf + blob-theme-browser + announce_browser_installed "Brave Origin" + ;; +firefox) + echo "Installing Firefox..." + blob-pkg-add firefox || exit 1 + + browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution + setup_firefox_wayland + announce_browser_installed "Firefox" + ;; +zen) + echo "Installing Zen..." + blob-pkg-aur-add zen-browser-bin || exit 1 + + browser_policy_setup_firefox_distribution /opt/zen-browser/distribution + setup_firefox_wayland + announce_browser_installed "Zen" + ;; +*) + echo "Usage: blob-install-browser " + exit 1 + ;; +esac diff --git a/bin/blob-install-chromium-copy-url b/bin/blob-install-chromium-copy-url new file mode 100755 index 0000000..43411ac --- /dev/null +++ b/bin/blob-install-chromium-copy-url @@ -0,0 +1,28 @@ +#!/bin/bash + +# blob:summary=Install the native messaging host for the Copy URL Chromium extension + +set -euo pipefail + +HOST_NAME="com.blob.copy_url" +HOST_PATH="$BLOB_PATH/bin/blob-chromium-copy-url-host" +TEMPLATE="$BLOB_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json" + +browser_dirs=( + "$HOME/.config/chromium" + "$HOME/.config/google-chrome" + "$HOME/.config/google-chrome-beta" + "$HOME/.config/google-chrome-unstable" + "$HOME/.config/BraveSoftware/Brave-Browser" + "$HOME/.config/BraveSoftware/Brave-Browser-Beta" + "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/microsoft-edge" + "$HOME/.config/microsoft-edge-dev" +) + +manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE") + +for dir in "${browser_dirs[@]}"; do + mkdir -p "$dir/NativeMessagingHosts" + printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json" +done diff --git a/bin/blob-install-chromium-ytdlp b/bin/blob-install-chromium-ytdlp new file mode 100755 index 0000000..8cee99b --- /dev/null +++ b/bin/blob-install-chromium-ytdlp @@ -0,0 +1,29 @@ +#!/bin/bash + +# blob:summary=Install the native messaging host for the yt-dlp Chromium extension + +set -euo pipefail + +HOST_NAME="com.blob.ytdlp" +HOST_PATH="$BLOB_PATH/bin/blob-chromium-ytdlp-host" +TEMPLATE="$BLOB_PATH/default/chromium/native-messaging-hosts/$HOST_NAME.json" + +# Chromium-based browser profile roots that use the NativeMessagingHosts layout. +browser_dirs=( + "$HOME/.config/chromium" + "$HOME/.config/google-chrome" + "$HOME/.config/google-chrome-beta" + "$HOME/.config/google-chrome-unstable" + "$HOME/.config/BraveSoftware/Brave-Browser" + "$HOME/.config/BraveSoftware/Brave-Browser-Beta" + "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/microsoft-edge" + "$HOME/.config/microsoft-edge-dev" +) + +manifest=$(sed "s|__HOST_PATH__|$HOST_PATH|g" "$TEMPLATE") + +for dir in "${browser_dirs[@]}"; do + mkdir -p "$dir/NativeMessagingHosts" + printf '%s\n' "$manifest" >"$dir/NativeMessagingHosts/$HOST_NAME.json" +done diff --git a/bin/blob-install-dev-env b/bin/blob-install-dev-env new file mode 100755 index 0000000..3779646 --- /dev/null +++ b/bin/blob-install-dev-env @@ -0,0 +1,155 @@ +#!/bin/bash + +# blob:summary=Install a supported development environment +# blob:name=dev-env +# blob:args= +# blob:examples=blob install dev-env ruby | blob install dev-env node +# blob:requires-sudo=true + +if [[ -z $1 ]]; then + echo "Usage: blob-install-dev-env " >&2 + exit 1 +fi + +install_php() { + blob-pkg-add php composer php-sqlite xdebug + + # Install Path for Composer + if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then + echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" + source "$HOME/.bashrc" + echo "Added Composer global bin directory to PATH." + else + echo "Composer global bin directory already in PATH." + fi + + # Enable some extensions + local php_ini_path="/etc/php/php.ini" + local extensions_to_enable=( + "bcmath" + "intl" + "iconv" + "openssl" + "pdo_sqlite" + "pdo_mysql" + ) + + # Enable Xdebug + sudo sed -i \ + -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ + -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ + /etc/php/conf.d/xdebug.ini + + for ext in "${extensions_to_enable[@]}"; do + sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" + done +} + +install_node() { + echo -e "Installing Node.js...\n" + mise use --global node +} + +case "$1" in +ruby) + echo -e "Installing Ruby on Rails...\n" + blob-pkg-add libyaml + mise settings add ruby.compile false + mise settings add idiomatic_version_file_enable_tools ruby + mise use --global ruby@latest + echo "gem: --no-document" >~/.gemrc + mise x ruby -- gem install rails --no-document + echo -e "\nYou can now run: rails new myproject" + ;; +node) + install_node + ;; +bun) + echo -e "Installing Bun...\n" + mise use -g bun@latest + ;; +deno) + echo -e "Installing Deno...\n" + mise use -g deno@latest + ;; +go) + echo -e "Installing Go...\n" + mise use --global go@latest + ;; +php) + echo -e "Installing PHP...\n" + install_php + ;; +laravel) + echo -e "Installing PHP and Laravel...\n" + install_php + install_node + composer global require laravel/installer + echo -e "\nYou can now run: laravel new myproject" + ;; +symfony) + echo -e "Installing PHP and Symfony...\n" + install_php + blob-pkg-add symfony-cli + echo -e "\nYou can now run: symfony new --webapp myproject" + ;; +python) + echo -e "Installing Python...\n" + mise use --global python@latest + echo -e "\nInstalling uv...\n" + curl -fsSL https://astral.sh/uv/install.sh | sh + ;; +elixir) + echo -e "Installing Elixir...\n" + mise use --global erlang@latest + mise use --global elixir@latest + mise x elixir -- mix local.hex --force + ;; +phoenix) + echo -e "Installing Phoenix Framework...\n" + # Ensure Erlang/Elixir first + mise use --global erlang@latest + mise use --global elixir@latest + # Hex & Rebar + mise x elixir -- mix local.hex --force + mise x elixir -- mix local.rebar --force + # Phoenix project (phx_new) + mise x elixir -- mix archive.install hex phx_new --force + echo -e "\nYou can now run: mix phx.new my_app" + ;; +rust) + echo -e "Installing Rust...\n" + bash -c "$(curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs)" -- -y + ;; +java) + echo -e "Installing Java...\n" + mise use --global java@latest + ;; +zig) + echo -e "Installing Zig...\n" + mise use --global zig@latest + mise use -g zls@latest + ;; +ocaml) + echo -e "Installing OCaml...\n" + bash -c "$(curl -fsSL https://raw.githubusercontent.com/ocaml/opam/master/shell/install.sh)" + opam init --yes + eval "$(opam env)" + opam install ocaml-lsp-server odoc ocamlformat utop --yes + ;; +dotnet) + echo -e "Installing .NET...\n" + mise use --global dotnet@latest + ;; +clojure) + echo -e "Installing Clojure...\n" + blob-pkg-add rlwrap + mise use --global clojure@latest + ;; +scala) + echo -e "Installing Scala...\n" + mise use --global java@latest + mise use --global scala@latest + mise use --global scala-cli@latest + ;; +esac diff --git a/bin/blob-install-docker-dbs b/bin/blob-install-docker-dbs new file mode 100755 index 0000000..4044be1 --- /dev/null +++ b/bin/blob-install-docker-dbs @@ -0,0 +1,28 @@ +#!/bin/bash + +# blob:summary=Install one of the supported databases in a Docker container with the suitable development options. +# blob:requires-sudo=true + +options=("MySQL" "PostgreSQL" "Redis" "MongoDB" "MariaDB" "MSSQL") + +if (( $# == 0 )); then + choices=$(printf "%s\n" "${options[@]}" | gum choose --header "Select database (return to install, esc to cancel)") || main_menu +else + choices="$@" +fi + +if [[ -n $choices ]]; then + for db in $choices; do + echo "Installing $db..." + case $db in + MySQL) sudo docker run -d --restart unless-stopped -p "127.0.0.1:3306:3306" --name=mysql8 -e MYSQL_ROOT_PASSWORD= -e MYSQL_ALLOW_EMPTY_PASSWORD=true mysql:8.4 ;; + PostgreSQL) sudo docker run -d --restart unless-stopped -p "127.0.0.1:5432:5432" --name=postgres18 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:18 ;; + MariaDB) sudo docker run -d --restart unless-stopped -p "127.0.0.1:3306:3306" --name=mariadb11 -e MARIADB_ROOT_PASSWORD= -e MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true mariadb:11.8 ;; + Redis) sudo docker run -d --restart unless-stopped -p "127.0.0.1:6379:6379" --name=redis redis:7 ;; + MongoDB) sudo docker run -d --restart unless-stopped -p "127.0.0.1:27017:27017" --name mongodb -e MONGO_INITDB_ROOT_USERNAME=admin -e MONGO_INITDB_ROOT_PASSWORD=admin123 mongo:noble ;; + MSSQL) sudo docker run -d --restart unless-stopped -p "127.0.0.1:1433:1433" --name mssql -e MSSQL_PID=Developer -e ACCEPT_EULA=Y -e "MSSQL_SA_PASSWORD=@dmin123" mcr.microsoft.com/mssql/server:2022-CU12-ubuntu-22.04 ;; + esac + done +else + echo "No databases selected for installation." +fi diff --git a/bin/blob-install-gaming-battlenet b/bin/blob-install-gaming-battlenet new file mode 100755 index 0000000..327bc7f --- /dev/null +++ b/bin/blob-install-gaming-battlenet @@ -0,0 +1,88 @@ +#!/bin/bash + +# blob:summary=Install Battle.net standalone via umu-launcher + GE-Proton (no Steam, no Lutris, no Heroic). +# blob:requires-sudo=true + +set -e + +PREFIX="$HOME/Games/battlenet" +LAUNCHER="$PREFIX/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" +INSTALLER_URL="https://downloader.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live" + +echo "Installing Battle.net..." + +blob-pkg-add umu-launcher +blob-install-gaming-gpu-lib32 + +# Detect a half-finished prefix from a closed/crashed previous run and offer +# to wipe it before trying again. Battle.net's installer isn't idempotent. +if [[ -d $PREFIX && ! -f $LAUNCHER ]]; then + echo + echo "Found a partial Battle.net install at $PREFIX (no Launcher.exe)." + echo "Battle.net's installer can't resume from this state." + if gum confirm "Wipe the partial prefix and start fresh?"; then + pkill -f "$PREFIX" 2>/dev/null || true + sleep 1 + rm -rf "$PREFIX" + else + echo "Aborting. Re-run when ready to wipe." + exit 1 + fi +fi + +mkdir -p "$PREFIX" + +export WINEPREFIX="$PREFIX" +export PROTONPATH=GE-Proton +export GAMEID=umu-battlenet +export PROTON_VERB=run + +if [[ -f $LAUNCHER ]]; then + echo "Battle.net is already installed at $PREFIX." + launched_installer=0 +else + cache_dir="$HOME/.cache/blob" + mkdir -p "$cache_dir" + installer="$cache_dir/Battle.net-Setup.exe" + + echo + echo "Downloading Battle.net installer..." + curl --fail --location --retry 3 "$INSTALLER_URL" --output "$installer" + + cat <<'EOF' + +Launching the Battle.net setup wizard. Click through it normally — the +default install path is fine. When it finishes, Battle.net will be in your +app launcher. + +EOF + + log="/tmp/blob-battlenet-installer.log" + setsid -f sh -c "umu-run '$installer' >'$log' 2>&1" /dev/null 2>&1 + echo "Installer log: $log" + launched_installer=1 +fi + +mkdir -p "$HOME/.local/share/applications" +install -m 644 "$BLOB_PATH/default/applications/battlenet.desktop" \ + "$HOME/.local/share/applications/battlenet.desktop" +update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true + +if (( launched_installer )); then + cat </dev/null; then + PACKAGES+=("${VULKAN_DRIVERS[$vendor]}") + fi +done + +if blob-hw-nvidia-gsp; then + PACKAGES+=(lib32-nvidia-utils) +elif blob-hw-nvidia-without-gsp; then + PACKAGES+=(lib32-nvidia-580xx-utils) +fi + +(( ${#PACKAGES[@]} > 0 )) && blob-pkg-add "${PACKAGES[@]}" diff --git a/bin/blob-install-gaming-heroic b/bin/blob-install-gaming-heroic new file mode 100755 index 0000000..97d8b1d --- /dev/null +++ b/bin/blob-install-gaming-heroic @@ -0,0 +1,12 @@ +#!/bin/bash + +# blob:summary=Install Heroic Games Launcher (Epic, GOG, Amazon Prime Gaming) with graphics drivers. +# blob:requires-sudo=true + +set -e + +echo "Installing Heroic Games Launcher..." +blob-pkg-add heroic-games-launcher-bin +blob-install-gaming-gpu-lib32 + +setsid uwsm-app -- gtk-launch heroic >/dev/null 2>&1 & diff --git a/bin/blob-install-gaming-lutris b/bin/blob-install-gaming-lutris new file mode 100755 index 0000000..5e76911 --- /dev/null +++ b/bin/blob-install-gaming-lutris @@ -0,0 +1,23 @@ +#!/bin/bash + +# blob:summary=Install Lutris with Wine + DXVK for running Windows games (Battle.net, EA, Ubisoft Connect, etc.) +# blob:requires-sudo=true + +set -e + +echo "Installing Lutris..." +blob-pkg-add lutris umu-launcher wine-staging wine-mono wine-gecko winetricks python-protobuf +blob-install-gaming-gpu-lib32 + +# Lutris ships with `#!/usr/bin/env python3`, which resolves to mise's Python and +# fails to import the lutris module. Pin the shebang to the system Python. +sudo sed -i '/env python3/ c\#!/bin/python3' /usr/bin/lutris + +cat <<'EOF' + +Lutris will open and auto-fetch its DXVK and VKD3D runtimes in the background +(watch the bottom status bar). Once that finishes, click the + to add or install games. + +EOF + +setsid lutris >/dev/null 2>&1 & diff --git a/bin/blob-install-gaming-retroarch b/bin/blob-install-gaming-retroarch new file mode 100755 index 0000000..6a0e6a6 --- /dev/null +++ b/bin/blob-install-gaming-retroarch @@ -0,0 +1,85 @@ +#!/bin/bash + +# blob:summary=Install RetroArch with the full libretro core set plus FBNeo and a ~/Games ROM directory. + +set -e + +echo "Installing RetroArch..." +blob-pkg-add \ + retroarch \ + retroarch-assets-glui retroarch-assets-ozone retroarch-assets-xmb \ + libretro-beetle-pce libretro-beetle-pce-fast libretro-beetle-psx libretro-beetle-psx-hw libretro-beetle-supergrafx \ + libretro-blastem \ + libretro-bsnes libretro-bsnes-hd \ + libretro-core-info \ + libretro-desmume libretro-dolphin libretro-flycast \ + libretro-gambatte libretro-genesis-plus-gx \ + libretro-kronos \ + libretro-mame libretro-melonds libretro-mesen libretro-mesen-s libretro-mgba libretro-mupen64plus-next \ + libretro-nestopia \ + libretro-overlays \ + libretro-parallel-n64 libretro-picodrive libretro-play libretro-ppsspp \ + libretro-sameboy libretro-scummvm libretro-shaders-slang libretro-snes9x \ + libretro-yabause \ + libretro-cap32-git libretro-fbneo-git libretro-uae-git \ + libretro-vice-x128-git libretro-vice-x64-git libretro-vice-x64dtv-git libretro-vice-x64sc-git \ + libretro-vice-xcbm2-git libretro-vice-xcbm5x0-git libretro-vice-xpet-git \ + libretro-vice-xplus4-git libretro-vice-xscpu64-git libretro-vice-xvic-git \ + libretro-database-git \ + retroarch-joypad-autoconfig-git + +# Set up ~/Games for BIOS files and ROMs +mkdir -p "$HOME/Games/bios" "$HOME/Games/roms" + +CFG="$HOME/.config/retroarch/retroarch.cfg" +mkdir -p "$(dirname "$CFG")" +touch "$CFG" + +set_cfg() { + local key=$1 value=$2 + if grep -q "^$key = " "$CFG"; then + sed -i "s|^$key = .*|$key = \"$value\"|" "$CFG" + else + echo "$key = \"$value\"" >>"$CFG" + fi +} + +set_cfg rgui_browser_directory "$HOME/Games/roms" +set_cfg system_directory "$HOME/Games/bios" + +# Point at the cores and assets installed by pacman +set_cfg libretro_directory "/usr/lib/libretro" +set_cfg libretro_info_path "/usr/share/libretro/info" +set_cfg overlay_directory "/usr/share/libretro/overlays" +set_cfg osk_overlay_directory "/usr/share/libretro/overlays/keyboards" +set_cfg video_shader_dir "/usr/share/libretro/shaders/shaders_slang" +set_cfg joypad_autoconfig_dir "/usr/share/libretro/autoconfig" + +# Point at the database, cheats, and cursors from libretro-database-git +set_cfg content_database_path "/usr/share/libretro/database/rdb" +set_cfg cheat_database_path "/usr/share/libretro/database/cht" +set_cfg cursor_directory "/usr/share/libretro/database/cursors" + +# Vulkan is required for slang shaders and unlocks hardware renderers in beetle-psx-hw, parallel-n64, dolphin +set_cfg video_driver "vulkan" + +# XMB is the classic PS3-style menu (vs. ozone/rgui/glui) +set_cfg menu_driver "xmb" + +# Default to crt-royale shader for that classic CRT look. The global preset is +# auto-loaded by RetroArch when auto_shaders_enable is true and no per-core/per-game +# preset takes precedence — setting video_shader alone in retroarch.cfg is not enough. +set_cfg video_shader_enable "true" +set_cfg auto_shaders_enable "true" +mkdir -p ~/.config/retroarch/config +echo '#reference "/usr/share/libretro/shaders/shaders_slang/crt/crt-royale.slangp"' \ + > ~/.config/retroarch/config/global.slangp + +# Hide Images and Video tabs in the main menu sidebar +set_cfg content_show_images "false" +set_cfg content_show_video "false" + +echo "" +echo "Put your roms and bios files in ~/Games. Then start RetroArch from the app launcher (Super + Space)." + +setsid nautilus "$HOME/Games" >/dev/null 2>&1 & diff --git a/bin/blob-install-gaming-steam b/bin/blob-install-gaming-steam new file mode 100755 index 0000000..a728946 --- /dev/null +++ b/bin/blob-install-gaming-steam @@ -0,0 +1,15 @@ +#!/bin/bash + +# blob:summary=Install Steam and graphics drivers selected for this system +# blob:requires-sudo=true + +set -e + +echo "Installing Steam..." +blob-pkg-add steam +blob-install-gaming-gpu-lib32 + +echo "" +echo "Steam will start automatically now. This might take a while..." + +setsid uwsm-app -- gtk-launch steam >/dev/null 2>&1 & diff --git a/bin/blob-install-gaming-xbox-cloud b/bin/blob-install-gaming-xbox-cloud new file mode 100755 index 0000000..83cd702 --- /dev/null +++ b/bin/blob-install-gaming-xbox-cloud @@ -0,0 +1,12 @@ +#!/bin/bash + +# blob:summary=Install Xbox Cloud Gaming as a web app and launch it. +# blob:group=install +# blob:name=gaming xbox-cloud + +set -e + +echo "Installing Xbox Cloud Gaming..." +blob-webapp-install "Xbox Cloud Gaming" "https://www.xbox.com/en-US/play" "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/xbox.png" + +setsid blob-launch-webapp "https://www.xbox.com/en-US/play" >/dev/null 2>&1 & diff --git a/bin/blob-install-gaming-xbox-controllers b/bin/blob-install-gaming-xbox-controllers new file mode 100755 index 0000000..1a333c2 --- /dev/null +++ b/bin/blob-install-gaming-xbox-controllers @@ -0,0 +1,39 @@ +#!/bin/bash + +# blob:summary=Install support for using Xbox controllers with Steam/RetroArch/etc. +# blob:group=install +# blob:name=gaming xbox-controllers +# blob:requires-sudo=true + +set -e + +echo "Installing Xbox controller Bluetooth support..." + +# Install xpadneo to ensure controllers work out of the box +blob-pkg-add xpadneo-dkms + +# Prevent xpad/xpadneo driver conflict +echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null +echo hid_xpadneo | sudo tee /etc/modules-load.d/xpadneo.conf >/dev/null + +# Ensure user is in the input group (controllers need it) +needs_reboot=false +if ! id -nG "$USER" | grep -qw input; then + sudo usermod -aG input "$USER" + needs_reboot=true +fi + +# Swap drivers in the running kernel so a reboot isn't needed otherwise +if lsmod | grep -q '^xpad '; then + sudo modprobe -r xpad 2>/dev/null || needs_reboot=true +fi + +if $needs_reboot; then + gum confirm "Reboot needed to finish setup. Reboot now?" && sudo reboot now + exit 0 +fi + +sudo modprobe hid_xpadneo + +echo "" +echo "Now you can pair your Xbox controller with Bluetooth using Super + Ctrl + B." diff --git a/bin/blob-launch-battlenet b/bin/blob-launch-battlenet new file mode 100755 index 0000000..76b77f7 --- /dev/null +++ b/bin/blob-launch-battlenet @@ -0,0 +1,48 @@ +#!/bin/bash + +# blob:summary=Launch the installed Battle.net client via umu-launcher + GE-Proton. +# blob:args=[--with-mangohud] +# blob:examples=blob launch battlenet | blob launch battlenet --with-mangohud + +set -e + +PREFIX="$HOME/Games/battlenet" +LAUNCHER="$PREFIX/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" + +with_mangohud=0 +for arg in "$@"; do + case "$arg" in + --with-mangohud) with_mangohud=1 ;; + -h|--help) + cat <<'EOF' +Usage: blob-launch-battlenet [--with-mangohud] + +Options: + --with-mangohud Enable the MangoHud FPS overlay for games launched from + Battle.net. Toggle perf logging in-game with Shift_L+F2; + CSV logs land in ~/mangohud/. +EOF + exit 0 + ;; + *) + echo "Unknown argument: $arg" >&2 + echo "Try: blob-launch-battlenet --help" >&2 + exit 1 + ;; + esac +done + +if [[ ! -f $LAUNCHER ]]; then + echo "Battle.net is not installed. Run blob-install-gaming-battlenet first." >&2 + exit 1 +fi + +env_args=( + WINEPREFIX="$PREFIX" + PROTONPATH=GE-Proton + GAMEID=umu-battlenet + PROTON_VERB=run +) +(( with_mangohud )) && env_args+=(MANGOHUD=1) + +env "${env_args[@]}" umu-run "$LAUNCHER" diff --git a/bin/blob-launch-or-focus-webapp b/bin/blob-launch-or-focus-webapp new file mode 100755 index 0000000..4cc614d --- /dev/null +++ b/bin/blob-launch-or-focus-webapp @@ -0,0 +1,15 @@ +#!/bin/bash + +# blob:summary=Launch or focus on a given web app identified by the window-pattern. +# blob:args= + +if (($# == 0)); then + echo "Usage: blob-launch-or-focus-webapp [window-pattern] [url-and-flags...]" + exit 1 +fi + +WINDOW_PATTERN="$1" +shift +LAUNCH_COMMAND="blob-launch-webapp $@" + +exec blob-launch-or-focus "$WINDOW_PATTERN" "$LAUNCH_COMMAND" diff --git a/bin/blob-launch-webapp b/bin/blob-launch-webapp new file mode 100755 index 0000000..ea33a95 --- /dev/null +++ b/bin/blob-launch-webapp @@ -0,0 +1,13 @@ +#!/bin/bash + +# blob:summary=Launch a URL as a web app in the default supported browser +# blob:args= + +browser=$(xdg-settings get default-web-browser) + +case $browser in +google-chrome* | brave* | microsoft-edge* | opera* | vivaldi* | helium*) ;; +*) browser="chromium.desktop" ;; +esac + +exec setsid uwsm-app -- $(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$browser 2>/dev/null | head -1) --app="$1" "${@:2}" diff --git a/bin/blob-pkg-aur-add b/bin/blob-pkg-aur-add new file mode 100755 index 0000000..d2a644a --- /dev/null +++ b/bin/blob-pkg-aur-add @@ -0,0 +1,18 @@ +#!/bin/bash + +# blob:summary=Add the named packages to the system from the AUR if they're missing. Returns false if it couldn't be done. +# blob:args= + +if blob-pkg-missing "$@"; then + yay -S --noconfirm --needed "$@" || exit 1 +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 diff --git a/bin/blob-remove-browser b/bin/blob-remove-browser new file mode 100755 index 0000000..c19a083 --- /dev/null +++ b/bin/blob-remove-browser @@ -0,0 +1,70 @@ +#!/bin/bash + +# blob:summary=Remove a supported browser and clean up Blob browser defaults +# blob:args= +# blob:examples=blob remove browser firefox | blob remove browser brave +# blob:requires-sudo=true + +set_fallback_default_browser() { + local current_browser + current_browser=$(env -u BROWSER xdg-settings get default-web-browser) + + if [[ $current_browser != $1 ]]; then + return + fi + + if blob-cmd-present chromium; then + env -u BROWSER xdg-settings set default-web-browser chromium.desktop || true + fi +} + +case $1 in +chrome) + echo "Removing Chrome..." + set_fallback_default_browser google-chrome.desktop + blob-pkg-drop google-chrome + rm -f ~/.config/chrome-flags.conf + sudo rm -f /etc/opt/chrome/policies/managed/color.json + ;; +edge) + echo "Removing Edge..." + set_fallback_default_browser microsoft-edge.desktop + blob-pkg-drop microsoft-edge-stable-bin + rm -f ~/.config/microsoft-edge-stable-flags.conf + sudo rm -f /etc/opt/edge/policies/managed/color.json + ;; +brave) + echo "Removing Brave..." + set_fallback_default_browser brave-browser.desktop + blob-pkg-drop brave-bin + rm -f ~/.config/brave-flags.conf + + if blob-pkg-missing brave-origin-bin; then + sudo rm -rf /etc/brave + fi + ;; +brave-origin) + echo "Removing Brave Origin..." + set_fallback_default_browser brave-origin.desktop + blob-pkg-drop brave-origin-bin + rm -f ~/.config/brave-origin-flags.conf + + if blob-pkg-missing brave-bin; then + sudo rm -rf /etc/brave + fi + ;; +firefox) + echo "Removing Firefox..." + set_fallback_default_browser firefox.desktop + blob-pkg-drop firefox + ;; +zen) + echo "Removing Zen..." + set_fallback_default_browser zen.desktop + blob-pkg-drop zen-browser-bin + ;; +*) + echo "Usage: blob-remove-browser " + exit 1 + ;; +esac diff --git a/bin/blob-remove-dev-env b/bin/blob-remove-dev-env new file mode 100755 index 0000000..aae8962 --- /dev/null +++ b/bin/blob-remove-dev-env @@ -0,0 +1,113 @@ +#!/bin/bash + +# blob:summary=Remove a development environment that was previously installed via blob-install-dev-env. +# blob:args= +# blob:requires-sudo=true + +if [[ -z $1 ]]; then + echo "Usage: blob-remove-dev-env " >&2 + exit 1 +fi + +remove_php() { + sudo pacman -Rns --noconfirm php composer php-sqlite xdebug 2>/dev/null || true +} + +case "$1" in +ruby) + echo -e "Removing Ruby...\n" + mise uninstall ruby --all + mise rm -g ruby + rm -f ~/.gemrc + ;; +node) + echo -e "Removing Node.js...\n" + mise uninstall node --all + mise rm -g node + ;; +bun) + echo -e "Removing Bun...\n" + mise uninstall bun --all + mise rm -g bun + ;; +deno) + echo -e "Removing Deno...\n" + mise uninstall deno --all + mise rm -g deno + ;; +go) + echo -e "Removing Go...\n" + mise uninstall go --all + mise rm -g go + ;; +php) + echo -e "Removing PHP...\n" + remove_php + ;; +laravel) + echo -e "Removing Laravel...\n" + composer global remove laravel/installer 2>/dev/null || true + ;; +symfony) + echo -e "Removing Symfony CLI...\n" + sudo pacman -Rns --noconfirm symfony-cli 2>/dev/null || true + ;; +python) + echo -e "Removing Python...\n" + mise uninstall python --all + mise rm -g python + rm -rf ~/.local/bin/uv ~/.local/bin/uvx ~/.cargo/bin/uv 2>/dev/null || true + ;; +elixir|phoenix) + echo -e "Removing Elixir/Erlang...\n" + mise uninstall elixir --all + mise uninstall erlang --all + mise rm -g elixir + mise rm -g erlang + ;; +zig) + echo -e "Removing Zig...\n" + mise uninstall zig --all + mise uninstall zls --all + mise rm -g zig + mise rm -g zls + ;; +rust) + echo -e "Removing Rust...\n" + rustup self uninstall -y 2>/dev/null || true + ;; +java) + echo -e "Removing Java...\n" + mise uninstall java --all + mise rm -g java + ;; +dotnet) + echo -e "Removing .NET...\n" + mise uninstall dotnet --all + mise rm -g dotnet + ;; +ocaml) + echo -e "Removing OCaml...\n" + opam switch remove default -y 2>/dev/null || true + rm -rf ~/.opam 2>/dev/null || true + sudo rm -f /usr/local/bin/opam 2>/dev/null || true + ;; +clojure) + echo -e "Removing Clojure...\n" + mise uninstall clojure --all + mise rm -g clojure + ;; +scala) + echo -e "Removing Scala...\n" + mise uninstall scala --all + mise uninstall scala-cli --all + mise rm -g scala + mise rm -g scala-cli + ;; +*) + echo "Unknown environment: $1" + exit 1 + ;; +esac + +echo -e "\nDone!" diff --git a/bin/blob-remove-gaming-battlenet b/bin/blob-remove-gaming-battlenet new file mode 100755 index 0000000..41b90cc --- /dev/null +++ b/bin/blob-remove-gaming-battlenet @@ -0,0 +1,35 @@ +#!/bin/bash + +# blob:summary=Remove Battle.net, its Proton prefix, installed games, and desktop entry. + +set -e + +PREFIX="$HOME/Games/battlenet" + +# Stop any running Battle.net / wine processes tied to this prefix. +pkill -f "$PREFIX" 2>/dev/null || true +sleep 1 + +rm -rf "$PREFIX" +rm -f "$HOME/.local/share/applications/battlenet.desktop" +rm -f "$HOME/.cache/blob/Battle.net-Setup.exe" +update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true + +echo +echo "Battle.net and its Proton prefix at $PREFIX have been removed." + +if blob-pkg-present umu-launcher; then + echo + if gum confirm "Also remove umu-launcher? It's only used by this command."; then + blob-pkg-drop umu-launcher + fi +fi + +PROTON_DIR="$HOME/.local/share/Steam/compatibilitytools.d" +if compgen -G "$PROTON_DIR/GE-Proton*" >/dev/null; then + echo + if gum confirm "Also remove GE-Proton runtimes downloaded by umu?"; then + rm -rf "$PROTON_DIR"/GE-Proton* + rm -rf "$HOME/.local/share/umu" + fi +fi diff --git a/bin/blob-remove-gaming-geforce-now b/bin/blob-remove-gaming-geforce-now new file mode 100755 index 0000000..71697f0 --- /dev/null +++ b/bin/blob-remove-gaming-geforce-now @@ -0,0 +1,14 @@ +#!/bin/bash + +# blob:summary=Remove the GeForce NOW Flatpak app and its data. +# blob:group=remove +# blob:name=gaming geforce-now + +set -e + +if blob-cmd-present flatpak && flatpak info com.nvidia.geforcenow &>/dev/null; then + flatpak uninstall -y --delete-data com.nvidia.geforcenow +fi + +echo "" +echo "GeForce NOW removed." diff --git a/bin/blob-remove-gaming-heroic b/bin/blob-remove-gaming-heroic new file mode 100755 index 0000000..afb6c41 --- /dev/null +++ b/bin/blob-remove-gaming-heroic @@ -0,0 +1,17 @@ +#!/bin/bash + +# blob:summary=Remove Heroic Games Launcher and its game libraries, configs, and caches. +# blob:requires-sudo=true + +set -e + +blob-pkg-drop heroic-games-launcher-bin + +rm -rf \ + "$HOME/.config/heroic" \ + "$HOME/.local/share/heroic" \ + "$HOME/.cache/heroic" \ + "$HOME/Games/Heroic" + +echo "" +echo "Heroic and its data have been removed." diff --git a/bin/blob-remove-gaming-lutris b/bin/blob-remove-gaming-lutris new file mode 100755 index 0000000..8c0117e --- /dev/null +++ b/bin/blob-remove-gaming-lutris @@ -0,0 +1,21 @@ +#!/bin/bash + +# blob:summary=Remove Lutris, Wine, umu-launcher, and all their configs and caches. +# blob:requires-sudo=true + +set -e + +blob-pkg-drop lutris wine-staging wine-mono wine-gecko winetricks python-protobuf umu-launcher + +rm -rf \ + "$HOME/.config/lutris" \ + "$HOME/.local/share/lutris" \ + "$HOME/.cache/lutris" \ + "$HOME/.local/share/umu" \ + "$HOME/.cache/umu" \ + "$HOME/.wine" \ + "$HOME/.cache/wine" \ + "$HOME/.cache/winetricks" + +echo "" +echo "Lutris, Wine, umu-launcher, and their configs have been removed." diff --git a/bin/blob-remove-gaming-minecraft b/bin/blob-remove-gaming-minecraft new file mode 100755 index 0000000..b5d0842 --- /dev/null +++ b/bin/blob-remove-gaming-minecraft @@ -0,0 +1,17 @@ +#!/bin/bash + +# blob:summary=Remove the Minecraft launcher along with its worlds, mods, and caches. +# blob:requires-sudo=true + +set -e + +blob-pkg-drop minecraft-launcher + +rm -rf \ + "$HOME/.minecraft" \ + "$HOME/.config/Minecraft Launcher" \ + "$HOME/.local/share/minecraft-launcher" \ + "$HOME/.cache/minecraft" + +echo "" +echo "Minecraft and its data have been removed." diff --git a/bin/blob-remove-gaming-retroarch b/bin/blob-remove-gaming-retroarch new file mode 100755 index 0000000..398654e --- /dev/null +++ b/bin/blob-remove-gaming-retroarch @@ -0,0 +1,37 @@ +#!/bin/bash + +# blob:summary=Remove RetroArch, all libretro cores, and its config/saves. Leaves ~/Games/roms and ~/Games/bios alone. +# blob:requires-sudo=true + +set -e + +blob-pkg-drop \ + retroarch \ + retroarch-assets-glui retroarch-assets-ozone retroarch-assets-xmb \ + libretro-beetle-pce libretro-beetle-pce-fast libretro-beetle-psx libretro-beetle-psx-hw libretro-beetle-supergrafx \ + libretro-blastem \ + libretro-bsnes libretro-bsnes-hd \ + libretro-core-info \ + libretro-desmume libretro-dolphin libretro-flycast \ + libretro-gambatte libretro-genesis-plus-gx \ + libretro-kronos \ + libretro-mame libretro-melonds libretro-mesen libretro-mesen-s libretro-mgba libretro-mupen64plus-next \ + libretro-nestopia \ + libretro-overlays \ + libretro-parallel-n64 libretro-picodrive libretro-play libretro-ppsspp \ + libretro-sameboy libretro-scummvm libretro-shaders-slang libretro-snes9x \ + libretro-yabause \ + libretro-cap32-git libretro-fbneo-git libretro-uae-git \ + libretro-vice-x128-git libretro-vice-x64-git libretro-vice-x64dtv-git libretro-vice-x64sc-git \ + libretro-vice-xcbm2-git libretro-vice-xcbm5x0-git libretro-vice-xpet-git \ + libretro-vice-xplus4-git libretro-vice-xscpu64-git libretro-vice-xvic-git \ + retroarch-joypad-autoconfig-git + +rm -rf \ + "$HOME/.config/retroarch" \ + "$HOME/.local/share/retroarch" \ + "$HOME/.cache/retroarch" + +echo "" +echo "RetroArch and its cores have been removed." +echo "ROMs and BIOS files at ~/Games/roms and ~/Games/bios were left in place." diff --git a/bin/blob-remove-gaming-steam b/bin/blob-remove-gaming-steam new file mode 100755 index 0000000..777215e --- /dev/null +++ b/bin/blob-remove-gaming-steam @@ -0,0 +1,17 @@ +#!/bin/bash + +# blob:summary=Remove Steam and all of its game libraries, configs, and caches. +# blob:requires-sudo=true + +set -e + +blob-pkg-drop steam + +rm -rf \ + "$HOME/.steam" \ + "$HOME/.local/share/Steam" \ + "$HOME/.config/steam" \ + "$HOME/.cache/steam" + +echo "" +echo "Steam and its data have been removed." diff --git a/bin/blob-remove-gaming-xbox-cloud b/bin/blob-remove-gaming-xbox-cloud new file mode 100755 index 0000000..011d639 --- /dev/null +++ b/bin/blob-remove-gaming-xbox-cloud @@ -0,0 +1,7 @@ +#!/bin/bash + +# blob:summary=Remove the Xbox Cloud Gaming web app. + +set -e + +blob-webapp-remove "Xbox Cloud Gaming" diff --git a/bin/blob-remove-gaming-xbox-controllers b/bin/blob-remove-gaming-xbox-controllers new file mode 100755 index 0000000..025053f --- /dev/null +++ b/bin/blob-remove-gaming-xbox-controllers @@ -0,0 +1,15 @@ +#!/bin/bash + +# blob:summary=Remove the xpadneo Xbox controller driver and undo its module/blacklist config. +# blob:group=remove +# blob:name=gaming xbox-controllers +# blob:requires-sudo=true + +set -e + +blob-pkg-drop xpadneo-dkms + +sudo rm -f /etc/modprobe.d/blacklist-xpad.conf /etc/modules-load.d/xpadneo.conf + +echo "" +echo "Xbox controller support removed. Reboot to fully unload xpadneo and restore xpad." diff --git a/bin/blob-webapp-handler-hey b/bin/blob-webapp-handler-hey new file mode 100755 index 0000000..f5a4f3f --- /dev/null +++ b/bin/blob-webapp-handler-hey @@ -0,0 +1,15 @@ +#!/bin/bash + +# blob:summary=Open HEY webmail and translate mailto links +# blob:args=[url] + +url="$1" +web_url="https://app.hey.com" + +# Handle mailto: URLs +if [[ $url =~ ^mailto: ]]; then + email=$(echo "$url" | sed 's/mailto://') + web_url="https://app.hey.com/messages/new?to=$email" +fi + +exec blob-launch-webapp "$web_url" diff --git a/bin/blob-webapp-handler-zoom b/bin/blob-webapp-handler-zoom new file mode 100755 index 0000000..fec6304 --- /dev/null +++ b/bin/blob-webapp-handler-zoom @@ -0,0 +1,23 @@ +#!/bin/bash + +# blob:summary=Open Zoom web meetings from browser protocol links +# blob:args=[url] + +url="$1" +web_url="https://app.zoom.us/wc/home" + +if [[ $url =~ ^zoom(mtg|us):// ]]; then + confno=$(echo "$url" | sed -n 's/.*[?&]confno=\([^&]*\).*/\1/p') + + if [[ -n $confno ]]; then + pwd=$(echo "$url" | sed -n 's/.*[?&]pwd=\([^&]*\).*/\1/p') + + if [[ -n $pwd ]]; then + web_url="https://app.zoom.us/wc/join/$confno?pwd=$pwd" + else + web_url="https://app.zoom.us/wc/join/$confno" + fi + fi +fi + +exec blob-launch-webapp "$web_url" diff --git a/bin/blob-webapp-install b/bin/blob-webapp-install new file mode 100755 index 0000000..3b0cc71 --- /dev/null +++ b/bin/blob-webapp-install @@ -0,0 +1,241 @@ +#!/bin/bash + +# blob:summary=Create a desktop launcher for a web app +# blob:args=[name url icon-url-or-name [custom-exec] [mime-types]] + +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/-$//' +} + +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere blob-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + +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" +} + +download_icon() { + curl -fsSL --max-time 10 -o "$2" "$1" 2>/dev/null && + [[ -s $2 && $(file -b --mime-type "$2") == image/* ]] +} + +# Chromium --app= treats javascript:, file:, and data: as a document to +# run. Prefix schemeless input with https as before, then refuse anything +# that is not http(s). +normalize_webapp_url() { + local url=$1 + if [[ ! $url =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then + url="https://$url" + fi + printf '%s' "$url" +} + +# Raw whitespace must be percent-encoded in a URL. Refuse it before serializing +# the desktop entry; before Exec argument quoting, it also split browser flags +# and additional URLs into separate arguments. Schemes are case-insensitive. +require_http_url() { + local url=$1 + + if [[ $url =~ [[:space:]] ]]; then + echo "Error: web app URL must not contain whitespace." >&2 + exit 1 + fi + + if [[ ! ${url,,} =~ ^https?:// ]]; then + echo "Error: web app URL must be http or https." >&2 + exit 1 + fi +} + +fetch_site_icon() { + local site_url="$1" dest="$2" + local origin page icon_url + origin=$(sed -E 's|^(https?://[^/]+).*|\1|' <<<"$site_url") + + # Prefer the site's own high-res icon (apple-touch-icon is typically 180px+), + # then the well-known path, then Google's favicon service as a last resort. + page=$(curl -fsSL --max-time 5 "$site_url" 2>/dev/null | head -c 100000 | tr '\n' ' ') + icon_url=$(grep -oiE "]*rel=[\"'][^\"']*apple-touch-icon[^\"']*[\"'][^>]*>" <<<"$page" | + grep -oiE "href=[\"'][^\"']+" | head -1 | sed -E "s/^href=[\"']//") + + case $icon_url in + http://* | https://*) ;; + //*) icon_url="https:$icon_url" ;; + /*) icon_url="$origin$icon_url" ;; + ?*) icon_url="$origin/$icon_url" ;; + esac + + { [[ -n $icon_url ]] && download_icon "$icon_url" "$dest"; } || + download_icon "$origin/apple-touch-icon.png" "$dest" || + download_icon "https://www.google.com/s2/favicons?domain=${site_url}&sz=256" "$dest" +} + +desktop_string_escape() { + # Desktop Entry "string" value (freedesktop Desktop Entry Spec, "Value types"): + # a raw newline would start a new key line and let a value inject a second + # Exec=. Escape backslash first, then tab/CR/LF and a leading space. Every value + # written into the .desktop file passes through here. + # + # Parameter expansion rather than sed: GNU sed's N auto-prints the pattern space + # and exits at end of input, so a `:a;N;$!ba` slurp skips every following s/// + # for a value with no newline in it - which is every value except the injection + # attempt this exists to stop. + local value="$1" + + value=${value//\\/\\\\} + value=${value//$'\t'/\\t} + value=${value//$'\r'/\\r} + value=${value//$'\n'/\\n} + [[ $value == " "* ]] && value="\\s${value# }" + + printf '%s' "$value" +} + +desktop_exec_arg() { + # One Exec argument, double-quoted per the freedesktop Exec spec: inside quotes + # " ` $ \ take a backslash and a literal % becomes %%. Only the default Exec's + # URL needs this; $CUSTOM_EXEC stays a whole command line (file-syntax only). + local escaped + escaped=$(printf '%s' "$1" \ + | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/`/\\`/g' -e 's/\$/\\$/g' -e 's/%/%%/g') + printf '"%s"' "$escaped" +} + +if (( $# < 3 )); then + echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" + APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") + require_plain_name "$APP_NAME" + APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") + APP_URL=$(normalize_webapp_url "$APP_URL") + require_http_url "$APP_URL" + + # Try to fetch the site's icon automatically first. + mkdir -p "$ICON_DIR" + ICON_VALUE=$(safe_icon_name "$APP_NAME") + if fetch_site_icon "$APP_URL" "$ICON_DIR/$ICON_VALUE.png"; then + gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true + ICON_REF="$ICON_VALUE" + else + ICON_REF=$(gum input --prompt "Icon URL/name> " --placeholder "Could not fetch favicon automatically. Enter PNG icon URL or icon name") + fi + + CUSTOM_EXEC="" + MIME_TYPES="" + INTERACTIVE_MODE=true +else + APP_NAME="$1" + APP_URL=$(normalize_webapp_url "$2") + require_http_url "$APP_URL" + ICON_REF="$3" + CUSTOM_EXEC="$4" # Optional custom exec command + MIME_TYPES="$5" # Optional mime types + INTERACTIVE_MODE=false +fi + +# Ensure valid execution +if [[ -z $APP_NAME || -z $APP_URL ]]; then + echo "You must set app name and app URL!" + exit 1 +fi + +require_plain_name "$APP_NAME" + +if [[ -z $ICON_REF ]]; then + ICON_VALUE=$(safe_icon_name "$APP_NAME") + mkdir -p "$ICON_DIR" + if ! fetch_site_icon "$APP_URL" "$ICON_DIR/$ICON_VALUE.png"; then + echo "Error: Failed to download icon." + exit 1 + fi + gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true +elif [[ $ICON_REF =~ ^https?:// ]]; then + ICON_VALUE=$(safe_icon_name "$APP_NAME") + mkdir -p "$ICON_DIR" + if ! download_icon "$ICON_REF" "$ICON_DIR/$ICON_VALUE.png"; 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. + # Accept either "HEY" or the historical "HEY.png" argument form. + ICON_VALUE=$(icon_name_from_ref "$ICON_REF") +fi + +# Default Exec quotes the URL as one Exec-spec argument; the whole line then gets +# the file-syntax escaping below (unescaped first at read time per spec, so the +# layers compose). $CUSTOM_EXEC is a full command line, so it gets file-syntax only. +if [[ -n $CUSTOM_EXEC ]]; then + EXEC_COMMAND=$CUSTOM_EXEC +else + EXEC_COMMAND="blob-launch-webapp $(desktop_exec_arg "$APP_URL")" +fi + +# Create application .desktop file +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" + +name_field=$(desktop_string_escape "$APP_NAME") +exec_field=$(desktop_string_escape "$EXEC_COMMAND") +icon_field=$(desktop_string_escape "$ICON_VALUE") + +cat >"$DESKTOP_FILE" <>"$DESKTOP_FILE" +fi + +chmod +x "$DESKTOP_FILE" + +if [[ $INTERACTIVE_MODE == "true" ]]; then + echo -e "You can now find $APP_NAME using the app launcher (SUPER + SPACE)\n" +fi diff --git a/bin/blob-webapp-remove b/bin/blob-webapp-remove new file mode 100755 index 0000000..4902d2d --- /dev/null +++ b/bin/blob-webapp-remove @@ -0,0 +1,61 @@ +#!/bin/bash + +# blob:summary=Remove a web app 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/" + +# Always index the launchers, so removal deletes the file that was found rather +# than a path rebuilt from the displayed name. Installs predating the name +# validation could nest the launcher inside directories, and those are exactly +# the ones a reconstructed path cannot reach. +WEB_APP_PATHS=() +while IFS= read -r -d '' file; do + if grep -q '^Exec=.*\(blob-launch-webapp\|blob-webapp-handler\).*' "$file"; then + WEB_APPS+=("$(basename "${file%.desktop}")") + WEB_APP_PATHS+=("$file") + fi +done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0 2>/dev/null) + +# The launcher matching a chosen name, or empty when nothing was indexed under +# it (an app removed between the scan and the pick, say). +path_for_web_app() { + local wanted="$1" i + for i in "${!WEB_APPS[@]}"; do + if [[ ${WEB_APPS[$i]} == "$wanted" ]]; then + printf '%s\n' "${WEB_APP_PATHS[$i]}" + return 0 + fi + done +} + +if (( $# == 0 )); then + if ((${#WEB_APPS[@]})); then + mapfile -t SORTED_WEB_APPS < <(printf '%s\n' "${WEB_APPS[@]}" | sort) + APP_NAME=$(blob-menu-select "Select web app to remove" "${SORTED_WEB_APPS[@]}" -- --width 520 --maxheight 520) + else + echo "No web apps to remove." + exit 1 + fi +else + APP_NAME="$*" +fi + +if [[ -z $APP_NAME ]]; then + echo "You must select a web app to remove." + exit 1 +fi + +icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//') +desktop_file=$(path_for_web_app "$APP_NAME") +rm -f "${desktop_file:-$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  "Web app removed" "$APP_NAME" +fi +update-desktop-database "$DESKTOP_DIR" &>/dev/null diff --git a/bin/blob-windows-vm b/bin/blob-windows-vm new file mode 100755 index 0000000..6c927f7 --- /dev/null +++ b/bin/blob-windows-vm @@ -0,0 +1,1591 @@ +#!/bin/bash + +# blob:summary=Install, launch, stop, inspect, or remove the Windows VM +# blob:args= [options] +# blob:requires-sudo=true + +# The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so +# it needs the root-owned Docker daemon. By default the user is NOT in the docker +# group (that group is root-equivalent), so Docker access is gated behind a +# single polkit prompt. If the user opted into sudoless Docker +# (blob-setup-security-sudoless-docker), the socket is reachable directly and +# no prompt appears. +# +# The compose file lives in a root-owned directory and is only ever written by +# the elevated, input-validated write_compose action below. That is the whole +# point: a root-invoked `docker compose up` must never consume a file that a +# process running as the user could have rewritten to bind-mount / into the +# container. Earlier versions kept it under ~/.config/windows, which a rogue +# process could edit and then trigger a privileged bring-up of — a file-swap +# path to root. Do not move it back under $HOME. + +RUNTIME_DIR="${BLOB_WINDOWS_DIR:-/var/lib/blob/windows}" +COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml" +LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml" +# The guest password lives in the root-owned compose (readable by root and the +# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in +# the user's own config, so the plaintext password is never world-readable. +CREDENTIALS_FILE="$HOME/.config/windows/credentials" +IMAGE="dockurr/windows" +CONTAINER="blob-windows" +VM_LOCK_DIR=/run/lock/blob-windows-vm +# Removal is the only path that recursively proves there are no bind aliases. +# Bound every metadata walk so a large or hostile caller tree fails closed +# instead of hanging a privileged action indefinitely. +TREE_SCAN_TIMEOUT_SECONDS=5 +TREE_SCAN_KILL_AFTER_SECONDS=1 +TREE_SCAN_TIMEOUT=/usr/bin/timeout +TREE_SCAN_FIND=/usr/bin/find + +# The privileged process must not resolve mount, stat, Docker, or any other +# helper from a caller-controlled PATH, and validation must not change with an +# inherited locale. pkexec normally sanitizes both; pin them here as defense in +# depth for every direct __priv entry as well. +if ((EUID == 0)); then + export PATH=/usr/bin:/usr/sbin:/bin:/sbin + export LC_ALL=C.UTF-8 +fi + +# --- privilege helpers ------------------------------------------------------- + +# True when this session can reach the Docker socket directly (sudoless Docker +# on and in effect). Asking about the socket rather than the configured groups +# keeps the prompt in place through the window where sudoless Docker is enabled +# but the reboot that grants the group has not happened yet. +docker_needs_sudo() { blob-sudo-docker; } + +# The command to hand pkexec for the privileged re-exec. pkexec runs whatever +# executable it is given (after authorization) and only shows the path in the +# prompt — it does NOT require the target to be root-owned. So resolve to the +# packaged command and refuse to elevate anything a non-root user could have +# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a +# prompt the user grants for the trusted helper could run an attacker's binary +# as root. Fails closed (empty output) when no trustworthy target is found. +priv_target() { + local candidate=/usr/bin/blob-windows-vm canonical probe owner mode + [[ -f $candidate && ! -L $candidate && -x $candidate ]] || return 1 + canonical=$(realpath -e -- "$candidate" 2>/dev/null) || return 1 + [[ $canonical == "$candidate" ]] || return 1 + probe=$candidate + while :; do + owner=$(stat -Lc '%u' "$probe" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$probe" 2>/dev/null) || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || return 1 + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + printf '%s\n' "$candidate" +} + +# Run a privileged VM action. write_compose always elevates (the compose is +# root-owned); the daemon operations run directly when sudoless Docker is on and +# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec +# policy is auth_admin (not auth_admin_keep), so each elevated action prompts: +# a launch asks once to start and, unless authorization is still cached by the +# agent, again to stop. +priv() { + local action="$1" + shift + if [[ $action != write_compose && $action != remove ]] && ! docker_needs_sudo; then + # Bring-up normally runs directly for a docker-group user, but recreating + # the protected bind anchors after reboot (or migrating an old compose) + # still needs one privileged invocation. + if [[ -d $VM_LOCK_DIR && ! -L $VM_LOCK_DIR && -r $VM_LOCK_DIR && -x $VM_LOCK_DIR ]] && { + [[ $action != up && $action != up_wait ]] || { + ! compose_needs_security_migration && mounts_ready >/dev/null 2>&1 + } + }; then + with_vm_lock "__priv_$action" "$@" + return + fi + fi + local target + target=$(priv_target) || { + echo "blob-windows-vm: refusing to run a non-root-owned command as root" >&2 + return 1 + } + pkexec "$target" __priv "$action" "$@" +} + +dc() { docker-compose -f "$COMPOSE_FILE" "$@"; } + +with_vm_lock() { + local fd rc=0 + if ((EUID == 0)); then + assert_boundary_dir /run 0 && assert_boundary_dir /run/lock 0 || return 1 + if getent group docker >/dev/null 2>&1; then + install -d -o root -g docker -m 0750 -- "$VM_LOCK_DIR" || return 1 + else + install -d -o root -g root -m 0700 -- "$VM_LOCK_DIR" || return 1 + fi + fi + assert_boundary_dir "$VM_LOCK_DIR" 0 || return 1 + # Flock the directory inode itself. Concurrent first callers may both run + # install -d, but mkdir is atomic and they necessarily open the same stable + # inode below the root-owned /run/lock parent. + exec {fd}<"$VM_LOCK_DIR/." || return 1 + flock -x "$fd" || { exec {fd}<&-; return 1; } + "$@" || rc=$? + flock -u "$fd" || rc=1 + exec {fd}<&- + return "$rc" +} + +# --- validation (shared by the user-side prompts and the root-side writer) ---- + +valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; } +valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); } +valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; } +valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; } +valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; } +valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; } + +# The only privileged sub-actions __priv may dispatch. A bash command name +# containing a slash is executed as a path, so validating the action here — not +# just interpolating it into "__priv_${action}" — is what stops an action like +# ../tmp/evil from running an arbitrary file as root. +valid_priv_action() { + case "$1" in + write_compose | up | up_wait | down | status | remove) return 0 ;; + *) return 1 ;; + esac +} + +# --- privileged actions (run as root via pkexec, or directly when sudoless) --- + +# Resolve the account that authorized pkexec. Never trust HOME or a caller- +# supplied mount path in the privileged process: pkexec can reset HOME, and the +# old path arguments were the source of an arbitrary host bind-mount primitive. +resolve_caller() { + local entry canonical parent owner mode + + if ((EUID == 0)); then + [[ ${PKEXEC_UID:-} =~ ^[0-9]{1,10}$ ]] && ((10#$PKEXEC_UID > 0)) || { + echo "blob-windows-vm: cannot identify the user who authorized this action" >&2 + return 1 + } + CALLER_UID=$((10#$PKEXEC_UID)) + else + CALLER_UID=$(id -u) + fi + + entry=$(getent passwd "$CALLER_UID") || { + echo "blob-windows-vm: no account exists for uid $CALLER_UID" >&2 + return 1 + } + IFS=: read -r _ _ _ CALLER_GID _ CALLER_HOME _ <<<"$entry" + [[ $CALLER_GID =~ ^[0-9]+$ && $CALLER_HOME == /* && -d $CALLER_HOME ]] || { + echo "blob-windows-vm: invalid home directory for uid $CALLER_UID" >&2 + return 1 + } + + # A direct, non-root development invocation with a non-standard runtime has + # no privilege boundary and may use its current HOME (which also keeps these + # functions testable). Production always uses the account database value. + if ((EUID != 0)) && [[ $RUNTIME_DIR != /var/lib/blob/windows ]]; then + CALLER_HOME=${HOME:-$CALLER_HOME} + fi + canonical=$(realpath -e -- "$CALLER_HOME" 2>/dev/null) || return 1 + [[ $canonical == "$CALLER_HOME" ]] || { + echo "blob-windows-vm: refusing a home directory reached through a symlink" >&2 + return 1 + } + + if ((EUID == 0)); then + owner=$(stat -Lc '%u' "$CALLER_HOME") || return 1 + [[ $owner == "$CALLER_UID" ]] || { + echo "blob-windows-vm: caller does not own $CALLER_HOME" >&2 + return 1 + } + # The user must not be able to rename or replace their home while root is + # opening and pinning the familiar data entries below it. + parent=$(dirname -- "$CALLER_HOME") + while :; do + owner=$(stat -Lc '%u' "$parent") || return 1 + mode=$(stat -Lc '%a' "$parent") || return 1 + [[ $owner == 0 ]] && ! ((8#$mode & 022)) || { + echo "blob-windows-vm: unsafe writable parent in home path: $parent" >&2 + return 1 + } + [[ $parent == / ]] && break + parent=$(dirname -- "$parent") + done + fi + + # Docker only ever sees fixed paths below the root-owned runtime tree. The + # user's real data stays wherever ~/.windows and ~/Windows resolve (including + # separately mounted homes and legitimate symlinks); those sources are pinned + # into these anchors with bind mounts before Docker is allowed to start. + MOUNT_ROOT="$RUNTIME_DIR/mounts" + USERS_DIR="$MOUNT_ROOT/users" + CALLER_DATA_ROOT="$USERS_DIR/$CALLER_UID" + EXPECTED_STORAGE="$CALLER_DATA_ROOT/storage" + EXPECTED_SHARED="$CALLER_DATA_ROOT/shared" + LEGACY_STORAGE="$CALLER_HOME/.windows" + LEGACY_SHARED="$CALLER_HOME/Windows" + # The first protected-anchor implementation used a root-owned sibling of + # home. Recognize that exact derived pair during upgrade, but never accept a + # path read from user input. + OLD_MOUNT_ROOT="$(dirname -- "$CALLER_HOME")/.blob-windows" + OLD_EXPECTED_STORAGE="$OLD_MOUNT_ROOT/users/$CALLER_UID/storage" + OLD_EXPECTED_SHARED="$OLD_MOUNT_ROOT/users/$CALLER_UID/shared" +} + +boundary_owner() { + # Production boundaries remain root-owned even when a docker-group user runs + # the read-only bring-up checks directly. A non-standard runtime is supported + # only for unprivileged tests/development and is owned by that caller. + if ((EUID == 0)) || [[ $RUNTIME_DIR == /var/lib/blob/windows ]]; then + printf '0' + else + printf '%s' "$CALLER_UID" + fi +} + +assert_boundary_dir() { + local path="$1" expected_owner="$2" owner mode canonical + [[ -d $path && ! -L $path ]] || return 1 + canonical=$(realpath -e -- "$path" 2>/dev/null) || return 1 + [[ $canonical == "$path" ]] || return 1 + owner=$(stat -Lc '%u' "$path") || return 1 + mode=$(stat -Lc '%a' "$path") || return 1 + [[ $owner == "$expected_owner" ]] && ! ((8#$mode & 022)) +} + +prepare_boundary_component() { + local path="$1" parent="$2" owner="$3" mode="$4" + assert_boundary_dir "$parent" "$owner" || return 1 + if [[ -e $path || -L $path ]]; then + assert_boundary_dir "$path" "$owner" || return 1 + else + if ((EUID == 0)); then + install -d -o root -g root -m "$mode" -- "$path" || return 1 + else + install -d -m "$mode" -- "$path" || return 1 + fi + fi + chmod "$mode" -- "$path" || return 1 + if ((EUID == 0)); then chown root:root -- "$path" || return 1; fi + assert_boundary_dir "$path" "$owner" +} + +prepare_runtime_tree() { + local owner probe runtime_parent + owner=$(boundary_owner) + if ((EUID == 0)); then + [[ $RUNTIME_DIR == /var/lib/blob/windows ]] || { + echo "blob-windows-vm: refusing a non-standard privileged runtime path" >&2 + return 1 + } + # Check the nearest existing ancestor before mkdir can follow anything. + # Every new component is then created by root and checked again below. + probe=$RUNTIME_DIR + while [[ ! -e $probe && ! -L $probe ]]; do probe=$(dirname -- "$probe"); done + while :; do + assert_boundary_dir "$probe" 0 || { + echo "blob-windows-vm: unsafe runtime parent: $probe" >&2 + return 1 + } + [[ $probe == / ]] && break + probe=$(dirname -- "$probe") + done + fi + + runtime_parent=$(dirname -- "$RUNTIME_DIR") + if ((EUID == 0)) && [[ ! -e $runtime_parent && ! -L $runtime_parent ]]; then + [[ $runtime_parent == /var/lib/blob ]] || return 1 + assert_boundary_dir /var/lib 0 || return 1 + install -d -o root -g root -m 0755 -- "$runtime_parent" || return 1 + fi + prepare_boundary_component "$RUNTIME_DIR" "$runtime_parent" "$owner" 0755 && + prepare_boundary_component "$MOUNT_ROOT" "$RUNTIME_DIR" "$owner" 0711 && + prepare_boundary_component "$USERS_DIR" "$MOUNT_ROOT" "$owner" 0711 && + prepare_boundary_component "$CALLER_DATA_ROOT" "$USERS_DIR" "$owner" 0711 || { + echo "blob-windows-vm: unsafe VM mount boundary" >&2 + return 1 + } +} + +# Open the source directory itself and keep the descriptor alive until after the +# bind. /proc/$BASHPID/fd refers to this exact shell process (including when a +# function runs in a pipeline subshell), not the short-lived mount subprocess, +# so a rename or symlink swap after open cannot change which inode is mounted. +open_mount_source() { + local path="$1" label="$2" fd record uid identity + [[ -d $path ]] || { + echo "blob-windows-vm: $label source is not a directory: $path" >&2 + return 1 + } + # Appending /. makes a directory-to-FIFO swap fail with ENOTDIR instead of + # leaving the privileged helper blocked while opening an attacker-held pipe. + exec {fd}<"$path/." || { + echo "blob-windows-vm: cannot open $label source: $path" >&2 + return 1 + } + [[ -d /proc/$BASHPID/fd/$fd ]] || { + exec {fd}<&- + return 1 + } + record=$(stat -Lc '%u|%d:%i' "/proc/$BASHPID/fd/$fd" 2>/dev/null) || { + exec {fd}<&- + return 1 + } + IFS='|' read -r uid identity <<<"$record" + [[ $uid == "$CALLER_UID" ]] || { + exec {fd}<&- + echo "blob-windows-vm: $label source must be a directory owned by uid $CALLER_UID" >&2 + return 1 + } + OPENED_MOUNT_FD=$fd + OPENED_MOUNT_ID=$identity +} + +# Return 0 when ancestor_id contains the already-open descendant directory, 1 +# when the walk reaches the namespace root without finding it, and 2 on any +# error or an implausibly deep walk. Every hop is opened relative to a pinned +# directory FD; no caller-mutable pathname is re-resolved. +pinned_dir_contains() { + local ancestor_id="$1" descendant_fd="$2" walk_fd parent_fd current_id parent_id depth + exec {walk_fd}<"/proc/$BASHPID/fd/$descendant_fd/." || return 2 + for ((depth = 0; depth < 256; depth++)); do + current_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$walk_fd" 2>/dev/null) || { + exec {walk_fd}<&- + return 2 + } + if [[ $current_id == "$ancestor_id" ]]; then + exec {walk_fd}<&- + return 0 + fi + exec {parent_fd}<"/proc/$BASHPID/fd/$walk_fd/.." || { + exec {walk_fd}<&- + return 2 + } + parent_id=$(stat -Lc '%d:%i' "/proc/$BASHPID/fd/$parent_fd" 2>/dev/null) || { + exec {parent_fd}<&- + exec {walk_fd}<&- + return 2 + } + if [[ $parent_id == "$current_id" ]]; then + exec {parent_fd}<&- + exec {walk_fd}<&- + return 1 + fi + exec {walk_fd}<&- + walk_fd=$parent_fd + done + exec {walk_fd}<&- + return 2 +} + +# A bind alias can give the same directory inode a second parent chain, so an +# upward walk alone is insufficient for destructive removal. Search from a +# pinned tree root for the other pinned inode without following symlinks or +# crossing the removal traversal's filesystem boundary. Return 0 when found, 1 +# when absent, and 2 on timeout, traversal error, or unexpected output. +pinned_tree_contains() { + local root_fd="$1" needle_fd="$2" found rc + # -xdev still evaluates a nested mountpoint itself before pruning its + # children, so a direct different-filesystem alias of the needle is found too. + # This matches removal's traversal boundary without skipping mount aliases. + if found=$("$TREE_SCAN_TIMEOUT" --signal=TERM --kill-after="${TREE_SCAN_KILL_AFTER_SECONDS}s" \ + "${TREE_SCAN_TIMEOUT_SECONDS}s" "$TREE_SCAN_FIND" -P "/proc/$BASHPID/fd/$root_fd/." \ + -xdev -type d -samefile "/proc/$BASHPID/fd/$needle_fd/." \ + -printf 'found\n' -quit 2>/dev/null); then + rc=0 + else + rc=$? + fi + ((rc == 0)) || return 2 + case "$found" in + found) return 0 ;; + "") return 1 ;; + *) return 2 ;; + esac +} + +validate_pinned_sources_disjoint() { + local storage_fd="$1" storage_id="$2" shared_fd="$3" shared_id="$4" rc + if [[ $storage_id == "$shared_id" ]]; then + echo "blob-windows-vm: storage and shared must be different directories" >&2 + return 1 + fi + if pinned_dir_contains "$storage_id" "$shared_fd"; then + echo "blob-windows-vm: shared directory must not be inside storage" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "blob-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi + if pinned_dir_contains "$shared_id" "$storage_fd"; then + echo "blob-windows-vm: storage directory must not be inside shared" >&2 + return 1 + else + rc=$? + ((rc == 1)) || { + echo "blob-windows-vm: could not verify storage/shared ancestry" >&2 + return 1 + } + fi +} + +removal_trees_disjoint() { + local storage_fd shared_fd anchor_storage_fd anchor_shared_fd storage_id shared_id rc=1 scan_rc + local scan scan_root_fd scan_needle_fd scan_label + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id" || + ! mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" || + ! mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_storage_fd}<"$EXPECTED_STORAGE/."; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! exec {anchor_shared_fd}<"$EXPECTED_SHARED/."; then + exec {anchor_storage_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Scan the protected anchor views first. Also scan the pinned caller views: + # a submount attached after the original non-recursive bind is intentionally + # absent from the anchor view, but removal must still refuse that alias. + rc=0 + for scan in \ + "$anchor_storage_fd:$anchor_shared_fd:shared below protected storage" \ + "$anchor_shared_fd:$anchor_storage_fd:storage below protected shared" \ + "$storage_fd:$shared_fd:shared below storage source" \ + "$shared_fd:$storage_fd:storage below shared source"; do + IFS=: read -r scan_root_fd scan_needle_fd scan_label <<<"$scan" + if pinned_tree_contains "$scan_root_fd" "$scan_needle_fd"; then + echo "blob-windows-vm: refusing removal: $scan_label" >&2 + rc=1 + break + else + scan_rc=$? + if ((scan_rc != 1)); then + echo "blob-windows-vm: removal containment scan failed or timed out" >&2 + rc=1 + break + fi + fi + done + + exec {anchor_storage_fd}<&- + exec {anchor_shared_fd}<&- + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +prepare_mount_anchor() { + local path="$1" owner + owner=$(boundary_owner) + [[ ! -L $path ]] || return 1 + if mountpoint -q -- "$path" 2>/dev/null; then return 0; fi + prepare_boundary_component "$path" "$CALLER_DATA_ROOT" "$owner" 0700 || return 1 + [[ -z $(find "$path" -mindepth 1 -print -quit) ]] || { + echo "blob-windows-vm: refusing to hide data below mount anchor: $path" >&2 + return 1 + } +} + +mounted_leaf_matches() { + local stable="$1" identity="$2" actual owner mode canonical + [[ -d $stable && ! -L $stable ]] || return 1 + canonical=$(realpath -e -- "$stable" 2>/dev/null) || return 1 + [[ $canonical == "$stable" ]] || return 1 + mountpoint -q -- "$stable" 2>/dev/null || return 1 + [[ $(mount_layer_count "$stable") == 1 ]] || return 1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || return 1 + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || return 1 + mode=$(stat -Lc '%a' "$stable" 2>/dev/null) || return 1 + [[ $actual == "$identity" && $owner == "$CALLER_UID" && $mode == 700 ]] +} + +bind_mount_leaf() { + local fd="$1" identity="$2" stable="$3" actual owner + MOUNT_LEAF_NEW=0 + if mountpoint -q -- "$stable" 2>/dev/null; then + mounted_leaf_matches "$stable" "$identity" || { + echo "blob-windows-vm: protected mount at $stable no longer matches its home source" >&2 + return 1 + } + return 0 + fi + + # util-linux normally canonicalizes a /proc//fd link back to a pathname, + # which would throw away the FD pin. Pass the procfd to mount(2) unchanged. + mount --no-canonicalize --bind "/proc/$BASHPID/fd/$fd" "$stable" || return 1 + MOUNT_LEAF_NEW=1 + actual=$(stat -Lc '%d:%i' "$stable" 2>/dev/null) || actual="" + owner=$(stat -Lc '%u' "$stable" 2>/dev/null) || owner="" + if [[ $actual != "$identity" || $owner != "$CALLER_UID" ]]; then + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "blob-windows-vm: could not roll back unverified bind at $stable" >&2 + fi + echo "blob-windows-vm: bind verification failed for $stable" >&2 + return 1 + fi + mounted_leaf_matches "$stable" "$identity" || { + if umount -- "$stable"; then + MOUNT_LEAF_NEW=0 + else + echo "blob-windows-vm: could not roll back invalid bind at $stable" >&2 + fi + return 1 + } +} + +prepare_caller_mounts() { + local storage_fd storage_id shared_fd shared_id storage_mode shared_mode + CALLER_MOUNTS_NEW_STORAGE=0 + CALLER_MOUNTS_NEW_SHARED=0 + resolve_caller && prepare_runtime_tree || return 1 + prepare_mount_anchor "$EXPECTED_STORAGE" && prepare_mount_anchor "$EXPECTED_SHARED" || return 1 + + # Pre-open and validate both sources before changing either mount anchor. + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + # Privacy is an explicit preflight step for both already-pinned sources, not + # a side effect halfway through the two-mount transaction. Old umask-022 + # installs are hardened together before either Docker-facing anchor changes. + chmod 0700 -- "/proc/$BASHPID/fd/$storage_fd" "/proc/$BASHPID/fd/$shared_fd" || { + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + } + storage_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$storage_fd" 2>/dev/null) || storage_mode="" + shared_mode=$(stat -Lc '%a' "/proc/$BASHPID/fd/$shared_fd" 2>/dev/null) || shared_mode="" + if [[ $storage_mode != 700 || $shared_mode != 700 ]]; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + + if bind_mount_leaf "$storage_fd" "$storage_id" "$EXPECTED_STORAGE"; then + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + else + CALLER_MOUNTS_NEW_STORAGE=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if ! bind_mount_leaf "$shared_fd" "$shared_id" "$EXPECTED_SHARED"; then + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + rollback_new_caller_mounts || true + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + CALLER_MOUNTS_NEW_SHARED=$MOUNT_LEAF_NEW + + exec {storage_fd}<&- + exec {shared_fd}<&- +} + +mounts_ready() { + local storage_fd storage_id shared_fd shared_id rc=1 + resolve_caller || return 1 + open_mount_source "$LEGACY_STORAGE" storage || return 1 + storage_fd=$OPENED_MOUNT_FD + storage_id=$OPENED_MOUNT_ID + if ! open_mount_source "$LEGACY_SHARED" shared; then + exec {storage_fd}<&- + return 1 + fi + shared_fd=$OPENED_MOUNT_FD + shared_id=$OPENED_MOUNT_ID + if ! validate_pinned_sources_disjoint "$storage_fd" "$storage_id" "$shared_fd" "$shared_id"; then + exec {storage_fd}<&- + exec {shared_fd}<&- + return 1 + fi + if assert_boundary_dir "$RUNTIME_DIR" "$(boundary_owner)" && + assert_boundary_dir "$MOUNT_ROOT" "$(boundary_owner)" && + assert_boundary_dir "$USERS_DIR" "$(boundary_owner)" && + assert_boundary_dir "$CALLER_DATA_ROOT" "$(boundary_owner)" && + mounted_leaf_matches "$EXPECTED_STORAGE" "$storage_id" && + mounted_leaf_matches "$EXPECTED_SHARED" "$shared_id"; then + rc=0 + fi + exec {storage_fd}<&- + exec {shared_fd}<&- + return "$rc" +} + +mount_layer_count() { + local path="$1" + awk -v path="$path" '$5 == path { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +mount_descendant_count() { + local path="$1" + awk -v prefix="$path/" 'index($5, prefix) == 1 { count++ } END { print count + 0 }' /proc/self/mountinfo +} + +rollback_new_caller_mounts() { + local failed=0 + if ((CALLER_MOUNTS_NEW_SHARED)); then + if umount -- "$EXPECTED_SHARED"; then CALLER_MOUNTS_NEW_SHARED=0; else failed=1; fi + fi + if ((CALLER_MOUNTS_NEW_STORAGE)); then + if umount -- "$EXPECTED_STORAGE"; then CALLER_MOUNTS_NEW_STORAGE=0; else failed=1; fi + fi + ((failed == 0)) || echo "blob-windows-vm: could not roll back newly created VM mounts" >&2 + return "$failed" +} + +write_compose_atomically() ( + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + local tmp="" rc esc_password + cleanup_writer() { + rc=$? + trap - EXIT + [[ -z $tmp ]] || rm -f -- "$tmp" || true + if ((rc != 0)) && ! rollback_new_caller_mounts; then rc=1; fi + exit "$rc" + } + trap cleanup_writer EXIT + + # Neutralize anything in the password that could be misread when the compose + # is parsed. Two layers apply, in this order at parse time: docker compose + # variable interpolation over the raw text ($VAR / $$), then YAML parsing of + # the double-quoted scalar. Encode for the inner layer first (backslash, then + # double-quote) and the interpolation layer last ($ -> $$), so a password + # containing " \ or $ reaches the guest verbatim. unescape() reverses this in + # the opposite order for the RDP credentials. + esc_password=${password//\\/\\\\} + esc_password=${esc_password//\"/\\\"} + esc_password=${esc_password//\$/\$\$} + + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || exit 1 + # blob:heredoc-expands paths=EXPECTED_STORAGE,EXPECTED_SHARED -- both are + # root-protected anchors derived from the authenticated caller uid and bound + # to source inodes that were opened and validated before this compose is + # written. The remaining expansions are revalidated scalar settings. + cat >"$tmp" </dev/null || chown root:root "$tmp" || exit 1 + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || exit 1 + tmp="" + trap - EXIT +) + +# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the +# compose atomically as root. Re-validation here is the security boundary: the +# writer refuses rather than emit a compose an attacker could have influenced. +# Only these fixed keys are honored; image, container name, devices, caps, and +# port bindings are hard-coded and never taken from input. +__priv_write_compose() { + local ram cores disk username password tz key value + + while IFS='=' read -r key value; do + case "$key" in + RAM) ram="$value" ;; + CORES) cores="$value" ;; + DISK) disk="$value" ;; + USERNAME) username="$value" ;; + PASSWORD) password="$value" ;; + TZ) tz="$value" ;; + esac + done + + valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; } + valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; } + valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; } + valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; } + valid_password "$password" || { echo "invalid password" >&2; exit 2; } + valid_tz "$tz" || tz="UTC" + prepare_caller_mounts || exit 2 + # Readable by root and the docker group only. Any failure after mounting rolls + # back just the binds this writer created and leaves an old compose untouched. + write_compose_atomically "$ram" "$cores" "$disk" "$username" "$password" "$tz" || exit 2 +} + +# Read the host source of a bind mount out of the compose (e.g. /storage). +get_mount_source() { + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1 +} + +# True only when a trusted compose has an exact security upgrade path. The +# result forces a one-time elevated migration for sudoless-Docker users. An +# arbitrary or mixed bind pair is never classified as migratable. +compose_needs_security_migration() { + local storage shared + [[ -f $COMPOSE_FILE ]] || return 1 + resolve_caller || return 1 + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || return 1 + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + if compose_mount_pair_is_migratable "$storage" "$shared"; then + return 0 + fi + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] && ! compose_web_protected +} + +compose_mount_pair_is_migratable() { + local storage="$1" shared="$2" + [[ $storage == "$LEGACY_STORAGE" && $shared == "$LEGACY_SHARED" ]] || + [[ $storage == "$OLD_EXPECTED_STORAGE" && $shared == "$OLD_EXPECTED_SHARED" ]] +} + +mount_source_count() { + local destination="$1" + sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$destination\$|x|p" "$COMPOSE_FILE" | wc -l +} + +compose_web_protected() { + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$COMPOSE_FILE" | wc -l) == 1 ]] +} + +rewrite_compose_security() { + local tmp + tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX") || return 1 + awk -v storage="$EXPECTED_STORAGE" -v shared="$EXPECTED_SHARED" ' + /^ environment:$/ { print; print " PROTECT: \"Y\""; next } + /^[[:space:]]+PROTECT:/ { next } + /^[[:space:]]*-[[:space:]]*\/[^:]*:\/storage$/ { print " - " storage ":/storage"; next } + /^[[:space:]]*-[[:space:]]*\/[^:]*:\/shared$/ { print " - " shared ":/shared"; next } + { print } + ' "$COMPOSE_FILE" >"$tmp" || { rm -f "$tmp"; return 1; } + [[ $(sed -n 's/^[[:space:]]*PROTECT:.*$/x/p' "$tmp" | wc -l) == 1 && + $(sed -n 's/^[[:space:]]*PROTECT:[[:space:]]*"Y"[[:space:]]*$/x/p' "$tmp" | wc -l) == 1 ]] || { + rm -f "$tmp" + return 1 + } + chmod 0640 "$tmp" || { rm -f "$tmp"; return 1; } + if ((EUID == 0)); then + chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" || { + rm -f "$tmp" + return 1 + } + fi + mv -fT -- "$tmp" "$COMPOSE_FILE" || { rm -f "$tmp"; return 1; } +} + +assert_compose_trusted() { + local owner expected mode + [[ -f $COMPOSE_FILE && ! -L $COMPOSE_FILE ]] || return 1 + owner=$(stat -Lc '%u' "$COMPOSE_FILE") || return 1 + mode=$(stat -Lc '%a' "$COMPOSE_FILE") || return 1 + expected=$(boundary_owner) + [[ $owner == "$expected" ]] && ! ((8#$mode & 022)) +} + +assert_mounts_safe() { + local storage shared needs_rewrite=0 mounts_prepared=0 + resolve_caller || return 1 + assert_compose_trusted || { + echo "blob-windows-vm: refusing an untrusted compose file" >&2 + return 1 + } + storage=$(get_mount_source /storage) + shared=$(get_mount_source /shared) + + [[ $(mount_source_count /storage) == 1 && $(mount_source_count /shared) == 1 ]] || { + echo "blob-windows-vm: refusing duplicate or missing VM mounts in the compose" >&2 + return 1 + } + + if compose_mount_pair_is_migratable "$storage" "$shared"; then + ((EUID == 0)) || { + echo "blob-windows-vm: legacy VM data needs an authorized migration" >&2 + return 1 + } + prepare_caller_mounts || return 1 + mounts_prepared=1 + needs_rewrite=1 + storage=$EXPECTED_STORAGE + shared=$EXPECTED_SHARED + fi + + [[ $storage == "$EXPECTED_STORAGE" && $shared == "$EXPECTED_SHARED" ]] || { + echo "blob-windows-vm: refusing unexpected host paths in the compose" >&2 + return 1 + } + + if ! compose_web_protected; then + ((EUID == 0)) || { + echo "blob-windows-vm: web-console protection needs an authorized migration" >&2 + return 1 + } + needs_rewrite=1 + fi + + if ((needs_rewrite)) && ! rewrite_compose_security; then + if ((mounts_prepared)); then rollback_new_caller_mounts || true; fi + return 1 + fi + + # Mounts disappear at reboot. Root recreates them from the already-opened, + # caller-owned sources; a docker-group invocation may proceed directly only + # while the exact pinned pair is still present. + if ((EUID == 0)); then + prepare_caller_mounts || return 1 + fi + mounts_ready || { + echo "blob-windows-vm: refusing an unsafe VM mount anchor" >&2 + return 1 + } +} + +__priv_up() { assert_mounts_safe && dc up -d; } + +__priv_down() { dc down; } + +# Bring the VM up and wait until the guest reports it is ready, all under a +# single elevation so the readiness poll does not prompt on every iteration. +__priv_up_wait() { + assert_mounts_safe || return 1 + local status + status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null) + if [[ $status != "running" ]]; then + dc up -d || return 1 + fi + + # docker logs persists across restarts, so anchor the scan to the current + # start time; an empty --since would match a stale "started successfully". + local started_at count=0 + while true; do + started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null) + if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then + return 0 + fi + sleep 2 + ((++count > 60)) && { + echo "Timeout: Windows VM did not report ready within 2 minutes" >&2 + return 1 + } + done +} + +# Print the status (empty if the container does not exist) and always succeed, +# so a non-zero exit from priv status means the elevation itself failed +# (authorization declined) rather than "no such container". +__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; } + +__priv_remove() { + # Rebuild/verify both pinned binds before deleting through the storage anchor. + # In particular, a legitimate ~/.windows symlink means deleting only the link + # from the user side would strand the virtual disk in its external target. + assert_mounts_safe || return 1 + [[ $EXPECTED_STORAGE == "$USERS_DIR/$CALLER_UID/storage" ]] || return 1 + [[ $EXPECTED_SHARED == "$USERS_DIR/$CALLER_UID/shared" ]] || return 1 + [[ $(mount_layer_count "$EXPECTED_STORAGE") == 1 && + $(mount_layer_count "$EXPECTED_SHARED") == 1 && + $(mount_descendant_count "$EXPECTED_STORAGE") == 0 && + $(mount_descendant_count "$EXPECTED_SHARED") == 0 ]] || { + echo "blob-windows-vm: refusing removal with unknown or stacked VM mounts" >&2 + return 1 + } + + dc down || { + echo "blob-windows-vm: could not stop the Windows VM; storage was not deleted" >&2 + return 1 + } + if docker inspect "$CONTAINER" >/dev/null 2>&1; then + echo "blob-windows-vm: Windows container still exists; storage was not deleted" >&2 + return 1 + fi + docker info >/dev/null 2>&1 || { + echo "blob-windows-vm: cannot verify Docker state; storage was not deleted" >&2 + return 1 + } + mounts_ready || { + echo "blob-windows-vm: VM mount identity changed during removal" >&2 + return 1 + } + removal_trees_disjoint || return 1 + + # find -xdev deliberately empties the verified disk source without crossing + # into another mounted filesystem. Shared files are never traversed. + find "$EXPECTED_STORAGE" -xdev -mindepth 1 -delete || return 1 + [[ -z $(find "$EXPECTED_STORAGE" -mindepth 1 -print -quit) ]] || return 1 + + # Release only the single known top mounts checked above. Unmount shared first + # so a storage-unmount failure cannot expose shared data to deletion. + umount -- "$EXPECTED_SHARED" || return 1 + umount -- "$EXPECTED_STORAGE" || return 1 + docker rmi "$IMAGE" 2>/dev/null || true + rm -f "$COMPOSE_FILE" + rmdir -- "$EXPECTED_STORAGE" "$EXPECTED_SHARED" "$CALLER_DATA_ROOT" 2>/dev/null || true +} + +# --- config helpers ---------------------------------------------------------- + +# Validate both familiar home entries before creating or changing either. A +# legitimate symlink is kept exactly as-is; only its caller-owned directory +# target is used. The privileged half repeats the ownership check on pinned FDs. +preflight_user_mount_source() { + local path="$1" label="$2" uid owner + uid=$(id -u) + if [[ -L $path ]]; then + [[ -d $path ]] || { + echo "blob-windows-vm: $label is a broken or non-directory symlink: $path" >&2 + return 1 + } + elif [[ -e $path ]]; then + [[ -d $path ]] || { + echo "blob-windows-vm: $label is not a directory: $path" >&2 + return 1 + } + else + return 0 + fi + owner=$(stat -Lc '%u' -- "$path") || return 1 + [[ $owner == "$uid" ]] || { + echo "blob-windows-vm: $label must be owned by uid $uid: $path" >&2 + return 1 + } +} + +prepare_user_mount_sources() { + local storage="$HOME/.windows" shared="$HOME/Windows" storage_id shared_id + preflight_user_mount_source "$storage" storage && + preflight_user_mount_source "$shared" shared || return 1 + [[ -e $storage || -L $storage ]] || install -d -m 0700 -- "$storage" || return 1 + [[ -e $shared || -L $shared ]] || install -d -m 0700 -- "$shared" || return 1 + storage_id=$(stat -Lc '%d:%i' -- "$storage") || return 1 + shared_id=$(stat -Lc '%d:%i' -- "$shared") || return 1 + [[ $storage_id != "$shared_id" ]] || { + echo "blob-windows-vm: storage and shared must be different directories" >&2 + return 1 + } + chmod 0700 -- "$storage" "$shared" +} + +storage_space_path() { + if [[ -d $HOME/.windows ]]; then + realpath -e -- "$HOME/.windows" + else + printf '%s\n' "$HOME" + fi +} + +available_storage_gb() { + local path + path=$(storage_space_path) || return 1 + df -P -- "$path" | awk 'NR==2 {print int($4/1024/1024)}' +} + +# Feed the collected settings to the elevated writer. +write_compose() { + local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" + printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\n' \ + "$ram" "$cores" "$disk" "$username" "$password" "$tz" | + priv write_compose +} + +# Reverse, in the opposite order, the escaping __priv_write_compose applied to +# the password: undo the interpolation layer ($$ -> $) first, then the YAML +# layer (\" -> ", then \\ -> \). +unescape() { + local v=$1 + v=${v//\$\$/\$} + v=${v//\\\"/\"} + v=${v//\\\\/\\} + printf '%s' "$v" +} + +# Store the RDP credentials privately for the user (0600) so the plaintext +# password is not world-readable. The password is one validated printable line +# (no newline), so plain KEY=VALUE is safe. +write_credentials() { + local username="$1" password="$2" old_umask dir tmp + dir=$(dirname -- "$CREDENTIALS_FILE") + mkdir -p "$dir" || return 1 + chmod 0700 "$dir" || return 1 + old_umask=$(umask) + umask 077 + tmp=$(mktemp "$dir/.credentials.XXXXXX") || { umask "$old_umask"; return 1; } + if ! printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$tmp" || + ! chmod 0600 "$tmp" || ! mv -fT -- "$tmp" "$CREDENTIALS_FILE"; then + rm -f -- "$tmp" + umask "$old_umask" + return 1 + fi + umask "$old_umask" +} + +# Read one field from the private credentials file; IFS on the first = keeps a +# password that itself contains =. +read_credential() { + local want="$1" key value + [[ -f $CREDENTIALS_FILE ]] || return 1 + while IFS='=' read -r key value; do + [[ $key == "$want" ]] && { + printf '%s' "$value" + return 0 + } + done <"$CREDENTIALS_FILE" + return 1 +} + +read_compose_value() { + local key="$1" file="$2" + sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1 +} + +# Older installs kept the compose under ~/.config/windows. Carry those settings +# into the root-owned location (preserving the VM's data via the same volume +# paths) so an upgrade does not strand or re-download an existing VM. +migrate_legacy_compose() { + [[ -f $COMPOSE_FILE ]] && return 0 + [[ -f $LEGACY_COMPOSE_FILE ]] || return 1 + + echo "Migrating Windows VM configuration to $COMPOSE_FILE ..." + local ram cores disk username password tz + ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE") + cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE") + disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE") + username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE") + password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE") + tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE") + [[ -z $tz ]] && tz="UTC" + prepare_user_mount_sources || { + echo "Could not validate the existing Windows VM data directories." >&2 + return 1 + } + write_credentials "$username" "$password" || return 1 + # The elevated writer derives both mount anchors from the authenticated uid; + # it never consumes volume paths from this user-owned legacy file. + if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz"; then + echo "Could not migrate the existing configuration automatically." >&2 + echo "Re-run: blob-windows-vm install" >&2 + return 1 + fi + rm -f "$LEGACY_COMPOSE_FILE" +} + +# --- prerequisites ----------------------------------------------------------- + +check_prerequisites() { + local DISK_SIZE_GB=${1:-64} + local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead + + # Check for KVM support + if [[ ! -e /dev/kvm ]]; then + gum style \ + --border normal \ + --padding "1 2" \ + --margin "1" \ + "❌ KVM virtualization not available!" \ + "" \ + "Please enable virtualization in BIOS or run:" \ + " sudo modprobe kvm-intel # for Intel CPUs" \ + " sudo modprobe kvm-amd # for AMD CPUs" + exit 1 + fi + + # Check disk space + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } + if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then + echo "❌ Insufficient disk space!" + echo " Available: ${AVAILABLE_SPACE}GB" + echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)" + exit 1 + fi +} + +# --- commands ---------------------------------------------------------------- + +install_windows() { + # Set up trap to handle Ctrl+C + trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT + + prepare_user_mount_sources || exit 1 + check_prerequisites + + blob-pkg-add freerdp openbsd-netcat gum + + mkdir -p "$HOME/.local/share/applications" + + cat </dev/null +[Desktop Entry] +Name=Windows +Comment=Start Windows VM via Docker and connect with RDP +Exec=uwsm app -- blob-windows-vm launch +Icon=windows +Terminal=false +Type=Application +Categories=System;Virtualization; +EOF + + # Get system resources + TOTAL_RAM=$(free -h | awk 'NR==2 {print $2}') + TOTAL_RAM_GB=$(awk 'NR==1 {printf "%d", $2/1024/1024}' /proc/meminfo) + TOTAL_CORES=$(nproc) + + echo "" + echo "System Resources Detected:" + echo " Total RAM: $TOTAL_RAM" + echo " Total CPU Cores: $TOTAL_CORES" + echo "" + + RAM_OPTIONS="" + for size in 2 4 8 16 32 64; do + if ((size <= TOTAL_RAM_GB)); then + RAM_OPTIONS="$RAM_OPTIONS ${size}G" + fi + done + + SELECTED_RAM=$(echo $RAM_OPTIONS | tr ' ' '\n' | gum choose --selected="4G" --header="How much RAM would you like to allocate to Windows VM?") + + # Check if user cancelled + if [[ -z $SELECTED_RAM ]]; then + echo "Installation cancelled by user" + exit 1 + fi + + SELECTED_CORES=$(gum input --placeholder="Number of CPU cores (1-$TOTAL_CORES)" --value="2" --header="How many CPU cores would you like to allocate to Windows VM?" --char-limit=2) + + # Check if user cancelled (Ctrl+C in gum input returns empty string) + if [[ -z $SELECTED_CORES ]]; then + echo "Installation cancelled by user" + exit 1 + fi + + if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then + echo "Invalid input. Using default: 2 cores" + SELECTED_CORES=2 + fi + + AVAILABLE_SPACE=$(available_storage_gb) || { + echo "❌ Could not determine available space for $HOME/.windows" >&2 + exit 1 + } + MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image + + # Check if we have enough space for minimum + if ((MAX_DISK_GB < 32)); then + echo "❌ Insufficient disk space for Windows VM!" + echo " Available: ${AVAILABLE_SPACE}GB" + echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)" + exit 1 + fi + + DISK_OPTIONS="" + for size in 32 64 128 256 512; do + if ((size <= MAX_DISK_GB)); then + DISK_OPTIONS="$DISK_OPTIONS ${size}G" + fi + done + + # Default to 64G if available, otherwise 32G + DEFAULT_DISK="64G" + if ! echo "$DISK_OPTIONS" | grep -q "64G"; then + DEFAULT_DISK="32G" + fi + + SELECTED_DISK=$(echo $DISK_OPTIONS | tr ' ' '\n' | gum choose --selected="$DEFAULT_DISK" --header="How much disk space would you like to give Windows VM? (64GB+ recommended)") + + # Check if user cancelled + if [[ -z $SELECTED_DISK ]]; then + echo "Installation cancelled by user" + exit 1 + fi + + # Extract just the number for prerequisite check + DISK_SIZE_NUM=$(echo "$SELECTED_DISK" | sed 's/G//') + + # Re-check prerequisites with selected disk size + check_prerequisites "$DISK_SIZE_NUM" + + # Prompt for username and password + USERNAME=$(gum input --placeholder="Username (Press enter to use default: docker)" --header="Enter Windows username:") + if [[ -z $USERNAME ]]; then + USERNAME="docker" + fi + if ! valid_username "$USERNAME"; then + echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker" + USERNAME="docker" + fi + + PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:") + if [[ -z $PASSWORD ]]; then + PASSWORD="admin" + PASSWORD_DISPLAY="(default)" + else + PASSWORD_DISPLAY="(user-defined)" + fi + if ! valid_password "$PASSWORD"; then + echo "Invalid password (printable characters, up to 64). Using default: admin" + PASSWORD="admin" + PASSWORD_DISPLAY="(default)" + fi + + # Display configuration summary + gum style \ + --border normal \ + --padding "1 2" \ + --margin "1" \ + --align left \ + --bold \ + "Windows VM Configuration" \ + "" \ + "RAM: $SELECTED_RAM" \ + "CPU: $SELECTED_CORES cores" \ + "Disk: $SELECTED_DISK" \ + "Username: $USERNAME" \ + "Password: $PASSWORD_DISPLAY" + + # Ask for confirmation + echo "" + if ! gum confirm "Proceed with this configuration?"; then + echo "Installation cancelled by user" + exit 1 + fi + + local tz + tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC) + + # Write the root-owned compose from the validated settings (one prompt if + # sudoless Docker is off). The writer pins the familiar home directories (or + # their legitimate symlink targets) into root-protected bind anchors. + write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \ + "$USERNAME" "$PASSWORD" "$tz" || { + echo "❌ Failed to write the Windows VM configuration." + exit 1 + } + write_credentials "$USERNAME" "$PASSWORD" || { + echo "❌ Failed to store private RDP credentials." >&2 + exit 1 + } + + echo "" + echo "Starting Windows VM installation..." + echo "This will download a Windows 11 image (may take 10-15 minutes)." + echo "" + echo "Monitor installation progress at: http://127.0.0.1:8006" + echo "" + + echo "Starting Windows VM with docker-compose..." + if ! priv up; then + echo "❌ Failed to start Windows VM!" + echo " Common issues:" + echo " - Docker daemon not running: sudo systemctl start docker" + echo " - Port already in use: check if another VM is running" + exit 1 + fi + + echo "" + echo "Windows VM is starting up!" + echo "" + echo "Opening browser to monitor installation..." + + # Open browser to monitor installation + sleep 3 + xdg-open "http://127.0.0.1:8006" + + echo "" + echo "Installation is running in the background." + echo "You can monitor progress at: http://127.0.0.1:8006" + echo "" + echo "Once finished, launch 'Windows' via Super + Space" + echo "" + echo "To stop the VM: blob-windows-vm stop" + echo "" +} + +remove_windows() { + if ! gum confirm --default=false "Remove Windows VM and delete all associated data?"; then + echo "Removal cancelled by user" + exit 1 + fi + + echo "Removing Windows VM..." + + if [[ ! -f $COMPOSE_FILE && -f $LEGACY_COMPOSE_FILE ]]; then + migrate_legacy_compose || { + echo "❌ Could not safely migrate the VM before removal." >&2 + exit 1 + } + fi + if [[ -f $COMPOSE_FILE ]]; then + priv remove || { + echo "❌ Windows VM removal stopped before user-side cleanup; inspect the VM data before retrying." >&2 + exit 1 + } + fi + + rm -f "$HOME/.local/share/applications/windows-vm.desktop" + rm -rf "$HOME/.config/windows" + rm -rf "$HOME/.windows" + + echo "" + echo "Windows VM removal completed!" +} + +launch_windows() { + KEEP_ALIVE=false + if [[ $1 = "--keep-alive" ]] || [[ $1 = "-k" ]]; then + KEEP_ALIVE=true + fi + + if ! migrate_legacy_compose; then + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured. Please run: blob-windows-vm install" + exit 1 + fi + fi + + # RDP credentials come from the private per-user file. Fall back to the compose + # only when it is readable (sudoless mode), reversing the writer's escaping. + WIN_USER=$(read_credential USERNAME) || WIN_USER="" + WIN_PASS=$(read_credential PASSWORD) || WIN_PASS="" + if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then + [[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")") + [[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")") + fi + [[ -z $WIN_USER ]] && WIN_USER="docker" + [[ -z $WIN_PASS ]] && WIN_PASS="admin" + + echo "Starting Windows VM (this may prompt for authorization)..." + if ! priv up_wait; then + echo "❌ Failed to start Windows VM!" + echo " Try checking: blob-windows-vm status" + blob-notify-send -u critical "Windows VM" "Failed to start Windows VM" + exit 1 + fi + + # Build the connection info + if [[ $KEEP_ALIVE = "true" ]]; then + LIFECYCLE="VM will keep running after RDP closes +To stop: blob-windows-vm stop" + else + LIFECYCLE="VM will auto-stop when RDP closes" + fi + + gum style \ + --border normal \ + --padding "1 2" \ + --margin "1" \ + --align center \ + "Connecting to Windows VM" \ + "" \ + "$LIFECYCLE" + + # FreeRDP 3 tries Kerberos before NTLM for NLA, and krb5 ships /etc/krb5.conf + # as the MIT sample with default_realm = ATHENA.MIT.EDU. Every connect then + # goes looking for MIT's KDC: with internet up it fails fast, but off the + # network each attempt blocks ~23s and no RDP window is ever drawn. The VM + # authenticates against a local Windows account, so point FreeRDP at a + # realm-less config and let it fall straight through to NTLM. + KRB5_CONF="$HOME/.config/windows/krb5.conf" + mkdir -p "$(dirname "$KRB5_CONF")" + if [[ ! -f $KRB5_CONF ]]; then + printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF" + fi + export KRB5_CONFIG="$KRB5_CONF" + + # Detect display scale from Hyprland + HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale') + SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}') + + RDP_SCALE="" + if ((SCALE_PERCENT >= 170)); then + RDP_SCALE="/scale:180" + elif ((SCALE_PERCENT >= 130)); then + RDP_SCALE="/scale:140" + fi + # If scale is less than 130%, don't set any scale (use default 100) + + # Connect with RDP in fullscreen (auto-detects resolution) + xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Blob" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE + + # After RDP closes, stop the container unless --keep-alive was specified + if [[ $KEEP_ALIVE = "false" ]]; then + echo "" + echo "RDP session closed. Stopping Windows VM..." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?)." + echo " It may still be running. Stop it with: blob-windows-vm stop" + fi + else + echo "" + echo "RDP session closed. Windows VM is still running." + echo "To stop it: blob-windows-vm stop" + fi +} + +stop_windows() { + migrate_legacy_compose 2>/dev/null || true + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured." + exit 1 + fi + + echo "Stopping Windows VM..." + if priv down; then + echo "Windows VM stopped." + else + echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running." + exit 1 + fi +} + +status_windows() { + migrate_legacy_compose 2>/dev/null || true + if [[ ! -f $COMPOSE_FILE ]]; then + echo "Windows VM not configured." + echo "To set up: blob-windows-vm install" + exit 1 + fi + + if ! CONTAINER_STATUS=$(priv status); then + echo "Could not query the Windows VM (authorization declined?)." + echo "To try again: blob-windows-vm status" + exit 1 + fi + + if [[ -z $CONTAINER_STATUS ]]; then + echo "Windows VM container not found." + echo "To start: blob-windows-vm launch" + elif [[ $CONTAINER_STATUS = "running" ]]; then + gum style \ + --border normal \ + --padding "1 2" \ + --margin "1" \ + --align left \ + "Windows VM Status: RUNNING" \ + "" \ + "Web interface: http://127.0.0.1:8006" \ + "RDP available: port 3389" \ + "" \ + "To connect: blob-windows-vm launch" \ + "To stop: blob-windows-vm stop" + else + echo "Windows VM is stopped (status: $CONTAINER_STATUS)" + echo "To start: blob-windows-vm launch" + fi +} + +show_usage() { + echo "Usage: blob-windows-vm [command] [options]" + echo "" + echo "Commands:" + echo " install Install and configure Windows VM" + echo " remove Remove Windows VM and optionally its data" + echo " launch [options] Start Windows VM (if needed) and connect via RDP" + echo " Options:" + echo " --keep-alive, -k Keep VM running after RDP closes" + echo " stop Stop the running Windows VM" + echo " status Show current VM status" + echo " help Show this help message" + echo "" + echo "Examples:" + echo " blob-windows-vm install # Set up Windows VM for first time" + echo " blob-windows-vm launch # Connect to VM (auto-stop on exit)" + echo " blob-windows-vm launch -k # Connect to VM (keep running)" + echo " blob-windows-vm stop # Shut down the VM" +} + +# Main command dispatcher +case "$1" in +__priv) + ((EUID == 0)) || { + echo "blob-windows-vm __priv must run as root" >&2 + exit 1 + } + action="$2" + shift 2 + valid_priv_action "$action" || { + echo "blob-windows-vm: unknown privileged action" >&2 + exit 1 + } + with_vm_lock "__priv_${action}" "$@" + ;; +install) + install_windows + ;; +remove) + remove_windows + ;; +launch | start) + launch_windows "$2" + ;; +stop | down) + stop_windows + ;; +status) + status_windows + ;; +help | --help | -h | "") + show_usage + ;; +*) + echo "Unknown command: $1" >&2 + echo "" >&2 + show_usage >&2 + exit 1 + ;; +esac diff --git a/default/blob/blob-menu.jsonc b/default/blob/blob-menu.jsonc index 37aafd9..a09d5d6 100644 --- a/default/blob/blob-menu.jsonc +++ b/default/blob/blob-menu.jsonc @@ -24,6 +24,88 @@ "setup": {"icon":"","label":"Setup","aliases":["settings"]}, "install": {"icon":"󰉉","label":"Install"}, "remove": {"icon":"󰭌","label":"Remove","aliases":["uninstall"]}, + "install.development": {"icon":"󰵮","label":"Development"}, + "install.browser": {"icon":"","label":"Browser"}, + "install.gaming": {"icon":"","label":"Gaming"}, + "install.windows": {"icon":"󰍲","label":"Windows","when":"[[ ! -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"blob-launch-floating 'blob-windows-vm install'"}, + "install.browser.chrome": {"icon":"","label":"Chrome","when":"! blob-pkg-present google-chrome","action":"blob-launch-floating 'blob-install-browser chrome'"}, + "install.browser.edge": {"icon":"󰇩","label":"Edge","when":"! blob-pkg-present microsoft-edge-stable-bin","action":"blob-launch-floating 'blob-install-browser edge'"}, + "install.browser.brave": {"icon":"","label":"Brave","when":"! blob-pkg-present brave-bin","action":"blob-launch-floating 'blob-install-browser brave'"}, + "install.browser.brave-origin": {"icon":"","label":"Brave Origin","when":"! blob-pkg-present brave-origin-bin","action":"blob-launch-floating 'blob-install-browser brave-origin'"}, + "install.browser.firefox": {"icon":"","label":"Firefox","when":"! blob-pkg-present firefox","action":"blob-launch-floating 'blob-install-browser firefox'"}, + "install.browser.zen": {"icon":"󰖟","label":"Zen","when":"! blob-pkg-present zen-browser-bin","action":"blob-launch-floating 'blob-install-browser zen'"}, + "install.gaming.steam": {"icon":"","label":"Steam","when":"! blob-pkg-present steam","action":"blob-launch-floating blob-install-gaming-steam"}, + "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","when":"! blob-pkg-present retroarch","action":"blob-launch-floating blob-install-gaming-retroarch"}, + "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"! blob-pkg-present minecraft-launcher","action":"blob-install-launch Minecraft minecraft-launcher minecraft-launcher"}, + "install.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","when":"! flatpak info com.nvidia.geforcenow","action":"blob-launch-floating blob-install-gaming-geforce-now"}, + "install.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","when":"[[ ! -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"blob-launch-floating blob-install-gaming-xbox-cloud"}, + "install.gaming.xbox-controllers": {"icon":"󰂯","label":"Xbox Controllers","when":"! blob-pkg-present xpadneo-dkms","action":"blob-launch-floating blob-install-gaming-xbox-controllers"}, + "install.gaming.battlenet": {"icon":"","label":"Battle.net","when":"[[ ! -d $HOME/Games/battlenet ]]","action":"blob-launch-floating blob-install-gaming-battlenet"}, + "install.gaming.lutris": {"icon":"","label":"Lutris","when":"! blob-pkg-present lutris","action":"blob-launch-floating blob-install-gaming-lutris"}, + "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","when":"! blob-pkg-present heroic-games-launcher-bin","action":"blob-launch-floating blob-install-gaming-heroic"}, + "install.gaming.retro-launcher": {"icon":"󰯉","label":"RetroArch Game Launcher","action":"blob-games-retro-install"}, + "install.development.rails": {"icon":"󰫏","label":"Ruby on Rails","when":"[[ ! -d $HOME/.local/share/mise/installs/ruby ]]","action":"blob-launch-floating 'blob-install-dev-env ruby'"}, + "install.development.docker-dbs": {"icon":"","label":"Docker DB","action":"blob-launch-floating blob-install-docker-dbs"}, + "install.development.javascript": {"icon":"","label":"JavaScript"}, + "install.development.go": {"icon":"","label":"Go","when":"[[ ! -d $HOME/.local/share/mise/installs/go ]]","action":"blob-launch-floating 'blob-install-dev-env go'"}, + "install.development.php": {"icon":"","label":"PHP"}, + "install.development.python": {"icon":"","label":"Python","when":"[[ ! -d $HOME/.local/share/mise/installs/python ]]","action":"blob-launch-floating 'blob-install-dev-env python'"}, + "install.development.elixir": {"icon":"","label":"Elixir"}, + "install.development.zig": {"icon":"","label":"Zig","when":"[[ ! -d $HOME/.local/share/mise/installs/zig ]]","action":"blob-launch-floating 'blob-install-dev-env zig'"}, + "install.development.rust": {"icon":"","label":"Rust","when":"[[ ! -d $HOME/.rustup ]]","action":"blob-launch-floating 'blob-install-dev-env rust'"}, + "install.development.java": {"icon":"","label":"Java","when":"[[ ! -d $HOME/.local/share/mise/installs/java ]]","action":"blob-launch-floating 'blob-install-dev-env java'"}, + "install.development.dotnet": {"icon":"","label":".NET","when":"[[ ! -d $HOME/.local/share/mise/installs/dotnet ]]","action":"blob-launch-floating 'blob-install-dev-env dotnet'"}, + "install.development.ocaml": {"icon":"","label":"OCaml","when":"[[ ! -d $HOME/.opam ]]","action":"blob-launch-floating 'blob-install-dev-env ocaml'"}, + "install.development.clojure": {"icon":"","label":"Clojure","when":"[[ ! -d $HOME/.local/share/mise/installs/clojure ]]","action":"blob-launch-floating 'blob-install-dev-env clojure'"}, + "install.development.scala": {"icon":"","label":"Scala","when":"[[ ! -d $HOME/.local/share/mise/installs/scala ]]","action":"blob-launch-floating 'blob-install-dev-env scala'"}, + "install.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ ! -d $HOME/.local/share/mise/installs/node ]]","action":"blob-launch-floating 'blob-install-dev-env node'"}, + "install.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ ! -d $HOME/.local/share/mise/installs/bun ]]","action":"blob-launch-floating 'blob-install-dev-env bun'"}, + "install.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ ! -d $HOME/.local/share/mise/installs/deno ]]","action":"blob-launch-floating 'blob-install-dev-env deno'"}, + "install.development.php.php": {"icon":"","label":"PHP","when":"! blob-pkg-present php","action":"blob-launch-floating 'blob-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ ! -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"blob-launch-floating 'blob-install-dev-env laravel'"}, + "install.development.php.symfony": {"icon":"","label":"Symfony","when":"! blob-pkg-present symfony-cli","action":"blob-launch-floating 'blob-install-dev-env symfony'"}, + "install.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ ! -d $HOME/.local/share/mise/installs/elixir ]]","action":"blob-launch-floating 'blob-install-dev-env elixir'"}, + "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"! compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"blob-launch-floating 'blob-install-dev-env phoenix'"}, + "remove.development": {"icon":"󰵮","label":"Development","title":"Remove"}, + "remove.browser": {"icon":"","label":"Browser","title":"Remove"}, + "remove.gaming": {"icon":"","label":"Gaming","title":"Remove"}, + "remove.windows": {"icon":"󰍲","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"blob-launch-floating 'blob-windows-vm remove'"}, + "remove.browser.chrome": {"icon":"","label":"Chrome","when":"blob-pkg-present google-chrome","action":"blob-launch-floating 'blob-remove-browser chrome'"}, + "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"blob-pkg-present microsoft-edge-stable-bin","action":"blob-launch-floating 'blob-remove-browser edge'"}, + "remove.browser.brave": {"icon":"","label":"Brave","when":"blob-pkg-present brave-bin","action":"blob-launch-floating 'blob-remove-browser brave'"}, + "remove.browser.brave-origin": {"icon":"","label":"Brave Origin","when":"blob-pkg-present brave-origin-bin","action":"blob-launch-floating 'blob-remove-browser brave-origin'"}, + "remove.browser.firefox": {"icon":"","label":"Firefox","when":"blob-pkg-present firefox","action":"blob-launch-floating 'blob-remove-browser firefox'"}, + "remove.browser.zen": {"icon":"","label":"Zen","when":"blob-pkg-present zen-browser-bin","action":"blob-launch-floating 'blob-remove-browser zen'"}, + "remove.gaming.steam": {"icon":"","label":"Steam","when":"blob-pkg-present steam","action":"blob-launch-floating blob-remove-gaming-steam"}, + "remove.gaming.retroarch": {"icon":"","label":"RetroArch","when":"blob-pkg-present retroarch","action":"blob-launch-floating blob-remove-gaming-retroarch"}, + "remove.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"blob-pkg-present minecraft-launcher","action":"blob-launch-floating blob-remove-gaming-minecraft"}, + "remove.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","when":"flatpak info com.nvidia.geforcenow","action":"blob-launch-floating blob-remove-gaming-geforce-now"}, + "remove.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","when":"[[ -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"blob-launch-floating blob-remove-gaming-xbox-cloud"}, + "remove.gaming.xbox-controllers": {"icon":"󰖺","label":"Xbox Controllers (󰂯)","when":"blob-pkg-present xpadneo-dkms","action":"blob-launch-floating blob-remove-gaming-xbox-controllers"}, + "remove.gaming.battlenet": {"icon":"","label":"Battle.net","when":"[[ -d $HOME/Games/battlenet ]]","action":"blob-launch-floating blob-remove-gaming-battlenet"}, + "remove.gaming.lutris": {"icon":"","label":"Lutris","when":"blob-pkg-present lutris","action":"blob-launch-floating blob-remove-gaming-lutris"}, + "remove.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","when":"blob-pkg-present heroic-games-launcher-bin","action":"blob-launch-floating blob-remove-gaming-heroic"}, + "remove.development.rails": {"icon":"󰫏","label":"Ruby on Rails","when":"[[ -d $HOME/.local/share/mise/installs/ruby ]]","action":"blob-launch-floating 'blob-remove-dev-env ruby'"}, + "remove.development.javascript": {"icon":"","label":"JavaScript","title":"Remove"}, + "remove.development.go": {"icon":"","label":"Go","when":"[[ -d $HOME/.local/share/mise/installs/go ]]","action":"blob-launch-floating 'blob-remove-dev-env go'"}, + "remove.development.php": {"icon":"","label":"PHP","title":"Remove"}, + "remove.development.python": {"icon":"","label":"Python","when":"[[ -d $HOME/.local/share/mise/installs/python ]]","action":"blob-launch-floating 'blob-remove-dev-env python'"}, + "remove.development.elixir": {"icon":"","label":"Elixir","title":"Remove"}, + "remove.development.zig": {"icon":"","label":"Zig","when":"[[ -d $HOME/.local/share/mise/installs/zig ]]","action":"blob-launch-floating 'blob-remove-dev-env zig'"}, + "remove.development.rust": {"icon":"","label":"Rust","when":"[[ -d $HOME/.rustup ]]","action":"blob-launch-floating 'blob-remove-dev-env rust'"}, + "remove.development.java": {"icon":"","label":"Java","when":"[[ -d $HOME/.local/share/mise/installs/java ]]","action":"blob-launch-floating 'blob-remove-dev-env java'"}, + "remove.development.dotnet": {"icon":"","label":".NET","when":"[[ -d $HOME/.local/share/mise/installs/dotnet ]]","action":"blob-launch-floating 'blob-remove-dev-env dotnet'"}, + "remove.development.ocaml": {"icon":"","label":"OCaml","when":"[[ -d $HOME/.opam ]]","action":"blob-launch-floating 'blob-remove-dev-env ocaml'"}, + "remove.development.clojure": {"icon":"","label":"Clojure","when":"[[ -d $HOME/.local/share/mise/installs/clojure ]]","action":"blob-launch-floating 'blob-remove-dev-env clojure'"}, + "remove.development.scala": {"icon":"","label":"Scala","when":"[[ -d $HOME/.local/share/mise/installs/scala ]]","action":"blob-launch-floating 'blob-remove-dev-env scala'"}, + "remove.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"blob-launch-floating 'blob-remove-dev-env node'"}, + "remove.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"blob-launch-floating 'blob-remove-dev-env bun'"}, + "remove.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"blob-launch-floating 'blob-remove-dev-env deno'"}, + "remove.development.php.php": {"icon":"","label":"PHP","when":"blob-pkg-present php","action":"blob-launch-floating 'blob-remove-dev-env php'"}, + "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"blob-launch-floating 'blob-remove-dev-env laravel'"}, + "remove.development.php.symfony": {"icon":"","label":"Symfony","when":"blob-pkg-present symfony-cli","action":"blob-launch-floating 'blob-remove-dev-env symfony'"}, + "remove.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"blob-launch-floating 'blob-remove-dev-env elixir'"}, + "remove.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"blob-launch-floating 'blob-remove-dev-env phoenix'"}, "update": {"icon":"","label":"Update","aliases":["restart","refresh"]}, "about": {"icon":"","label":"About","action":"blob-launch-about"}, "system": {"icon":"","label":"System","aliases":["power-menu"]}, diff --git a/docs/commands.md b/docs/commands.md index 27e8906..b567c93 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -91,6 +91,13 @@ of the `blob` listing. | `blob-capture-webcam-list` | List webcam devices that support video capture (hidden) | - | | `blob-capture-webcam-resize` | Resize the active webcam recording overlay | | +## chromium + +| Command | Does | Arguments | +| --- | --- | --- | +| `blob-chromium-copy-url-host` | Native messaging host: copy a Chromium tab URL to the clipboard (hidden) | - | +| `blob-chromium-ytdlp-host` | Native messaging host: download the URL sent by the yt-dlp Chromium extension (hidden) | - | + ## clipboard | Command | Does | Arguments | @@ -163,6 +170,13 @@ of the `blob` listing. | `blob-font-list` | List available monospace fonts | - | | `blob-font-set` | Set the system monospace font | | +## games + +| Command | Does | Arguments | +| --- | --- | --- | +| `blob-games-retro-cores` | List installed RetroArch core names | - | +| `blob-games-retro-install` | Create a desktop launcher for a RetroArch game | [core path-to-game] | + ## git | Command | Does | Arguments | @@ -235,7 +249,21 @@ of the `blob` listing. | Command | Does | Arguments | | --- | --- | --- | +| `blob-install-browser` | Install a supported browser | | +| `blob-install-chromium-copy-url` | Install the native messaging host for the Copy URL Chromium extension | - | +| `blob-install-chromium-ytdlp` | Install the native messaging host for the yt-dlp Chromium extension | - | +| `blob-install-dev-env` | Install a supported development environment | | +| `blob-install-docker-dbs` | Install one of the supported databases in a Docker container with the suitable development options. | - | | `blob-install-font` | Install a Nerd Font package and switch the system to it | | +| `blob-install-gaming-battlenet` | Install Battle.net standalone via umu-launcher + GE-Proton (no Steam, no Lutris, no Heroic). | - | +| `blob-install-gaming-geforce-now` | Install and launch Geforce Now. | - | +| `blob-install-gaming-gpu-lib32` | Install lib32 graphics drivers (Vulkan + NVIDIA) for any detected GPUs. | - | +| `blob-install-gaming-heroic` | Install Heroic Games Launcher (Epic, GOG, Amazon Prime Gaming) with graphics drivers. | - | +| `blob-install-gaming-lutris` | Install Lutris with Wine + DXVK for running Windows games (Battle.net, EA, Ubisoft Connect, etc.) | - | +| `blob-install-gaming-retroarch` | Install RetroArch with the full libretro core set plus FBNeo and a ~/Games ROM directory. | - | +| `blob-install-gaming-steam` | Install Steam and graphics drivers selected for this system | - | +| `blob-install-gaming-xbox-cloud` | Install Xbox Cloud Gaming as a web app and launch it. | - | +| `blob-install-gaming-xbox-controllers` | Install support for using Xbox controllers with Steam/RetroArch/etc. | - | | `blob-install-launch` | Install a packaged app and launch it once it finishes | | ## launch @@ -243,6 +271,7 @@ of the `blob` listing. | Command | Does | Arguments | | --- | --- | --- | | `blob-launch-about` | Launch the fastfetch TUI that gives information about the current system. | - | +| `blob-launch-battlenet` | Launch the installed Battle.net client via umu-launcher + GE-Proton. | [--with-mangohud] | | `blob-launch-browser` | Launch the default browser as determined by xdg-settings. | [url] | | `blob-launch-config-editor` | Open a config file in the user's editor and surface a toast | | | `blob-launch-docker-tui` | Open the Docker TUI (lazydocker) with access to the Docker daemon (hidden) | - | @@ -261,9 +290,11 @@ of the `blob` listing. | `blob-launch-floating` | Launch a floating terminal with the Blob presentation wrapper | | | `blob-launch-or-focus` | Launch an app or focus an existing window matching a pattern | | | `blob-launch-or-focus-tui` | Launch a TUI or focus an existing terminal window for it | [--app-id=] [args...] | +| `blob-launch-or-focus-webapp` | Launch or focus on a given web app identified by the window-pattern. | | | `blob-launch-screensaver` | Launch the Blob screensaver in the default terminal on the system with the correct font configuration. | - | | `blob-launch-shell` | Launch the Blob shell with its log kept in the journal (hidden) | - | | `blob-launch-tui` | Launch a TUI command in the default terminal with Blob styling | [--app-id=] [args...] | +| `blob-launch-webapp` | Launch a URL as a web app in the default supported browser | | ## menu @@ -321,6 +352,7 @@ of the `blob` listing. | --- | --- | --- | | `blob-pkg-add` | Install Arch packages if they are missing | | | `blob-pkg-aur` | Returns true if the AUR is up and available. | - | +| `blob-pkg-aur-add` | Add the named packages to the system from the AUR if they're missing. Returns false if it couldn't be done. | | | `blob-pkg-aur-install` | Show a fuzzy-finder TUI for picking new AUR packages to install. | - | | `blob-pkg-drop` | Remove all the named packages from the system if they're installed (otherwise ignore). | | | `blob-pkg-install` | Show a fuzzy-finder TUI for picking new Arch and OPR packages to install. | - | @@ -384,6 +416,17 @@ of the `blob` listing. | Command | Does | Arguments | | --- | --- | --- | +| `blob-remove-browser` | Remove a supported browser and clean up Blob browser defaults | | +| `blob-remove-dev-env` | Remove a development environment that was previously installed via blob-install-dev-env. | | +| `blob-remove-gaming-battlenet` | Remove Battle.net, its Proton prefix, installed games, and desktop entry. | - | +| `blob-remove-gaming-geforce-now` | Remove the GeForce NOW Flatpak app and its data. | - | +| `blob-remove-gaming-heroic` | Remove Heroic Games Launcher and its game libraries, configs, and caches. | - | +| `blob-remove-gaming-lutris` | Remove Lutris, Wine, umu-launcher, and all their configs and caches. | - | +| `blob-remove-gaming-minecraft` | Remove the Minecraft launcher along with its worlds, mods, and caches. | - | +| `blob-remove-gaming-retroarch` | Remove RetroArch, all libretro cores, and its config/saves. Leaves ~/Games/roms and ~/Games/bios alone. | - | +| `blob-remove-gaming-steam` | Remove Steam and all of its game libraries, configs, and caches. | - | +| `blob-remove-gaming-xbox-cloud` | Remove the Xbox Cloud Gaming web app. | - | +| `blob-remove-gaming-xbox-controllers` | Remove the xpadneo Xbox controller driver and undo its module/blacklist config. | - | | `blob-remove-security-fido2` | Remove FIDO2 authentication from sudo and polkit | - | | `blob-remove-security-sudoless-docker` | Disable sudoless Docker by removing your user from the docker group | - | @@ -554,6 +597,21 @@ of the `blob` listing. | `blob-weather-location` | Show or set the location used for weather reports | - | | `blob-weather-status` | Returns a formatted weather status string with temperature and wind speed. | - | +## webapp + +| Command | Does | Arguments | +| --- | --- | --- | +| `blob-webapp-handler-hey` | Open HEY webmail and translate mailto links | [url] | +| `blob-webapp-handler-zoom` | Open Zoom web meetings from browser protocol links | [url] | +| `blob-webapp-install` | Create a desktop launcher for a web app | [name url icon-url-or-name [custom-exec] [mime-types]] | +| `blob-webapp-remove` | Remove a web app desktop launcher | [name] | + +## windows + +| Command | Does | Arguments | +| --- | --- | --- | +| `blob-windows-vm` | Install, launch, stop, inspect, or remove the Windows VM | [options] | + ## Totals -252 commands. +290 commands. diff --git a/docs/menu.md b/docs/menu.md index 0028bfa..1ddd6ae 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -8,12 +8,12 @@ menu fork carried is gone. ## Trimmed from upstream -Upstream ships 338 rows. This tree has 178. What came out: +Upstream ships 338 rows. This tree has 260. What came out: | Section | Dropped | Why | | --- | --- | --- | -| `install.*` | 84 of 90 | the software catalogue: browsers, gaming, dev languages, AI tools, services. Kept: Package, AUR, TUI, and the Style subtree (theme, background, font) | -| `remove.*` | 56 of 59 | same catalogue in reverse. Kept: Package, TUI, Theme | +| `install.*` | 35 of 91 | AI tools, editors, terminals, services, web apps, preinstalls. Browsers, gaming, dev languages and the Windows VM were restored on request | +| `remove.*` | 16 of 60 | the same set in reverse, plus security and dictation removal | | `setup.default.agent.*` | 14 | AI agent defaults | | `update.channel.*` | 5 | release channels for a package that will not exist | | `learn.*` | 4 | Omarchy's manual and Discord, plus tmux and herdr keybindings | @@ -27,8 +27,8 @@ which also keeps them working whichever way the web app question lands. ## Commands the menu still needs -The tree references 95 commands. All of them are now in `bin/`. What follows is -kept as the record of what Phase 6 had to fill: +Every command the tree references is in `bin/`. What follows is the record of +what Phase 6 had to fill: - **audio**: `blob-audio-restart` - **bar**: `blob-bar` @@ -78,3 +78,35 @@ Every row in this tree resolves to a command in `bin/`. The only unbacked `hypr/default/helpers.lua` and reachable only from a binding that uses `{ webapp = ... }`. No such binding ships. They stay as the hook for whichever way the web app question lands. + +## Restored install and remove subtrees + +Browsers, gaming, dev languages and the Windows VM came back after the first +pass cut them, which took 82 rows and 38 commands with them. + +| Subtree | Rows | Covers | +| --- | --- | --- | +| Browsers | 14 | Chrome, Edge, Brave, Brave Origin, Firefox, Zen, install and remove | +| Gaming | 20 | Steam, RetroArch, Minecraft, Lutris, Heroic, GeForce NOW, Xbox Cloud, Xbox controllers, Battle.net | +| Development | 44 | Ruby on Rails, JavaScript (Node/Bun/Deno), Go, PHP (+ Laravel, Symfony), Python, Elixir (+ Phoenix), Zig, Rust, Java, .NET, OCaml, Clojure, Scala | +| Windows VM | 2 | install and remove | + +### This settled the web app question + +Xbox Cloud Gaming and GeForce NOW are web apps, not packages. Restoring gaming +therefore pulled in `blob-webapp-install`, `blob-webapp-remove`, +`blob-launch-webapp` and `blob-launch-or-focus-webapp`, and those fall back to +`chromium.desktop` for any browser that is not Chrome, Brave, Edge, Opera, +Vivaldi or Helium. Zen is Firefox-based. + +So chromium stays, as the web app runtime. The `webapp` helper in +`hypr/default/helpers.lua` is live again rather than a dangling hook, though no +binding ships that uses it. The `install.webapp` and `remove.webapp` menu rows +are still out; the machinery behind them now works, so adding them back is a +two-line change if you want to create web app launchers from the menu. + +### Still out + +AI tools, editors, terminals, services (1Password, Dropbox, Spotify, Signal, +Tailscale, NordVPN, ONCE, Bitwarden), preinstalls, dictation, and the security +removal rows. Say the word on any of them.