diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2e9a3140fa..d353097d72 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Suggestion - url: https://github.com/basecamp/omarchy/discussions/categories/suggestions + url: https://github.com/omacom/omarchy/discussions/categories/suggestions about: Suggest a new feature, change to existing feature, or other ideas in Discussions. - name: Support url: https://omarchy.org/discord diff --git a/bin/omarchy-agent b/bin/omarchy-agent index e35b6dfabf..e35bcf04ad 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -74,11 +74,6 @@ crush) command=(crush --yolo) fi ;; -cursor-agent) - # --yolo skips command approvals; --trust skips the workspace trust prompt. - command=(cursor-agent --yolo --trust) - [[ -n ${prompt:-} ]] && command+=("$prompt") - ;; claude) command=(claude --permission-mode auto) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") @@ -100,6 +95,14 @@ codex) command=(codex --approve-for-me) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +cursor-agent) + # --yolo covers commands only; the workspace trust dialog has its own flag. + # A one-word prompt naming a subcommand (update, login, help) still runs that + # subcommand after a bare --, so the agent subcommand is named outright, and + # -- after it keeps a prompt starting with a dash from being read as an option. + command=(cursor-agent --yolo --trust) + [[ -n ${prompt:-} ]] && command+=(agent -- "$prompt") + ;; hermes) if [[ -n ${prompt:-} ]]; then command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt") @@ -107,6 +110,11 @@ hermes) command=(hermes --yolo) fi ;; +muse) + # --approval-mode never skips the tool prompts but keeps Muse's own sandbox. + command=(muse --approval-mode never) + [[ -n ${prompt:-} ]] && command+=(-- "$prompt") + ;; omp) command=(omp --auto-approve) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") diff --git a/bin/omarchy-apply-lock b/bin/omarchy-apply-lock index 9b97c0db69..5bb261cbe0 100755 --- a/bin/omarchy-apply-lock +++ b/bin/omarchy-apply-lock @@ -6,6 +6,12 @@ set -e +# Install and upgrade callers can start this helper as root. Ignore their PATH +# so optional commands never fall through to a user-writable directory. +if (( EUID == 0 )); then + export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +fi + target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}} if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) @@ -34,7 +40,8 @@ auth required pam_faillock.so authsucc account include system-local-login EOF -if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then +if [[ -x /usr/bin/fprintd-list ]] && + /usr/bin/fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then echo "Configuring lock screen fingerprint authentication..." as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 diff --git a/bin/omarchy-bar-text-color b/bin/omarchy-bar-text-color index a2ce8cdef1..35cd0eadd2 100755 --- a/bin/omarchy-bar-text-color +++ b/bin/omarchy-bar-text-color @@ -109,7 +109,10 @@ right) ;; esac -pixel=$(magick "$background_path" -auto-orient \ +# Sample the first frame only. Without the selector a video background makes +# ImageMagick decode the whole file and emit one value per frame, and the match +# below then fails into the fallback colour. +pixel=$(magick "$background_path[0]" -auto-orient \ -resize "${screen_width}x${screen_height}^" \ -gravity center -extent "${screen_width}x${screen_height}" \ -gravity NorthWest -crop "$crop" +repage \ diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 237108f09c..239bf74b59 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Set and launch the default coding agent -# omarchy:args=[pi|omp|opencode|ori|claude|codex|cursor|grok|openclaw|agy|hermes|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|cursor|grok|openclaw|agy|hermes|copilot|crush|muse] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -37,8 +37,13 @@ openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-op agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;; hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;; copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;; +muse | muse-code | musecode) + agent="muse"; name="Muse Code" + # Meta's launcher verifies and updates the native binary for this platform. + agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" + ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac @@ -77,8 +82,15 @@ else agent_install() { "$agent_installer" --now; } install_failure="Could not install $name" else - agent_present() { mise where "$agent_package" &>/dev/null; } - agent_install() { mise use -g "$agent_package"; } + # Anything at the wrapper's path other than the wrapper is the user's own + # install, such as the symlink Cursor's installer leaves. A mise copy would + # only shadow it, since the mise shims precede ~/.local/bin on PATH. + user_install() { + [[ -x $HOME/.local/bin/$agent ]] && + { [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; } + } + agent_present() { user_install || mise where "$agent_package" &>/dev/null; } + agent_install() { user_install || mise use -g "$agent_package"; } install_failure="Could not install $name with mise" fi diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index 3f6abafe7d..fb1411c0b9 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -21,6 +21,12 @@ omarchy-install-hermes-cli || true echo "Opening Hermes Desktop..." setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & +# Only a running Hermes can be told which skin to show, and the first launch +# takes minutes; a unit outlives this terminal and reports to the journal. +echo "Matching Hermes to the current theme once it is set up..." +systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true +systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait + echo "" echo "Hermes Desktop has been installed." echo "Its first launch installs the Hermes runtime, which takes a few minutes." diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 8c31646200..8305cb0fdd 100755 --- a/bin/omarchy-menu-images +++ b/bin/omarchy-menu-images @@ -75,8 +75,9 @@ fi selection_file=$(mktemp) done_file=$(mktemp) pending_file=$(mktemp) +pending_video_file=$(mktemp) rm -f "$done_file" -trap 'rm -f "$selection_file" "$done_file" "$pending_file"' EXIT +trap 'rm -f "$selection_file" "$done_file" "$pending_file" "$pending_video_file"' EXIT image_dirs_env="" for dir in "${image_dirs[@]}"; do @@ -111,8 +112,8 @@ cache_key=$(printf '%s' "$image_dirs_env" | md5sum | cut -d ' ' -f 1) rows_cache_file="$cache_dir/$cache_key.rows" rows_signature_file="$cache_dir/$cache_key.signature" rows_fast_signature_file="$cache_dir/$cache_key.fast-signature" -rows_signature="v3"$'\n' -rows_fast_signature="v2"$'\n' +rows_signature="v4"$'\n' +rows_fast_signature="v3"$'\n' rows_cacheable=true rows_cache_hit=false image_files=() @@ -133,17 +134,25 @@ else image_files+=("$image") image_signature=$(stat -Lc '%s:%Y' "$image") || continue rows_signature+="$image:$image_signature"$'\n' - done < <(find -L "$dir" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print0 2>/dev/null | sort -z) + done < <(find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null | sort -z) fi done fi +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + generate_thumbnail() { local image="$1" local thumbnail="$2" local lock="$thumbnail.lock" local lock_fd local tmp="$thumbnail.$$.jpg" + local thumbnail_command # Older releases used directories as locks, which could survive a killed # generator and block this thumbnail forever. Only reap aged ones, so a @@ -161,13 +170,29 @@ generate_thumbnail() { [[ -f $thumbnail ]] && return - # Callers fan out one generator per image, so keep each vips single-threaded. - # Close the lock fd for vips: an orphaned or hung vips must not keep holding - # the lock after this shell is killed. - if VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]" {lock_fd}>&-; then + # Callers fan out one generator per file, so keep each image conversion + # single-threaded. ffmpegthumbnailer provides a still preview for videos. + if is_video_path "$image"; then + # Videos are generated before the picker opens, so one unreadable or + # stalled file must not hold it shut. A failed run drops the row. + thumbnail_command=(timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8) + else + thumbnail_command=(env VIPS_CONCURRENCY=1 vipsthumbnail "$image" --size 1536x864 --smartcrop=centre --path "$tmp[Q=82,strip]") + fi + + # Close the lock fd for the converter: an orphaned or hung child must not + # keep holding the lock after this shell is killed. + if "${thumbnail_command[@]}" {lock_fd}>&-; then mv -f "$tmp" "$thumbnail" else + status=$? rm -f "$tmp" "$thumbnail" + # Remember a video the converter rejected, so it costs nothing on the next + # open. The key covers size and mtime, so a repaired file starts clean. A + # timeout is left to retry: the machine may only have been busy. + if is_video_path "$image" && (( status != 124 && status != 137 )); then + : >"$thumbnail.failed" + fi fi } @@ -186,7 +211,17 @@ thumbnail_for() { thumbnail="$cache_dir/$hash.jpg" if [[ ! -f $thumbnail ]]; then - if [[ $lazy_thumbnails == true && $cache_only != true ]]; then + # A video that already failed to convert has no row to offer. Hand the + # marker back so the caller can keep the rows uncached over its absence. + if is_video_path "$image" && [[ -f $thumbnail.failed ]]; then + printf '%s' "$thumbnail.failed" + return + fi + + # A lazy row stands in with the media file itself, which the picker draws + # with an Image -- fine for a picture, blank for a video. Videos take the + # queue instead, which also keeps them under its narrower fan out. + if [[ $lazy_thumbnails == true && $cache_only != true ]] && ! is_video_path "$image"; then rows_cacheable=false if [[ $prepare_only != true ]]; then @@ -197,19 +232,35 @@ thumbnail_for() { return fi - printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + if is_video_path "$image"; then + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + else + printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_file" + fi fi printf '%s' "$thumbnail" } -# Generate every queued thumbnail at once; each vips run is single-threaded. +# Each vips run is single-threaded, so still images can fill every core. +# ffmpegthumbnailer leaves FFmpeg's automatic threading on, so a full-width fan +# out of those would put a codec thread pool on every core at once. drain_pending_thumbnails() { - [[ -s $pending_file ]] || return 0 + local video_jobs + + export -f generate_thumbnail is_video_path + + if [[ -s $pending_file ]]; then + xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi - export -f generate_thumbnail - xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ - bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + if [[ -s $pending_video_file ]]; then + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + fi } if [[ $rows_cache_hit != true && -f $rows_cache_file && -f $rows_signature_file ]] && cmp -s "$rows_signature_file" <(printf '%s' "$rows_signature"); then @@ -219,6 +270,12 @@ elif [[ $rows_cache_hit != true ]]; then for image in "${image_files[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue + # Cached rows are trusted on the directory's mtime alone, which a file + # repaired in place never changes. Leave them uncached instead. + if [[ $thumbnail == *.failed ]]; then + rows_cacheable=false + continue + fi if [[ $lazy_thumbnails == true && $cache_only != true && $thumbnail == $image ]]; then rows_cacheable=false fi @@ -232,7 +289,7 @@ elif [[ $rows_cache_hit != true ]]; then drain_pending_thumbnails - if [[ -s $pending_file ]]; then + if [[ -s $pending_file || -s $pending_video_file ]]; then pruned="" while IFS=$'\t' read -r row_image row_thumbnail; do if [[ ! -e $row_thumbnail ]]; then diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 1145fbd169..f6b9f292d2 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -8,6 +8,9 @@ set -euo pipefail omarchy-pkg-drop hermes-desktop +# The installer leaves a unit waiting to hand the app the Omarchy theme. +systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true + # The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes # it over on install and runs its own runtime instead, so a copy still here is one # the app never superseded -- an interrupted install, or the terminal CLI from @@ -22,14 +25,11 @@ omarchy-install-hermes-cli --remove || cli_removed=false # and it is the only thing that tells that runtime apart from one the user # installed themselves -- the paths are the same either way. Without it the app # never got that far: a machine where it was installed but never launched still -# has whatever was there before, and none of it is ours to delete. +# has whatever was there before, and none of it is ours to delete unasked. if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then # The checkout and venv, its own uv, its own node. None of it is any use once - # the app is gone. Not ~/.config/Hermes, which holds the gateway connections - # and their encrypted tokens, the active profile and the update settings. Not - # the rest of ~/.hermes either: the chats, memories and the skills Hermes - # wrote for itself are the user's, they are small, and finding them still - # there after a reinstall is the better surprise. + # the app is gone, so it goes without asking; what the user made with the app + # is a different question, answered below. rm -rf \ "$HOME/.hermes/hermes-agent" \ "$HOME/.hermes/bootstrap-cache" \ @@ -58,14 +58,38 @@ if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then fi done - echo "" - echo "Hermes Desktop has been removed." +fi + +# What survives to here is the user's: the chats, memories and skills in +# ~/.hermes, the connections and their encrypted tokens in ~/.config/Hermes. +# Keeping them stays the default -- they are small, and finding them intact +# after a reinstall is the better surprise -- but a removal meant to be +# complete should not leave credentials behind either, so the choice is put in +# front of the user with the size, default no. Asked whenever the directories +# exist, marker or no marker: on a machine where the marker never appeared the +# data came from the terminal CLI or an install the app never finished, and it +# is still what removal is asked to clean up. Naming the paths keeps the +# question honest there too -- ~/.hermes may still carry a runtime the app +# never owned, a yes takes that with it, and saying so is the prompt's job. +# Without a terminal to ask in, keeping everything is the answer. +data_removed=false +if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && command -v gum >/dev/null; then + # du answers non-zero when either directory is missing, and pipefail would + # turn that into an aborted removal; the size is worth no such thing. + size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) + if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then + rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" + data_removed=true + fi +fi + +echo "" +echo "Hermes Desktop has been removed." +if [[ $data_removed == true ]]; then + echo "Its chats, memories, and settings in ~/.hermes and ~/.config/Hermes are gone too." +elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then echo "Your chats, memories, and skills are still in ~/.hermes," echo "and your connections and settings in ~/.config/Hermes." -else - echo "" - echo "Hermes Desktop has been removed." - echo "It never finished installing its own Hermes, so nothing in ~/.hermes was touched." fi # The messages above still hold -- the app and its runtime are gone -- but a CLI diff --git a/bin/omarchy-remove-ai-perplexity b/bin/omarchy-remove-ai-perplexity new file mode 100755 index 0000000000..9385948402 --- /dev/null +++ b/bin/omarchy-remove-ai-perplexity @@ -0,0 +1,49 @@ +#!/bin/bash + +# omarchy:summary=Remove the Perplexity desktop app along with its runtime caches. +# omarchy:requires-sudo=true + +# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. +set -euo pipefail + +omarchy-pkg-drop perplexity + +# The runtime the app downloads for itself: llama.cpp builds and local models +# under ~/.local/share, the older download location under ~/.cache. Of the +# perplexity-* dirs these are the only ones the desktop app owns; the rest +# belong to Perplexity products that outlive it. +rm -rf \ + "$HOME/.cache/Perplexity" \ + "$HOME/.cache/perplexity-rpc-server" \ + "$HOME/.local/share/perplexity-rpc-server" + +# What is left is the user's: the logins and session in ~/.config/Perplexity, +# the secret vault and device identity in ~/.local/state/perplexity, and the +# launcher flags. Keeping them stays the default -- they are small, and being +# signed in after a reinstall is the better surprise -- but a removal meant to +# be complete should not leave credentials behind either, so the choice is put +# in front of the user, default no. Without a terminal to ask in, keeping it +# is the answer -- and gum draws the prompt on stderr, so a redirected stderr +# would block on a question nobody can see. +data_removed=false +if [[ -t 0 && -t 2 ]]; then + # du answers non-zero when a directory is missing, and pipefail would turn + # that into an aborted removal; the size is worth no such thing. + size=$(du -shc "$HOME/.config/Perplexity" "$HOME/.local/state/perplexity" 2>/dev/null | tail -1 | cut -f1 || true) + if gum confirm --default=false "Also delete your Perplexity data ($size: logins, settings, secret vault and device identity)?"; then + rm -rf \ + "$HOME/.config/Perplexity" \ + "$HOME/.local/state/perplexity" + rm -f "$HOME/.config/perplexity-flags.conf" + data_removed=true + fi +fi + +echo "" +echo "Perplexity has been removed." +if [[ $data_removed == "true" ]]; then + echo "Its logins, settings, secret vault, and device identity are gone too." +else + echo "Nothing of yours was touched: not the logins in ~/.config/Perplexity," + echo "nor the secret vault and device identity in ~/.local/state/perplexity." +fi diff --git a/bin/omarchy-remove-preinstalls b/bin/omarchy-remove-preinstalls index 3dca7b2202..bfb8fe34ec 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -17,6 +17,19 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w ~/.local/bin/gh ~/.local/bin/opencode ~/.local/bin/playwright ~/.local/bin/playwright-cli ~/.local/bin/pi \ ~/.local/bin/omp ~/.local/bin/ori ~/.local/bin/grok ~/.local/bin/crush ~/.local/bin/ghui ~/.local/bin/hunk + # Cursor's own installer links ~/.local/bin/cursor-agent as well, so only + # the mise wrapper omarchy-mise-install wrote is a preinstall. + if [[ -f ~/.local/bin/cursor-agent && ! -L ~/.local/bin/cursor-agent ]] && + grep -Eq '^mise use -g .*"cursor-agent"' ~/.local/bin/cursor-agent; then + rm -f ~/.local/bin/cursor-agent + fi + + # Preserve a user-managed Muse launcher at the same path. + if [[ -f ~/.local/bin/muse && ! -L ~/.local/bin/muse ]] && + grep -Eq '^mise use -g .*"http:muse\[' ~/.local/bin/muse; then + rm -f ~/.local/bin/muse + fi + # Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes # Desktop's command, an official install, or anything else at that path is # the user's, so it is the installer that decides whether the wrapper is its diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index 383aa37629..e46aaed9ba 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -74,18 +74,15 @@ if ! omarchy-hw-fingerprint; then exit 1 fi -# Install required packages -echo "Installing required packages..." - -# libfprint-git provides+conflicts libfprint; pacman -S --noconfirm -# defaults the conflict prompt to N and aborts. Pre-remove it (deps-only, -# so an installed fprintd stays put) so stock libfprint installs cleanly. -if pacman -Q libfprint-git &>/dev/null; then - sudo pacman -Rdd --noconfirm libfprint-git +# libfprint-git tracks upstream ahead of the Arch release, so a new reader only +# needs a pin bump in omarchy-pkgs. It conflicts with stock libfprint, and +# --noconfirm answers that prompt with N; --ask 4 accepts the replacement in +# one transaction, so a failed install leaves the existing driver in place. +if omarchy-pkg-missing libfprint-git fprintd usbutils; then + echo "Installing required packages..." + sudo pacman -S --needed --noconfirm --ask 4 libfprint-git fprintd usbutils fi -omarchy-pkg-add libfprint fprintd usbutils - # Enroll first fingerprint echo -e "\e[32m\nLet's setup your right index finger as the first fingerprint.\e[0m" echo -e "Keep moving the finger around on sensor until the process completes.\n" diff --git a/bin/omarchy-theme-bg-next b/bin/omarchy-theme-bg-next index dfb3d3f072..f9907d7a3c 100755 --- a/bin/omarchy-theme-bg-next +++ b/bin/omarchy-theme-bg-next @@ -10,7 +10,8 @@ CURRENT_BACKGROUND_LINK="$HOME/.local/state/omarchy/current/background" mapfile -d '' -t BACKGROUNDS < <( find -L "$USER_BACKGROUNDS_PATH" "$THEME_BACKGROUNDS_PATH" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) TOTAL=${#BACKGROUNDS[@]} diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 45b420dfdb..835321d74b 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -1,11 +1,11 @@ #!/bin/bash -# omarchy:summary=Set the current background image -# omarchy:args= +# omarchy:summary=Set the current background image or video +# omarchy:args= # omarchy:examples=omarchy theme bg set ~/Pictures/background.png if [[ -z $1 ]]; then - echo "Usage: omarchy-theme-bg-set " >&2 + echo "Usage: omarchy-theme-bg-set " >&2 exit 1 fi @@ -17,7 +17,7 @@ if [[ ! -f $BACKGROUND ]]; then exit 1 fi -# Create symlink to the new background +# Create symlink to the new background media ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK" # Update the live shell background immediately when it is running. The diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index b0c1cda4f9..614a8a1bd6 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -48,12 +48,17 @@ shell_ipc() { timeout 2 omarchy-shell "$@" >/dev/null 2>&1 } +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + snapshot_background_path() { local background="$1" local name="$2" local snapshot extension [[ -f $background ]] || return + is_video_path "$background" && return mkdir -p "$BACKGROUND_TRANSITION_CACHE" extension=${background##*.} @@ -69,20 +74,35 @@ snapshot_current_background() { snapshot_background_path "$current_background" "previous" } +background_transition_uses_snapshots() { + local next_background="$1" + local current_background + + current_background=$(readlink -f "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + ! is_video_path "$current_background" && ! is_video_path "$next_background" +} + choose_theme_background() { + local theme_path="${1:-$CURRENT_THEME_PATH}" + local current_theme_backgrounds="$CURRENT_THEME_PATH/backgrounds" local backgrounds=() local current_background index next_index i CHOSEN_THEME_BACKGROUND="" mapfile -d '' -t backgrounds < <( - find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$CURRENT_THEME_PATH/backgrounds/" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ + find -L "$HOME/.config/omarchy/backgrounds/$THEME_NAME/" "$theme_path/backgrounds/" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ -print0 2>/dev/null | sort -z ) (( ${#backgrounds[@]} > 0 )) || return 1 current_background=$(readlink "$CURRENT_BACKGROUND_LINK" 2>/dev/null || true) + if [[ $theme_path != $CURRENT_THEME_PATH && ${current_background%/*} == $current_theme_backgrounds ]]; then + current_background="$theme_path/backgrounds/${current_background##*/}" + fi + index=-1 for i in "${!backgrounds[@]}"; do if [[ ${backgrounds[$i]} == $current_background ]]; then @@ -99,6 +119,16 @@ choose_theme_background() { fi } +choose_staged_theme_background() { + local next_theme_backgrounds="$NEXT_THEME_PATH/backgrounds" + + choose_theme_background "$NEXT_THEME_PATH" || return 1 + + if [[ ${CHOSEN_THEME_BACKGROUND%/*} == $next_theme_backgrounds ]]; then + CHOSEN_THEME_BACKGROUND="$CURRENT_THEME_PATH/backgrounds/${CHOSEN_THEME_BACKGROUND##*/}" + fi +} + set_theme_background_link() { choose_theme_background || return 1 ln -nsf "$CHOSEN_THEME_BACKGROUND" "$CURRENT_BACKGROUND_LINK" @@ -107,14 +137,19 @@ set_theme_background_link() { set_theme_background() { local new_background new_background_snapshot - if ! choose_theme_background; then - omarchy-notification-send "No background was found for theme" -t 2000 - shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true - return + if [[ -z $CHOSEN_THEME_BACKGROUND || ! -f $CHOSEN_THEME_BACKGROUND ]]; then + if ! choose_theme_background; then + omarchy-notification-send "No background was found for theme" -t 2000 + shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true + return + fi fi new_background="$CHOSEN_THEME_BACKGROUND" - new_background_snapshot=$(snapshot_background_path "$new_background" "next") + new_background_snapshot="" + if [[ $BACKGROUND_TRANSITION_SNAPSHOTS == "true" ]]; then + new_background_snapshot=$(snapshot_background_path "$new_background" "next") + fi if [[ -f $OLD_BACKGROUND_SNAPSHOT && -f $new_background_snapshot ]]; then shell_ipc background themeTransition "$OLD_BACKGROUND_SNAPSHOT" "$new_background_snapshot" "$new_background" "$colors_payload" "$shell_payload" || \ @@ -283,9 +318,19 @@ fi # Generate dynamic configs omarchy-theme-set-templates +CHOSEN_THEME_BACKGROUND="" OLD_BACKGROUND_SNAPSHOT="" +BACKGROUND_TRANSITION_SNAPSHOTS=true if [[ $THEME_HEADLESS != "1" && $OMARCHY_THEME_SKIP_BACKGROUND != "1" ]]; then - OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + # Resolve the staged choice while the old theme still exists. Video changes + # switch directly to the durable path in QML, so neither side needs a copy. + if choose_staged_theme_background; then + if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + OLD_BACKGROUND_SNAPSHOT=$(snapshot_current_background) + else + BACKGROUND_TRANSITION_SNAPSHOTS=false + fi + fi fi # Swap next theme in as current @@ -326,6 +371,7 @@ post_theme_commands=( omarchy-theme-set-gnome omarchy-theme-set-pi omarchy-theme-set-claude + omarchy-theme-set-hermes omarchy-theme-set-browser omarchy-theme-set-vscode omarchy-theme-set-obsidian diff --git a/bin/omarchy-theme-set-hermes b/bin/omarchy-theme-set-hermes new file mode 100755 index 0000000000..c73b24c7a1 --- /dev/null +++ b/bin/omarchy-theme-set-hermes @@ -0,0 +1,233 @@ +#!/bin/bash + +# omarchy:summary=Sync the generated Omarchy theme to Hermes as a skin +# omarchy:args=[--activate] [--wait] +# omarchy:hidden=true + +# A skin is Hermes' one theme unit for the desktop app, the TUI and the CLI; +# its gateway watches the active skin file and repaints every surface on change. + +set -euo pipefail + +HERMES_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/hermes.yaml" +HERMES_THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HERMES_CONFIG_PATH="$HERMES_HOME/config.yaml" +HERMES_SKIN_NAME="omarchy" +# The command the readiness probe vets, rather than whichever hermes is on PATH. +HERMES_COMMAND="$HOME/.local/bin/hermes" +# Written by the desktop app once the runtime its first launch provisions is in. +HERMES_BOOTSTRAP_MARKER="$HERMES_HOME/hermes-agent/.hermes-bootstrap-complete" +HERMES_ACTIVATE=0 +HERMES_WAIT=0 +HERMES_WAIT_LIMIT=$((30 * 60)) + +usage() { + echo "Usage: omarchy-theme-set-hermes [--activate] [--wait]" +} + +for arg in "$@"; do + case "$arg" in + --activate) + HERMES_ACTIVATE=1 + ;; + --wait) + HERMES_ACTIVATE=1 + HERMES_WAIT=1 + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; + esac +done + +# A theme switch runs this beside a dozen other hooks; only --activate explains. +note() { + if (( HERMES_ACTIVATE == 1 )); then + echo "$*" >&2 + fi +} + +# A theme applied before the template existed has no skin rendered yet. +if [[ ! -f $HERMES_SOURCE_PATH ]]; then + (( HERMES_ACTIVATE == 1 )) || exit 0 + + if [[ ! -s $HERMES_THEME_NAME_PATH ]]; then + echo "Hermes skin source missing: $HERMES_SOURCE_PATH" >&2 + echo "Select an Omarchy theme first." >&2 + exit 1 + fi + + omarchy-theme-refresh + + if [[ ! -f $HERMES_SOURCE_PATH ]]; then + echo "Hermes skin source missing after refreshing the theme: $HERMES_SOURCE_PATH" >&2 + exit 1 + fi +fi + +# The first launch takes minutes and may be abandoned; the readiness probe +# would start Hermes to answer, so poll for the marker instead. +if (( HERMES_WAIT == 1 )); then + waited=0 + until [[ -f $HERMES_BOOTSTRAP_MARKER && -f $HERMES_CONFIG_PATH ]]; do + if (( waited >= HERMES_WAIT_LIMIT )); then + echo "Hermes did not finish setting up within $((HERMES_WAIT_LIMIT / 60)) minutes; run omarchy-theme-set-hermes --activate once it has." >&2 + exit 0 + fi + + sleep 10 + waited=$((waited + 10)) + done +fi + +# Provisioning creates ~/.hermes on every machine for the Omarchy skill; the +# config is what Hermes writes once it has actually run. +if [[ ! -f $HERMES_CONFIG_PATH ]]; then + note "Hermes is not set up yet; launch it once, then run omarchy-theme-set-hermes --activate." + exit 0 +fi + +# Hermes parses the skin as YAML and hands its strings to every surface, so only +# the name, a plain description and #rrggbb colours may reach it, in the order +# YAML needs them. YAML breaks lines on bytes grep does not, so the bytes are +# counted first, NUL included. +skin_is_well_formed() { + local skin="$1" + + [[ -f $skin ]] && + (( $(LC_ALL=C tr -d ' -~\n' <"$skin" | wc -c) == 0 )) && + awk -v name="name: $HERMES_SKIN_NAME" ' + bad { next } + /^#/ || /^[[:space:]]*$/ { next } + !seen_name { if ($0 == name) seen_name = 1; else bad = 1; next } + !seen_colors { + if ($0 == "colors:") seen_colors = 1 + else if (!seen_description && $0 ~ /^description: [A-Za-z0-9 ,.()-]{0,200}$/) seen_description = 1 + else bad = 1 + next + } + /^ [a-z_]{1,64}: "#[0-9a-fA-F]{6}"$/ { colors++; next } + { bad = 1 } + END { exit (bad || !seen_colors || colors == 0) } + ' "$skin" +} + +# The check has to cover the bytes that get published, and the theme can change +# underneath between the two, so a private copy is taken and that is checked. +snapshot_dir=$(mktemp -d) +trap 'rm -rf "$snapshot_dir"' EXIT +HERMES_SNAPSHOT="$snapshot_dir/$HERMES_SKIN_NAME.yaml" + +take_snapshot() { + cp "$HERMES_SOURCE_PATH" "$HERMES_SNAPSHOT" 2>/dev/null && skin_is_well_formed "$HERMES_SNAPSHOT" +} + +if ! take_snapshot; then + echo "Skipping Hermes skin: $(basename "$HERMES_SOURCE_PATH") is not a plain color palette." >&2 + exit 0 +fi + +# The gateway reads the file whole on an mtime change, so the write is atomic; +# -T so a directory at the skin's path is an error rather than a destination. +publish_skin() { + local skins_dir="$1" + local tmp + + mkdir -p "$skins_dir" 2>/dev/null || return 1 + tmp=$(mktemp "$skins_dir/$HERMES_SKIN_NAME.yaml.XXXXXX" 2>/dev/null) || return 1 + if ! cp "$HERMES_SNAPSHOT" "$tmp" 2>/dev/null || ! mv -T "$tmp" "$skins_dir/$HERMES_SKIN_NAME.yaml" 2>/dev/null; then + rm -f "$tmp" + return 1 + fi +} + +# A profile is a Hermes home of its own; existing ones get the skin, none are +# made, and one that cannot take it does not cost the others. +publish_skin_everywhere() { + local profile + + publish_skin "$HERMES_HOME/skins" || { + echo "Could not publish the Hermes skin to $HERMES_HOME/skins." >&2 + return 1 + } + + for profile in "$HERMES_HOME"/profiles/*/; do + [[ -d $profile ]] || continue + publish_skin "${profile%/}/skins" || note "Could not publish the skin to the Hermes profile $(basename "$profile")." + done +} + +publish_skin_everywhere + +# Hermes reads the config of the profile named in active_profile; the profile +# exists once its directory does, with or without a config of its own. +active_config_path() { + local profile + + profile=$(cat "$HERMES_HOME/active_profile" 2>/dev/null || true) + profile=${profile,,} + + if [[ -n $profile && $profile != "default" && -d $HERMES_HOME/profiles/$profile ]]; then + echo "$HERMES_HOME/profiles/$profile/config.yaml" + else + echo "$HERMES_CONFIG_PATH" + fi +} + +# A theme switch finishes the hand-over only for the app Omarchy installed, and +# only Hermes' default is ever replaced, so a config plainly naming another skin +# ends it here without starting Hermes. Anything less plain is for Hermes to read. +if (( HERMES_ACTIVATE == 0 )); then + omarchy-pkg-present hermes-desktop || exit 0 + + skin_line=$(grep -m1 -x ' skin: .*' "$(active_config_path)" 2>/dev/null || true) + case "${skin_line# skin: }" in + "" | default | null | true | false | *[!A-Za-z0-9_-]*) ;; + *) exit 0 ;; + esac +fi + +# Omarchy's cold stub installs Hermes when run, so ask the probe before running it. +if ! omarchy-install-hermes-cli --check 2>/dev/null; then + note "Hermes is not ready, so the Omarchy skin is published but not active." + note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME" + exit 0 +fi + +# Only Hermes' own default is replaced, so a skin chosen in Hermes stays; an +# answer that did not come is not a default. +if ! current_skin=$(timeout 15 "$HERMES_COMMAND" config get display.skin 2>/dev/null); then + note "Hermes did not say which skin it is on, so the Omarchy skin is published but not active." + exit 0 +fi + +if [[ -n $current_skin && $current_skin != "default" && $current_skin != "$HERMES_SKIN_NAME" ]]; then + note "Hermes is set to the '$current_skin' skin; leaving it. Switch with: hermes config set display.skin $HERMES_SKIN_NAME" + exit 0 +fi + +# Hermes' own writer: it updates the active profile's config and touches the +# skin file so a running gateway broadcasts the change. A refusal is cosmetic. +if ! timeout 30 "$HERMES_COMMAND" config set display.skin "$HERMES_SKIN_NAME" >/dev/null 2>&1; then + note "Hermes refused to switch skins, so the Omarchy skin is published but not active." + exit 0 +fi + +note "Hermes is on the Omarchy skin." + +# The desktop applies a skin only from a broadcast, and a config written before +# the gateway seeded its watcher goes unannounced; a later write is announced. +# The theme may have changed underneath in the meantime, so it is checked again. +if (( HERMES_WAIT == 1 )); then + sleep 60 + + if take_snapshot; then + publish_skin_everywhere + fi +fi diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index 6d014dd5ce..49bc127c87 100755 --- a/bin/omarchy-theme-switcher +++ b/bin/omarchy-theme-switcher @@ -22,7 +22,7 @@ find_preview() { local theme_path="$1" local preview preview_name - for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp; do + for preview_name in preview.png preview.jpg preview.jpeg preview.webp preview.gif preview.bmp preview.mp4 preview.m4v preview.mov preview.webm preview.mkv preview.avi; do preview=$(find -L "$theme_path" -maxdepth 1 -type f -iname "$preview_name" -print -quit 2>/dev/null) if [[ -n $preview ]]; then @@ -32,7 +32,10 @@ find_preview() { done if [[ -d $theme_path/backgrounds ]]; then - find -L "$theme_path/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) -print 2>/dev/null | sort | head -n 1 + find -L "$theme_path/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print 2>/dev/null | sort | head -n 1 fi } @@ -48,7 +51,7 @@ add_theme_preview() { ln -s "$preview" "$preview_dir/$theme_name.$extension" } -fast_signature="v1"$'\n' +fast_signature="v2"$'\n' for theme_dir in "$USER_THEMES_PATH" "$OMARCHY_THEMES_PATH"; do if [[ -d $theme_dir ]]; then fast_signature+="$theme_dir:$(stat -Lc '%Y' "$theme_dir")"$'\n' @@ -104,7 +107,7 @@ fi current_theme=$(cat "$HOME/.local/state/omarchy/current/theme.name" 2>/dev/null) selected_preview="" -for extension in png jpg jpeg webp gif bmp; do +for extension in png jpg jpeg webp gif bmp mp4 m4v mov webm mkv avi; do if [[ -e $preview_dir/$current_theme.$extension ]]; then selected_preview="$preview_dir/$current_theme.$extension" break diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2e9bd75939..2169191489 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -175,7 +175,10 @@ target_home=$(getent passwd "$target_user" | cut -d: -f6) [[ -n $target_home && -d $target_home ]] || fail "Home directory for '$target_user' was not found." target_uid=$(id -u "$target_user") target_runtime_dir="/run/user/$target_uid" -package_path="/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin:$target_home/.local/bin" +# User-local commands are needed only after dropping to the target user. Never +# expose their search path to commands run through as_root. +root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin +package_path="$root_path:$target_home/.local/bin" as_root() { if (( EUID == 0 )); then @@ -661,7 +664,7 @@ configure_lock_authentication() { as_root env \ OMARCHY_INSTALL_USER="$target_user" \ OMARCHY_PATH=/usr/share/omarchy \ - PATH="$package_path" \ + PATH="$root_path" \ "$apply_lock" } @@ -1287,7 +1290,7 @@ apply_firewall_defaults() { fi log "Applying Omarchy firewall defaults" - as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$package_path" \ + as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \ bash -euo pipefail "$firewall_script" || warn "Could not apply firewall defaults; run 'sudo bash $firewall_script' after reboot." } @@ -2078,7 +2081,10 @@ for file in \ done ln -snf "$HOME/.local/state/omarchy/current/theme/btop.theme" "$HOME/.config/btop/themes/current.theme" if [[ ! -e $HOME/.local/state/omarchy/current/background && -d $HOME/.local/state/omarchy/current/theme/backgrounds ]]; then - background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) | sort | head -n1) + background=$(find "$HOME/.local/state/omarchy/current/theme/backgrounds" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + | sort | head -n1) [[ -n ${background:-} ]] && ln -snf "$background" "$HOME/.local/state/omarchy/current/background" fi diff --git a/config/hypr/bindings.lua b/config/hypr/bindings.lua index 98d3ecbe4c..6da7b938d2 100644 --- a/config/hypr/bindings.lua +++ b/config/hypr/bindings.lua @@ -1,5 +1,5 @@ --- Keep only your personal keybinding overrides here. Add new bindings or --- unbind defaults before replacing them. +-- Keep only your personal keybinding overrides here. Add new bindings with +-- o.bind or replace defaults with o.rebind. -- See current bindings and descriptions: -- omarchy menu keybindings --print @@ -15,10 +15,9 @@ -- Add a new binding. -- o.bind("SUPER + SHIFT + R", "SSH", "alacritty -e ssh your-server") --- Change an existing binding by unbinding it first, then binding the key again. --- This example changes SUPER+SPACE from the launcher to the Omarchy root menu. --- hl.unbind("SUPER + SPACE") --- o.bind("SUPER + SPACE", "Omarchy menu", "omarchy-menu toggle root") +-- Change an existing binding. o.rebind takes the same arguments as o.bind. +-- This example replaces the default file manager with Flea. +-- o.rebind("SUPER + SHIFT + F", "File manager", { launch = "flea" }) -- Disable a default binding without replacing it. -- hl.unbind("SUPER + SHIFT + B") diff --git a/default/agents/skills/diagnose-crash/reporting.md b/default/agents/skills/diagnose-crash/reporting.md index 154f1a25ce..15bd08bc54 100644 --- a/default/agents/skills/diagnose-crash/reporting.md +++ b/default/agents/skills/diagnose-crash/reporting.md @@ -40,8 +40,8 @@ useful; filing there yourself is not part of this. A duplicate issue costs a maintainer more time than no report at all. ```bash -gh search issues --repo basecamp/omarchy " crash" -gh issue list --repo basecamp/omarchy --state all --search " " +gh search issues --repo omacom/omarchy " crash" +gh issue list --repo omacom/omarchy --state all --search " " ``` Search on the crashing program, the signal, and distinctive symbols from the @@ -59,7 +59,7 @@ more than another duplicate. If a plausible match comes back, read it properly first: ```bash -gh issue view --repo basecamp/omarchy --comments +gh issue view --repo omacom/omarchy --comments ``` Confirm it is genuinely the same failure. The same program crashing is not the @@ -74,7 +74,7 @@ A comment that only says the bug happens to you too is noise. If that is all you have, tell the user so and file nothing. ```bash -gh issue comment --repo basecamp/omarchy --body "..." +gh issue comment --repo omacom/omarchy --body "..." ``` ## Filing a new issue @@ -82,7 +82,7 @@ gh issue comment --repo basecamp/omarchy --body "..." Only when the search turns up nothing that matches: ```bash -gh issue create --repo basecamp/omarchy --title "..." --body "..." +gh issue create --repo omacom/omarchy --title "..." --body "..." ``` Include what happened, what was expected, steps to reproduce, system details from diff --git a/default/agents/skills/omarchy/SKILL.md b/default/agents/skills/omarchy/SKILL.md index aed1e38c46..4ce34bb04d 100644 --- a/default/agents/skills/omarchy/SKILL.md +++ b/default/agents/skills/omarchy/SKILL.md @@ -278,7 +278,7 @@ This skill intentionally does not cover Omarchy source development. Do not use t ## Example Requests - "Change my theme to catppuccin" -> `omarchy theme set catppuccin` -- "Add a keybinding for Super+E to open file manager" -> Check existing bindings first, call `hl.unbind` if needed, then `o.bind` in `~/.config/hypr/bindings.lua` +- "Add a keybinding for Super+E to open file manager" -> Check existing bindings first, then use `o.rebind` to replace one or `o.bind` to add one in `~/.config/hypr/bindings.lua` - "Configure my external monitor" -> Edit `~/.config/hypr/monitors.lua` - "Make the window gaps smaller" -> Edit `~/.config/hypr/looknfeel.lua` - "Turn on night light" -> `omarchy toggle nightlight` (for time-based schedules, edit `~/.config/hypr/hyprsunset.conf` profiles, then `omarchy restart hyprsunset`) diff --git a/default/agents/skills/omarchy/contributing.md b/default/agents/skills/omarchy/contributing.md index 117a990180..b5fd05335d 100644 --- a/default/agents/skills/omarchy/contributing.md +++ b/default/agents/skills/omarchy/contributing.md @@ -3,13 +3,13 @@ Read this when the user wants to report an Omarchy bug, suggest a feature, or contribute a fix upstream. -Omarchy lives at https://github.com/basecamp/omarchy. Route requests to the +Omarchy lives at https://github.com/omacom/omarchy. Route requests to the right place: - **Verified bugs** -> GitHub issues. Issues are for validated bugs only, not support requests. - **Feature ideas and suggestions** -> - https://github.com/basecamp/omarchy/discussions/categories/suggestions + https://github.com/omacom/omarchy/discussions/categories/suggestions - **Support and "is this a bug?" questions** -> the Discord community at https://omarchy.org/discord. Start here when the problem isn't clearly a bug in Omarchy itself. @@ -43,7 +43,7 @@ For screen-recording failures specifically, rerun with File the issue with `gh` when available: ```bash -gh issue create --repo basecamp/omarchy --title "..." --body "..." +gh issue create --repo omacom/omarchy --title "..." --body "..." ``` Include: what happened, what was expected, steps to reproduce, system details, @@ -54,7 +54,7 @@ the debug log URL (or attached log), and the capture. Never develop against `/usr/share/omarchy`. Clone a working copy instead: ```bash -gh repo fork basecamp/omarchy --clone +gh repo fork omacom/omarchy --clone cd omarchy ``` diff --git a/default/agents/skills/omarchy/hyprland.md b/default/agents/skills/omarchy/hyprland.md index 961ff3886c..fd518ad3b1 100644 --- a/default/agents/skills/omarchy/hyprland.md +++ b/default/agents/skills/omarchy/hyprland.md @@ -43,18 +43,16 @@ View current bindings: `omarchy menu keybindings --print` **IMPORTANT: When re-binding an existing key:** 1. First check existing bindings: `omarchy menu keybindings --print` -2. If the key is already bound, you MUST call `hl.unbind(...)` BEFORE the new `o.bind(...)` +2. If the key is already bound, use `o.rebind(...)` to remove the existing binding and add its replacement. It takes the same arguments as `o.bind(...)`. 3. Inform the user what the key was previously bound to Example - rebinding SUPER+F (which is bound to fullscreen by default): ```lua --- Unbind existing SUPER+F (was: fullscreen) -hl.unbind("SUPER + F") --- New binding for file manager -o.bind("SUPER + F", "File manager", { launch = "nautilus" }) +-- Replace SUPER+F (was: fullscreen) with the file manager. +o.rebind("SUPER + F", "File manager", { launch = "nautilus" }) ``` -Always tell the user: "Note: SUPER+F was previously bound to fullscreen. I've added an unbind to override it." +Tell the user which action was replaced. Use `hl.unbind(...)` to remove a binding without replacing it. ## Display/Monitors diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index 897598f7d2..cb63382908 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -13,8 +13,9 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one - `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from , the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size -- `U+E90B` — Cursor, from +- `U+E90B` — Perplexity, from - `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG +- `U+E90D` — Cursor, from The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index aa639ba53a..8d6df78333 100644 Binary files a/default/fonts/omarchy/omarchy.ttf and b/default/fonts/omarchy/omarchy.ttf differ diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index b1eb48fa72..04964703f8 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -105,6 +105,11 @@ function o.bind(keys, description, dispatcher, options) hl.bind(keys, dispatcher, opts) end +function o.rebind(keys, description, dispatcher, options) + hl.unbind(keys) + o.bind(keys, description, dispatcher, options) +end + function o.launch(command) return "uwsm-app -- " .. command end diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index cbe1945bef..ef3f809950 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -140,9 +140,10 @@ "setup.default.agent.codex": {"icon":"","iconFont":"omarchy","label":"Codex","checked":"[[ \"$(omarchy-default-agent)\" == \"codex\" ]]","action":"omarchy-default-agent codex"}, "setup.default.agent.copilot": {"icon":"","label":"Copilot","checked":"[[ \"$(omarchy-default-agent)\" == \"copilot\" ]]","action":"omarchy-default-agent copilot"}, "setup.default.agent.crush": {"icon":"󰋑","label":"Crush","checked":"[[ \"$(omarchy-default-agent)\" == \"crush\" ]]","action":"omarchy-default-agent crush"}, - "setup.default.agent.cursor-agent": {"icon":"","iconFont":"omarchy","label":"Cursor","checked":"[[ \"$(omarchy-default-agent)\" == \"cursor-agent\" ]]","action":"omarchy-default-agent cursor-agent"}, + "setup.default.agent.cursor-agent": {"icon":"","iconFont":"omarchy","label":"Cursor CLI","checked":"[[ \"$(omarchy-default-agent)\" == \"cursor-agent\" ]]","action":"omarchy-default-agent cursor-agent"}, "setup.default.agent.grok": {"icon":"","iconFont":"omarchy","label":"Grok","checked":"[[ \"$(omarchy-default-agent)\" == \"grok\" ]]","action":"omarchy-default-agent grok"}, "setup.default.agent.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes","checked":"[[ \"$(omarchy-default-agent)\" == \"hermes\" ]]","action":"omarchy-default-agent hermes"}, + "setup.default.agent.muse": {"icon":"󰛤","label":"Muse Code","checked":"[[ \"$(omarchy-default-agent)\" == \"muse\" ]]","action":"omarchy-default-agent muse"}, "setup.default.agent.omp": {"icon":"","iconFont":"omarchy","label":"omp","checked":"[[ \"$(omarchy-default-agent)\" == \"omp\" ]]","action":"omarchy-default-agent omp"}, "setup.default.agent.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","checked":"[[ \"$(omarchy-default-agent)\" == \"openclaw\" ]]","action":"omarchy-default-agent openclaw"}, "setup.default.agent.opencode": {"icon":"","iconFont":"omarchy","label":"OpenCode","checked":"[[ \"$(omarchy-default-agent)\" == \"opencode\" ]]","action":"omarchy-default-agent opencode"}, @@ -164,7 +165,7 @@ "setup.default.editor": {"icon":"","label":"Editor","title":"Default Editor"}, "setup.default.editor.neovim": {"icon":"","label":"Neovim","checked":"[[ \"$(omarchy-default-editor)\" == \"nvim\" ]]","action":"omarchy-default-editor nvim"}, "setup.default.editor.vscode": {"icon":"","label":"VSCode","checked":"[[ \"$(omarchy-default-editor)\" == \"code\" ]]","action":"omarchy-default-editor code"}, - "setup.default.editor.cursor": {"icon":"","label":"Cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"}, + "setup.default.editor.cursor": {"icon":"","iconFont":"omarchy","label":"Cursor","checked":"[[ \"$(omarchy-default-editor)\" == \"cursor\" ]]","action":"omarchy-default-editor cursor"}, "setup.default.editor.zed": {"icon":"","label":"Zed","checked":"[[ \"$(omarchy-default-editor)\" == \"zeditor\" ]]","action":"omarchy-default-editor zed"}, "setup.default.editor.sublime": {"icon":"","label":"Sublime Text","checked":"[[ \"$(omarchy-default-editor)\" == \"sublime_text\" ]]","action":"omarchy-default-editor sublime_text"}, "setup.default.editor.helix": {"icon":"","label":"Helix","checked":"[[ \"$(omarchy-default-editor)\" == \"helix\" ]]","action":"omarchy-default-editor helix"}, @@ -229,7 +230,7 @@ "install.service.bitwarden": {"icon":"󰟵","label":"Bitwarden","disabled":"omarchy-pkg-present bitwarden","action":"omarchy-install-and-launch Bitwarden 'bitwarden bitwarden-cli' bitwarden"}, "install.service.chromium-account": {"icon":"","label":"Chromium Account","when":"[[ -f ~/.config/chromium-flags.conf ]]","disabled":"grep -q oauth2-client-id ~/.config/chromium-flags.conf","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-chromium-google-account"}, "install.editor.vscode": {"icon":"","label":"VSCode","disabled":"omarchy-pkg-present visual-studio-code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-vscode"}, - "install.editor.cursor": {"icon":"","label":"Cursor","disabled":"omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, + "install.editor.cursor": {"icon":"","iconFont":"omarchy","label":"Cursor","disabled":"omarchy-pkg-present cursor-bin","action":"omarchy-install-and-launch Cursor cursor-bin cursor"}, "install.editor.zed": {"icon":"","label":"Zed","disabled":"omarchy-pkg-present zed","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-zed"}, "install.editor.sublime": {"icon":"","label":"Sublime Text","disabled":"omarchy-pkg-present sublime-text-4","action":"omarchy-install-and-launch 'Sublime Text' sublime-text-4 sublime_text"}, "install.editor.helix": {"icon":"","label":"Helix","disabled":"omarchy-pkg-present helix","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-helix"}, @@ -246,6 +247,7 @@ "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, "install.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","disabled":"omarchy-cmd-present ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, "install.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","disabled":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-openclaw"}, + "install.ai.perplexity": {"icon":"","iconFont":"omarchy","label":"Perplexity","disabled":"omarchy-pkg-present perplexity","action":"omarchy-install-and-launch Perplexity perplexity perplexity"}, "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-install-and-launch 'T3 Code' t3code-bin t3code"}, "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, @@ -312,6 +314,7 @@ "remove.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"omarchy-pkg-present lmstudio-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-lm-studio"}, "remove.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"omarchy-pkg-present ollama","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-ollama"}, "remove.ai.openclaw": {"icon":"","iconFont":"omarchy","label":"OpenClaw","when":"omarchy-pkg-present openclaw","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-openclaw"}, + "remove.ai.perplexity": {"icon":"","iconFont":"omarchy","label":"Perplexity","when":"omarchy-pkg-present perplexity","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-perplexity"}, "remove.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","when":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-t3-code"}, "remove.gaming.steam": {"icon":"","label":"Steam","when":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-steam"}, "remove.gaming.retroarch": {"icon":"","label":"RetroArch","when":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-gaming-retroarch"}, diff --git a/default/themed/hermes.yaml.tpl b/default/themed/hermes.yaml.tpl new file mode 100644 index 0000000000..2e39710e08 --- /dev/null +++ b/default/themed/hermes.yaml.tpl @@ -0,0 +1,47 @@ +name: omarchy +description: Omarchy system theme +colors: + background: "{{ background }}" + ui_text: "{{ foreground }}" + ui_primary: "{{ accent }}" + ui_accent: "{{ accent }}" + ui_border: "{{ muted }}" + ui_label: "{{ accent }}" + ui_ok: "{{ green }}" + ui_warn: "{{ yellow }}" + ui_error: "{{ red }}" + ui_tool: "{{ cyan }}" + ui_thinking: "{{ dark_foreground }}" + banner_border: "{{ muted }}" + banner_title: "{{ accent }}" + banner_accent: "{{ accent }}" + banner_dim: "{{ dark_foreground }}" + banner_text: "{{ foreground }}" + prompt: "{{ bright_foreground }}" + input_rule: "{{ muted }}" + response_border: "{{ accent }}" + shell_dollar: "{{ blue }}" + selection_bg: "{{ selection }}" + session_label: "{{ accent }}" + session_border: "{{ muted }}" + status_bar_bg: "{{ dark_background }}" + status_bar_text: "{{ foreground }}" + status_bar_strong: "{{ accent }}" + status_bar_dim: "{{ dark_foreground }}" + status_bar_good: "{{ green }}" + status_bar_warn: "{{ yellow }}" + status_bar_bad: "{{ red }}" + status_bar_critical: "{{ bright_red }}" + voice_status_bg: "{{ dark_background }}" + completion_menu_bg: "{{ lighter_background }}" + completion_menu_current_bg: "{{ selection }}" + completion_menu_meta_bg: "{{ lighter_background }}" + completion_menu_meta_current_bg: "{{ selection }}" + diff_added: "{{ mix background green 15% }}" + diff_removed: "{{ mix background red 15% }}" + diff_added_word: "{{ green }}" + diff_removed_word: "{{ red }}" + syntax_string: "{{ green }}" + syntax_number: "{{ yellow }}" + syntax_keyword: "{{ magenta }}" + syntax_comment: "{{ muted }}" diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 03af62997a..76e741108c 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -108,6 +108,8 @@ ttfx qemu-user-static-binfmt qrencode qt6-imageformats +qt6-multimedia +qt6-multimedia-ffmpeg quickshell ripgrep ruby diff --git a/install/user/mise.sh b/install/user/mise.sh index 4baeffce9b..54088956ec 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -19,3 +19,6 @@ omarchy-mise-install github:OpenRouterLabs/ori-releases ori # omarchy-provision-user -- the default browser, the mailto handler and the # finalize-user marker all come after it. omarchy-install-hermes-cli || true +if omarchy-cmd-missing muse; then + omarchy-mise-install "http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" muse +fi diff --git a/manual/17-ai.md b/manual/17-ai.md index beba898656..63381e0373 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -10,12 +10,13 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `agy` | [Google Antigravity CLI](https://github.com/google-antigravity/antigravity-cli) | | `copilot` | [GitHub Copilot CLI](https://github.com/github/copilot-cli) | | `crush` | [Crush](https://github.com/charmbracelet/crush) | -| `cursor-agent` | [Cursor Agent](https://cursor.com/docs/cli/overview) | +| `cursor-agent` | [Cursor Agent](https://cursor.com/docs/cli/overview) from Cursor's official Linux tarball | | `grok` | Grok CLI from xAI | | `pi` | [Mario Zechner's Pi](https://github.com/badlogic/pi-mono) | | `omp` | [Oh My Pi](https://github.com/can1357/oh-my-pi) | | `ori` | [Ori](https://openrouter.ai/docs/guides/ori/harness), OpenRouter's harness | | `hermes` | [Hermes](https://hermes-agent.nousresearch.com/), Nous Research's agent | +| `muse` | [Muse Code](https://dev.meta.ai), Meta's coding agent | `ori` is the odd one out: it runs the other harnesses against OpenRouter's whole model catalog, so `ori claude`, `ori codex`, or `ori opencode` start those agents on whichever model you point them at, and `ori code` is Ori's own agent. @@ -25,9 +26,11 @@ To wrap an additional CLI the same way, run `omarchy-mise-install [com Pick your default agent with `omarchy default agent ` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification. +[Muse Code](https://dev.meta.ai) — Meta's `muse` — uses a preinstalled mise stub like the other agents. Picking it as the default installs Meta's official launcher through mise's HTTP backend. The launcher verifies and updates the native binary for your machine. + Once you've chosen, `Super + Shift + Ctrl + A` launches the default agent in a dedicated terminal window (or brings up the picker if you haven't chosen yet). You can also launch it straight into a task with `omarchy agent prompt "Review this project"`. Agents launched this way run unattended in their respective don't-stop-to-ask modes, so be ready for them to actually do things! And since agents refuse to remember trust for your home directory, launches from `$HOME` start in `~/Work` instead. -There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, and OpenCode all follow along when you switch the Omarchy theme. +There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, OpenCode, and Hermes (once Hermes Desktop is installed) all follow along when you switch the Omarchy theme. ### The agents panel @@ -45,9 +48,9 @@ Crashes can also be silenced one program at a time, which is what the diagnosis ### Desktop apps -The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, Hermes Desktop, and OpenClaw. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. -Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself. +Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it. diff --git a/manual/31-dotfiles.md b/manual/31-dotfiles.md index 11e0d7623c..ec148b445b 100644 --- a/manual/31-dotfiles.md +++ b/manual/31-dotfiles.md @@ -66,10 +66,11 @@ Look, this is your computer. You can do whatever you want with it, but I would a You can change just about everything that way, like the default keybindings. Just edit `~/.config/hypr/bindings.lua` to, say, replace [Obsidian](https://obsidian.md/) with [Joplin](https://joplinapp.org/) (install with `omarchy-pkg-add joplin-bin`): +```lua +o.rebind("SUPER + SHIFT + O", "Joplin", "joplin-desktop") ``` -hl.unbind("SUPER + SHIFT + O") -o.bind("SUPER + SHIFT + O", "Joplin", "joplin-desktop") -``` + +`o.rebind` removes the existing binding before adding its replacement. It takes the same arguments as `o.bind`, including launch helpers and binding options. Use `o.bind` to add a binding, or `hl.unbind` to remove one without replacing it. If you insist on hacking on the internal Omarchy files, switch to the dev channel via _Update > Channel > Dev_. That links Omarchy to a git checkout of the source code in `~/omarchy`, which you're free to change to your heart's content. Ain't nobody here to tell you what to do! diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 6edec3911c..f2cd51eb69 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,4 +4,6 @@ Every theme ships with its own set of backgrounds, and you can add extras of you You can do this most easily by going to _Install > Style > Background_ in the Omarchy Menu. That'll bring up the folder where the backgrounds for that theme is stored. Hit `Super + Shift + F` to start another file manager, find your background, copy it over. Now it'll be included in the choices of backgrounds you can select between using `Super + Ctrl + Space`. +Backgrounds can be videos as well as stills. Drop an `mp4`, `m4v`, `mov`, `webm`, `mkv`, or `avi` file in the same folder and it appears alongside the images, playing on a loop. Only your first monitor's wallpaper plays a video's sound track, through the default audio output at the system volume, and the lock screen stays silent. Playback stops on its own whenever nothing can see it — while a fullscreen window covers that monitor, while the screensaver is up, and once a locked screen has gone dark — but a video wallpaper still costs far more power than a still one, and each monitor decodes its own copy. + You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. diff --git a/migrations/1785090473.sh b/migrations/1785090473.sh index 1e64b6ee54..ca75cf2c46 100644 --- a/migrations/1785090473.sh +++ b/migrations/1785090473.sh @@ -1,17 +1,8 @@ -echo "Switch fingerprint support back to stock libfprint" +echo "Repair fingerprint support left without a libfprint" -# libfprint-git existed to carry the focaltech_moc driver and the FocalTech -# FT9349 device ID (2808:a97a) before any release shipped them. libfprint -# 1.94.100 has both, so fingerprint setups go back to the stock Arch package. - -# The remove/install pair below isn't one transaction: if the install failed -# on a previous run, libfprint-git is already gone but fprintd is left with -# no libfprint — the elif finishes the job on rerun. -if pacman -Q libfprint-git &>/dev/null; then - # Deps-only removal keeps fprintd installed while its libfprint - # dependency is swapped out underneath it. - sudo pacman -Rdd --noconfirm libfprint-git - omarchy-pkg-add libfprint -elif pacman -Q fprintd &>/dev/null && ! pacman -Q libfprint &>/dev/null; then - omarchy-pkg-add libfprint +# An earlier version of this migration swapped libfprint-git for stock +# libfprint in two steps. A run that failed between them left fprintd with +# no library; finish with the driver the fingerprint setup installs now. +if omarchy-pkg-present fprintd && omarchy-pkg-missing libfprint && omarchy-pkg-missing libfprint-git; then + omarchy-pkg-add libfprint-git fi diff --git a/migrations/1786609204.sh b/migrations/1786609204.sh new file mode 100644 index 0000000000..b783309208 --- /dev/null +++ b/migrations/1786609204.sh @@ -0,0 +1,3 @@ +echo "Install native video wallpaper playback dependencies" + +omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg diff --git a/migrations/1788577553.sh b/migrations/1788577553.sh new file mode 100644 index 0000000000..12ca0d6a5a --- /dev/null +++ b/migrations/1788577553.sh @@ -0,0 +1,6 @@ +echo "Skip the upstream Cursor CLI mise wrapper" + +# This fork installs Cursor Agent from Cursor's official Linux tarball +# (omarchy-install-cursor-agent) when it is chosen as the default agent. +# The mise wrapper would sit on PATH and look installed while still being a +# stub, so we do not write one here. diff --git a/migrations/1788619462.sh b/migrations/1788619462.sh new file mode 100644 index 0000000000..0b49167935 --- /dev/null +++ b/migrations/1788619462.sh @@ -0,0 +1,9 @@ +echo "Hand Hermes Desktop the Omarchy theme as a skin" + +# Only the app Omarchy installed under Install > AI follows the theme by itself. +# A Hermes the user set up some other way keeps whatever skin they chose. +omarchy-pkg-present hermes-desktop || exit 0 + +# The same hand-over a fresh install does. A Hermes that is not ready or refuses +# the write is reported and done with there; only Omarchy's own failures return. +omarchy-theme-set-hermes --activate diff --git a/migrations/1788724825.sh b/migrations/1788724825.sh new file mode 100644 index 0000000000..cc76dc0f13 --- /dev/null +++ b/migrations/1788724825.sh @@ -0,0 +1,5 @@ +echo "Install Muse Code via mise wrapper" + +if omarchy-cmd-missing muse && [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install "http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" muse +fi diff --git a/plans/nix.md b/plans/nix.md new file mode 100644 index 0000000000..b1a8ce315b --- /dev/null +++ b/plans/nix.md @@ -0,0 +1,150 @@ +# Plan: Nix — replace Arch with a sovereign Nix foundation + +Revision 2. Rev 2 incorporates adversarial review by codex (xhigh): atomicity restated as atomic selection rather than transactional activation, staged switches for major updates, password hashes kept out of the store, a legal-redistribution gate for unfree packages, precise sovereignty boundaries (mise, fwupd, Steam, Cloudflare), a signed release manifest with anti-rollback, source-rebuild proof in the continuity gate, garbage-collection policy, and a substantially hardened migration: supported-layout gating, live-probed hardware config, an explicit boot transaction with user blessing, state-divergence policy for the shared home, and two-stage rollback. + +## Problem + +Omarchy spends a remarkable amount of its code protecting users from its own package manager. The scars are all pacman-shaped: + +- `omarchy-update-system-pkgs-when-conflicted` is ~150 lines of quarantine choreography — stash unowned conflicting files under `/var/lib/omarchy/replaced`, retry, restore what the upgrade didn't claim — because pacman refuses to own file conflicts. +- The `etc-overrides/` mechanism (`docs/file-layout.md`) exists solely because pacman won't let two packages touch the same `/etc` file, so we ship copies to `/usr/share/omarchy/etc-overrides/` and `cp -f` them into place from scriptlets. +- An ALPM hook (`00-omarchy-update-guard.hook`) aborts direct `pacman -Syu` because updates that bypass `omarchy update` skip the coordination around them — snapshots, migrations, hooks, restart checks; we built a guard to keep users away from the distribution's own tooling. +- The keyring dance in `omarchy-update-keyring` bootstraps trust through `keys.openpgp.org`, and `etc/gnupg/dirmngr.conf` lists five more external keyservers — our signature chain roots outside our infrastructure. +- Updates are not atomic, so we bolted atomicity on: snapper snapshots plus `limine-snapper-sync` approximate what the package manager can't promise, and `docs/update-process.md` still lists pacnew/pacsave handling as an open wound. +- `omarchy-upgrade-to-quattro` is 2,389 lines. That is what it costs to move a fleet of mutable, individually-drifted Arch installs through one package-layout transition. + +And sovereignty is only half-won. We already run the hosting — `mirror.omarchy.org` serves core/extra/multilib, `pkgs.omarchy.org` serves the `[omarchy]` repo, stable deliberately trails upstream Arch by a month (`manual/30-updates.md`) — but we don't own the substance. Arch decides what a "system upgrade" contains and when soname bumps land; we inherit every decision a day later and can only delay it. The AUR path (`omarchy-pkg-aur-*`, the Install menu) executes unsigned build scripts fetched live from `aur.archlinux.org`. T2 Macs add a GitHub-hosted third-party repo with `SigLevel = Never` (`install/hardware/pacman.sh`). And because every install mutates independently, no two Omarchy machines run the same bytes — "we tested this update" is a statement about our machine, not yours. + +Nix fixes the category, not the symptoms. A NixOS system is a closure: one immutable tree of store paths containing every package, config file, and service definition, built once, signed once, and selected atomically — the running system is a symlink flip to a complete generation, and the old one stays bootable. Rollback is booting the previous generation; file conflicts and pacnew files are structurally impossible; and every machine's packages are the byte-identical store paths we built and tested (the thin top-level closure that composes them — hostname, disk UUIDs, the user's package manifest — is assembled per machine; the payload is not). To be precise about what is and isn't atomic: *selecting* a generation is atomic, *activating* one is a sequence — services stop, activation scripts run, services start — and a step in that sequence can fail. The design below stages risky switches across a reboot for exactly that reason. The catch is that the Nix ecosystem assumes nixos.org: `cache.nixos.org` as substituter, nixpkgs from GitHub, channels from `channels.nixos.org`, an install script piped from their web server. This plan takes the technology and none of the hosting. + +## Shape + +- Omarchy becomes a NixOS-based system whose entire supply chain runs on omarchy.org infrastructure: a pinned nixpkgs fork on our git hosting, closures built on our build farm, binaries served from our signed cache. A user's machine never contacts nixos.org, cache.nixos.org, or GitHub for OS concerns — the same posture `pkgs.omarchy.org` and the mirrors have today, extended until it covers everything. +- Users don't learn Nix. The `omarchy` CLI keeps its verbs (`omarchy-pkg-add`, `omarchy update`, `omarchy-channel-set`), `~/.config` stays your mutable files, themes and the refresh pattern are untouched. Nix is plumbing, exactly as pacman was plumbing — it just leaks less. +- An update is: fetch prebuilt, signed store paths from our cache, compose the new generation, activate it — across a reboot when the jump is big — and keep the old generation bootable. What we ship is what we tested, store path for store path. + +## Sovereignty, precisely + +"Sovereign" means two different things at two different times, and the plan should be honest about which is which: + +- **Runtime sovereignty (absolute, for OS delivery)**: an installed machine resolves every OS need — binaries, sources, expressions, signatures, update metadata — against omarchy.org hosts only. No fallback substituters, no keyservers, no GitHub fetches, no upstream flake registry. If nixos.org vanished tomorrow, no user would notice. +- **Build-time sovereignty (continuity)**: our infrastructure ingests from upstream nixpkgs at development time, then archives everything — the nixpkgs tree in our git mirror, every source tarball in our archive, every build product *and its build closure* (sources, patches, derivations, the compilers that made it) in our cache. If upstream vanished, we could keep building, patching, and releasing from what we hold, indefinitely. What we do not claim: re-deriving the world from a bootstrap seed. Nixpkgs' standard binary bootstrap tarballs are part of what we mirror and trust; full source-bootstrap purity is out of scope. + +The boundary is OS delivery, and the plan names what sits outside it rather than letting "absolute" quietly overclaim. `mise`-managed tools pull from GitHub and language registries; fwupd firmware comes from LVFS; Steam downloads Valve's content; browsers update their own components. Those are application-content channels the user chose, not OS delivery, and they keep working — but each gets an explicit decision (mirror it, repoint it, or declare it outside the promise) instead of an assumption. The dev channel's GitHub clone in `omarchy-channel-set` repoints to our git hosting. And Cloudflare stays as the DDoS shield and CDN (`manual/48-security.md`), but the cache origin is storage we control, with a documented path to serve it from elsewhere — a CDN in front of sovereign infrastructure, never the only copy of it. + +The release gate makes this testable, in two parts. Delivery: a release is publishable only if a clean machine, with outbound network restricted to omarchy.org, can install the ISO, update, and install every curated extra. Continuity: from an empty store, with binary substitution disabled and only our source archive reachable, the release closure must rebuild — proving we archived the build inputs, not just the outputs. Sovereignty becomes a CI assertion instead of an aspiration. + +## Rejected approaches + +- **Nix on top of Arch** (Nix as a secondary package manager, Arch stays the base): two package managers, two update pipelines, two failure modes, and the worst properties of both — pacman still owns the system, so none of the atomicity or reproducibility arrives where it matters. The halfway house costs most of the migration and delivers little of the payoff. +- **Guix**: the same functional model with a nicer language, but its FSDG-purist stance on proprietary firmware, microcode, and NVIDIA drivers means fighting the distribution on exactly the hardware enablement (`install/hardware/` is 51 leaves deep) that Omarchy considers table stakes. Nonguix exists; building a product on an unofficial channel the project disowns is not a foundation. +- **cache.nixos.org as fallback substituter**: the tempting hedge — use our cache first, theirs when we miss. It silently converts every gap in our build coverage into an external runtime dependency, which is precisely the failure mode this plan exists to eliminate. Misses should fail loudly and get fixed in our farm, not papered over by someone else's CDN. +- **Hydra for the build farm**: the canonical Nix CI is a sprawling Perl application that is its own operational project. Our release matrix is a known, finite list of targets; plain `nix build` over that list in ordinary CI, followed by `nix copy` to the cache, does the job with tooling we already understand. +- **A live binary-cache daemon** (Attic, Harmonia): a Nix binary cache is narinfo and nar files — static content. Object storage behind Cloudflare is the same shape as the pacman repo we serve today, has no attack surface, and scales for free. A daemon earns its keep only if we later want deduplicating storage across many releases; start dumb. +- **home-manager for user configs**: it would make `~/.config` a farm of read-only symlinks into the store, which is the opposite of Omarchy's "your files" philosophy (`plans/dots.md` exists because those files are yours to edit). The declarative boundary stops at the system layer; the user layer stays mutable plain files. +- **Image-based atomicity instead** (ostree/Silverblue-style, or A/B partitions): atomic, but at image granularity — you get our image or you get nothing, and local package additions become a bolted-on overlay mechanism. Nix gives the same atomicity at package granularity, so `omarchy-pkg-add` keeps meaning something. +- **Staying on Arch and hardening further**: the baseline. Every mitigation above can be polished, but they remain mitigations for structural properties — mutability, non-atomicity, conflict-prone file ownership — that pacman cannot shed. We would be signing up to maintain the workaround museum forever. + +## Design + +### Supply chain + +- **nixpkgs fork**: a mirror of nixpkgs on our git hosting, plus an `omarchy` branch carrying our patches (the successor to `omarchy-pkgs`' PKGBUILD patches). Each release pins an exact revision. Flake inputs reference our tarball endpoint (`https://mirror.omarchy.org/src/nixpkgs-.tar.gz`) with the lockfile's `narHash` pinning content, so even the expression source is fetched from us and integrity-checked. +- **Source archive**: builders fetch upstream sources once, at ingestion; every fixed-output derivation's output is then held in our cache and our source mirror. `hashedMirrors` pointed at omarchy.org covers `fetchurl`, but it is a hint, not a boundary — `fetchgit`, flake fetchers, and language-ecosystem fetchers each need their own mirroring, and a cache miss makes Nix try a local build whose fetcher will happily call GitHub. So the boundary is enforced where it can't be forgotten: builder and client network policy allows omarchy.org only, and a miss *fails loudly* — a hole in our archive is a bug to fix in the farm, never a silent fallback to upstream. Rebuilds never need the original upstream URL to still exist. +- **The omarchy flake**: lives where `omarchy-pkgs` lives today — same repo split as now (this repo is the runtime; the packaging repo owns pins, the overlay of packages nixpkgs lacks, and the NixOS modules; `omarchy-iso` owns the installer). The T2 Mac kernel and the `linux-ptl` kernel move from third-party repos and AUR-adjacent sources into our overlay, built and signed on our farm — which closes today's `SigLevel = Never` hole outright. + +### Binary cache and trust + +- `cache.omarchy.org`: narinfo + nar objects on object storage behind Cloudflare, populated by `nix copy` from the farm, signed with an Omarchy ed25519 cache key. Released objects are write-once (object-locked): a nondeterministic rebuild must never silently replace a narinfo the fleet already trusts. +- Cache signatures authenticate store paths; they do not say "this is the current stable release." That job belongs to a **release manifest**: a small document per channel naming the release version, the exact top-level closure hashes per hardware variant, and an expiry — signed offline with a release key that is *separate* from the cache key, monotonically versioned so a compromised CDN cannot replay last month's release, and re-signed on a cadence so a frozen mirror goes stale loudly. `omarchy-update-available` and the update flow trust the manifest first, paths second. Key hygiene — build key, cache key, release key, rotation, and revocation — is a Phase 0 deliverable with a rehearsed compromise-recovery runbook, not an appendix. +- Client `nix.conf` (owned by our NixOS module, not user-editable state): `substituters = https://cache.omarchy.org` — nothing else, replacing the default cache.nixos.org entirely; `trusted-public-keys` lists only our key; the flake registry is pinned to our own registry file so bare flake references cannot reach GitHub. +- Trust roots: the cache and release public keys ship inside the ISO and the installed closure. `keys.openpgp.org`, `archlinux-keyring`, `omarchy-update-keyring`, and the five keyservers in `etc/gnupg/dirmngr.conf` all leave the OS trust path (gnupg remains for the user's own purposes). + +### Build farm + +Our own builders run `nix build` over the release matrix: the base system closure per hardware variant (NVIDIA open/legacy, T2, `linux-ptl`, plain), every optional package behind the Install menu and `omarchy-install-*`, and the ISO. A release job then verifies the gate: every store path in every target closure must be substitutable from `cache.omarchy.org` before the release tag is signed. Nothing a user can reach through blessed UI may miss the cache. + +One gate is legal, not technical: nixpkgs distinguishes redistributable-unfree from unfree-you-may-not-redistribute, and serving a package from our cache *is* redistribution. NVIDIA userspace drivers (nixpkgs patches them), VS Code, Chrome, vendor firmware, and printer blobs each need a per-package answer in Phase 0: confirmed redistribution rights, a redistributable substitute (VSCodium-shaped choices), or a blessed vendor-fetch exception — which is a named, per-package hole in the runtime-sovereignty claim, recorded as such rather than discovered later. No package enters the curated set without landing in one of those three buckets. + +### The system layer + +- Everything under `install/config/`, `install/hardware/`, and the `etc/` tree becomes NixOS module code: `services.displayManager.sddm`, `boot.plymouth`, snapper, docker, cups hardening, the sysctl/sudoers/tmpfiles drop-ins, the NVIDIA modprobe and initrd logic that today lives as conditional bash inside `etc/mkinitcpio.conf.d/omarchy_hooks.conf`. `omarchy-apply-system` and `omarchy-apply-hardware` become module imports plus hardware-variant selection instead of sourced shell leaves — and the entire `etc-overrides/` mechanism is deleted, because composing `/etc` from multiple sources is what the module system is. +- **Bootloader**: limine stays — NixOS ships a `boot.loader.limine` module — but its job changes: boot entries are system generations, not snapper snapshots, so `limine-snapper-sync` and `limine-mkinitcpio` retire. The UKI and fallback-entry behavior configured in `etc/limine-entry-tool.d/` and the direct-boot path (`omarchy-setup-direct-boot`) must be reproduced deliberately — upstream's limine/UKI story is still settling — and boot security is its own workstream: Secure Boot stays explicitly unsupported (as `manual/02-getting-started.md` says today) unless that workstream designs key enrollment, measurement, and recovery properly; it does not sneak in as a module default. +- **Per-machine composition, budgeted**: the cache delivers every package prebuilt, but each machine still evaluates and assembles its thin top-level closure — `/etc`, initrd, activation scripts — locally on every switch. That cost is real on low-end hardware and gets a measured budget (time and memory, on the weakest supported machines) in the acceptance suite, not an assumption that "everything substitutes, so it's fast." +- **A supported customization layer**: `/etc` becoming module-owned cannot mean "hope nobody needed to change it." Mounts, sudo rules, kernel parameters, and service tweaks are system concerns with no home-directory equivalent, so the machine gets a blessed local-override file the modules import — real Nix options, documented, surviving updates — and every managed `/etc` file has a named owner. Coordination that today hides behind the pacman guard (migrations, hooks, restart markers) moves into activation-time logic keyed by release version, so even a user running `nixos-rebuild` directly cannot skip it: `omarchy update` stays the pleasant path, but correctness no longer depends on being the only path. +- **Store hygiene**: closures don't orphan, but unreferenced store paths accumulate and old generations are what rollback is made of — so garbage collection is policy, not an afterthought: automatic GC with a generation-retention window, a cap on boot-menu generations, and a free-space floor, sized so the store's steady state on a user disk compares honestly with today's pruned pacman cache. +- **Filesystem**: btrfs stays for `/home` (snapper's remaining job: user-file snapshots, until `plans/backup.md` and `plans/dots.md` cover that ground) and for `omarchy-system-factory-reset`'s subvolume mechanics — though the reset workflow itself (the `@factory` baseline, UKI rebuild, LUKS re-key) must be ported, and the restore guarantee narrows honestly: booting an old generation restores the OS, not mutable `/var` state the old root snapshots used to carry. `omarchy-snapshot restore` for the OS becomes "boot the previous generation." + +### The user layer stays mutable + +Non-negotiable: `~/.config` remains plain files the user owns and edits. `/etc/skel` seeding, `omarchy-refresh-config`, themes, and the entire `default/` → `~/.config` pipeline work unchanged. The declarative world ends at the system/user boundary; crossing it (home-manager) is rejected above. This is the line that keeps Omarchy feeling like Omarchy rather than like NixOS. + +### Package UX + +- The machine grows a package manifest — a plain text list in the spirit of `install/omarchy-base.packages`, owned by the machine, listing what this user added. `omarchy-pkg-add ` resolves the name (an alias table maps established Arch names to nixpkgs attributes, so muscle memory and the menu's package names keep working), appends to the manifest, and rebuilds against our cache — prebuilt, so "rebuild" means download, a local re-evaluation, and a switch: never a compile, and held to the per-machine composition budget above rather than assumed fast. `omarchy-pkg-drop` removes and rebuilds. `pkg-present`/`pkg-missing` query the running closure. +- The Quickshell menu's guard prelude (`shell/plugins/menu/MenuModel.js` snapshots `pacman -Qq` plus a Provides parse because forking per guard "spends over a second") gets simpler and faster: one listing of the current closure's package set, computed at activation time and cached, replaces the pacman queries. +- **The AUR is gone, replaced by the curated extras set**: everything the Install menu offers today (Chrome, Brave, Zen, VS Code, Steam and the lib32 Vulkan stack via nixpkgs' 32-bit support, and friends) comes from nixpkgs or our overlay, built and signed on our farm — the first time Omarchy's optional software carries the same signature chain as its core. Arbitrary AUR browsing (`omarchy-pkg-aur-install`) has no sovereign equivalent and is not replaced. The escape hatch for power users — adding their own flakes or substituters — is real Nix, documented as leaving the supported, sovereign envelope, and never wired into blessed UI. + +### Updates, channels, migrations + +- `omarchy-update` keeps its skeleton — transcript, lock, free-space check, confirm, stay-awake, migrations, hooks, `omarchy-update-restart` — and swaps its heart: the pacman transaction becomes "download the release closure from the cache, then switch." Failure before activation leaves the running system untouched, and the failed download costs nothing. Activation itself is the sequence that can still hurt — services stop, scripts run, services start — so routine updates switch live, while kernel and other big jumps stage as the *next boot's* generation and activate through the reboot `omarchy-update-restart` already prompts for. `omarchy-update-analyze-logs` survives with a shorter beat: activation and service-restart failures still deserve forensics; package transactions no longer do. And rolling back a generation rolls back the OS, not `/var` — a service that migrated its database forward needs its own story, which is what snapshots-before-update remain for. +- Deleted outright, with the failure modes they existed for: `omarchy-update-keyring`, `omarchy-update-pkg-prune`, `omarchy-update-system-pkgs-when-conflicted`, `omarchy-update-pacman-guard` and the ALPM hooks, `omarchy-update-orphan-pkgs` (replaced by the GC policy above), `omarchy-update-aur-pkgs`, and the pacnew concern. The guard's job — "don't update behind Omarchy's back" — is covered by the activation-time coordination described above, which runs no matter who triggers the switch. +- **Channels**: `stable`/`rc`/`edge` become branches of the omarchy flake with their own nixpkgs pins and their own cache prefixes, mirroring today's three pacman.conf templates. `omarchy-channel-set` flips the flake reference and switches. `dev` keeps its meaning: a local checkout via `omarchy-dev-link`, with `omarchy update` fast-forwarding it as now. +- **Version**: real at last. `omarchy-version` reports the release tag of the running closure instead of deriving it from `pacman -Q`; `omarchy-update-available` compares that against a small release-manifest JSON on the cache host instead of running `checkupdates`. +- **Migrations** (`migrations/`, 94 files) shrink to their legitimate residue: user-space state under `$HOME`. The 14 that touch pacman/limine/mkinitcpio have no successors — system-state transitions become module code that is simply part of the next closure. The per-user marker mechanism and `omarchy-migrate-notify` survive for what remains. + +### ISO and installer + +`omarchy-iso` rebuilds around a NixOS ISO carrying the full release closure in its store. Offline installation becomes `nix copy` from the ISO's store to the target plus writing the hardware module selection and the machine manifest — structurally the same "offline mirror" trick the ISO does today with pacman packages, minus the post-install `pacman.conf` restore dance (`install/post-install/pacman.sh`). The ISO signature chain (`iso.omarchy.org`, `.sig`) is unchanged. + +## Migrating from Quattro to Cinque + +`omarchy-upgrade-to-quattro`'s 2,389 lines are the cautionary tale for what in-place transitions cost — and that one didn't change the package manager. But Quattro's standard disk layout is the opportunity: root on a btrfs subvolume (`@`) with `/home` on its own (`@home`), inside one LUKS container, under a bootloader that already knows how to offer multiple roots. That layout lets Cinque move in *beside* Quattro instead of on top of it. + +### The parallel-root migration + +`omarchy-upgrade-to-cinque` ships as an ordinary Quattro package update, the same delivery path the v3→v4 upgrader used. It never runs unprompted — migration is an explicit user action, announced through the usual channels, never something `omarchy update` springs on anyone. + +1. **Preflight, running system untouched**: the migrator supports the standard layout — btrfs root on `@`, `/home` on `@home`, one LUKS container, limine — and *refuses* everything else (LVM, RAID, exotic mount graphs, hand-built boot chains) toward the reinstall path; `omarchy-system-factory-reset` already gates on the same layout for the same reason, and for boot and storage, "I don't recognize this" is a blocker, not a warning. Then: a hardware gate — the machine's variant (NVIDIA generation, T2, `linux-ptl`) must have a built Cinque closure in the cache, or the migrator refuses with "not yet" rather than "hope so"; a space gate computed from the actual NAR sizes the cache reports plus the retained Quattro root, btrfs metadata headroom, and ESP room for both systems' boot artifacts (the fixed 10 GiB check in `omarchy-update-requires-free-space` is not an estimator); hibernation detection — a suspended image or the swap-subvolume setup from `omarchy-hibernation-setup` is invalidated and its resume configuration carried or rebuilt, because resuming one OS's hibernation image from the other corrupts the filesystem; and the inventory that feeds the *won't-survive report* (see below), which the user reads before consenting. +2. **Fetch**: the release closure downloads from `cache.omarchy.org` into a fresh `@cinque` subvolume's `/nix` store — resumable, verifiable against signatures, and entirely inert while Quattro keeps running. The sovereignty gate applies here too: the whole migration touches only omarchy.org hosts. +3. **Carry state**: the partition table, LUKS container, and `@home` are untouched — Cinque mounts the same `/home`. The machine's module configuration is generated from the *live* system — current mounts, `fstab`, `crypttab`, `lsblk`, `/proc/cmdline` — not from a replay of historical hardware detection, and the generated initrd is validated before anything is asked to boot from it. Accounts carry as the full database, not a hash import: `/etc/passwd`, `/etc/shadow`, groups, and NixOS's ID-stability state move as root-only files with `users.mutableUsers` on — password hashes must never be interpolated into the world-readable store. `machine-id`, SSH host keys, and NetworkManager connections come along; `/var/lib` payloads that are data rather than OS (docker volumes chief among them) are copied with their services stopped — a reflink copy of a live database is cheap and worthless. +4. **First boot, Quattro still the default**: the migrator adds a Cinque boot entry inside a deliberate boot transaction — the ESP contents and firmware boot variables are inventoried and backed up first, foreign entries (Windows, other distros, the fallback loader) are preserved, and machines using `omarchy-setup-direct-boot`'s NVRAM path get that path handled explicitly. The user boots Cinque by choosing it; limine has no proven boot-once/boot-counting mechanism today, so *blessing is a human act*: first-boot verification (graphical session reached, network up, closure healthy) presents its results and asks before Cinque becomes the default. A failed boot needs no cleverness — the default was still Quattro, and a diagnostic bundle waits for `omarchy-upload-log`. +5. **Rollback window, then reclaim**: at cutover the migrator snapshots `@home` — the two systems share a live home from here on, and applications will migrate profiles and state forward in formats the old side may not read, so a real return to Quattro needs that anchor to offer. `omarchy-upgrade-to-cinque --rollback` is two-stage by construction: it makes Quattro the default and reboots into it; only then, from the running Quattro, does it offer to restore the home snapshot (with post-cutover writes preserved alongside, never silently discarded) and remove `@cinque` — a system never deletes the root it is running on. In the other direction, `--reclaim` (or the update pipeline, after enough clean boots — open question) deletes the Quattro root and returns the space. + +Rollback is a reboot plus a decision about state, and the plan says so — the OS comes back untouched by menu choice; the shared home's forward drift is what the cutover snapshot exists to answer. That is still a property no in-place mechanism can offer, and it is what makes offering the migration to a fleet responsible rather than reckless. + +One wrinkle owned explicitly: during the window, exactly one side owns the bootloader — Cinque, from the moment its entry is blessed. The Quattro root is kept bootable but frozen — the migrator's only writes into it are disabling `limine-snapper-sync` and the update timers, because two operating systems regenerating one boot configuration is how both stop booting. Booting Quattro during the window is for rescue and rollback, not for continued dual life; the way back to a *living* Quattro is `--rollback`, which returns bootloader ownership along with the default. + +### The won't-survive report + +Some of what a Quattro machine accumulated has no Cinque equivalent, and the preflight says so per-machine, before anything changes: + +- **Packages the user added**: the delta of `pacman -Qqe` against the Quattro release baseline (the raw list would drown the signal in the base system), run through the alias table into the manifest; AUR packages without an overlay equivalent (`pacman -Qem` minus the curated set) are listed by name with the escape-hatch documentation linked. Not a blocker — the user decides. +- **Custom pacman repos**: both the `pre-refresh-pacman.d` hook layer and repos hand-added to `pacman.conf`, named as unsupported since the mechanism itself retires. +- **System-level drift**: `pacman -Qii` backup-file diffs are the start, not the whole story — the scan also covers unowned files in `/etc` (`omarchy-update-system-pkgs-when-conflicted`'s quarantine logic proves we can tell), package-file divergence via `pacman -Qkk`, locally enabled or masked systemd units and drop-ins, DKMS modules, printer configuration, and firewall rules. Everything found is listed so the user can carry the *intent* forward — into `~/.config`, an Omarchy setting, or Cinque's local-override module — instead of silently losing edits. Drift in boot or storage configuration is a blocker, per the preflight. + +Per-user state needs no migration at all: migration markers, themes, and everything else under `/home` ride along on `@home`. Dev-link users get their checkout fast-forwarded onto the Cinque branch by the migrator rather than a package swap. + +### Rejected migration paths + +- **`NIXOS_LUSTRATE` in place**: the historical takeover mechanism mutates the only root the machine has — a failure mid-lustrate is an unbootable machine and a restore from backup — and upstream is deprecating it (it doesn't work with the now-default systemd initrd, and NixOS's own guidance points at install-to-another-root instead, which is exactly what the parallel subvolume is). The parallel root delivers everything lustrate promised, plus a rollback that is just a boot-menu choice. Machines without room for two roots get "free up space first," not a reason to lose the rollback. +- **Reinstall as the only path**: always supported, documented, and cheap once `plans/backup.md` and `plans/dots.md` land (which this plan therefore treats as prerequisites, not nice-to-haves) — but a migration path only matters if the fleet actually takes it, and "back up, reflash, restore" is where fleets quietly decide to stay behind. Reinstall is the fallback, not the offer. +- **Automatic migration through `omarchy update`**: never. Changing a user's operating system's foundation is a decision, not an update. + +## Rollout + +- **Phase 0 — infrastructure, zero user impact**: nixpkgs mirror and tarball endpoint, source archive, `cache.omarchy.org`, the key hierarchy (build/cache/release, offline signing workflow, compromise runbook), the signed release-manifest format, the legal-redistribution inventory for the curated set, the build farm, and a CI job that builds the current desktop's equivalent closure and proves both halves of the sovereignty gate (delivery with outbound network restricted to omarchy.org; rebuild from the source archive with substitution disabled). +- **Phase 1 — system parity** (packaging repo, with changes here): NixOS modules covering every `install/config/`, `install/hardware/`, and `etc/` entry; the flake with per-channel pins; boots and passes the graphical acceptance suite in the VM (`agents/skills/acceptance-tests.md`). +- **Phase 2 — CLI port** (this repo): `pkg-*`, `update-*`, `channel-*`, `version-*`, snapshot/restore semantics, menu guards; delete the pacman-only organs; port the 18 pacman/yay-mocking test files in `test/shell.d/` to the new seams. +- **Phase 3 — ISO and installer** (`omarchy-iso`): the offline NixOS ISO, installer flow, hardware detection wiring into module selection. +- **Phase 4 — release and overlap**: ship as the next major; maintain the Quattro channels in parallel through the overlap window; deliver `omarchy-upgrade-to-cinque` as a Quattro package update, with the reinstall-with-restore path documented as the fallback. +- **Docs and tests**: `docs/update-process.md` rewritten around the switch model; a new `docs/` reference for the sovereignty gate and cache/mirror topology; manual chapters for updating, rollback-by-generation, and the extras set; shell tests for manifest editing, alias resolution, channel flips, and guard-free update flow; switch-time and evaluation budgets measured on the weakest supported hardware in the acceptance suite; the release-gate CI assertion is itself the sovereignty test. + +## Open questions + +1. **Which Nix**: upstream CppNix is the safe default; Lix is an argument about governance and pace we don't strictly need to have while we're rehosting everything anyway. Whichever we pick, users get it from our ISO and our cache — never from an install script on someone else's domain. +2. **Flakes or stable evaluation**: flakes are the ecosystem's lingua franca but formally still experimental upstream. Since we pin our own Nix, we can adopt flakes and own the flag — or use plain evaluation with explicit pins and lose some tooling. Leaning flakes; deserves a deliberate decision. +3. **How far the curated extras set reaches**: nixpkgs holds ~100k packages; we will build and cache hundreds, not all of it. What is the story when a user wants a package outside the set — a request pipeline into the overlay, the documented unsupported escape hatch, or both? +4. **Reclaim policy** for the migration's rollback window: does the retained Quattro root get deleted only by explicit `--reclaim`, or automatically after N clean Cinque boots — and how long is a responsible default window on space-constrained disks? +5. **Btrfs by default, still**: with system rollback moved to generations, btrfs earns its place only through `/home` snapshots and factory reset. Keep it, or simplify the default filesystem story? +6. **Naming and posture**: "powered by Nix" is a fact; "a NixOS derivative" is a relationship with trademark and community expectations attached. How loudly do we say which — and does sovereign rehosting change what we ought to call it? diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index ca265acda4..14af44fed9 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -44,6 +44,10 @@ QtObject { return "file://" + String(path).split("/").map(encodeURIComponent).join("/") } + function isVideoPath(path) { + return /\.(mp4|m4v|mov|webm|mkv|avi)$/i.test(String(path || "")) + } + // Single-quote a string for bash. The replace handles embedded single // quotes by closing, escaping, and re-opening the literal. function shellQuote(value) { diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml new file mode 100644 index 0000000000..b281ae223a --- /dev/null +++ b/shell/Ui/BackgroundMedia.qml @@ -0,0 +1,87 @@ +import QtQuick +import qs.Commons + +Item { + id: root + + property string path: "" + property int version: 0 + property bool playbackEnabled: true + property bool audioEnabled: false + // Bumped when the file behind an unchanged path may have been replaced. + // Images cache-bust through version; a video is rebuilt, since FFmpeg + // would read a query as part of the filename. + property int reloads: 0 + property bool reloading: false + readonly property var current: video ? videoLoader.item : imageLoader.item + readonly property bool ready: current ? current.ready : false + readonly property bool video: Util.isVideoPath(path) + // Cache-bust images selected in a running lock session. FFmpeg treats the + // query as part of a local filename, so videos must keep their plain URL. + // Each URL is empty for the other kind, so a switch never hands the still + // loader a video, or the player a still, in the moment before it unloads. + // Both test the path directly: going through `video` lets a URL evaluate + // against the stale flag and leak the wrong file for one pass. + readonly property url imageUrl: path && !Util.isVideoPath(path) ? Util.fileUrl(path) + (version ? "?v=" + version : "") : "" + readonly property url videoUrl: path && Util.isVideoPath(path) ? Util.fileUrl(path) : "" + + Loader { + id: imageLoader + anchors.fill: parent + active: root.path !== "" && !root.video + sourceComponent: imageComponent + } + + // Loaded by URL rather than from a Component here, so QtMultimedia and its + // audio dependency closure never map into a session that only shows images. + Loader { + id: videoLoader + anchors.fill: parent + active: root.path !== "" && root.video && !root.reloading + source: "BackgroundVideo.qml" + } + + onReloadsChanged: { + if (!video) return + reloading = true + Qt.callLater(function() { root.reloading = false }) + } + + // A player on its way out keeps its source: pushing an empty one starts a + // load of nothing that its destructor then cancels, which FFmpeg logs. + Binding { + target: videoLoader.item + property: "mediaSource" + value: root.videoUrl + when: videoLoader.item !== null && Util.isVideoPath(root.path) + restoreMode: Binding.RestoreNone + } + + Binding { + target: videoLoader.item + property: "playbackEnabled" + value: root.playbackEnabled + when: videoLoader.item !== null + } + + Binding { + target: videoLoader.item + property: "audioEnabled" + value: root.audioEnabled + when: videoLoader.item !== null + } + + Component { + id: imageComponent + + Image { + readonly property bool ready: status === Image.Ready + source: root.imageUrl + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: root.version === 0 + sourceSize.width: root.version > 0 ? width : 0 + sourceSize.height: root.version > 0 ? height : 0 + } + } +} diff --git a/shell/Ui/BackgroundVideo.qml b/shell/Ui/BackgroundVideo.qml new file mode 100644 index 0000000000..519e8bd99f --- /dev/null +++ b/shell/Ui/BackgroundVideo.qml @@ -0,0 +1,122 @@ +import QtQuick +import QtMultimedia + +// Deliberately a bare MediaPlayer and VideoOutput rather than the Video +// convenience type: Video always builds an AudioOutput, and a muted sink still +// decodes the audio stream and opens an audio client on every output. +Item { + id: root + + property url mediaSource: "" + property bool playbackEnabled: true + property bool audioEnabled: false + property int mediaGeneration: 0 + property bool priming: false + property int primingGeneration: -1 + property bool frameReceived: false + readonly property bool ready: player.hasVideo + + onMediaSourceChanged: { + mediaGeneration += 1 + priming = false + primingGeneration = -1 + frameReceived = false + primePauseTimer.stop() + framePauseTimer.stop() + output.clearOutput() + } + + onPlaybackEnabledChanged: { + priming = false + frameReceived = false + primePauseTimer.stop() + framePauseTimer.stop() + if (playbackEnabled) player.play() + else player.pause() + } + + function pauseAfterPrimedFrame() { + if (!priming + || root.playbackEnabled + || primingGeneration !== root.mediaGeneration) return + + priming = false + primePauseTimer.stop() + framePauseTimer.stop() + player.pause() + } + + // A paused MediaPlayer can load a source without presenting its first frame. + // Prime it until VideoOutput receives a frame, with a timeout so a stalled + // decoder cannot keep running indefinitely on battery. + Timer { + id: primePauseTimer + interval: 1000 + repeat: false + + onTriggered: root.pauseAfterPrimedFrame() + } + + // Receiving a frame means the decoder has produced it, but the scene graph + // may not have committed it yet. Give VideoOutput a render cycle before + // pausing so the first frame is not lost on a source switch. + Timer { + id: framePauseTimer + interval: 50 + repeat: false + + onTriggered: root.pauseAfterPrimedFrame() + } + + VideoOutput { + id: output + anchors.fill: parent + fillMode: VideoOutput.PreserveAspectCrop + } + + // Sound is opted into per output: with a player per monitor, every output + // playing the track would layer copies of it. The sink is only built once + // the media reports a sound track, so a silent file never opens an audio + // client or its threads. Priming a paused player must not be heard. + Loader { + id: audioLoader + active: root.audioEnabled && player.hasAudio + sourceComponent: AudioOutput { + muted: root.priming || !root.playbackEnabled + } + } + + MediaPlayer { + id: player + source: root.mediaSource + videoOutput: output + audioOutput: audioLoader.item + loops: MediaPlayer.Infinite + autoPlay: root.playbackEnabled + onMediaStatusChanged: { + if (mediaStatus !== MediaPlayer.LoadedMedia) return + + if (!root.playbackEnabled) { + root.priming = true + root.primingGeneration = root.mediaGeneration + root.frameReceived = false + primePauseTimer.restart() + } + player.play() + } + } + + Connections { + target: output.videoSink + function onVideoFrameChanged() { + if (player.mediaStatus !== MediaPlayer.BufferedMedia) return + if (!root.priming + || root.playbackEnabled + || root.primingGeneration !== root.mediaGeneration + || root.frameReceived) return + + root.frameReceived = true + framePauseTimer.restart() + } + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index b25bf5ca00..63e8263936 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -3,6 +3,8 @@ module qs.Ui BarIndicator 1.0 BarIndicator.qml BarIconButton 1.0 BarIconButton.qml BarWidget 1.0 BarWidget.qml +BackgroundMedia 1.0 BackgroundMedia.qml +BackgroundVideo 1.0 BackgroundVideo.qml BorderOverlay 1.0 BorderOverlay.qml BorderSurface 1.0 BorderSurface.qml Button 1.0 Button.qml diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 21e1b0a980..dea93a4cf6 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -16,6 +17,7 @@ Item { property string currentBackground: "" property string displayedBackground: "" + property int displayedReloads: 0 property string incomingBackground: "" property string oldBackground: "" property bool finishingTransition: false @@ -26,6 +28,27 @@ Item { property string pendingShellRaw: "" property real revealProgress: 1 + // Injected by the first-party service loader; used to reach the lock and idle + // services so playback can stop whenever nothing can see the wallpaper. + property var shell: null + + // Stop a video wallpaper's decoding whenever it is covered. Qt's FFmpeg + // engine drives its own clock, so an unseen player keeps decoding until it + // is told not to — a locked laptop would otherwise decode until it died. + readonly property var lockService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.lock") : null + readonly property var idleService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.idle") : null + readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null + readonly property bool lockActive: lockService ? lockService.locked : false + readonly property bool screensaverActive: idleService ? idleService.screensaverWindowCount > 0 : false + readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false + // A lock or a screensaver covers every output, so it is decided once here. + // Fullscreen is decided per output below, because it only covers its own. + readonly property bool sessionObscured: lockActive || screensaverActive + + function isVideo(path) { + return Util.isVideoPath(path) + } + function imageUrl(path) { return Util.fileUrl(path) } @@ -50,10 +73,15 @@ Item { revealAnimation.stop() finishingTransition = false - if (instant || !displayedBackground) { + // Video frames are not fed through the image-only reveal stack. Switching + // instantly also avoids decoding two full videos during a transition. + if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" - displayedBackground = path + // A theme switch can replace the file behind an unchanged path, which + // an unchanged property would never pick up. + if (displayedBackground === finalPath) displayedReloads += 1 + displayedBackground = finalPath revealProgress = 1 return } @@ -196,11 +224,24 @@ Item { color: "transparent" // Keep render updates enabled. The background layer has been observed to // lose its committed buffer while parked with updatesEnabled=false, - // leaving a black desktop until omarchy-shell is restarted. The wallpaper - // itself is static, so this favors correctness over a small render-loop - // optimization. + // leaving a black desktop until omarchy-shell is restarted. A still + // wallpaper costs nothing to keep enabled, and a video one is throttled + // by pausing playback rather than by parking the layer. updatesEnabled: true + // Pausing every wallpaper for one fullscreen window would freeze the one + // still on show next to it, which costs a viewer more than it saves. The + // workspace on show here knows whether a fullscreen window covers it, + // wherever focus happens to be. + readonly property var hyprlandMonitor: Hyprland.monitorFor(modelData) + readonly property var visibleWorkspace: hyprlandMonitor ? hyprlandMonitor.activeWorkspace : null + readonly property bool fullscreenHere: visibleWorkspace ? visibleWorkspace.hasFullscreen : false + + // A sound track plays from one output only, or every monitor would + // layer its own copy of it. + readonly property bool firstScreen: Quickshell.screens.length > 0 + && String(Quickshell.screens[0].name || "") === String(modelData.name || "") + property bool maskReady: false function maybeStartReveal() { @@ -218,15 +259,15 @@ Item { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore - Image { + BackgroundMedia { id: base anchors.fill: parent - source: root.imageUrl(root.displayedBackground) - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: true - onStatusChanged: { - if (status === Image.Ready && root.finishingTransition) { + path: root.displayedBackground + reloads: root.displayedReloads + playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere + audioEnabled: panel.firstScreen + onReadyChanged: { + if (ready && root.finishingTransition) { root.incomingBackground = "" root.oldBackground = "" root.finishingTransition = false diff --git a/shell/plugins/image-picker/list.sh b/shell/plugins/image-picker/list.sh index a30e474380..0fb7411723 100755 --- a/shell/plugins/image-picker/list.sh +++ b/shell/plugins/image-picker/list.sh @@ -3,12 +3,18 @@ image_dirs=${1:-} cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/image-selector index_file="$cache_dir/index.tsv" +pending_video_file=$(mktemp) mkdir -p "$cache_dir" +trap 'rm -f "$pending_video_file"' EXIT -thumbnail_for() { +is_video_path() { + [[ ${1,,} =~ \.(mp4|m4v|mov|webm|mkv|avi)$ ]] +} + +thumbnail_path_for() { local image="$1" - local signature hash thumbnail legacy_hash + local signature hash signature=$(stat -Lc '%s:%Y' "$image") || return hash=$(awk -F '\t' -v path="$image" -v sig="$signature" '$1 == path && $2 == sig { print $3; exit }' "$index_file" 2>/dev/null) @@ -17,9 +23,58 @@ thumbnail_for() { hash=$(printf '%s\t%s' "$image" "$signature" | md5sum | cut -d ' ' -f 1) fi - thumbnail="$cache_dir/$hash.jpg" + printf '%s/%s.jpg' "$cache_dir" "$hash" +} + +generate_video_thumbnail() { + local image="$1" + local thumbnail="$2" + local lock="$thumbnail.lock" + local lock_fd + local tmp="$thumbnail.$$.jpg" + + if [[ -d $lock ]] && (( $(date +%s) - $(stat -c '%Y' "$lock" 2>/dev/null || date +%s) > 120 )); then + rmdir "$lock" 2>/dev/null + fi + + exec {lock_fd}>"$lock" || return + flock -w 30 "$lock_fd" || return + rm -f "$thumbnail".*.jpg + + [[ -f $thumbnail ]] && return + + if timeout -k 5 10 ffmpegthumbnailer -i "$image" -o "$tmp" -s 1536 -q 8 {lock_fd}>&-; then + mv -f "$tmp" "$thumbnail" + else + status=$? + rm -f "$tmp" "$thumbnail" + # Remember a rejected video so it costs nothing on the next scan; the key + # covers size and mtime, so a repaired file starts clean. A timeout is + # left to retry: the machine may only have been busy. + (( status == 124 || status == 137 )) || : >"$thumbnail.failed" + return 1 + fi +} + +drain_pending_video_thumbnails() { + local video_jobs + + [[ -s $pending_video_file ]] || return 0 + + video_jobs=$(( $(nproc) / 4 )) + (( video_jobs > 0 )) || video_jobs=1 + export -f generate_video_thumbnail + xargs -a "$pending_video_file" -0 -n 2 -P "$video_jobs" \ + bash -c 'generate_video_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true +} + +thumbnail_for() { + local image="$1" + local thumbnail legacy_hash + + thumbnail=$(thumbnail_path_for "$image") || return - if [[ ! -f $thumbnail ]]; then + if [[ ! -f $thumbnail ]] && ! is_video_path "$image"; then # Older on-demand picker code keyed fallback thumbnails by file content. # Keep finding those if a user still has them cached. legacy_hash=$(md5sum "$image" 2>/dev/null | cut -d ' ' -f 1) @@ -28,17 +83,31 @@ thumbnail_for() { if [[ -f $thumbnail ]]; then printf '%s' "$thumbnail" - else + elif ! is_video_path "$image"; then printf '%s' "$image" fi } -while IFS= read -r dir; do - [[ -n $dir && -d $dir ]] || continue - find -L "$dir" -maxdepth 1 -type f \ - \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \) \ - -print0 2>/dev/null -done <<<"$image_dirs" | sort -z | while IFS= read -r -d '' image; do +mapfile -d '' -t images < <( + while IFS= read -r dir; do + [[ -n $dir && -d $dir ]] || continue + find -L "$dir" -maxdepth 1 -type f \ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.gif' -o -iname '*.bmp' -o -iname '*.webp' \ + -o -iname '*.mp4' -o -iname '*.m4v' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.mkv' -o -iname '*.avi' \) \ + -print0 2>/dev/null + done <<<"$image_dirs" | sort -z +) + +for image in "${images[@]}"; do + if is_video_path "$image"; then + thumbnail=$(thumbnail_path_for "$image") || continue + [[ -f $thumbnail || -f $thumbnail.failed ]] || printf '%s\0%s\0' "$image" "$thumbnail" >>"$pending_video_file" + fi +done + +drain_pending_video_thumbnails + +for image in "${images[@]}"; do thumbnail=$(thumbnail_for "$image") [[ -n $thumbnail ]] || continue printf '%s\t%s\n' "$image" "$thumbnail" diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index c2deae0fac..e7a430ac67 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -14,6 +14,11 @@ Item { property int failedAttempts: 0 property bool inputEnabled: true property bool loadBackground: true + // A locked session blanks the displays after a few seconds. Nothing is + // visible from then until the user wakes it, so a video must not keep + // decoding through what is usually the longest part of a lock. + property bool displaysBlank: false + property bool powerSaverActive: false property string passwordText: "" property bool syncingPasswordText: false @@ -43,15 +48,6 @@ Item { signal clearFailureRequested() signal wakeRequested() - // Cache-busts the lock background by appending `?v=`. Adding a query - // string keeps Image's loader happy while forcing it to reload when the - // user picks a new background mid-session. - function fileUrl(path) { - if (!path) return "" - var encoded = String(path).split("/").map(encodeURIComponent).join("/") - return "file://" + encoded + "?v=" + backgroundVersion - } - function forcePasswordFocus() { passwordInput.forceActiveFocus() } @@ -90,28 +86,34 @@ Item { anchors.fill: parent color: Color.background - Image { + BackgroundMedia { id: wallpaper anchors.fill: parent - source: root.loadBackground ? root.fileUrl(root.backgroundPath) : "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - sourceSize.width: width - sourceSize.height: height + path: root.loadBackground ? root.backgroundPath : "" + version: root.backgroundVersion + playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive } MultiEffect { anchors.fill: wallpaper - source: wallpaper + source: wallpaper.video ? null : wallpaper + visible: !wallpaper.video autoPaddingEnabled: false - blurEnabled: root.loadBackground && wallpaper.status === Image.Ready + blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 blurMax: 128 blurMultiplier: 1.25 contrast: -0.08 } + // Qt's video output cannot be sampled by MultiEffect on every renderer. + // Keep video wallpapers visible and darken them slightly for legibility. + Rectangle { + anchors.fill: wallpaper + visible: wallpaper.video + color: "#22000000" + } + MouseArea { anchors.fill: parent hoverEnabled: true diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 9ecb1cc09b..94d43b68eb 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -31,11 +31,21 @@ Item { property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" + property bool displaysBlank: false + // displaysBlank tracks what the lock asked for; Hyprland reports what each + // panel actually did. While a video is on show the two are reconciled, so a + // blank that failed keeps playing and a panel woken behind the lock's back + // (a resume that kept the same outputs) resumes instead of freezing. + property var monitorDpms: ({}) + property bool monitorDpmsKnown: false + readonly property bool videoBackground: Util.isVideoPath(backgroundPath) property bool strandedLock: false property bool strandedLockResolved: false readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating + readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null + readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false function realScreenCount() { var screens = Quickshell.screens || [] @@ -165,14 +175,42 @@ Item { } function runWake() { + root.displaysBlank = false + root.monitorDpmsKnown = false if (!wakeProcess.running) wakeProcess.running = true if (lockRequested) armBlankTimer() } function runBlank() { + root.displaysBlank = true + root.monitorDpmsKnown = false if (!blankProcess.running) blankProcess.running = true } + function screenBlank(screenName) { + var name = String(screenName || "") + if (!monitorDpmsKnown || !(name in monitorDpms)) return displaysBlank + return !monitorDpms[name] + } + + function applyMonitorDpms(text) { + var monitors + try { + monitors = JSON.parse(String(text || "")) + } catch (error) { + return + } + if (!Array.isArray(monitors)) return + + var dpms = {} + for (var i = 0; i < monitors.length; i++) { + var monitor = monitors[i] + if (monitor && monitor.name && !monitor.disabled) dpms[String(monitor.name)] = !!monitor.dpmsStatus + } + monitorDpms = dpms + monitorDpmsKnown = true + } + function submitPassword(value) { var password = String(value || "") if (!lockRequested || authenticatingPassword || password.length === 0) return @@ -276,6 +314,8 @@ Item { failedAttempts: root.failedAttempts inputEnabled: root.lockRequested loadBackground: root.locked + displaysBlank: root.screenBlank(lockSurface.screen ? lockSurface.screen.name : "") + powerSaverActive: root.powerSaverActive passwordText: root.enteredPassword onPasswordTextEdited: function(password) { root.enteredPassword = password } onSubmitPassword: function(password) { root.submitPassword(password) } @@ -306,6 +346,7 @@ Item { failedAttempts: 0 inputEnabled: false loadBackground: root.previewVisible + powerSaverActive: root.powerSaverActive passwordText: "" } @@ -411,6 +452,31 @@ Item { command: ["bash", "-c", "omarchy-brightness-keyboard off; omarchy-brightness-display off"] } + // Quickshell exposes no DPMS signal, so the panel state is polled while a + // video is the locked wallpaper. A wake or blank request drops the last + // answer, so its optimistic state applies until the next poll confirms it. + Process { + id: monitorDpmsProcess + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: root.applyMonitorDpms(text) + } + } + + Timer { + id: monitorDpmsTimer + interval: 3000 + repeat: true + triggeredOnStart: true + running: root.locked && root.videoBackground + onTriggered: { + if (!monitorDpmsProcess.running) monitorDpmsProcess.running = true + } + onRunningChanged: { + if (!running) root.monitorDpmsKnown = false + } + } + Timer { id: idleBlankTimer interval: 5000 @@ -467,6 +533,10 @@ Item { Connections { target: Quickshell function onScreensChanged() { + // A panel coming back is a display turning on that runWake did not ask + // for, so the blank state has to be given up here or a visible lock + // wallpaper stays frozen until the next keypress. + root.displaysBlank = false root.requestSessionLock() // A monitor still coming up has no workspace, so cannot answer yet. diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 37fdf5c72f..a1b01cfff6 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -12,6 +12,8 @@ Item { readonly property int batteryThreshold: 10 property string pendingPowerSource: "" + property string activePowerProfile: "" + readonly property bool powerSaverOnBattery: UPower.onBattery && activePowerProfile === "power-saver" PersistentProperties { id: persisted @@ -53,11 +55,38 @@ Item { powerProfileProcess.running = true } + function refreshPowerProfile() { + if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true + } + Process { id: warningProcess } Process { id: powerProfileProcess - onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + onExited: { + if (root.pendingPowerSource !== "") root.runPendingPowerProfile() + root.refreshPowerProfile() + } + } + + Process { + id: powerProfileReadProcess + command: ["powerprofilesctl", "get"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.activePowerProfile = String(text || "").trim() + } + } + + Timer { + // powerprofilesctl has no portable monitor subcommand; keep profile changes + // visible to consumers such as the wallpaper service without requiring the + // power panel to be open. + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refreshPowerProfile() } Timer { @@ -73,6 +102,9 @@ Item { function onOnBatteryChanged() { root.checkBattery() root.applyPowerProfile() + root.refreshPowerProfile() } } + + Component.onCompleted: root.refreshPowerProfile() } diff --git a/shell/shell.qml b/shell/shell.qml index 7e1cf75dc9..37a0fe6e4f 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -272,6 +272,10 @@ ShellRoot { property var _services: ({}) + // Reassigned as each service registers, so a binding that reads this before + // looking a service up by id re-evaluates once that service exists. + readonly property var services: _services + function serviceFor(pluginId) { return _services[String(pluginId)] || null } diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh new file mode 100644 index 0000000000..5e7170cf54 --- /dev/null +++ b/test/shell.d/apply-lock-test.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +apply_lock="$ROOT/bin/omarchy-apply-lock" + +root_path_guard=$(awk ' + /^if \(\( EUID == 0 \)\); then$/ { inside = 1 } + inside { print } + inside && /^fi$/ { exit } +' "$apply_lock") +grep -Fx ' export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' <<<"$root_path_guard" >/dev/null || + fail "the root lock helper replaces its inherited command path" +if grep -E '(\.local/bin|target_user|target_home)' <<<"$root_path_guard" >/dev/null; then + fail "the root lock helper does not retain a user-controlled command directory" +fi +pass "the root lock helper uses only trusted command directories" + +grep -F '[[ -x /usr/bin/fprintd-list ]]' "$apply_lock" >/dev/null || + fail "the lock helper checks the trusted fprintd-list executable" +grep -F '/usr/bin/fprintd-list "$target_user"' "$apply_lock" >/dev/null || + fail "the lock helper invokes fprintd-list by its trusted absolute path" +if grep -F 'omarchy-cmd-present fprintd-list' "$apply_lock" >/dev/null || + grep -E '(^|[[:space:];&|])fprintd-list([[:space:]]|$)' "$apply_lock" >/dev/null || + grep -E 'command[[:space:]]+-v[[:space:]]+fprintd-list' "$apply_lock" >/dev/null; then + fail "the lock helper does not resolve fprintd-list through PATH" +fi +pass "the lock helper pins fprintd-list to its packaged system path" + +# Exercise the helper as real root when the suite already has it, or as root in +# an unprivileged user namespace otherwise. A hardened kernel can disable user +# namespaces, so preserve the static coverage above and skip only this probe. +root_runner=() +root_runtime_available=1 +if (( EUID != 0 )); then + if command -v unshare >/dev/null && unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + else + root_runtime_available=0 + fi +fi + +if (( ! root_runtime_available )); then + pass "no unprivileged user namespace; skipping the root lock-helper lookup matrix" + exit 0 +fi + +# Retarget the two PAM files, the trusted fprintd-list binary, and the final +# shell status query in copies under this scratch directory. The production +# files and service stay untouched even when this suite itself runs as root. +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +poison_bin="$test_tmp/poison-bin" +trusted_root_bin="$test_tmp/trusted-root-bin" +trusted_fprintd="$test_tmp/trusted-fprintd-list" +password_pam="$test_tmp/omarchy-lock-password" +fingerprint_pam="$test_tmp/omarchy-lock-fingerprint" +attack_marker="$test_tmp/user-fprintd-list-ran" +trusted_uid="$test_tmp/trusted-fprintd-list.uid" +trusted_args="$test_tmp/trusted-fprintd-list.args" +attack_args="$test_tmp/user-fprintd-list.args" +patched_helper="$test_tmp/omarchy-apply-lock-patched" +absolute_only_helper="$test_tmp/omarchy-apply-lock-absolute-only" +root_path_only_helper="$test_tmp/omarchy-apply-lock-root-path-only" +unprotected_helper="$test_tmp/omarchy-apply-lock-unprotected" +target_user=omarchy-regression-user +mkdir -p "$poison_bin" "$trusted_root_bin" + +# The runtime copy pins to this isolated root path. It contains every bare +# command the exercised helper needs, but deliberately no fprintd-list. +for helper in grep rm tee; do + ln -s "/usr/bin/$helper" "$trusted_root_bin/$helper" +done + +export TEST_ATTACK_ARGS="$attack_args" +export TEST_ATTACK_MARKER="$attack_marker" +export TEST_TRUSTED_ARGS="$trusted_args" +export TEST_TRUSTED_UID="$trusted_uid" + +cat >"$trusted_fprintd" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_TRUSTED_UID" +printf '%s\n' "$*" >"$TEST_TRUSTED_ARGS" +echo "Fingerprints are enrolled" +EOF + +cat >"$poison_bin/fprintd-list" <<'EOF' +#!/bin/bash + +printf '%s\n' "$EUID" >"$TEST_ATTACK_MARKER" +printf '%s\n' "$*" >"$TEST_ATTACK_ARGS" +echo "Fingerprints are enrolled" +EOF + +chmod +x "$trusted_fprintd" "$poison_bin/fprintd-list" + +prepare_helper() { + local destination="$1" keep_root_path="$2" use_absolute_fprintd="$3" + + awk \ + -v password_pam="$password_pam" \ + -v fingerprint_pam="$fingerprint_pam" \ + -v trusted_root_bin="$trusted_root_bin" \ + -v trusted_fprintd="$trusted_fprintd" \ + -v keep_root_path="$keep_root_path" \ + -v use_absolute_fprintd="$use_absolute_fprintd" ' + { + line = $0 + gsub("/etc/pam\\.d/omarchy-lock-password", "\"" password_pam "\"", line) + gsub("/etc/pam\\.d/omarchy-lock-fingerprint", "\"" fingerprint_pam "\"", line) + + if (line == "if (( EUID == 0 )); then" && keep_root_path == 0) { + print "if (( 0 )); then" + next + } + if (line == " export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin") { + print " export PATH=\"" trusted_root_bin "\"" + next + } + if (line == "if [[ -x /usr/bin/fprintd-list ]] &&") { + if (use_absolute_fprintd == 1) { + print "if [[ -x \"" trusted_fprintd "\" ]] &&" + } else { + print "if command -v fprintd-list >/dev/null 2>&1 &&" + } + next + } + if (line == " /usr/bin/fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then") { + if (use_absolute_fprintd == 1) { + print " \"" trusted_fprintd "\" \"$target_user\" 2>/dev/null | grep -qi finger; then" + } else { + print " fprintd-list \"$target_user\" 2>/dev/null | grep -qi finger; then" + } + next + } + if (line == "if omarchy-shell lock status >/dev/null 2>&1; then") { + print "if false; then" + next + } + + print line + } + ' "$apply_lock" >"$destination" + chmod +x "$destination" +} + +prepare_helper "$patched_helper" 1 1 +prepare_helper "$absolute_only_helper" 0 1 +prepare_helper "$root_path_only_helper" 1 0 +prepare_helper "$unprotected_helper" 0 0 + +for helper in "$patched_helper" "$absolute_only_helper" "$root_path_only_helper" "$unprotected_helper"; do + if grep -F '/etc/pam.d/' "$helper" >/dev/null || + grep -F '/usr/bin/fprintd-list' "$helper" >/dev/null || + grep -F 'omarchy-shell lock status' "$helper" >/dev/null; then + fail "the isolated root fixture redirects every live-system lock-helper target" + fi +done + +reset_runtime_files() { + rm -f "$password_pam" "$fingerprint_pam" "$trusted_uid" "$trusted_args" "$attack_marker" "$attack_args" +} + +run_as_root() { + local helper="$1" description="$2" output + + if ! output=$(PATH="$poison_bin:/usr/bin:/bin" OMARCHY_INSTALL_USER="$target_user" \ + "${root_runner[@]}" /bin/bash "$helper" 2>&1); then + fail "$description" "$output" + fi +} + +reset_runtime_files +run_as_root "$patched_helper" "the fully hardened lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the hardened root lock helper executes the user-planted fprintd-list" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the trusted fprintd-list probe runs with EUID 0" +grep -Fx "$target_user" "$trusted_args" >/dev/null || fail "the trusted fprintd-list probe receives the target user" +[[ -s $password_pam && -s $fingerprint_pam ]] || + fail "the isolated root lock-helper run writes both scratch PAM fixtures" +pass "the hardened root lock helper uses the trusted fingerprint probe" + +reset_runtime_files +run_as_root "$absolute_only_helper" "the absolute-path-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "an absolute fprintd-list path permits the user-planted command" +grep -Fx '0' "$trusted_uid" >/dev/null || fail "the absolute-path defense runs the trusted probe as root" +pass "the absolute fprintd-list path independently blocks the user-planted command" + +reset_runtime_files +run_as_root "$root_path_only_helper" "the root-PATH-only lock helper runs in an isolated root context" +[[ ! -e $attack_marker ]] || fail "the trusted root path permits the user-planted fprintd-list" +pass "the trusted root path independently blocks the user-planted command" + +# Mutation control: removing both protections must execute the planted command +# as UID 0, proving the matrix detects the original privilege-boundary failure. +reset_runtime_files +run_as_root "$unprotected_helper" "the unprotected mutation runs in an isolated root context" +grep -Fx '0' "$attack_marker" >/dev/null || + fail "the root lock-helper fixture detects a PATH-resolved fprintd-list regression" +grep -Fx "$target_user" "$attack_args" >/dev/null || + fail "the planted fprintd-list receives the target user" +[[ -s $fingerprint_pam ]] || fail "the planted fprintd-list controls the fingerprint PAM branch" +pass "the root lock-helper matrix rejects the vulnerable PATH lookup" diff --git a/test/shell.d/bar-text-color-test.sh b/test/shell.d/bar-text-color-test.sh index b7626c2b6e..d22a289c04 100755 --- a/test/shell.d/bar-text-color-test.sh +++ b/test/shell.d/bar-text-color-test.sh @@ -5,6 +5,7 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command magick +require_command ffmpeg TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT @@ -28,3 +29,15 @@ pass "transparent bar text keeps text color on dark wallpaper" result=$(HOME="$TMPDIR" omarchy-bar-text-color top 20 '#ffffff' '#101010' --background "$TMPDIR/missing.png" --screen 100x100) [[ $result == "#ffffff" ]] || fail "transparent bar text falls back to text color when sampling fails" "expected #ffffff, got $result" pass "transparent bar text falls back to text color when sampling fails" + +# A video background must be sampled one frame at a time. Reading the whole file +# emits a value per frame, which parses as nothing and silently falls back — +# and decodes the entire wallpaper to find that out. +light_top_video="$TMPDIR/light-top.mp4" +ffmpeg -y -f lavfi -i "testsrc=size=640x360:rate=10:duration=2" \ + -vf "drawbox=x=0:y=0:w=640:h=40:color=0xf5f5f5:t=fill" \ + -c:v libx264 -preset ultrafast -pix_fmt yuv420p "$light_top_video" -loglevel error + +result=$(HOME="$TMPDIR" omarchy-bar-text-color top 40 '#ffffff' '#101010' --background "$light_top_video" --screen 640x360) +[[ $result == "#101010" ]] || fail "transparent bar text samples one frame of a video wallpaper" "expected #101010, got $result" +pass "transparent bar text samples one frame of a video wallpaper" diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh old mode 100644 new mode 100755 index c06c83d2bf..cf5d0cf88f --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -21,6 +21,7 @@ terminal_log="$test_tmp/terminal" menu_log="$test_tmp/menu" cursor_install_log="$test_tmp/cursor-install" hermes_install_log="$test_tmp/hermes-install" +muse_login_log="$test_tmp/muse-login" mkdir -p "$mock_bin" "$test_home" cat >"$mock_bin/omarchy-notification-send" <<'SH' @@ -95,6 +96,22 @@ cat >"$mock_bin/omarchy-menu" <<'SH' printf '%s\0' "$@" >"$OMARCHY_TEST_AGENT_MENU_LOG" SH +cat >"$mock_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +echo "Muse must install through mise" >&2 +exit 1 +SH +ln -s omarchy-pkg-add "$mock_bin/omarchy-pkg-aur-add" + +cat >"$mock_bin/muse" <<'SH' +#!/bin/bash +if [[ ${1:-} == "login" ]]; then + printf 'muse %s\n' "$*" >>"$OMARCHY_TEST_MUSE_LOGIN_LOG" +else + printf '%s\0' muse "$@" >"$OMARCHY_TEST_AGENT_INLINE_LOG" +fi +SH + cat >"$mock_bin/omarchy-test-noop" <<'SH' #!/bin/bash exit 0 @@ -119,6 +136,7 @@ export OMARCHY_TEST_AGENT_TERMINAL_LOG="$terminal_log" export OMARCHY_TEST_AGENT_MENU_LOG="$menu_log" export OMARCHY_TEST_CURSOR_INSTALL_LOG="$cursor_install_log" export OMARCHY_TEST_HERMES_INSTALL_LOG="$hermes_install_log" +export OMARCHY_TEST_MUSE_LOGIN_LOG="$muse_login_log" export OMARCHY_PATH="$ROOT" grok_package="npm:@xai-official/grok" @@ -126,6 +144,8 @@ omp_package="github:can1357/oh-my-pi" crush_package="crush" agy_package="antigravity-cli" ori_package="github:OpenRouterLabs/ori-releases" +cursor_agent_package="cursor-agent" +muse_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" assert_lazy_stub() { local package=$1 @@ -144,6 +164,7 @@ assert_lazy_stub "$grok_package" grok assert_lazy_stub "$omp_package" omp assert_lazy_stub "$crush_package" crush assert_lazy_stub "$ori_package" ori +assert_lazy_stub "$muse_package" muse pass "custom agent lazy stubs preserve their mise packages" source "$ROOT/install/user/mise.sh" @@ -156,8 +177,30 @@ grep -F "asdf:icholy/asdf-cursor-agent" "$stub_log" >/dev/null && fail "user setup does not create a Cursor asdf stub" grep -F "cursor-agent" "$stub_log" >/dev/null && fail "user setup does not create a Cursor mise stub" +OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/install/user/mise.sh" +grep -Fx "$muse_package muse" "$stub_log" >/dev/null || fail "user setup creates the Muse lazy stub" pass "user setup creates the custom agent lazy stubs" +: >"$stub_log" +source "$ROOT/install/user/mise.sh" +grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null && fail "user setup replaces an existing cursor-agent command" +pass "user setup keeps an existing Cursor CLI install" +grep -Fx "$muse_package muse" "$stub_log" >/dev/null && fail "user setup replaces an existing Muse command" + +: >"$stub_log" +OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/migrations/1788724825.sh" >/dev/null +grep -Fx "$muse_package muse" "$stub_log" >/dev/null || fail "Muse migration creates its lazy stub" +: >"$stub_log" +source "$ROOT/migrations/1788724825.sh" >/dev/null +[[ ! -s $stub_log ]] || fail "Muse migration replaces an existing command" +mkdir -p "$test_home/.local/state/omarchy" +touch "$test_home/.local/state/omarchy/preinstalls-removed" +OMARCHY_TEST_MISSING_COMMAND=muse source "$ROOT/migrations/1788724825.sh" >/dev/null +[[ ! -s $stub_log ]] || fail "Muse migration ignores the preinstall opt-out" +rm "$test_home/.local/state/omarchy/preinstalls-removed" +pass "Muse migration preserves existing installs and the preinstall opt-out" + + : >"$stub_log" source "$ROOT/migrations/1785617047.sh" >/dev/null grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration creates a working lazy stub" @@ -166,6 +209,14 @@ grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "Oh My Pi migration c source "$ROOT/migrations/1787342993.sh" >/dev/null grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "Ori migration creates a working lazy stub" +: >"$stub_log" +export OMARCHY_TEST_MISSING_COMMAND=cursor-agent +source "$ROOT/migrations/1788577553.sh" >/dev/null +unset OMARCHY_TEST_MISSING_COMMAND +grep -F "cursor-agent" "$stub_log" >/dev/null && + fail "Cursor CLI migration does not install Cursor through mise" +pass "Cursor CLI migration skips the upstream mise wrapper" + : >"$stub_log" source "$ROOT/migrations/1785846769.sh" >/dev/null grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "agent migration repairs the Oh My Pi lazy stub" @@ -264,6 +315,7 @@ source "$ROOT/migrations/1785617047.sh" >/dev/null source "$ROOT/migrations/1785846769.sh" >/dev/null source "$ROOT/migrations/1787163407.sh" >/dev/null source "$ROOT/migrations/1787342993.sh" >/dev/null +OMARCHY_TEST_MISSING_COMMAND=cursor-agent source "$ROOT/migrations/1788577553.sh" >/dev/null [[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out" [[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out" [[ -e $test_home/.local/bin/cursor-agent ]] || fail "Cursor migration still leaves a real cursor-agent after opt-out" @@ -289,14 +341,32 @@ rm "$test_home/.local/state/omarchy/preinstalls-removed" rm -f "$agent_file" pass "agent migrations install working wrappers without overriding the preinstall opt-out" +"$ROOT/bin/omarchy-mise-install" "$muse_package" muse touch "$test_home/.local/bin/agy" "$test_home/.local/bin/ori" omarchy-remove-preinstalls >/dev/null -for command in agy omp ori grok crush; do +for command in agy omp ori grok crush muse; do [[ ! -e $test_home/.local/bin/$command ]] || fail "Remove Preinstalls deletes the $command lazy stub" done [[ -e $test_home/.local/bin/cursor-agent ]] || fail "Remove Preinstalls leaves a user-installed Cursor Agent in place" pass "Remove Preinstalls deletes every optional agent lazy stub" +# Cursor's installer links the same path, so anything but the mise wrapper is +# the user's own install. +rm -f "$test_home/.local/bin/cursor-agent" +touch "$test_home/.local/bin/cursor-agent.official" +ln -s cursor-agent.official "$test_home/.local/bin/cursor-agent" +omarchy-remove-preinstalls >/dev/null +[[ -L $test_home/.local/bin/cursor-agent ]] || fail "Remove Preinstalls keeps an official Cursor CLI install" +rm -f "$test_home/.local/bin/cursor-agent" "$test_home/.local/bin/cursor-agent.official" +pass "Remove Preinstalls keeps an official Cursor CLI install" +printf '#!/bin/bash\necho user-muse\n' >"$test_home/.local/bin/muse" +chmod +x "$test_home/.local/bin/muse" +omarchy-remove-preinstalls >/dev/null +[[ $("$test_home/.local/bin/muse") == "user-muse" ]] || fail "Remove Preinstalls deletes a user-managed Muse" +rm "$test_home/.local/bin/muse" +pass "Remove Preinstalls keeps a user-managed Muse install" + + [[ -z $(omarchy-default-agent) ]] || fail "default agent is unset until one is chosen" pass "default agent is unset until one is chosen" @@ -360,6 +430,9 @@ declare -A expected_agents=( [hermes]="hermes" [copilot]="copilot" [github-copilot]="copilot" + [muse]="muse" + [muse-code]="muse" + [musecode]="muse" ) declare -A expected_packages=( @@ -373,6 +446,8 @@ declare -A expected_packages=( [grok]="$grok_package" [agy]="$agy_package" [copilot]="copilot" + [cursor-agent]="$cursor_agent_package" + [muse]="$muse_package" ) for selection in "${!expected_agents[@]}"; do @@ -395,8 +470,12 @@ for selection in "${!expected_agents[@]}"; do else OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent "$selection" mapfile -d '' -t mise_args <"$mise_log" - [[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" && ${mise_args[2]} == ${expected_packages[$expected]} ]] || + [[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" ]] || fail "default agent installs $selection globally through mise" + case ${mise_args[2]} in + "${expected_packages[$expected]}") ;; + *) fail "default agent preserves $selection backend options" ;; + esac fi [[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $selection" @@ -522,6 +601,76 @@ grep -F "Could not set Codex as the default coding agent" "$test_tmp/setup-failu [[ ! -s $agent_open_log ]] || fail "failed activation does not open an agent" pass "default agent reports mise failures without notifications" +# Muse follows the shared mise installation and launch path. +: >"$notification_history" +: >"$agent_open_log" +: >"$terminal_log" +omarchy-default-agent muse +mapfile -d '' -t terminal_args <"$terminal_log" +[[ ${terminal_args[0]} == "omarchy-default-agent" && ${terminal_args[1]} == "--install" && ${terminal_args[2]} == "muse" ]] || + fail "missing Muse installation opens in a terminal" +[[ ! -s $notification_history ]] || fail "missing Muse installation skips notifications" +[[ ! -s $agent_open_log ]] || fail "missing Muse installation waits to open the agent" +[[ $(omarchy-default-agent) == "copilot" ]] || fail "missing Muse installation waits to change the selection" + +if OMARCHY_TEST_MISE_FAIL=true omarchy-default-agent --install muse >"$test_tmp/muse-install-failure-output" 2>&1; then + fail "missing Muse rejects a failed mise installation" +fi +[[ $(omarchy-default-agent) == "copilot" ]] || fail "failed Muse installation preserves the current default" +[[ ! -s $muse_login_log && ! -s $agent_open_log ]] || fail "failed Muse installation skips login and launch" +grep -F "Could not install Muse Code with mise" "$test_tmp/muse-install-failure-output" >/dev/null || + fail "failed Muse installation identifies mise" +pass "failed Muse mise installation preserves the selection and skips login" + +: >"$mise_history" +: >"$stub_log" +omarchy-default-agent --install muse >"$test_tmp/muse-install-output" +grep -Fx "use -g $muse_package" "$mise_history" >/dev/null || fail "visible Muse installation uses the HTTP backend" +[[ ! -s $stub_log ]] || fail "Muse selection recreates its preinstalled wrapper" +[[ ! -s $muse_login_log ]] || fail "Muse selection runs a separate login flow" +[[ $(omarchy-default-agent) == "muse" ]] || fail "visible Muse installation changes the selection" +mapfile -d '' -t agent_open_args <"$agent_open_log" +[[ ${#agent_open_args[@]} == 2 && ${agent_open_args[0]} == "omarchy-agent" && ${agent_open_args[1]} == "--inline" ]] || + fail "newly installed Muse opens in the installation terminal" +pass "Muse installs visibly through mise and opens directly" + +: >"$terminal_log" +: >"$muse_login_log" +: >"$agent_open_log" +OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent muse-code +[[ ! -s $terminal_log ]] || fail "installed Muse selection skips the terminal" +[[ ! -s $muse_login_log ]] || fail "installed Muse selection skips the login" +[[ $(omarchy-default-agent) == "muse" ]] || fail "default agent canonicalizes muse-code" +mapfile -d '' -t agent_open_args <"$agent_open_log" +[[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] || + fail "installed Muse opens in a new terminal after selection" +pass "installed Muse selects and opens directly" + +OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi +: >"$agent_open_log" +if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_MISE_FAIL=true omarchy-default-agent musecode >"$test_tmp/muse-failure-output" 2>&1; then + fail "default agent rejects a failed Muse activation" +fi +[[ $(omarchy-default-agent) == "pi" ]] || fail "failed Muse activation preserves the current default agent" +grep -F "Could not set Muse Code as the default coding agent" "$test_tmp/muse-failure-output" >/dev/null || + fail "default agent reports a failed Muse activation" +[[ ! -s $agent_open_log ]] || fail "failed Muse activation does not open an agent" +pass "default agent reports Muse mise failures without changing the selection" + +# A manually installed launcher belongs to the user; selecting it must not +# install a second copy or replace it with the Omarchy wrapper. +printf '#!/bin/bash\necho user-muse\n' >"$test_home/.local/bin/muse" +chmod +x "$test_home/.local/bin/muse" +: >"$mise_history" +: >"$stub_log" +: >"$terminal_log" +omarchy-default-agent muse +[[ $(omarchy-default-agent) == "muse" ]] || fail "a user-installed Muse can be selected" +[[ ! -s $mise_history && ! -s $stub_log && ! -s $terminal_log ]] || fail "a user-installed Muse skips installation and wrapper creation" +[[ $("$test_home/.local/bin/muse") == "user-muse" ]] || fail "a user-installed Muse is preserved" +rm "$test_home/.local/bin/muse" +pass "selecting a user-installed Muse preserves its launcher" + rm "$mock_bin/omarchy-agent" hash -r @@ -570,14 +719,21 @@ assert_launch opencode opencode --auto --prompt "Review this project" assert_launch ori ori code --interactive --prompt "Review this project" assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" +assert_launch muse muse --approval-mode never -- "Review this project" assert_launch crush crush run "Review this project" assert_launch grok grok --permission-mode bypassPermissions -- "Review this project" +assert_launch cursor-agent cursor-agent --yolo --trust agent -- "Review this project" assert_launch hermes env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=Review this project" assert_launch agy agy --dangerously-skip-permissions --prompt-interactive "Review this project" assert_launch copilot copilot --allow-all --interactive "Review this project" -assert_launch cursor-agent cursor-agent --yolo --trust "Review this project" pass "agent launcher adapts initial prompts for every supported agent" +literal_muse_prompt=$'--disable-sandbox !Crash {$(touch must-not-run)}\ntrailing\\ ' +printf '%s\n' "muse" >"$agent_file" +omarchy-agent-prompt "$literal_muse_prompt" +assert_launched muse "separates prompt text from options" muse --approval-mode never -- "$literal_muse_prompt" +pass "Muse receives option-like prompts as one literal argument" + literal_hermes_prompt=$' --help !Crash /quit {$(touch must-not-run)}\ntrailing\\ ' printf '%s\n' "hermes" >"$agent_file" omarchy-agent-prompt "$literal_hermes_prompt" @@ -591,12 +747,13 @@ assert_bypass opencode opencode --auto assert_bypass ori ori code assert_bypass claude claude --permission-mode auto assert_bypass codex codex --approve-for-me +assert_bypass muse muse --approval-mode never assert_bypass crush crush --yolo assert_bypass grok grok --permission-mode bypassPermissions +assert_bypass cursor-agent cursor-agent --yolo --trust assert_bypass hermes hermes --yolo assert_bypass agy agy --dangerously-skip-permissions assert_bypass copilot copilot --allow-all -assert_bypass cursor-agent cursor-agent --yolo --trust pass "agent launcher skips permission prompts for every supported agent" printf '%s\n' "opencode" >"$agent_file" diff --git a/test/shell.d/fingerprint-driver-migration-test.sh b/test/shell.d/fingerprint-driver-migration-test.sh new file mode 100755 index 0000000000..6b3f523cac --- /dev/null +++ b/test/shell.d/fingerprint-driver-migration-test.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# The fingerprint driver migration only repairs a machine an earlier version of +# it left with fprintd and no libfprint; any installed driver is left alone. +# The real package helpers run over a stubbed pacman. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1785090473.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/bin" +export CALL_LOG="$scratch/calls" +export PATH="$scratch/bin:$ROOT/bin:$PATH" + +cat > "$scratch/bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +# INSTALLED lists the installed package names, one per line; an install adds +# its packages to INSTALLED_LOG so omarchy-pkg-add's follow-up query sees them. +cat > "$scratch/bin/pacman" <<'STUB' +#!/bin/bash +case "$1" in + -Q) grep -qx "$2" <<< "${INSTALLED:-}" || grep -qx "$2" "$INSTALLED_LOG" ;; + -S) + printf 'pacman %s\n' "$*" >> "$CALL_LOG" + for arg in "$@"; do + [[ $arg == -* ]] || printf '%s\n' "$arg" >> "$INSTALLED_LOG" + done + ;; + *) printf 'pacman %s\n' "$*" >> "$CALL_LOG" ;; +esac +STUB +chmod +x "$scratch/bin/"* +export INSTALLED_LOG="$scratch/installed" + +run_migration() { + : > "$CALL_LOG" + : > "$INSTALLED_LOG" + bash -euo pipefail "$migration" > /dev/null +} + +INSTALLED='fprintd' run_migration +grep -qx 'pacman -S --noconfirm --needed libfprint-git' "$CALL_LOG" || fail "fprintd without a library gets libfprint-git" +pass "fprintd without a library gets libfprint-git" + +INSTALLED=$'libfprint-git\nfprintd' run_migration +[[ ! -s $CALL_LOG ]] || fail "an installed libfprint-git is left alone" "$(<"$CALL_LOG")" +pass "an installed libfprint-git is left alone" + +INSTALLED=$'libfprint\nfprintd' run_migration +[[ ! -s $CALL_LOG ]] || fail "an installed stock libfprint is left alone" "$(<"$CALL_LOG")" +pass "an installed stock libfprint is left alone" + +INSTALLED='' run_migration +[[ ! -s $CALL_LOG ]] || fail "a machine without fprintd is left alone" "$(<"$CALL_LOG")" +pass "a machine without fprintd is left alone" diff --git a/test/shell.d/fingerprint-package-test.sh b/test/shell.d/fingerprint-package-test.sh new file mode 100755 index 0000000000..7032998261 --- /dev/null +++ b/test/shell.d/fingerprint-package-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# +# The fingerprint setup installs libfprint-git in place of stock libfprint. The +# two conflict, so the swap has to happen inside one --ask 4 transaction, and a +# rerun with everything installed must not touch pacman at all. The real +# omarchy-pkg-missing runs; pacman and the privileged calls are stubbed. + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/bin" +export CALL_LOG="$scratch/calls" +export PATH="$scratch/bin:$ROOT/bin:$PATH" + +cat > "$scratch/bin/omarchy-hw-fingerprint" <<'STUB' +#!/bin/bash +exit "${HARDWARE_STATUS:-0}" +STUB +cat > "$scratch/bin/sudo" <<'STUB' +#!/bin/bash +case "$1" in + pacman | fprintd-enroll) exec "$@" ;; + *) echo "Unexpected privileged call: $*" >> "$CALL_LOG"; exit 99 ;; +esac +STUB +# INSTALLED lists the installed package names, one per line. +cat > "$scratch/bin/pacman" <<'STUB' +#!/bin/bash +case "$1" in + -Q) grep -qx "$2" <<< "${INSTALLED:-}" ;; + -S) + printf 'pacman %s\n' "$*" >> "$CALL_LOG" + exit "${INSTALL_STATUS:-0}" + ;; + *) printf 'pacman %s\n' "$*" >> "$CALL_LOG"; exit 99 ;; +esac +STUB +cat > "$scratch/bin/fprintd-enroll" <<'STUB' +#!/bin/bash +# Stop before verification/PAM; no host authentication files may be changed. +echo enroll >> "$CALL_LOG" +exit 1 +STUB +cat > "$scratch/bin/fprintd-verify" <<'STUB' +#!/bin/bash +echo verify >> "$CALL_LOG" +exit 1 +STUB +chmod +x "$scratch/bin/"* + +run_setup() { + : > "$CALL_LOG" + if "$ROOT/bin/omarchy-setup-security-fingerprint" > "$scratch/output" 2>&1; then + fail "setup stops on the simulated enrollment or installation failure" + fi + if grep -q 'Unexpected privileged call' "$CALL_LOG"; then + fail "setup does not change PAM after failed enrollment" + fi +} + +assert_installs() { + grep -qx 'pacman -S --needed --noconfirm --ask 4 libfprint-git fprintd usbutils' "$CALL_LOG" || fail "$1" + (( $(grep -c '^pacman ' "$CALL_LOG") == 1 )) || fail "$1: one pacman transaction" +} + +run_setup +assert_installs "a fresh machine installs libfprint-git, fprintd and usbutils" +grep -qx enroll "$CALL_LOG" || fail "installation is followed by enrollment" +pass "a fresh machine installs libfprint-git and reaches enrollment" + +INSTALLED=$'libfprint\nfprintd\nusbutils' run_setup +assert_installs "installed stock libfprint is replaced in the same transaction" +pass "installed stock libfprint is replaced without a removal step" + +INSTALLED=$'libfprint-git\nfprintd\nusbutils' run_setup +if grep -q '^pacman' "$CALL_LOG"; then + fail "a rerun with everything installed does not touch pacman" +fi +grep -qx enroll "$CALL_LOG" || fail "a rerun with everything installed reaches enrollment" +pass "a rerun with everything installed goes straight to enrollment" + +INSTALL_STATUS=1 run_setup +if grep -qx enroll "$CALL_LOG"; then + fail "a failed package transaction prevents enrollment" +fi +pass "a failed installation stops before enrollment" + +HARDWARE_STATUS=1 run_setup +[[ ! -s $CALL_LOG ]] || fail "missing hardware stops before package operations" +pass "missing hardware performs no package operations" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index d3389087c0..0a06f03a56 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -24,6 +24,21 @@ cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG" exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}" SH + +# The remover asks through gum whether the user's data should go too. The stub +# answers "no" unless a test says otherwise, and logs every call: a real gum +# would hang a test run, and one that answered "yes" on its own would be the +# very data loss the default-no exists to prevent. +cat >"$mock_bin/gum" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG" +exit "${OMARCHY_TEST_GUM_STATUS:-1}" +SH +cat >"$mock_bin/systemctl" <<'SH' +#!/bin/bash +echo "systemctl $*" >>"$OMARCHY_TEST_SYSTEMCTL_LOG" +SH + chmod +x "$mock_bin"/* seed_install() { @@ -43,13 +58,34 @@ seed_install() { touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" } +# "$test_tmp/installer-log" + : >"$test_tmp/gum-log" + : >"$test_tmp/systemctl-log" OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \ + OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ + OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ - bash "$ROOT/bin/omarchy-remove-ai-hermes" >/dev/null 2>&1 + bash "$ROOT/bin/omarchy-remove-ai-hermes" /dev/null 2>&1 +} + +# script(1) puts the remover on a pty, which is the only way -t 0 answers true +# without a person at a real one; the stubbed gum then supplies the answer. +remove_tty() { + : >"$test_tmp/installer-log" + : >"$test_tmp/gum-log" + : >"$test_tmp/systemctl-log" + OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \ + OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \ + OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ + OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ + OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ + HOME="$test_home" PATH="$mock_bin:$PATH" \ + script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1 } # The app brings its own uv and its own node; both are runtime, not data. @@ -62,6 +98,10 @@ remove || fail "remove succeeds" [[ ! -d $test_home/.hermes/node ]] || fail "the node the app installed is removed" pass "removal takes the whole runtime the app installed" +grep -Fxq 'systemctl --user stop omarchy-hermes-theme.service' "$test_tmp/systemctl-log" || + fail "the unit the installer left waiting to hand over the theme is stopped" "$(cat "$test_tmp/systemctl-log")" +pass "removal stops the installer's theme hand-over" + [[ -d $test_home/.config/Hermes ]] || fail "gateway connections, tokens and settings survive removal" pass "removal keeps the app's connections and settings" @@ -77,6 +117,12 @@ pass "removal clears only the managed Node links it stranded" [[ -f $test_home/.hermes/SOUL.md ]] || fail "SOUL.md survives removal" pass "removal keeps what belongs to the user" +# Without a terminal there is nobody to ask, so gum must not even be reached: +# a gum that answered "yes" on its own would be a data loss. +[[ ! -s $test_tmp/gum-log ]] || + fail "removal does not ask about the user's data without a terminal" +pass "removal keeps the user's data unasked when there is no terminal" + [[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed" pass "removal takes the command the app installed" @@ -134,6 +180,45 @@ remove || fail "remove succeeds with a wrapper pointing at a sibling directory" fail "a wrapper pointing at ~/xhermes is not mistaken for one pointing into ~/.hermes" pass "removal matches the runtime path as a plain string" +# On a terminal the user is asked, default no: declining leaves every piece of +# data where it was. +seed_install +remove_tty || fail "remove succeeds when the data question is declined" +tr '\0' '\n' <"$test_tmp/gum-log" | grep -qx 'confirm' || + fail "removal asks about the user's data on a terminal" +[[ -f $test_home/.hermes/sessions/one.json && -d $test_home/.config/Hermes ]] || + fail "declining the question keeps the user's data" +pass "removal asks on a terminal and declining keeps the data" + +# An explicit yes is the one path that takes the data too. +seed_install +OMARCHY_TEST_GUM_STATUS=0 remove_tty || fail "remove succeeds when the data goes too" +[[ ! -e $test_home/.hermes && ! -e $test_home/.config/Hermes ]] || + fail "a yes deletes ~/.hermes and ~/.config/Hermes" +pass "removal deletes the user's data only on an explicit yes" + +# Without the bootstrap marker the runtime is not the app's to take unasked, +# but the data question is still the user's to answer: declining keeps the +# whole tree -- runtime included -- untouched. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +remove_tty || fail "remove succeeds when the app never installed Hermes" +tr '\0' '\n' <"$test_tmp/gum-log" | grep -qx 'confirm' || + fail "removal still asks about the data without the bootstrap marker" +[[ -d $test_home/.hermes/hermes-agent && -d $test_home/.config/Hermes ]] || + fail "declining keeps a Hermes the app never installed" +pass "removal asks without the marker and declining keeps everything" + +# The prompt names ~/.hermes itself, so a yes takes the whole tree there too, +# unowned runtime and all -- that is what was asked and answered. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_GUM_STATUS=0 remove_tty || + fail "remove succeeds when the data goes too without the marker" +[[ ! -e $test_home/.hermes && ! -e $test_home/.config/Hermes ]] || + fail "a yes takes ~/.hermes whole when the marker never appeared" +pass "removal honors a yes on the named paths without the marker" + # A CLI teardown that fails must not stop the runtime handling, and must not be # papered over either: the data work still happens, and the failure reaches the # caller's exit code. diff --git a/test/shell.d/hermes-skin-migration-test.sh b/test/shell.d/hermes-skin-migration-test.sh new file mode 100644 index 0000000000..3fef5303c4 --- /dev/null +++ b/test/shell.d/hermes-skin-migration-test.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +set -euo pipefail + +# The migration hands an existing Hermes Desktop install the Omarchy skin. It +# is exercised here with the package probe and the skin hook stubbed, so a +# migration that reached a Hermes Omarchy did not install, or that marked a +# failed hand-over done, shows up in what it ran. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1788619462.sh" +[[ -f $migration ]] || fail "Hermes skin migration exists" +[[ $(stat -c %a "$migration") == "644" ]] || fail "migration is a plain 0644 file" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +calls="$test_tmp/calls" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-theme-set-hermes" <<'SH' +#!/bin/bash +echo "omarchy-theme-set-hermes $*" >>"$OMARCHY_TEST_CALLS" +[[ ${OMARCHY_TEST_HOOK_FAILS:-0} == 0 ]] +SH + +chmod +x "$mock_bin"/* + +run_migration() { + : >"$calls" + OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \ + OMARCHY_TEST_HOOK_FAILS="${OMARCHY_TEST_HOOK_FAILS:-0}" \ + OMARCHY_TEST_CALLS="$calls" \ + PATH="$mock_bin:$PATH" \ + HOME="$test_tmp/home" \ + OMARCHY_PATH="$ROOT" \ + bash -euo pipefail "$migration" >/dev/null +} + +OMARCHY_TEST_DESKTOP_INSTALLED=0 run_migration || fail "migration exits clean without Hermes Desktop" +[[ ! -s $calls ]] || fail "a machine without Hermes Desktop is left alone" "$(cat "$calls")" +pass "migration only applies where Omarchy installed Hermes Desktop" + +run_migration || fail "migration exits clean with Hermes Desktop installed" +[[ $(cat "$calls") == "omarchy-theme-set-hermes --activate" ]] || + fail "the skin is rendered, published and activated through the hook's deliberate form" "$(cat "$calls")" +pass "migration hands the skin over through the hook" + +if OMARCHY_TEST_HOOK_FAILS=1 run_migration; then + fail "a hand-over that failed on Omarchy's side stays pending" +fi +pass "migration stays pending when the hand-over fails" diff --git a/test/shell.d/hermes-theme-test.sh b/test/shell.d/hermes-theme-test.sh new file mode 100644 index 0000000000..631ffe7023 --- /dev/null +++ b/test/shell.d/hermes-theme-test.sh @@ -0,0 +1,401 @@ +#!/bin/bash + +set -euo pipefail + +# omarchy-theme-set-hermes writes a file another program parses and asks that +# program to switch to it while it is still on its default. Both are exercised +# here against a throwaway HOME with the Hermes readiness probe, the hermes +# command and the theme refresh stubbed, so a skin that stopped being +# validated, a write into a Hermes that was never set up, or an activation +# that trampled a chosen skin shows up in what landed on disk and what was run. + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +mkdir -p "$mock_bin" + +cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' +#!/bin/bash +echo "check" >>"$OMARCHY_TEST_HERMES_CALLS" +[[ $1 == "--check" && ${OMARCHY_TEST_HERMES_READY:-0} == "1" ]] +SH + +# A theme switch finishes the hand-over only for the desktop app Omarchy +# installed; --activate is asked for by name and does not look. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == "hermes-desktop" && ${OMARCHY_TEST_DESKTOP_INSTALLED:-1} == "1" ]] +SH + +# The hook runs the hermes the probe vets, ~/.local/bin/hermes, not one on PATH; +# reset_home installs this stub there and a decoy on PATH that must never run. +cat >"$mock_bin/hermes" <<'SH' +#!/bin/bash +echo "PATH hermes ran: $*" >>"$OMARCHY_TEST_HERMES_CALLS" +exit 1 +SH + +cat >"$mock_bin/hermes-stub" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$OMARCHY_TEST_HERMES_CALLS" +if [[ $1 == "config" && $2 == "get" ]]; then + [[ ${OMARCHY_TEST_HERMES_GET_FAILS:-0} == 0 ]] || exit 1 + printf '%s\n' "${OMARCHY_TEST_HERMES_SKIN-default}" +fi +if [[ $1 == "config" && $2 == "set" ]]; then + [[ ${OMARCHY_TEST_HERMES_SET_FAILS:-0} == 0 ]] || exit 1 +fi +SH + +# A refresh re-stages the current theme, which is where the skin gets rendered. +cat >"$mock_bin/omarchy-theme-refresh" <<'SH' +#!/bin/bash +echo "refresh" >>"$OMARCHY_TEST_HERMES_CALLS" +printf 'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n' \ + >"$HOME/.local/state/omarchy/current/theme/hermes.yaml" +SH + +# --wait sleeps between its polls and once more after activating; the stub +# records the delays it was asked for and returns at once. +cat >"$mock_bin/sleep" <<'SH' +#!/bin/bash +printf 'sleep %s\n' "$1" >>"$OMARCHY_TEST_HERMES_CALLS" +if [[ $1 == 60 && -n ${OMARCHY_TEST_SWAP_SOURCE:-} ]]; then + printf '%s\n' "$OMARCHY_TEST_SWAP_SOURCE" >"$HOME/.local/state/omarchy/current/theme/hermes.yaml" +fi +if [[ $1 == 60 && ${OMARCHY_TEST_DROP_SKINS:-0} == 1 ]]; then + rm -f "$HOME/.hermes/skins/omarchy.yaml" "$HOME"/.hermes/profiles/*/skins/omarchy.yaml +fi +SH + +chmod +x "$mock_bin"/* + +good_skin='name: omarchy +description: Omarchy system theme +colors: + background: "#1a1b26" + ui_text: "#a9b1d6" + ui_accent: "#7aa2f7"' + +test_home="$test_tmp/home" +hermes_home="$test_home/.hermes" +skin="$hermes_home/skins/omarchy.yaml" +hermes_calls="$test_tmp/hermes-calls" + +# Each case gets a fresh HOME so no file survives from the one before. The +# Hermes home is created the way provisioning does on every machine; only +# --set-up adds the config that says Hermes itself has run, on its default +# skin unless --on names another. +reset_home() { + local source="$good_skin" + + rm -rf "$test_home" + mkdir -p "$test_home/.local/state/omarchy/current/theme" "$test_home/.local/bin" "$hermes_home/skills" + cp "$mock_bin/hermes-stub" "$test_home/.local/bin/hermes" + : >"$hermes_calls" + + while (( $# > 0 )); do + case "$1" in + --set-up) printf 'display:\n skin: default\n' >"$hermes_home/config.yaml" ;; + --on) printf 'display:\n skin: %s\n' "$2" >"$hermes_home/config.yaml"; shift ;; + *) source="$1" ;; + esac + shift + done + + printf '%s\n' "$source" >"$test_home/.local/state/omarchy/current/theme/hermes.yaml" +} + +run_hook() { + OMARCHY_TEST_HERMES_READY="${OMARCHY_TEST_HERMES_READY:-0}" \ + OMARCHY_TEST_HERMES_SKIN="${OMARCHY_TEST_HERMES_SKIN-default}" \ + OMARCHY_TEST_HERMES_GET_FAILS="${OMARCHY_TEST_HERMES_GET_FAILS:-0}" \ + OMARCHY_TEST_HERMES_SET_FAILS="${OMARCHY_TEST_HERMES_SET_FAILS:-0}" \ + OMARCHY_TEST_HERMES_CALLS="$hermes_calls" \ + OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-1}" \ + OMARCHY_TEST_SWAP_SOURCE="${OMARCHY_TEST_SWAP_SOURCE:-}" \ + OMARCHY_TEST_DROP_SKINS="${OMARCHY_TEST_DROP_SKINS:-0}" \ + PATH="$mock_bin:$PATH" \ + HOME="$test_home" \ + HERMES_HOME='' \ + "$ROOT/bin/omarchy-theme-set-hermes" "$@" +} + +# -- publishing --------------------------------------------------------------- + +reset_home +run_hook +[[ ! -e $hermes_home/skins ]] || fail "a Hermes home that only holds the Omarchy skill gets no skin" +[[ ! -s $hermes_calls ]] || fail "nothing is run for a Hermes that never ran" "$(cat "$hermes_calls")" +pass "a theme switch leaves a machine that never ran Hermes alone" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/work" +run_hook 2>"$test_tmp/stderr" +diff -q "$test_home/.local/state/omarchy/current/theme/hermes.yaml" "$skin" >/dev/null || + fail "the generated skin is published to ~/.hermes/skins/omarchy.yaml" +diff -q "$skin" "$hermes_home/profiles/work/skins/omarchy.yaml" >/dev/null || + fail "an existing Hermes profile gets the skin too" +[[ $(ls "$hermes_home/skins") == "omarchy.yaml" ]] || fail "no temporary file is left beside the skin" +[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is asked nothing more" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch says nothing about Hermes" "$(cat "$test_tmp/stderr")" +pass "the skin is published to the Hermes home and every profile" + +reset_home --set-up 'name: omarchy +description: Omarchy system theme +colors: + background: "{{ background }}"' +mkdir -p "$hermes_home/skins" +printf 'name: omarchy\ncolors:\n background: "#000000"\n' >"$skin" +run_hook 2>"$test_tmp/stderr" +grep -q '#000000' "$skin" || fail "an unresolved placeholder keeps the previous skin in place" +grep -q 'not a plain color palette' "$test_tmp/stderr" || fail "an unresolved placeholder is reported" +pass "a skin with unresolved colors is not published" + +# mv would otherwise move the temp file inside a directory at the skin's path, +# leaving Hermes a directory to read and the temp file behind. +reset_home --set-up +mkdir -p "$skin" +if run_hook 2>/dev/null; then + fail "a directory at the skin's path is an error, not a place to put the skin" +fi +[[ -z $(ls -A "$skin") && $(ls "$hermes_home/skins") == "omarchy.yaml" ]] || + fail "nothing is left inside or beside a directory at the skin's path" "$(ls -R "$hermes_home/skins")" +pass "a directory at the skin's path is refused cleanly" + +for bad in \ + $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\nbanner_logo: "[link=file:///etc/passwd]x[/link]"' \ + $'name: omarchy\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26\\"\\n ui_text: \\"#ffffff"' \ + $'name: nord\ndescription: Nord\ncolors:\n background: "#2e3440"' \ + $'description: Omarchy system theme\ncolors:\n background: "#1a1b26"' \ + $'name: omarchy\ndescription: Nord: arctic palette\ncolors:\n background: "#2e3440"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\rbanner_logo: "[link=file:///etc/passwd]x[/link]"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xe2\x80\xa8banner_logo: "evil"' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\n#\xc2\x85banner_logo: "evil"' \ + $'name: omarchy\n background: "#1a1b26"\ncolors:' \ + $'name: omarchy\ncolors:\n background: "#1a1b26"\ncolors:' \ + $'colors:\n background: "#1a1b26"\nname: omarchy' \ + $'name: omarchy\ncolors:'; do + reset_home --set-up "$bad" + run_hook 2>/dev/null + [[ ! -e $skin ]] || fail "a skin that is not exactly a named palette of hex colors is not published" "$bad" +done +pass "a skin is held to the shape Hermes loads, on the lines Hermes' YAML reader sees" + +# A NUL cannot travel through a shell string, so it is written straight to the +# source; grep reads past one where YAML stops. +reset_home --set-up +printf 'name: omarchy\ncolors:\n background: "#1a1b26"\0\n' >"$test_home/.local/state/omarchy/current/theme/hermes.yaml" +run_hook 2>/dev/null +[[ ! -e $skin ]] || fail "a NUL byte in the skin is rejected" +pass "a skin carrying a NUL byte is not published" + +reset_home --set-up $'# rendered by Omarchy\nname: omarchy\n\ndescription: Omarchy system theme\ncolors:\n background: "#1a1b26"\n' +run_hook 2>/dev/null +[[ -f $skin ]] || fail "comments and blank lines are allowed around the palette" +pass "a well-formed skin with comments and blank lines is published" + +# -- a theme switch finishes a missed hand-over --------------------------------- + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr" +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] || + fail "a ready Hermes still on its default is switched by a theme switch" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch activates quietly" "$(cat "$test_tmp/stderr")" +pass "a theme switch activates the skin on a Hermes still on its default" + +reset_home --on omarchy +OMARCHY_TEST_HERMES_READY=1 run_hook +[[ -f $skin ]] || fail "the skin is published when it is already active" +[[ ! -s $hermes_calls ]] || fail "a Hermes already on the skin is not started" "$(cat "$hermes_calls")" +pass "a theme switch does not start a Hermes already on the skin" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook +[[ -f $skin ]] || fail "a Hermes installed some other way still gets the skin published" +[[ ! -s $hermes_calls ]] || fail "a theme switch does not touch a Hermes Omarchy did not install as the app" "$(cat "$hermes_calls")" +pass "a theme switch activates only for the desktop app Omarchy installed" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DESKTOP_INSTALLED=0 run_hook --activate 2>/dev/null +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "--activate switches whichever Hermes it is asked about" "$(cat "$hermes_calls")" +pass "--activate does not ask which Hermes it is" + +reset_home --on ares +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook 2>"$test_tmp/stderr" +[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it" +[[ ! -s $hermes_calls ]] || fail "a theme switch does not start a Hermes whose config names a chosen skin" "$(cat "$hermes_calls")" +[[ ! -s $test_tmp/stderr ]] || fail "a chosen skin is left without comment on a theme switch" "$(cat "$test_tmp/stderr")" +pass "a theme switch leaves a skin the user chose in Hermes" + +# Hermes reads the config of the profile named in active_profile, so that is +# the config that says whether there is anything left to do. +reset_home --on omarchy +mkdir -p "$hermes_home/profiles/work" +printf 'display:\n skin: default\n' >"$hermes_home/profiles/work/config.yaml" +echo work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "an active profile still on its default is switched even when the root config names the skin" "$(cat "$hermes_calls")" +pass "a theme switch follows the active Hermes profile" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/work" +printf 'display:\n skin: ares\n' >"$hermes_home/profiles/work/config.yaml" +echo work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +[[ ! -s $hermes_calls ]] || fail "a skin chosen in the active profile is not replaced" "$(cat "$hermes_calls")" +pass "a theme switch leaves a skin chosen in the active Hermes profile" + +# Hermes selects a profile once its directory exists; without a config of its +# own it is on the default skin whatever the root config says. +reset_home --on omarchy +mkdir -p "$hermes_home/profiles/work" +echo Work >"$hermes_home/active_profile" +OMARCHY_TEST_HERMES_READY=1 run_hook +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "an active profile without a config of its own is on the default and gets switched" "$(cat "$hermes_calls")" +pass "a theme switch follows an active profile that has no config yet" + +# Only a plainly named skin ends a switch early; anything Hermes might read as +# its default is left for Hermes to answer. +for line in 'skin: "default"' 'skin: default # chosen long ago' 'skin: null' 'skin: false'; do + reset_home --set-up + printf 'display:\n %s\n' "$line" >"$hermes_home/config.yaml" + OMARCHY_TEST_HERMES_READY=1 run_hook + grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "a config line Hermes reads as the default still gets the skin activated" "$line: $(cat "$hermes_calls")" +done +pass "a theme switch asks Hermes about any skin line that is not a plain name" + +reset_home --set-up +mkdir -p "$hermes_home/profiles/broken" +: >"$hermes_home/profiles/broken/skins" +OMARCHY_TEST_HERMES_READY=1 run_hook 2>"$test_tmp/stderr" || fail "a profile that cannot take the skin does not fail the hook" +[[ ! -s $test_tmp/stderr ]] || fail "a theme switch stays quiet about a profile it could not reach" "$(cat "$test_tmp/stderr")" +[[ -f $skin ]] || fail "the Hermes home still gets the skin beside a broken profile" +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "activation still happens beside a broken profile" "$(cat "$hermes_calls")" +pass "a profile that cannot take the skin costs nobody else" + +# -- activation --------------------------------------------------------------- + +reset_home +run_hook --activate 2>"$test_tmp/stderr" +[[ ! -e $hermes_home/skins && ! -e $hermes_home/config.yaml ]] || + fail "--activate writes nothing into a Hermes that has never run" +grep -q 'not set up yet' "$test_tmp/stderr" || fail "--activate says why nothing happened" +pass "--activate waits for Hermes to have been set up" + +reset_home --set-up +run_hook --activate 2>"$test_tmp/stderr" +[[ -f $skin ]] || fail "--activate publishes the skin when Hermes is not ready" +[[ $(cat "$hermes_calls") == "check" ]] || fail "a Hermes that is not ready is not run" "$(cat "$hermes_calls")" +grep -q 'hermes config set display.skin omarchy' "$test_tmp/stderr" || fail "an unready Hermes gets the command that finishes the job" +pass "--activate publishes but does not run a Hermes that is not ready" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>"$test_tmp/stderr" +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy' ]] || + fail "a ready Hermes is asked for its skin and then to switch" "$(cat "$hermes_calls")" +grep -q 'on the Omarchy skin' "$test_tmp/stderr" || fail "--activate reports success" +pass "--activate goes through hermes config set when Hermes runs" + +reset_home --on omarchy +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=omarchy run_hook --activate 2>/dev/null +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "--activate goes through Hermes even when the config already names the skin" "$(cat "$hermes_calls")" +pass "--activate always asks Hermes to switch" + +for chosen in omarchy ""; do + reset_home --set-up + OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN="$chosen" run_hook --activate 2>/dev/null + grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || + fail "the default and the omarchy skin are both replaced" "skin='$chosen': $(cat "$hermes_calls")" +done +pass "--activate replaces Hermes' default skin" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SKIN=ares run_hook --activate 2>"$test_tmp/stderr" +[[ -f $skin ]] || fail "a chosen skin still gets the Omarchy skin published beside it" +! grep -q 'config set' "$hermes_calls" || fail "a skin the user chose is not replaced" "$(cat "$hermes_calls")" +grep -q "'ares' skin" "$test_tmp/stderr" || fail "leaving a chosen skin is reported" +pass "--activate leaves a skin the user chose in Hermes" + +reset_home --set-up +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_GET_FAILS=1 run_hook --activate 2>"$test_tmp/stderr" || fail "a Hermes that does not answer is not an error" +! grep -q 'config set' "$hermes_calls" || fail "no answer from Hermes is not taken for the default" "$(cat "$hermes_calls")" +grep -q 'did not say' "$test_tmp/stderr" || fail "an unanswered question is reported" +pass "--activate does not switch a Hermes that did not say which skin it is on" + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_HERMES_SET_FAILS=1 run_hook --wait 2>"$test_tmp/stderr" || fail "a Hermes that refuses the write is not an error" +grep -q 'refused' "$test_tmp/stderr" || fail "a refused write is reported" +! grep -q 'sleep 60' "$hermes_calls" || fail "nothing is announced for a write that did not happen" "$(cat "$hermes_calls")" +pass "--activate reports a Hermes that refused the skin and moves on" + +# -- a skin the current theme has not rendered yet ------------------------------- + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +run_hook +[[ ! -e $hermes_home/skins && ! -s $hermes_calls ]] || + fail "a theme switch without a rendered skin publishes nothing" "$(cat "$hermes_calls")" +pass "a theme switch has nothing to do without a rendered skin" + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +if run_hook --activate 2>"$test_tmp/stderr"; then + fail "--activate fails when no theme has been selected" +fi +grep -q 'Select an Omarchy theme' "$test_tmp/stderr" || fail "a missing theme is reported" +pass "--activate fails without a current theme to render the skin from" + +reset_home --set-up +rm "$test_home/.local/state/omarchy/current/theme/hermes.yaml" +echo tokyo-night >"$test_home/.local/state/omarchy/current/theme.name" +OMARCHY_TEST_HERMES_READY=1 run_hook --activate 2>/dev/null +[[ $(head -1 "$hermes_calls") == "refresh" ]] || fail "a theme applied before the template existed is re-staged" "$(cat "$hermes_calls")" +[[ -f $skin ]] || fail "the freshly rendered skin is published" +grep -Fxq 'config set display.skin omarchy' "$hermes_calls" || fail "the freshly rendered skin is activated" "$(cat "$hermes_calls")" +pass "--activate renders the skin for a theme that predates it" + +# -- waiting for the desktop app's first launch ---------------------------------- + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>/dev/null +[[ $(cat "$hermes_calls") == $'check\nconfig get display.skin\nconfig set display.skin omarchy\nsleep 60' ]] || + fail "--wait activates as soon as the runtime marker is there, then republishes after the gateway is up" "$(cat "$hermes_calls")" +[[ -f $skin ]] || fail "--wait publishes the skin" +pass "--wait activates once the desktop app has built its runtime" + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_SWAP_SOURCE=$'name: omarchy\ncolors:\n background: "#1a1b26"\nbanner_logo: "evil"' run_hook --wait 2>/dev/null +! grep -q 'banner_logo' "$skin" || fail "the republish after the gateway is up does not publish a theme that changed underneath into something rejected" +pass "--wait checks the skin again before republishing it" + +reset_home --set-up +mkdir -p "$hermes_home/hermes-agent" "$hermes_home/profiles/work" +touch "$hermes_home/hermes-agent/.hermes-bootstrap-complete" +OMARCHY_TEST_HERMES_READY=1 OMARCHY_TEST_DROP_SKINS=1 run_hook --wait 2>/dev/null +[[ -f $skin && -f $hermes_home/profiles/work/skins/omarchy.yaml ]] || + fail "the republish after the gateway is up writes the skin to the Hermes home and every profile again" "$(ls -R "$hermes_home")" +pass "--wait republishes the skin everywhere once the gateway is up" + +reset_home +OMARCHY_TEST_HERMES_READY=1 run_hook --wait 2>"$test_tmp/stderr" +[[ $(head -1 "$hermes_calls") == "sleep 10" && ! -e $hermes_home/skins ]] || + fail "--wait polls for the runtime instead of running Hermes" "$(head -3 "$hermes_calls")" +[[ $(grep -c 'sleep 10' "$hermes_calls") == 180 ]] || fail "--wait gives up after 30 minutes" "$(grep -c 'sleep 10' "$hermes_calls")" +grep -q 'did not finish setting up' "$test_tmp/stderr" || fail "giving up is reported" +pass "--wait polls until the desktop app has built its runtime and gives up in time" diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index c34d4bfd6b..d0eb9e0e39 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -40,6 +40,13 @@ hl = setmetatable({ release = opts.release == true, }) end, + unbind = function(keys) + for index = #bindings, 1, -1 do + if bindings[index].keys == keys then + table.remove(bindings, index) + end + end + end, config = function() end, env = function() end, monitor = function() end, @@ -188,3 +195,17 @@ probe=$(PATH="$stub_bin:$PATH" list_bindings "$home" \ grep -Fqx "ALT+SHIFT+SUPER+RIGHT" <<<"$probe" || fail "the conflict check ignores modifier order" pass "the conflict check catches collisions across keycodes and modifier order" + +rebound=$(PATH="$stub_bin:$PATH" list_bindings "$home" \ + 'o.rebind("SUPER + SHIFT + F", "Flea", { launch = "flea" })' | \ + awk -F'\t' '$1 == "SHIFT+SUPER+F"') +[[ $rebound == $'SHIFT+SUPER+F\tSUPER + SHIFT + F\tFlea' ]] || + fail "rebinding replaces the default file manager without stacking actions" "$rebound" +pass "rebinding replaces the default file manager without stacking actions" + +rebound=$(PATH="$stub_bin:$PATH" list_bindings "$home" \ + 'o.rebind("F9", "Dictation on release", "voxtype record toggle", { release = true })' | \ + awk -F'\t' '$2 == "F9"') +[[ $rebound == $'F9 (release)\tF9\tDictation on release' ]] || + fail "rebinding replaces all bindings for a key and preserves binding options" "$rebound" +pass "rebinding replaces all bindings for a key and preserves binding options" diff --git a/test/shell.d/menu-images-test.sh b/test/shell.d/menu-images-test.sh index ac20a8ca62..179dcb6b6a 100644 --- a/test/shell.d/menu-images-test.sh +++ b/test/shell.d/menu-images-test.sh @@ -69,7 +69,7 @@ PATH="$stub_bin:$PATH" XDG_CACHE_HOME="$cache_home" \ fail "image menu recovers thumbnails from stranded locks" (( $(awk 'END { print NR }' "$cache_dir/$cache_key.rows") == 3 )) || fail "image menu rebuilds every row after cache invalidation" -[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v3" ]] || +[[ $(head -n 1 "$cache_dir/$cache_key.signature") == "v4" ]] || fail "image menu invalidates stale row caches" [[ ! -e $stale_tmp ]] || fail "image menu clears partial thumbnails left by killed generators" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh old mode 100644 new mode 100755 index c5a3278923..b227bb1017 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -228,7 +228,9 @@ const expectedAgents = { openclaw: { icon: '\ue90c', iconFont: 'omarchy', label: 'OpenClaw' }, copilot: { icon: '', label: 'Copilot' }, crush: { icon: '󰋑', label: 'Crush' }, - 'cursor-agent': { icon: '\ue90b', iconFont: 'omarchy', label: 'Cursor' }, + muse: { icon: '󰛤', label: 'Muse Code' }, + 'cursor-agent': { icon: '\ue90d', iconFont: 'omarchy', label: 'Cursor CLI' }, + } assert( Object.entries(expectedAgents).every(([agent, expected]) => { @@ -241,13 +243,13 @@ assert( && !entry.when && entry.checked.includes(`== \"${agent}\"`) }), - 'menu exposes every coding agent with its own glyph under Defaults > Agent' + 'menu exposes every supported coding agent with its own glyph under Defaults > Agent' ) assertDeepEqual( defaultItems .filter(item => item.parent === 'setup.default.agent') .map(item => item.label), - ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Cursor', 'Grok', 'Hermes', 'omp', 'OpenClaw', 'OpenCode', 'Ori', 'Pi'], + ['Antigravity', 'Claude', 'Codex', 'Copilot', 'Crush', 'Cursor CLI', 'Grok', 'Hermes', 'Muse Code', 'omp', 'OpenClaw', 'OpenCode', 'Ori', 'Pi'], 'menu sorts coding agents alphabetically' ) const expectedDefaults = { @@ -639,5 +641,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e90c"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e90d"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index a10bc3b6ad..9d51831858 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -15,6 +15,22 @@ printf 'drop:%s\n' "$*" >>"$TEST_LOG" SCRIPT chmod +x "$tmp_dir/bin/omarchy-pkg-drop" +# omarchy-remove-ai-perplexity asks through gum whether the user's data goes +# too. The stub answers "no" unless a test says otherwise and logs the call: a +# real gum would hang the run, and one that answered "yes" on its own would be +# the data loss the default-no exists to prevent. It logs to its own file +# because the OpenClaw section below greps TEST_LOG to prove gum was never +# reached, and the pty runs here call it on purpose. +cat >"$tmp_dir/bin/gum" <<'SCRIPT' +#!/bin/bash +printf 'gum:%s\n' "$*" >>"$TEST_GUM_LOG" +exit "${TEST_GUM_STATUS:-1}" +SCRIPT +chmod +x "$tmp_dir/bin/gum" + +export TEST_GUM_LOG="$tmp_dir/gum-log" +touch "$TEST_GUM_LOG" + export TEST_LOG="$tmp_dir/log" export PATH="$tmp_dir/bin:$PATH" @@ -75,6 +91,88 @@ for kept in .grok .claude.json .npm .local/share/opencode; do done pass "T3 Code removal keeps the agent state it bootstrapped" +# Perplexity's other products (the CLI, Comet) share the perplexity-* prefix; +# the desktop app owns only its rpc-server runtime and Electron caches. The +# logins, vault, and flags are the user's and only go when a terminal said so. +seed_perplexity() { + fresh_home + mkdir -p "$HOME/.config/Perplexity" "$HOME/.cache/Perplexity" \ + "$HOME/.cache/perplexity-rpc-server" "$HOME/.local/share/perplexity-rpc-server" \ + "$HOME/.local/state/perplexity" "$HOME/.cache/perplexity-personal-computer-poc" + touch "$HOME/.config/perplexity-flags.conf" +} + +# /dev/null + +for gone in .cache/Perplexity .cache/perplexity-rpc-server .local/share/perplexity-rpc-server; do + [[ ! -e $HOME/$gone ]] || fail "Perplexity removal deletes the app's runtime and caches" "$gone" +done +pass "Perplexity removal deletes the app's runtime and caches" + +for kept in .config/Perplexity .local/state/perplexity .config/perplexity-flags.conf .cache/perplexity-personal-computer-poc; do + [[ -e $HOME/$kept ]] || fail "Perplexity removal keeps the user's data and other products' caches" "$kept" +done +pass "Perplexity removal keeps the user's data and other products' caches" + +grep -qx 'drop:perplexity' "$TEST_LOG" || fail "Perplexity removal drops the perplexity package" +pass "Perplexity removal drops the perplexity package" + +# Without a terminal there is nobody to ask, so gum must not even be reached: +# one that answered "yes" on its own would be a data loss. +! grep -q '^gum:' "$TEST_GUM_LOG" || fail "Perplexity removal keeps the user's data unasked when there is no terminal" +pass "Perplexity removal keeps the user's data unasked when there is no terminal" + +# script(1) puts the remover on a pty, the only way -t 0 answers true in a +# test; the stubbed gum then supplies the answer. +seed_perplexity +script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1 + +[[ -d $HOME/.config/Perplexity && -d $HOME/.local/state/perplexity && -f $HOME/.config/perplexity-flags.conf ]] || + fail "Perplexity removal keeps the user's data when the answer is no" +pass "Perplexity removal keeps the user's data when the answer is no" + +# The stub answers "no" on its own, so only the logged arguments prove the +# real gum would not default to yes on a bare Enter. +grep -q '^gum:confirm --default=false ' "$TEST_GUM_LOG" || + fail "Perplexity removal asks with the destructive answer defaulted off" +pass "Perplexity removal asks with the destructive answer defaulted off" + +# A partial install has no user data to measure; du failing must not abort +# the removal between the package and the prompt. +seed_perplexity +rm -rf "$HOME/.config/Perplexity" "$HOME/.local/state/perplexity" +script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1 || + fail "Perplexity removal survives user-data directories that are already gone" +pass "Perplexity removal survives user-data directories that are already gone" + +# gum draws its prompt on stderr; with stderr redirected the question would be +# invisible, so the remover must keep the data instead of blocking on it. +seed_perplexity +gum_calls_before=$(wc -l <"$TEST_GUM_LOG") +script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity' 2>/dev/null" /dev/null >/dev/null 2>&1 || + fail "Perplexity removal completes when stderr is not a terminal" +[[ -d $HOME/.config/Perplexity ]] && (( $(wc -l <"$TEST_GUM_LOG") == gum_calls_before )) || + fail "Perplexity removal keeps the user's data unasked when stderr is not a terminal" +pass "Perplexity removal keeps the user's data unasked when stderr is not a terminal" + +seed_perplexity +TEST_GUM_STATUS=0 script -qec "'$ROOT/bin/omarchy-remove-ai-perplexity'" /dev/null >/dev/null 2>&1 + +for gone in .config/Perplexity .local/state/perplexity .config/perplexity-flags.conf; do + [[ ! -e $HOME/$gone ]] || fail "Perplexity removal deletes the user's data on an explicit yes" "$gone" +done +pass "Perplexity removal deletes the user's data on an explicit yes" + +# Without HOME the rm -rf paths would degrade to /-rooted ones; -u makes that a +# refusal instead. +if env -u HOME "$ROOT/bin/omarchy-remove-ai-perplexity" /dev/null 2>&1; then + fail "Perplexity removal refuses to run without HOME" +fi +pass "Perplexity removal refuses to run without HOME" + # ~/.grok belongs to the Grok CLI that omarchy-default-agent installs. fresh_home mkdir -p "$HOME/.config/Grok Bot" "$HOME/.grokbot" "$HOME/.grok" diff --git a/test/shell.d/theme-staging-test.sh b/test/shell.d/theme-staging-test.sh index cc749a9e2a..9ec3b0f9fa 100755 --- a/test/shell.d/theme-staging-test.sh +++ b/test/shell.d/theme-staging-test.sh @@ -207,7 +207,7 @@ pass "a theme name cannot climb out of the theme directories" # generates. Every generated theme file is either denied to an installed theme or # recorded here as carrying colour, so a new template fails until it is placed. denied=(alacritty.toml foot.ini ghostty.conf kitty.conf gum_env.lua hyprland.lua neovim.lua vscode.json) -colour_only=(btop.theme chromium.theme claude.json helix.toml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) +colour_only=(btop.theme chromium.theme claude.json helix.toml hermes.yaml hyprland-preview-share-picker.css keyboard.rgb obsidian.css pi.json shell.toml vscode-theme.json) for tpl in "$ROOT"/default/themed/*.tpl; do generated=$(basename "$tpl" .tpl) diff --git a/test/shell.d/upgrade-to-quattro-test.sh b/test/shell.d/upgrade-to-quattro-test.sh index 0648409d8e..d357f51348 100644 --- a/test/shell.d/upgrade-to-quattro-test.sh +++ b/test/shell.d/upgrade-to-quattro-test.sh @@ -6,6 +6,10 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" upgrade_to_quattro="$ROOT/bin/omarchy-upgrade-to-quattro" +function_body() { + awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" +} + snapshot_line=$(grep -n '^create_pre_upgrade_snapshot$' "$upgrade_to_quattro" | cut -d: -f1) pacman_line=$(grep -n '^configure_pacman_channel$' "$upgrade_to_quattro" | cut -d: -f1) [[ -n $snapshot_line && -n $pacman_line ]] || fail "upgrade snapshot and first mutation calls exist" @@ -73,6 +77,44 @@ grep -F 'OMARCHY_INSTALL_USER="$target_user"' "$upgrade_to_quattro" >/dev/null grep -F '"$apply_lock"' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade configures lock screen authentication for the target user" +root_path_count=$(awk '/^root_path=/{ count++ } END { print count + 0 }' "$upgrade_to_quattro") +(( root_path_count == 1 )) || fail "Omarchy 4 upgrade defines exactly one root command path" +grep -Fx 'root_path=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade limits root command lookup to trusted system directories" +if grep -E '^root_path=.*(target_home|\.local/bin)' "$upgrade_to_quattro" >/dev/null; then + fail "Omarchy 4 upgrade does not put the target user's bin directory on the root command path" +fi +grep -Fx 'package_path="$root_path:$target_home/.local/bin"' "$upgrade_to_quattro" >/dev/null || + fail "Omarchy 4 upgrade retains the target user's bin directory for user commands" + +lock_authentication_body=$(function_body configure_lock_authentication) +lock_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$lock_authentication_body") +(( lock_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged lock helper exactly one command path" +grep -Fx ' PATH="$root_path" \' <<<"$lock_authentication_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged lock helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$lock_authentication_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged lock helper the target user's path" +fi + +firewall_body=$(function_body apply_firewall_defaults) +firewall_path_assignment_count=$(awk '{ count += gsub(/(^|[[:space:]])PATH=/, "") } END { print count + 0 }' <<<"$firewall_body") +(( firewall_path_assignment_count == 1 )) || + fail "Omarchy 4 upgrade gives the privileged firewall helper exactly one command path" +grep -Fx ' as_root env OMARCHY_PATH=/usr/share/omarchy PATH="$root_path" \' <<<"$firewall_body" >/dev/null || + fail "Omarchy 4 upgrade gives the privileged firewall helper the trusted root path" +if grep -E '(package_path|target_home|\.local/bin)' <<<"$firewall_body" >/dev/null; then + fail "Omarchy 4 upgrade does not give the privileged firewall helper the target user's path" +fi + +user_omarchy_body=$(function_body run_as_user_omarchy) +grep -F 'PATH="$package_path"' <<<"$user_omarchy_body" >/dev/null || + fail "Omarchy 4 upgrade retains the package and user path for target-user commands" +if grep -F 'PATH="$root_path"' <<<"$user_omarchy_body" >/dev/null; then + fail "Omarchy 4 upgrade does not narrow target-user commands to the root-only path" +fi +pass "Omarchy 4 upgrade separates privileged and target-user command paths" + grep -F 'install/helpers/browser-policy.sh' "$upgrade_to_quattro" >/dev/null || fail "Omarchy 4 upgrade uses the shared browser-policy helper" grep -F 'as_root test -f "$browser_policy_helper"' "$upgrade_to_quattro" >/dev/null || @@ -100,10 +142,6 @@ pass "Omarchy 4 upgrade retires systemd-networkd for NetworkManager" # Booting with both managers enabled leaves them fighting over the Wi-Fi # adapter, so enabling NetworkManager and disabling iwd cannot be separated by # any step that might abort in between. -function_body() { - awk -v name="$1" '$0 == name "() {" { inside = 1; next } inside && $0 == "}" { exit } inside' "$upgrade_to_quattro" -} - migrations_body=$(function_body run_post_upgrade_migrations) grep -F 'fail "Omarchy migrations did not complete.' <<<"$migrations_body" >/dev/null || fail "Omarchy 4 upgrade fails when a migration cannot complete" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh new file mode 100755 index 0000000000..1a15dacef6 --- /dev/null +++ b/test/shell.d/video-background-test.sh @@ -0,0 +1,366 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') + +const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8') +const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') +const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8') +const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') +const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8') +const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8') +const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8') +const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8') +const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8') +const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') +const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8') +const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') +const directImageList = fs.readFileSync(path.join(root, 'shell/plugins/image-picker/list.sh'), 'utf8') + +assert( + /function isVideoPath\(path\)[\s\S]*\.test\(String\(path \|\| ""\)\)/.test(utilQml) && + !utilQml.includes('split(/[?#]/)'), + 'shared media helper identifies video paths without truncating valid local names' +) +assert( + videoQml.includes('loops: MediaPlayer.Infinite') && + videoQml.includes('autoPlay: root.playbackEnabled') && + videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') && + /imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && + /videoUrl: path && Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) : ""/.test(mediaQml), + 'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file' +) +assert( + videoQml.includes('MediaPlayer.LoadedMedia') && + videoQml.includes('primePauseTimer') && + videoQml.includes('mediaGeneration') && + videoQml.includes('videoSink') && + videoQml.includes('onVideoFrameChanged') && + videoQml.includes('MediaPlayer.BufferedMedia') && + videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') && + videoQml.includes('interval: 1000') && + videoQml.includes('interval: 50') && + videoQml.includes('frameReceived') && + videoQml.includes('output.clearOutput()') && + !videoQml.includes('KeepLastFrame') && + /onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) && + videoQml.includes('primingGeneration') && + videoQml.includes('player.play()') && + videoQml.includes('player.pause()'), + 'paused video sources are primed to display their first frame' +) +assert( + !/^\s*import QtMultimedia/m.test(mediaQml) && + mediaQml.includes('source: "BackgroundVideo.qml"'), + 'the still-image path never imports QtMultimedia, so image-only sessions do not map it' +) +assert( + !/^\s*Video\s*\{/m.test(videoQml) && + /property bool audioEnabled: false/.test(videoQml) && + /property bool audioEnabled: false/.test(mediaQml) && + /active: root\.audioEnabled && player\.hasAudio/.test(videoQml) && + /audioOutput: audioLoader\.item/.test(videoQml) && + /muted: root\.priming \|\| !root\.playbackEnabled/.test(videoQml) && + /property: "audioEnabled"\s*\n\s*value: root\.audioEnabled/.test(mediaQml) && + /firstScreen: Quickshell\.screens\.length > 0\s*\n\s*&& String\(Quickshell\.screens\[0\]\.name/.test(backgroundQml) && + backgroundQml.includes('audioEnabled: panel.firstScreen') && + !lockQml.includes('audioEnabled'), + 'a sound track plays from the first monitor only, a silent file builds no audio output, and the lock stays quiet' +) +assert( + /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && + !videoQml.includes('Component.onDestruction'), + 'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel' +) +assert( + !mediaQml.includes('mipmap'), + 'the shared image path leaves mipmapping off, as the desktop background had it' +) +assert( + /instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml), + 'video switches bypass the image-only reveal stack and use the durable background path' +) +assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') +assert( + lockQml.includes('source: wallpaper.video ? null : wallpaper') && + lockQml.includes('visible: !wallpaper.video') && + lockQml.includes('visible: wallpaper.video'), + 'lock screen bypasses its image effect for video output' +) +assert( + /sessionObscured:\s*lockActive \|\| screensaverActive/.test(backgroundQml) && + backgroundQml.includes('playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere') && + backgroundQml.includes('omarchy.lock') && + backgroundQml.includes('omarchy.idle') && + backgroundQml.includes('omarchy.battery'), + 'desktop playback stops while covered or on battery power-saver' +) +assert( + backgroundQml.includes('Hyprland.monitorFor(modelData)') && + /fullscreenHere: visibleWorkspace \? visibleWorkspace\.hasFullscreen : false/.test(backgroundQml) && + !backgroundQml.includes('ToplevelManager.activeToplevel'), + 'a fullscreen window pauses only the output it covers, wherever focus is' +) +assert( + /if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) && + backgroundQml.includes('reloads: root.displayedReloads') && + /active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) && + /onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml), + 'a theme switch that keeps the video path still reopens the replaced file' +) +assert( + barTextColor.includes('magick "$background_path[0]"'), + 'bar colour sampling reads one frame instead of decoding a whole video' +) +assert( + menuImages.includes('pending_video_file') && /video_jobs=\$\(\( \$\(nproc\) \/ 4 \)\)/.test(menuImages), + 'video thumbnails fan out narrower than single-threaded vips jobs' +) +assert( + themeSwitcher.includes('fast_signature="v2"'), + 'the theme preview cache rebuilds after preview discovery learned about video' +) +assert( + /lazy_thumbnails == true && \$cache_only != true \]\] && ! is_video_path/.test(menuImages), + 'a video never stands in as its own lazy thumbnail, so the fan out cap always applies' +) +assert( + /thumbnail_command=\(timeout -k \d+ \d+ ffmpegthumbnailer/.test(menuImages), + 'a stalled video cannot hold the picker shut, because its generator is time bounded' +) +assert( + directImageList.includes('generate_video_thumbnail') && + /timeout -k \d+ \d+ ffmpegthumbnailer/.test(directImageList) && + /if \[\[ ! -f \$thumbnail \]\] && ! is_video_path/.test(directImageList), + 'a direct picker scan generates bounded video thumbnails without content-hashing the media' +) +assert( + themeSet.includes('choose_staged_theme_background') && + themeSet.includes('background_transition_uses_snapshots') && + themeSet.includes('BACKGROUND_TRANSITION_SNAPSHOTS=false') && + /if \[\[ \$BACKGROUND_TRANSITION_SNAPSHOTS == "true" \]\]/.test(themeSet) && + /if \[\[ -z \$CHOSEN_THEME_BACKGROUND \|\| ! -f \$CHOSEN_THEME_BACKGROUND \]\]/.test(themeSet), + 'theme changes disable both transition snapshots whenever either side is a video' +) +assert( + /function onScreensChanged\(\) \{[\s\S]*?root\.displaysBlank = false/.test(lockService), + 'a display coming back gives up the blank state instead of freezing a visible wallpaper' +) +assert( + lockService.includes('function screenBlank(screenName)') && + lockService.includes('function applyMonitorDpms(text)') && + lockService.includes('command: ["hyprctl", "monitors", "-j"]') && + lockService.includes('running: root.locked && root.videoBackground') && + /displaysBlank: root\.screenBlank\(lockSurface\.screen/.test(lockService) && + /function runWake\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService) && + /function runBlank\(\) \{[\s\S]*?root\.monitorDpmsKnown = false/.test(lockService), + 'a locked video wallpaper follows what each panel actually did, not only what the lock asked for' +) +assert( + lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') && + lockView.includes('&& !root.powerSaverActive') && + /displaysBlank: root\.screenBlank\(/.test(lockService) && + /powerSaverActive: root\.powerSaverActive/.test(lockService) && + /function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) && + /function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService), + 'the lock screen stops playback once displays go dark or power-saver is active' +) +assert( + batteryService.includes('property string activePowerProfile') && + batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') && + batteryService.includes('["powerprofilesctl", "get"]') && + batteryService.includes('interval: 2000'), + 'the battery service tracks the active power-saver profile' +) +assert( + themeSwitcher.includes("-iname '*.mp4'") && + themeSwitcher.includes('mp4 m4v mov webm mkv avi') && + themeSwitcher.includes('preview.mp4'), + 'theme switcher previews video-only themes, named preview files included' +) +assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background') +assert( + multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'), + 'existing Quattro installations receive video playback dependencies' +) +JS + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" "$test_tmp/backgrounds" "$test_tmp/generator-cache" "$test_tmp/direct-cache" +printf 'not a real video\n' >"$test_tmp/backgrounds/sample.mp4" + +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +while (( $# > 0 )); do + case "$1" in + -i) input=$2; shift 2 ;; + -o) output=$2; shift 2 ;; + *) shift ;; + esac +done +printf 'thumbnail for %s\n' "$input" >"$output" +SH +chmod +x "$test_tmp/bin/ffmpegthumbnailer" + +cat >"$test_tmp/bin/md5sum" <<'SH' +#!/bin/bash +if (( $# > 0 )); then + printf 'unexpected file hash: %s\n' "$*" >>"$MD5_FILE_CALLS" +fi +exec /usr/bin/md5sum "$@" +SH +chmod +x "$test_tmp/bin/md5sum" + +md5_file_calls="$test_tmp/md5-file-calls" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/generator-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$test_tmp/backgrounds" + +generator_thumbnail=$(find "$test_tmp/generator-cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.jpg' -print -quit) +[[ -s $generator_thumbnail ]] || fail "menu image generator creates a video thumbnail" + +generator_row=$(XDG_CACHE_HOME="$test_tmp/generator-cache" "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") +IFS=$'\t' read -r generator_row_path generator_row_thumbnail <<<"$generator_row" +[[ $generator_row_path == "$test_tmp/backgrounds/sample.mp4" && $generator_row_thumbnail == "$generator_thumbnail" ]] || \ + fail "direct picker consumes the menu image generator thumbnail" "$generator_row" + +row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") + +IFS=$'\t' read -r row_path row_thumbnail <<<"$row" +[[ $row_path == "$test_tmp/backgrounds/sample.mp4" && $row_thumbnail == *.jpg && -s $row_thumbnail ]] || \ + fail "image picker lists videos with their cached thumbnail" "$row" +[[ ! -s $md5_file_calls ]] || fail "direct video scans avoid hashing the complete media file" "$(<"$md5_file_calls")" + +failed_backgrounds="$test_tmp/failed-backgrounds" +failed_cache="$test_tmp/failed-cache" +mkdir -p "$failed_backgrounds" "$failed_cache" +printf 'broken video\n' >"$failed_backgrounds/broken.mp4" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +exit 1 +SH + +cached_row=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$test_tmp/direct-cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$test_tmp/backgrounds") +[[ $cached_row == "$row" ]] || fail "direct picker reuses its cached video thumbnail" "$cached_row" + +failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" MD5_FILE_CALLS="$md5_file_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds") +[[ -z $failed_rows ]] || fail "direct picker omits a video whose thumbnail fails" "$failed_rows" + +failed_marker=$(find "$failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -n $failed_marker ]] || fail "direct picker remembers a video the converter rejected" + +thumbnailer_calls="$test_tmp/thumbnailer-calls" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$THUMBNAILER_CALLS" +exit 1 +SH + +failed_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$failed_backgrounds") +[[ -z $failed_rows && ! -e $thumbnailer_calls ]] || fail "direct picker skips a rejected video on the next scan" "$(cat "$thumbnailer_calls" 2>/dev/null)" + +generator_failed_cache="$test_tmp/generator-failed-cache" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ -s $thumbnailer_calls ]] || fail "menu image generator tries a video it has not seen" +generator_marker=$(find "$generator_failed_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -n $generator_marker ]] || fail "menu image generator remembers a video the converter rejected" +rm -f "$thumbnailer_calls" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ ! -e $thumbnailer_calls ]] || fail "menu image generator skips a rejected video on the next open" "$(<"$thumbnailer_calls")" + +# A repaired file gets a fresh key, so the old marker no longer applies, and +# the rows are not cached over its absence, so an in-place repair that leaves +# the directory's mtime alone is still noticed. +touch -d '2 minutes' "$failed_backgrounds/broken.mp4" +PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$generator_failed_cache" THUMBNAILER_CALLS="$thumbnailer_calls" \ + "$ROOT/bin/omarchy-menu-images" --prepare-only "$failed_backgrounds" +[[ -s $thumbnailer_calls ]] || fail "menu image generator retries a video that changed since it was rejected" + +timeout_backgrounds="$test_tmp/timeout-backgrounds" +timeout_cache="$test_tmp/timeout-cache" +mkdir -p "$timeout_backgrounds" +printf 'slow video\n' >"$timeout_backgrounds/slow.mp4" +cat >"$test_tmp/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +exit 124 +SH +timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \ + "$ROOT/shell/plugins/image-picker/list.sh" "$timeout_backgrounds") +[[ -z $timeout_rows ]] || fail "direct picker omits a video whose thumbnail timed out" "$timeout_rows" +timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) +[[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed" + +grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package" +grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package" + +pass "menu image generator creates thumbnails consumed by the picker" +pass "direct picker generates and reuses still thumbnails" +pass "direct picker omits videos whose thumbnails cannot be generated" +pass "a rejected video is remembered so it costs nothing on the next open" +pass "a timed out video is left to retry" +pass "a locked video wallpaper follows the panels' real DPMS state" +pass "Qt Multimedia playback dependencies are declared" + +source <(awk ' + /^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 } + copying { print } + copying && /^}$/ { copying=0 } +' "$ROOT/bin/omarchy-theme-set") + +transition_home="$test_tmp/transition-home" +CURRENT_THEME_PATH="$transition_home/.local/state/omarchy/current/theme" +NEXT_THEME_PATH="$transition_home/.local/state/omarchy/current/next-theme" +CURRENT_BACKGROUND_LINK="$transition_home/.local/state/omarchy/current/background" +BACKGROUND_TRANSITION_CACHE="$transition_home/.cache/omarchy/background-transitions" +THEME_NAME="video-test" +HOME="$transition_home" +mkdir -p "$CURRENT_THEME_PATH/backgrounds" "$NEXT_THEME_PATH/backgrounds" "$HOME/.config/omarchy/backgrounds/$THEME_NAME" +printf 'old image\n' >"$CURRENT_THEME_PATH/backgrounds/old.png" +printf 'old image staged\n' >"$NEXT_THEME_PATH/backgrounds/old.png" +printf 'new video\n' >"$NEXT_THEME_PATH/backgrounds/new.mp4" +ln -s "$CURRENT_THEME_PATH/backgrounds/old.png" "$CURRENT_BACKGROUND_LINK" + +choose_staged_theme_background || fail "staged video background is selected before the theme swap" +expected_staged_background="$CURRENT_THEME_PATH/backgrounds/new.mp4" +[[ $CHOSEN_THEME_BACKGROUND == $expected_staged_background ]] || \ + fail "staged background resolves to its durable post-swap path" "$CHOSEN_THEME_BACKGROUND" +if background_transition_uses_snapshots "$CHOSEN_THEME_BACKGROUND"; then + fail "image to video theme transitions skip snapshots" +fi +background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png" || \ + fail "image to image theme transitions retain snapshots" + +rm "$CURRENT_BACKGROUND_LINK" +printf 'old video\n' >"$CURRENT_THEME_PATH/backgrounds/old.mp4" +ln -s "$CURRENT_THEME_PATH/backgrounds/old.mp4" "$CURRENT_BACKGROUND_LINK" +if background_transition_uses_snapshots "$CURRENT_THEME_PATH/backgrounds/new.png"; then + fail "video to image theme transitions skip snapshots" +fi + +video_snapshot=$(snapshot_background_path "$CURRENT_THEME_PATH/backgrounds/old.mp4" "video") +[[ -z $video_snapshot && ! -e $BACKGROUND_TRANSITION_CACHE ]] || fail "video files are never snapshotted" + +CHOSEN_THEME_BACKGROUND="$transition_home/disappeared.mp4" +BACKGROUND_TRANSITION_SNAPSHOTS=false +OLD_BACKGROUND_SNAPSHOT="" +colors_payload="" +shell_payload="" +shell_ipc() { :; } +set_theme_background +[[ -f $CHOSEN_THEME_BACKGROUND && $(readlink "$CURRENT_BACKGROUND_LINK") == "$CHOSEN_THEME_BACKGROUND" ]] || \ + fail "theme changes recover when a preselected background disappears" "$CHOSEN_THEME_BACKGROUND" + +pass "theme transitions skip snapshots whenever either side is a video" +pass "theme changes recover from a missing preselected background"