diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 2e9a3140..d353097d 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/README.md b/README.md index 96838a94..5cedfc5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Omarchy -Omarchy is a beautiful, modern & opinionated Linux distribution by DHH. +Omarchy is a beautiful, fun & agentic Linux distribution by DHH. Read more at [omarchy.org](https://omarchy.org). diff --git a/agents/skills/migrations.md b/agents/skills/migrations.md index b8152b43..5ed1d489 100644 --- a/agents/skills/migrations.md +++ b/agents/skills/migrations.md @@ -163,6 +163,8 @@ rm ~/.local/state/omarchy/migrations/.sh omarchy-migrate ``` +Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay. + Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the normal migration runner. Do not add compatibility migrations for old installer layouts; put pre-4 package-layout transition work in the upgrade command instead. diff --git a/agents/skills/shell-dev.md b/agents/skills/shell-dev.md index d33f21ef..9765af89 100644 --- a/agents/skills/shell-dev.md +++ b/agents/skills/shell-dev.md @@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files. [`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and `shell/services/PluginRegistry.qml` for the current contract; fields such as `activation` are optional. -- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the - shell-injected properties `omarchyPath`, `shell`, `manifest`, and - `pluginRegistry` / `barWidgetRegistry` as appropriate. +- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties. - Panel / overlay / menu plugins must expose `open(payloadJson)` and `close()` lifecycle methods for `shell summon` and `shell hide`. diff --git a/bin/omarchy b/bin/omarchy index 4219109b..2bace6df 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -65,6 +65,7 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers" GROUP_DESCRIPTIONS[network]="Network status helpers" GROUP_DESCRIPTIONS[notification]="Notification helpers" GROUP_DESCRIPTIONS[mise]="Mise tool wrappers" +GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup" GROUP_DESCRIPTIONS[osd]="On-screen display status helpers" GROUP_DESCRIPTIONS[pkg]="Package management helpers" GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management" diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 3e3b7a5d..e35bcf04 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -82,10 +82,39 @@ grok) command=(grok --permission-mode bypassPermissions) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; +openclaw) + # The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI + # must attach to the running gateway (the embedded `openclaw chat` refuses to + # start while the gateway owns the state directory). It has no permission + # prompts to skip, and --message seeds the session while keeping it + # interactive. + command=(omarchy-launch-openclaw --tui) + [[ -n ${prompt:-} ]] && command+=(--message "$prompt") + ;; 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") + else + 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 9b97c0db..5bb261cb 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 a2ce8cde..35cd0ead 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 1f89b765..af93d863 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|grok|agy|copilot|crush] +# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse] # omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude installing=false @@ -32,21 +32,52 @@ claude | claude-code) agent="claude"; name="Claude Code" ;; codex) agent="codex"; name="Codex" ;; crush) agent="crush"; name="Crush" ;; grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;; +openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;; 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]" + ;; +cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;; *) - echo "Usage: omarchy-default-agent " + echo "Usage: omarchy-default-agent " exit 1 ;; esac agent_package=${agent_package:-$agent} -if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then +# Hermes reaches mise through its own installer rather than straight from +# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to +# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman +# package the same way; see omarchy-install-openclaw-cli. +if [[ -n ${agent_installer:-} ]]; then + # Not omarchy-cmd-present: the stub is on PATH from first boot and says + # nothing about whether Hermes is installed behind it. Treating a cold stub + # as installed skips the floating terminal and runs the minute-long install + # inside the menu action instead. + agent_present() { "$agent_installer" --check; } + agent_install() { "$agent_installer" --now; } +else + # 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"; } +fi + +if [[ $installing == "false" ]] && ! agent_present; then exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent" fi -if ! mise use -g "$agent_package"; then +if ! agent_install; then if [[ $installing == "true" ]]; then echo "Could not install $name with mise" >&2 else diff --git a/bin/omarchy-display-text-size b/bin/omarchy-display-text-size index b15b200f..bdf87786 100755 --- a/bin/omarchy-display-text-size +++ b/bin/omarchy-display-text-size @@ -129,7 +129,8 @@ term_pt_for() { 'BEGIN { printf "%d", int(s * p / b + 0.5) }' } -# Set the font point size in every terminal config that exists. Family is left +# Set the font point size in terminal configs, creating Kitty overrides when +# it inherits its size from the system config. Family is left # untouched — that is omarchy-font-set's job. Live-reload signals mirror # omarchy-font-set; foot has no reload signal, so running instances are nudged. set_terminal_size() { @@ -139,8 +140,13 @@ set_terminal_size() { sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml fi - if [[ -f ~/.config/kitty/kitty.conf ]]; then - sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf + if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then + mkdir -p ~/.config/kitty + if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then + sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf + else + printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf + fi pkill -USR1 kitty 2>/dev/null || true fi @@ -177,9 +183,13 @@ term_current_pt() { elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1 elif [[ -f ~/.config/kitty/kitty.conf ]]; then - grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1 + local pt + pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1) + echo "${pt:-$TERM_DEFAULT_PT}" elif [[ -f ~/.config/foot/foot.ini ]]; then grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1 + elif omarchy-cmd-present kitty; then + echo "$TERM_DEFAULT_PT" fi } diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index 7c80fbc4..080f055b 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -30,8 +30,14 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml fi -if [[ -f ~/.config/kitty/kitty.conf ]]; then - sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf +if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then + mkdir -p ~/.config/kitty + if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then + kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g') + sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf + else + printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf + fi pkill -USR1 kitty fi diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index 7bc57246..1aeb0d75 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -30,6 +30,35 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf" SWAP_FILE="/swap/swapfile" RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf" +install_root_file() { + local source="$1" + local destination="$2" + local mode="$3" + local stage + + stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1 + safe_stage_path "$stage" "$destination" || return 1 + + if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" && + sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then + return 0 + else + safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage" + return 1 + fi +} + +safe_stage_path() { + local stage="$1" + local destination="$2" + local prefix suffix + + prefix="${destination%/*}/.${destination##*/}.omarchy." + [[ $stage == "$prefix"* ]] || return 1 + suffix=${stage#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + # Check if hibernation is already configured if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then # Fix empty resume_offset if btrfs map-swapfile failed during initial setup @@ -83,14 +112,20 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then sudo swapon -p 0 "$SWAP_FILE" fi +# Ensure keyboard backlight doesn't prevent sleep +# Install this before writing the resume marker so a failed install remains +# retryable through the normal setup command. +if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \ + /usr/lib/systemd/system-sleep/keyboard-backlight 0755; then + echo "Could not install the keyboard-backlight system-sleep hook" >&2 + exit 1 +fi + # Add resume hook to mkinitcpio sudo mkdir -p /etc/mkinitcpio.conf.d echo "Adding resume hook to $MKINITCPIO_CONF" echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null -# Ensure keyboard backlight doesn't prevent sleep -sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/ - # Add resume= kernel parameters so the initramfs resume hook knows where to find the # hibernation image. Without these, resume happens late (after GPU drivers load) and fails. if [[ ! -f $RESUME_DROP_IN ]]; then diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes new file mode 100755 index 00000000..5bf6b674 --- /dev/null +++ b/bin/omarchy-install-ai-hermes @@ -0,0 +1,159 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes desktop app +# omarchy:requires-sudo=true + +set -e + +if (( EUID == 0 )); then + echo "Run this command as your desktop user, without sudo." >&2 + exit 1 +fi + +echo "Installing Hermes Desktop..." +omarchy-pkg-add hermes-desktop + +if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] || + ! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then + echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2 + exit 1 +fi + +# If Hermes was already installed for the terminal, the app supersedes it: one +# machine, one Hermes. This drops that copy so the terminal, the default agent +# and the app all end up on the app's installation. +omarchy-install-hermes-cli || true + +# Keep the runtime at the root even when invoked from a Hermes profile. +HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}") +home_parent=$(dirname -- "$HERMES_HOME") +if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then + HERMES_HOME=$(dirname -- "$home_parent") +fi +export HERMES_HOME + +runtime="$HERMES_HOME/hermes-agent" +native_app="$runtime/apps/desktop/release/linux-unpacked" + +runtime_ready() { + [[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] && + timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1 +} + +check_main() { + local main_commit + main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true) + if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then + echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2 + return 1 + fi +} + +if ! runtime_ready; then + # The upstream installer can reset an existing checkout. Do not pin a newer + # or modified runtime back to the package release while repairing setup. + if [[ -e $runtime || -L $runtime ]]; then + if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] || + [[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; then + echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2 + exit 1 + fi + check_main + fi + + # Upstream replaces these commands, including foreign files and symlinks. + # Keep their original bytes/links before handing the names to the desktop. + command_backup="" + for command in hermes hermes-agent hermes-acp; do + command_path="$HOME/.local/bin/$command" + if [[ -e $command_path || -L $command_path ]]; then + if [[ ! -f $command_path && ! -L $command_path ]]; then + echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2 + exit 1 + fi + if [[ -z $command_backup ]]; then + command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX") + echo "Saving existing Hermes commands in $command_backup" + fi + cp -a -- "$command_path" "$command_backup/" + fi + done + + echo "Setting up the Hermes runtime..." + bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME" + if ! runtime_ready; then + echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2 + exit 1 + fi +fi + +runtime_commit=$(git -C "$runtime" rev-parse HEAD) +if [[ $runtime_commit == "$release_commit" ]]; then + # The updater switches to main before checking for changes. Start main at + # the packaged release, with enough history for its first fast-forward. + check_main + if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then + git -C "$runtime" fetch --unshallow origin main + fi + git -C "$runtime" switch -C main "$release_commit" + + if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then + git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch + elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then + echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2 + exit 1 + fi +fi + +if [[ -e $native_app || -L $native_app ]]; then + if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then + echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2 + exit 1 + fi +else + if [[ $runtime_commit != "$release_commit" ]]; then + echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2 + exit 1 + fi + desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json) + if [[ -n $desktop_changes ]]; then + echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2 + exit 1 + fi + + mkdir -p -- "${native_app%/*}" + staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX") + trap 'rm -rf -- "$staging"' EXIT + cp -a /opt/hermes-desktop/. "$staging/" + chmod 0755 "$staging/chrome-sandbox" + mv -T --no-clobber -- "$staging" "$native_app" + if [[ -e $staging ]]; then + echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2 + exit 1 + fi + trap - EXIT + + # Record this matching prebuilt app using the CLI's own content hash, so + # subsequent menu launches do not rebuild an app that is already current. + env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY' +import sys +from pathlib import Path + +sys.path.insert(0, sys.argv[1]) +from hermes_cli.main import _write_desktop_build_stamp + +_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False) +PY +fi + +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; 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." diff --git a/bin/omarchy-install-ai-openclaw b/bin/omarchy-install-ai-openclaw new file mode 100755 index 00000000..cb79a9ad --- /dev/null +++ b/bin/omarchy-install-ai-openclaw @@ -0,0 +1,35 @@ +#!/bin/bash + +# omarchy:summary=Install the OpenClaw agent platform and its Control UI web app +# omarchy:requires-sudo=true + +set -e + +echo "Installing OpenClaw..." +omarchy-pkg-add openclaw + +# The desktop app is OpenClaw's Control UI: a web app served by its own +# gateway. The launcher entry goes through omarchy-launch-openclaw, which +# onboards or starts that gateway before opening the window. The icon ships +# inside the package, so nothing is fetched here. +echo "Installing the OpenClaw web app..." +omarchy-webapp-install OpenClaw "http://127.0.0.1:18789" \ + /usr/lib/node_modules/openclaw/dist/control-ui/apple-touch-icon.png \ + omarchy-launch-openclaw + +# A first install runs onboarding right here: launching the app instead would +# open a second floating terminal for the wizard, identical to this one. +# omarchy-openclaw-onboard runs the wizard the way this flow needs (terminal +# prompts, gateway as a user service, and it actually returns). A machine +# that is already onboarded goes straight to the app. +if [[ -f $HOME/.openclaw/openclaw.json ]]; then + echo "Opening OpenClaw..." + setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 & +elif omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]]; then + echo "Opening OpenClaw..." + setsid uwsm-app -- gtk-launch OpenClaw >/dev/null 2>&1 & +fi + +echo "" +echo "OpenClaw has been installed." +echo "If you skipped onboarding, launching OpenClaw from the app grid resumes it." diff --git a/bin/omarchy-install-ai-t3-code b/bin/omarchy-install-ai-t3-code new file mode 100755 index 00000000..b9e3af38 --- /dev/null +++ b/bin/omarchy-install-ai-t3-code @@ -0,0 +1,31 @@ +#!/bin/bash + +# omarchy:summary=Install T3 Code and point it at the Omarchy palette +# omarchy:requires-sudo=true + +set -e + +T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}" +T3CODE_STATE_DIR="$T3CODE_HOME/userdata" + +echo "Installing T3 Code..." +omarchy-pkg-add t3code-bin + +echo "Matching T3 Code to the current theme..." +mkdir -p "$T3CODE_STATE_DIR" + +# An updated user's current theme may have been staged before this template existed. +if [[ ! -f $HOME/.local/state/omarchy/current/theme/t3code.json ]]; then + omarchy-theme-refresh +fi + +omarchy-theme-set-t3code + +# The packaged CLI selects the published palette before the app's first launch. +t3 theme set omarchy --base-dir "$T3CODE_HOME" + +echo "Opening T3 Code..." +setsid uwsm-app -- gtk-launch t3code >/dev/null 2>&1 & + +echo "" +echo "T3 Code has been installed." diff --git a/bin/omarchy-install-chromium-copy-url b/bin/omarchy-install-chromium-copy-url index 4f6dae02..0ee586d6 100755 --- a/bin/omarchy-install-chromium-copy-url +++ b/bin/omarchy-install-chromium-copy-url @@ -16,6 +16,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/bin/omarchy-install-chromium-ytdlp b/bin/omarchy-install-chromium-ytdlp index c700578c..63ba46d5 100755 --- a/bin/omarchy-install-chromium-ytdlp +++ b/bin/omarchy-install-chromium-ytdlp @@ -17,6 +17,9 @@ browser_dirs=( "$HOME/.config/BraveSoftware/Brave-Browser" "$HOME/.config/BraveSoftware/Brave-Browser-Beta" "$HOME/.config/BraveSoftware/Brave-Browser-Nightly" + "$HOME/.config/BraveSoftware/Brave-Origin" + "$HOME/.config/BraveSoftware/Brave-Origin-Beta" + "$HOME/.config/BraveSoftware/Brave-Origin-Nightly" "$HOME/.config/microsoft-edge" "$HOME/.config/microsoft-edge-dev" ) diff --git a/bin/omarchy-install-hermes-cli b/bin/omarchy-install-hermes-cli new file mode 100755 index 00000000..4f4b99d7 --- /dev/null +++ b/bin/omarchy-install-hermes-cli @@ -0,0 +1,280 @@ +#!/bin/bash + +# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin +# omarchy:args=[--check|--now|--owns|--remove] +# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now + +# Hermes pins every one of its dependencies exactly and declares +# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's +# Python nor share the python-* packages. mise builds it a private environment +# instead. +# +# It gets its own installer rather than a line in omarchy-mise-install because +# of the interpreter pin. Given no compatible interpreter to hand, uv builds +# the venv against the system Python in violation of Hermes' own bound, +# reports success, and leaves the breakage to surface later inside a +# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to +# say otherwise. +# +# There is only ever one Hermes on a machine. hermes-desktop cannot run against +# this one -- it needs a runtime built from its own commit, and the version gap +# fails its readiness probe -- so it installs its own under ~/.hermes and puts +# that on PATH. When the package is present it therefore owns Hermes outright: +# this installer stands aside and removes its own copy, so the terminal, the +# default agent and the app are all the same installation. + +set -euo pipefail + +mode=${1:-} + +tool='pipx:hermes-agent[extras=all]' +python='3.13' + +# The line that identifies the stub as this installer's; matched whole, so a +# wrapper that merely mentions the command is not mistaken for ours. +marker='# Written by omarchy-install-hermes-cli.' + +# The package, not the runtime directory: it is installed before the app has +# ever run, and that is exactly when we must not start building a second copy. +desktop_owns_hermes() { + omarchy-pkg-present hermes-desktop +} + +# The venv appears at the python-deps stage, several stages before the one that +# installs the command, so its presence says nothing about being usable. The +# marker is written last, and the command is what the agent actually runs. +desktop_hermes_ready() { + [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1 + + # An executable of that name proves nothing about whose it is; the app's own + # points into ~/.hermes, and anything else is not the install we are asking + # about. Matched as a plain string, because the path carries a dot and an + # unanchored pattern would also claim a wrapper pointing at ~/xhermes. + [[ -f $HOME/.local/bin/hermes ]] || return 1 + grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1 + + # And a marker left behind by an install whose venv has since gone answers + # for nothing, so the command has to run, exactly as a foreign one must. + hermes_prompt_ready +} + +# Whether Hermes is really installed, not merely whether the stub exists. A +# stub on its own is cold: running it installs Hermes, which takes minutes. +installed() { + [[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]] +} + +# The stub is the only thing this installer owns. Anything else at that path +# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link +# -- was put there by the user and is never deleted or overwritten here. +# Symlinks count as foreign even when they resolve to a marked file: the stub +# is written as a regular file, so a link is someone else's arrangement. +ours() { + [[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] && + grep -qxF "$marker" "$HOME/.local/bin/hermes" +} + +foreign_hermes() { + [[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours +} + +# A hermes at that path is usable when it is a command that runs: a regular +# executable whose --version answers. The executable bit alone proves little -- a directory +# passes -x on search permission, and a wrapper whose interpreter or target is +# gone passes it too. The desktop app applies the same probe with the same 15 +# second budget, so what passes here is what it will use. +hermes_runs() { + [[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] && + timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1 +} + +# A flag counts only when the help defines it, not whenever it is mentioned: +# what follows must be a shape argparse prints after a definition -- the usage +# line's closing bracket, the gap before same-line help text, an uppercase +# metavar, or the end of the line. Prose like "With --tui: run ..." stays +# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by +# parsing an actual invocation on purpose: a release that ignores unknown +# arguments would turn the probe into a live session. +help_defines_flag() { + grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2" +} + +# Probed for the flags omarchy-agent actually passes -- --query to seed the +# session and --tui to keep it interactive -- rather than a marker standing in +# for them: the old probe keyed on chat carrying --oneshot, which no released +# Hermes did (it lived at the top level until v0.21 added chat's own), so +# every release read as "not ready". +hermes_prompt_ready() { + local help + hermes_runs && + help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) && + help_defines_flag '--tui' "$help" && + help_defines_flag '--query' "$help" +} + +# --owns answers whether the wrapper on PATH is the one this command wrote, so +# the migration and Remove Preinstalls do not each carry their own copy of the +# marker and drift from it. +if [[ $mode == "--owns" ]]; then + if ours; then exit 0; else exit 1; fi +fi + +# --remove tears down a Hermes CLI this installer put in place -- the mise tool +# it installed and the stub it marked -- so Remove Hermes clears a CLI the app +# never superseded (an interrupted install, or the terminal CLI from before the +# app existed) rather than leaving it stranded on PATH. The whole teardown +# turns on the marked stub, exactly as replacement does further down: without +# it nothing proves the mise environment is Omarchy's rather than one the user +# built against the same spec, and a user's stays theirs. The desktop takeover +# removes the environment without asking, but that is its own bargain -- a +# second Hermes has to go whoever built it, and the app still provides the +# command afterwards; here nothing would. Idempotent: nothing owned, nothing +# to do. +if [[ $mode == "--remove" ]]; then + if ours; then + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true + + # Every step is attempted before any is judged, and judged by what is left + # rather than by what the commands claimed: the marked stub still answering + # hermes, or mise still resolving the tool, is a CLI still installed no + # matter how the removal exited. + # Not "run --remove again": once the stub is gone nothing marks the mise + # environment as ours, so a rerun would find nothing it owns and succeed + # without touching what was left. Only the full commands finish the job. + if ours || mise where "$tool" >/dev/null 2>&1; then + echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2 + echo " rm -f ~/.local/bin/hermes" >&2 + echo " mise rm -g '$tool'" >&2 + echo " mise uninstall --all '$tool'" >&2 + exit 1 + fi + fi + + exit 0 +fi + +# --check lets callers tell a cold stub from a working one before they commit +# to a path that assumes Hermes is ready. +if [[ $mode == "--check" ]]; then + if desktop_owns_hermes; then + if desktop_hermes_ready; then exit 0; else exit 1; fi + fi + # A foreign command is ready only when it also supports prompted sessions; + # since it is not ours to replace, nothing this installer does will update it. + if foreign_hermes; then + if hermes_prompt_ready; then exit 0; else exit 1; fi + fi + if installed && hermes_prompt_ready; then exit 0; else exit 1; fi +fi + +# Hand Hermes over to the app rather than keeping a second copy beside it. +if desktop_owns_hermes; then + # Not gated on that copy being healthy: `mise up` can rebuild it against the + # wrong interpreter and a half-finished install answers to neither test, and + # either way it is still a second Hermes. Removing nothing is harmless. + if mise where "$tool" >/dev/null 2>&1; then + echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2 + fi + + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + + # Our own stub has to go with it. Left in place it still answers `hermes` + # until the app's bootstrap overwrites it, and answering means building the + # second Hermes this whole arrangement exists to avoid. + if ours; then + rm -f "$HOME/.local/bin/hermes" + fi + + if desktop_hermes_ready; then + exit 0 + fi + + echo "Hermes Desktop is installed but has not set Hermes up yet." >&2 + echo "Launch Hermes Desktop once to finish installing it." >&2 + exit 1 +fi + +# The user already has a hermes of their own. Leave it be: a working one is +# what the default agent will run, and a broken one is theirs to fix. +if foreign_hermes; then + if hermes_prompt_ready; then + exit 0 + fi + + if hermes_runs; then + echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2 + echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi + + echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2 + echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2 + exit 1 +fi + +# Only the marked wrapper proves the matching mise environment is ours to replace. +if installed && ! hermes_prompt_ready; then + if ours; then + echo "Updating Hermes for prompted sessions..." >&2 + mise rm -g "$tool" >/dev/null 2>&1 || true + mise uninstall --all "$tool" >/dev/null 2>&1 || true + else + echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2 + echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2 + exit 1 + fi +fi + +mkdir -p "$HOME/.local/bin" +rm -f "$HOME/.local/bin/hermes" + +cat >"$HOME/.local/bin/hermes" </dev/null)/hermes-agent/lib/python$python" ]]; then + echo "Installing Hermes on Python $python (this takes a minute)..." >&2 + + # mise's pipx backend shells out to uv, which a stock Omarchy does not have. + # It is fetched here rather than when this stub was written, so setting up a + # machine that never runs Hermes costs nothing. + if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then + mise use -g --quiet uv@latest || exit 1 + fi + + mise use -g --quiet --force '$tool' || exit 1 +fi + +# The pin belongs to building Hermes, not to everything Hermes then runs. +# Exported it would reach the agent and every command it shells out to, so a +# uv in the user's own project would resolve 3.13 there too -- uv only warns +# when that contradicts the project's requires-python, and builds it anyway. +exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@" +EOF + +chmod +x "$HOME/.local/bin/hermes" + +# The desktop app resolves a hermes on PATH by running `hermes --version` with +# a 15 second budget, then falls back to cloning its own copy when that times +# out. A first-run mise install does not fit in 15 seconds, so anything that +# hands Hermes to the GUI has to install it here rather than leave it stubbed. +if [[ $mode == "--now" ]]; then + "$HOME/.local/bin/hermes" --version + if ! hermes_prompt_ready; then + echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2 + exit 1 + fi +fi diff --git a/bin/omarchy-install-openclaw-cli b/bin/omarchy-install-openclaw-cli new file mode 100755 index 00000000..18e12317 --- /dev/null +++ b/bin/omarchy-install-openclaw-cli @@ -0,0 +1,29 @@ +#!/bin/bash + +# omarchy:summary=Ensure the OpenClaw CLI is installed for the default agent +# omarchy:args=[--check|--now] +# omarchy:requires-sudo=true + +# OpenClaw is not a mise tool: the openclaw pacman package is the one OpenClaw +# installation on the machine — the CLI, the gateway, and the Install > AI web +# app all share it, and the fast ring keeps it current. The default-agent flow +# talks to that package through the same --check/--now contract mise-backed +# agents get from mise itself. + +set -euo pipefail + +case "${1:---now}" in +--check) + omarchy-pkg-present openclaw + ;; +--now) + if ! omarchy-pkg-present openclaw; then + echo "Installing OpenClaw..." + omarchy-pkg-add openclaw + fi + ;; +*) + echo "Usage: omarchy-install-openclaw-cli [--check|--now]" >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-launch-1password b/bin/omarchy-launch-1password index d50da0b2..61889fd9 100755 --- a/bin/omarchy-launch-1password +++ b/bin/omarchy-launch-1password @@ -4,8 +4,11 @@ set -e +# 1Password reads the display scale itself, the way Electron apps do, and comes +# up oversized next to every other window on a scaled monitor. The packaged +# .desktop pins it too; this covers the hotkey, which runs the binary directly. if omarchy-cmd-present 1password; then - exec setsid uwsm-app -- 1password + exec setsid uwsm-app -- 1password --force-device-scale-factor=1 else exec omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password fi diff --git a/bin/omarchy-launch-openclaw b/bin/omarchy-launch-openclaw new file mode 100755 index 00000000..13ab6c24 --- /dev/null +++ b/bin/omarchy-launch-openclaw @@ -0,0 +1,91 @@ +#!/bin/bash + +# omarchy:summary=Open the OpenClaw Control UI (or its terminal UI with --tui), onboarding or starting the gateway first when needed. +# omarchy:args=[--tui [--message ]] + +set -euo pipefail + +tui=false +message=() +if [[ ${1:-} == "--tui" ]]; then + tui=true + shift + if [[ ${1:-} == "--message" ]]; then + message=(--message "${2:?--message needs a value}") + shift 2 + fi +fi + +# Onboarding is OpenClaw's own interactive wizard, run through +# omarchy-openclaw-onboard so it stays in the terminal, installs the gateway +# as a user service, and returns (see that script for why bare `openclaw +# onboard` does none of those as of 2026.9.1). The config check gates what +# follows because the wizard's "Skip for now" also exits 0: only inference +# that passed writes the config, and skipping must not loop back into the +# wizard. +if [[ ! -f $HOME/.openclaw/openclaw.json ]]; then + if [[ $tui == "true" ]]; then + # Already in a terminal, so the wizard runs right here. + omarchy-openclaw-onboard + [[ -f $HOME/.openclaw/openclaw.json ]] || exit 1 + else + # The wizard needs a real terminal, and chaining the relaunch means + # finishing it lands the user in the app. Single quotes so $HOME expands + # in the spawned terminal. + # shellcheck disable=SC2016 + exec omarchy-launch-floating-terminal-with-presentation \ + 'omarchy-openclaw-onboard && [[ -f $HOME/.openclaw/openclaw.json ]] && omarchy-launch-openclaw' + fi +fi + +dashboard_url() { + # browserUrl carries a single-use browser handoff; url is the shared-auth + # fallback for gateways predating the handoff flow. The timeout keeps a + # wedged CLI from hanging an app-grid launch that has no terminal to ^C. + timeout 10 openclaw dashboard --json 2>/dev/null | jq -re '.browserUrl // .url // empty' +} + +# --json never starts the gateway, so an empty answer means it is not running. +# Recovery goes through the gateway's own service commands. `dashboard --yes` +# used to be the start/install-without-prompting path, but as of 2026.9.1 it +# defers to "the owning supervisor" for both a missing and a stopped unit, +# and once the gateway is up it copies a one-time pairing URL into the +# clipboard, which nothing here needs. Enablement, not the unit file, decides: +# a unit that was written but never enabled (an install that died halfway) +# would otherwise be "started" once and stay off at every following login, +# where --force rewrites and enables it. +if ! url=$(dashboard_url); then + if systemctl --user is-enabled --quiet openclaw-gateway.service 2>/dev/null; then + timeout 60 openclaw gateway start >&2 || true + else + timeout 120 openclaw gateway install --force >&2 || true + fi + + # A first-ever service install (unit write, daemon-reload, first boot) + # takes materially longer than starting an installed unit, so the budget + # is sized for the slow case. + for _ in {1..30}; do + url=$(dashboard_url) && break + sleep 1 + done +fi + +if [[ -z ${url:-} ]]; then + echo "OpenClaw's gateway did not come up. Check it with: openclaw gateway status" >&2 + echo "If onboarding never finished, rerun it with: omarchy-openclaw-onboard" >&2 + exit 1 +fi + +if [[ $tui == "true" ]]; then + # Attach to the gateway rather than `openclaw chat`: chat is the embedded + # local runtime, which refuses to start while the gateway owns ~/.openclaw's + # state directory -- and on any machine set up through Install > AI, the + # gateway service is running whenever the desktop session is. + exec openclaw tui "${message[@]}" +fi + +# The handoff URL rides in the browser's argv, which uwsm's app daemon echoes +# into the user journal. Accepted: the token is single-use with a ten-minute +# expiry against a loopback-only gateway, and journal access already implies +# access to ~/.openclaw itself. +exec omarchy-launch-webapp "$url" diff --git a/bin/omarchy-menu-images b/bin/omarchy-menu-images index 8c316462..8305cb0f 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 - xargs -a "$pending_file" -0 -n 2 -P "$(nproc)" \ - bash -c 'generate_thumbnail "$1" "$2"' _ >/dev/null 2>&1 || true + 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 + + 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-mise-install b/bin/omarchy-mise-install index e6dfc96c..356c3324 100755 --- a/bin/omarchy-mise-install +++ b/bin/omarchy-mise-install @@ -12,8 +12,38 @@ package=$1 command=${2:-$1} bin=${3:-$command} +# The command name becomes a file name under ~/.local/bin, so a slash in it +# writes the wrapper somewhere else and the rm below deletes somewhere else. A +# leading dot hides it or walks up, and a leading dash makes a name that reads +# as an option to whatever picks it up. Checked before anything is removed or +# written, and kept to those shapes so package names like npm:playwright still +# stand in for the command name. +case "$command" in + */* | .* | -* | *[[:cntrl:]]*) + echo "omarchy-mise-install: '$command' is not usable as a command name" >&2 + exit 1 + ;; +esac + mkdir -p "$HOME/.local/bin" +# The heredoc below is unquoted, so whatever these hold is written into the +# wrapper as shell source. Quote them the way omarchy-install-and-launch does, so +# a package name carrying shell characters stays one argument instead of running. +printf -v package_arg '%q' "$package" +printf -v bin_arg '%q' "$bin" + +# Keep values that are inert inside double quotes in the form existing migrations +# recognize, including mise backend options that %q would unnecessarily escape. +case "$package" in + *'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;; + *) package_arg="\"$package\"" ;; +esac +case "$bin" in + *'$'* | *'`'* | *'"'* | *'\'* | *'!'* | *[[:cntrl:]]*) ;; + *) bin_arg="\"$bin\"" ;; +esac + # These tools install and upgrade on first run, so mise's release cooldown would # hold a new version back for days after it ships. Exported rather than set on # the install line alone, so resolving the version to execute agrees with the @@ -22,8 +52,8 @@ rm -f "$HOME/.local/bin/$command" cat >"$HOME/.local/bin/$command" </dev/null | jq -e '.ok == true' >/dev/null 2>&1 +} + +# A gateway already answering means OpenClaw is set up, and this run must not +# read its own success off state that predates it: the wizard's repair pass +# would be stopped mid-prompt the moment the watcher looked. Nothing to do. +if [[ -f $config ]] && gateway_answers; then + echo "OpenClaw is already set up and its gateway is running." >&2 + echo "To change providers or settings, run: openclaw onboard --classic" >&2 + exit 0 +fi + +# Marks when this run began, so a config left behind by an earlier, incomplete +# setup is not mistaken for this run having applied its own: the gateway +# deadline below must not start ticking while the user is still at prompts. +started=$(mktemp) +trap 'rm -f "$started"' EXIT + +# Backgrounded so this script can watch it, but with the terminal kept as its +# stdin (bash would otherwise hand a background job /dev/null). Job control is +# off in a script, so it stays in the terminal's foreground process group and +# reads from it freely. Ctrl-C does not reach it directly, though: bash starts +# async children with SIGINT ignored when job control is off, so the INT trap +# below is what turns Ctrl-C into the wizard's exit. +"${wizard[@]}" <&0 & +wizard_pid=$! + +stop_wizard() { + kill -TERM "$wizard_pid" 2>/dev/null || true +} +# Any signal at this script, whether Ctrl-C from the terminal or a kill aimed +# at its pid alone, takes the wizard down with it rather than leaving it +# running unwatched. +trap 'stop_wizard' INT TERM HUP + +# Whether this run has applied setup: the config exists and is not older than +# the run itself. +config_applied() { + [[ -f $config && ! $started -nt $config ]] +} + +# Whether this run's gateway is up: setup applied, and the process answering +# on the gateway's port is the main process of the service the wizard +# installs. Not the dashboard alone: with no config on disk `openclaw +# dashboard --json` still probes the default loopback port, so a gateway left +# behind by something else (an earlier guided onboarding's foreground +# gateway, say) would read as this run's success while the user is still at +# the first prompt. And not the unit being active either: its Type=simple +# counts it active from the fork, before it has found the port taken by such +# an orphan, and the app would then open on the orphan rather than the +# service this run installed. +gateway_ready() { + config_applied || return 1 + local json port listener main_pid + json=$(timeout 10 openclaw dashboard --json 2>/dev/null) || return 1 + jq -e '.ok == true' <<<"$json" >/dev/null 2>&1 || return 1 + port=$(jq -r '.port // empty' <<<"$json" 2>/dev/null) + [[ -n $port ]] || return 1 + listener=$(ss -ltnpH "sport = :$port" 2>/dev/null | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | head -1) + main_pid=$(systemctl --user show -p MainPID --value openclaw-gateway.service 2>/dev/null) + [[ -n $listener && -n $main_pid && $main_pid != 0 && $listener == "$main_pid" ]] +} + +stopped=false +timed_out=false +config_seen_at= +while kill -0 "$wizard_pid" 2>/dev/null; do + sleep 2 + if gateway_ready; then + # Let the outro finish printing, then end the process the wizard leaves + # running. + sleep "$settle_seconds" + stop_wizard + stopped=true + break + fi + config_applied || continue + : "${config_seen_at:=$SECONDS}" + if (( SECONDS - config_seen_at >= gateway_timeout )); then + # Setup was applied but the service never came up (port taken, unit + # failing, ...): the wizard would sit in its never-exiting state forever, + # and so would whoever is waiting on this script. + stop_wizard + timed_out=true + break + fi +done + +wait "$wizard_pid" +rc=$? + +if [[ $timed_out == true ]]; then + echo "OpenClaw's gateway did not come up within ${gateway_timeout}s of setup finishing." >&2 + echo "Check it with: openclaw gateway status" >&2 + exit 1 +fi +# A wizard stopped here after the gateway came up did its job. One that +# exited on its own, including a user who chose "Skip for now" (no config, +# non-zero), keeps its own exit code. +[[ $stopped == true ]] && rc=0 +exit "$rc" diff --git a/bin/omarchy-pkg-aur-install b/bin/omarchy-pkg-aur-install index dcb7ba1e..3d38b4db 100755 --- a/bin/omarchy-pkg-aur-install +++ b/bin/omarchy-pkg-aur-install @@ -24,6 +24,6 @@ if [[ -n $pkg_names ]]; then source omarchy-sudo-keepalive echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm - sudo updatedb + sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots omarchy-show-done fi diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index 4c21ff49..b5321a4d 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -449,7 +449,7 @@ greeter_screen() { rows=$(stty size 2>/dev/null /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 +# before the app existed. Remove Hermes clears that too, scoped by the installer +# to what Omarchy owns so a hermes the user set up themselves is left alone. +# Tolerated here rather than fatal, so the ~/.hermes handling below still runs; +# the failure is answered for at the end instead of being swallowed. +cli_removed=true +ensure_hermes_stopped +omarchy-install-hermes-cli --remove || cli_removed=false + +# The app writes this when the runtime it provisions under ~/.hermes has landed, +# 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 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, 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" \ + "$HOME/.hermes/bin" \ + "$HOME/.hermes/node" + + # Only the wrappers pointing into ~/.hermes, matched as a plain string: the + # path carries a dot, so an unanchored pattern would also claim a wrapper + # pointing at a sibling like ~/xhermes. + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + + if [[ -f $wrapper && ! -L $wrapper ]] && grep -qF "$HOME/.hermes" "$wrapper"; then + rm -f "$wrapper" + fi + done + + # When Hermes brought its own Node it symlinked these next to its own commands, + # and they point at what we just deleted. Only the links into ~/.hermes: a + # system Node, or someone else's, lives somewhere else entirely. + for command in node npm npx; do + link="$HOME/.local/bin/$command" + + if [[ -L $link && $(readlink "$link") == "$HOME/.hermes"/* ]]; then + rm -f "$link" + fi + done + +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 ]] && omarchy-cmd-present gum; 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 + ensure_hermes_stopped + 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." +fi + +# The messages above still hold -- the app and its runtime are gone -- but a CLI +# teardown that failed already said so on stderr, and that stands. +if [[ $cli_removed == "false" ]]; then + exit 1 +fi diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw new file mode 100755 index 00000000..e610cbbe --- /dev/null +++ b/bin/omarchy-remove-ai-openclaw @@ -0,0 +1,78 @@ +#!/bin/bash + +# omarchy:summary=Remove the OpenClaw agent platform along with its gateway service and web app. +# 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 + +unit_dir="$HOME/.config/systemd/user" + +# Only systemd's own word counts as "stopped": a non-zero exit from is-active +# also covers an unreachable user manager, which says nothing about whether +# the process is alive. +unit_stopped() { + local state + state=$(systemctl --user is-active "$1" 2>/dev/null) || true + [[ $state == inactive || $state == failed ]] +} + +# The user services OpenClaw installs for itself: the gateway through +# onboarding, and the node host if the user ever paired this machine to +# another gateway. OpenClaw writes them to $HOME/.config regardless of +# XDG_CONFIG_HOME, so look exactly there; no unit means nothing registered. +# Upstream's own teardown knows every piece its install wrote (unit file, the +# default.target.wants enablement symlink, the reload), so prefer it while the +# binary is still installed and fall back to doing the same by hand. A service +# that will not stop aborts the removal: dropping the package would strand the +# live process on deleted code with no way to restart it cleanly. +for unit_file in "$unit_dir"/openclaw-gateway.service "$unit_dir"/openclaw-node.service; do + [[ -f $unit_file ]] || continue + unit=${unit_file##*/} + role=${unit#openclaw-} + role=${role%.service} + if openclaw "$role" uninstall >/dev/null 2>&1 || + systemctl --user disable --now "$unit" 2>/dev/null || + unit_stopped "$unit"; then + # .bak is what `gateway install --force` leaves behind when it rewrites a + # unit, so it goes with the unit. + rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit" + systemctl --user daemon-reload 2>/dev/null || true + # A unit that had been failing stays listed as "not-found failed" after + # its file is gone until its failed state is reset. + systemctl --user reset-failed "$unit" 2>/dev/null || true + else + echo "Could not stop $unit; OpenClaw was not removed." >&2 + exit 1 + fi +done + +omarchy-pkg-drop openclaw + +# The web app launcher and icon omarchy-install-ai-openclaw created. +rm -f "$HOME/.local/share/applications/OpenClaw.desktop" +rm -f "$HOME/.local/share/icons/hicolor/256x256/apps/openclaw.png" +gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true + +# ~/.openclaw stays unless asked: the chats, memories, and credentials in +# there are the user's agent, and finding them intact after a reinstall is the +# better surprise. But it also holds the plugin runtimes and caches OpenClaw +# downloads for itself, easily hundreds of megabytes, so the choice is put in +# front of the user with the size rather than left silent. Without a terminal +# to ask in, keeping it is the answer. +state_removed=false +if [[ -d $HOME/.openclaw && -t 0 ]] && omarchy-cmd-present gum; then + size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) + if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then + rm -rf "$HOME/.openclaw" + state_removed=true + fi +fi + +echo "" +echo "OpenClaw has been removed." +if [[ $state_removed == true ]]; then + echo "Its chats, memories, and settings in ~/.openclaw are gone too." +elif [[ -d $HOME/.openclaw ]]; then + echo "Your agent's chats, memories, and settings are still in ~/.openclaw." +fi diff --git a/bin/omarchy-remove-ai-perplexity b/bin/omarchy-remove-ai-perplexity new file mode 100755 index 00000000..93859484 --- /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 c096ca2e..bfb8fe34 100755 --- a/bin/omarchy-remove-preinstalls +++ b/bin/omarchy-remove-preinstalls @@ -17,6 +17,27 @@ 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 + # own, rather than a copy of its marker kept here. + if omarchy-install-hermes-cli --owns; then + rm -f ~/.local/bin/hermes + fi + omarchy-pkg-drop \ aether \ cliamp \ diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index 383aa376..e46aaed9 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 dfb3d3f0..f9907d7a 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 45b420df..835321d7 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 b0c1cda4..782b4ba7 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,8 @@ post_theme_commands=( omarchy-theme-set-gnome omarchy-theme-set-pi omarchy-theme-set-claude + omarchy-theme-set-hermes + omarchy-theme-set-t3code 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 00000000..c73b24c7 --- /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-set-t3code b/bin/omarchy-theme-set-t3code new file mode 100755 index 00000000..7b6f69bf --- /dev/null +++ b/bin/omarchy-theme-set-t3code @@ -0,0 +1,34 @@ +#!/bin/bash + +# omarchy:summary=Sync the generated Omarchy theme to T3 Code +# omarchy:hidden=true + +set -euo pipefail + +T3CODE_SOURCE_PATH="$HOME/.local/state/omarchy/current/theme/t3code.json" +T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}" +T3CODE_STATE_DIR="$T3CODE_HOME/userdata" +# The filename is the theme id T3 Code shows and `t3 theme set` accepts, and +# it stays stable while the colors change underneath on every theme switch. +T3CODE_THEME_PATH="$T3CODE_STATE_DIR/themes/omarchy.json" + +[[ -f $T3CODE_SOURCE_PATH ]] || exit 0 + +# A theme without an accent or a background leaves its placeholder unresolved. +# Publishing that would only make T3 Code reject the file, so skip instead and +# leave the previous palette in place. +if grep -q '{{' "$T3CODE_SOURCE_PATH"; then + echo "Skipping T3 Code theme: $(basename "$T3CODE_SOURCE_PATH") has unresolved colors." >&2 + exit 0 +fi + +# Only follow an install that already exists. Creating the directory here would +# leave a stray T3 Code state dir on machines that do not run it. +[[ -d $T3CODE_STATE_DIR ]] || exit 0 + +# T3 Code watches this file and retints every connected client on change, so +# the write has to be atomic: a half-written file would read as invalid. +mkdir -p "$(dirname "$T3CODE_THEME_PATH")" +tmp=$(mktemp "$T3CODE_THEME_PATH.XXXXXX") +cp "$T3CODE_SOURCE_PATH" "$tmp" +mv "$tmp" "$T3CODE_THEME_PATH" diff --git a/bin/omarchy-theme-switcher b/bin/omarchy-theme-switcher index 6d014dd5..49bc127c 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-toggle-fullscreen-desktop b/bin/omarchy-toggle-fullscreen-desktop new file mode 100755 index 00000000..b5543f85 --- /dev/null +++ b/bin/omarchy-toggle-fullscreen-desktop @@ -0,0 +1,27 @@ +#!/bin/bash + +# omarchy:summary=Toggle a full screen desktop: hide the top bar and remove the window gaps together +# omarchy:args=[toggle|on|off] +# omarchy:examples=omarchy toggle fullscreen desktop | omarchy toggle fullscreen desktop off | omarchy toggle fullscreen desktop on + +ACTION="${1:-toggle}" + +case $ACTION in + toggle|"") + # Only leave full screen when both halves are already in it, so a lone bar + # or a lone gap toggle is pulled into line rather than flipped away. + if omarchy-toggle-enabled bar-off && omarchy-hyprland-toggle-enabled window-no-gaps; then + ACTION="off" + else + ACTION="on" + fi + ;; + on|off) ;; + *) + echo "Usage: omarchy-toggle-fullscreen-desktop [toggle|on|off]" >&2 + exit 1 + ;; +esac + +omarchy-toggle-bar "$ACTION" +omarchy-hyprland-toggle window-no-gaps "$ACTION" diff --git a/bin/omarchy-toggle-hybrid-gpu b/bin/omarchy-toggle-hybrid-gpu index aa7c0582..835c007b 100755 --- a/bin/omarchy-toggle-hybrid-gpu +++ b/bin/omarchy-toggle-hybrid-gpu @@ -3,6 +3,35 @@ # omarchy:summary=Toggle dedicated vs integrated GPU mode via supergfxd (for hybrid gpu laptops, like Asus G14). # omarchy:requires-sudo=true +install_root_file() { + local source="$1" + local destination="$2" + local mode="$3" + local stage + + stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1 + safe_stage_path "$stage" "$destination" || return 1 + + if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" && + sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then + return 0 + else + safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage" + return 1 + fi +} + +safe_stage_path() { + local stage="$1" + local destination="$2" + local prefix suffix + + prefix="${destination%/*}/.${destination##*/}.omarchy." + [[ $stage == "$prefix"* ]] || return 1 + suffix=${stage#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + if omarchy-cmd-missing supergfxctl; then omarchy-pkg-add supergfxctl @@ -54,18 +83,31 @@ case "$gpu_mode" in ;; "Hybrid") if gum confirm "Use only integrated GPU and reboot?"; then - # Switch to integrated mode and ensure vfio is enabled (needed for sleep/wake trick) - sudo sed -i "s/\"mode\": \".*\"/\"mode\": \"Integrated\"/" /etc/supergfxd.conf - sudo sed -i 's/"vfio_enable": false/"vfio_enable": true/' /etc/supergfxd.conf - - # Force igpu mode after system sleep (or dgpu could get activated) - sudo mkdir -p /usr/lib/systemd/system-sleep - sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" /usr/lib/systemd/system-sleep/ - # Delay supergfxd startup to avoid race condition with display manager # that can cause system freeze when booting in Integrated mode sudo mkdir -p /etc/systemd/system/supergfxd.service.d - sudo cp -p "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" /etc/systemd/system/supergfxd.service.d/ + if ! install_root_file "$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" \ + /etc/systemd/system/supergfxd.service.d/delay-start.conf 0644; then + echo "Could not install the supergfxd startup-delay override" >&2 + exit 1 + fi + + # Publish the self-guarding sleep hook before enabling Integrated mode. It + # remains inert while the config says Hybrid, so any failed step is safe to + # retry without leaving the GPU config partially switched. + sudo mkdir -p /usr/lib/systemd/system-sleep + if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" \ + /usr/lib/systemd/system-sleep/force-igpu 0755; then + echo "Could not install the force-igpu system-sleep hook" >&2 + exit 1 + fi + + # Switch both settings in one atomic config rewrite only after every + # supporting file has been installed successfully. + sudo sed -i \ + -e 's/"mode": ".*"/"mode": "Integrated"/' \ + -e 's/"vfio_enable": false/"vfio_enable": true/' \ + /etc/supergfxd.conf omarchy-system-reboot fi diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 2e9bd759..21691914 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 98d3ecbe..6da7b938 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/config/hypr/input.lua b/config/hypr/input.lua index d20be92a..d06e05c7 100644 --- a/config/hypr/input.lua +++ b/config/hypr/input.lua @@ -45,7 +45,8 @@ -- }) -- App-specific touchpad scroll speeds. --- o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +-- o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- o.window("foot", { scroll_touchpad = 2.0 }) -- o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) -- Enable touchpad gestures for changing workspaces. diff --git a/config/kitty/kitty.conf b/config/kitty/kitty.conf index 7493b9e2..3bbeb342 100644 --- a/config/kitty/kitty.conf +++ b/config/kitty/kitty.conf @@ -1,35 +1,21 @@ +# Remove the include below to disconnect Kitty from Omarchy's theming system. include ~/.local/state/omarchy/current/theme/kitty.conf +# Settings below override Omarchy's defaults in /etc/xdg/kitty/kitty.conf. +# Learn more: https://sw.kovidgoyal.net/kitty/conf/ + # Font -font_family JetBrainsMono Nerd Font -bold_italic_font auto -font_size 9.0 +# font_family JetBrainsMono Nerd Font +# font_size 12 -# Window -window_padding_width 14 -hide_window_decorations yes -confirm_os_window_close 0 +# Window padding +# window_padding_width 14 -# Keybindings -map ctrl+insert copy_to_clipboard -map shift+insert paste_from_clipboard -# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter. -map shift+enter send_text all \e[13;2u -# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter. -map alt+shift+enter send_text all \e[13;4u +# Unmap a shortcut, passing it through to the terminal application +# map ctrl+insert -# Allow remote access -allow_remote_control yes -listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid} +# Set or replace a shortcut +# map ctrl+shift+c copy_to_clipboard -# Aesthetics -cursor_shape block -cursor_blink_interval 0 -shell_integration no-cursor -enable_audio_bell no - -# Minimal Tab bar styling -tab_bar_edge bottom -tab_bar_style powerline -tab_powerline_style slanted -tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''} +# Remove all inherited shortcuts, including Kitty's built-in shortcuts +# clear_all_shortcuts yes diff --git a/default/agents/skills/diagnose-crash/reporting.md b/default/agents/skills/diagnose-crash/reporting.md index 154f1a25..15bd08bc 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 1b00974a..4ce34bb0 100644 --- a/default/agents/skills/omarchy/SKILL.md +++ b/default/agents/skills/omarchy/SKILL.md @@ -13,7 +13,7 @@ description: > # Omarchy Skill -Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, modern, opinionated Arch Linux distribution with Hyprland. +Manage [Omarchy](https://omarchy.org/) Linux systems - a beautiful, fun, agentic Arch Linux distribution with Hyprland. This skill is for end-user customization on installed systems. It is not for contributing to Omarchy source code. @@ -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 117a9901..b5fd0533 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 961ff388..fd518ad3 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 005fc874..cb633829 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,6 +12,10 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `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` — 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 fc47d013..8d6df783 100644 Binary files a/default/fonts/omarchy/omarchy.ttf and b/default/fonts/omarchy/omarchy.ttf differ diff --git a/default/hypr/apps/hermes.lua b/default/hypr/apps/hermes.lua new file mode 100644 index 00000000..a7ad307f --- /dev/null +++ b/default/hypr/apps/hermes.lua @@ -0,0 +1,7 @@ +-- Hermes Desktop's frameless HUD manages its own geometry. +o.window({ class = "^Hermes$", title = "^Hermes HUD$" }, { + tag = "-default-opacity", + float = true, + border_size = 0, + opacity = "1 1", +}) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 406b773f..9c03d273 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -19,6 +19,7 @@ o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", "omarchy-menu toggle theme" o.bind("SUPER + BACKSPACE", "Toggle window transparency", "omarchy-hyprland-window-transparency-toggle") o.bind("SUPER + SHIFT + BACKSPACE", "Toggle window gaps", "omarchy-hyprland-window-gaps-toggle") o.bind("SUPER + CTRL + BACKSPACE", "Toggle single-window square aspect", "omarchy-hyprland-window-single-square-aspect-toggle") +o.bind_toggle("SUPER + CTRL + ALT + F", "Toggle full screen desktop", "fullscreen-desktop") -- xkbcommon names the comma keysym "comma"; the upper-case "COMMA" does not match. o.bind("SUPER + comma", "Dismiss last notification", "omarchy-shell notifications dismissOne") diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index b1eb48fa..04964703 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/hypr/input.lua b/default/hypr/input.lua index bdcd384f..a87ad994 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -75,5 +75,7 @@ hl.config({ }) -- Scroll nicely in the terminal. -o.window("(Alacritty|kitty|foot)", { scroll_touchpad = 1.5 }) +o.window("(Alacritty|kitty)", { scroll_touchpad = 1.5 }) +-- foot only applies its scrollback multiplier to wheel clicks, not precise touchpad scrolling. +o.window("foot", { scroll_touchpad = 2.0 }) o.window("com.mitchellh.ghostty", { scroll_touchpad = 0.2 }) diff --git a/default/omarchy/launcher.hides b/default/omarchy/launcher.hides index 113565c7..77a57d3b 100644 --- a/default/omarchy/launcher.hides +++ b/default/omarchy/launcher.hides @@ -12,6 +12,7 @@ fcitx5-configtool fcitx5-wayland-launcher foot-server footclient +hermes java-java-openjdk jconsole-java-openjdk jshell-java-openjdk diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 10a045af..745ec311 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -140,8 +140,12 @@ "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 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"}, "setup.default.agent.ori": {"icon":"","iconFont":"omarchy","label":"Ori","checked":"[[ \"$(omarchy-default-agent)\" == \"ori\" ]]","action":"omarchy-default-agent ori"}, "setup.default.agent.pi": {"icon":"","iconFont":"omarchy","label":"Pi","checked":"[[ \"$(omarchy-default-agent)\" == \"pi\" ]]","action":"omarchy-default-agent pi"}, @@ -161,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"}, @@ -226,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"}, @@ -239,9 +243,12 @@ "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, + "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "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.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.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-launch-floating-terminal-with-presentation omarchy-install-ai-t3-code"}, "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"}, "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, @@ -292,6 +299,7 @@ "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, + "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, "remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"}, "remove.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"}, "remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"}, @@ -305,6 +313,8 @@ "remove.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"omarchy-pkg-present grok-bot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-grok-bot"}, "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/systemd/system-sleep/force-igpu b/default/systemd/system-sleep/force-igpu index 6f129438..8568067e 100644 --- a/default/systemd/system-sleep/force-igpu +++ b/default/systemd/system-sleep/force-igpu @@ -1,29 +1,65 @@ #!/bin/bash +set -e + # Use the Vfio to Integrated trick to turn off NVIDIA dgpu when in integrated mode # without needing to restart the computer. This is needed because computers like the Asus G14 # will wake after suspend in Hybrid mode, even if the system was in Integrated mode before # suspending. +restore_marker=/run/omarchy-force-igpu-integrated +sleep_action=${SYSTEMD_SLEEP_ACTION:-$2} +[[ -x /usr/bin/supergfxctl ]] || exit 0 + +switch_mode() { + local expected="$1" current + + if ! /usr/bin/timeout --kill-after=1s 3s /usr/bin/supergfxctl -m "$expected"; then + echo "Could not request the GPU transition to $expected mode" >&2 + return 1 + fi + for _ in {1..10}; do + if current=$(/usr/bin/timeout --kill-after=1s 2s /usr/bin/supergfxctl -g 2>/dev/null) && + [[ $current == "$expected" ]]; then + return 0 + fi + sleep 1 + done + + echo "Could not confirm the GPU transition to $expected mode" >&2 + return 1 +} + case "$1" in pre) + # Remember the mode this sleep cycle started in. supergfxctl persists the + # temporary hibernate switch to Vfio, so post must not consult that mutable + # value when deciding whether to restore Integrated mode. + if [[ -L $restore_marker ]]; then + exit 1 + elif [[ ! -f $restore_marker ]]; then + /usr/bin/grep -Eq '"mode"[[:space:]]*:[[:space:]]*"Integrated"' /etc/supergfxd.conf 2>/dev/null || exit 0 + /usr/bin/install -m 0600 -o root -g root -T /dev/null "$restore_marker" + fi + # Before hibernating, switch to Vfio so the nvidia driver is detached from the dGPU. # Without this, hibernate resume fails because the nvidia driver can't freeze a # powered-off dGPU (returns -EIO), which aborts the entire resume. - if [[ $2 == "hibernate" ]]; then - /usr/bin/supergfxctl -m Vfio - sleep 1 + if [[ $sleep_action == "hibernate" ]]; then + switch_mode Vfio fi ;; post) + [[ -f $restore_marker && ! -L $restore_marker ]] || exit 0 + # small delay so the device is fully re-enumerated sleep 4 # force-bind dGPU to vfio (fully detached from nvidia) - /usr/bin/supergfxctl -m Vfio - sleep 1 + switch_mode Vfio # then go back to Integrated, which powers it off again - /usr/bin/supergfxctl -m Integrated + switch_mode Integrated + /usr/bin/rm -f -- "$restore_marker" ;; esac diff --git a/default/systemd/system-sleep/keyboard-backlight b/default/systemd/system-sleep/keyboard-backlight index c6fbea1c..014ab4a8 100644 --- a/default/systemd/system-sleep/keyboard-backlight +++ b/default/systemd/system-sleep/keyboard-backlight @@ -3,7 +3,9 @@ # Turn off keyboard backlight before hibernate to prevent hang on power-off. # The ASUS keyboard controller can block S4 shutdown if LEDs are active. -if [[ $1 == "pre" && $2 == "hibernate" ]]; then +sleep_action=${SYSTEMD_SLEEP_ACTION:-$2} + +if [[ $1 == "pre" && $sleep_action == "hibernate" ]]; then device="" for candidate in /sys/class/leds/*kbd_backlight*; do if [[ -e "$candidate" ]]; then diff --git a/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf b/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf new file mode 100644 index 00000000..571a53bc --- /dev/null +++ b/default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots diff --git a/default/themed/hermes.yaml.tpl b/default/themed/hermes.yaml.tpl new file mode 100644 index 00000000..2e39710e --- /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/default/themed/t3code.json.tpl b/default/themed/t3code.json.tpl new file mode 100644 index 00000000..e420dcc4 --- /dev/null +++ b/default/themed/t3code.json.tpl @@ -0,0 +1,36 @@ +{ + "name": "Omarchy", + "appearance": "{{ mode }}", + "canvas": "{{ background }}", + "accent": "{{ accent }}", + "colors": { + "chrome": "{{ background }}", + "toolbar": "{{ background }}", + "toolbarForeground": "{{ foreground }}", + "toolbarBorder": "{{ muted }}", + + "text": "{{ foreground }}", + "border": "{{ muted }}", + "focus": "{{ accent }}", + + "sidebar": "{{ dark_background }}", + "sidebarForeground": "{{ foreground }}", + "sidebarBorder": "{{ muted }}", + "sidebarRowHover": "{{ mix dark_background foreground 6% }}", + "sidebarRowActive": "{{ mix dark_background accent 18% }}", + "sidebarRowSelected": "{{ selection }}", + + "error": "{{ mix red foreground 35% }}", + "warning": "{{ mix yellow foreground 35% }}", + + "codeBackground": "{{ dark_background }}", + "codeForeground": "{{ foreground }}", + + "terminalBackground": "{{ background }}", + "terminalForeground": "{{ foreground }}", + "terminalCursor": "{{ bright_foreground }}", + "terminalSelection": "{{ selection }}", + "terminalScrollbar": "{{ muted }}", + "terminalScrollbarHover": "{{ dark_foreground }}" + } +} diff --git a/docs/file-layout.md b/docs/file-layout.md index 2a9d965b..f261085f 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -84,6 +84,7 @@ version ──► omarchy /usr/share/omarchy config/** ──► omarchy-settings /etc/skel/.config/** (seeds new users) /usr/share/omarchy/config/** (resync source) etc/fastfetch/config.jsonc ──► omarchy-settings /etc/fastfetch/config.jsonc +etc/xdg/kitty/kitty.conf ──► omarchy-settings /etc/xdg/kitty/kitty.conf applications/*.desktop ──► omarchy-settings /etc/skel/.local/share/applications/ /usr/share/omarchy/applications/ @@ -124,8 +125,7 @@ default/** ──► omarchy-settings /usr/share/omarchy ├─ applications/mimeapps.list /usr/share/applications/mimeapps.list ├─ systemd/user/*.service /usr/lib/systemd/user/ ├─ systemd/user/app.slice.d/10-oomd.conf /usr/lib/systemd/user/app.slice.d/ - ├─ systemd/system-sleep/{force-igpu, - │ keyboard-backlight,unmount-fuse} /usr/lib/systemd/system-sleep/ + ├─ systemd/system-sleep/unmount-fuse /usr/lib/systemd/system-sleep/ ├─ systemd/zram-generator.conf.d/90-omarchy.conf /usr/lib/systemd/zram-generator.conf.d/ ├─ fonts/omarchy/omarchy.ttf /usr/share/fonts/omarchy/ ├─ sddm/omarchy/ /usr/share/sddm/themes/omarchy/ @@ -139,6 +139,8 @@ logo.{txt,svg}, icon.{txt,png} ──► omarchy-settings /usr/share/omarchy /etc/skel/.config/omarchy/branding/{about,screensaver}.txt ``` +The hardware-conditional `force-igpu` and `keyboard-backlight` sources also live under `default/systemd/system-sleep/`, but their setup commands publish root-owned copies only on machines that need them; they are not installed by `omarchy-settings`. + ### Why `etc-overrides/` exists Some files under `/etc/` (`.bashrc` in `/etc/skel`, `nsswitch.conf`, @@ -152,6 +154,14 @@ without a file conflict. Instead their sources (under `etc/` in the repo; Tradeoff: user edits to those files get clobbered on every `omarchy-settings` upgrade. This is documented in the PKGBUILD. +## Locate indexing + +`default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf` ships through `omarchy-settings` to `/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf`. It replaces the existing service's `ExecStart` with `updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots`, keeping Btrfs subvolume mounts searchable and excluding Snapper snapshots. The upstream service retains its timer, resource limits, and sandbox; Omarchy's existing AC-power condition still applies. + +`/etc/updatedb.conf` remains owned by plocate and is never rewritten by Omarchy. The command-line options override bind-mount pruning and add to the administrator's existing path exclusions. Installer and AUR package refreshes pass the same options directly because installation may run without systemd and an explicitly requested refresh should work on battery. + +Arch's systemd package hook reloads units when the vendor drop-in is installed or upgraded. The settings package containing the drop-in must ship alongside the runtime package that removes the old configuration helper and migration. Pacman removes those retired files; no new state migration is needed. A running indexer finishes with its original options, and subsequent service starts use the drop-in. For an immediate local test after installing the packages, restart `plocate-updatedb.service` while connected to AC power. + ## Env bootstrap (`default/bash/env-bootstrap`) Single source of truth for `OMARCHY_PATH` and dev-link-aware `PATH`. It: @@ -198,11 +208,7 @@ Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, `flags.lua`, or the nautilus extensions — `/etc/skel` already seeded those. It only does the things `/etc/skel` can't: -- Skill symlinks `~/.{agents,claude,codex,pi/agent}/skills/` → - `$OMARCHY_PATH/default/agents/skills/`, looping over every skill - directory there (currently `omarchy` and `diagnose-crash`) so new skills - need no edit. Symlinks (not copies) so `omarchy dev link` against a dev - checkout repoints them correctly. +- Skill symlinks into `~/.agents/skills/`, `~/.claude/skills/`, `~/.codex/skills/`, `~/.pi/agent/skills/`, `~/.gemini/config/skills/` (Antigravity), `~/.hermes/skills/`, and each existing `~/.hermes/profiles/*/skills/` → `$OMARCHY_PATH/default/agents/skills/`, looping over every skill directory there (currently `omarchy` and `diagnose-crash`) so new skills need no edit. Symlinks (not copies) so `omarchy dev link` against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles. - `xdg-user-dirs-update` (Templates/Public/Desktop folded back into `$HOME`) and `~/.config/gtk-3.0/bookmarks` (needs `$HOME` expansion). - Hyprland's package-owned default input reads `XKBLAYOUT` / `XKBVARIANT` @@ -355,3 +361,9 @@ return to the packaged default. | New stock theme | `themes//` (+ matching templates under `default/themed/` if they need theme colors) | | User-installed theme | `~/.config/omarchy/themes//` | | Generated current theme/background state | `~/.local/state/omarchy/current/` | + +## Kitty defaults and user overrides + +Kitty loads `/etc/xdg/kitty/kitty.conf` before `~/.config/kitty/kitty.conf`. The `omarchy-settings` package owns the system file; the user template contains only the active theme include and commented examples for personal overrides. Keeping the theme include in the user file lets users remove it without changing the packaged defaults. Individual inherited keybindings can be unmapped with an empty `map ` directive, or all inherited bindings can be cleared with `clear_all_shortcuts yes`. + +The system default uses `allow_remote_control socket-only` so Omarchy can query the active terminal directory over its Unix socket while Kitty rejects remote-control requests arriving through terminal output. Changing this setting requires restarting Kitty. The migration refreshes the exact previous stock config with a backup; customized configs retain their settings and ordering, with only explicit unrestricted `yes`, `y`, or `true` remote-control settings commented out. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a7c1b389..d62ff743 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -37,14 +37,11 @@ wait). Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. -Panels, overlays, and menus are loaded when summoned. Plugins can set the -top-level manifest key `keepLoaded: true` to survive between summons. -First-party services are loaded at startup. +Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. -Entry points are QML `Item`s. Panel, overlay, and menu entry points expose -`open(payloadJson)` and `close()` for summon/hide; on load the host injects -`omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / -`barWidgetRegistry`) as properties. +Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation. + +A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). @@ -81,12 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable. Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`; widgets that omit it default to `center`. -Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you -before cloning, plugins land disabled so you can review the code before -`omarchy plugin enable`, and updates show a diff before touching anything. -Commands confirm in a terminal even when given arguments; without one they -refuse rather than guess. Add `--yes` to skip every prompt (the path for -scripts and agents). +Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell. You can still install by hand: drop a plugin into `~/.config/omarchy/plugins//`, run `omarchy-shell shell rescanPlugins`, then diff --git a/docs/testing.md b/docs/testing.md index 3928ed2b..bcd3b4af 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -20,11 +20,10 @@ the end and exits non-zero. the theme pipeline: template rendering (`omarchy-theme-set-templates`, `omarchy-theme-color`, `omarchy-theme-osc`), the theme sync commands (tmux, GNOME, VS Code, Pi, Claude) run against stub binaries and a fake - `$HOME`, and the theme-state migrations. + `$HOME`. - **`./test/shell`** — runs every `test/shell.d/*-test.sh` (except `base-test.sh` itself). Each file is an independent suite covering one area: - a shell plugin, a `bin/` command, a config invariant, a migration. This is - where new tests go. + a shell plugin, a `bin/` command, a config invariant, or a still-live migration. This is where new tests go. - **Acceptance** — everything that needs a real desktop doing real things. Deliberately excluded from `./test/all`; it runs in a VM, not the development session. @@ -132,7 +131,7 @@ only a live session can prove. fake `$HOME`, runs `bash -euo pipefail "$ROOT/migrations/.sh"`, and asserts the resulting state — including running it twice to prove idempotence, and once against non-legacy state to prove it leaves user - customization alone. + customization alone. Keep that test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair. Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay. - **Assert the invariant, not the snapshot.** Config tests pin the property a test is named for (this widget stays adjacent to that one) rather than whole structures, so unrelated churn does not fail them. diff --git a/etc/xdg/kitty/kitty.conf b/etc/xdg/kitty/kitty.conf new file mode 100644 index 00000000..485381d0 --- /dev/null +++ b/etc/xdg/kitty/kitty.conf @@ -0,0 +1,35 @@ +# Omarchy defaults. Put personal overrides in ~/.config/kitty/kitty.conf. + +# Font +font_family JetBrainsMono Nerd Font +bold_italic_font auto +font_size 9.0 + +# Window +window_padding_width 14 +hide_window_decorations yes +confirm_os_window_close 0 + +# Keybindings +map ctrl+insert copy_to_clipboard +map shift+insert paste_from_clipboard +# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter. +map shift+enter send_text all \e[13;2u +# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter. +map alt+shift+enter send_text all \e[13;4u + +# Allow local cwd lookup, but reject remote control through terminal output. +allow_remote_control socket-only +listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid} + +# Aesthetics +cursor_shape block +cursor_blink_interval 0 +shell_integration no-cursor +enable_audio_bell no + +# Minimal Tab bar styling +tab_bar_edge bottom +tab_bar_style powerline +tab_powerline_style slanted +tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''} diff --git a/install/config/all.sh b/install/config/all.sh index d8c7d9bb..a221a398 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,6 +7,5 @@ run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" -run_logged "$OMARCHY_INSTALL/config/locate.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/locate.sh b/install/config/locate.sh deleted file mode 100644 index cc95e9b8..00000000 --- a/install/config/locate.sh +++ /dev/null @@ -1,32 +0,0 @@ -UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" - -echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes" - -[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 - -# updatedb refuses to run at all on a config that defines a variable twice, so -# every setting here is rewritten where it already stands and only appended -# when the file has no line for it. - -# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning -# bind mounts leaves them out of the index entirely. -if grep -qE '^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" -else - printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH" -fi - -# Snapper snapshots are nested subvolumes reached by plain directory -# traversal, so without this updatedb indexes the system once per snapshot. -if grep -qE '^[[:space:]]*PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - # updatedb only accepts quoted values and allows a comment after them. Read - # back what the machine already prunes and write the whole setting out again - # rather than splicing into a line of unknown shape. - pruned=$(sed -nE 's|^[[:space:]]*PRUNEPATHS[[:space:]]*=[[:space:]]*"([^"]*)".*|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) - - if [[ " $pruned " != *" /.snapshots "* ]]; then - sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH" - fi -else - printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" -fi diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index c5013ba6..76e74110 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 @@ -133,6 +135,7 @@ ufw-docker unzip usage uwsm +vi whois wireless-regdb wireplumber diff --git a/install/post-install/localdb.sh b/install/post-install/localdb.sh index 8c3f36a8..6600d301 100644 --- a/install/post-install/localdb.sh +++ b/install/post-install/localdb.sh @@ -1,2 +1,3 @@ # Update localdb so locate can find the installed system files immediately. -updatedb +# Match the scheduled service while installation runs without a system manager. +updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots diff --git a/install/user/mise.sh b/install/user/mise.sh index 0c8ab99b..8106038e 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -1,3 +1,7 @@ +# Upgrades must not delete the version a running process is executing from: +# mise up would prune the old install dir out from under a live session. +mise settings set upgrade.auto_prune false + omarchy-mise-install codex omarchy-mise-install claude omarchy-mise-install crush @@ -9,7 +13,18 @@ omarchy-mise-install npm:playwright playwright omarchy-mise-install pi omarchy-mise-install github:can1357/oh-my-pi omp omarchy-mise-install npm:@xai-official/grok grok +# Cursor's own installer links the same path, so a re-provision keeps it. +omarchy-cmd-missing cursor-agent && omarchy-mise-install cursor-agent omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:OpenRouterLabs/ori-releases ori +# Every line above writes a stub and cannot fail. This one can: it exits +# non-zero when Hermes Desktop owns Hermes but has not finished setting it up, +# and this leaf is sourced under `bash -eE`, so that would abort the rest of +# 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/07-hotkeys.md b/manual/07-hotkeys.md index 5a038ff6..72acdc8a 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -192,6 +192,7 @@ All style options are also accessible under _Style_ in the Omarchy menu (`Super | `Shift + Mute` | Switch to next audio output | | `Shift + Play` | Switch to next media source | | `Super + Shift + Backspace` | Toggle window gaps | +| `Super + Ctrl + Alt + F` | Toggle full screen desktop (top bar + window gaps) | ## Reminders diff --git a/manual/17-ai.md b/manual/17-ai.md index 57698f42..8911b974 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -14,6 +14,9 @@ Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a f | `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 | +| `cursor-agent` | [Cursor CLI](https://cursor.com/cli) | `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. @@ -23,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 @@ -43,7 +48,11 @@ Crashes can also be silenced one program at a time, which is what the diagnosis ### Desktop apps -The _Install > AI_ menu also carries a couple of graphical AI apps: the ChatGPT desktop app, and Grok Bot for chatting with xAI's models. +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. 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. ### Local LLMs @@ -51,6 +60,6 @@ Omarchy recommends two ways of running local LLM models: LM Studio and Ollama. L ### The Omarchy Skill -Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. +Agent skills help AI use specific tools in a specific way, and Omarchy ships with a default skill for tailoring the system. Like tweaking your Hyprland config, adjusting the bar, or even creating a new theme from scratch. It's symlinked into the skill directories for Claude Code (`~/.claude/skills`), Codex (`~/.codex/skills`), Pi (`~/.pi/agent/skills`), Antigravity (`~/.gemini/config/skills`), Hermes (`~/.hermes/skills` and each `~/.hermes/profiles/*/skills`), and the generic `~/.agents/skills` location, so most harnesses pick it up automatically. But you should treat this skill as experimental. Different models will use it to different effect. It's best to run in plan mode first, so you have an idea of what the agent would like to change. And then be ready to rollback changes or even invoking `omarchy reinstall configs`, if the agent makes a mess of everything. diff --git a/manual/18-development-tools.md b/manual/18-development-tools.md index 59ae9198..f49125d9 100644 --- a/manual/18-development-tools.md +++ b/manual/18-development-tools.md @@ -4,6 +4,8 @@ Omarchy ships with [Neovim](https://neovim.io/) by default, but if you'd like something a bit more mainstream and familiar, you can run the Omarchy Menu (`Super + Space`) and see the options under _Install > Editor_. We have VSCode, Cursor, Zed, Sublime Text, Helix, Vim, and Emacs listed there. If you don't find what you're looking for, checkout _Install > Package_, and see if it isn't in an Arch package (and if not, try _Install > AUR_ to check the AUR). +The original `vi` editor is also available out of the box. Run `vi filename` to edit a file in the terminal. + Theme matching is offered for `VSCode`, `Cursor`, `VSCodium`, and `Helix`. You can set the system-wide default editor under `Setup > Defaults > Editor`. diff --git a/manual/31-dotfiles.md b/manual/31-dotfiles.md index 11e0d762..ec148b44 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/32-shell-plugins.md b/manual/32-shell-plugins.md index d54df3fb..38ae4001 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -4,7 +4,7 @@ The Omarchy desktop runs as a single long-lived Quickshell process called `omarc That's not just an implementation detail. It means you can turn pieces of the desktop off, swap them out, or write your own without touching a line of Omarchy's source. -The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup; the only difference is where they sit on disk. +The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup, but built-ins receive trusted shell interfaces while third-party plugins receive a limited interface scoped to their own service and lifecycle. Clones of built-ins keep only the source-specific configuration and UI calls needed for the original behavior. ## Seeing what you have @@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root. omarchy plugin add https://github.com/acme/omarchy-weather.git --enable ``` -Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. A plugin isn't a config file — it's code that runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them. +Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them. + +A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service. Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins//`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC. diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 6edec391..f2cd51eb 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/1784809451.sh b/migrations/1784809451.sh deleted file mode 100644 index bcd6c6de..00000000 --- a/migrations/1784809451.sh +++ /dev/null @@ -1,30 +0,0 @@ -echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" - -OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" -locate_config_script="$OMARCHY_PATH/install/config/locate.sh" -UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" - -as_root() { - if (( EUID == 0 )); then - "$@" - else - sudo "$@" - fi -} - -[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 -[[ -f $locate_config_script ]] || exit 0 - -if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && - grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -E '(^|[[:space:]"])/\.snapshots([[:space:]"]|$)' >/dev/null; then - exit 0 -fi - -as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script" - -# Rebuild the index with the new exclusions; pruning /.snapshots turns -# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart -# rather than start: the machines this targets are the ones with an updatedb -# already grinding through every snapshot, and a run that started before the -# rewrite keeps using the config it read at startup. -as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/migrations/1785090473.sh b/migrations/1785090473.sh index 1e64b6ee..ca75cf2c 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 00000000..b7833092 --- /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/1787215483.sh b/migrations/1787215483.sh new file mode 100644 index 00000000..2e473ef0 --- /dev/null +++ b/migrations/1787215483.sh @@ -0,0 +1,3 @@ +echo "Stop mise upgrades from pruning versions still in use" + +mise settings set upgrade.auto_prune false diff --git a/migrations/1787760281.sh b/migrations/1787760281.sh new file mode 100644 index 00000000..952fa9a2 --- /dev/null +++ b/migrations/1787760281.sh @@ -0,0 +1,25 @@ +echo "Install the Hermes CLI wrapper for existing installs" + +# Users who removed the preinstalls opted out of the mise wrappers, and Hermes +# is one of them. +[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0 + +# Hermes Desktop provides its own Hermes. The installer stands aside for it, +# removing the mise copy and the Omarchy wrapper an earlier install may have +# left beside the app. It also reports when the app has not finished setting +# Hermes up, which is the app's to finish, not this migration's to fail on. +if omarchy-pkg-present hermes-desktop; then + omarchy-install-hermes-cli || true + exit 0 +fi + +# Anything already answering to hermes that this installer did not write -- +# an official install, a hand-rolled wrapper, even a dangling link -- belongs to +# the user and stays exactly as it is. The installer is asked rather than +# matched against here, so there is one answer to who owns that wrapper. +wrapper="$HOME/.local/bin/hermes" +if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then + exit 0 +fi + +omarchy-install-hermes-cli diff --git a/migrations/1787843905.sh b/migrations/1787843905.sh new file mode 100644 index 00000000..e0e58cd5 --- /dev/null +++ b/migrations/1787843905.sh @@ -0,0 +1,22 @@ +echo "Link Omarchy agent skills into Hermes skill directories" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +skills_source="$OMARCHY_PATH/default/agents/skills" + +[[ -d $skills_source ]] || exit 0 + +mkdir -p "$HOME/.hermes/skills" + +for skill in "$skills_source"/*/; do + [[ -d $skill ]] || continue + name=${skill%/} + name=${name##*/} + ln -sfn "$skills_source/$name" "$HOME/.hermes/skills/$name" + if [[ -d $HOME/.hermes/profiles ]]; then + for profile in "$HOME"/.hermes/profiles/*/; do + [[ -d $profile ]] || continue + mkdir -p "$profile/skills" + ln -sfn "$skills_source/$name" "$profile/skills/$name" + done + fi +done diff --git a/migrations/1788577553.sh b/migrations/1788577553.sh new file mode 100644 index 00000000..cc1a54ad --- /dev/null +++ b/migrations/1788577553.sh @@ -0,0 +1,8 @@ +echo "Install Cursor CLI via mise wrapper" + +# Cursor's own installer links ~/.local/bin/cursor-agent, so an existing +# command is the user's and stays. The wrapper resolves cursor-agent through +# mise's registry, which lists it from 2026.8.15 on. +if omarchy-cmd-missing cursor-agent && [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install cursor-agent +fi diff --git a/migrations/1788595060.sh b/migrations/1788595060.sh new file mode 100644 index 00000000..750da2dc --- /dev/null +++ b/migrations/1788595060.sh @@ -0,0 +1,4 @@ +echo "Register the Chromium extension native messaging hosts for Brave Origin" + +omarchy-install-chromium-copy-url +omarchy-install-chromium-ytdlp diff --git a/migrations/1788596255.sh b/migrations/1788596255.sh new file mode 100644 index 00000000..e4c7b0c4 --- /dev/null +++ b/migrations/1788596255.sh @@ -0,0 +1,3 @@ +echo "Add vi as a standard terminal editor" + +omarchy-pkg-add vi diff --git a/migrations/1788619462.sh b/migrations/1788619462.sh new file mode 100644 index 00000000..0b491679 --- /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/1788662350.sh b/migrations/1788662350.sh new file mode 100644 index 00000000..c0d4dbf2 --- /dev/null +++ b/migrations/1788662350.sh @@ -0,0 +1,303 @@ +echo "Repair user-owned system-sleep hooks and hybrid GPU service configuration" + +system_sleep_dir=/usr/lib/systemd/system-sleep +supergfxd_drop_in=/etc/systemd/system/supergfxd.service.d/delay-start.conf +quarantine_root=/var/lib/omarchy/migrations/1788662350-system-sleep +reload_needed_marker=/var/lib/omarchy/migrations/1788662350-systemd-reload-needed +keyboard_source="$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" +force_igpu_source="$OMARCHY_PATH/default/systemd/system-sleep/force-igpu" +supergfxd_source="$OMARCHY_PATH/default/systemd/system/supergfxd.service.d/delay-start.conf" +legacy_keyboard_sha256=f313a81e47401f0d38b8602e5997f52c5286d5e97f74027564ddd515b3d16511 +legacy_force_igpu_sha256=d604e7c4903829563e45fc52188fc5602c3f1bc66e247f0a2cc0a974ed6e57db + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +path_is_root_controlled() { + local path="$1" + local current=/ component candidate file_mode link metadata part status uid gid mode + local missing_depth=0 symlink_count=0 + local -a pending resolved link_components + + [[ $path == /* ]] || return 1 + IFS=/ read -r -a pending <<<"$path" + # A non-root group is harmless when neither it nor everyone else can write. + # Resolve symlinks component by component so an indirect link cannot hide an + # intermediate directory controlled by an unprivileged user. + metadata=$(path_metadata /) || return 1 + read -r file_mode uid gid mode <<<"$metadata" + (( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1 + + while ((${#pending[@]})); do + component=${pending[0]} + pending=("${pending[@]:1}") + [[ -n $component ]] || continue + [[ $component == "." ]] && continue + + if [[ $component == ".." ]]; then + if ((${#resolved[@]})); then + unset 'resolved[-1]' + fi + + current=/ + for part in "${resolved[@]}"; do + if [[ $current == "/" ]]; then + current="/$part" + else + current="$current/$part" + fi + done + if (( missing_depth > 0 && ${#resolved[@]} < missing_depth )); then + missing_depth=0 + fi + continue + fi + + if [[ $current == "/" ]]; then + candidate="/$component" + else + candidate="$current/$component" + fi + + if (( missing_depth > 0 )); then + # The first missing component makes descendants inactive today, but keep + # consuming the lexical suffix. A later .. can escape back into an + # existing user-controlled path that would become active if an + # administrator creates the missing directory. + resolved+=("$component") + current=$candidate + continue + elif metadata=$(path_metadata "$candidate"); then + read -r file_mode uid gid mode <<<"$metadata" + else + status=$? + if (( status == 2 )); then + resolved+=("$component") + current=$candidate + missing_depth=${#resolved[@]} + continue + else + return 1 + fi + fi + if (( (16#$file_mode & 16#f000) == 16#a000 )); then + ((++symlink_count <= 40)) || return 1 + link=$(readlink_with_privilege "$candidate") || return 1 + IFS=/ read -r -a link_components <<<"$link" + pending=("${link_components[@]}" "${pending[@]}") + if [[ $link == /* ]]; then + resolved=() + current=/ + fi + continue + fi + + (( uid == 0 && (8#$mode & 8#022) == 0 )) || return 1 + resolved+=("$component") + current=$candidate + done +} + +path_metadata() { + local path="$1" + local metadata parent + + if /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null; then + return 0 + elif [[ ! -e $path && ! -L $path ]]; then + parent=${path%/*} + [[ -n $parent ]] || parent=/ + # Avoid asking for sudo for ordinary ENOENT. If the parent is searchable, + # the absence is conclusive; an inaccessible root-only chain still needs a + # privileged metadata check so safe administrator symlinks are preserved. + [[ -x $parent ]] && return 2 + if metadata=$(as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" 2>/dev/null); then + printf '%s\n' "$metadata" + return 0 + elif as_root /usr/bin/test -x "$parent"; then + # The privileged probe could search the protected parent, so stat's + # failure identifies a target that does not exist yet. + return 2 + else + return 1 + fi + else + as_root /usr/bin/stat -c '%f %u %g %a' -- "$path" + fi +} + +readlink_with_privilege() { + local path="$1" + + if /usr/bin/readlink -- "$path" 2>/dev/null; then + return 0 + else + as_root /usr/bin/readlink -- "$path" + fi +} + +privileged_entry_is_safe() { + local path="$1" + + path_is_root_controlled "$path" +} + +file_matches_source() { + local source="$1" + local destination="$2" + + [[ -f $destination && ! -L $destination ]] || return 1 + + if [[ -r $destination ]]; then + /usr/bin/cmp -s -- "$source" "$destination" + else + as_root /usr/bin/cmp -s -- "$source" "$destination" + fi +} + +file_matches_sha256() { + local destination="$1" + local expected="$2" + local digest + + [[ -f $destination && ! -L $destination ]] || return 1 + if [[ -r $destination ]]; then + digest=$(/usr/bin/sha256sum -- "$destination") || return 1 + else + digest=$(as_root /usr/bin/sha256sum -- "$destination") || return 1 + fi + [[ ${digest%% *} == "$expected" ]] +} + +safe_stage_path() { + local stage="$1" + local destination="$2" + local prefix suffix + + prefix="${destination%/*}/.${destination##*/}.omarchy." + [[ $stage == "$prefix"* ]] || return 1 + suffix=${stage#"$prefix"} + [[ $suffix =~ ^[[:alnum:]]{6}$ ]] +} + +install_root_file() { + local source="$1" + local destination="$2" + local mode="$3" + local stage + + stage=$(as_root /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1 + safe_stage_path "$stage" "$destination" || return 1 + + if as_root /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" && + as_root /usr/bin/mv -Tf -- "$stage" "$destination"; then + return 0 + else + safe_stage_path "$stage" "$destination" && as_root /usr/bin/rm -f -- "$stage" + return 1 + fi +} + +preserve_unsafe_customization() { + local path="$1" + local label="$2" + local backup_dir backup + + if ! as_root /usr/bin/install -d -m 0700 -o root -g root "$quarantine_root"; then + echo "Could not create the root-only system-sleep quarantine at $quarantine_root" >&2 + return 1 + fi + if ! backup_dir=$(as_root /usr/bin/mktemp -d -- "$quarantine_root/${label}.XXXXXX"); then + echo "Could not reserve a quarantine path for $path" >&2 + return 1 + fi + backup="$backup_dir/original" + + if as_root /usr/bin/cp -a --no-dereference -T -- "$path" "$backup"; then + printf '%s\n' "$backup" + else + as_root /usr/bin/rm -rf -- "$backup_dir" + echo "Could not preserve unsafe custom content from $path before repairing it" >&2 + return 1 + fi +} + +repair_unsafe_privileged_entry() { + local source="$1" + local destination="$2" + local mode="$3" + local label="$4" + local legacy_sha256="${5:-}" + local backup current_mode + + [[ -e $destination || -L $destination ]] || return 0 + [[ -f $destination || -L $destination ]] || return 0 + + if file_matches_source "$source" "$destination"; then + current_mode=$(/usr/bin/stat -c '%a' -- "$destination" 2>/dev/null) || + current_mode=$(as_root /usr/bin/stat -c '%a' -- "$destination") || return 1 + if privileged_entry_is_safe "$destination" && [[ $current_mode == "${mode#0}" ]]; then + return 0 + fi + elif [[ -n $legacy_sha256 ]] && file_matches_sha256 "$destination" "$legacy_sha256"; then + : + else + privileged_entry_is_safe "$destination" && return 0 + backup=$(preserve_unsafe_customization "$destination" "$label") || return 1 + fi + + if install_root_file "$source" "$destination" "$mode"; then + if [[ -n ${backup:-} ]]; then + echo "Preserved unsafe custom content from $destination at $backup for administrator review" >&2 + fi + else + if [[ -n ${backup:-} ]]; then + echo "Preserved unsafe custom content from $destination at $backup, but could not repair the active path" >&2 + fi + return 1 + fi +} + +# Replace rather than chown an unsafe destination: its current owner may have +# already changed the contents or kept a writable file descriptor open. The +# root-owned staging inode makes the final rename an atomic trust transition. +repair_unsafe_privileged_entry "$keyboard_source" \ + "$system_sleep_dir/keyboard-backlight" 0755 keyboard-backlight "$legacy_keyboard_sha256" + +force_igpu="$system_sleep_dir/force-igpu" +repair_unsafe_privileged_entry "$force_igpu_source" "$force_igpu" 0755 force-igpu "$legacy_force_igpu_sha256" + +systemd_reload_needed=false +if [[ -e $reload_needed_marker || -L $reload_needed_marker ]]; then + systemd_reload_needed=true +fi + +if [[ -e $supergfxd_drop_in || -L $supergfxd_drop_in ]]; then + if ! privileged_entry_is_safe "$supergfxd_drop_in"; then + # Replacing the drop-in and reloading systemd are one repair. Record the + # second half before changing the file so failure or interruption cannot + # be forgotten when a retry sees only the trusted replacement on disk. + if ! as_root /usr/bin/install -Dm0644 -o root -g root /dev/null "$reload_needed_marker"; then + echo "Could not persist the pending systemd reload for the repaired supergfxd configuration" >&2 + exit 1 + fi + systemd_reload_needed=true + repair_unsafe_privileged_entry "$supergfxd_source" "$supergfxd_drop_in" 0644 delay-start.conf + fi +fi + +if $systemd_reload_needed; then + if ! as_root /usr/bin/systemctl daemon-reload; then + echo "Could not reload systemd after repairing the supergfxd configuration; the migration will retry" >&2 + exit 1 + fi + if ! as_root /usr/bin/rm -f -- "$reload_needed_marker"; then + echo "Could not clear the pending systemd reload marker; the migration will retry" >&2 + exit 1 + fi +fi diff --git a/migrations/1788724825.sh b/migrations/1788724825.sh new file mode 100644 index 00000000..cc76dc0f --- /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/migrations/1788745941.sh b/migrations/1788745941.sh new file mode 100644 index 00000000..5fe72caa --- /dev/null +++ b/migrations/1788745941.sh @@ -0,0 +1,33 @@ +echo "Update Kitty configuration" + +kitty_config="$HOME/.config/kitty/kitty.conf" +# config/kitty/kitty.conf as shipped after 008f3a22 (Kitty cwd lookup). +stock_sha="856cd466bf568d091cb775c5b90d1852178090a419f492fde02a4eaef6407bf9" +unrestricted='^[[:space:]]*allow_remote_control[[:space:]]+(yes|y|true)[[:space:]]*$' + +if [[ -f $kitty_config ]]; then + changed=false + + if [[ $(sha256sum "$kitty_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + omarchy-refresh-config kitty/kitty.conf + changed=true + elif grep -qE "$unrestricted" "$kitty_config"; then + # Preserve customizations and ordering. An otherwise stock line can be an + # intentional override of an earlier include or mapping. + backup=$(mktemp "$kitty_config.bak.XXXXXX") + cp -p "$kitty_config" "$backup" + sed --follow-symlinks -i -E "s/$unrestricted/# &/" "$kitty_config" + printf '\n%s\n' \ + "Unrestricted remote control disabled." \ + "Your other Kitty settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + changed=true + fi + + if [[ $changed == "true" ]]; then + # Kitty reads allow_remote_control at startup; config reload is insufficient. + gum style --border rounded --border-foreground 3 --padding "1 2" --margin "1 0" \ + "Restart Kitty" "" \ + "Close and reopen all Kitty windows to apply this change." + fi +fi diff --git a/plans/nix.md b/plans/nix.md new file mode 100644 index 00000000..b1a8ce31 --- /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 ca265acd..14af44fe 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/README.md b/shell/README.md index e72d02ce..ca07b520 100644 --- a/shell/README.md +++ b/shell/README.md @@ -86,8 +86,16 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall back to the built-in `omarchy.bar`, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need to outlive a single summon can set `keepLoaded: true` (e.g. the image -picker keeps its overlay window mounted between summons). First-party -services are loaded at startup. +picker keeps its overlay window mounted between summons). The same flag +keeps a service mounted across plugin hot-reload, so tearing down a +changed bar widget cannot destroy `omarchy.lock` while Hyprland still +holds the session lock. The kept instance is not replaced, so code +changes to a `keepLoaded` service itself only take effect on a shell +restart. First-party services are loaded at startup. + +Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph. + +Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service. The full schema lives in `services/PluginRegistry.qml`. @@ -104,10 +112,7 @@ omarchy plugin update # updates every git-managed plugin omarchy plugin remove acme.weather ``` -> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns -> you before cloning, plugins land disabled so you can review the code before -> enabling, and updates show a diff of the changes before touching anything. -> Only add repos whose code you are willing to run. +> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run. Each command is **interactive** when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully **non-interactive** when given diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml new file mode 100644 index 00000000..b281ae22 --- /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 00000000..519e8bd9 --- /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/PluginBarApi.qml b/shell/Ui/PluginBarApi.qml new file mode 100644 index 00000000..03c748b1 --- /dev/null +++ b/shell/Ui/PluginBarApi.qml @@ -0,0 +1,87 @@ +import QtQuick + +// Bar surface exposed to an installed third-party widget. Scalar presentation +// state is mirrored by Bar.qml and operations are delegated through scoped +// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a +// visual child from the parent hierarchy of the QML scene that renders it. +QtObject { + id: api + + required property string pluginId + required property string moduleName + property var shell: null + + property color foreground: "transparent" + property color barForeground: "transparent" + property color background: "transparent" + property color urgent: "transparent" + property string fontFamily: "" + property string position: "top" + property bool vertical: false + property int barSize: 0 + property bool transparent: false + property bool foregroundAnimationEnabled: true + property bool centerSectionRevealHeld: false + property bool _centerHoverRevealSuppressed: false + readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed + property var activePopout: null + property var clickTargets: [] + property var layoutConfig: ({}) + readonly property var foreignPopoutMarker: ({ foreign: true }) + + property var _showTooltip: null + property var _hideTooltip: null + property var _registerClickTarget: null + property var _unregisterClickTarget: null + property var _requestPopout: null + property var _releasePopout: null + property var _switchPanelFrom: null + property var _targetBelongsToWindow: null + property var _moduleWidgets: null + property var _run: null + property var _setCenterHoverRevealSuppressed: null + + function setCenterHoverRevealSuppressed(value) { + if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value) + } + + function showTooltip(target, text) { + if (_showTooltip) _showTooltip(target, String(text || "")) + } + + function hideTooltip(target) { + if (_hideTooltip) _hideTooltip(target) + } + + function registerClickTarget(target) { + if (_registerClickTarget) _registerClickTarget(target) + } + + function unregisterClickTarget(target) { + if (_unregisterClickTarget) _unregisterClickTarget(target) + } + + function requestPopout(owner) { + if (_requestPopout) _requestPopout(owner) + } + + function releasePopout(owner) { + if (_releasePopout) _releasePopout(owner) + } + + function switchPanelFrom(owner, direction) { + return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false + } + + function targetBelongsToWindow(target, window) { + return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false + } + + function moduleWidgets(id) { + return _moduleWidgets ? _moduleWidgets(String(id || "")) : [] + } + + function run(command) { + if (_run) _run(String(command || "")) + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index b25bf5ca..092caf6c 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 @@ -23,6 +25,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml PanelToolTip 1.0 PanelToolTip.qml +PluginBarApi 1.0 PluginBarApi.qml PointerMoveGate 1.0 PointerMoveGate.qml ScreenMoveRemap 1.0 ScreenMoveRemap.qml PopupCard 1.0 PopupCard.qml diff --git a/shell/plugins/README.md b/shell/plugins/README.md index d4252515..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and, only when fingerprints are enrolled, `omarchy-lock-fingerprint` for fingerprint auth. It mirrors the previous lock screen field dimensions, colors, blurred wallpaper, placeholder, and Hyprland-driven corners. +The plugin sets `keepLoaded: true` so a plugin hot-reload (for example +an installed bar widget changing on disk) does not destroy the lock +client while Hyprland still holds the session lock. ## Polkit agent diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 21e1b0a9..dea93a4c 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/bar/Bar.qml b/shell/plugins/bar/Bar.qml index def615af..be9fece4 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -12,20 +12,29 @@ Item { id: root // The omarchy-shell host injects omarchyPath from OMARCHY_PATH. - required property string omarchyPath + property string omarchyPath: Quickshell.env("OMARCHY_PATH") // Injected by the host shell so bar slots can resolve enabled widgets. - required property var barWidgetRegistry + property var barWidgetRegistry: fallbackBarWidgetRegistry + // Read-only registry view for third-party full bars; the built-in bar does + // not otherwise need it, but declaring it keeps clone construction atomic. + property var pluginRegistry: null // Injected by the host shell every time shell.json is reloaded. Holds the // `bar:` subtree: position, centerAnchor, layout. The host owns file IO; // the bar just renders whatever it's handed. The bar font follows the // OS-level fontconfig monospace binding — it is not stored in shell.json. - required property var barConfig + property var barConfig: ({}) // Injected by the host shell. Used for shell-wide actions such as opening // settings and persisting inline widget state. property var shell: null // Manifest for the active bar option. Present for custom bars and useful for // diagnostics; the built-in bar does not otherwise need it. property var manifest: null + QtObject { + id: fallbackBarWidgetRegistry + property var widgets: ({}) + property int revision: 0 + function metadataFor(id) { return null } + } // Mirrors the on-disk `bar-off` flag so the user can hide the bar without // killing the entire shell. Hidden panels stay mapped but park off-screen // without an exclusion zone; updated by the FileView watcher further down. @@ -100,6 +109,223 @@ Item { property var barMoveScreen: null property var clickTargets: [] property var moduleSlots: [] + property var pluginBarApis: ({}) + property var pluginObjectOwners: [] + + Component { + id: pluginBarApiComponent + PluginBarApi { } + } + + function publicLayoutConfig() { + return JSON.parse(JSON.stringify(root.layoutConfig || {})) + } + + function bindPluginBarApi(api) { + if (!api) return + api.foreground = Qt.binding(function() { return root.foreground }) + api.barForeground = Qt.binding(function() { return root.barForeground }) + api.background = Qt.binding(function() { return root.background }) + api.urgent = Qt.binding(function() { return root.urgent }) + api.fontFamily = Qt.binding(function() { return root.fontFamily }) + api.position = Qt.binding(function() { return root.position }) + api.vertical = Qt.binding(function() { return root.vertical }) + api.barSize = Qt.binding(function() { return root.barSize }) + api.transparent = Qt.binding(function() { return root.transparent }) + api.foregroundAnimationEnabled = Qt.binding(function() { return root.foregroundAnimationEnabled }) + api.centerSectionRevealHeld = Qt.binding(function() { return root.centerSectionRevealHeld }) + api._centerHoverRevealSuppressed = Qt.binding(function() { return root.centerHoverRevealSuppressed }) + root.syncPluginBarApiObjects(api) + } + + function syncPluginBarApiObjects(api) { + if (!api) return + api.activePopout = root.pluginOwnsBarObject(api.pluginId, root.activePopout) + ? root.activePopout : (root.activePopout ? api.foreignPopoutMarker : null) + api.clickTargets = root.pluginClickTargets(api.pluginId) + api.layoutConfig = root.publicLayoutConfig() + } + + function pluginObjectRecord(target) { + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (record && record.target === target) return record + } + return null + } + + function markPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + if (!key || !target) return false + var record = root.pluginObjectRecord(target) + if (record && record.pluginId !== key) return false + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var existing = pluginObjectOwners[i] + if (!existing || existing.target !== target) next.push(existing) + } + var updated = record || { target: target, pluginId: key, clickTarget: false, popout: false } + updated[role] = true + next.push(updated) + pluginObjectOwners = next + return true + } + + function unmarkPluginObject(pluginId, target, role) { + var key = String(pluginId || "") + var next = [] + for (var i = 0; i < pluginObjectOwners.length; i++) { + var record = pluginObjectOwners[i] + if (!record || record.target !== target || record.pluginId !== key) { + next.push(record) + continue + } + record[role] = false + if (record.clickTarget || record.popout) next.push(record) + } + pluginObjectOwners = next + } + + function pluginOwnsBarObject(pluginId, target) { + var record = target ? root.pluginObjectRecord(target) : null + return !!record && record.pluginId === String(pluginId || "") + } + + function pluginClickTargets(pluginId) { + var out = [] + for (var i = 0; i < root.clickTargets.length; i++) { + var target = root.clickTargets[i] + if (root.pluginOwnsBarObject(pluginId, target)) out.push(target) + } + return out + } + + function syncAllPluginBarApiObjects() { + for (var id in pluginBarApis) root.syncPluginBarApiObjects(pluginBarApis[id]) + } + + function registerPluginClickTarget(pluginId, target) { + if (!root.markPluginObject(pluginId, target, "clickTarget")) return + root.registerClickTarget(target) + } + + function unregisterPluginClickTarget(pluginId, target) { + if (!root.pluginOwnsBarObject(pluginId, target)) return + root.unregisterClickTarget(target) + root.unmarkPluginObject(pluginId, target, "clickTarget") + } + + function requestPluginPopout(pluginId, owner) { + if (!root.markPluginObject(pluginId, owner, "popout")) return + root.requestPopout(owner) + } + + function releasePluginPopout(pluginId, owner) { + if (!root.pluginOwnsBarObject(pluginId, owner)) return + root.releasePopout(owner) + root.unmarkPluginObject(pluginId, owner, "popout") + } + + function pluginBarApiFor(pluginId, moduleName, registered) { + var key = String(pluginId || "") + if (!key) return null + + var pluginShell = null + if (registered && root.shell && typeof root.shell.pluginShellForId === "function") { + // Only the trusted built-in bar receives ShellRoot and can request a + // service-capable facade for the widget it is instantiating. + pluginShell = root.shell.pluginShellForId(moduleName) + } else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") { + // Replacement bars receive a service-less entry facade. Giving an + // untrusted bar a generic facade factory would let it retrieve another + // third-party plugin's live service object. + pluginShell = root.shell.pluginShellForBarEntry(key, moduleName) + } + + if (pluginBarApis[key]) { + pluginBarApis[key].shell = pluginShell + return pluginBarApis[key] + } + + var api = pluginBarApiComponent.createObject(null, { + pluginId: key, + moduleName: String(moduleName || ""), + shell: pluginShell, + _showTooltip: function(target, text) { root.showTooltip(target, text) }, + _hideTooltip: function(target) { root.hideTooltip(target) }, + _registerClickTarget: function(target) { root.registerPluginClickTarget(key, target) }, + _unregisterClickTarget: function(target) { root.unregisterPluginClickTarget(key, target) }, + _requestPopout: function(owner) { root.requestPluginPopout(key, owner) }, + _releasePopout: function(owner) { root.releasePluginPopout(key, owner) }, + _switchPanelFrom: function(owner, direction) { return root.switchPanelFrom(owner, direction) }, + _targetBelongsToWindow: function(target, window) { return root.targetBelongsToWindow(target, window) }, + _moduleWidgets: function(requestedId) { + return String(requestedId || "") === String(moduleName || "") + ? root.moduleWidgets(moduleName) : [] + }, + _run: function(command) { root.run(command) }, + _setCenterHoverRevealSuppressed: function(value) { + root.centerHoverRevealSuppressed = !!value + } + }) + if (!api) return null + root.bindPluginBarApi(api) + + var next = ({}) + for (var id in pluginBarApis) next[id] = pluginBarApis[id] + next[key] = api + pluginBarApis = next + return api + } + + function pluginBarApiUsed(pluginId) { + for (var i = 0; i < moduleSlots.length; i++) { + var slot = moduleSlots[i] + if (slot && slot.pluginApiId === pluginId) return true + } + return false + } + + function releasePluginObjects(pluginId) { + var owned = pluginObjectOwners.slice() + for (var i = 0; i < owned.length; i++) { + var record = owned[i] + if (!record || record.pluginId !== pluginId) continue + if (record.clickTarget) root.unregisterClickTarget(record.target) + if (record.popout && root.activePopout === record.target) root.releasePopout(record.target) + } + pluginObjectOwners = pluginObjectOwners.filter(function(record) { + return record && record.pluginId !== pluginId + }) + } + + function prunePluginBarApis() { + var next = ({}) + for (var id in pluginBarApis) { + var api = pluginBarApis[id] + if (root.pluginBarApiUsed(id)) { + next[id] = api + continue + } + root.releasePluginObjects(id) + if (api && typeof api.destroy === "function") api.destroy() + } + pluginBarApis = next + } + + onActivePopoutChanged: syncAllPluginBarApiObjects() + onClickTargetsChanged: syncAllPluginBarApiObjects() + onLayoutConfigChanged: syncAllPluginBarApiObjects() + onModuleSlotsChanged: Qt.callLater(prunePluginBarApis) + + Component.onDestruction: { + for (var id in pluginBarApis) { + root.releasePluginObjects(id) + if (pluginBarApis[id] && typeof pluginBarApis[id].destroy === "function") + pluginBarApis[id].destroy() + } + pluginBarApis = ({}) + } function registerClickTarget(target) { if (!target || clickTargets.indexOf(target) !== -1) return @@ -599,6 +825,10 @@ Item { if (barHoverCount === 0) centerSectionRevealTimer.restart() } + function setCenterHoverRevealSuppressed(value) { + centerHoverRevealSuppressed = !!value + } + Timer { id: centerSectionRevealTimer interval: 120 @@ -1548,6 +1778,9 @@ Item { readonly property string moduleName: root.entryId(entry) readonly property var moduleSettings: root.entrySettings(entry) readonly property string customType: root.customModuleType(entry) + readonly property var registryMetadata: root.barWidgetRegistry.metadataFor(root.canonicalWidgetId(moduleName)) + readonly property bool firstParty: registryMetadata && registryMetadata.firstParty === true + readonly property string pluginApiId: registered ? root.canonicalWidgetId(moduleName) : "bar-entry:" + moduleName // Re-evaluate when the registry mutates (Component reference changes, // plugin enabled/disabled, etc.). Reading the `widgets` property creates // the binding dependency — the wrapped function call alone wouldn't. @@ -1766,7 +1999,8 @@ Item { function injectProps() { var target = activeItem if (!target) return - if ("bar" in target) target.bar = root + if ("bar" in target) target.bar = firstParty + ? root : root.pluginBarApiFor(pluginApiId, moduleName, registered) if ("moduleName" in target) target.moduleName = moduleName if ("settings" in target) target.settings = moduleSettings } diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 5457d88e..6c3e7206 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -27,6 +27,11 @@ BarWidget { // the widget ships on the bar and stays out of the way until there are two. // An older Hyprland that doesn't report the list keeps showing the label. property bool multipleLayouts: true + // Where the reading sits in the layout list, how long that list is, and every + // keyboard sharing it. A switch moves that set together, so it needs all three. + property int layoutIndex: 0 + property int layoutCount: 0 + property var syncNames: [] // Short language code per layout description ("English (US)": "en"), read from // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) @@ -63,17 +68,31 @@ BarWidget { } // switchxkblayout is a hyprctl command rather than a dispatcher, so it has to - // be run rather than sent over the dispatch socket. It switches the keyboard - // the last reading spoke for, so a click always advances the device the label - // is describing. Switching the seat together would reach the typed keyboard - // without having to name it, but it would also carry the buttons along, and - // the whole read depends on those staying where they started: once a button - // has been advanced too, a toggle that wraps the keyboard back to the first - // layout leaves the button reading as the furthest along, and the label - // follows the button. + // be run rather than sent over the dispatch socket. + // + // Move every keyboard holding the same layout list, rather than the single one + // the last reading spoke for. Naming one device puts the whole switch behind + // UNTYPED_KEYBOARDS recognising every non-keyboard by name, and that list + // cannot keep up with what a seat carries: vendor hotkey blocks + // (intel-hid-events, dell-wmi-hotkeys), HID consumer controls, and Bluetooth + // AVRCP endpoints from a pair of headphones all arrive holding the seat's + // layout list, and they sort ahead of the keyboard being typed on. The click + // then advances a device nobody types on; that device is now the furthest + // along, so it wins the next reading too, and the label describes it while the + // real keyboard never moved. + // + // An absolute index rather than "next", because "next" advances each device + // from wherever it already sits: a seat that has drifted apart stays drifted + // and merely inverts. One index converges them in a single click, and a seat + // in lockstep is what leaves the reading nothing to disagree about afterwards. + // + // Keyboards given their own kb_layout hold a different list and are left out: + // an index into this list would not mean the same layout to them. function cycleLayout() { - if (!root.keyboardName || !root.bar) return - root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next") + if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return + const next = (root.layoutIndex + 1) % root.layoutCount + root.bar.run(root.syncNames.map(name => + "hyprctl switchxkblayout " + Util.shellQuote(name) + " " + next).join("; ")) refreshTimer.restart() } @@ -149,6 +168,14 @@ BarWidget { root.keyboardCount = typed.length root.keyboardName = String(kb.name || "") root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1 + root.layoutIndex = kb.active_layout_index || 0 + root.layoutCount = kb.layout === undefined ? 0 : String(kb.layout).split(",").length + // Buttons and virtual keyboards are included on purpose: they hold the + // same list, and leaving them behind is what lets a reading drift onto + // one of them later. + root.syncNames = listed.filter(k => String(k.layout) === String(kb.layout)) + .map(k => String(k.name || "")) + .filter(name => name !== "") root.layoutFull = kb.active_keymap } } diff --git a/shell/plugins/image-picker/list.sh b/shell/plugins/image-picker/list.sh index a30e4743..0fb74117 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" +} - if [[ ! -f $thumbnail ]]; then +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 ]] && ! 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 c2deae0f..e7a430ac 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 9ecb1cc0..94d43b68 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/lock/manifest.json b/shell/plugins/lock/manifest.json index 87acd939..30809608 100644 --- a/shell/plugins/lock/manifest.json +++ b/shell/plugins/lock/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Quickshell session lock with separate password and fingerprint PAM flows.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index be5d08a0..ec3990e2 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -119,7 +119,9 @@ Panel { // Summoning by hotkey moves no pointer, so a hover the bar was still // holding must not keep the center indicators revealed behind the panel. function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index b4c84c68..5ed2c0d6 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -14,9 +14,25 @@ function wifiIconFor(strength) { return icons[index] } -function connectionIcon(kind, signalStrength) { - if (kind === "wifi") return wifiIconFor(signalStrength) - if (kind === "ethernet") return "󰈀" +// A known plain-HTTP endpoint lets the network redirect the browser to its +// login page. Never execute or automatically open an untrusted Location header. +var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt" + +function connectivityState(kind, connectivity, states, checksEnabled) { + if (kind === "disconnected") return "none" + // Ignore stale cached results when the operator has disabled probing. + if (!checksEnabled) return "unknown" + if (connectivity === states.Portal) return "portal" + if (connectivity === states.Limited) return "limited" + if (connectivity === states.Full) return "full" + if (connectivity === states.None) return "none" + return "unknown" +} + +function connectionIcon(kind, signalStrength, connectivity) { + var restricted = connectivity === "portal" || connectivity === "limited" + if (kind === "wifi") return restricted ? "󰤩" : wifiIconFor(signalStrength) + if (kind === "ethernet") return restricted ? "󰈂" : "󰈀" return "󰤮" } @@ -352,6 +368,8 @@ if (typeof module !== "undefined") { parseNetworkStatus: parseNetworkStatus, wifiIconFor: wifiIconFor, connectionIcon: connectionIcon, + connectivityState: connectivityState, + captivePortalUrl: captivePortalUrl, formatHeaderSpeed: formatHeaderSpeed, formatHeaderFreq: formatHeaderFreq, headerDetail: headerDetail, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index d1e41149..97729dcc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -119,9 +119,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move + // header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" + property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -220,6 +220,8 @@ Panel { // network target; both cards are their own plugins now. function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } + function openCaptivePortal() { root.openCaptivePortal() } + function checkConnectivity() { root.checkConnectivity() } } function activateHeader() { @@ -322,11 +324,11 @@ Panel { refresh(true) selectedIndex = wifiNetworks.length > 0 ? 0 : -1 wifiActionFocused = false - focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" + focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns") var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 syncBandIndex() - cursorActive = false + cursorActive = hasCaptivePortal } else { // Drop a restart armed by this open: without it a close/reopen inside // the 100ms window reuses the running timer and re-enables the scanner @@ -450,7 +452,59 @@ Panel { Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: Model.connectionIcon(kind, signalStrength) + // NetworkManager performs the HTTP probe (including unexpected page bodies, + // not just redirects). Consume its native notifications rather than running + // a second curl loop or mistaking an ordinary timeout for a captive portal. + readonly property bool connectivityChecksEnabled: networkManagerAvailable + && Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, { + Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited, + Full: NetworkConnectivity.Full, None: NetworkConnectivity.None + }, connectivityChecksEnabled) + readonly property bool hasCaptivePortal: connectivity === "portal" + readonly property bool restricted: hasCaptivePortal || connectivity === "limited" + readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity) + readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork + ? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name + : (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "") + + onConnectionKeyChanged: Qt.callLater(checkConnectivity) + onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity) + onHasCaptivePortalChanged: { + if (hasCaptivePortal && opened && passwordSsid === "") { + focusSection = "portal" + cursorActive = true + } else if (!hasCaptivePortal && focusSection === "portal") { + focusSection = headerActionCount > 0 ? "header" : "dns" + headerIndex = 0 + } + } + onRestrictedChanged: { + connectionPhraseSwap.stop() + heroMeta.opacity = 1.0 + } + + function checkConnectivity() { + if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity() + } + + function openCaptivePortal() { + if (!hasCaptivePortal) return + // Explicit user action only. argv (not a shell string), and a fixed HTTP + // URL: let the browser handle the redirect without trusting portal input. + Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl]) + close() + } + + // Keep checking while login is needed, even with the panel closed in favour + // of the browser. Normal connected operation relies on NM's own schedule. + Timer { + id: connectivityPoll + interval: 10000 + repeat: true + running: root.restricted && root.connectivityChecksEnabled + onTriggered: root.checkConnectivity() + } // The share card is its own panel plugin (omarchy.wifiqr) so a replacement // design can take it over; summon() routes to whichever implementation is @@ -469,6 +523,7 @@ Panel { } function refresh(scanWifi) { + checkConnectivity() if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true if (!dnsProc.running) { @@ -901,7 +956,7 @@ Panel { Timer { id: connectionPhraseTimer interval: 2800 - running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) + running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) repeat: true onTriggered: connectionPhraseSwap.restart() } @@ -958,6 +1013,9 @@ Panel { anchors.fill: parent bar: root.bar text: root.icon + active: root.restricted + tooltipText: root.hasCaptivePortal ? "Sign in to this network" + : (root.restricted ? "Limited internet access" : "") onPressed: function(b) { if (root.opened) root.close() @@ -1001,23 +1059,34 @@ Panel { if (dy >= 0) return } if (dy !== 0) { - // Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section - // dropping out of the chain entirely when it isn't on screen. + // Hidden sections drop out of the keyboard chain entirely. if (root.focusSection === "header") { if (dy > 0) { - if (root.canSelectBand) { + if (root.hasCaptivePortal) { + root.focusSection = "portal" + } else if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = true } else { root.focusSection = "dns" } } + } else if (root.focusSection === "portal") { + if (dy < 0 && root.headerActionCount > 0) { + root.focusSection = "header" + root.headerIndex = 0 + } else if (dy > 0) { + root.focusSection = root.canSelectBand ? "band" : "dns" + root.bandAutoFocused = true + } } else if (root.focusSection === "band") { // Automatic on the header line, then the pills -- which collapse // away under Automatic, leaving a single row to walk. if (dy < 0) { if (!root.bandAutoFocused) { root.bandAutoFocused = true + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1035,6 +1104,8 @@ Panel { if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = !root.bandPillsVisible + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1063,6 +1134,7 @@ Panel { onActivateRequested: { if (root.cursorActive) { if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "portal") root.openCaptivePortal() else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "dns") root.activateDns() else root.activateSelected() @@ -1092,7 +1164,7 @@ Panel { id: heroIcon textFormat: Text.PlainText text: root.icon - color: root.bar.foreground + color: root.restricted ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.networkManagerAvailable ? 1.0 : 0.5 @@ -1175,6 +1247,9 @@ Panel { width: parent.width readonly property string title: { + // The HTTP restriction does not undo association. Show the live + // SSID even before route/details polling has returned anything. + if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "ethernet") return "Ethernet" return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") @@ -1194,6 +1269,8 @@ Panel { textFormat: Text.PlainText width: parent.width text: { + if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" + if (root.restricted) return "LIMITED INTERNET ACCESS" if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() if (root.kind === "disconnected") return "NOT CONNECTED" @@ -1204,7 +1281,7 @@ Panel { return "" } visible: text !== "" - color: Qt.darker(root.bar.foreground, 1.4) + color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true @@ -1215,6 +1292,43 @@ Panel { } + Column { + visible: root.hasCaptivePortal + width: parent.width + spacing: Style.space(6) + + Button { + id: portalAction + width: parent.width + text: "Open Captive Portal" + iconText: "󰏌" + foreground: root.bar.urgent + accent: root.bar.urgent + fontFamily: root.bar.fontFamily + verticalPadding: Style.space(10) + bordered: true + active: true + hasCursor: root.cursorActive && root.focusSection === "portal" + onHovered: function(on) { + if (!on) return + root.cursorActive = true + root.focusSection = "portal" + } + onClicked: root.openCaptivePortal() + } + + Text { + width: parent.width + text: "Sign in or accept this network’s terms to access the internet." + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: root.bar.foreground + opacity: 0.7 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + // Connection details: transfer metrics first, then IP/Gateway. Column { visible: !!root.info.iface @@ -1654,6 +1768,7 @@ Panel { if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" if (isBusy && root.actionKind === "forget") return "Forgetting…" if (isFailed) return root.failureReason || "Failed" + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" if (isConnected) return "Connected" return "" } @@ -1661,6 +1776,7 @@ Panel { readonly property color statusColor: { if (isFailed) return root.bar.urgent if (isBusy) return root.bar.foreground + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent if (isConnected) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } @@ -1715,7 +1831,8 @@ Panel { Text { id: networkIcon textFormat: Text.PlainText - text: row.net ? root.wifiIconFor(row.net.signal) : "" + text: row.net ? Model.connectionIcon("wifi", row.net.signal, + row.isConnected && root.kind === "wifi" ? root.connectivity : "") : "" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.title diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index edb12777..d9d43b68 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -63,7 +63,9 @@ Panel { } function setCenterHoverRevealSuppressed(value) { - if (root.bar && "centerHoverRevealSuppressed" in root.bar) + if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") + root.bar.setCenterHoverRevealSuppressed(value) + else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } diff --git a/shell/plugins/polkit/manifest.json b/shell/plugins/polkit/manifest.json index 1e80b655..5d1f037e 100644 --- a/shell/plugins/polkit/manifest.json +++ b/shell/plugins/polkit/manifest.json @@ -5,6 +5,11 @@ "version": "1.0.0", "author": "Omarchy", "description": "Theme-aware authentication dialog for privileged actions.", + "omarchy": { + "capabilities": [ + "authentication" + ] + }, "kinds": [ "service" ], diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index 37fdf5c7..a1b01cff 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/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 488f2961..1453920d 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -16,7 +16,8 @@ Item { readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake" readonly property int defaultScreensaverSeconds: 150 readonly property int defaultLockSeconds: 300 - readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle ? shell.shellConfig.idle : ({}) + readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle + ? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({})) readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds) readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds) readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds) diff --git a/shell/services/AuthServiceStore.js b/shell/services/AuthServiceStore.js new file mode 100644 index 00000000..d3eb5a66 --- /dev/null +++ b/shell/services/AuthServiceStore.js @@ -0,0 +1,45 @@ +// Intentionally not `.pragma library`: QML JavaScript imports get a private +// module instance per importing component. shell.qml's instance retains the +// authentication services; a third-party plugin importing this file receives +// a separate empty store rather than a shared path to credential-bearing QML. + +var services = ({}) +var trustedIds = ({}) + +function has(id) { + return services[String(id || "")] !== undefined +} + +function put(id, service) { + var key = String(id || "") + if (!key || !service) return + trustedIds[key] = true + if (services[key] && services[key] !== service && typeof services[key].destroy === "function") + services[key].destroy() + services[key] = service +} + +function isTrusted(id) { + return trustedIds[String(id || "")] === true +} + +function ids() { + return Object.keys(services) +} + +function updateManifest(id, manifest) { + var service = services[String(id || "")] + if (service && "manifest" in service) service.manifest = manifest +} + +function destroy(id) { + var key = String(id || "") + var service = services[key] + if (service && typeof service.destroy === "function") service.destroy() + delete services[key] +} + +function destroyAll() { + var keys = ids() + for (var i = 0; i < keys.length; i++) destroy(keys[i]) +} diff --git a/shell/services/PluginAppLibraryApi.qml b/shell/services/PluginAppLibraryApi.qml new file mode 100644 index 00000000..00d5e8f0 --- /dev/null +++ b/shell/services/PluginAppLibraryApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Detached application-library capability for third-party menus. Callbacks +// expose the supported app-list operations without retaining AppLibrary or its +// ShellRoot parent in the plugin-visible object graph. +QtObject { + required property string ownerPluginId + + signal appsChanged() + + property var _entryName: null + property var _entrySubtext: null + property var _sortedEntries: null + property var _iconSource: null + property var _refreshIcons: null + property var _launch: null + property var _remove: null + + function entryName(entry) { + return _entryName ? _entryName(entry) : "" + } + + function entrySubtext(entry) { + return _entrySubtext ? _entrySubtext(entry) : "" + } + + function sortedEntries(query) { + return _sortedEntries ? _sortedEntries(String(query || "")) : [] + } + + function iconSource(icon) { + return _iconSource ? _iconSource(icon) : "" + } + + function refreshIcons() { + if (_refreshIcons) _refreshIcons() + } + + function launch(desktopId, name) { + if (_launch) _launch(String(desktopId || ""), String(name || "")) + } + + function remove(desktopId, name) { + if (_remove) _remove(String(desktopId || ""), String(name || "")) + } +} diff --git a/shell/services/PluginBarStateApi.qml b/shell/services/PluginBarStateApi.qml new file mode 100644 index 00000000..db802b77 --- /dev/null +++ b/shell/services/PluginBarStateApi.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Scalar-only view of the active bar for plugins that position independent +// windows. The active Bar QObject is never retained here. +QtObject { + required property string ownerPluginId + + property bool barHidden: false + property int barSize: 0 + property string fontFamily: "" + property string position: "top" +} diff --git a/shell/services/PluginBarWidgetRegistryApi.qml b/shell/services/PluginBarWidgetRegistryApi.qml new file mode 100644 index 00000000..238729f1 --- /dev/null +++ b/shell/services/PluginBarWidgetRegistryApi.qml @@ -0,0 +1,24 @@ +import QtQuick + +// Detached widget-catalogue snapshot for third-party full-bar implementations. +// Plugins can render the referenced components, but mutating this local view +// cannot replace a registration in the host registry. +QtObject { + id: api + + property var widgets: ({}) + property int revision: 0 + + function metadataFor(id) { + var entry = widgets[String(id || "")] + return entry ? entry.metadata : null + } + + function availableIds() { + return Object.keys(widgets) + } + + function has(id) { + return widgets[String(id || "")] !== undefined + } +} diff --git a/shell/services/PluginFirstPartyServiceApi.qml b/shell/services/PluginFirstPartyServiceApi.qml new file mode 100644 index 00000000..1f052629 --- /dev/null +++ b/shell/services/PluginFirstPartyServiceApi.qml @@ -0,0 +1,46 @@ +import QtQuick + +// Narrow proxy for the non-authentication first-party services used by the +// built-in bar. It intentionally has no generic property or method forwarding. +QtObject { + required property string ownerPluginId + required property string serviceId + + property bool stayAwake: false + property bool enabled: false + property bool doNotDisturb: false + property var activePlayer: null + property var sourcePlayers: [] + + property var _setIdleEnabled: null + property var _setNightlight: null + property var _setDoNotDisturb: null + property var _runAction: null + property var _playerKey: null + property var _selectPlayer: null + + function setIdleEnabled(value) { + if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value) + } + + function setNightlight(value) { + if (serviceId === "omarchy.nightlight" && _setNightlight) _setNightlight(!!value) + } + + function setDoNotDisturb(value) { + if (serviceId === "omarchy.notifications" && _setDoNotDisturb) _setDoNotDisturb(!!value) + } + + function runAction(action, showFeedback, playerId) { + if (serviceId === "omarchy.media" && _runAction) + _runAction(String(action || ""), !!showFeedback, String(playerId || "")) + } + + function playerKey(player) { + return serviceId === "omarchy.media" && _playerKey ? _playerKey(player) : "" + } + + function selectPlayer(playerId) { + if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || "")) + } +} diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index f08bf765..e8b8ad36 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -20,7 +20,7 @@ QtObject { property var shellConfigProvider: null property var shellConfigMutator: null - // { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in. + // { pluginId: manifest } — manifests have source/trust metadata stamped in. property var installedPlugins: ({}) property int registryRevision: 0 property bool scanning: false @@ -78,8 +78,7 @@ QtObject { } } // Every entry point must be a relative path inside the plugin's source - // directory. Reject the whole manifest if anything looks like an attempt - // to escape the plugin's sandbox. + // directory. Reject the whole manifest if an entry point escapes it. for (var key in manifest.entryPoints) { if (!isSafeEntryPoint(manifest.entryPoints[key])) { console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='" @@ -90,6 +89,32 @@ QtObject { return manifest } + function trustedCapabilities(manifest) { + if (!manifest || !manifest.__isFirstParty) return [] + var metadata = Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var declared = metadata && Array.isArray(metadata.capabilities) ? metadata.capabilities : [] + var out = [] + for (var i = 0; i < declared.length; i++) { + var capability = String(declared[i] || "") + if (capability && out.indexOf(capability) === -1) out.push(capability) + } + return out + } + + function stampHostCapabilities(firstParty, thirdParty) { + for (var firstPartyId in firstParty) + firstParty[firstPartyId].__hostCapabilities = trustedCapabilities(firstParty[firstPartyId]) + + for (var thirdPartyId in thirdParty) { + var manifest = thirdParty[thirdPartyId] + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var clonedFrom = metadata ? String(metadata.clonedFrom || "") : "" + var source = clonedFrom ? firstParty[clonedFrom] : null + manifest.__hostCapabilities = source && Array.isArray(source.__hostCapabilities) + ? source.__hostCapabilities.slice() : [] + } + } + function entryPointUrl(manifest, kind) { if (!Util.isPlainObject(manifest)) return "" var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null @@ -594,6 +619,8 @@ QtObject { } flush() + stampHostCapabilities(firstParty, thirdParty) + var merged = {} for (var fk in firstParty) merged[fk] = firstParty[fk] // Third-party plugins never shadow first-party ids. The whole diff --git a/shell/services/PluginRegistryApi.qml b/shell/services/PluginRegistryApi.qml new file mode 100644 index 00000000..32033c2b --- /dev/null +++ b/shell/services/PluginRegistryApi.qml @@ -0,0 +1,32 @@ +import QtQuick + +// Read-only, self-scoped registry view for an installed third-party plugin. +// The host updates manifest/enabled when it rescans; no host registry object is +// retained here, so `parent` and property traversal cannot reach ShellRoot. +QtObject { + id: api + + required property string pluginId + property var manifest: null + property bool enabled: false + property var _entryPointUrl: null + + readonly property var installedPlugins: { + var out = ({}) + if (manifest) out[pluginId] = manifest + return out + } + + function isEnabled(id) { + return String(id || "") === pluginId && enabled + } + + function resolveEnabledId(id) { + return String(id || "") === pluginId ? pluginId : "" + } + + function entryPointUrl(candidate, kind) { + if (!candidate || String(candidate.id || "") !== pluginId) return "" + return _entryPointUrl ? _entryPointUrl(String(kind || "")) : "" + } +} diff --git a/shell/services/PluginShellApi.qml b/shell/services/PluginShellApi.qml new file mode 100644 index 00000000..0202aa01 --- /dev/null +++ b/shell/services/PluginShellApi.qml @@ -0,0 +1,70 @@ +import QtQuick + +// Capability-scoped shell surface for installed third-party plugins. +// +// The callbacks are closed over one plugin id by shell.qml. A plugin can call +// them directly, but it cannot widen their scope: ordinary plugins are limited +// to their own id, and full-bar callbacks independently enforce their explicit +// non-authentication UI scope. This object avoids directly injecting the host +// shell, but it is not a QML sandbox: visual plugins share the host object tree. +QtObject { + id: api + + required property string pluginId + + property var appLibrary: null + property var bar: null + property var barConfig: ({}) + property var idleConfig: ({}) + + property var _serviceLookup: null + property var _firstPartyServiceLookup: null + property var _barEntryShellLookup: null + property var _summon: null + property var _hide: null + property var _toggle: null + property var _isOpen: null + property var _updateSettings: null + property var _mutateBarConfig: null + + function serviceFor(id) { + return _serviceLookup ? _serviceLookup(String(id || "")) : null + } + + // Only full-bar facades receive narrow proxies for the specific + // non-authentication services used by the built-in bar widgets. + function firstPartyServiceFor(id) { + return _firstPartyServiceLookup + ? _firstPartyServiceLookup(String(id || "")) : null + } + + function pluginShellForBarEntry(ownerId, moduleName) { + return _barEntryShellLookup + ? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null + } + + function summon(id, payloadJson) { + return _summon ? _summon(String(id || ""), String(payloadJson || "")) : false + } + + function hide(id) { + return _hide ? _hide(String(id || "")) : false + } + + function toggle(id, payloadJson) { + return _toggle ? _toggle(String(id || ""), String(payloadJson || "")) : false + } + + function isPluginOpen(id) { + return _isOpen ? _isOpen(String(id || "")) : false + } + + function updateEntryInline(id, settings) { + return _updateSettings ? _updateSettings(String(id || ""), settings) : false + } + + function mutateShellConfig(mutator) { + return _mutateBarConfig && typeof mutator === "function" + ? _mutateBarConfig(mutator) : false + } +} diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..82080624 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -7,6 +7,7 @@ import qs.Commons import "plugins/bar" import "services" +import "services/AuthServiceStore.js" as AuthServiceStore ShellRoot { id: shell @@ -113,7 +114,10 @@ ShellRoot { } readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar - onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig + onBarConfigChanged: { + if (bar && "barConfig" in bar) + bar.barConfig = shell.barConfigFor(shell.activeBarManifest) + } FileView { id: defaultsFile path: shell.defaultsPath @@ -214,11 +218,11 @@ ShellRoot { function configureBar(target, manifest) { if (!target) return if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath - if ("shell" in target) target.shell = shell - if ("manifest" in target) target.manifest = manifest - if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry - if ("barConfig" in target) target.barConfig = shell.barConfig + if ("shell" in target) target.shell = shell.pluginShellFor(manifest) + if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest) + if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest) shell.bar = target } @@ -253,8 +257,7 @@ ShellRoot { onActiveChanged: if (!active) shell.bar = null onStatusChanged: { if (status === Loader.Error) { - var detail = errorString && errorString() ? errorString() : "" - console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail) + console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId) shell.failedBarId = shell.activeBarId } } @@ -271,13 +274,617 @@ ShellRoot { } property var _services: ({}) + property var _pluginShellApis: ({}) + property var _pluginShellApiDescriptors: ({}) + property var _pluginBarEntryShellApis: ({}) + property var _pluginRegistryApis: ({}) + property var _pluginBarWidgetRegistryApis: ({}) + property var _pluginAppLibraryApis: ({}) + property var _pluginBarStateApis: ({}) + property var _pluginFirstPartyServiceApis: ({}) + + Component { + id: pluginShellApiComponent + PluginShellApi { } + } + + Component { + id: pluginRegistryApiComponent + PluginRegistryApi { } + } + + Component { + id: pluginBarWidgetRegistryApiComponent + PluginBarWidgetRegistryApi { } + } + + Component { + id: pluginAppLibraryApiComponent + PluginAppLibraryApi { } + } + + Component { + id: pluginBarStateApiComponent + PluginBarStateApi { } + } + + Component { + id: pluginFirstPartyServiceApiComponent + PluginFirstPartyServiceApi { } + } + + function publicPluginManifest(manifest) { + if (!manifest) return null + if (manifest.__isFirstParty) return manifest + var copy = JSON.parse(JSON.stringify(manifest)) + delete copy.__sourceDir + delete copy.__isFirstParty + delete copy.__hostCapabilities + return copy + } + + function publicBarConfig() { + return JSON.parse(JSON.stringify(shell.barConfig || {})) + } + + function barConfigFor(manifest) { + return !manifest || manifest.__isFirstParty + ? shell.barConfig : shell.publicBarConfig() + } + + function publicBarWidgetSnapshot() { + var source = shell.barWidgetRegistry.widgets || {} + var snapshot = {} + for (var id in source) { + var entry = source[id] + if (!entry) continue + snapshot[id] = { + component: entry.component, + metadata: JSON.parse(JSON.stringify(entry.metadata || {})) + } + } + return snapshot + } + + function manifestHasKind(manifest, kind) { + return !!manifest && Array.isArray(manifest.kinds) + && manifest.kinds.indexOf(kind) !== -1 + } + + function pluginHasBarCapabilities(manifest) { + return shell.manifestHasKind(manifest, "bar") + } + + function publicIdleConfigFor(manifest) { + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + if (!metadata || String(metadata.clonedFrom || "") !== "omarchy.idle") return ({}) + var idle = shell.shellConfig && Util.isPlainObject(shell.shellConfig.idle) + ? shell.shellConfig.idle : ({}) + return JSON.parse(JSON.stringify(idle)) + } + + function pluginCloneMaySummon(manifest, requestedId) { + var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null + var sourceId = metadata ? String(metadata.clonedFrom || "") : "" + var allowed = { + "omarchy.audio": ["omarchy.osd"], + "omarchy.media": ["omarchy.osd"], + "omarchy.monitor": ["omarchy.osd"], + "omarchy.network": ["omarchy.speedtest", "omarchy.wifiqr"] + } + var targets = allowed[sourceId] || [] + return targets.indexOf(String(requestedId || "")) !== -1 + } + + function pluginOwnsTarget(pluginId, requestedId) { + var caller = String(pluginId || "") + if (!caller) return false + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) === caller + } + + function pluginServiceFor(pluginId, requestedId) { + if (!shell.pluginOwnsTarget(pluginId, requestedId)) return null + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(requestedId)) + } + + function barEntryConfigured(pluginId) { + var location = shell.pluginRegistry.findEntryLocation(shell.shellConfig, pluginId) + return location && location.kind === "bar" + } + + function barPluginMayControl(manifest, requestedId) { + if (!shell.pluginHasBarCapabilities(manifest)) return false + var id = shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + var target = shell.pluginRegistry.installedPlugins[id] + if (!target || shell.isAuthenticationService(target, id)) return false + if (shell.barEntryConfigured(id)) return true + var uiKinds = ["bar-widget", "panel", "overlay", "menu"] + for (var i = 0; i < uiKinds.length; i++) + if (shell.manifestHasKind(target, uiKinds[i])) return true + return false + } + + function mutatePluginBarConfig(mutator) { + if (typeof mutator !== "function") return false + shell.mutateShellConfig(function(config) { + var scoped = { bar: JSON.parse(JSON.stringify(config.bar || {})) } + mutator(scoped) + if (Util.isPlainObject(scoped.bar)) config.bar = JSON.parse(JSON.stringify(scoped.bar)) + }) + return true + } + + function pluginAppLibraryFor(cacheKey, pluginId) { + if (_pluginAppLibraryApis[cacheKey]) return _pluginAppLibraryApis[cacheKey] + var api = pluginAppLibraryApiComponent.createObject(null, { + ownerPluginId: pluginId, + _entryName: function(entry) { return shell.appLibrary.entryName(entry) }, + _entrySubtext: function(entry) { return shell.appLibrary.entrySubtext(entry) }, + _sortedEntries: function(query) { return shell.appLibrary.sortedEntries(query) }, + _iconSource: function(icon) { return shell.appLibrary.iconSource(icon) }, + _refreshIcons: function() { shell.appLibrary.refreshIcons() }, + _launch: function(desktopId, name) { shell.appLibrary.launch(desktopId, name) }, + _remove: function(desktopId, name) { shell.appLibrary.remove(desktopId, name) } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginAppLibraryApis) next[id] = _pluginAppLibraryApis[id] + next[cacheKey] = api + _pluginAppLibraryApis = next + return api + } + + function pluginBarStateFor(cacheKey, pluginId) { + if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey] + var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId }) + if (!api) return null + api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false }) + api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 }) + api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" }) + api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" }) + var next = ({}) + for (var id in _pluginBarStateApis) next[id] = _pluginBarStateApis[id] + next[cacheKey] = api + _pluginBarStateApis = next + return api + } + + function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) { + var id = String(requestedId || "") + var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + if (allowed.indexOf(id) === -1) return null + var proxyKey = cacheKey + "::" + id + if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] + + function service() { + return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id)) + } + var api = pluginFirstPartyServiceApiComponent.createObject(null, { + ownerPluginId: pluginId, + serviceId: id, + _setIdleEnabled: function(value) { + var target = service() + if (target && typeof target.setIdleEnabled === "function") target.setIdleEnabled(value) + }, + _setNightlight: function(value) { + var target = service() + if (target && typeof target.setNightlight === "function") target.setNightlight(value) + }, + _setDoNotDisturb: function(value) { + var target = service() + if (target && typeof target.setDoNotDisturb === "function") target.setDoNotDisturb(value) + }, + _runAction: function(action, showFeedback, targetKey) { + var target = service() + if (target && typeof target.runAction === "function") target.runAction(action, showFeedback, targetKey) + }, + _playerKey: function(player) { + var target = service() + return target && typeof target.playerKey === "function" ? target.playerKey(player) : "" + }, + _selectPlayer: function(playerKey) { + var target = service() + if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey) + } + }) + if (!api) return null + api.stayAwake = Qt.binding(function() { + var target = service() + return target ? target.stayAwake === true : false + }) + api.enabled = Qt.binding(function() { + var target = service() + return target ? target.enabled === true : false + }) + api.doNotDisturb = Qt.binding(function() { + var target = service() + return target ? target.doNotDisturb === true : false + }) + api.activePlayer = Qt.binding(function() { + var target = service() + return target ? target.activePlayer : null + }) + api.sourcePlayers = Qt.binding(function() { + var target = service() + return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : [] + }) + var next = ({}) + for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing] + next[proxyKey] = api + _pluginFirstPartyServiceApis = next + return api + } + + function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) { + return [ + allowOwnService ? "own-service" : "no-own-service", + barCapabilities ? "bar" : "no-bar", + shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu" + ].join("|") + } + + function cacheWithoutKey(cache, key, destroyValue) { + var next = ({}) + for (var existing in cache) { + if (existing === key) { + var value = cache[existing] + if (destroyValue && value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function cacheWithoutPrefix(cache, prefix) { + var next = ({}) + for (var existing in cache) { + if (existing.indexOf(prefix) === 0) { + var value = cache[existing] + if (value && typeof value.destroy === "function") value.destroy() + } else { + next[existing] = cache[existing] + } + } + return next + } + + function revokePluginShellApi(cacheKey) { + var key = String(cacheKey || "") + if (!key) return + _pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true) + _pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::") + _pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":") + _pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true) + _pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false) + } + + function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) { + var key = String(manifest && manifest.id || "") + if (!key) return null + var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) + var cached = _pluginShellApis[cacheKey] + var descriptor = _pluginShellApiDescriptors[cacheKey] + if (cached && descriptor && descriptor.pluginId === key + && descriptor.profile === profile) return cached + if (cached || descriptor) shell.revokePluginShellApi(cacheKey) + + function currentManifest() { + return shell.pluginRegistry.installedPlugins[key] || null + } + + function hasCurrentBarCapabilities() { + return barCapabilities && shell.pluginHasBarCapabilities(currentManifest()) + } + + // Construct the narrow service proxies before any plugin binding can call + // firstPartyServiceFor(). Creating a QObject while evaluating that binding + // makes QML re-enter the binding and report a loop on the caller's service + // property, even though the resulting proxy is otherwise acyclic. + var firstPartyServices = ({}) + if (barCapabilities) { + var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + for (var i = 0; i < serviceIds.length; i++) { + var serviceId = serviceIds[i] + firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId) + } + } + + var api = pluginShellApiComponent.createObject(null, { + pluginId: key, + appLibrary: shell.manifestHasKind(manifest, "menu") + ? shell.pluginAppLibraryFor(cacheKey, key) : null, + bar: shell.pluginBarStateFor(cacheKey, key), + barConfig: shell.publicBarConfig(), + idleConfig: shell.publicIdleConfigFor(manifest), + _serviceLookup: function(requestedId) { + return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null + }, + _firstPartyServiceLookup: function(requestedId) { + if (allowOwnService && shell.pluginOwnsTarget(key, requestedId)) + return shell.pluginServiceFor(key, requestedId) + return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null + }, + _barEntryShellLookup: function(ownerId, moduleName) { + return hasCurrentBarCapabilities() + ? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null + }, + _summon: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId) + && !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false + return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _hide: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _toggle: function(requestedId, payloadJson) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _isOpen: function(requestedId) { + if (!shell.pluginOwnsTarget(key, requestedId) + && !shell.barPluginMayControl(currentManifest(), requestedId)) return false + return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId)) + }, + _updateSettings: function(requestedId, settings) { + if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings) + if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId)) + return shell.updateEntryInline(requestedId, settings) + return false + }, + _mutateBarConfig: function(mutator) { + return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginShellApis) next[id] = _pluginShellApis[id] + next[cacheKey] = api + _pluginShellApis = next + var descriptorNext = ({}) + for (var descriptorKey in _pluginShellApiDescriptors) + descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey] + descriptorNext[cacheKey] = { + pluginId: key, + allowOwnService: allowOwnService === true, + profile: profile + } + _pluginShellApiDescriptors = descriptorNext + return api + } + + function scopedPluginShellForId(pluginId) { + var key = String(pluginId || "") + var manifest = shell.pluginRegistry.installedPlugins[key] + if (!manifest) return null + if (!manifest.__isFirstParty) return shell.pluginShellFor(manifest) + return shell.createScopedPluginShell(manifest, "hosted:" + key, false, false) + } + + function pluginShellForId(pluginId) { + return shell.scopedPluginShellForId(pluginId) + } + + function pluginShellForBarEntry(ownerId, moduleName) { + var owner = String(ownerId || "") + var target = String(moduleName || "") + if (!owner || !target) return null + if (!shell.barEntryConfigured(target)) return null + var cacheKey = owner + "::" + target + if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey] + + function owns(requestedId) { + return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) + === shell.pluginRegistry.resolveEnabledId(target) + } + + function currentManifest() { + var id = shell.pluginRegistry.resolveEnabledId(target) + return shell.pluginRegistry.installedPlugins[id] || null + } + + var api = pluginShellApiComponent.createObject(null, { + pluginId: target, + barConfig: shell.publicBarConfig(), + _summon: function(requestedId, payloadJson) { + if (!owns(requestedId) + && !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false + return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson) + }, + _hide: function(requestedId) { + return owns(requestedId) + ? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _toggle: function(requestedId, payloadJson) { + return owns(requestedId) + ? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false + }, + _isOpen: function(requestedId) { + return owns(requestedId) + ? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false + }, + _updateSettings: function(requestedId, settings) { + return String(requestedId || "") === target + ? shell.updateEntryInline(target, settings) : false + } + }) + if (!api) return null + var next = ({}) + for (var id in _pluginBarEntryShellApis) next[id] = _pluginBarEntryShellApis[id] + next[cacheKey] = api + _pluginBarEntryShellApis = next + return api + } + + function pluginShellFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell + var key = String(manifest.id || "") + if (!key) return null + return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest)) + } + + function pluginRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.pluginRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginRegistryApis[key]) return _pluginRegistryApis[key] + + var api = pluginRegistryApiComponent.createObject(null, { + pluginId: key, + manifest: shell.publicPluginManifest(manifest), + enabled: shell.pluginRegistry.isEnabled(key), + _entryPointUrl: function(kind) { + var current = shell.pluginRegistry.installedPlugins[key] + return current ? shell.pluginRegistry.entryPointUrl(current, kind) : "" + } + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginRegistryApis) next[id] = _pluginRegistryApis[id] + next[key] = api + _pluginRegistryApis = next + return api + } + + function pluginBarWidgetRegistryFor(manifest) { + if (!manifest || manifest.__isFirstParty) return shell.barWidgetRegistry + var key = String(manifest.id || "") + if (!key) return null + if (_pluginBarWidgetRegistryApis[key]) return _pluginBarWidgetRegistryApis[key] + + var api = pluginBarWidgetRegistryApiComponent.createObject(null, { + widgets: shell.publicBarWidgetSnapshot(), + revision: shell.barWidgetRegistry.revision + }) + if (!api) return null + + var next = ({}) + for (var id in _pluginBarWidgetRegistryApis) next[id] = _pluginBarWidgetRegistryApis[id] + next[key] = api + _pluginBarWidgetRegistryApis = next + return api + } + + function pluginApiActive(api, plugins) { + var id = api ? String(api.pluginId || api.ownerPluginId || "") : "" + var manifest = id ? plugins[id] : null + return !!manifest && shell.pluginRegistry.isEnabled(id) + } + + function prunePluginApis() { + var plugins = shell.pluginRegistry.installedPlugins + var shellKeys = Object.keys(_pluginShellApis) + for (var si = 0; si < shellKeys.length; si++) { + var shellKey = shellKeys[si] + var shellApi = _pluginShellApis[shellKey] + var descriptor = _pluginShellApiDescriptors[shellKey] + var manifest = descriptor ? plugins[descriptor.pluginId] : null + var barCapabilities = descriptor && descriptor.allowOwnService + && shell.pluginHasBarCapabilities(manifest) + var expectedProfile = descriptor + ? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : "" + var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId) + if (!active || descriptor.profile !== expectedProfile) + shell.revokePluginShellApi(shellKey) + } + + var registryNext = ({}) + for (var registryKey in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[registryKey] + if (shell.pluginApiActive(registryApi, plugins)) registryNext[registryKey] = registryApi + else if (registryApi && typeof registryApi.destroy === "function") registryApi.destroy() + } + _pluginRegistryApis = registryNext + + var widgetNext = ({}) + for (var widgetKey in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetKey] + if (plugins[widgetKey] && shell.pluginRegistry.isEnabled(widgetKey)) widgetNext[widgetKey] = widgetApi + else if (widgetApi && typeof widgetApi.destroy === "function") widgetApi.destroy() + } + _pluginBarWidgetRegistryApis = widgetNext + + var appNext = ({}) + for (var appKey in _pluginAppLibraryApis) { + var appApi = _pluginAppLibraryApis[appKey] + if (shell.pluginApiActive(appApi, plugins)) appNext[appKey] = appApi + else if (appApi && typeof appApi.destroy === "function") appApi.destroy() + } + _pluginAppLibraryApis = appNext + + var barStateNext = ({}) + for (var barStateKey in _pluginBarStateApis) { + var barStateApi = _pluginBarStateApis[barStateKey] + if (shell.pluginApiActive(barStateApi, plugins)) barStateNext[barStateKey] = barStateApi + else if (barStateApi && typeof barStateApi.destroy === "function") barStateApi.destroy() + } + _pluginBarStateApis = barStateNext + + var serviceNext = ({}) + for (var serviceKey in _pluginFirstPartyServiceApis) { + var serviceApi = _pluginFirstPartyServiceApis[serviceKey] + if (shell.pluginApiActive(serviceApi, plugins)) serviceNext[serviceKey] = serviceApi + else if (serviceApi && typeof serviceApi.destroy === "function") serviceApi.destroy() + } + _pluginFirstPartyServiceApis = serviceNext + + var entryNext = ({}) + for (var entryKey in _pluginBarEntryShellApis) { + var entryApi = _pluginBarEntryShellApis[entryKey] + if (entryApi && shell.barEntryConfigured(entryApi.pluginId)) entryNext[entryKey] = entryApi + else if (entryApi && typeof entryApi.destroy === "function") entryApi.destroy() + } + _pluginBarEntryShellApis = entryNext + } + + function syncPluginApis() { + shell.prunePluginApis() + var plugins = shell.pluginRegistry.installedPlugins + for (var id in _pluginRegistryApis) { + var registryApi = _pluginRegistryApis[id] + var manifest = plugins[id] + registryApi.manifest = shell.publicPluginManifest(manifest) + registryApi.enabled = !!manifest && shell.pluginRegistry.isEnabled(id) + } + for (var widgetId in _pluginBarWidgetRegistryApis) { + var widgetApi = _pluginBarWidgetRegistryApis[widgetId] + widgetApi.widgets = shell.publicBarWidgetSnapshot() + widgetApi.revision = shell.barWidgetRegistry.revision + } + for (var shellKey in _pluginShellApis) { + var shellApi = _pluginShellApis[shellKey] + var descriptor = _pluginShellApiDescriptors[shellKey] + var shellManifest = descriptor ? plugins[descriptor.pluginId] : null + shellApi.barConfig = shell.publicBarConfig() + shellApi.idleConfig = shell.publicIdleConfigFor(shellManifest) + } + for (var entryKey in _pluginBarEntryShellApis) + _pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig() + } + + // 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 } function firstPartyServiceFor(pluginId) { - return serviceFor(pluginId) + return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId)) + } + + function isAuthenticationService(manifest, pluginId) { + var key = String(pluginId || (manifest && manifest.id) || "") + return AuthServiceStore.isTrusted(key) + || (!!manifest && Array.isArray(manifest.__hostCapabilities) + && manifest.__hostCapabilities.indexOf("authentication") !== -1) } function ensureService(pluginId) { @@ -290,6 +897,8 @@ ShellRoot { if (!manifest.entryPoints || !manifest.entryPoints.service) return null var url = pluginRegistry.entryPointUrl(manifest, "service") if (!url) return null + var authenticationService = shell.isAuthenticationService(manifest, key) + if (authenticationService && AuthServiceStore.has(key)) return null var comp = Qt.createComponent(url, Component.PreferSynchronous) function finalize() { @@ -297,27 +906,37 @@ ShellRoot { console.warn("service plugin load failed for " + key + ": " + comp.errorString()) return } - var inst = comp.createObject(serviceHost) + // Authentication services and third-party services have no visual + // parent. Parenting either to serviceHost would let a plugin's object + // traversal walk between the host and credential-bearing QML. + var inst = comp.createObject(manifest.__isFirstParty && !authenticationService ? serviceHost : null) if (!inst) { console.warn("service plugin createObject returned null for", key) return } if ("omarchyPath" in inst) inst.omarchyPath = shell.omarchyPath - if ("shell" in inst) inst.shell = shell - if ("manifest" in inst) inst.manifest = manifest - if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistry - var snext = ({}) - for (var sk in _services) snext[sk] = _services[sk] - snext[key] = inst - _services = snext + if ("shell" in inst) inst.shell = shell.pluginShellFor(manifest) + if ("manifest" in inst) inst.manifest = shell.publicPluginManifest(manifest) + if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest) + if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistryFor(manifest) + if (authenticationService) { + // Never publish lock/polkit through ShellRoot._services. The private JS + // import retains their lifetime without adding a traversable property + // or QObject parent back to the host shell. + AuthServiceStore.put(key, inst) + } else { + var snext = ({}) + for (var sk in _services) snext[sk] = _services[sk] + snext[key] = inst + _services = snext + } } if (comp.status === Component.Loading) { comp.statusChanged.connect(finalize) return null } finalize() - return _services[key] || null + return authenticationService ? null : (_services[key] || null) } function _syncServices() { @@ -329,33 +948,115 @@ ShellRoot { if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue if (!m.entryPoints || !m.entryPoints.service) continue if (!pluginRegistry.isEnabled(id)) continue - if (_services[id]) continue + var authenticationService = shell.isAuthenticationService(m, id) + if (_services[id]) { + if (authenticationService) { + // A service that gains a trusted authentication capability must move + // out of the host's public service map before it is recreated. + var published = _services[id] + if (published && typeof published.destroy === "function") published.destroy() + var withoutPublished = ({}) + for (var publishedId in _services) + if (publishedId !== id) withoutPublished[publishedId] = _services[publishedId] + _services = withoutPublished + } else { + // A kept instance outlives the rescan; hand it the fresh manifest. + var kept = _services[id] + if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m) + if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m) + continue + } + } + if (AuthServiceStore.has(id)) { + if (authenticationService) { + AuthServiceStore.updateManifest(id, shell.publicPluginManifest(m)) + continue + } + // A service that loses its trusted authentication capability can move + // back to the ordinary service map only after the isolated copy dies. + AuthServiceStore.destroy(id) + } ensureService(id) } - // Drop services for plugins that have been disabled or removed. + // Drop services for plugins that have been disabled or removed, or that + // no longer declare a service entry point. for (var existingId in _services) { var stillThere = plugins[existingId] + var stillService = stillThere && Array.isArray(stillThere.kinds) + && stillThere.kinds.indexOf("service") !== -1 + && stillThere.entryPoints && stillThere.entryPoints.service var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) - if (stillThere && stillEnabled) continue + if (stillService && stillEnabled) continue var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() var next = ({}) for (var k in _services) if (k !== existingId) next[k] = _services[k] _services = next } + // Authentication services are retained outside the root object graph, so + // reconcile their disable/remove lifecycle separately from _services. + var authenticationIds = AuthServiceStore.ids() + for (var ai = 0; ai < authenticationIds.length; ai++) { + var authenticationId = authenticationIds[ai] + var authenticationManifest = plugins[authenticationId] + var stillAuthenticationService = authenticationManifest + && Array.isArray(authenticationManifest.kinds) + && authenticationManifest.kinds.indexOf("service") !== -1 + && authenticationManifest.entryPoints + && authenticationManifest.entryPoints.service + if (stillAuthenticationService && pluginRegistry.isEnabled(authenticationId) + && shell.isAuthenticationService(authenticationManifest, authenticationId)) continue + AuthServiceStore.destroy(authenticationId) + } } + function serviceKeepLoaded(pluginId) { + var plugins = pluginRegistry && pluginRegistry.installedPlugins + var manifest = plugins ? plugins[pluginId] : null + return !!(manifest && manifest.keepLoaded === true) + } + + // keepLoaded services (lock, idle, polkit) must survive plugin hot-reload. + // Destroying omarchy.lock drops the ext-session-lock client while Hyprland + // still holds the lock, which surfaces the crashed-lockscreen fallback. function unloadPluginServices() { + var next = ({}) for (var existingId in _services) { + if (serviceKeepLoaded(existingId)) { + next[existingId] = _services[existingId] + continue + } var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() } - _services = ({}) + _services = next + var authenticationIds = AuthServiceStore.ids() + for (var ai = 0; ai < authenticationIds.length; ai++) { + var authenticationId = authenticationIds[ai] + if (!serviceKeepLoaded(authenticationId)) + AuthServiceStore.destroy(authenticationId) + } } Connections { target: shell.pluginRegistry - function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() } + function onPluginsChanged() { + shell.syncPluginApis() + if (!shell.pluginReloading) shell._syncServices() + } + } + + Connections { + target: shell.barWidgetRegistry + function onChanged() { shell.syncPluginApis() } + } + + Connections { + target: shell.appLibrary + function onAppsChanged() { + for (var id in shell._pluginAppLibraryApis) + shell._pluginAppLibraryApis[id].appsChanged() + } } // Writes inline settings to a bar layout entry or top-level plugin entry in @@ -627,10 +1328,10 @@ ShellRoot { onLoaded: { if (!item) return if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath - if ("shell" in item) item.shell = shell - if ("manifest" in item) item.manifest = panelEntry.manifest - if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry - if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry + if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest) + if ("manifest" in item) item.manifest = shell.publicPluginManifest(panelEntry.manifest) + if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(panelEntry.manifest) + if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistryFor(panelEntry.manifest) // Plugins that pair a panel UI with a service entry read shared // state off `service`. Hand them the matching singleton if one was // loaded. @@ -696,7 +1397,8 @@ ShellRoot { schema: meta.schema || [], pluginId: manifest.id, sourceDir: manifest.__sourceDir || "", - source: "plugin" + source: "plugin", + firstParty: !!manifest.__isFirstParty } // A load already in flight for this URL registers itself when it diff --git a/test/cli b/test/cli index 80b981d5..1643d2db 100755 --- a/test/cli +++ b/test/cli @@ -519,101 +519,6 @@ expected_background="$THEME_TMPDIR/.local/state/omarchy/current/theme/background [[ ! -e $THEME_TMPDIR/.config/omarchy/current ]] || fail "headless theme set avoids config current state" pass "headless theme set creates current background symlink" -make_tmpdir MIGRATION_TMPDIR -mkdir -p \ - "$MIGRATION_TMPDIR/.config/omarchy/current/theme" \ - "$MIGRATION_TMPDIR/.config/alacritty" \ - "$MIGRATION_TMPDIR/.config/hypr" \ - "$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker" \ - "$MIGRATION_TMPDIR/.config/btop/themes" -printf 'tokyo-night\n' >"$MIGRATION_TMPDIR/.config/omarchy/current/theme.name" -printf 'x\n' >"$MIGRATION_TMPDIR/.config/omarchy/current/theme/colors.toml" -printf 'general.import = [ "~/.config/omarchy/current/theme/alacritty.toml" ]\n' >"$MIGRATION_TMPDIR/.config/alacritty/alacritty.toml" -printf 'stylesheets: ["../omarchy/current/theme/hyprland-preview-share-picker.css"]\n' >"$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker/config.yaml" -cat >"$MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA' -package.path = os.getenv("HOME") - .. "/.config/?.lua;" - .. (os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") - .. "/?.lua;" - .. package.path -LUA -ln -s "$MIGRATION_TMPDIR/.config/omarchy/current/theme/btop.theme" "$MIGRATION_TMPDIR/.config/btop/themes/current.theme" -HOME="$MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781043107.sh" >/dev/null -[[ -f $MIGRATION_TMPDIR/.local/state/omarchy/current/theme.name ]] || fail "current theme migration moves theme name" -[[ ! -e $MIGRATION_TMPDIR/.config/omarchy/current ]] || fail "current theme migration removes legacy config state" -grep -Fq '~/.local/state/omarchy/current/theme/alacritty.toml' "$MIGRATION_TMPDIR/.config/alacritty/alacritty.toml" || fail "current theme migration updates alacritty import" -grep -Fq '../../.local/state/omarchy/current/theme/hyprland-preview-share-picker.css' "$MIGRATION_TMPDIR/.config/hyprland-preview-share-picker/config.yaml" || fail "current theme migration updates relative stylesheet" -grep -Fq '/.local/state/?.lua;' "$MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "current theme migration keeps Hyprland theme modules discoverable" -[[ $(readlink "$MIGRATION_TMPDIR/.config/btop/themes/current.theme") == "$MIGRATION_TMPDIR/.local/state/omarchy/current/theme/btop.theme" ]] || fail "current theme migration updates btop symlink" -pass "current theme migration moves state and rewrites shipped references" - -make_tmpdir NVIM_MIGRATION_TMPDIR -nvim_theme_link="$NVIM_MIGRATION_TMPDIR/.config/nvim/lua/plugins/theme.lua" -nvim_expected_target="../../../../.local/state/omarchy/current/theme/neovim.lua" -nvim_expected_resolved_target="$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme/neovim.lua" -mkdir -p \ - "$(dirname "$nvim_theme_link")" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme" \ - "$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme" -touch "$NVIM_MIGRATION_TMPDIR/.local/state/omarchy/current/theme/neovim.lua" -for nvim_legacy_target in \ - "../../../omarchy/current/theme/neovim.lua" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do - ln -sfn "$nvim_legacy_target" "$nvim_theme_link" - HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781158082.sh" >/dev/null - nvim_actual_target=$(readlink "$nvim_theme_link") - nvim_resolved_target=$(readlink -f "$nvim_theme_link") - [[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme migration updates theme symlink" - [[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme migration points to current theme" -done -pass "nvim theme migration relinks current theme" - -for nvim_legacy_target in \ - "../../../omarchy/current/theme/neovim.lua" \ - "../../../../.config/omarchy/current/theme/neovim.lua" \ - "~/.config/omarchy/current/theme/neovim.lua" \ - "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do - ln -sfn "$nvim_legacy_target" "$nvim_theme_link" - HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null - nvim_actual_target=$(readlink "$nvim_theme_link") - nvim_resolved_target=$(readlink -f "$nvim_theme_link") - [[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme repair migration updates $nvim_legacy_target" - [[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme repair migration points to current theme" -done - -HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null -[[ $(readlink "$nvim_theme_link") == $nvim_expected_target ]] || fail "nvim theme repair migration is idempotent" - -touch "$NVIM_MIGRATION_TMPDIR/custom-theme.lua" -ln -sfn "../../../../custom-theme.lua" "$nvim_theme_link" -HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null -[[ $(readlink "$nvim_theme_link") == "../../../../custom-theme.lua" ]] || fail "nvim theme repair migration leaves custom symlinks alone" -pass "nvim theme repair migration relinks every legacy spelling" - -make_tmpdir HYPR_MIGRATION_TMPDIR -mkdir -p "$HYPR_MIGRATION_TMPDIR/.config/hypr" -cat >"$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA' --- Learn how to configure Hyprland: https://wiki.hypr.land/Configuring/Start/ - --- Load user modules from ~/.config and Omarchy defaults from $OMARCHY_PATH. -package.path = os.getenv("HOME") - .. "/.config/?.lua;" - .. (os.getenv("OMARCHY_PATH") or (os.getenv("HOME") .. "/.local/share/omarchy")) - .. "/?.lua;" - .. package.path - --- All Omarchy default setups -require("default.hypr.omarchy") - --- Add any other personal Hyprland configuration below. -o.window("Example", { workspace = "1" }) -LUA -HOME="$HYPR_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1781063758.sh" >/dev/null -grep -Fq 'dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/bootstrap.lua")' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration adds bootstrap" -! grep -Fq 'package.path = os.getenv("HOME")' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration removes legacy path block" -grep -Fq 'o.window("Example", { workspace = "1" })' "$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" || fail "hyprland bootstrap migration preserves custom config" -pass "hyprland bootstrap migration updates stale user entrypoint" - cp "$NEXT_THEME/colors.toml" "$CURRENT_THEME/colors.toml" cp "$NEXT_THEME/vscode-theme.json" "$CURRENT_THEME/vscode-theme.json" diff --git a/test/shell.d/agents-default-migration-test.sh b/test/shell.d/agents-default-migration-test.sh deleted file mode 100755 index 319893a6..00000000 --- a/test/shell.d/agents-default-migration-test.sh +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -require_command jq - -migration="$ROOT/migrations/1785344985.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -mkdir -p "$test_dir/bin" - -cat >"$test_dir/bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash - -echo restart >>"$SHELL_RESTARTS" -STUB - -chmod +x "$test_dir/bin/"* - -export SHELL_RESTARTS="$test_dir/shell-restarts" - -home="$test_dir/home" -config="$home/.config/omarchy/shell.json" - -run_migration() { - : >"$SHELL_RESTARTS" - HOME="$home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null -} - -# The shipped default minus the widget is what every machine installed before -# this migration has on disk. -write_config() { - rm -rf "$home" - mkdir -p "$home/.config/omarchy" - jq "${1:-.}" "$ROOT/config/omarchy/shell.json" >"$config" -} - -without_widget='del(.bar.layout[][] | select((if type == "object" then .id else . end) == "omarchy.agents"))' - -ids() { - jq -c --arg section "$1" '[.bar.layout[$section][]? | if type == "object" then .id else . end]' "$config" -} - -# ------------------------------------------------------------------ shipped default - -jq -e '[.bar.layout.right[].id] | index("omarchy.agents")' "$ROOT/config/omarchy/shell.json" >/dev/null || - fail "shipped config puts the agents widget in the bar" -pass "shipped config puts the agents widget in the bar" - -# ------------------------------------------------------------------ placement - -write_config "$without_widget" -run_migration - -[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.bluetooth","omarchy.network","omarchy.audio","omarchy.monitor","omarchy.power"]' ]] || - fail "migration inserts the agents widget after the tray" "$(ids right)" -pass "migration inserts the agents widget after the tray" - -(($(wc -l <"$SHELL_RESTARTS") == 0)) || fail "migration leaves the shell restart to omarchy update" -pass "migration leaves the shell restart to omarchy update" - -before=$(sha256sum "$config") -run_migration -[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(ids right)" -pass "migration is idempotent" - -# ------------------------------------------------------------------ curated bars - -# A user who already placed the widget keeps it exactly where they put it, in -# whichever section, and never gets a second copy. -write_config "$without_widget | .bar.layout.center += [{ id: \"omarchy.agents\" }]" -run_migration - -[[ $(ids center) == *'"omarchy.agents"'* ]] || fail "migration leaves a user-placed widget alone" "$(ids center)" -[[ $(ids right) != *'"omarchy.agents"'* ]] || fail "migration does not add a second copy" "$(ids right)" -pass "migration respects a widget the user already placed" - -# Layouts written before entries grew options are bare id strings. -write_config "$without_widget | .bar.layout.right = [\"omarchy.tray\", \"omarchy.agents\", \"omarchy.power\"]" -run_migration - -[[ $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] || - fail "migration reads string-form entries" "$(ids right)" -pass "migration reads string-form entries" - -# A tray dropped from the right section must not strand the widget or drop it. -write_config "$without_widget | del(.bar.layout.right[] | select(.id == \"omarchy.tray\"))" -run_migration - -[[ $(ids right) == '["omarchy.agents",'* ]] || fail "migration places the widget without a tray" "$(ids right)" -pass "migration places the widget without a tray" - -# ------------------------------------------------------------------ everything else - -write_config "$without_widget" -cp "$config" "$test_dir/before.json" -run_migration - -diff <(jq -S 'del(.bar.layout.right)' "$test_dir/before.json") <(jq -S 'del(.bar.layout.right)' "$config") >/dev/null || - fail "migration touches nothing but the right section" "$(diff <(jq -S . "$test_dir/before.json") <(jq -S . "$config"))" -pass "migration touches nothing but the right section" - -# A config the migration cannot parse is left alone rather than truncated. -rm -rf "$home" -mkdir -p "$home/.config/omarchy" -printf '{ not json' >"$config" -run_migration - -[[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")" -pass "migration leaves an unparsable config untouched" diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh index d0b6a3d1..5ebd1cfe 100644 --- a/test/shell.d/app-search-test.sh +++ b/test/shell.d/app-search-test.sh @@ -55,6 +55,14 @@ const entries = [ } ] +// Keep the packaged launcher when upstream rebuilds register their own entry. +const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/)) +const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }] +for (const query of ['', 'hermes']) { + const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id)) + assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible') +} + const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') diff --git a/test/shell.d/apply-lock-test.sh b/test/shell.d/apply-lock-test.sh new file mode 100644 index 00000000..5e7170cf --- /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 b7626c2b..d22a289c 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/bluetooth-migration-test.sh b/test/shell.d/bluetooth-migration-test.sh deleted file mode 100644 index 95d5b116..00000000 --- a/test/shell.d/bluetooth-migration-test.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -migration="$ROOT/migrations/1786380259.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -mkdir -p "$test_dir/bin" - -# sudo runs the real command, so sed acts on the redirected main.conf below and -# the elevated power calls land in the stub beside it. -cat >"$test_dir/bin/sudo" <<'STUB' -#!/bin/bash - -printf 'sudo %s\n' "$*" >>"$CALLS" -exec "$@" -STUB - -cat >"$test_dir/bin/omarchy-bluetooth-power" <<'STUB' -#!/bin/bash - -printf 'omarchy-bluetooth-power %s\n' "$*" >>"$CALLS" -[[ $1 == "is-on" ]] || exit 0 -[[ ${POWERED:-} == "yes" ]] -STUB - -chmod +x "$test_dir/bin/"* - -export CALLS="$test_dir/calls" - -marker="$test_dir/marker" -main_conf="$test_dir/main.conf" - -reset_machine() { - rm -f "$marker" - printf '[Policy]\nAutoEnable=false\n' >"$main_conf" -} - -run_migration() { - : >"$CALLS" - - OMARCHY_BLUETOOTH_MIGRATION_MARKER="$marker" \ - OMARCHY_BLUETOOTH_MAIN_CONF="$main_conf" \ - PATH="$test_dir/bin:$PATH" \ - bash -euo pipefail "$migration" >/dev/null -} - -# An adapter that is powered right now is one the user turned on, so it stays on. -reset_machine -POWERED=yes run_migration - -grep -qx 'omarchy-bluetooth-power on' "$CALLS" || - fail "migration keeps a powered adapter on" "$(cat "$CALLS")" -pass "migration keeps a powered adapter on" - -grep -qx '#AutoEnable=true' "$main_conf" || - fail "migration puts AutoEnable back to its default" "$(cat "$main_conf")" -pass "migration puts AutoEnable back to its default" - -[[ -e $marker ]] || fail "migration records the machine as done" -pass "migration records the machine as done" - -# Anything else is a machine that has been booting with Bluetooth off, and the -# block is what carries that over now AutoEnable no longer holds the adapter down. -reset_machine -POWERED=no run_migration - -grep -qx 'omarchy-bluetooth-power off' "$CALLS" || - fail "migration carries an unpowered adapter over to the block" "$(cat "$CALLS")" -pass "migration carries an unpowered adapter over to the block" - -# No daemon to ask reads the same way: off is what the machine has been doing. -reset_machine -run_migration - -grep -qx 'omarchy-bluetooth-power off' "$CALLS" || - fail "migration blocks when no adapter can be read" "$(cat "$CALLS")" -pass "migration blocks when no adapter can be read" - -# /dev/rfkill is only writable unelevated from an active graphical seat, so an -# update run over SSH would abort here and abort again on every retry. -grep -qx 'sudo omarchy-bluetooth-power off' "$CALLS" || - fail "migration changes the radio through sudo" "$(cat "$CALLS")" -pass "migration changes the radio through sudo" - -# A second account must not undo an administrator's later choice, since migration -# completion is recorded per user. -printf '[Policy]\nAutoEnable=false\n' >"$main_conf" -POWERED=yes run_migration - -grep -qx 'AutoEnable=false' "$main_conf" || - fail "migration leaves a later opt-out alone" "$(cat "$main_conf")" -pass "migration leaves a later opt-out alone" - -[[ ! -s $CALLS ]] || - fail "migration touches no radio state on a second run" "$(cat "$CALLS")" -pass "migration touches no radio state on a second run" - -# Only the exact line Omarchy wrote is reverted, so a hand-edited opt-out stands. -reset_machine -printf '[Policy]\nAutoEnable = false\n' >"$main_conf" -POWERED=yes run_migration - -grep -qx 'AutoEnable = false' "$main_conf" || - fail "migration keeps a hand-edited AutoEnable" "$(cat "$main_conf")" -pass "migration keeps a hand-edited AutoEnable" diff --git a/test/shell.d/chromium-copy-url-test.sh b/test/shell.d/chromium-copy-url-test.sh index 6dad096f..daed1a3d 100644 --- a/test/shell.d/chromium-copy-url-test.sh +++ b/test/shell.d/chromium-copy-url-test.sh @@ -71,6 +71,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-copy-url-host" ' ' "$native_manifest" >/dev/null || fail "copy-url native host manifest uses Omarchy host path and extension id" pass "copy-url native host installer registers the stable extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.copy_url.json ]] || + fail "copy-url native host installer covers Brave Origin" +pass "copy-url native host installer covers Brave Origin" + # Chromium ships in the base packages, so fresh installs do not go through # omarchy-install-browser, and they mark every migration as already applied. # The user install still has to register the host itself. diff --git a/test/shell.d/chromium-ytdlp-test.sh b/test/shell.d/chromium-ytdlp-test.sh index 6cd571c4..a2872699 100755 --- a/test/shell.d/chromium-ytdlp-test.sh +++ b/test/shell.d/chromium-ytdlp-test.sh @@ -33,6 +33,10 @@ jq -e --arg path "$ROOT/bin/omarchy-chromium-ytdlp-host" ' ' "$manifest_path" >/dev/null pass "yt-dlp native host manifest uses Omarchy host path and extension id" +[[ -f $test_home/.config/BraveSoftware/Brave-Origin/NativeMessagingHosts/com.omarchy.ytdlp.json ]] || + fail "yt-dlp native host installer covers Brave Origin" +pass "yt-dlp native host installer covers Brave Origin" + parse_result=$(bash -c ' OMARCHY_PATH="$3" source "$1" diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 3add6e30..355ca26b 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -38,6 +38,15 @@ jq -e ' ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default clock date format has no leading zero" +jq -e ' + def ids: map(.id // .); + (.bar.layout.right | ids) as $ids | + ($ids | index("omarchy.tray")) as $tray | + ($ids | index("omarchy.agents")) as $agents | + $tray != null and $agents == $tray + 1 +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default right layout keeps agents next to the tray" + ROOT="$ROOT" python3 <<'PY' import json import os @@ -142,6 +151,7 @@ package_defaults = [ ("default/systemd/user/omarchy-fcitx5.service", "/usr/lib/systemd/user/omarchy-fcitx5.service", "systemd/user/omarchy-fcitx5.service"), ("default/systemd/user/omarchy-crash-watch.service", "/usr/lib/systemd/user/omarchy-crash-watch.service", "systemd/user/omarchy-crash-watch.service"), ("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"), + ("default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "/usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf", "systemd/system/plocate-updatedb.service.d/10-omarchy.conf"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"), ] @@ -418,39 +428,3 @@ if grep -RIl 'upgrade-to-quattro\|Omarchy 4\.0 is upgraded' "$ROOT/migrations" > fail "4.0 upgrade is not modeled as a migration" fi pass "4.0 upgrade is handled outside the migration runner" - -clock_migration=$(grep -rl 'Remove leading zero from bar clock date' "$ROOT/migrations" | head -n 1 || true) -[[ -n $clock_migration ]] || fail "clock date format user migration exists" - -cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' -{ - "version": 1, - "bar": { - "layout": { - "left": [], - "center": [ - { "id": "omarchy.clock", "formatAlt": "dd MMMM 'W'ww yyyy" }, - { "id": "omarchy.weather" } - ], - "right": [ - { "id": "local.clock", "formatAlt": "dd MMMM 'W'ww yyyy" } - ] - } - }, - "plugins": [] -} -JSON - -HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration" - -jq -e ' - .bar.layout.center[0].formatAlt == "d MMMM \u0027W\u0027ww yyyy" and - .bar.layout.right[0].formatAlt == "dd MMMM \u0027W\u0027ww yyyy" -' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null -pass "clock date format migration removes leading zero from clock" - -before=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') -HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" bash "$clock_migration" -after=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') -[[ $before == "$after" ]] || fail "clock date format migration is idempotent" -pass "clock date format migration is idempotent" diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index db964d90..ea65b973 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -19,6 +19,7 @@ mise_history="$test_tmp/mise-history" stub_log="$test_tmp/stubs" terminal_log="$test_tmp/terminal" menu_log="$test_tmp/menu" +muse_login_log="$test_tmp/muse-login" mkdir -p "$mock_bin" "$test_home" cat >"$mock_bin/omarchy-notification-send" <<'SH' @@ -69,6 +70,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 @@ -91,6 +108,7 @@ export OMARCHY_TEST_MISE_HISTORY="$mise_history" export OMARCHY_TEST_STUB_LOG="$stub_log" export OMARCHY_TEST_AGENT_TERMINAL_LOG="$terminal_log" export OMARCHY_TEST_AGENT_MENU_LOG="$menu_log" +export OMARCHY_TEST_MUSE_LOGIN_LOG="$muse_login_log" export OMARCHY_PATH="$ROOT" grok_package="npm:@xai-official/grok" @@ -98,6 +116,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 @@ -116,16 +136,41 @@ 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 "$cursor_agent_package" cursor-agent +assert_lazy_stub "$muse_package" muse pass "custom agent lazy stubs preserve their mise packages" -source "$ROOT/install/user/mise.sh" +OMARCHY_TEST_MISSING_COMMAND=cursor-agent source "$ROOT/install/user/mise.sh" grep -Fx "$agy_package agy" "$stub_log" >/dev/null || fail "user setup creates the Antigravity lazy stub" grep -Fx "$grok_package grok" "$stub_log" >/dev/null || fail "user setup creates the Grok lazy stub" +grep -Fx "$cursor_agent_package" "$stub_log" >/dev/null || fail "user setup creates the Cursor CLI lazy stub" grep -Fx "$omp_package omp" "$stub_log" >/dev/null || fail "user setup creates the Oh My Pi lazy stub" grep -Fx "$crush_package" "$stub_log" >/dev/null || fail "user setup creates the Crush lazy stub" grep -Fx "$ori_package ori" "$stub_log" >/dev/null || fail "user setup creates the Ori lazy 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" @@ -134,6 +179,17 @@ 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 -Fx "$cursor_agent_package" "$stub_log" >/dev/null || fail "Cursor CLI migration creates a working lazy stub" + +: >"$stub_log" +source "$ROOT/migrations/1788577553.sh" >/dev/null +[[ ! -s $stub_log ]] || fail "Cursor CLI migration reinstalls an existing cursor-agent command" +pass "Cursor CLI migration preserves an existing Cursor CLI install" + : >"$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" @@ -216,6 +272,7 @@ touch "$test_home/.local/state/omarchy/preinstalls-removed" source "$ROOT/migrations/1785617047.sh" >/dev/null source "$ROOT/migrations/1785846769.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" @@ -240,13 +297,30 @@ 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 cursor-agent muse; do [[ ! -e $test_home/.local/bin/$command ]] || fail "Remove Preinstalls deletes the $command lazy stub" done 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. +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" @@ -307,6 +381,11 @@ declare -A expected_agents=( [gemini-cli]="agy" [copilot]="copilot" [github-copilot]="copilot" + [cursor]="cursor-agent" + [cursor-agent]="cursor-agent" + [muse]="muse" + [muse-code]="muse" + [musecode]="muse" ) declare -A expected_packages=( @@ -320,6 +399,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 @@ -329,8 +410,12 @@ for selection in "${!expected_agents[@]}"; do [[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $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 mapfile -d '' -t agent_open_args <"$agent_open_log" [[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] || @@ -380,6 +465,35 @@ mapfile -d '' -t agent_open_args <"$agent_open_log" fail "installed agent opens in a new terminal after selection" pass "installed agents select and open without notifications" +# Cursor's installer links the wrapper's path, and the mise shims precede +# ~/.local/bin, so a mise copy would shadow the user's own install. +touch "$test_home/.local/bin/cursor-agent.official" +chmod +x "$test_home/.local/bin/cursor-agent.official" +ln -s cursor-agent.official "$test_home/.local/bin/cursor-agent" +: >"$terminal_log" +: >"$mise_log" +: >"$agent_open_log" +omarchy-default-agent cursor-agent +[[ ! -s $terminal_log ]] || fail "an official Cursor CLI install needs no install terminal" +[[ ! -s $mise_log ]] || fail "an official Cursor CLI install is left to itself by mise" +[[ $(<"$agent_file") == "cursor-agent" ]] || fail "an official Cursor CLI install becomes the default" +mapfile -d '' -t agent_open_args <"$agent_open_log" +[[ ${#agent_open_args[@]} == 1 && ${agent_open_args[0]} == "omarchy-agent" ]] || + fail "an official Cursor CLI install opens after selection" +rm -f "$test_home/.local/bin/cursor-agent" "$test_home/.local/bin/cursor-agent.official" +printf '%s\n' copilot >"$agent_file" +pass "selecting an official Cursor CLI install skips mise" + +# A file nothing can run is not an install; the wrapper is still wanted. +touch "$test_home/.local/bin/cursor-agent" +: >"$terminal_log" +omarchy-default-agent cursor-agent +mapfile -d '' -t terminal_args <"$terminal_log" +[[ ${terminal_args[*]} == "omarchy-default-agent --install cursor-agent" ]] || + fail "a dead file at the wrapper's path still installs Cursor CLI" +rm -f "$test_home/.local/bin/cursor-agent" +pass "a dead file at the wrapper's path does not pass for an install" + : >"$agent_open_log" if omarchy-default-agent unsupported >"$test_tmp/invalid-output" 2>&1; then fail "default agent rejects unsupported providers" @@ -414,6 +528,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 @@ -431,8 +615,10 @@ assert_launched() { fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" for ((index = 0; index < ${#expected[@]}; index++)); do - [[ ${actual[$index]} == ${expected[$index]} ]] || - fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" + case ${actual[$index]} in + "${expected[$index]}") ;; + *) fail "$agent launch $description" "expected: ${expected[*]}\nactual: ${actual[*]}" ;; + esac done } @@ -460,20 +646,39 @@ 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" 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" +assert_launched hermes "binds its literal initial prompt" env -u HERMES_SESSION_SOURCE \ + hermes chat --yolo --tui "--query=$literal_hermes_prompt" +pass "Hermes receives prompted launches as one literal query argument" + assert_bypass pi pi assert_bypass omp omp --auto-approve 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 pass "agent launcher skips permission prompts for every supported agent" @@ -531,3 +736,68 @@ fi grep -F "missing is not installed" "$test_tmp/missing-output" >/dev/null || fail "agent launcher explains when the default command is missing" pass "agent launcher reports a missing default command" + +# OpenClaw comes from its pacman package, not mise: choosing it must route +# through omarchy-install-openclaw-cli and never touch a mise environment. +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $1 == openclaw && ${OMARCHY_TEST_OPENCLAW_INSTALLED:-false} == "true" ]] +SH +cat >"$mock_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf '%s\n' "pkg-add $*" >>"$OMARCHY_TEST_STUB_LOG" +SH +cat >"$mock_bin/omarchy-launch-openclaw" <<'SH' +#!/bin/bash +printf '%s\0' omarchy-launch-openclaw "$@" >"$OMARCHY_TEST_AGENT_INLINE_LOG" +SH +cat >"$mock_bin/openclaw" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/omarchy-pkg-present" "$mock_bin/omarchy-pkg-add" \ + "$mock_bin/omarchy-launch-openclaw" "$mock_bin/openclaw" + +: >"$launch_log" +: >"$terminal_log" +: >"$mise_history" +OMARCHY_TEST_OPENCLAW_INSTALLED=true omarchy-default-agent openclaw +read -r chosen <"$agent_file" +[[ $chosen == openclaw ]] || fail "choosing OpenClaw records it as the default agent" +mapfile -d '' -t launch_args <"$launch_log" +[[ ${launch_args[*]} == "--app-id=org.omarchy.agent omarchy-launch-openclaw --tui" ]] || + fail "choosing OpenClaw launches its terminal UI" +[[ ! -s $terminal_log ]] || fail "an installed OpenClaw needs no install terminal" +! grep -q 'use -g openclaw' "$mise_history" || fail "OpenClaw never installs through mise" +pass "choosing OpenClaw uses the package and launches its terminal UI" + +: >"$terminal_log" +OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent openclaw +mapfile -d '' -t terminal_args <"$terminal_log" +[[ ${terminal_args[*]} == "omarchy-default-agent --install openclaw" ]] || + fail "a missing OpenClaw routes through the install terminal" +pass "a missing OpenClaw routes through the install terminal" + +: >"$stub_log" +: >"$inline_log" +OMARCHY_TEST_OPENCLAW_INSTALLED=false omarchy-default-agent --install openclaw >/dev/null +grep -Fx "pkg-add openclaw" "$stub_log" >/dev/null || + fail "installing OpenClaw as default agent adds its package" +mapfile -d '' -t inline_args <"$inline_log" +[[ ${inline_args[*]} == "omarchy-launch-openclaw --tui" ]] || + fail "installing OpenClaw as default agent hands over to its terminal UI" +pass "installing OpenClaw as default agent adds its package" + +: >"$launch_log" +omarchy agent prompt "Review this project" +mapfile -d '' -t launch_args <"$launch_log" +# Element-wise: the prompt must travel as one argv entry, which a space-joined +# comparison could not tell apart from a prompt split into words. +[[ ${#launch_args[@]} == 5 && + ${launch_args[0]} == "--app-id=org.omarchy.agent" && + ${launch_args[1]} == "omarchy-launch-openclaw" && + ${launch_args[2]} == "--tui" && + ${launch_args[3]} == "--message" && + ${launch_args[4]} == "Review this project" ]] || + fail "OpenClaw receives prompts through --message" "argv: ${launch_args[*]}" +pass "OpenClaw receives prompts through --message" 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 00000000..6b3f523c --- /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 00000000..70329982 --- /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/fixtures/kitty/check-config.py b/test/shell.d/fixtures/kitty/check-config.py new file mode 100644 index 00000000..6f6c0c9c --- /dev/null +++ b/test/shell.d/fixtures/kitty/check-config.py @@ -0,0 +1,58 @@ +import os +from pathlib import Path +from tempfile import TemporaryDirectory + +from kitty.config import load_config +from kitty.options.utils import parse_map + +root = Path(os.environ['ROOT']) +system = root / 'etc/xdg/kitty/kitty.conf' +template = root / 'config/kitty/kitty.conf' +legacy = root / 'test/shell.d/fixtures/kitty/legacy.conf' +active_lines = [line for line in template.read_text().splitlines() if line and not line.startswith('#')] +assert active_lines == ['include ~/.local/state/omarchy/current/theme/kitty.conf'] + +with TemporaryDirectory() as tmp: + user = Path(tmp) / 'kitty.conf' + # Use a local theme to avoid depending on the developer's generated state. + theme = Path(tmp) / 'theme.conf' + theme.write_text('background #123456\n') + themed = f'include {theme}\n' + user.write_text(themed) + errors = [] + opts = load_config(str(system), str(user), accumulate_bad_lines=errors) + assert not errors, errors + assert opts.allow_remote_control == 'socket-only' + assert opts.listen_on == 'unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid}' + + old = Path(tmp) / 'legacy.conf' + old.write_text(legacy.read_text().replace(active_lines[0], themed.strip())) + before = load_config(str(old), accumulate_bad_lines=errors) + # Moving defaults must preserve appearance and behavior except remote control. + for key in ('font_family', 'bold_italic_font', 'font_size', 'window_padding_width', + 'hide_window_decorations', 'confirm_os_window_close', 'cursor_shape', + 'cursor_blink_interval', 'shell_integration', 'enable_audio_bell', + 'tab_bar_edge', 'tab_bar_style', 'tab_powerline_style', + 'tab_title_template', 'background'): + assert getattr(opts, key) == getattr(before, key), key + + def binding(options, shortcut): + trigger = next(parse_map(shortcut)).trigger + return [entry.definition for entry in options.keyboard_modes[''].keymap.get(trigger, [])] + + for shortcut in ('ctrl+insert', 'shift+insert', 'shift+enter', 'alt+shift+enter'): + assert binding(opts, shortcut) == binding(before, shortcut), shortcut + + user.write_text(themed + 'font_size 15\nmap ctrl+insert\nmap shift+insert copy_to_clipboard\n') + opts = load_config(str(system), str(user), accumulate_bad_lines=errors) + assert opts.font_size == 15 + assert not any(binding(opts, 'ctrl+insert')) + assert binding(opts, 'shift+insert')[-1] == 'copy_to_clipboard' + + user.write_text(themed + 'clear_all_shortcuts yes\nmap f1 new_window\n') + opts = load_config(str(system), str(user), accumulate_bad_lines=errors) + assert len(opts.keyboard_modes[''].keymap) == 1 + assert binding(opts, 'f1') == ['new_window'] + assert not errors, errors + +print('ok - Kitty loads defaults and theme, preserves appearance, and supports user overrides and unmapping') diff --git a/test/shell.d/fixtures/kitty/legacy.conf b/test/shell.d/fixtures/kitty/legacy.conf new file mode 100644 index 00000000..7493b9e2 --- /dev/null +++ b/test/shell.d/fixtures/kitty/legacy.conf @@ -0,0 +1,35 @@ +include ~/.local/state/omarchy/current/theme/kitty.conf + +# Font +font_family JetBrainsMono Nerd Font +bold_italic_font auto +font_size 9.0 + +# Window +window_padding_width 14 +hide_window_decorations yes +confirm_os_window_close 0 + +# Keybindings +map ctrl+insert copy_to_clipboard +map shift+insert paste_from_clipboard +# Send Shift+Enter as CSI-u so TUIs can distinguish it from Enter. +map shift+enter send_text all \e[13;2u +# Kitty legacy encoding sends Alt+Shift+Enter the same as Alt+Enter; send CSI-u so tmux can match M-S-Enter. +map alt+shift+enter send_text all \e[13;4u + +# Allow remote access +allow_remote_control yes +listen_on unix:${XDG_RUNTIME_DIR}/omarchy-kitty-{kitty_pid} + +# Aesthetics +cursor_shape block +cursor_blink_interval 0 +shell_integration no-cursor +enable_audio_bell no + +# Minimal Tab bar styling +tab_bar_edge bottom +tab_bar_style powerline +tab_powerline_style slanted +tab_title_template {title}{' :{}:'.format(num_windows) if num_windows > 1 else ''} diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml new file mode 100644 index 00000000..b41c6513 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml @@ -0,0 +1,31 @@ +pragma Singleton +import QtQuick +import Quickshell.Networking + +QtObject { + property int backend: NetworkBackendType.NetworkManager + property bool wifiEnabled: true + property bool canCheckConnectivity: true + property bool connectivityCheckEnabled: true + property int connectivity: NetworkConnectivity.Full + property int checks: 0 + function checkConnectivity() { checks++ } + + property var devices: ({ values: [wifi] }) + property QtObject wifi: QtObject { + property int type: DeviceType.Wifi + property string name: "test-wifi" + property bool connected: true + property bool scannerEnabled: false + property var networks: ({ values: [network] }) + } + property QtObject network: QtObject { + property string name: "Guest Wi-Fi" + property bool connected: true + property bool known: true + property bool stateChanging: false + property real signalStrength: 0.8 + property int security: WifiSecurityType.Open + signal connectionFailed(int reason) + } +} diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/qmldir b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir new file mode 100644 index 00000000..03b0cb27 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir @@ -0,0 +1 @@ +singleton NetworkMock 1.0 NetworkMock.qml diff --git a/test/shell.d/fixtures/network-captive-portal/shell.qml b/test/shell.d/fixtures/network-captive-portal/shell.qml new file mode 100644 index 00000000..24c361bc --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/shell.qml @@ -0,0 +1,161 @@ +import QtQuick +import Quickshell +import Quickshell.Networking +import qs.Commons +import "mocks" +import "network" as Network + +ShellRoot { + id: test + property bool failed: false + function check(ok, message) { + if (!ok) { + failed = true + console.log("RESULT fail " + message) + } + } + + // Not visible in the normal test run. The optional preview maps the real + // KeyboardPanel for a screenshot, without ever altering the host network. + Item { + Network.Panel { + id: panel + bar: QtObject { + property color foreground: Color.foreground + property color barForeground: Color.foreground + property color urgent: Color.urgent + property string fontFamily: Style.font.family + property string position: "top" + property int barSize: 24 + property bool vertical: false + property bool foregroundAnimationEnabled: false + property var activePopout: null + function requestPopout(owner) { activePopout = owner } + function releasePopout(owner) { activePopout = null } + function registerClickTarget(target) {} + function unregisterClickTarget(target) {} + function hideTooltip(target) {} + function showTooltip(target, text) {} + } + } + } + + Timer { + interval: 250 + running: true + onTriggered: { + test.check(panel.kind === "wifi", "connected Wi-Fi fixture") + test.check(panel.connectivity === "full", "normal connectivity") + test.check(!panel.testButton.visible && !panel.testBarButton.active, "no false portal banner") + test.check(!panel.testPoll.running, "normal connectivity adds no polling") + test.check(NetworkMock.checks > 0, "checks at connection/startup") + var before = NetworkMock.checks + panel.checkConnectivity() + test.check(NetworkMock.checks === before + 1, "manual check delegates to NM") + NetworkMock.connectivity = NetworkConnectivity.Portal + Qt.callLater(portalChecks) + } + } + + function portalChecks() { + check(panel.hasCaptivePortal && panel.restricted, "native portal activates restricted mode") + check(panel.testButton.visible, "portal button visible") + check(panel.testButton.text === "Open Captive Portal", "prominent action label") + check(panel.icon === "󰤩" && panel.testBarButton.active, "blocked bar icon and warning color") + check(panel.testMeta.text === "SIGN-IN REQUIRED", "status replaces cheerful connection phrase") + check(panel.testTitle.text === "Guest Wi-Fi", "connected SSID survives missing route details") + check(panel.testPoll.running && panel.testPoll.interval === 10000, "restricted recheck runs while closed") + var before = NetworkMock.checks + panel.testPoll.triggered() + check(NetworkMock.checks === before + 1, "background timer rechecks through NM") + panel.testKeys.textKey("r") + check(NetworkMock.checks === before + 2, "r requests fresh connectivity") + // Exercise the existing cursor model, not a separate test-only action. + panel.cursorActive = true + panel.focusSection = "header" + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "portal", "down from header reaches portal") + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "dns", "down from portal skips absent band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from DNS reaches portal") + panel.bandAvailable = ["2.4", "5"] + panel.testKeys.moveRequested(0, 1) + check(panel.focusSection === "band", "down from portal reaches available band") + panel.testKeys.moveRequested(0, -1) + check(panel.focusSection === "portal", "up from band reaches portal") + panel.testKeys.activateRequested() + NetworkMock.connectivity = NetworkConnectivity.Full + Qt.callLater(recoveryChecks) + } + + function recoveryChecks() { + check(!panel.hasCaptivePortal && !panel.restricted, "login recovery clears restriction") + check(!panel.testPoll.running, "recovery stops extra checks") + check(!panel.testButton.visible && !panel.testBarButton.active, "recovery hides button and warning color") + check(panel.focusSection === "header", "disappearing button leaves valid cursor") + check(panel.icon !== "󰤩", "signal icon returns") + // No browser launch when the portal is gone (runner asserts one launch). + panel.openCaptivePortal() + NetworkMock.connectivity = NetworkConnectivity.Limited + Qt.callLater(limitedChecks) + } + + function limitedChecks() { + check(panel.restricted && !panel.hasCaptivePortal, "outage is not mislabelled as a portal") + check(!panel.testButton.visible && panel.testMeta.text === "LIMITED INTERNET ACCESS", "limited state has no login button") + NetworkMock.connectivity = NetworkConnectivity.Portal + NetworkMock.connectivityCheckEnabled = false + Qt.callLater(disabledChecks) + } + + function disabledChecks() { + check(!panel.hasCaptivePortal && panel.connectivity === "unknown", "disabled checks ignore cached portal") + check(!panel.testPoll.running, "disabled checks stop polling") + var before = NetworkMock.checks + panel.checkConnectivity() + check(NetworkMock.checks === before, "does not enable or invoke disabled checks") + NetworkMock.connectivityCheckEnabled = true + NetworkMock.network.connected = false + NetworkMock.wifi.connected = false + Qt.callLater(disconnectedChecks) + } + + function disconnectedChecks() { + check(panel.kind === "disconnected" && !panel.hasCaptivePortal, "disconnect clears stale portal") + check(!panel.testButton.visible && panel.icon === "󰤮", "disconnected icon not portal icon") + if (failed) { Qt.quit(); return } + console.log("RESULT pass") + var preview = Quickshell.env("NETWORK_TEST_PREVIEW") + if (preview === "portal" || preview === "full") { + NetworkMock.network.connected = true + NetworkMock.wifi.connected = true + NetworkMock.connectivity = preview === "portal" ? NetworkConnectivity.Portal : NetworkConnectivity.Full + panel.open() + previewCapture.start() + previewDone.start() + } else { + // Give the detached, stubbed browser command time to append its argv. + done.start() + } + } + + // Optional fresh, panel-only captures. Rendering the card itself excludes + // the host desktop, and the network details above come only from fixtures. + // NETWORK_TEST_PREVIEW=portal (or full), NETWORK_TEST_SCREENSHOT=/tmp/new.png + Timer { + id: previewCapture + interval: 750 + onTriggered: { + var path = Quickshell.env("NETWORK_TEST_SCREENSHOT") + if (!path) return + var card = panel.testKeys.parent.parent + card.grabToImage(function(result) { + test.check(result.saveToFile(path), "save fresh preview screenshot") + Qt.quit() + }) + } + } + Timer { id: done; interval: 300; onTriggered: Qt.quit() } + Timer { id: previewDone; interval: 15000; onTriggered: Qt.quit() } +} diff --git a/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml new file mode 100644 index 00000000..5ca9c271 --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreOwner.qml @@ -0,0 +1,20 @@ +import QtQuick +import "services/AuthServiceStore.js" as AuthServiceStore + +QtObject { + function retain(id, service) { + AuthServiceStore.put(id, service) + } + + function has(id) { + return AuthServiceStore.has(id) + } + + function isTrusted(id) { + return AuthServiceStore.isTrusted(id) + } + + function updateManifest(id, manifest) { + AuthServiceStore.updateManifest(id, manifest) + } +} diff --git a/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml new file mode 100644 index 00000000..a7527589 --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/AuthStoreReader.qml @@ -0,0 +1,8 @@ +import QtQuick +import "services/AuthServiceStore.js" as AuthServiceStore + +QtObject { + function has(id) { + return AuthServiceStore.has(id) + } +} diff --git a/test/shell.d/fixtures/plugin-auth-boundary/shell.qml b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml new file mode 100644 index 00000000..48a15f7d --- /dev/null +++ b/test/shell.d/fixtures/plugin-auth-boundary/shell.qml @@ -0,0 +1,92 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" + +ShellRoot { + id: root + + property var calls: [] + property QtObject ownService: QtObject { + property string marker: "own" + property var manifest: null + } + + AuthStoreOwner { id: authStoreOwner } + AuthStoreReader { id: authStoreReader } + + Component { + id: apiComponent + PluginShellApi { } + } + + FileView { + id: resultFile + path: Quickshell.env("OMARCHY_QML_TEST_RESULT") + atomicWrites: true + } + + Component.onCompleted: { + var caller = "example.safe" + authStoreOwner.retain("omarchy.lock", root.ownService) + authStoreOwner.updateManifest("omarchy.lock", { version: "kept" }) + var api = apiComponent.createObject(null, { + pluginId: caller, + idleConfig: { screensaver: 60, lock: 120 }, + _serviceLookup: function(requestedId) { + return requestedId === caller ? root.ownService : null + }, + _summon: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["summon"]) + return true + }, + _hide: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["hide"]) + return true + }, + _toggle: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["toggle"]) + return true + }, + _isOpen: function(requestedId) { return requestedId === caller }, + _updateSettings: function(requestedId) { + if (requestedId !== caller) return false + root.calls = root.calls.concat(["settings"]) + return true + } + }) + + var own = api.serviceFor(caller) + var result = { + detached: api.parent === undefined || api.parent === null, + ownService: own && own.marker === "own", + foreignService: api.serviceFor("omarchy.lock") === null, + firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null, + ownSummon: api.summon(caller, "{}") === true, + foreignSummon: api.summon("omarchy.lock", "{}") === false, + ownHide: api.hide(caller) === true, + foreignHide: api.hide("omarchy.lock") === false, + ownToggle: api.toggle(caller, "{}") === true, + foreignToggle: api.toggle("omarchy.lock", "{}") === false, + ownOpen: api.isPluginOpen(caller) === true, + foreignOpen: api.isPluginOpen("omarchy.lock") === false, + ownSettings: api.updateEntryInline(caller, {}) === true, + foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false, + detachedIdleConfig: api.idleConfig.screensaver === 60 && api.idleConfig.lock === 120, + authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true, + authStoreOwnerRemembersTrust: authStoreOwner.isTrusted("omarchy.lock") === true, + authStoreOwnerUpdatesManifest: root.ownService.manifest + && root.ownService.manifest.version === "kept", + authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false, + noGenericPluginShellFactory: typeof api.pluginShellForId !== "function", + calls: root.calls + } + result.ok = Object.keys(result).every(function(key) { + return key === "ok" || key === "calls" || result[key] === true + }) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"]) + resultFile.setText(JSON.stringify(result)) + } +} diff --git a/test/shell.d/fixtures/plugin-registry/shell.qml b/test/shell.d/fixtures/plugin-registry/shell.qml index e10f73ed..49e6f437 100644 --- a/test/shell.d/fixtures/plugin-registry/shell.qml +++ b/test/shell.d/fixtures/plugin-registry/shell.qml @@ -83,6 +83,9 @@ ShellRoot { scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" })) scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" })) scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" })) + var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" }) + futureAuth.omarchy = { capabilities: ["authentication"] } + scan += block("firstparty", "/first/future-auth", futureAuth) scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" })) scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" })) @@ -103,6 +106,12 @@ ShellRoot { localBar.omarchy = { clonedFrom: "omarchy.bar" } scan += block("thirdparty", "/third/local-bar", localBar) scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" })) + var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" }) + localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" } + scan += block("thirdparty", "/third/local-future-auth", localFutureAuth) + var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" }) + spoofedAuth.omarchy = { capabilities: ["authentication"] } + scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth) scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" })) scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" })) @@ -116,22 +125,28 @@ ShellRoot { root.assertDeepEqual(pluginIds(), [ "local.bar", "local.first-widget", + "local.future-auth", "local.grouped-panel", "local.hybrid", "local.weather", "omarchy.bar", "omarchy.first-widget", + "omarchy.future-auth", "omarchy.grouped-panel", "omarchy.hybrid", "third.bar", "third.center-widget", "third.panel", "third.right-widget", + "third.spoofed-auth", "third.widget" ], "registry merges valid first-party and third-party manifests") root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped") root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped") + root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability") + root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities") + root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities") root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved") root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths") root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths") diff --git a/test/shell.d/hermes-cli-migration-test.sh b/test/shell.d/hermes-cli-migration-test.sh new file mode 100755 index 00000000..bd18f6fb --- /dev/null +++ b/test/shell.d/hermes-cli-migration-test.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1787760281.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +mock_bin="$test_tmp/bin" +test_home="$test_tmp/home" +hermes="$test_home/.local/bin/hermes" +marker="# Written by omarchy-install-hermes-cli." +mkdir -p "$mock_bin" "$test_home/.local/bin" "$test_home/.local/state/omarchy" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +mise_log="$test_tmp/mise-log" +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" +[[ $1 != "where" ]] +SH + +chmod +x "$mock_bin"/* + +# The real installer is on PATH so the migration writes today's stub, not a +# copy of it. +run_migration() { + OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$test_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null 2>&1 +} + +run_migration || fail "the migration installs the wrapper on a plain install" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper" +pass "the migration installs the Hermes wrapper" + +before=$(cat "$hermes") +run_migration || fail "rerunning the migration succeeds" +[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper" +pass "the migration is idempotent" + +chmod -x "$hermes" +run_migration || fail "the migration repairs a non-executable Omarchy wrapper" +[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || + fail "the migration restores a non-executable Omarchy wrapper" +pass "the migration repairs a non-executable Omarchy wrapper" + +rm -f "$hermes" +touch "$test_home/.local/state/omarchy/preinstalls-removed" +run_migration || fail "the migration succeeds for users who removed the preinstalls" +[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out" +pass "the migration skips users who removed the preinstalls" +rm -f "$test_home/.local/state/omarchy/preinstalls-removed" + +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes" +[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes" +pass "the migration stands aside for Hermes Desktop" + +# Standing aside is not the same as leaving a second Hermes behind: the wrapper +# an earlier install wrote and the mise copy it points at both go when the +# desktop app owns Hermes, even though the app has not finished setting up. +printf '%s\n' "#!/bin/bash" "$marker" >"$hermes" +chmod +x "$hermes" +: >"$mise_log" +run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present" +[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes" +mise_calls=$(tr '\0' ' ' <"$mise_log") +[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop" +[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop" +pass "the migration clears the old Omarchy Hermes for Hermes Desktop" + +# ...while anyone else's hermes stays exactly where it is, and is not run. +foreign_ran="$test_tmp/foreign-ran" +foreign_body="#!/bin/bash +touch $foreign_ran +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$foreign_body" >"$hermes" +chmod +x "$hermes" +run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes" +[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] || + fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes" +[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes" +pass "the migration preserves a foreign hermes for Hermes Desktop" +rm -f "$hermes" + +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a foreign hermes command" +[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone" +pass "the migration preserves a foreign hermes command" + +chmod -x "$hermes" +run_migration || fail "the migration succeeds over a non-executable foreign hermes" +[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] || + fail "the migration leaves a non-executable foreign hermes alone" +pass "the migration preserves a non-executable foreign hermes" + +rm -f "$hermes" +ln -s "$test_home/nowhere/hermes" "$hermes" +run_migration || fail "the migration succeeds over a dangling hermes link" +[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] || + fail "the migration leaves a dangling hermes link alone" +pass "the migration preserves a dangling hermes link" + +rm -f "$hermes" +mkdir "$hermes" +run_migration || fail "the migration succeeds over a directory at the hermes path" +[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone" +pass "the migration preserves a directory at the hermes path" + +rmdir "$hermes" +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes" +chmod +x "$hermes" +run_migration || fail "the migration succeeds over a wrapper that mentions the installer" +grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer" +pass "the migration preserves a wrapper that merely mentions the installer" diff --git a/test/shell.d/hermes-cli-test.sh b/test/shell.d/hermes-cli-test.sh new file mode 100755 index 00000000..1cf033ab --- /dev/null +++ b/test/shell.d/hermes-cli-test.sh @@ -0,0 +1,576 @@ +#!/bin/bash + +set -euo pipefail + +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" +test_home="$test_tmp/home" +mise_log="$test_tmp/mise-log" +mkdir -p "$mock_bin" "$test_home/.local/bin" + +cat >"$mock_bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]] +SH + +cat >"$mock_bin/omarchy-cmd-missing" <<'SH' +#!/bin/bash +! command -v "$1" >/dev/null 2>&1 +SH + +# `mise where` must fail so the installer sees no Hermes behind the stub. +# +# With OMARCHY_TEST_MISE_X_HERMES=1, `mise x -- hermes ...` emulates the Hermes +# the Omarchy stub runs, so the readiness probe can be exercised through a +# mise-installed hermes and not only the foreign and desktop wrappers. Off by +# default, so `mise x` stays silent for every test that does not opt in. +cat >"$mock_bin/mise" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG" +if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then + printf '%s\n' "$OMARCHY_TEST_MISE_ROOT" + exit 0 +fi +if [[ $1 == "x" && ${OMARCHY_TEST_MISE_X_HERMES:-0} == 1 ]]; then + # Args are `x -- hermes `; skip to what follows hermes. + shift + while (( $# )) && [[ $1 != "--" ]]; do shift; done + shift 2 + if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" + else + echo "hermes-agent 0.0.0-test" + fi + exit 0 +fi +[[ $1 != "where" ]] +SH + +chmod +x "$mock_bin"/* + +run_installer() { + OMARCHY_TEST_DESKTOP_INSTALLED="$1" \ + OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1 +} + +stub_marker="# Written by omarchy-install-hermes-cli." +python_pin="3.13" +app_stub_body='#!/bin/bash +exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"' + +# Writing the stub must not provision anything: user setup calls this on every +# machine, including the ones that never run Hermes. +: >"$mise_log" +rm -f "$test_home/.local/bin/hermes" +run_installer 0 || fail "installer failed with no desktop installed" +[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it" +tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" && + fail "writing the stub does not install uv" +pass "writing the Hermes stub provisions nothing" + +# The desktop app owns Hermes, so our own stub must go rather than sit there +# answering `hermes` until the app's bootstrap replaces it. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "the desktop taking over removes the stub this command wrote" +pass "installing the desktop app removes the CLI stub" + +# ...but the app's own hermes is not ours to delete. +printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 || true +[[ -x $test_home/.local/bin/hermes ]] || + fail "the desktop app's own hermes command survives" +pass "the app's own hermes command is left alone" + +# A copy mise cannot vouch for is still a second Hermes. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true +tr '\0' '\n' <"$mise_log" | grep -q "uninstall" || + fail "takeover removes a mise copy even when it is not healthy" +pass "takeover removes an unhealthy mise copy" + +# --check answers about Hermes being usable, not about the venv appearing. The +# venv exists from the python-deps stage, several stages before the command. +rm -rf "$test_home/.hermes" +rm -f "$test_home/.local/bin/hermes" +run_installer 1 --check && fail "--check reports Hermes missing before the app installs it" +# The venv command answers the readiness probes, as the real one does: foreign +# wrappers below exec it, and the installer runs both before trusting them. +mkdir -p "$test_home/.hermes/hermes-agent/venv/bin" +cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + [[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes" +run_installer 1 --check && fail "--check waits for the install to finish, not just the venv" +touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check || fail "--check reports Hermes present once the app has finished" +pass "--check follows the app's completed install" + +# An executable called hermes that belongs to something else is not this +# install being ready. +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 1 --check && fail "--check rejects a hermes command belonging to something else" +pass "--check rejects a foreign hermes command" + +# A hermes the user installed themselves -- the official installer, a wrapper of +# their own -- is not ours to replace. --check follows whether it runs, and +# installing steps aside so the default agent uses it. +official_body="#!/bin/bash +unset PYTHONPATH +unset PYTHONHOME +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a working foreign hermes command" +run_installer 0 || fail "installing over a foreign hermes command returns success" +run_installer 0 --now || fail "--now over a foreign hermes command returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "a foreign hermes command is left untouched" +pass "a foreign hermes command is preserved and satisfies --check" + +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check && + fail "--check rejects a foreign Hermes without native prompted sessions" +OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 && + fail "installing refuses a foreign Hermes without native prompted sessions" +[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] || + fail "an older foreign Hermes command is left untouched" +pass "a foreign Hermes must support native prompted sessions" + +# Broken foreign paths are still foreign. They cannot be used, so --check says +# so and the installer refuses rather than replacing them. +printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes" +chmod -x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a non-executable foreign hermes" +run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes" +[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] || + fail "a non-executable foreign hermes is left untouched" +pass "a non-executable foreign hermes is preserved" + +# The executable bit is not enough: a wrapper whose interpreter is gone passes +# -x and still cannot run. The probe has to run it to find out, and finding +# out never touches the file. +broken_interp_body="#!$test_home/nowhere/python3 +print('hermes')" +printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] || + fail "a foreign hermes whose interpreter is missing is left untouched" +pass "a foreign hermes with a missing interpreter is preserved and rejected" + +# Likewise a wrapper that execs a target that is no longer there. +broken_target_body="#!/bin/bash +exec $test_home/nowhere/hermes \"\$@\"" +printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing" +run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing" +run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing" +[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] || + fail "a foreign hermes whose target is missing is left untouched" +pass "a foreign hermes with a missing target is preserved and rejected" + +foreign_target="$test_home/foreign/hermes" +mkdir -p "$(dirname "$foreign_target")" +printf '%s\n' "$official_body" >"$foreign_target" +chmod +x "$foreign_target" +rm -f "$test_home/.local/bin/hermes" +ln -s "$foreign_target" "$test_home/.local/bin/hermes" +run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command" +run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command" +run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] || + fail "a foreign link to a working hermes command is left untouched" +pass "a foreign link to a working hermes command is preserved" + +rm -f "$test_home/.local/bin/hermes" +ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a dangling hermes link" +run_installer 0 && fail "the installer does not succeed over a dangling hermes link" +[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] || + fail "a dangling hermes link is left untouched" +pass "a dangling hermes link is preserved" + +# A directory passes -x on search permission alone. It is still not a command. +rm -f "$test_home/.local/bin/hermes" +mkdir "$test_home/.local/bin/hermes" +run_installer 0 --check && fail "--check rejects a directory at the hermes path" +run_installer 0 && fail "the installer does not succeed over a directory at the hermes path" +[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched" +pass "a directory at the hermes path is preserved and rejected" + +# Mentioning the installer is not the same as being written by it. +rmdir "$test_home/.local/bin/hermes" +mentions_body="#!/bin/bash +# Replaces the stub omarchy-install-hermes-cli used to write. +exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" +printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "installing over a wrapper that mentions the installer returns success" +[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] || + fail "a wrapper that merely mentions the installer is left untouched" +pass "ownership needs the exact marker line, not a mention" + +# Our own stub is ours to rewrite, so reinstalling refreshes it to the current +# template. +rm -f "$test_home/.local/bin/hermes" +printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes" +chmod +x "$test_home/.local/bin/hermes" +run_installer 0 || fail "reinstalling over our own stub succeeds" +grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker" +grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub" +grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template" +pass "reinstalling refreshes the Omarchy stub" + +mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled" +pass "reinstalling replaces an older owned Hermes environment" + +# The mise-installed path is what a machine without the desktop app runs, and +# --check gates the default agent there too. The stub is present and its mise +# environment resolves, so readiness turns on the hermes mise runs -- exercised +# here in both directions, since the desktop and foreign cases cover only their +# own wrappers. +run_mise_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_WHERE_OK=1 \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + OMARCHY_TEST_MISE_X_HERMES=1 \ + OMARCHY_TEST_HERMES_CAPABLE="$1" \ + HOME="$test_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} +run_mise_check 1 || fail "--check accepts a mise-installed hermes that runs the seeded session" +run_mise_check 0 && fail "--check rejects a mise-installed hermes without the flags omarchy-agent passes" +pass "--check follows the mise-installed hermes it would actually run" + +rm -f "$test_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 && + fail "installing refuses to claim an unmarked Hermes mise environment" +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "an unmarked Hermes mise environment is never removed" +[[ ! -e $test_home/.local/bin/hermes ]] || + fail "an unmarked Hermes mise environment is not given an Omarchy wrapper" +pass "a Hermes mise environment needs wrapper ownership before replacement" + +# install/user/mise.sh is sourced by install/user/all.sh through run_logged, +# which runs it under `bash -eE` and hands its exit code back to +# omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user +# -- the default browser, the mailto handler, the first-install migration +# markers, the finalize-user marker -- runs after that source, so this leaf +# returning non-zero costs the user all of it. The Hermes installer is the only +# line in it that can fail, and it does exactly that whenever hermes-desktop is +# installed but the app has not been launched yet: the case a second user on a +# shared machine hits on their first login. +mise_sh_home="$test_tmp/mise-sh-home" +mkdir -p "$mise_sh_home/.local/bin" + +cat >"$mock_bin/omarchy-mise-install" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$mock_bin/omarchy-mise-install" + +# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 && + fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up" + +# Sourced exactly as run_logged does it. +OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$mise_sh_home" \ + PATH="$mock_bin:$ROOT/bin:$PATH" \ + bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 || + fail "user setup survives a Hermes install that cannot finish" +pass "user setup survives a Hermes install that cannot finish" + +# UV_PYTHON pins the interpreter Hermes is built against. Left in the +# environment it reaches Hermes itself and every command the agent shells out +# to, so a `uv` run in the user's own project resolves 3.13 there as well -- +# uv only warns that this contradicts the project's requires-python, then +# builds the venv anyway. The stub drops it before handing over. +leak_home="$test_tmp/leak-home" +leak_bin="$test_tmp/leak-bin" +leak_log="$test_tmp/leak-log" +leak_prefix="$test_tmp/leak-prefix" +mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin" + +# A mise whose `where` satisfies the stub's probe, so the stub goes straight to +# handing over, and whose `x` records the UV_PYTHON it was handed. +cat >"$leak_bin/mise" <"$leak_log" ;; +esac +SH +chmod +x "$leak_bin/mise" + +OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$leak_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 || + fail "the installer writes a stub for the leak check" + +HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \ + "$leak_home/.local/bin/hermes" --version >/dev/null 2>&1 + +[[ -f $leak_log ]] || fail "the stub reaches the command it wraps" +[[ -z $(cat "$leak_log") ]] || + fail "the interpreter pin does not follow Hermes into the commands it runs" +pass "the interpreter pin does not follow Hermes into the commands it runs" + +# --owns is the one answer to whether the wrapper on PATH is this installer's. +# Remove Preinstalls and the migration both ask it rather than carrying their +# own copy of the marker, so a change to what ownership means reaches them. +owns_home="$test_tmp/owns-home" +mkdir -p "$owns_home/.local/bin" + +run_owns() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$owns_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --owns +} + +rm -f "$owns_home/.local/bin/hermes" +run_owns && fail "--owns says no when there is no wrapper at all" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes" +chmod +x "$owns_home/.local/bin/hermes" +run_owns || fail "--owns recognises the stub this installer wrote" + +printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the exact marker line, not a mention" + +# Quoting the marker inside a longer line is not the same as carrying it: the +# match is whole-line, so a wrapper describing what it replaced stays the +# user's. +printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \ + >"$owns_home/.local/bin/hermes" +run_owns && fail "--owns needs the marker to be the whole line, not part of one" + +rm -f "$owns_home/.local/bin/hermes" +ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes" +run_owns && fail "--owns disclaims a symlink, whatever it resolves to" +rm -f "$owns_home/.local/bin/hermes" +pass "--owns answers for the wrapper this installer wrote and nothing else" + +# The marker lives in exactly one place. Every other caller asks --owns, so a +# second copy is drift waiting to happen. +marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \ + "$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l) +(( marker_copies == 1 )) || + fail "only omarchy-install-hermes-cli spells out the ownership marker" +pass "the ownership marker is written down once" + +# --remove tears down a Hermes CLI this installer owns, so Remove Hermes can +# clear one the desktop app never superseded. It turns on the same ownership as +# the rest of the file, so its cases mirror that split. +remove_home="$test_tmp/remove-home" +mkdir -p "$remove_home/.local/bin" + +run_remove() { + OMARCHY_TEST_DESKTOP_INSTALLED=0 \ + OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \ + OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$remove_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --remove +} + +rm -f "$remove_home/.local/bin/hermes" +: >"$mise_log" +run_remove || fail "--remove succeeds when there is nothing to remove" +# No stub means no proof the mise environment -- if one even exists -- is +# Omarchy's, so nothing may reach mise at all. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove leaves mise alone when nothing proves ownership" +pass "--remove is idempotent when no Hermes CLI is present" + +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +: >"$mise_log" +run_remove || fail "--remove succeeds tearing down an owned CLI" +tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config" +tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool" +[[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns" +pass "--remove tears down the mise CLI and the stub this installer owns" + +# When mise still resolves the tool after the teardown, the environment +# survived whatever uninstall claimed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove && fail "--remove claims success while mise still resolves the tool" +pass "--remove fails when the mise environment survives the teardown" + +foreign_remove_body="#!/bin/bash +exec /usr/local/bin/my-own-hermes \"\$@\"" +printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +: >"$mise_log" +OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds with a foreign hermes present" +[[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] || + fail "--remove leaves a hermes it does not own untouched" +# The wrapper may front a mise environment the user built against the very same +# spec; without the marker there is no telling, so the environment stays too. +tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' && + fail "--remove never removes a mise environment it cannot prove is Omarchy's" +pass "--remove leaves a Hermes the user installed themselves" + +# Judged by what is left, not by what rm claimed: a stub that survives the +# teardown is a CLI still installed, and --remove has to say so. +printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes" +chmod +x "$remove_home/.local/bin/hermes" +chmod 555 "$remove_home/.local/bin" +run_remove && fail "--remove claims success while the stub survives" +chmod 755 "$remove_home/.local/bin" +rm -f "$remove_home/.local/bin/hermes" +pass "--remove fails when the stub cannot be removed" + +# The app's marker says its install once landed, not that it is still there. A +# wrapper whose runtime has since gone answers for nothing, so readiness runs +# the command, exactly as it does for a hermes the user installed themselves. +ready_home="$test_tmp/ready-home" +mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin" +touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$ready_home/.local/bin/hermes" +chmod +x "$ready_home/.local/bin/hermes" + +run_ready_check() { + OMARCHY_TEST_DESKTOP_INSTALLED=1 \ + OMARCHY_TEST_MISE_LOG="$mise_log" \ + HOME="$ready_home" \ + PATH="$mock_bin:$PATH" \ + bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1 +} + +run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone" + +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[-q QUERY, --query QUERY] [--tui]" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check || fail "--check accepts the app's wrapper once it runs" +pass "readiness runs the app's command rather than trusting its marker" + +# A release whose help lists only the old probe's --oneshot marker cannot run +# the seeded --tui --query session omarchy-agent starts, so it is not ready. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "--oneshot" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check && fail "--check accepts a release without the flags omarchy-agent passes" +pass "a release listing only --oneshot is not prompt-ready" + +# A release that lists --tui-theme and --query-log but has dropped the bare +# --tui/--query omarchy-agent passes must not read as ready on the substring +# alone. The probe matches at a flag boundary for exactly this case. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui-theme THEME] [--query-log FILE]" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check && fail "--check accepts a release whose flags only contain --tui/--query as a substring" +pass "a flag that merely contains --tui or --query is not prompt-ready" + +# Each flag answers for itself: a release that kept --tui but dropped --query, +# or the reverse, cannot run the seeded session either, so neither grep may +# ride on the other's match. +for kept in '--tui' '-q QUERY, --query QUERY'; do + cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo "[--tui_mode MODE] [--query_log FILE]" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check && fail "--check accepts flags that extend --tui/--query with an underscore" +pass "an underscore continuation is not the bare flag" + +# A flag mentioned in another option's help text is not that option. Hermes +# already writes "With --tui:" into --dev's description, so prose has to stay +# prose even when both names appear in it. +cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH' +#!/bin/bash +if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then + echo " --dev With --tui: run sources via tsx" + echo " --log FILE Where --query output lands" +else + echo "hermes-agent 0.0.0-test" +fi +SH +chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes" +run_ready_check && fail "--check accepts flags that appear only in option descriptions" +pass "a flag mentioned in prose is not a defined option" diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh new file mode 100644 index 00000000..d9d342cf --- /dev/null +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -0,0 +1,359 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +for command in git jq python3; do require_command "$command"; done + +test_tmp=$(mktemp -d) +trap 'rm -rf -- "$test_tmp"' EXIT +export OMARCHY_TEST_ROOT="$test_tmp" +mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed" + +# Real Git exercises patch checks and preservation; all package, desktop and +# service commands are mocks. No command reaches the live user installation. +git -C "$test_tmp/seed" init -q -b main +printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore" +printf 'before\n' >"$test_tmp/seed/runtime.txt" +mkdir -p "$test_tmp/seed/apps/desktop/src" +printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js" +mkdir -p "$test_tmp/seed/hermes_cli" +cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY' +import os +from pathlib import Path + +def _write_desktop_build_stamp(project_root, *, source_mode): + home = Path(os.environ['HERMES_HOME']) + assert project_root == home / 'hermes-agent' + assert source_mode is False + assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file() + (home / 'desktop-build-stamp.json').write_text('upstream build stamp') + with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log: + log.write('build-stamp\n') +PY +git -C "$test_tmp/seed" add . +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture +release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +printf 'after\n' >"$test_tmp/seed/runtime.txt" +git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch" +printf 'before\n' >"$test_tmp/seed/runtime.txt" +printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js" +git -C "$test_tmp/seed" add apps/desktop/src/main.js +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main +origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +export OMARCHY_TEST_RELEASE_COMMIT="$release_commit" +printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json" +printf 'packaged app\n' >"$test_tmp/package/resources/app.asar" +printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes" +touch "$test_tmp/package/chrome-sandbox" +chmod 755 "$test_tmp/package/Hermes" +chmod 4755 "$test_tmp/package/chrome-sandbox" + +cat >"$test_tmp/share/install.sh" <<'MOCK' +#!/bin/bash +set -e +printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events" +printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args" +[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7 +commit=$OMARCHY_TEST_RELEASE_COMMIT +force=false +while (( $# )); do + case "$1" in + --dir) runtime=$2; shift ;; + --commit) commit=$2; shift ;; + --force-commit) force=true ;; + --hermes-home) [[ $2 == "$HERMES_HOME" ]] ;; + esac + shift +done +mkdir -p -- "${runtime%/*}" +if [[ ! -d $runtime ]]; then + git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime" +else + git -C "$runtime" checkout -q main + git -C "$runtime" pull -q --ff-only origin main +fi +git -C "$runtime" fetch -q origin "$commit" +if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then + git -C "$runtime" checkout -q --detach "$commit" +fi +mkdir -p "$runtime/venv/bin" +git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit" +printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes" +chmod +x "$runtime/venv/bin/hermes" +printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python" +chmod +x "$runtime/venv/bin/python" +[[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete" +mkdir -p "$HOME/.local/bin" +for command in hermes hermes-agent hermes-acp; do + rm -f "$HOME/.local/bin/$command" + printf 'native runtime shim\n' >"$HOME/.local/bin/$command" +done +MOCK + +cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' +#!/bin/bash +printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" +[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] +MOCK +cat >"$test_tmp/bin/git" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi +exec /usr/bin/git "$@" +MOCK +cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK' +#!/bin/bash +printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events" +exit 1 +MOCK +cat >"$test_tmp/bin/setsid" <<'MOCK' +#!/bin/bash +exec "$@" +MOCK +cat >"$test_tmp/bin/cp" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then + touch "${@: -1}/partial-copy" + exit 9 +fi +exec /usr/bin/cp "$@" +MOCK +cat >"$test_tmp/bin/mv" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then + mkdir -p "${@: -1}" +fi +exec /usr/bin/mv "$@" +MOCK +cat >"$test_tmp/bin/uwsm-app" <<'MOCK' +#!/bin/bash +[[ $1 == -- ]] || exit 1 +shift +exec "$@" +MOCK +cat >"$test_tmp/bin/hermes-desktop" <<'MOCK' +#!/bin/bash +sleep 0.05 +native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked" +if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then + printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events" +else + printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events" +fi +MOCK +cat >"$test_tmp/bin/systemctl" <<'MOCK' +#!/bin/bash +printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events" +MOCK +cat >"$test_tmp/bin/systemd-run" <<'MOCK' +#!/bin/bash +printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events" +# Join the mock asynchronous launch so every test owns its full lifetime. +for (( attempt=0; attempt<100; attempt++ )); do + if grep -q '^launch' "$OMARCHY_TEST_ROOT/events"; then exit 0; fi + sleep 0.01 +done +exit 1 +MOCK +chmod +x "$test_tmp/bin/"* + +# Substitute only system package paths in a scratch copy of the actual script. +python3 - "$ROOT/bin/omarchy-install-ai-hermes" "$test_tmp" <<'PY' +from pathlib import Path +import sys +source, scratch = Path(sys.argv[1]), Path(sys.argv[2]) +script = source.read_text() +for original, replacement in { + '/opt/hermes-desktop': str(scratch / 'package'), + '/usr/share/hermes-desktop': str(scratch / 'share'), + '/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'), +}.items(): + script = script.replace(original, replacement) +(scratch / 'installer').write_text(script) +PY + +new_home() { + test_home="$test_tmp/$1" + hermes_home="$test_home/.hermes" + runtime="$hermes_home/hermes-agent" + native="$runtime/apps/desktop/release/linux-unpacked" + mkdir -p "$test_home" + : >"$test_tmp/events" +} +run_installer() { + HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$PATH" \ + bash "$test_tmp/installer" >"$test_tmp/output" 2>&1 +} +assert_stopped() { + if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi +} + +new_home fresh +run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")" +expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home") +[[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments" +[[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap" +grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" +[[ $(sed -n '4p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy and precedes launch" +[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build" +[[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch" +[[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid" +[[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged" +[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release" +[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history" +# Reproduce the updater's checkout/count/pull sequence while origin stays put. +git clone -q "$runtime" "$test_tmp/first-update" +git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed" +git -C "$test_tmp/first-update" fetch -q origin main +git -C "$test_tmp/first-update" checkout -q main +[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install" +git -C "$test_tmp/first-update" pull -q --ff-only origin main +[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin" +pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch" + +printf 'user app\n' >"$native/resources/app.asar" +printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json" +: >"$test_tmp/events" +run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")" +! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again" +! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer" +[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged" +[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged" +pass "repeat setup accepts the applied patch and preserves the existing native app" + +# Advancing the runtime must never reinstall the release or reapply its patch. +printf 'new main\n' >"$runtime/runtime.txt" +git -C "$runtime" add runtime.txt +git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update +: >"$test_tmp/events" +run_installer || fail "a complete updated runtime and native app are reused" +[[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched" +mv "$native" "$test_tmp/saved-native" +: >"$test_tmp/events" +run_installer && fail "a newer runtime cannot receive an older native app" +[[ ! -e $native ]] || fail "no mismatched native app was copied" +grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance" +assert_stopped "a missing updated app prevents launch and theme setup" +pass "updated runtimes are preserved and never seeded with the old packaged app" + +new_home dirty-desktop +HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" +printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js" +: >"$test_tmp/events" +run_installer && fail "modified desktop sources cannot be certified as the packaged build" +[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp" +[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved" +grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance" +assert_stopped "modified desktop sources prevent stamping, launch and theme setup" +pass "a matching commit with modified desktop sources is preserved without seeding or stamping" + +for failure in package install marker; do + new_home "$failure-failure" + case "$failure" in + package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;; + install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;; + marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;; + esac + [[ ! -e $native ]] || fail "failed setup does not seed the app" + assert_stopped "failed setup prevents launch and theme setup" +done +pass "package, upstream installer and readiness failures stop before launch" + +for failure in copy race; do + new_home "$failure-failure" + if [[ $failure == "copy" ]]; then + OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup" + [[ ! -e $native ]] || fail "partial copy is never published" + else + OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication" + [[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved" + fi + [[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up" + assert_stopped "publication failure prevents launch" +done +pass "failed copies and concurrent app creation preserve existing work and clean only staging" + +new_home incomplete-native +run_installer || fail "incomplete native fixture sets up" +rm "$native/resources/app.asar" +: >"$test_tmp/events" +run_installer && fail "incomplete existing app requires repair" +[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten" +assert_stopped "incomplete native app prevents launch" +pass "an incomplete existing native app is preserved" + +new_home patch-conflict +run_installer || fail "patch conflict fixture sets up" +printf 'local edit\n' >"$runtime/runtime.txt" +: >"$test_tmp/events" +run_installer && fail "unexpected patch conflict stops setup" +[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved" +assert_stopped "patch conflict prevents launch" +rm "$runtime/.hermes-bootstrap-complete" +: >"$test_tmp/events" +run_installer && fail "incomplete modified runtime cannot be reset by upstream installer" +! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer" +pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely" + +new_home full-history-retry +git clone -q "$test_tmp/seed" "$runtime" +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies" +[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release" +pass "full-history retries force the guarded release pin before dependency setup" + +new_home local-main +git clone -q "$test_tmp/seed" "$runtime" +printf 'local branch work\n' >"$runtime/keep" +git -C "$runtime" add keep +git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work +local_main=$(git -C "$runtime" rev-parse main) +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer && fail "local main commits cannot be reset by upstream installation" +! grep -qx bootstrap "$test_tmp/events" || fail "local main is checked before upstream installer" +[[ $(git -C "$runtime" rev-parse main) == "$local_main" ]] || fail "local main commit stays referenced" +pass "detached release checkouts do not hide local main work from the installer guard" + +new_home deepen-retry +OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup" +[[ ! -e $native ]] || fail "failed history fetch does not seed the app" +assert_stopped "failed history fetch prevents launch" +: >"$test_tmp/events" +run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")" +! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation" +pass "a history fetch failure can be retried without reinstalling the ready runtime" + +new_home existing-commands +mkdir -p "$test_home/.local/bin" +printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes" +printf 'symlink target\n' >"$test_home/target" +ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent" +ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp" +run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")" +backups=("$test_home/.local/bin/".hermes-before-desktop.*) +[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names" +[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved" +[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links" +[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target" +grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported" +pass "pre-existing command files and symlinks are backed up before replacement" + +new_home old-package +mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json" +run_installer && fail "an old installed package cannot bootstrap" +grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance" +! grep -qx handoff "$test_tmp/events" || fail "old package is rejected before CLI handoff" +! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer" +mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json" +pass "old package fails with upgrade guidance before changing the runtime or CLI" + +new_home custom-profile +hermes_home="$test_home/custom home" +runtime="$hermes_home/hermes-agent" +OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds" +[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime" +grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer" +pass "custom profile paths normalize to the shared Hermes home" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh new file mode 100755 index 00000000..f325b472 --- /dev/null +++ b/test/shell.d/hermes-remove-test.sh @@ -0,0 +1,384 @@ +#!/bin/bash + +set -euo pipefail + +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" +test_home="$test_tmp/home" +mkdir -p "$mock_bin" + +# Keep package-path checks scoped to the fixture, even with a live app open. +python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY' +from pathlib import Path +import sys +source, scratch = map(Path, sys.argv[1:]) +(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package'))) +PY + +cat >"$mock_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" +SH + +# The CLI teardown is the installer's own, exercised in hermes-cli-test.sh; here +# it is mocked to a logger so this test stays about what Remove Hermes does with +# ~/.hermes, and to keep real mise out of a run with HOME pointed at a fixture. +cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH' +#!/bin/bash +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" +if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then + touch "$OMARCHY_TEST_PROMPT_GATE.started" + for (( attempt=0; attempt<500; attempt++ )); do + [[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0 + sleep 0.01 + done + exit 1 +fi +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() { + rm -rf "$test_home" + mkdir -p "$test_home/.hermes/hermes-agent" "$test_home/.hermes/bootstrap-cache" \ + "$test_home/.hermes/bin" "$test_home/.hermes/node/bin" \ + "$test_home/.hermes/memories" "$test_home/.hermes/sessions" \ + "$test_home/.config/Hermes" "$test_home/.local/bin" + printf 'chat\n' >"$test_home/.hermes/sessions/one.json" + printf 'memory\n' >"$test_home/.hermes/memories/one.md" + printf 'soul\n' >"$test_home/.hermes/SOUL.md" + printf 'uv\n' >"$test_home/.hermes/bin/uv" + ln -sf "$test_home/.hermes/node/bin/node" "$test_home/.local/bin/node" + ln -sf "$test_home/.hermes/node/bin/npm" "$test_home/.local/bin/npm" + ln -sf /usr/bin/npx "$test_home/.local/bin/npx" + printf 'node\n' >"$test_home/.hermes/node/bin/node" + 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 "$test_tmp/remover" "$test_tmp/output" 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 '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1 +} + +# The app brings its own uv and its own node; both are runtime, not data. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds" +[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime checkout is removed" +[[ ! -d $test_home/.hermes/bin ]] || fail "the uv the app installed is removed" +[[ ! -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" + +# -L, not -e: a dangling symlink fails -e while very much still being there. +[[ ! -L $test_home/.local/bin/node ]] || fail "a node symlink into ~/.hermes is removed" +[[ ! -L $test_home/.local/bin/npm ]] || fail "an npm symlink into ~/.hermes is removed" +[[ -L $test_home/.local/bin/npx ]] || fail "an npx symlink pointing elsewhere survives" +pass "removal clears only the managed Node links it stranded" + +[[ -f $test_home/.hermes/sessions/one.json ]] || fail "chats survive removal" +[[ -f $test_home/.hermes/memories/one.md ]] || fail "memories survive removal" +[[ -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" + +# Removal also asks the installer to tear down a mise CLI the app superseded, so +# a copy left from before the app took over does not linger once Hermes is gone. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal asks the installer to tear down its own CLI" +pass "removal tears down the mise CLI through the installer" + +# A hermes command the app did not write survives even when the app did install +# a runtime of its own. +seed_install +printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/my-own-hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a foreign hermes present" +[[ -f $test_home/.local/bin/hermes ]] || + fail "a hermes command the app did not write survives removal" +pass "removal leaves a hermes it does not own" + +# Installed but never launched. The app provisions its runtime on first launch +# and marks it complete when it lands, so without that marker everything under +# ~/.hermes predates the app -- an official install, or one built by hand -- and +# the paths are identical either way. Dropping the package is the whole job. +seed_install +rm -f "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete" +printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH" +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds when the app never finished installing Hermes" +# The stranded pre-desktop CLI is exactly the interrupted-install case, so the +# teardown must be asked for here too, not only when the app's runtime landed. +tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' || + fail "removal tears down the CLI even when the app never finished installing" +[[ -d $test_home/.hermes/hermes-agent ]] || + fail "a Hermes runtime the app never installed survives removal" +[[ -f $test_home/.hermes/hermes-agent/PATCH ]] || + fail "local changes to a runtime the app never installed survive removal" +[[ -d $test_home/.hermes/bin && -d $test_home/.hermes/node ]] || + fail "the rest of a runtime the app never installed survives removal" +[[ -f $test_home/.local/bin/hermes ]] || + fail "the command a runtime the app never installed put on PATH survives removal" +[[ -L $test_home/.local/bin/node ]] || + fail "node links belonging to a runtime the app never installed survive removal" +pass "removal leaves a Hermes the app never installed" + +# ~/.hermes carries a dot, so a pattern rather than a plain string would also +# claim a wrapper pointing at a sibling directory that merely looks like it. +seed_install +mkdir -p "$test_home/xhermes/bin" +sibling_body="#!/bin/bash +exec $test_home/xhermes/bin/hermes \"\$@\"" +printf '%s\n' "$sibling_body" >"$test_home/.local/bin/hermes" +remove || fail "remove succeeds with a wrapper pointing at a sibling directory" +[[ -f $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$sibling_body" ]] || + 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. +seed_install +printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \ + >"$test_home/.local/bin/hermes" +OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces in the exit code" +[[ ! -d $test_home/.hermes/hermes-agent ]] || + fail "a failed CLI teardown does not stop the runtime removal" +pass "a failed CLI teardown is reported after the runtime is handled" + +# Real SQLite writers exercise the kernel's live/deleted file descriptors. +# Package, service and confirmation commands remain confined to the mocks. +python3 - "$test_tmp" <<'PY' +import os +from pathlib import Path +import pty +import subprocess +import sys +import time + +scratch = Path(sys.argv[1]) +writer_code = '''import os, sqlite3, sys +c = sqlite3.connect(os.environ['TEST_DB']) +c.execute('pragma journal_mode=wal') +c.execute('create table fixture(value)') +c.execute("insert into fixture values ('keep')") +c.commit() +print('ready', flush=True) +sys.stdin.readline() +c.close() +''' + +def setup(name): + home = scratch / name + runtime = home / '.hermes/hermes-agent' + runtime.mkdir(parents=True) + (runtime / '.hermes-bootstrap-complete').touch() + (home / '.config/Hermes').mkdir(parents=True) + env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin", + 'OMARCHY_TEST_GUM_STATUS': '0'} + for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'): + log = home / (key + '.log') + log.touch() + env['OMARCHY_TEST_' + key + '_LOG'] = str(log) + return home, runtime, env + +def writer(db): + child = subprocess.Popen([sys.executable, '-u', '-c', writer_code], + env={**os.environ, 'TEST_DB': str(db)}, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + assert child.stdout.readline().strip() == 'ready' + return child + +def stop(child): + if child.poll() is None: + child.stdin.write('\n') + child.stdin.flush() + child.wait(timeout=5) + +def remove(env): + master, slave = pty.openpty() + try: + return subprocess.run(['bash', str(scratch / 'remover')], env=env, + stdin=slave, capture_output=True, text=True, timeout=10) + finally: + os.close(master) + os.close(slave) + +def blocked(result, home, runtime, child): + assert result.returncode != 0 and str(child.pid) in result.stderr, result + assert 'Close Hermes' in result.stderr, result.stderr + assert (runtime / '.hermes-bootstrap-complete').exists() + assert all((home / (name + '.log')).stat().st_size == 0 + for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM')) + assert child.poll() is None, 'remover must not kill sessions' + +for deleted in (False, True): + home, runtime, env = setup('deleted-writer' if deleted else 'live-writer') + db = home / '.hermes/state.db' + child = writer(db) + try: + if deleted: + for suffix in ('', '-wal', '-shm'): + Path(str(db) + suffix).unlink() + db.write_bytes(b'new database generation') + blocked(remove(env), home, runtime, child) + if deleted: + assert db.read_bytes() == b'new database generation' + finally: + stop(child) + assert remove(env).returncode == 0, 'removal succeeds once the writer closes' + assert not (home / '.hermes').exists() +print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry') + +for kind in ('terminal', 'desktop', 'working-directory'): + home, runtime, env = setup(kind) + executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes') + args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30'] + child = subprocess.Popen(args, executable='/usr/bin/sleep', + cwd=runtime if kind == 'working-directory' else scratch) + try: + blocked(remove(env), home, runtime, child) + finally: + child.terminate() + child.wait(timeout=5) +print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database') + +home, runtime, env = setup('unrelated-writer') +sibling = home / '.hermes-other' +sibling.mkdir() +child = writer(sibling / 'state.db') +try: + assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal' + assert child.poll() is None +finally: + stop(child) +print('ok - unrelated database holders are left alone') + +home, runtime, env = setup('prompt-race') +gate = home / 'prompt' +env['OMARCHY_TEST_PROMPT_GATE'] = str(gate) +master, slave = pty.openpty() +remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) +os.close(slave) +child = None +try: + deadline = time.monotonic() + 5 + while not Path(str(gate) + '.started').exists(): + assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached' + time.sleep(0.01) + child = writer(home / '.hermes/state.db') + Path(str(gate) + '.continue').touch() + stdout, stderr = remover.communicate(timeout=10) + assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr) + assert (home / '.hermes/state.db-wal').exists() + assert (home / '.config/Hermes').exists() +finally: + if child is not None: + stop(child) + if remover.poll() is None: + remover.terminate() + remover.wait(timeout=5) + os.close(master) +print('ok - a writer started during confirmation blocks data deletion') +PY diff --git a/test/shell.d/hermes-skills-migration-test.sh b/test/shell.d/hermes-skills-migration-test.sh new file mode 100755 index 00000000..f1145bd7 --- /dev/null +++ b/test/shell.d/hermes-skills-migration-test.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1787843905.sh" +[[ -f $migration ]] || fail "Hermes skills migration exists" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" + +run_migration() { + HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean" +} + +assert_link() { + local link="$1" + local skill="$2" + local description="$3" + + [[ -L $link && $(readlink "$link") == "$ROOT/default/agents/skills/$skill" ]] || + fail "$description" "$link -> $(readlink "$link" 2>/dev/null || echo missing)" +} + +# ------------------------------------------------------------------ default home, no profiles + +rm -rf "$home" +mkdir -p "$home" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home" +done +[[ -e $home/.hermes/profiles ]] && fail "migration does not create Hermes profiles" +pass "migration links the default Hermes home and does not create profiles" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home for $skill" +done +pass "migration is idempotent on the default home" + +# ------------------------------------------------------------------ pre-existing profile + +rm -rf "$home" +mkdir -p "$home/.hermes/profiles/james" +run_migration + +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration links $skill into the default Hermes home when a profile exists" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration links $skill into a pre-existing Hermes profile" +done +[[ -d $home/.hermes/profiles/james ]] || fail "migration leaves the pre-existing profile in place" +profile_count=$(find "$home/.hermes/profiles" -mindepth 1 -maxdepth 1 -type d | wc -l) +(( profile_count == 1 )) || fail "migration does not create extra profiles" "count=$profile_count" +pass "migration links a pre-existing Hermes profile and does not create extras" + +run_migration +for skill in omarchy diagnose-crash; do + assert_link "$home/.hermes/skills/$skill" "$skill" "migration is idempotent on the default home when a profile exists for $skill" + assert_link "$home/.hermes/profiles/james/skills/$skill" "$skill" "migration is idempotent on a pre-existing profile for $skill" +done +pass "migration is idempotent on a pre-existing profile" + +# ------------------------------------------------------------------ missing skill source + +rm -rf "$home" +mkdir -p "$home" "$test_dir/empty-omarchy" +HOME="$home" OMARCHY_PATH="$test_dir/empty-omarchy" bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the skill source is missing" +[[ -e $home/.hermes ]] && fail "migration no-ops when the skill source is missing" +pass "migration no-ops when the skill source is missing" 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 00000000..3fef5303 --- /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 00000000..631ffe70 --- /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 c34d4bfd..d0eb9e0e 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/kitty-config-test.sh b/test/shell.d/kitty-config-test.sh new file mode 100755 index 00000000..3c2d0da3 --- /dev/null +++ b/test/shell.d/kitty-config-test.sh @@ -0,0 +1,154 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +test_home="$test_dir/home" +kitty_config="$test_home/.config/kitty/kitty.conf" +legacy="$ROOT/test/shell.d/fixtures/kitty/legacy.conf" +migration="$ROOT/migrations/1788745941.sh" +mkdir -p "$(dirname "$kitty_config")" "$test_dir/bin" + +run_migration() { + env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$ROOT/bin:$PATH" bash -euo pipefail "$migration" +} + +cp "$legacy" "$kitty_config" +output=$(run_migration) +cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock config becomes the user template" +backups=("$kitty_config".bak.*) +cmp -s "$legacy" "${backups[0]}" || fail "refresh backs up the original config" +[[ $output == *"Close and reopen all Kitty windows"* ]] || fail "migration requires a full restart" +pass "stock config is refreshed with a backup and restart guidance" + +output=$(run_migration) +cmp -s "$ROOT/config/kitty/kitty.conf" "$kitty_config" || fail "stock migration is idempotent" +[[ $output != *"Close and reopen"* ]] || fail "rerun does not repeat restart guidance" +pass "stock migration is idempotent" + +cat >"$kitty_config" <<'CONF' +# Keep this comment and my theme choice +include my-theme.conf +font_family My Font +font_size 13 +map ctrl+insert +include shortcuts.conf +map shift+insert paste_from_clipboard + allow_remote_control yes +allow_remote_control y +allow_remote_control true +# allow_remote_control yes +listen_on unix:/tmp/my-kitty +CONF +printf 'allow_remote_control yes \n' >>"$kitty_config" +cp "$kitty_config" "$test_dir/custom-original" +cat >"$test_dir/expected" <<'CONF' +# Keep this comment and my theme choice +include my-theme.conf +font_family My Font +font_size 13 +map ctrl+insert +include shortcuts.conf +map shift+insert paste_from_clipboard +# allow_remote_control yes +# allow_remote_control y +# allow_remote_control true +# allow_remote_control yes +listen_on unix:/tmp/my-kitty +CONF +printf '# allow_remote_control yes \n' >>"$test_dir/expected" +chmod 600 "$kitty_config" +run_migration >/dev/null +cmp -s "$test_dir/expected" "$kitty_config" || fail "customizations survive the security repair" +[[ $(stat -c %a "$kitty_config") == "600" ]] || fail "migration preserves config permissions" +backup=$(rg -l 'allow_remote_control true' "$kitty_config".bak.* | tail -1) +cmp -s "$test_dir/custom-original" "$backup" || fail "custom config is backed up" +run_migration >/dev/null +cmp -s "$test_dir/expected" "$kitty_config" || fail "custom migration is idempotent" +pass "custom config repair preserves ordering, mappings, theme, permissions, and original backup" + +for mode in no n false socket-only socket password; do + printf 'allow_remote_control %s\nfont_size 13\n' "$mode" >"$kitty_config" + cp "$kitty_config" "$test_dir/expected" + run_migration >/dev/null + cmp -s "$test_dir/expected" "$kitty_config" || fail "migration preserves $mode" +done +pass "explicit restricted remote-control modes are preserved" + +printf 'font_size 13\n' >"$kitty_config" +cp "$kitty_config" "$test_dir/expected" +run_migration >/dev/null +cmp -s "$test_dir/expected" "$kitty_config" || fail "omitted setting stays omitted" +rm "$kitty_config" +run_migration >/dev/null +[[ ! -e $kitty_config ]] || fail "absent config stays absent" +pass "migration leaves omitted settings and absent user configs alone" + +printf 'allow_remote_control yes\nfont_size 13\n' >"$test_dir/dotfiles.conf" +ln -s "$test_dir/dotfiles.conf" "$kitty_config" +run_migration >/dev/null +[[ -L $kitty_config ]] || fail "migration preserves a dotfile symlink" +grep -qx '# allow_remote_control yes' "$test_dir/dotfiles.conf" || fail "symlink target is repaired" +pass "custom dotfile symlinks survive the repair" +rm "$kitty_config" + +# Exercise the real font commands without changing the running desktop. +for command in pkill omarchy-restart-shell omarchy-hook omarchy-notification-send; do + printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/$command" +done +printf '#!/bin/bash\nexit 1\n' >"$test_dir/bin/pgrep" +printf '#!/bin/bash\nprintf "Test Font\\n"\n' >"$test_dir/bin/fc-list" +printf '#!/bin/bash\nexit 0\n' >"$test_dir/bin/kitty" +cat >"$test_dir/bin/gsettings" <<'SH' +#!/bin/bash +if [[ $1 == "get" ]]; then + if [[ $3 == "font-name" ]]; then + echo "'Sans 11'" + else + echo 1.0 + fi +fi +SH +chmod +x "$test_dir/bin/"* + +run_command() { + env HOME="$test_home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" "$ROOT/bin/$@" +} + +cp "$ROOT/config/kitty/kitty.conf" "$kitty_config" +output=$(run_command omarchy-display-text-size) +[[ $output == *"terminal font: 9 pt"* ]] || fail "size report accounts for inherited Kitty default" +run_command omarchy-font-set 'Test Font' +run_command omarchy-display-text-size 16 +grep -qx 'font_family Test Font' "$kitty_config" || fail "font command creates family override" +run_command omarchy-font-set Font +grep -qx 'font_family Font' "$kitty_config" || fail "font command updates family override" +[[ $(grep -c '^font_family ' "$kitty_config") == "1" ]] || fail "font update avoids duplicate overrides" +grep -qx 'font_size 12.0' "$kitty_config" || fail "size command creates size override" +grep -qx '# font_size 12' "$kitty_config" || fail "font commands keep commented instructions" +run_command omarchy-display-text-size 18 +[[ $(grep -c '^font_size ' "$kitty_config") == "1" ]] || fail "size update avoids duplicate overrides" +run_command omarchy-display-text-size reset +grep -qx 'font_size 9.0' "$kitty_config" || fail "size reset restores default" +pass "font controls add and update overrides in the minimal template" + +rm "$kitty_config" +output=$(run_command omarchy-display-text-size) +[[ $output == *"terminal font: 9 pt"* ]] || fail "size report handles absent Kitty config" +run_command omarchy-font-set 'Test Font' +run_command omarchy-display-text-size 16 +grep -qx 'font_family Test Font' "$kitty_config" || fail "font command handles absent config" +grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent setting" +! grep -q '^include ' "$kitty_config" || fail "font controls must not opt users back into theming" +rm "$kitty_config" +run_command omarchy-display-text-size 16 +grep -qx 'font_size 12.0' "$kitty_config" || fail "size command handles absent config" +pass "font controls create missing Kitty overrides without restoring the theme include" + +if "$ROOT/bin/omarchy-cmd-present" kitty; then + kitty +runpy "$(cat "$ROOT/test/shell.d/fixtures/kitty/check-config.py")" +else + pass "Kitty not installed; skipping native config parser checks" +fi diff --git a/test/shell.d/launch-1password-test.sh b/test/shell.d/launch-1password-test.sh index c9790d2f..a1b64624 100755 --- a/test/shell.d/launch-1password-test.sh +++ b/test/shell.d/launch-1password-test.sh @@ -31,8 +31,9 @@ chmod +x "$mock_bin"/* launch_log="$test_tmp/launch-log" PATH="$mock_bin:$PATH" OMARCHY_TEST_INSTALLED=true OMARCHY_TEST_LOG="$launch_log" \ bash "$ROOT/bin/omarchy-launch-1password" -grep -Fxq 'launch:-- 1password' "$launch_log" || fail "1Password launcher starts the installed app" -pass "1Password launcher starts the installed app" +grep -Fxq 'launch:-- 1password --force-device-scale-factor=1' "$launch_log" || + fail "1Password launcher starts the installed app at a fixed scale factor" +pass "1Password launcher starts the installed app at a fixed scale factor" PATH="$mock_bin:$PATH" OMARCHY_TEST_INSTALLED=false OMARCHY_TEST_LOG="$launch_log" \ bash "$ROOT/bin/omarchy-launch-1password" diff --git a/test/shell.d/launch-openclaw-test.sh b/test/shell.d/launch-openclaw-test.sh new file mode 100755 index 00000000..4c443e07 --- /dev/null +++ b/test/shell.d/launch-openclaw-test.sh @@ -0,0 +1,189 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +mkdir -p "$tmp_dir/bin" "$tmp_dir/home" +export TEST_LOG="$tmp_dir/log" +export PATH="$tmp_dir/bin:$PATH" +export HOME="$tmp_dir/home" + +for stub in omarchy-launch-webapp omarchy-launch-floating-terminal-with-presentation omarchy-openclaw-onboard; do + cat >"$tmp_dir/bin/$stub" <