From fa3f607705749692ecaf8c0d7aa806476d108b27 Mon Sep 17 00:00:00 2001 From: Paolo Pellicori Date: Sun, 23 Aug 2026 16:42:13 +0200 Subject: [PATCH 01/51] [Security] Keep the Windows VM password out of the RDP client's argument list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `launch_windows` passed the VM password to xfreerdp3 as `/p:"$WIN_PASS"`. `/proc//cmdline` is world-readable, so every other user on the machine could read the password for as long as the session stayed open — `ps` was enough. The arguments now go in over stdin with `/args-from:stdin`, leaving only that flag in the process list. The argument list itself is unchanged, and building it as an array also fixes the unquoted `$RDP_SCALE` expansion. --- bin/omarchy-windows-vm | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..31d0230c 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -363,8 +363,31 @@ To stop: omarchy-windows-vm stop" fi # If scale is less than 130%, don't set any scale (use default 100) - # Connect with RDP in fullscreen (auto-detects resolution) - xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE + RDP_ARGS=( + "/u:$WIN_USER" + "/p:$WIN_PASS" + /v:127.0.0.1:3389 + -grab-keyboard + /sound + /microphone + /clipboard + /cert:ignore + "/title:Windows VM - Omarchy" + /dynamic-resolution + /gfx:AVC444 + /floatbar:sticky:off,default:visible,show:fullscreen + ) + if [[ -n $RDP_SCALE ]]; then + RDP_ARGS+=("$RDP_SCALE") + fi + + # Connect with RDP in fullscreen (auto-detects resolution). The arguments go + # in over stdin rather than on the command line: /proc//cmdline is + # world-readable, so passing the VM password as /p:"$WIN_PASS" would show it + # to every other user on the machine for as long as the session is open. + # /args-from must stay the only argument here — FreeRDP rejects it outright + # when it is combined with any other, so new flags belong in RDP_ARGS above. + printf '%s\n' "${RDP_ARGS[@]}" | xfreerdp3 /args-from:stdin # After RDP closes, stop the container unless --keep-alive was specified if [[ $KEEP_ALIVE = "false" ]]; then From 0a65b45ab145c5bc134b6846e72e12dbf459de53 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:48:49 +0300 Subject: [PATCH 02/51] Refuse hook and state names that are paths omarchy-hook and omarchy-state set join a name straight into a path. A name with a slash, or a bare . or .., points outside the hooks or state directory. Every caller in the repo passes a fixed label, so this is a footgun guard for future callers, not a fix for anything that ships today. Names with dots in the middle (a..b) stay allowed. omarchy-state clear is untouched: find -name matches basenames only. --- bin/omarchy-hook | 12 +++ bin/omarchy-state | 15 ++- test/shell.d/hook-state-name-guard-test.sh | 109 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/shell.d/hook-state-name-guard-test.sh diff --git a/bin/omarchy-hook b/bin/omarchy-hook index 8c2a59d9..499cdf14 100755 --- a/bin/omarchy-hook +++ b/bin/omarchy-hook @@ -11,6 +11,18 @@ if (( $# < 1 )); then fi HOOK=$1 + +# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set, +# font-set). The name becomes a filename under the hooks directory. A slash +# would turn it into directory levels, and a bare `.` or `..` would point bash +# at the directory itself or its parent. Refuse those rather than follow them. +# Dots inside a name (a..b) are fine; once slashes are out, only the whole +# name being `.` or `..` can leave the directory. +if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then + echo "Invalid hook name: $HOOK" >&2 + exit 2 +fi + HOOK_PATH="$HOME/.config/omarchy/hooks/$1" HOOK_DIR="$HOOK_PATH.d" shift diff --git a/bin/omarchy-state b/bin/omarchy-state index 4fda5b3a..3b2f5e17 100755 --- a/bin/omarchy-state +++ b/bin/omarchy-state @@ -21,6 +21,19 @@ if [[ -z $STATE_NAME ]]; then fi case "$COMMAND" in -set) touch "$STATE_DIR/$STATE_NAME" ;; +set) + # State names are fixed labels (reboot-required, restart-*-required). The + # name becomes a filename under the state directory. A slash would turn it + # into directory levels, and a bare `.` or `..` would touch the directory + # itself or its parent. Refuse those. Dots inside a name (a..b) are fine; + # once slashes are out, only the whole name being `.` or `..` can leave the + # directory. clear needs no such guard: find -name matches basenames only, + # so a pattern can never walk out of the directory. + if [[ $STATE_NAME == */* || $STATE_NAME == "." || $STATE_NAME == ".." ]]; then + echo "Invalid state name: $STATE_NAME" >&2 + exit 2 + fi + touch "$STATE_DIR/$STATE_NAME" + ;; clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;; esac diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh new file mode 100644 index 00000000..2955ad27 --- /dev/null +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +fake_home="$work_dir/home" +mkdir -p "$fake_home/.config/omarchy/hooks" "$fake_home/.local/state/omarchy" + +# --- omarchy-hook -------------------------------------------------------------- + +# A hook name is a label, not a path. One carrying a slash, or one that is a +# bare `.` or `..`, would run a script from outside the hooks directory. + +cat >"$fake_home/.config/omarchy/hooks/test-hook" <<'SH' +touch "$HOME/hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" test-hook +[[ -f $fake_home/hook-ran ]] || + fail "omarchy hook runs a named hook from the hooks directory" +pass "omarchy hook runs a named hook from the hooks directory" + +# Dots inside a name are not a path. a..b stays inside the hooks directory. +cat >"$fake_home/.config/omarchy/hooks/a..b" <<'SH' +touch "$HOME/dotted-hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" a..b +[[ -f $fake_home/dotted-hook-ran ]] || + fail "omarchy hook accepts a hook name with dots in the middle" +pass "omarchy hook accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook" "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook refuses a hook name of $name" "exit: $status" + pass "omarchy hook refuses a hook name of $name" +done + +# This file sits where a name of ../../evil would resolve: hooks/../.. is +# ~/.config. +cat >"$fake_home/.config/evil" <<'SH' +touch "$HOME/escape-ran" +SH +chmod +x "$fake_home/.config/evil" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "../../evil" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/escape-ran ]] || + fail "omarchy hook runs nothing when it refuses the name" +pass "omarchy hook refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a slash" "exit: $status" +pass "omarchy hook refuses a hook name with a slash" + +# --- omarchy-state ------------------------------------------------------------- + +state_dir="$fake_home/.local/state/omarchy" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set reboot-required +[[ -f $state_dir/reboot-required ]] || + fail "omarchy state set still creates a plain state file" +pass "omarchy state set still creates a plain state file" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set v1..2 +[[ -f $state_dir/v1..2 ]] || + fail "omarchy state set accepts a state name with dots in the middle" +pass "omarchy state set accepts a state name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-state" set "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy state set refuses a state name of $name" "exit: $status" + pass "omarchy state set refuses a state name of $name" +done + +# state/../.. is ~/.local. The guard must fire before touch gets there. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "../../escape" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.local/escape ]] || + fail "omarchy state set creates nothing outside the state directory" +pass "omarchy state set refuses a state name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a slash" "exit: $status" +pass "omarchy state set refuses a state name with a slash" + +# clear takes patterns by design ("state-name-or-pattern") and matches +# basenames through find -name, so it can never walk out of the directory. +touch "$state_dir/restart-a-required" "$state_dir/restart-b-required" "$state_dir/keep-me" +HOME="$fake_home" "$ROOT/bin/omarchy-state" clear "restart-*-required" +[[ ! -e $state_dir/restart-a-required && ! -e $state_dir/restart-b-required && -f $state_dir/keep-me ]] || + fail "omarchy state clear still clears matching patterns only" +pass "omarchy state clear still clears matching patterns only" From a75924aca4ad20275557332956fd2d8665160eab Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 5 Sep 2026 17:57:11 -0400 Subject: [PATCH 03/51] Erase old password hashes during factory reset --- bin/omarchy-system-factory-reset | 39 ++++-- test/shell.d/factory-reset-accounts-test.sh | 146 ++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 test/shell.d/factory-reset-accounts-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..f9e57d95 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh new file mode 100644 index 00000000..7f6f4a31 --- /dev/null +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if (( EUID != 0 )); then + if unshare --user --map-root-user true 2>/dev/null; then + exec unshare --user --map-root-user bash "$0" + fi + pass "no unprivileged user namespace; skipping factory account cleanup" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions" + +cat >"$test_tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$1/functions" +TOP_MNT="$2" +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$TOP_MNT/reset.log" + +log() { printf '%s\n' "$1" >>"$LOG_FILE"; } +fail() { log "$1"; exit 1; } + +# Account tools are real. Only snapshots, boot rebuilding, and system services +# are replaced: all writes stay inside this test's disposable directory. +btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then + mkdir -p "$4" + cp -a "$3/." "$4/" + elif [[ $1 == "property" ]]; then + printf '%s\n' "$6" >"$4/read-only" + else + return 1 + fi +} +systemd-id128() { printf '%032d\n' 1; } +install_provisioning_units() { :; } +encrypted_install() { return 1; } +rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; } +sync() { :; } + +userdel() { + [[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42 + command userdel "$@" +} +usermod() { + [[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42 + command usermod "$@" +} +rm() { + [[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42 + command rm "$@" +} + +stage_full_reset +SH + +make_fixture() { + local top="$1" root_hash="${2:-original-root-hash}" + local factory="$top/@factory" + mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \ + "$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \ + "$factory/var/lib/omarchy/provisioning/packages" + touch "$top/@/old-system" "$factory/home/seller/private-file" \ + "$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \ + "$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz" + printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner" + chmod +x "$factory/usr/bin/omarchy-provision-owner" + printf 'true\n' >"$factory/read-only" + cat >"$factory/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/:/usr/bin/nologin +seller:x:1000:1000:Seller:/home/seller:/bin/bash +EOF + printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \ + "$root_hash" >"$factory/etc/shadow" + printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" + printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" + printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + chmod 600 "$factory/etc/"{shadow,gshadow} + for file in passwd shadow group gshadow; do + cp "$factory/etc/$file" "$factory/etc/$file-" + done +} + +assert_scrubbed() { + local root="$1" file + [[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] || + fail "reset erases the root hash while keeping the account locked" + ! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} || + fail "reset removes seller account credentials and group membership" + [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" + grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" + [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" + for file in passwd shadow group gshadow; do + [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" + done +} + +for scenario in normal locked; do + top="$test_tmp/$scenario" + if [[ $scenario == "locked" ]]; then + make_fixture "$top" '!' + else + make_fixture "$top" + fi + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + [[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only" + [[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] || + fail "reset reaches provisioning after cleanup" + + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated" +done + +for target in @omarchy-reset-next @factory; do + for command in userdel usermod rm; do + top="$test_tmp/fail-$target-$command" + make_fixture "$top" + if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then + fail "reset accepted failed $command in $target" + fi + [[ -f $top/@/old-system && ! -e $top/rebuilt ]] || + fail "failed cleanup must not activate or rebuild the reset system" + [[ $(cat "$top/@factory/read-only") == "true" ]] || + fail "failed cleanup must leave the baseline read-only" + pass "failed $command in $target aborts reset before activation" + done +done From a00be8fa16c941749886a80d91413e9bbe3c09e4 Mon Sep 17 00:00:00 2001 From: omarchybot Date: Sun, 6 Sep 2026 05:37:10 -0700 Subject: [PATCH 04/51] Remove the subuid and subgid backups during the factory scrub userdel rewrites /etc/subuid and /etc/subgid, and like every shadow-utils database write it leaves the previous contents behind in a dash-suffixed backup. The scrub removed four of the six backups those tools produce, so the retained @factory baseline still named the previous owner in /etc/subuid- and /etc/subgid- along with their subordinate ID range. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-system-factory-reset | 2 +- test/shell.d/factory-reset-accounts-test.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index f9e57d95..43348e5a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -310,7 +310,7 @@ scrub_factory_accounts() { # passwd --lock preserves the hash. Replace it, then remove the backups # that userdel and usermod leave behind. usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 - rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} } # Remove the seller's account material and machine identity from the retained diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh index 7f6f4a31..ed2bb697 100644 --- a/test/shell.d/factory-reset-accounts-test.sh +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -90,8 +90,10 @@ EOF printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + printf 'seller:100000:65536\n' >"$factory/etc/subuid" + printf 'seller:100000:65536\n' >"$factory/etc/subgid" chmod 600 "$factory/etc/"{shadow,gshadow} - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do cp "$factory/etc/$file" "$factory/etc/$file-" done } @@ -105,7 +107,7 @@ assert_scrubbed() { [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" done } From e522a18ef0d31b5024a5a92697215abe7b57042e Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:59:36 +0300 Subject: [PATCH 05/51] Refuse path-like names in omarchy-hook-install too The runner already rejects a slash, a bare . or .. The installer still joined the type into hooks/.d before mkdir/cp, so a name nothing can run could still land on disk. --- bin/omarchy-hook-install | 11 +++++ test/shell.d/hook-state-name-guard-test.sh | 49 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/bin/omarchy-hook-install b/bin/omarchy-hook-install index 7c536482..6e954bd1 100755 --- a/bin/omarchy-hook-install +++ b/bin/omarchy-hook-install @@ -15,6 +15,17 @@ fi HOOK_TYPE=$1 HOOK_FILE=$2 + +# Hook types are the same labels omarchy-hook runs (post-update, theme-set). +# The type becomes a directory under the hooks directory. A slash would turn +# it into directory levels. A bare `.` or `..` is a name the runner already +# refuses, so installing under it would write a hook nothing can run. Refuse +# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine. +if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then + echo "Invalid hook name: $HOOK_TYPE" >&2 + exit 2 +fi + HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d" HOOK_NAME=$(basename "$HOOK_FILE") HOOK_PATH="$HOOK_DIR/$HOOK_NAME" diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh index 2955ad27..0157202b 100644 --- a/test/shell.d/hook-state-name-guard-test.sh +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -63,6 +63,55 @@ HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$ fail "omarchy hook refuses a hook name with a slash" "exit: $status" pass "omarchy hook refuses a hook name with a slash" +# --- omarchy-hook-install ------------------------------------------------------ + +# The installer joins the type into ~/.config/omarchy/hooks/.d before +# mkdir/cp. The runner already refuses a slashed type; install must too, or a +# name the runner will not run still lands on disk. + +source_hook="$work_dir/source-hook" +cat >"$source_hook" <<'SH' +#!/bin/bash +true +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" post-update "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/post-update.d/source-hook ]] || + fail "omarchy hook install still installs a named hook" +pass "omarchy hook install still installs a named hook" + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" a..b "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/a..b.d/source-hook ]] || + fail "omarchy hook install accepts a hook name with dots in the middle" +pass "omarchy hook install accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "$name" "$source_hook" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook install refuses a hook name of $name" "exit: $status" + [[ ! -e $fake_home/.config/omarchy/hooks/${name}.d ]] || + fail "omarchy hook install creates no directory for a hook name of $name" + pass "omarchy hook install refuses a hook name of $name" +done + +# hooks/../../evil.d is ~/.config/evil.d. The guard must fire before mkdir. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "../../evil" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.config/evil.d ]] || + fail "omarchy hook install creates nothing outside the hooks directory" +pass "omarchy hook install refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "sub/dir" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a slash" "exit: $status" +[[ ! -e $fake_home/.config/omarchy/hooks/sub ]] || + fail "omarchy hook install creates no nested directory from a slashed name" +pass "omarchy hook install refuses a hook name with a slash" + # --- omarchy-state ------------------------------------------------------------- state_dir="$fake_home/.local/state/omarchy" From 5db4a40195eee1c8c45ba318d0c5ce307db08bd3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:11:00 +0200 Subject: [PATCH 06/51] Loosen the offline Node pin so mise up tracks new releases Offline installs unpack the bundled tarball and pin Node to its exact version, since latest can't be resolved without network. But nothing ever loosened that pin, so Node stayed frozen at the ISO's version and mup skipped it forever, while online installs tracked latest. Rewrite the pin to latest right after registering the bundled version: mise resolves latest to the installed version while offline (verified with no network and an empty cache), and the first mise up with network picks up new releases just like an online install. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017LseZ1jcLaFBnndRnW4yb5 --- install/user/mise-work.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index f77d5561..7490979f 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -37,6 +37,11 @@ if [[ -n $NODE_PACKAGE_DIR ]]; then mkdir -p "$NODE_INSTALL_DIR" tar -xzf "$NODE_TARBALL" --strip-components=1 -C "$NODE_INSTALL_DIR" mise use -g node@"$NODE_VERSION" + + # That pinned the exact bundled version, which would exempt Node from + # mise up forever. Loosen it to latest, like an online install gets: + # mise resolves latest to the installed version while offline. + mise config set tools.node latest --file "$HOME/.config/mise/config.toml" fi else mise use -g node@latest From 9246647071329fed57066cfd8acb34438d01df5d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:15:27 +0200 Subject: [PATCH 07/51] Switch TCP congestion control to BBR with fq pacing Cubic keeps pushing until packets drop, which stands queues up in the path on fast links. BBR paces to its estimate of bottleneck bandwidth and minimum RTT instead, cutting queueing latency while keeping throughput. fq is the qdisc BBR is built to pace through. tcp_bbr and sch_fq are modules in every kernel Omarchy ships and autoload when the sysctls are set. The migration re-applies the shipped file so new connections switch without a reboot, no-ops once the live values match, and flags a reboot if applying fails. Co-Authored-By: Claude Fable 5.1 --- etc/sysctl.d/99-omarchy-sysctl.conf | 6 ++++++ migrations/1789294350.sh | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 migrations/1789294350.sh diff --git a/etc/sysctl.d/99-omarchy-sysctl.conf b/etc/sysctl.d/99-omarchy-sysctl.conf index 1cce6d7f..dac0fb2b 100644 --- a/etc/sysctl.d/99-omarchy-sysctl.conf +++ b/etc/sysctl.d/99-omarchy-sysctl.conf @@ -1,6 +1,12 @@ # Solve common flakiness with SSH (MTU discovery on flaky links). net.ipv4.tcp_mtu_probing=1 +# BBR estimates bottleneck bandwidth and minimum RTT and paces to them, where +# cubic keeps pushing until packets drop. That cuts queueing latency +# (bufferbloat) on fast links. fq is the qdisc BBR is built to pace through. +net.core.default_qdisc=fq +net.ipv4.tcp_congestion_control=bbr + # Tune reclaim for swap on zram, which is orders of magnitude faster than the # disk swapfile these defaults assume. diff --git a/migrations/1789294350.sh b/migrations/1789294350.sh new file mode 100644 index 00000000..9eba0416 --- /dev/null +++ b/migrations/1789294350.sh @@ -0,0 +1,11 @@ +echo "Switch TCP congestion control to BBR with fq pacing" + +# Boot applies the shipped file regardless; this only makes new connections +# use BBR now. Setting the sysctls autoloads tcp_bbr and sch_fq. Qdiscs +# already attached to interfaces stay until they are recreated, normally at +# reboot. +if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" && $(sysctl -n net.core.default_qdisc) == "fq" ]]; then + exit 0 +fi + +sudo sysctl -p /etc/sysctl.d/99-omarchy-sysctl.conf >/dev/null || omarchy-state set reboot-required From f464b6087dd4550ed153d940fccd38531a83425b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 14 Sep 2026 23:45:34 -0400 Subject: [PATCH 08/51] Use Omarchy kernel headers for hardware DKMS drivers --- install/hardware/fix-bcm43xx.sh | 2 +- install/hardware/fix-tuxedo-backlight.sh | 2 +- install/hardware/fix-yt6801-ethernet-adapter.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index 6b7e5648..94fd16e6 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl-dkms linux-headers + omarchy-pkg-add broadcom-wl-dkms linux-omarchy-headers fi diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index 9644d310..a2d3b8c6 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,7 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add linux-headers tuxedo-drivers-nocompatcheck-dkms + omarchy-pkg-add linux-omarchy-headers tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers # clevo_wmi module. When clevo_xsm_wmi loads first, it grabs the Clevo WMI GUIDs, diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index 34331a0f..7c0360e6 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,4 +1,4 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add linux-headers yt6801-dkms + omarchy-pkg-add linux-omarchy-headers yt6801-dkms fi From 662051ecde66c5d4f9501df1e816ae5136a408cb Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 15 Sep 2026 00:06:17 -0400 Subject: [PATCH 09/51] Install matching kernel headers for every DKMS setup --- bin/omarchy-install-gaming-xbox-controllers | 3 +- bin/omarchy-pkg-add-kernel-headers | 13 +++ install/hardware/apple/fix-spi-keyboard.sh | 1 + install/hardware/fix-bcm43xx.sh | 3 +- install/hardware/fix-elgato-camlink-4k.sh | 4 +- install/hardware/fix-tuxedo-backlight.sh | 3 +- .../hardware/fix-yt6801-ethernet-adapter.sh | 3 +- install/hardware/intel/ipu7-camera.sh | 1 + install/hardware/nvidia.sh | 5 +- install/omarchy-other.packages | 2 - migrations/1789444024.sh | 8 ++ test/acceptance.d/system-test.sh | 16 +++- test/shell.d/kernel-headers-test.sh | 89 +++++++++++++++++++ 13 files changed, 138 insertions(+), 13 deletions(-) create mode 100755 bin/omarchy-pkg-add-kernel-headers create mode 100644 migrations/1789444024.sh create mode 100755 test/shell.d/kernel-headers-test.sh diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index f4134f76..8f7bdf28 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -10,7 +10,8 @@ set -e echo "Installing Xbox controller Bluetooth support..." # Install xpadneo to ensure controllers work out of the box -omarchy-pkg-add linux-headers xpadneo-dkms +omarchy-pkg-add-kernel-headers +omarchy-pkg-add xpadneo-dkms # Prevent xpad/xpadneo driver conflict echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null diff --git a/bin/omarchy-pkg-add-kernel-headers b/bin/omarchy-pkg-add-kernel-headers new file mode 100755 index 00000000..28590b36 --- /dev/null +++ b/bin/omarchy-pkg-add-kernel-headers @@ -0,0 +1,13 @@ +#!/bin/bash + +# omarchy:summary=Install headers for the supported system kernel +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Inspect the target's packages: uname reports the live ISO kernel in a chroot. +# A retained stock kernel must not take precedence over the Omarchy kernel. +if omarchy-pkg-present linux-t2; then + omarchy-pkg-add linux-t2-headers +else + omarchy-pkg-add linux-omarchy-headers +fi diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index 330081b7..fd5d8e45 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -3,6 +3,7 @@ product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" + omarchy-pkg-add-kernel-headers omarchy-pkg-add macbook12-spi-driver-dkms sudo mkdir -p /etc/mkinitcpio.conf.d if [[ $product_name == "MacBook8,1" ]]; then diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index 94fd16e6..f829749e 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,6 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl-dkms linux-omarchy-headers + omarchy-pkg-add-kernel-headers + omarchy-pkg-add broadcom-wl-dkms fi diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index 274076ce..d12cbfe4 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,8 +4,8 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | sed 's/$/-headers/') - omarchy-pkg-add "${kernel_headers[@]}" v4l2loopback-dkms v4l2loopback-utils v4l2-relayd + omarchy-pkg-add-kernel-headers + omarchy-pkg-add v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules sudo install -Dm644 "$OMARCHY_PATH/default/v4l2-relayd/camlink.conf" /etc/v4l2-relayd.d/camlink.conf diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index a2d3b8c6..c40fadb8 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,8 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add linux-omarchy-headers tuxedo-drivers-nocompatcheck-dkms + omarchy-pkg-add-kernel-headers + omarchy-pkg-add tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers # clevo_wmi module. When clevo_xsm_wmi loads first, it grabs the Clevo WMI GUIDs, diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index 7c0360e6..e20cb92e 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,4 +1,5 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add linux-omarchy-headers yt6801-dkms + omarchy-pkg-add-kernel-headers + omarchy-pkg-add yt6801-dkms fi diff --git a/install/hardware/intel/ipu7-camera.sh b/install/hardware/intel/ipu7-camera.sh index 97e5549b..45f1aced 100644 --- a/install/hardware/intel/ipu7-camera.sh +++ b/install/hardware/intel/ipu7-camera.sh @@ -1,5 +1,6 @@ # Install MIPI camera support for Intel IPU7 hardware if grep -q "OVTI08F4" /sys/bus/acpi/devices/*/hid 2>/dev/null; then + omarchy-pkg-add-kernel-headers omarchy-pkg-add intel-ipu7-camera fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index 48f62cd4..3b9adc18 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -1,8 +1,4 @@ if lspci | grep -qi 'nvidia'; then - # Check which kernel is installed and set appropriate headers package - KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | head -1 || true) - [[ -n $KERNEL_PACKAGE ]] && omarchy-pkg-add "$KERNEL_PACKAGE-headers" - if omarchy-hw-nvidia-gsp; then PACKAGES=(nvidia-open-dkms nvidia-utils lib32-nvidia-utils libva-nvidia-driver) elif omarchy-hw-nvidia-without-gsp; then @@ -15,6 +11,7 @@ if lspci | grep -qi 'nvidia'; then exit 0 fi + omarchy-pkg-add-kernel-headers omarchy-pkg-add "${PACKAGES[@]}" # Per-session Hyprland NVIDIA env vars are handled by default/hypr/nvidia.lua. diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 1f325b24..4a8b128f 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -20,9 +20,7 @@ libva-nvidia-driver limine limine-mkinitcpio-hook limine-snapper-sync -linux linux-firmware -linux-headers linux-omarchy linux-omarchy-headers macbook12-spi-driver-dkms diff --git a/migrations/1789444024.sh b/migrations/1789444024.sh new file mode 100644 index 00000000..05107342 --- /dev/null +++ b/migrations/1789444024.sh @@ -0,0 +1,8 @@ +echo "Install missing headers for the Omarchy or T2 kernel" + +# Fresh ISO installs mark earlier migrations complete, so the kernel migration +# cannot repair headers omitted by those installers. Package installation is +# idempotent when another user has already applied this repair. +if omarchy-pkg-present linux-omarchy || omarchy-pkg-present linux-t2; then + omarchy-pkg-add-kernel-headers +fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 0d648e9c..af0d5204 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -24,6 +24,20 @@ verify_core_packages() { pass "all Omarchy core packages are installed (${#missing[@]} missing)" } +verify_kernel_headers() { + local kernel=linux-omarchy + local release + release=$(uname -r) + omarchy-pkg-present linux-t2 && kernel=linux-t2 + + [[ $(cat "/usr/lib/modules/$release/pkgbase") == "$kernel" ]] || + fail "the installed system boots the supported kernel" "$release is not $kernel" + omarchy-pkg-present "$kernel-headers" || fail "kernel headers are installed" "$kernel-headers is missing" + [[ $(cat "/usr/lib/modules/$release/build/include/config/kernel.release") == "$release" ]] || + fail "headers match the running kernel" "$release has missing or mismatched headers" + pass "the running $kernel kernel has matching headers ($release)" +} + verify_defaults() { [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium is the default browser" pass "Chromium is the default browser" @@ -112,7 +126,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_kernel_headers verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/kernel-headers-test.sh b/test/shell.d/kernel-headers-test.sh new file mode 100755 index 00000000..51db25d0 --- /dev/null +++ b/test/shell.d/kernel-headers-test.sh @@ -0,0 +1,89 @@ +#!/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/etc/modprobe.d" "$tmp_dir/sys/class/dmi/id" "$tmp_dir/sys/bus/acpi/devices/camera" +export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" +export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" + +cat > "$tmp_dir/bin/pacman" <<'SH' +#!/bin/bash +[[ $1 == "-Q" ]] || exit 1 +grep -Fxq -- "$2" "$INSTALLED_PACKAGES" +SH +cat > "$tmp_dir/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$CALL_LOG" +[[ ${FAIL_HEADERS:-0} != 1 || $* != *-headers* ]] +SH +cat > "$tmp_dir/bin/lspci" <<'SH' +#!/bin/bash +printf '%s\n' 'NVIDIA [10de:2560]' 'Broadcom [14e4:43a0]' 'Motorcomm YT6801 Ethernet' +SH +cat > "$tmp_dir/bin/id" <<'SH' +#!/bin/bash +echo input +SH +for command in sudo lsmod omarchy-hw-nvidia-gsp omarchy-hw-elgato-camlink-4k; do + printf '#!/bin/bash\nexit 0\n' > "$tmp_dir/bin/$command" +done +chmod +x "$tmp_dir/bin/"* + +for installed in linux-omarchy linux-t2 linux; do + echo "$installed" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + bash -euo pipefail "$ROOT/migrations/1789444024.sh" >/dev/null + if [[ $installed == "linux" ]]; then + [[ ! -s $CALL_LOG ]] || fail "header repair skips systems without a supported kernel" + else + [[ $(cat "$CALL_LOG") == "$installed-headers" ]] || fail "header repair covers existing $installed installs" + fi +done +pass "header repair covers existing Omarchy and T2 installs" +echo MacBook8,1 > "$tmp_dir/sys/class/dmi/id/product_name" +echo TUXEDO > "$tmp_dir/sys/class/dmi/id/sys_vendor" +echo OVTI08F4 > "$tmp_dir/sys/bus/acpi/devices/camera/hid" + +for installed in 'linux-omarchy' 'linux linux-omarchy' 'linux' 'linux-t2' 'linux linux-omarchy linux-t2'; do + expected=linux-omarchy-headers + [[ $installed == *linux-t2* ]] && expected=linux-t2-headers + read -ra packages <<< "$installed" + printf '%s\n' "${packages[@]}" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + omarchy-pkg-add-kernel-headers + [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "headers match supported kernel with $installed installed" + pass "headers match supported kernel with $installed installed" +done + +# Run every DKMS installer against hardware fixtures and stub package writes. +# Redirect absolute filesystem paths in sourced leaves into the fixture too. +for family in linux-omarchy linux-t2; do + printf '%s\n' linux "$family" > "$INSTALLED_PACKAGES" + for script in \ + bin/omarchy-install-gaming-xbox-controllers \ + install/hardware/nvidia.sh \ + install/hardware/apple/fix-spi-keyboard.sh \ + install/hardware/intel/ipu7-camera.sh \ + install/hardware/fix-bcm43xx.sh \ + install/hardware/fix-tuxedo-backlight.sh \ + install/hardware/fix-yt6801-ethernet-adapter.sh \ + install/hardware/fix-elgato-camlink-4k.sh; do + sed -e "s|/etc/|$tmp_dir/etc/|g" -e "s|/sys/|$tmp_dir/sys/|g" \ + -e "s|/lib/modules/|$tmp_dir/lib/modules/|g" "$ROOT/$script" > "$tmp_dir/installer.sh" + : > "$CALL_LOG" + bash -e "$tmp_dir/installer.sh" >/dev/null + [[ $(head -1 "$CALL_LOG") == "$family-headers" ]] || fail "$script installs $family headers first" "$(cat "$CALL_LOG")" + (( $(wc -l < "$CALL_LOG") == 2 )) || fail "$script installs its driver after the headers" + ! grep -wq linux-headers "$CALL_LOG" || fail "$script avoids stock headers" + + : > "$CALL_LOG" + if FAIL_HEADERS=1 bash -e "$tmp_dir/installer.sh" >/dev/null; then + fail "$script stops when headers cannot be installed" + fi + [[ $(cat "$CALL_LOG") == "$family-headers" ]] || fail "$script must not install DKMS drivers without headers" + pass "$script uses $family headers and stops on header installation failure" + done +done From 24417bf191e09fc4d672c74c34e184449e92aeda Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 15 Sep 2026 00:46:30 -0400 Subject: [PATCH 10/51] Treat matching kernel headers as a base system guarantee --- agents/skills/install-scripts.md | 1 + bin/omarchy-install-gaming-xbox-controllers | 1 - bin/omarchy-pkg-add-kernel-headers | 13 --- install/hardware/apple/fix-spi-keyboard.sh | 1 - install/hardware/fix-bcm43xx.sh | 1 - install/hardware/fix-elgato-camlink-4k.sh | 1 - install/hardware/fix-tuxedo-backlight.sh | 1 - .../hardware/fix-yt6801-ethernet-adapter.sh | 1 - install/hardware/intel/ipu7-camera.sh | 1 - install/hardware/nvidia.sh | 1 - migrations/1789444024.sh | 8 +- test/shell.d/kernel-headers-migration-test.sh | 58 ++++++++++++ test/shell.d/kernel-headers-test.sh | 89 ------------------- 13 files changed, 64 insertions(+), 113 deletions(-) delete mode 100755 bin/omarchy-pkg-add-kernel-headers create mode 100755 test/shell.d/kernel-headers-migration-test.sh delete mode 100755 test/shell.d/kernel-headers-test.sh diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md index 999a6e9c..aa993e5e 100644 --- a/agents/skills/install-scripts.md +++ b/agents/skills/install-scripts.md @@ -13,6 +13,7 @@ commands and reusable setup leaves: - use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths. - keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`. - keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user. +- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist. - prefer helper commands for package and command checks where available. Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index 8f7bdf28..f2cda4e7 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -10,7 +10,6 @@ set -e echo "Installing Xbox controller Bluetooth support..." # Install xpadneo to ensure controllers work out of the box -omarchy-pkg-add-kernel-headers omarchy-pkg-add xpadneo-dkms # Prevent xpad/xpadneo driver conflict diff --git a/bin/omarchy-pkg-add-kernel-headers b/bin/omarchy-pkg-add-kernel-headers deleted file mode 100755 index 28590b36..00000000 --- a/bin/omarchy-pkg-add-kernel-headers +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install headers for the supported system kernel -# omarchy:hidden=true -# omarchy:requires-sudo=true - -# Inspect the target's packages: uname reports the live ISO kernel in a chroot. -# A retained stock kernel must not take precedence over the Omarchy kernel. -if omarchy-pkg-present linux-t2; then - omarchy-pkg-add linux-t2-headers -else - omarchy-pkg-add linux-omarchy-headers -fi diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index fd5d8e45..330081b7 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -3,7 +3,6 @@ product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" - omarchy-pkg-add-kernel-headers omarchy-pkg-add macbook12-spi-driver-dkms sudo mkdir -p /etc/mkinitcpio.conf.d if [[ $product_name == "MacBook8,1" ]]; then diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index f829749e..0f9cf474 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,6 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add-kernel-headers omarchy-pkg-add broadcom-wl-dkms fi diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index d12cbfe4..9ae9e343 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,7 +4,6 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index c40fadb8..7fb408e0 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,6 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index e20cb92e..f88a9cec 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,5 +1,4 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add yt6801-dkms fi diff --git a/install/hardware/intel/ipu7-camera.sh b/install/hardware/intel/ipu7-camera.sh index 45f1aced..97e5549b 100644 --- a/install/hardware/intel/ipu7-camera.sh +++ b/install/hardware/intel/ipu7-camera.sh @@ -1,6 +1,5 @@ # Install MIPI camera support for Intel IPU7 hardware if grep -q "OVTI08F4" /sys/bus/acpi/devices/*/hid 2>/dev/null; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add intel-ipu7-camera fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index 3b9adc18..1c36787b 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -11,7 +11,6 @@ if lspci | grep -qi 'nvidia'; then exit 0 fi - omarchy-pkg-add-kernel-headers omarchy-pkg-add "${PACKAGES[@]}" # Per-session Hyprland NVIDIA env vars are handled by default/hypr/nvidia.lua. diff --git a/migrations/1789444024.sh b/migrations/1789444024.sh index 05107342..b5bc9149 100644 --- a/migrations/1789444024.sh +++ b/migrations/1789444024.sh @@ -3,6 +3,8 @@ echo "Install missing headers for the Omarchy or T2 kernel" # Fresh ISO installs mark earlier migrations complete, so the kernel migration # cannot repair headers omitted by those installers. Package installation is # idempotent when another user has already applied this repair. -if omarchy-pkg-present linux-omarchy || omarchy-pkg-present linux-t2; then - omarchy-pkg-add-kernel-headers -fi +for kernel in linux-omarchy linux-t2; do + if omarchy-pkg-present "$kernel"; then + omarchy-pkg-add "$kernel-headers" + fi +done diff --git a/test/shell.d/kernel-headers-migration-test.sh b/test/shell.d/kernel-headers-migration-test.sh new file mode 100755 index 00000000..458ea330 --- /dev/null +++ b/test/shell.d/kernel-headers-migration-test.sh @@ -0,0 +1,58 @@ +#!/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" +export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" +export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" + +# Keep the real package helpers, but contain every pacman transaction here. +cat > "$tmp_dir/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;; + -S) + [[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1 + shift 3 # -S --noconfirm --needed + printf '%s\n' "$@" >> "$INSTALLED_PACKAGES" + printf '%s\n' "$@" >> "$CALL_LOG" + ;; + *) exit 1 ;; +esac +SH +cat > "$tmp_dir/bin/sudo" <<'SH' +#!/bin/bash +[[ $1 == "pacman" ]] || exit 1 +"$@" +SH +chmod +x "$tmp_dir/bin/"* + +migration="$ROOT/migrations/1789444024.sh" +for kernels in linux-omarchy linux-t2 'linux-omarchy linux-t2'; do + read -ra installed <<< "$kernels" + printf '%s\n' linux linux-headers "${installed[@]}" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + bash -euo pipefail "$migration" >/dev/null + for kernel in "${installed[@]}"; do + grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "$kernel gets its headers" + done + : > "$CALL_LOG" + bash -euo pipefail "$migration" >/dev/null + [[ ! -s $CALL_LOG ]] || fail "header repair is idempotent" + pass "missing headers are repaired once for $kernels" +done + +printf '%s\n' linux linux-aarch64 > "$INSTALLED_PACKAGES" +: > "$CALL_LOG" +bash -euo pipefail "$migration" >/dev/null +[[ ! -s $CALL_LOG ]] || fail "header repair skips unrelated kernels" +pass "header repair skips unrelated kernels" + +echo linux-omarchy > "$INSTALLED_PACKAGES" +if FAIL_INSTALL=1 bash -euo pipefail "$migration" >/dev/null; then + fail "a failed header installation must leave the migration pending" +fi +pass "header installation failure is propagated" diff --git a/test/shell.d/kernel-headers-test.sh b/test/shell.d/kernel-headers-test.sh deleted file mode 100755 index 51db25d0..00000000 --- a/test/shell.d/kernel-headers-test.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/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/etc/modprobe.d" "$tmp_dir/sys/class/dmi/id" "$tmp_dir/sys/bus/acpi/devices/camera" -export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" -export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" - -cat > "$tmp_dir/bin/pacman" <<'SH' -#!/bin/bash -[[ $1 == "-Q" ]] || exit 1 -grep -Fxq -- "$2" "$INSTALLED_PACKAGES" -SH -cat > "$tmp_dir/bin/omarchy-pkg-add" <<'SH' -#!/bin/bash -printf '%s\n' "$*" >> "$CALL_LOG" -[[ ${FAIL_HEADERS:-0} != 1 || $* != *-headers* ]] -SH -cat > "$tmp_dir/bin/lspci" <<'SH' -#!/bin/bash -printf '%s\n' 'NVIDIA [10de:2560]' 'Broadcom [14e4:43a0]' 'Motorcomm YT6801 Ethernet' -SH -cat > "$tmp_dir/bin/id" <<'SH' -#!/bin/bash -echo input -SH -for command in sudo lsmod omarchy-hw-nvidia-gsp omarchy-hw-elgato-camlink-4k; do - printf '#!/bin/bash\nexit 0\n' > "$tmp_dir/bin/$command" -done -chmod +x "$tmp_dir/bin/"* - -for installed in linux-omarchy linux-t2 linux; do - echo "$installed" > "$INSTALLED_PACKAGES" - : > "$CALL_LOG" - bash -euo pipefail "$ROOT/migrations/1789444024.sh" >/dev/null - if [[ $installed == "linux" ]]; then - [[ ! -s $CALL_LOG ]] || fail "header repair skips systems without a supported kernel" - else - [[ $(cat "$CALL_LOG") == "$installed-headers" ]] || fail "header repair covers existing $installed installs" - fi -done -pass "header repair covers existing Omarchy and T2 installs" -echo MacBook8,1 > "$tmp_dir/sys/class/dmi/id/product_name" -echo TUXEDO > "$tmp_dir/sys/class/dmi/id/sys_vendor" -echo OVTI08F4 > "$tmp_dir/sys/bus/acpi/devices/camera/hid" - -for installed in 'linux-omarchy' 'linux linux-omarchy' 'linux' 'linux-t2' 'linux linux-omarchy linux-t2'; do - expected=linux-omarchy-headers - [[ $installed == *linux-t2* ]] && expected=linux-t2-headers - read -ra packages <<< "$installed" - printf '%s\n' "${packages[@]}" > "$INSTALLED_PACKAGES" - : > "$CALL_LOG" - omarchy-pkg-add-kernel-headers - [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "headers match supported kernel with $installed installed" - pass "headers match supported kernel with $installed installed" -done - -# Run every DKMS installer against hardware fixtures and stub package writes. -# Redirect absolute filesystem paths in sourced leaves into the fixture too. -for family in linux-omarchy linux-t2; do - printf '%s\n' linux "$family" > "$INSTALLED_PACKAGES" - for script in \ - bin/omarchy-install-gaming-xbox-controllers \ - install/hardware/nvidia.sh \ - install/hardware/apple/fix-spi-keyboard.sh \ - install/hardware/intel/ipu7-camera.sh \ - install/hardware/fix-bcm43xx.sh \ - install/hardware/fix-tuxedo-backlight.sh \ - install/hardware/fix-yt6801-ethernet-adapter.sh \ - install/hardware/fix-elgato-camlink-4k.sh; do - sed -e "s|/etc/|$tmp_dir/etc/|g" -e "s|/sys/|$tmp_dir/sys/|g" \ - -e "s|/lib/modules/|$tmp_dir/lib/modules/|g" "$ROOT/$script" > "$tmp_dir/installer.sh" - : > "$CALL_LOG" - bash -e "$tmp_dir/installer.sh" >/dev/null - [[ $(head -1 "$CALL_LOG") == "$family-headers" ]] || fail "$script installs $family headers first" "$(cat "$CALL_LOG")" - (( $(wc -l < "$CALL_LOG") == 2 )) || fail "$script installs its driver after the headers" - ! grep -wq linux-headers "$CALL_LOG" || fail "$script avoids stock headers" - - : > "$CALL_LOG" - if FAIL_HEADERS=1 bash -e "$tmp_dir/installer.sh" >/dev/null; then - fail "$script stops when headers cannot be installed" - fi - [[ $(cat "$CALL_LOG") == "$family-headers" ]] || fail "$script must not install DKMS drivers without headers" - pass "$script uses $family headers and stops on header installation failure" - done -done From 45e32c8c2d000ffee5488e16fb7601100a2c0511 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 08:10:58 -0400 Subject: [PATCH 11/51] Set up browser integration when choosing Claude --- bin/omarchy-agent | 2 +- bin/omarchy-default-agent | 5 +++ bin/omarchy-install-chromium-claude | 43 +++++++++++++++++++ manual/17-ai.md | 2 + test/shell.d/chromium-claude-test.sh | 50 +++++++++++++++++++++++ test/shell.d/default-agent-test.sh | 22 +++++++++- test/shell.d/unowned-system-paths-test.sh | 7 +++- 7 files changed, 126 insertions(+), 5 deletions(-) create mode 100755 bin/omarchy-install-chromium-claude create mode 100644 test/shell.d/chromium-claude-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index e35bcf04..23d0e2f3 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -75,7 +75,7 @@ crush) fi ;; claude) - command=(claude --permission-mode auto) + command=(claude --permission-mode auto --chrome) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; grok) diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index af93d863..8c14a4f2 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -86,6 +86,11 @@ if ! agent_install; then exit 1 fi +if [[ $agent == "claude" ]] && ! omarchy-install-chromium-claude; then + echo "Could not install the Claude browser extension" >&2 + exit 1 +fi + mkdir -p "$(dirname "$agent_file")" printf '%s\n' "$agent" >"$agent_file" diff --git a/bin/omarchy-install-chromium-claude b/bin/omarchy-install-chromium-claude new file mode 100755 index 00000000..df338271 --- /dev/null +++ b/bin/omarchy-install-chromium-claude @@ -0,0 +1,43 @@ +#!/bin/bash + +# omarchy:summary=Install the Claude extension for Chromium-based browsers +# omarchy:requires-sudo=true + +set -euo pipefail + +if (( EUID == 0 )); then + export PATH=/usr/bin:/bin +fi + +EXTENSION_ID="fcoeoabgfenejglbffodgkkbkcdhcgfn" +EXTENSION_JSON='{ "external_update_url": "https://clients2.google.com/service/update2/crx" }' +PACKAGED_PATH=/usr/bin/omarchy-install-chromium-claude + +# Brave and Brave Origin share Chromium's external extension directory. +# Seed all supported browsers, including those installed after choosing Claude. +EXTENSION_DIRS=( + /usr/share/chromium/extensions + /usr/share/google-chrome/extensions + /usr/share/microsoft-edge/extensions +) + +installed=true +for dir in "${EXTENSION_DIRS[@]}"; do + if [[ ! -f $dir/$EXTENSION_ID.json ]] || [[ $(cat "$dir/$EXTENSION_ID.json") != "$EXTENSION_JSON" ]]; then + installed=false + fi +done + +if [[ $installed == "true" ]]; then + exit 0 +fi + +if (( EUID == 0 )); then + for dir in "${EXTENSION_DIRS[@]}"; do + printf '%s\n' "$EXTENSION_JSON" | install -D -m 0644 /dev/stdin "$dir/$EXTENSION_ID.json" + done +elif [[ -t 0 ]]; then + exec sudo "$PACKAGED_PATH" +else + exec pkexec "$PACKAGED_PATH" +fi diff --git a/manual/17-ai.md b/manual/17-ai.md index 8c3d48b0..7f76d481 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,6 +26,8 @@ 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. +Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge, and enables browser integration when launched through `omarchy agent`. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. + [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. diff --git a/test/shell.d/chromium-claude-test.sh b/test/shell.d/chromium-claude-test.sh new file mode 100644 index 00000000..5d8c61ef --- /dev/null +++ b/test/shell.d/chromium-claude-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Exercise the privileged installer without writing to the host's /usr/share. +if ! command -v bwrap >/dev/null || ! bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true 2>/dev/null; then + pass "user namespaces unavailable; skipping isolated Claude extension installation" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/share" "$test_tmp/bin" +installer="$ROOT/bin/omarchy-install-chromium-claude" +extension_id=fcoeoabgfenejglbffodgkkbkcdhcgfn +sandbox=(bwrap --ro-bind / / --bind "$test_tmp" "$test_tmp" --dev /dev --proc /proc --unshare-user) + +"${sandbox[@]}" --uid 0 --gid 0 --bind "$test_tmp/share" /usr/share bash "$installer" +for browser in chromium google-chrome microsoft-edge; do + file="$test_tmp/share/$browser/extensions/$extension_id.json" + jq -e '.external_update_url == "https://clients2.google.com/service/update2/crx"' "$file" >/dev/null || + fail "$browser registers the official Claude Web Store extension" + [[ $(stat -c '%a' "$file") == "644" ]] || fail "$browser extension registration is readable" +done +pass "Claude extension installer registers all supported browser families" + +cat >"$test_tmp/bin/pkexec" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >"$AUTH_LOG" +exit 42 +SH +chmod +x "$test_tmp/bin/pkexec" +export AUTH_LOG="$test_tmp/auth-log" +export PATH="$test_tmp/bin:$PATH" + +# A read-only /usr/share and a failing auth stub prove that a repeated run +# neither rewrites the files nor requests authentication. +"${sandbox[@]}" --uid 1000 --gid 1000 --ro-bind "$test_tmp/share" /usr/share bash "$installer" "$mock_bin/omarchy-install-chromium-claude" <<'SH' +#!/bin/bash +echo claude-extension >>"$OMARCHY_TEST_STUB_LOG" +[[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} != "true" ]] +SH + cat >"$mock_bin/omarchy-notification-send" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_NOTIFICATION_HISTORY" @@ -406,9 +412,16 @@ declare -A expected_packages=( for selection in "${!expected_agents[@]}"; do expected=${expected_agents[$selection]} : >"$agent_open_log" + : >"$stub_log" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent "$selection" [[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $selection" + if [[ $expected == "claude" ]]; then + grep -qx claude-extension "$stub_log" || fail "Claude selection installs the browser extension" + else + [[ ! -s $stub_log ]] || fail "other agents do not install the Claude extension" + fi + mapfile -d '' -t mise_args <"$mise_log" [[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" ]] || fail "default agent installs $selection globally through mise" @@ -427,6 +440,11 @@ pass "default agent selects and opens every supported provider and alias" pass "default agent stores its selection in Omarchy user config" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi +if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1; then + fail "Claude selection fails when browser extension installation fails" +fi +[[ $(omarchy-default-agent) == "pi" ]] || fail "extension installation failure preserves the default agent" +pass "extension installation failure preserves the default agent" : >"$notification_history" : >"$agent_open_log" : >"$terminal_log" @@ -644,7 +662,7 @@ assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" 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 claude claude --permission-mode auto --chrome -- "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" @@ -672,7 +690,7 @@ 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 claude claude --permission-mode auto --chrome assert_bypass codex codex --approve-for-me assert_bypass muse muse --approval-mode never assert_bypass crush crush --yolo diff --git a/test/shell.d/unowned-system-paths-test.sh b/test/shell.d/unowned-system-paths-test.sh index e2450619..efe940b4 100755 --- a/test/shell.d/unowned-system-paths-test.sh +++ b/test/shell.d/unowned-system-paths-test.sh @@ -31,10 +31,13 @@ allowed = { # them so the hook does not exist where it does not apply. "/usr/lib/systemd/system-sleep", # Written through a variable, so the scan below cannot see them at the point - # they are written. Both drop configuration into another project's tree rather - # than Omarchy's, which is why neither is a candidate for omarchy-settings. + # they are written. These drop configuration into another project's tree + # rather than Omarchy's and are not candidates for omarchy-settings. "/usr/share/chromium/extensions", "/usr/lib/firefox/distribution", + # Claude's extension is registered only when the user selects Claude. + "/usr/share/google-chrome/extensions", + "/usr/share/microsoft-edge/extensions", # Static content that belongs in omarchy-settings. It cannot move there in the # same release that first ships omarchy-update-system-pkgs-when-conflicted: the # upgrade carrying the handler is the one that would hit the conflict, and the From 49418942c898c8ed9ea604c35a71f0d5275ae26c Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:50:20 +0300 Subject: [PATCH 12/51] Merge pull request #7807 from Adolanium/keyring-fail-loud Stop update-keyring from claiming success when key operations fail --- bin/omarchy-update-keyring | 9 ++ test/shell.d/update-keyring-test.sh | 137 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 test/shell.d/update-keyring-test.sh diff --git a/bin/omarchy-update-keyring b/bin/omarchy-update-keyring index d561e22a..3a1c1c17 100755 --- a/bin/omarchy-update-keyring +++ b/bin/omarchy-update-keyring @@ -3,6 +3,11 @@ # omarchy:summary=Ensure the Omarchy and Arch keyring packages are installed and populated # omarchy:requires-sudo=true +# omarchy-update runs this under set -e as a trusted pre-step, so a failed recv +# or a broken keyring has to stop this script here, not surface later as +# signature errors in the middle of the main transaction. +set -euo pipefail + if omarchy-pkg-missing omarchy-keyring || ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then sudo pacman-key --recv-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 --keyserver keys.openpgp.org sudo pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 @@ -19,4 +24,8 @@ fi # Always reinstall, as the keyring can be updated without a package version bump. echo -e "\e[32m\nUpdate Arch signing keys\e[0m" sudo pacman -Sy --noconfirm archlinux-keyring >/dev/null 2> >(grep -vE '^warning: archlinux-keyring-[^ ]+ is up to date -- reinstalling$' >&2) + +# Say "correct" only once the key verifiably is: before the failure checks +# above, a failed recv or reinstall still ended here with exit 0. +sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 >/dev/null echo "Keys are correct" diff --git a/test/shell.d/update-keyring-test.sh b/test/shell.d/update-keyring-test.sh new file mode 100644 index 00000000..15d5bb52 --- /dev/null +++ b/test/shell.d/update-keyring-test.sh @@ -0,0 +1,137 @@ +#!/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 + +stub_bin="$test_tmp/bin" +log_file="$test_tmp/keyring.log" +mkdir -p "$stub_bin" + +# Behavior is driven by env vars so each case can pick its failure point: +# KEYRING_TEST_PKG_MISSING exit status of omarchy-pkg-missing (default 1: installed) +# KEYRING_TEST_LIST_FAIL_ON which --list-keys call fails, counted per run (default: none) +# KEYRING_TEST_RECV_STATUS exit status of --recv-keys (default 0) +# KEYRING_TEST_REINSTALL_STATUS exit status of the archlinux-keyring reinstall (default 0) +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo' >>"$KEYRING_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$KEYRING_TEST_LOG" +done +printf '\n' >>"$KEYRING_TEST_LOG" + +if [[ $1 == "pacman-key" && $2 == "--list-keys" ]]; then + calls_file="$KEYRING_TEST_DIR/list-calls" + calls=$(( $(cat "$calls_file" 2>/dev/null || echo 0) + 1 )) + echo "$calls" >"$calls_file" + if [[ ${KEYRING_TEST_LIST_FAIL_ON:-} == "$calls" ]]; then + exit 1 + fi + exit 0 +fi + +if [[ $1 == "pacman-key" && $2 == "--recv-keys" ]]; then + exit "${KEYRING_TEST_RECV_STATUS:-0}" +fi + +if [[ $1 == "pacman-key" && $2 == "--lsign-key" ]]; then + exit 0 +fi + +if [[ $1 == "pacman" && $* == *archlinux-keyring* ]]; then + exit "${KEYRING_TEST_REINSTALL_STATUS:-0}" +fi + +exit 0 +SH +chmod +x "$stub_bin/sudo" + +cat >"$stub_bin/omarchy-pkg-missing" <<'SH' +#!/bin/bash + +exit "${KEYRING_TEST_PKG_MISSING:-1}" +SH +chmod +x "$stub_bin/omarchy-pkg-missing" + +cat >"$stub_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash + +printf 'pkg-add\t%s\n' "$1" >>"$KEYRING_TEST_LOG" +exit 0 +SH +chmod +x "$stub_bin/omarchy-pkg-add" + +run_keyring() { + KEYRING_TEST_LOG="$log_file" \ + KEYRING_TEST_DIR="$test_tmp" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-update-keyring" "$@" +} + +# Everything healthy: the key and package are present, the reinstall works. +: >"$log_file" +rm -f "$test_tmp/list-calls" +run_keyring >"$test_tmp/ok.out" + +grep -F "Keys are correct" "$test_tmp/ok.out" >/dev/null || + fail "update-keyring reports success when the keyring is healthy" "$(cat "$test_tmp/ok.out")" +pass "update-keyring reports success when the keyring is healthy" + +grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring$' "$log_file" || + fail "update-keyring still reinstalls archlinux-keyring" "$(cat "$log_file")" +pass "update-keyring still reinstalls archlinux-keyring" + +# Key and package missing: the full populate path runs and verifies at the end. +: >"$log_file" +rm -f "$test_tmp/list-calls" +KEYRING_TEST_PKG_MISSING=0 run_keyring >"$test_tmp/populate.out" + +grep -F "Keys are correct" "$test_tmp/populate.out" >/dev/null || + fail "update-keyring populates a missing keyring and reports success" "$(cat "$test_tmp/populate.out")" +for expected in 'recv-keys' 'lsign-key' $'pkg-add\tomarchy-keyring'; do + grep -Eq "$expected" "$log_file" || + fail "update-keyring populates a missing keyring and reports success" "$(cat "$log_file")" +done +pass "update-keyring populates a missing keyring and reports success" + +# recv-keys failing must stop the script, not end in "Keys are correct". +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_PKG_MISSING=0 KEYRING_TEST_RECV_STATUS=1 run_keyring >"$test_tmp/recv.out" 2>&1; then + fail "update-keyring fails when recv-keys fails" +fi +if grep -F "Keys are correct" "$test_tmp/recv.out" >/dev/null; then + fail "update-keyring fails when recv-keys fails" "$(cat "$test_tmp/recv.out")" +fi +if grep -q 'lsign-key' "$log_file"; then + fail "update-keyring stops at the failed recv instead of signing anyway" "$(cat "$log_file")" +fi +pass "update-keyring fails when recv-keys fails" + +# A failed archlinux-keyring reinstall must not end in success either. +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_REINSTALL_STATUS=1 run_keyring >"$test_tmp/reinstall.out" 2>&1; then + fail "update-keyring fails when the archlinux-keyring reinstall fails" +fi +if grep -F "Keys are correct" "$test_tmp/reinstall.out" >/dev/null; then + fail "update-keyring fails when the archlinux-keyring reinstall fails" "$(cat "$test_tmp/reinstall.out")" +fi +pass "update-keyring fails when the archlinux-keyring reinstall fails" + +# The closing check is what backs the success line: the first --list-keys +# passes (key present, populate skipped), the verifying one fails. +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_LIST_FAIL_ON=2 run_keyring >"$test_tmp/verify.out" 2>&1; then + fail "update-keyring fails when the final key check fails" +fi +if grep -F "Keys are correct" "$test_tmp/verify.out" >/dev/null; then + fail "update-keyring fails when the final key check fails" "$(cat "$test_tmp/verify.out")" +fi +pass "update-keyring fails when the final key check fails" From b44fb747803e22903ddab642b1aad517b223b677 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Tue, 15 Sep 2026 21:23:37 +0530 Subject: [PATCH 13/51] [Security] Keep screen-recording state out of world-writable /tmp (#8374) * Keep screen-recording state out of world-writable /tmp * Compare the /tmp name across the run instead of requiring it absent Co-Authored-By: Claude Opus 5 (1M context) * Fall back to the state directory when there is no runtime dir * Let the /tmp snapshot come back empty Co-Authored-By: Claude Opus 5 (1M context) * Resolve the region file the same way in the resizer Co-Authored-By: Claude Opus 5 (1M context) * Protect recording fallback state and document its path --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-capture-screenrecording | 25 +++- bin/omarchy-capture-webcam-resize | 5 +- default/agents/skills/omarchy/capture.md | 2 +- default/agents/skills/omarchy/contributing.md | 2 +- test/shell.d/screenrecording-test.sh | 111 ++++++++++++++++++ 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index d57b26fe..0c4c5494 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -15,8 +15,9 @@ # start. # # Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and -# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so -# users can attach a log when reporting capture failures. +# the picker target it was launched with) to +# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when +# reporting capture failures. [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" @@ -34,9 +35,18 @@ WEBCAM_SIZE="medium" RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" -RECORDING_FILE="/tmp/omarchy-screenrecord-filename" -REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" -LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null") +# Both of these live in the per-user runtime directory, which is 0700. What +# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes +# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in +# world-writable /tmp is a name any other local account can create first and +# then point wherever it likes. The debug log carries the recorder's stderr and +# the picked geometry, which is nobody else's business either. Fall back to +# the state directory when there is no session runtime dir -- :-/tmp would put +# both files back at the fixed names this comment describes. +RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" +RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename" +REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region" +LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do case "$arg" in @@ -144,6 +154,11 @@ select_capture_target() { } start_screenrecording() { + mkdir -p "$RUNTIME_DIR" || return 1 + # XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. + if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then + chmod 700 "$RUNTIME_DIR" || return 1 + fi local capture_args=() local target diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index cde61e08..3c9d1215 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,7 +8,10 @@ set -euo pipefail readonly MARGIN=40 -readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" +# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback +# included, or a recording without a session runtime dir anchors the camera to +# the whole monitor instead of the region it recorded. +readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 diff --git a/default/agents/skills/omarchy/capture.md b/default/agents/skills/omarchy/capture.md index 41a52b6c..621bc298 100644 --- a/default/agents/skills/omarchy/capture.md +++ b/default/agents/skills/omarchy/capture.md @@ -33,7 +33,7 @@ Recordings land in the configured Videos directory (override with `omarchy capture webcam resize `. If recording fails to start, rerun with `OMARCHY_SCREENRECORD_DEBUG=true` to -collect a log at `/tmp/omarchy-screenrecord.log` worth attaching to a bug +collect a log at `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory) worth attaching to a bug report. ## Text Capture (OCR) diff --git a/default/agents/skills/omarchy/contributing.md b/default/agents/skills/omarchy/contributing.md index b5fd0533..c76c12d6 100644 --- a/default/agents/skills/omarchy/contributing.md +++ b/default/agents/skills/omarchy/contributing.md @@ -38,7 +38,7 @@ drag-and-drop in the web form, so save the capture and hand the user the file path to attach (`gh` cannot upload media). For screen-recording failures specifically, rerun with -`OMARCHY_SCREENRECORD_DEBUG=true` and attach `/tmp/omarchy-screenrecord.log`. +`OMARCHY_SCREENRECORD_DEBUG=true` and attach `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory). File the issue with `gh` when available: diff --git a/test/shell.d/screenrecording-test.sh b/test/shell.d/screenrecording-test.sh index 7859ec14..438ccc57 100644 --- a/test/shell.d/screenrecording-test.sh +++ b/test/shell.d/screenrecording-test.sh @@ -299,3 +299,114 @@ grep -F 'move = { "(monitor_w-monitor_h*2/9-40)", "(monitor_h-monitor_h/4-40)" } grep -F 'move = { "(monitor_w-monitor_h*3/10-40)", "(monitor_h-monitor_h*27/80-40)" }' "$webcam_rules" >/dev/null || \ fail "large webcam starts at its final corner position" pass "webcam size rules place the initial window in its final corner" + +# The stop path reads the recording state file back and uses its contents as a +# path -- ffmpeg writes beside it, `mv` replaces it, `rm -f` deletes its +# preview -- so it has to live in the per-user runtime directory rather than +# under a name in world-writable /tmp that another account can create first. +recording_dir="$tmp_dir/recordings" +mkdir -p "$recording_dir" + +cat >"$stub_bin/pgrep" <<'SH' +#!/bin/bash +exit 1 +SH + +cat >"$stub_bin/omarchy-hyprland-monitor-focused" <<'SH' +#!/bin/bash +printf 'DP-1\n' +SH + +cat >"$stub_bin/gpu-screen-recorder" <<'SH' +#!/bin/bash +for i in "$@"; do + [[ -n ${take_next:-} ]] && { : >"$i"; break; } + [[ $i == "-o" ]] && take_next=1 +done +sleep 5 +SH + +cat >"$stub_bin/omarchy-shell" <<'SH' +#!/bin/bash +exit 0 +SH + +chmod +x "$stub_bin"/pgrep "$stub_bin"/omarchy-hyprland-monitor-focused \ + "$stub_bin"/gpu-screen-recorder "$stub_bin"/omarchy-shell + +# Compare that name across the run rather than demanding it be absent: the +# whole point of the finding is that anyone can own it already, and a leftover +# from a pre-fix recording would red-light the fixed script. +tmp_state="/tmp/omarchy-screenrecord-filename" +tmp_state_before=$(stat -c '%y %s' "$tmp_state" 2>/dev/null || true) + +OMARCHY_SCREENRECORD_DIR="$recording_dir" \ + "$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1 + +pkill -f "$stub_bin/gpu-screen-recorder" 2>/dev/null || true + +[[ $(stat -c '%y %s' "$tmp_state" 2>/dev/null) == "$tmp_state_before" ]] || + fail "screen recording keeps no state under a fixed /tmp name" +pass "screen recording keeps no state under a fixed /tmp name" + +[[ -s $XDG_RUNTIME_DIR/omarchy-screenrecord-filename ]] || + fail "the recording state file lives in the per-user runtime directory" \ + "$(ls -a "$XDG_RUNTIME_DIR")" +pass "the recording state file lives in the per-user runtime directory" + +[[ $(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-filename") == "$recording_dir"/* ]] || + fail "the recording state file names the recording that was started" \ + "$(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-filename")" +pass "the recording state file names the recording that was started" + +# The :-/tmp fallback would reopen the hole this PR closes. A recording +# started without a session runtime dir has to land under the state directory. +state_home="$tmp_dir/state-home" +mkdir -p "$state_home/omarchy" "$tmp_dir/home-fallback" +chmod 755 "$state_home/omarchy" +tmp_state_before=$(stat -c '%y %s' "$tmp_state" 2>/dev/null || true) + +env -u XDG_RUNTIME_DIR \ + HOME="$tmp_dir/home-fallback" \ + XDG_STATE_HOME="$state_home" \ + OMARCHY_SCREENRECORD_DIR="$recording_dir" \ + "$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1 + +pkill -f "$stub_bin/gpu-screen-recorder" 2>/dev/null || true + +[[ $(stat -c '%y %s' "$tmp_state" 2>/dev/null) == "$tmp_state_before" ]] || + fail "without a runtime dir, screen recording still keeps no state under a fixed /tmp name" +pass "without a runtime dir, screen recording still keeps no state under a fixed /tmp name" + +fallback_file="$state_home/omarchy/omarchy-screenrecord-filename" +[[ -s $fallback_file ]] || + fail "without a runtime dir the recording state file lives in the state directory" \ + "$(ls -la "$state_home/omarchy" 2>/dev/null || true)" +pass "without a runtime dir the recording state file lives in the state directory" + +[[ $(<"$fallback_file") == "$recording_dir"/* ]] || + fail "the fallback state file names the recording that was started" \ + "$(<"$fallback_file")" +pass "the fallback state file names the recording that was started" + +# The overlay resizer reads the region file the recorder writes, so the two +# have to resolve the same fallback as well as the same runtime dir. +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "800x600+100+100" >"$state_home/omarchy/omarchy-screenrecord-region" +env -u XDG_RUNTIME_DIR \ + HOME="$tmp_dir/home-fallback" \ + XDG_STATE_HOME="$state_home" \ + "$ROOT/bin/omarchy-capture-webcam-resize" reset + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "without a runtime dir the webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "without a runtime dir the webcam anchors to the recorded region" + +mode=$(stat -c '%a' "$state_home/omarchy" 2>/dev/null || stat -f '%Lp' "$state_home/omarchy") +[[ $mode == "700" ]] || fail "fallback directory is private even when it already existed" "mode: $mode" +pass "fallback directory is private even when it already existed" From a73bcbfc0a9570ba7072791639dc97eb567b6f41 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Tue, 15 Sep 2026 18:02:17 +0200 Subject: [PATCH 14/51] Remove unsafe project bin PATH injection (#11336) * Remove unsafe project bin PATH injection * Cover customized unsafe Mise paths * Revoke legacy Mise Work trust * Harden legacy Mise trust cleanup * Preserve ignored Mise Work configs * Scope Mise path cleanup to env * Accept paranoid Mise ignore marker Reported-by: infosec-us-team --- install/user/mise-work.sh | 7 - manual/19-shell-tools.md | 2 + migrations/1789095456.sh | 99 ++++++++++ test/shell.d/mise-work-path-test.sh | 270 ++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 migrations/1789095456.sh create mode 100644 test/shell.d/mise-work-path-test.sh diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index f77d5561..9ce5249f 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -2,13 +2,6 @@ mkdir -p "$HOME/Work" mkdir -p "$HOME/Work/tries" -cat >"$HOME/Work/.mise.toml" <<'EOF' -[env] -_.path = "{{ cwd }}/bin" -EOF - -mise trust ~/Work/.mise.toml - # Offline installs unpack the Node tarball bundled by the ISO: from # /opt/packages in the ISO chroot, or from the copy staged in provisioning state when # omarchy-provision-owner finalizes the user at first boot. diff --git a/manual/19-shell-tools.md b/manual/19-shell-tools.md index 69965fdd..8252d795 100644 --- a/manual/19-shell-tools.md +++ b/manual/19-shell-tools.md @@ -57,3 +57,5 @@ The full manual can be found via `man yt-dlp`. ## try [try](https://github.com/tobi/try) makes it easy to manage programming experiments with date-stamped directories. All experiments live in `~/Work/tries` and you can access them via `try`. + +Omarchy does not add a project's `bin/` directory to `PATH` automatically. Run trusted project-local tools with an explicit relative path, such as `bin/rails` or `./bin/dev`. diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh new file mode 100644 index 00000000..381557fc --- /dev/null +++ b/migrations/1789095456.sh @@ -0,0 +1,99 @@ +echo "Remove automatic project bin directories from PATH" + +work_dir="$HOME/Work" +mise_config="$work_dir/.mise.toml" +# install/user/mise-work.sh as shipped in Omarchy 4.0.3. +stock_sha="bd04f191d63bbde86920f44f76f0989fad980afc84e268e8474c201ec7149245" +cwd_bin='\{\{[[:space:]]*cwd[[:space:]]*\}\}/bin' +unsafe_path="^[[:space:]]*_[.]path[[:space:]]*=[[:space:]]*(\"$cwd_bin\"|'$cwd_bin')[[:space:]]*(#.*)?$" +env_section='^[[:space:]]*\[[[:space:]]*env[[:space:]]*\][[:space:]]*(#.*)?$' +any_section='^[[:space:]]*\[\[?.*\]\]?[[:space:]]*(#.*)?$' + +remove_empty_work_dir=false +if [[ ! -e $work_dir ]]; then + mkdir -p "$work_dir" + remove_empty_work_dir=true +fi + +was_ignored=false +if [[ -d $work_dir ]]; then + mise_state_dir=${MISE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mise} + ignored_configs_dir="$mise_state_dir/ignored-configs" + work_target=$(readlink -m "$work_dir") + config_path_target="$work_target/.mise.toml" + config_target=$(readlink -m "$mise_config") + + if [[ -d $ignored_configs_dir ]]; then + for ignored_entry in "$ignored_configs_dir"/*; do + [[ -L $ignored_entry ]] || continue + ignored_target=$(readlink "$ignored_entry") + if [[ $ignored_target == $work_target || $ignored_target == $config_path_target || $ignored_target == $config_target ]]; then + was_ignored=true + break + fi + done + fi + + if [[ $was_ignored == "false" ]]; then + # Normal Mise trust is recorded against the config-root directory, while + # paranoid trust is recorded against the file and its contents. Stage an + # empty, inert config when the legacy file is gone so either trust mode can + # resolve and revoke the original grant. + remove_empty_mise_config=false + if [[ ! -e $mise_config && ! -L $mise_config ]]; then + if (set -o noclobber; : >"$mise_config") 2>/dev/null; then + remove_empty_mise_config=true + fi + fi + + untrust_target="$work_dir" + if [[ -f $mise_config ]]; then + untrust_target="$mise_config" + fi + + if mise trust --untrust "$untrust_target"; then + : + else + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + exit 1 + fi + + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ ! -L $mise_config && $(sha256sum "$mise_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + rm -f -- "$mise_config" + else + unsafe_env_paths=$(sed -n -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%p; }" "$mise_config") + if [[ -n $unsafe_env_paths ]]; then + backup=$(mktemp "$mise_config.bak.XXXXXX") + cp -p -- "$mise_config" "$backup" + sed --follow-symlinks -i -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%d; }" "$mise_config" + + printf '\n%s\n' \ + "Automatic project bin directories were removed from your Mise PATH." \ + "Your other Mise settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ $was_ignored == "true" ]]; then + printf '\n%s\n' "This custom config remains ignored by Mise." + else + printf '\n%s\n %s\n' \ + "Mise trust for this custom config was revoked. Review it before trusting it again:" \ + "mise trust $mise_config" + fi +fi + +if [[ $remove_empty_work_dir == "true" ]]; then + rmdir "$work_dir" 2>/dev/null || true +fi diff --git a/test/shell.d/mise-work-path-test.sh b/test/shell.d/mise-work-path-test.sh new file mode 100644 index 00000000..9c50b91b --- /dev/null +++ b/test/shell.d/mise-work-path-test.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command mise + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +migration="$ROOT/migrations/1789095456.sh" + +run_migration() { + local test_home="$1" + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + bash -euo pipefail "$migration" +} + +run_mise() { + local test_home="$1" + shift + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + mise "$@" +} + +mise_environment() { + local test_home="$1" + local project="$2" + + ( + cd "$project" + run_mise "$test_home" env -s bash + ) +} + +mise_path_active() { + local test_home="$1" + local project="$2" + local output + + if ! output=$(mise_environment "$test_home" "$project" 2>/dev/null); then + return 1 + fi + + grep -F "$project/bin" <<<"$output" >/dev/null +} + +assert_unsafe_variant_removed() { + local variant="$1" + local assignment="$2" + local variant_home="$test_dir/$variant-home" + local variant_config="$variant_home/Work/.mise.toml" + local variant_project="$variant_home/Work/tries/untrusted-repository" + + mkdir -p "$variant_project/bin" + printf '[env]\n%s\n' "$assignment" >"$variant_config" + run_mise "$variant_home" trust "$variant_config" >/dev/null + + mise_path_active "$variant_home" "$variant_project" || fail "$variant legacy config prepends the repository bin directory" + + run_migration "$variant_home" >/dev/null + if mise_path_active "$variant_home" "$variant_project"; then + fail "$variant repository bin directory remains in PATH after migration" + fi +} + +install_home="$test_dir/install-home" +install_log="$test_dir/install-mise.log" +mkdir -p "$install_home" "$test_dir/bin" +cat >"$test_dir/bin/mise" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$MISE_TEST_LOG" +SH +chmod +x "$test_dir/bin/mise" + +env \ + HOME="$install_home" \ + MISE_TEST_LOG="$install_log" \ + OMARCHY_SETUP_CONTEXT=runtime \ + PATH="$test_dir/bin:/usr/bin" \ + bash -euo pipefail -c 'source "$1"' bash "$ROOT/install/user/mise-work.sh" + +[[ -d $install_home/Work/tries ]] || fail "installer creates the work and tries directories" +[[ ! -e $install_home/Work/.mise.toml ]] || fail "installer does not create a trusted Work Mise config" +[[ $(<"$install_log") == "use -g node@latest" ]] || fail "installer only invokes Mise for the global Node setup" +pass "new installs do not add project bin directories to PATH" + +stock_home="$test_dir/stock-home" +stock_config="$stock_home/Work/.mise.toml" +stock_project="$stock_home/Work/tries/untrusted-repository" +mkdir -p "$stock_project/bin" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +run_mise "$stock_home" trust "$stock_config" >/dev/null + +mise_path_active "$stock_home" "$stock_project" || fail "legacy config prepends the repository bin directory" + +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "migration removes the stock Work Mise config" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +if mise_path_active "$stock_home" "$stock_project"; then + fail "recreated Work config remains trusted after migration" +fi +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "stock migration is idempotent" +pass "migration removes the repository bin directory and revokes the Work trust root" + +assert_unsafe_variant_removed inline-comment '_.path = "{{ cwd }}/bin" # Omarchy default' +assert_unsafe_variant_removed single-quoted "_.path = '{{ cwd }}/bin'" +pass "migration removes annotated and single-quoted project bin paths" + +custom_home="$test_dir/custom-home" +custom_config="$custom_home/Work/.mise.toml" +mkdir -p "$(dirname "$custom_config")" +cat >"$custom_config" <<'TOML' +[env] +KEEP = "yes" + _.path = "{{ cwd }}/bin" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +cp "$custom_config" "$test_dir/custom-original" +cat >"$test_dir/custom-expected" <<'TOML' +[env] +KEEP = "yes" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +chmod 600 "$custom_config" +run_mise "$custom_home" trust "$custom_config" >/dev/null + +custom_output=$(run_migration "$custom_home") +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "migration preserves unrelated custom Mise settings" +[[ $(stat -c %a "$custom_config") == "600" ]] || fail "migration preserves custom config permissions" +grep -F "mise trust $custom_config" <<<"$custom_output" >/dev/null || fail "migration explains how to review and re-trust a custom config" +custom_backups=("$custom_config".bak.*) +[[ -f ${custom_backups[0]} ]] || fail "migration backs up a customized Mise config" +(( ${#custom_backups[@]} == 1 )) || fail "migration creates one custom config backup" +cmp -s "$test_dir/custom-original" "${custom_backups[0]}" || fail "custom config backup preserves the original" + +run_migration "$custom_home" >/dev/null +custom_backups=("$custom_config".bak.*) +(( ${#custom_backups[@]} == 1 )) || fail "custom migration does not create another backup on rerun" +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "custom migration is idempotent" +pass "custom Mise settings, permissions, and original backup survive the repair" + +unrelated_home="$test_dir/unrelated-home" +unrelated_config="$unrelated_home/Work/.mise.toml" +unrelated_project="$unrelated_home/Work/tries/untrusted-repository" +mkdir -p "$unrelated_project/bin" +printf '[env]\nKEEP = "yes"\n' >"$unrelated_config" +cp "$unrelated_config" "$test_dir/unrelated-original" +run_mise "$unrelated_home" trust "$unrelated_config" >/dev/null +run_migration "$unrelated_home" >/dev/null +cmp -s "$test_dir/unrelated-original" "$unrelated_config" || fail "unrelated Mise config remains unchanged" +unrelated_backups=("$unrelated_config".bak.*) +[[ ! -e ${unrelated_backups[0]} ]] || fail "unchanged Mise config is not backed up" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$unrelated_config" +if mise_path_active "$unrelated_home" "$unrelated_project"; then + fail "safe Work config retains its old trust grant" +fi + +absent_home="$test_dir/absent-home" +absent_config="$absent_home/Work/.mise.toml" +absent_project="$absent_home/Work/tries/untrusted-repository" +mkdir -p "$(dirname "$absent_config")" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +run_mise "$absent_home" trust "$absent_config" >/dev/null +rm "$absent_config" +rmdir "$absent_home/Work" +run_migration "$absent_home" >/dev/null +[[ ! -e $absent_home/Work ]] || fail "migration does not retain a temporary Work directory" +mkdir -p "$absent_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +if mise_path_active "$absent_home" "$absent_project"; then + fail "deleted Work directory retains its stale trust grant" +fi +pass "migration leaves unrelated configs alone and revokes dangling Work trust" + +paranoid_home="$test_dir/paranoid-home" +paranoid_work="$paranoid_home/Work" +paranoid_config="$paranoid_work/.mise.toml" +paranoid_project="$paranoid_work/tries/untrusted-repository" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +OMARCHY_TEST_MISE_PARANOID=true run_mise "$paranoid_home" trust "$paranoid_config" >/dev/null +OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project" || fail "paranoid legacy config prepends the repository bin directory" +rm -r "$paranoid_work" + +OMARCHY_TEST_MISE_PARANOID=true run_migration "$paranoid_home" >/dev/null +[[ ! -e $paranoid_work ]] || fail "paranoid migration removes its temporary Work directory" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +if OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project"; then + fail "paranoid migration retains content-bound trust for the deleted legacy config" +fi +pass "migration revokes stale content-bound trust in Mise paranoid mode" + +ignored_home="$test_dir/ignored-home" +ignored_config="$ignored_home/Work/.mise.toml" +ignored_project="$ignored_home/Work/tries/untrusted-repository" +mkdir -p "$ignored_project/bin" +cat >"$ignored_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +run_mise "$ignored_home" trust "$ignored_config" >/dev/null +run_mise "$ignored_home" trust --ignore "$ignored_config" >/dev/null + +ignored_output=$(run_migration "$ignored_home") +grep -F '{{ cwd }}/bin' "$ignored_config" >/dev/null && fail "ignored config retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$ignored_config" >/dev/null || fail "ignored config keeps unrelated settings" +ignored_entries=("$ignored_home/.local/state/mise/ignored-configs/"*) +[[ -L ${ignored_entries[0]} ]] || fail "migration preserves the explicit Mise ignore marker" +(( ${#ignored_entries[@]} == 1 )) || fail "migration preserves exactly one Mise ignore marker" +ignored_target=$(readlink "${ignored_entries[0]}") +[[ $ignored_target == $ignored_home/Work || $ignored_target == $ignored_config ]] || fail "preserved Mise ignore marker still targets the Work config" +grep -F "remains ignored by Mise" <<<"$ignored_output" >/dev/null || fail "migration reports that the custom config remains ignored" +if mise_path_active "$ignored_home" "$ignored_project"; then + fail "ignored config becomes active after migration" +fi +pass "migration preserves an explicit decision to ignore the Work config" + +symlink_home="$test_dir/symlink-home" +symlink_config="$symlink_home/Work/.mise.toml" +symlink_target="$test_dir/dotfiles-mise.toml" +mkdir -p "$(dirname "$symlink_config")" +cat >"$symlink_target" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +ln -s "$symlink_target" "$symlink_config" + +run_migration "$symlink_home" >/dev/null +[[ -L $symlink_config ]] || fail "migration preserves a dotfile symlink" +grep -F '{{ cwd }}/bin' "$symlink_target" >/dev/null && fail "symlink target retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$symlink_target" >/dev/null || fail "symlink target keeps unrelated settings" +pass "custom dotfile symlinks survive the repair" From 8fa9f4d03e02411f62772f2defc2b6139e86d222 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 12:38:00 -0400 Subject: [PATCH 15/51] Leave Claude browser integration settings unchanged --- bin/omarchy-agent | 2 +- manual/17-ai.md | 2 +- test/shell.d/default-agent-test.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 23d0e2f3..e35bcf04 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -75,7 +75,7 @@ crush) fi ;; claude) - command=(claude --permission-mode auto --chrome) + command=(claude --permission-mode auto) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; grok) diff --git a/manual/17-ai.md b/manual/17-ai.md index 7f76d481..24ade4f4 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,7 @@ 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. -Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge, and enables browser integration when launched through `omarchy agent`. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. +Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. [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. diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 1ce05582..7fd06093 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -662,7 +662,7 @@ assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" 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 --chrome -- "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" @@ -690,7 +690,7 @@ 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 --chrome +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 From 677e69d4f13e90ee83251d75e7839f76532f97bc Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 12:40:25 -0400 Subject: [PATCH 16/51] Make Claude browser extension installation best effort --- bin/omarchy-default-agent | 5 ++--- manual/17-ai.md | 2 +- test/shell.d/default-agent-test.sh | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 8c14a4f2..eb2ef7fb 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -86,9 +86,8 @@ if ! agent_install; then exit 1 fi -if [[ $agent == "claude" ]] && ! omarchy-install-chromium-claude; then - echo "Could not install the Claude browser extension" >&2 - exit 1 +if [[ $agent == "claude" ]]; then + omarchy-install-chromium-claude 2>/dev/null || true fi mkdir -p "$(dirname "$agent_file")" diff --git a/manual/17-ai.md b/manual/17-ai.md index 24ade4f4..0d62622d 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,7 @@ 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. -Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. +Choosing Claude also attempts to set up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling or a failed extension install still selects and launches Claude. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. [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. diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 7fd06093..60e630d3 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -25,7 +25,10 @@ mkdir -p "$mock_bin" "$test_home" cat >"$mock_bin/omarchy-install-chromium-claude" <<'SH' #!/bin/bash echo claude-extension >>"$OMARCHY_TEST_STUB_LOG" -[[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} != "true" ]] +if [[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} == "true" ]]; then + echo "Extension installation failed" >&2 + exit 1 +fi SH cat >"$mock_bin/omarchy-notification-send" <<'SH' @@ -440,11 +443,14 @@ pass "default agent selects and opens every supported provider and alias" pass "default agent stores its selection in Omarchy user config" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi -if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1; then - fail "Claude selection fails when browser extension installation fails" -fi -[[ $(omarchy-default-agent) == "pi" ]] || fail "extension installation failure preserves the default agent" -pass "extension installation failure preserves the default agent" +: >"$agent_open_log" +OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1 +[[ $(omarchy-default-agent) == "claude" ]] || fail "extension installation failure still selects Claude" +mapfile -d '' -t agent_open_args <"$agent_open_log" +[[ ${agent_open_args[*]} == "omarchy-agent" ]] || fail "extension installation failure still launches Claude" +[[ ! -s $test_tmp/extension-failure ]] || fail "extension installation failure is silent" +pass "extension installation failure silently continues selecting and launching Claude" +OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi : >"$notification_history" : >"$agent_open_log" : >"$terminal_log" From 181ad4b4d399feb7f797fa941150d3c65866a496 Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 16:56:47 +0100 Subject: [PATCH 17/51] feat: improves php and laravel installation --- bin/omarchy-install-dev-env | 36 +++++------------------------- bin/omarchy-remove-dev-env | 7 +++++- default/omarchy/omarchy-menu.jsonc | 8 +++---- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 263fd755..5dcd819a 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -12,37 +12,11 @@ if [[ -z $1 ]]; then fi install_php() { - omarchy-pkg-add php composer php-sqlite xdebug + mise tool-alias set php github:nunomaduro/static-php-builds + mise use --global php@latest - # Install Path for Composer - if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then - echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" - source "$HOME/.bashrc" - echo "Added Composer global bin directory to PATH." - else - echo "Composer global bin directory already in PATH." - fi - - # Enable some extensions - local php_ini_path="/etc/php/php.ini" - local extensions_to_enable=( - "bcmath" - "intl" - "iconv" - "openssl" - "pdo_sqlite" - "pdo_mysql" - ) - - # Enable Xdebug - sudo sed -i \ - -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ - -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ - /etc/php/conf.d/xdebug.ini - - for ext in "${extensions_to_enable[@]}"; do - sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" - done + # Composer's global binaries go to ~/.local/bin, which is already on PATH + mise x php -- composer global config bin-dir "$HOME/.local/bin" } install_node() { @@ -84,7 +58,7 @@ laravel) echo -e "Installing PHP and Laravel...\n" install_php install_node - composer global require laravel/installer + mise x php -- composer global require laravel/installer echo -e "\nYou can now run: laravel new myproject" ;; symfony) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index a24e7f9e..b586ed09 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -10,6 +10,11 @@ if [[ -z $1 ]]; then fi remove_php() { + mise uninstall php --all + mise rm -g php + mise tool-alias unset php + + # PHP from before it moved to mise omarchy-pkg-drop php composer php-sqlite xdebug } @@ -46,7 +51,7 @@ php) ;; laravel) echo -e "Removing Laravel...\n" - composer global remove laravel/installer 2>/dev/null || true + mise x php -- composer global remove laravel/installer 2>/dev/null || true ;; symfony) echo -e "Removing Symfony CLI...\n" diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 7583d8d8..2ee24576 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -277,8 +277,8 @@ "install.development.javascript.node": {"icon":"","label":"Node.js","disabled":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env node'"}, "install.development.javascript.bun": {"icon":"","label":"Bun","disabled":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env bun'"}, "install.development.javascript.deno": {"icon":"","label":"Deno","disabled":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env deno'"}, - "install.development.php.php": {"icon":"","label":"PHP","disabled":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, - "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, + "install.development.php.php": {"icon":"","label":"PHP","disabled":"[[ -d $HOME/.local/share/mise/installs/php ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env php'"}, + "install.development.php.laravel": {"icon":"","label":"Laravel","disabled":"[[ -x $HOME/.local/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env laravel'"}, "install.development.php.symfony": {"icon":"","label":"Symfony","disabled":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env symfony'"}, "install.development.elixir.elixir": {"icon":"","label":"Elixir","disabled":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env elixir'"}, "install.development.elixir.phoenix": {"icon":"","label":"Phoenix","disabled":"compgen -G \"$HOME/.mix/archives/phx_new*\"","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env phoenix'"}, @@ -343,8 +343,8 @@ "remove.development.javascript.node": {"icon":"","label":"Node.js","when":"[[ -d $HOME/.local/share/mise/installs/node ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env node'"}, "remove.development.javascript.bun": {"icon":"","label":"Bun","when":"[[ -d $HOME/.local/share/mise/installs/bun ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env bun'"}, "remove.development.javascript.deno": {"icon":"","label":"Deno","when":"[[ -d $HOME/.local/share/mise/installs/deno ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env deno'"}, - "remove.development.php.php": {"icon":"","label":"PHP","when":"omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, - "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, + "remove.development.php.php": {"icon":"","label":"PHP","when":"[[ -d $HOME/.local/share/mise/installs/php ]] || omarchy-pkg-present php","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env php'"}, + "remove.development.php.laravel": {"icon":"","label":"Laravel","when":"[[ -x $HOME/.local/bin/laravel || -x $HOME/.config/composer/vendor/bin/laravel ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env laravel'"}, "remove.development.php.symfony": {"icon":"","label":"Symfony","when":"omarchy-pkg-present symfony-cli","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env symfony'"}, "remove.development.elixir.elixir": {"icon":"","label":"Elixir","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env elixir'"}, "remove.development.elixir.phoenix": {"icon":"","label":"Phoenix","when":"[[ -d $HOME/.local/share/mise/installs/elixir ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-dev-env phoenix'"}, From 456af5cc8899b1a67219f9bd7489746441f78daf Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 17:44:30 +0100 Subject: [PATCH 18/51] chore: removes useless comment --- bin/omarchy-install-dev-env | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/omarchy-install-dev-env b/bin/omarchy-install-dev-env index 5dcd819a..03577198 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -15,7 +15,6 @@ install_php() { mise tool-alias set php github:nunomaduro/static-php-builds mise use --global php@latest - # Composer's global binaries go to ~/.local/bin, which is already on PATH mise x php -- composer global config bin-dir "$HOME/.local/bin" } From 486e1cab9b98c9064522741d7bc878f6c93c9497 Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 20:35:34 +0100 Subject: [PATCH 19/51] fix: removes the laravel binary even when php is already gone Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-remove-dev-env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index b586ed09..46bc8459 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -52,6 +52,9 @@ php) laravel) echo -e "Removing Laravel...\n" mise x php -- composer global remove laravel/installer 2>/dev/null || true + + # Composer cannot run once PHP is gone, so drop the installer binary directly (new and old bin-dir) + rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) echo -e "Removing Symfony CLI...\n" From 85da80dd7c280e99ef333bb2804fe4a7a6c91c7e Mon Sep 17 00:00:00 2001 From: nunomaduro Date: Wed, 16 Sep 2026 20:46:40 +0100 Subject: [PATCH 20/51] chore: removes comment --- bin/omarchy-remove-dev-env | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/omarchy-remove-dev-env b/bin/omarchy-remove-dev-env index 46bc8459..1be245b7 100755 --- a/bin/omarchy-remove-dev-env +++ b/bin/omarchy-remove-dev-env @@ -53,7 +53,6 @@ laravel) echo -e "Removing Laravel...\n" mise x php -- composer global remove laravel/installer 2>/dev/null || true - # Composer cannot run once PHP is gone, so drop the installer binary directly (new and old bin-dir) rm -f "$HOME/.local/bin/laravel" "$HOME/.config/composer/vendor/bin/laravel" ;; symfony) From 531c3e890ff0b3c062fa046c8115f72a9122bf09 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 16 Sep 2026 13:09:54 -0500 Subject: [PATCH 21/51] Install Elsewhen, the world clock plugin, by default Elsewhen (omacom.elsewhen) arrives as the elsewhen package under /usr/share/omarchy/plugins, the packaged root the shell scans between its bundled plugins and the user's. It opens the right section of the default bar, just before the tray, and a migration installs the package, writes the widget into a customized shell.json in the same spot, and retires a pristine pre-package clone of the upstream repo that the package now shadows. --- config/omarchy/shell.json | 3 + default/agents/skills/omarchy/plugins.md | 5 + docs/file-layout.md | 3 + docs/omarchy-shell.md | 13 + install/omarchy-base.packages | 1 + migrations/1789581661.sh | 85 ++++++ shell/README.md | 14 + shell/plugins/README.md | 6 +- test/shell.d/config-test.sh | 5 +- .../elsewhen-default-migration-test.sh | 285 ++++++++++++++++++ 10 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 migrations/1789581661.sh create mode 100644 test/shell.d/elsewhen-default-migration-test.sh diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index e6202da8..64cc4075 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -38,6 +38,9 @@ } ], "right": [ + { + "id": "omacom.elsewhen" + }, { "id": "omarchy.tray" }, diff --git a/default/agents/skills/omarchy/plugins.md b/default/agents/skills/omarchy/plugins.md index 6e3ab069..bf1033b3 100644 --- a/default/agents/skills/omarchy/plugins.md +++ b/default/agents/skills/omarchy/plugins.md @@ -9,9 +9,14 @@ inside a single long-running Quickshell process (`omarchy-shell`). ``` ~/.config/omarchy/shell.json # User overrides: bar, plugins, idle ~/.config/omarchy/plugins// # User-owned shell plugins +/usr/share/omarchy/plugins// # Packaged plugins (pacman), e.g. omacom.elsewhen $OMARCHY_PATH/config/omarchy/shell.json # Canonical defaults ``` +Packaged plugins such as Elsewhen (`elsewhen` package) update with `omarchy update` and are removed with pacman; +`omarchy plugin update` and `omarchy plugin remove` refuse them and name the +package. + The shell hot-reloads `shell.json` on save — no restart needed for layout changes. `idle.screensaver` and `idle.lock` are seconds since user idle began. diff --git a/docs/file-layout.md b/docs/file-layout.md index f261085f..8bbcd5b2 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -78,6 +78,9 @@ install/** ──► omarchy /usr/share/omarchy migrations/** ──► omarchy /usr/share/omarchy/migrations/ themes/** ──► omarchy /usr/share/omarchy/themes/ shell/** ──► omarchy /usr/share/omarchy/shell/ + (packaged plugins from their own + packages, e.g. elsewhen, land beside it + in /usr/share/omarchy/plugins//) version ──► omarchy /usr/share/omarchy/version + /etc/skel/.local/state/omarchy/migrations/* diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index d62ff743..18a61896 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -88,6 +88,19 @@ bar from the CLI — `use | reset | defaults | position | transparent | put | move | set`, with placement flags such as `--section` and `--index`. The lower-level IPC methods remain available through `omarchy-shell shell ...`. +## Packaged plugins + +A default plugin can ship as its own Arch package instead of in the Omarchy +checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs them at +`/usr/share/omarchy/plugins//`, a root the shell scans between +`$OMARCHY_PATH/shell/plugins` and `~/.config/omarchy/plugins`. A packaged +`omarchy.*` id is trusted like a bundled plugin and loads by default; any other +packaged id behaves like an installed plugin. Precedence is bundled, then +packaged, then user, so a checkout under `omarchy dev link` overrides the +package. Packaged plugins update through `omarchy update` and leave through +pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and +name the package, and Setup › Plugins › Remove does not list them. + ## IPC The shell exposes a `shell` target (the host also registers diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 76e74110..0a8e27cc 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -26,6 +26,7 @@ docker-compose dosfstools dotnet-runtime dua-cli +elsewhen evince exfatprogs expac diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh new file mode 100644 index 00000000..fd5d30be --- /dev/null +++ b/migrations/1789581661.sh @@ -0,0 +1,85 @@ +echo "Install Elsewhen, the world clock plugin" + +# Elsewhen ships as the elsewhen package rather than in this checkout; pacman +# puts it under /usr/share/omarchy/plugins, where the shell looks next to its +# bundled plugins. A package the mirror does not carry yet fails the migration +# on purpose: nothing before this line has changed anything, and the runner +# retries it on the next update or login rather than quietly leaving the +# machine without the plugin. +omarchy-pkg-add elsewhen + +# A shell that already knows the packaged root (dev-linked, or restarted +# since) picks the plugin up here; the one an update runs under predates it +# and is replaced by omarchy-update-restart right after the migrations. +omarchy-shell -q shell rescanPlugins || true + +# Before the package, the README had users run +# `omarchy plugin add https://github.com/omacom/elsewhen.git`, which left a +# git clone under ~/.config/omarchy/plugins. The packaged copy now shadows +# it: the shell logs a rejection on every scan, and `omarchy plugin update` +# keeps pulling a checkout nothing loads. A pristine clone of the upstream +# repo is retired the way `omarchy plugin remove` retires one (the repo +# remains upstream). Anything else -- a symlink, a plain directory, local +# changes, a fork, a checkout git cannot read -- is the user's and stays. This +# runs only once the package is in place, so the plugin never leaves a +# machine; its id stays in shell.json either way, which is what keeps it +# enabled. The shell an update runs under predates the packaged root, and it +# watches this directory: dropping the checkout empties the widget's slot in +# that shell until omarchy-update-restart replaces it a few steps later with +# one that finds the package. A one-time gap of a few minutes, during the +# update itself. +upstream_checkout() { + local dir="$1" origin status + [[ -d $dir && ! -L $dir && -d $dir/.git ]] || return 1 + origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 + origin=${origin,,} + origin=${origin%/} + origin=${origin%.git} + [[ $origin =~ ^([a-z+]+://)?([^/@]+@)?github\.com[/:]omacom/elsewhen$ ]] || return 1 + status=$(git -C "$dir" status --porcelain 2>/dev/null) || return 1 + [[ -z $status ]] +} + +checkout="$HOME/.config/omarchy/plugins/omacom.elsewhen" +if [[ -e $checkout || -L $checkout ]]; then + if upstream_checkout "$checkout"; then + rm -rf "$checkout" + echo "Retired the omacom.elsewhen checkout at $checkout in favour of the elsewhen package." + else + echo "The elsewhen package takes precedence over $checkout, which is left as it is." + fi +fi + +# The widget opens the right section of the default bar, just before the +# tray, which a machine without a shell.json takes on as soon as the shell +# restarts. A customized bar gets the entry written into its file rather than +# placed over IPC: the shell this runs under cannot see a widget its scan +# never reached and would refuse it, whereas the file it hot-reloads carries +# the entry through to the restart. A bar that already carries the widget +# keeps it where the user put it. +source omarchy-shell-config + +[[ -s $CONFIG_FILE ]] || exit 0 +# A file the shell cannot read is left for its owner to repair. +jq empty "$CONFIG_FILE" 2>/dev/null || exit 0 + +entry_id='def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;' + +if jq -e "$entry_id"' + any((.bar.layout // {} | .left, .center, .right | arrays)[]; entry_id == "omacom.elsewhen") +' "$CONFIG_FILE" >/dev/null; then + exit 0 +fi + +commit "$NORMALIZE | $entry_id"' + def ids: map(entry_id); + def insert_at($section; $index): + .bar.layout[$section] = .bar.layout[$section][:$index] + [{id: "omacom.elsewhen"}] + .bar.layout[$section][$index:]; + . as $config + | (["left", "center", "right"] | map(select($config.bar.layout[.] | ids | index("omarchy.tray") != null)) | first) as $section + | if $section != null then + insert_at($section; .bar.layout[$section] | ids | index("omarchy.tray")) + else + insert_at("right"; 0) + end +' diff --git a/shell/README.md b/shell/README.md index ca07b520..23a25ca6 100644 --- a/shell/README.md +++ b/shell/README.md @@ -140,6 +140,19 @@ You can still drop a plugin in without git: `barWidget.defaultSection`, or in the center when it is omitted, and can be moved with `omarchy bar move`; a full bar replaces the one in use. +### Packaged plugins + +Some default plugins ship as their own Arch package rather than in this +checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs such a plugin, +root-owned, at `/usr/share/omarchy/plugins//`, and +the shell scans that root between its bundled plugins and the user's. A +packaged `omarchy.*` id is trusted exactly like a bundled one; a packaged +plugin under any other id is treated like a user plugin. They update with +`omarchy update` and are removed with pacman, so `omarchy plugin update` and +`omarchy plugin remove` refuse them and name the package instead. A bundled +copy in `$OMARCHY_PATH/shell/plugins` wins over the packaged one, which is how +a packaged plugin is developed under `omarchy dev link`. + The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. The `omarchy plugin` commands wrap those calls. `omarchy bar move` and @@ -224,6 +237,7 @@ customization from the shipped defaults lives in it. |-----------------------------------|----------------|--------------------------------------------------------| | `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list | | `~/.config/omarchy/plugins//` | user | drop-in third-party plugin source files | +| `/usr/share/omarchy/plugins//` | pacman | packaged plugins (e.g. `elsewhen`), updated by `omarchy update` | The `config/omarchy/shell.json` default config describes the fresh-install state. When the user has no `shell.json`, the shell uses diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 66cb74e1..1a2d26fb 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -9,7 +9,10 @@ First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`; at startup; other panels, overlays, and menus are loaded on demand. User-installed plugins live alongside these conceptually but on disk under -`~/.config/omarchy/plugins//` rather than in this directory. +`~/.config/omarchy/plugins//` rather than in this directory. A few +default plugins arrive as their own package instead: pacman installs them at +`/usr/share/omarchy/plugins//`, where an `omarchy.*` id is trusted +like the plugins here, and a copy in this directory shadows the packaged one. | Plugin | id | kinds | entry point | |---------------|---------------------------|-------------------------|---------------------------------------| @@ -28,6 +31,7 @@ User-installed plugins live alongside these conceptually but on disk under | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | | Agents | `omarchy.agents` | `bar-widget` | `agents/Panel.qml` | +| Elsewhen | `omacom.elsewhen` | `bar-widget` | packaged: `elsewhen` installs `/usr/share/omarchy/plugins/omacom.elsewhen/` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 355ca26b..d9086d44 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -41,11 +41,12 @@ pass "default clock date format has no leading zero" jq -e ' def ids: map(.id // .); (.bar.layout.right | ids) as $ids | + ($ids | index("omacom.elsewhen")) as $elsewhen | ($ids | index("omarchy.tray")) as $tray | ($ids | index("omarchy.agents")) as $agents | - $tray != null and $agents == $tray + 1 + $elsewhen == 0 and $tray == 1 and $agents == $tray + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null -pass "default right layout keeps agents next to the tray" +pass "default right layout opens with elsewhen and the tray, then agents" ROOT="$ROOT" python3 <<'PY' import json diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh new file mode 100644 index 00000000..438ba1d3 --- /dev/null +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command jq +require_command git + +migration="$ROOT/migrations/1789581661.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" +export INSTALLED_PACKAGES="$test_dir/installed" CALL_LOG="$test_dir/calls" SHELL_CALLS="$test_dir/shell-calls" +# The checkout cases use the real git; keep the developer's config (signing, +# hooks, safe.directory) out of what it sees. +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com +export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com + +# Keep the real package helpers, but contain every pacman transaction here. +cat >"$test_dir/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;; + -S) + [[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1 + shift 3 # -S --noconfirm --needed + printf '%s\n' "$@" >>"$INSTALLED_PACKAGES" + printf '%s\n' "$@" >>"$CALL_LOG" + ;; + *) exit 1 ;; +esac +SH +cat >"$test_dir/bin/sudo" <<'SH' +#!/bin/bash +[[ $1 == "pacman" ]] || exit 1 +"$@" +SH +# The migration runs under a shell that predates the packaged root, or under +# none at all, so it must only ever ask the shell for best-effort refreshes. +cat >"$test_dir/bin/omarchy-shell" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$SHELL_CALLS" +[[ $1 != "-q" ]] || exit 0 +exit "${SHELL_STATUS:-0}" +SH +chmod +x "$test_dir/bin/"* + +home="$test_dir/home" +config="$home/.config/omarchy/shell.json" +checkout="$home/.config/omarchy/plugins/omacom.elsewhen" +output="$test_dir/output" +mkdir -p "$home/.config/omarchy" + +run_migration() { + : >"$CALL_LOG" + : >"$SHELL_CALLS" + HOME="$home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$migration" >"$output" +} + +ids() { + jq -c ".bar.layout.$1 | map(if type == \"object\" then .id else . end)" "$config" +} + +# ------------------------------------------------------------ package install +: >"$INSTALLED_PACKAGES" +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { + "layout": { + "left": [{ "id": "omarchy.menu" }], + "center": [{ "id": "omarchy.clock", "format": "HH:mm" }], + "right": [ + { "id": "omarchy.tray" }, + { "id": "omarchy.agents", "syncMode": "On" }, + { "id": "omarchy.power" } + ] + } + }, + "plugins": [] +} +JSON + +run_migration +grep -Fxq elsewhen "$CALL_LOG" || fail "migration installs the elsewhen package" "$(cat "$CALL_LOG")" +pass "migration installs the elsewhen package" + +[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents","omarchy.power"]' ]] || + fail "migration puts the widget just before the tray" "$(cat "$config")" +pass "migration puts the widget just before the tray" + +[[ $(jq -c '.bar.layout.right[2]' "$config") == '{"id":"omarchy.agents","syncMode":"On"}' ]] || + fail "migration keeps the settings of its neighbours" "$(cat "$config")" +[[ $(jq -c '.bar.layout.center[0]' "$config") == '{"format":"HH:mm","id":"omarchy.clock"}' ]] || + fail "migration leaves other sections alone" "$(cat "$config")" +pass "migration leaves the rest of the layout alone" + +grep -q 'shell rescanPlugins' "$SHELL_CALLS" && grep -q 'shell reloadConfig' "$SHELL_CALLS" || + fail "migration asks the running shell to rescan and reload" "$(cat "$SHELL_CALLS")" +pass "migration asks the running shell to rescan and reload" + +before=$(sha256sum "$config") +run_migration +[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(cat "$config")" +[[ ! -s $CALL_LOG ]] || fail "migration does not reinstall a present package" "$(cat "$CALL_LOG")" +pass "migration is idempotent" + +# ------------------------------------------------------------- placements +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { "layout": { "left": [], "center": ["omarchy.clock", "omarchy.tray"], "right": ["omarchy.power"] } } +} +JSON +run_migration +[[ $(ids center) == '["omarchy.clock","omacom.elsewhen","omarchy.tray"]' && $(ids right) == '["omarchy.power"]' ]] || + fail "migration follows the tray into another section and reads string entries" "$(cat "$config")" +pass "migration follows the tray into another section and reads string entries" + +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.agents" }, { "id": "omarchy.power" }] } } } +JSON +run_migration +[[ $(ids right) == '["omacom.elsewhen","omarchy.agents","omarchy.power"]' ]] || + fail "migration prepends to the right section when the tray is off the bar" "$(cat "$config")" +pass "migration prepends to the right section when the tray is off the bar" + +cat >"$config" <<'JSON' +{ + "version": 1, + "bar": { "layout": { "left": [{ "id": "omacom.elsewhen" }], "center": [], "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } +} +JSON +before=$(sha256sum "$config") +run_migration +[[ $before == $(sha256sum "$config") ]] || + fail "migration leaves a widget the user already placed where it is" "$(cat "$config")" +pass "migration leaves a widget the user already placed where it is" + +# ------------------------------------------------------------- edge cases +rm -f "$config" +run_migration +[[ ! -e $config ]] || fail "migration writes no shell.json where the defaults apply" "$(cat "$config")" +pass "migration writes no shell.json where the defaults apply" + +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" + +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } +JSON +SHELL_STATUS=1 run_migration +[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents"]' ]] || + fail "migration places the widget with no shell running" "$(cat "$config")" +pass "migration places the widget with no shell running" + +# A package the mirror does not carry yet leaves the migration pending rather +# than half-done: the layout must not name a widget nothing can install. +: >"$INSTALLED_PACKAGES" +cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } +JSON +before=$(sha256sum "$config") +if FAIL_INSTALL=1 run_migration 2>/dev/null; then + fail "a failed package installation must leave the migration pending" +fi +[[ $before == $(sha256sum "$config") ]] || + fail "a failed package installation leaves the layout untouched" "$(cat "$config")" +pass "a failed package installation is propagated and changes nothing" + +# ------------------------------------------------- pre-package checkouts +# Users who followed the README before the package existed hold a git clone +# under ~/.config/omarchy/plugins, which the packaged copy now shadows. Only a +# clone of the upstream repo with nothing local in it is retired; everything +# else is the user's and stays, and shell.json gets the widget either way. + +# A clone made by `omarchy plugin add`: one commit, the given origin, nothing +# local. Built with init rather than clone so the origin can be any URL form. +make_checkout() { + local origin="$1" + rm -rf "$checkout" + mkdir -p "$checkout" + git -C "$checkout" init -q + git -C "$checkout" remote add origin "$origin" + printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" + printf 'import QtQuick\nItem {}\n' >"$checkout/Panel.qml" + git -C "$checkout" add -A + git -C "$checkout" commit -q -m "Elsewhen" +} + +reset_layout() { + cat >"$config" <<'JSON' +{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.power" }] } } } +JSON +} + +assert_widget_placed() { + [[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.power"]' ]] || + fail "$1 still gets the widget" "$(cat "$config")" +} + +assert_retired() { + local description="$1" + [[ ! -e $checkout && ! -L $checkout ]] || fail "$description is retired" "$(ls -la "$checkout")" + grep -q 'Retired the omacom.elsewhen checkout' "$output" || fail "$description is reported as retired" "$(cat "$output")" + assert_widget_placed "$description" + pass "$description is retired" +} + +assert_kept() { + local description="$1" + [[ -e $checkout || -L $checkout ]] || fail "$description is kept" + grep -q 'takes precedence over' "$output" || fail "$description is reported as shadowed" "$(cat "$output")" + assert_widget_placed "$description" + pass "$description is kept" +} + +for origin in \ + https://github.com/omacom/elsewhen.git \ + https://github.com/omacom/elsewhen \ + git@github.com:omacom/elsewhen.git \ + ssh://git@github.com/omacom/elsewhen.git \ + https://GitHub.com/Omacom/Elsewhen/; do + make_checkout "$origin" + reset_layout + run_migration + assert_retired "a pristine clone with origin $origin" +done + +make_checkout https://github.com/omacom/elsewhen.git +printf 'import QtQuick\nItem { id: mine }\n' >"$checkout/Panel.qml" +reset_layout +run_migration +assert_kept "a clone with a modified tracked file" +[[ $(cat "$checkout/Panel.qml") == $'import QtQuick\nItem { id: mine }' ]] || + fail "a kept clone keeps its local change" "$(cat "$checkout/Panel.qml")" +pass "a kept clone keeps its local change" + +make_checkout https://github.com/omacom/elsewhen.git +printf 'notes\n' >"$checkout/NOTES.md" +reset_layout +run_migration +assert_kept "a clone with an untracked file" + +make_checkout https://github.com/someone/elsewhen-fork.git +reset_layout +run_migration +assert_kept "a clone of another repository" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" remote remove origin +reset_layout +run_migration +assert_kept "a clone without an origin remote" + +rm -rf "$checkout" +mkdir -p "$checkout" +printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" +reset_layout +run_migration +assert_kept "a plain directory without .git" + +rm -rf "$checkout" +make_checkout https://github.com/omacom/elsewhen.git +mv "$checkout" "$test_dir/elsewhen-src" +ln -s "$test_dir/elsewhen-src" "$checkout" +reset_layout +run_migration +assert_kept "a symlinked checkout" +[[ -L $checkout && -f $test_dir/elsewhen-src/manifest.json ]] || + fail "a symlinked checkout stays a symlink with its target intact" "$(ls -la "$checkout" "$test_dir/elsewhen-src")" +pass "a symlinked checkout stays a symlink with its target intact" + +rm -f "$checkout" +reset_layout +run_migration +grep -q 'omacom.elsewhen checkout\|takes precedence' "$output" && fail "no checkout means no checkout report" "$(cat "$output")" +assert_widget_placed "a machine without a checkout" +pass "a machine without a checkout is left alone" From 49286d0e66b22d6b91e21c0f8c8fe8bd1a6ce064 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 17 Sep 2026 21:07:08 -0500 Subject: [PATCH 22/51] Place Elsewhen immediately before the center clock --- config/omarchy/shell.json | 6 +-- default/agents/skills/omarchy/plugins.md | 4 +- docs/omarchy-shell.md | 11 +----- migrations/1789581661.sh | 10 ++--- shell/README.md | 11 +----- test/shell.d/config-test.sh | 14 +++++-- .../elsewhen-default-migration-test.sh | 37 ++++++++++++------- 7 files changed, 45 insertions(+), 48 deletions(-) diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index 64cc4075..b8aa5de4 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -21,6 +21,9 @@ { "id": "omarchy.indicators" }, + { + "id": "omacom.elsewhen" + }, { "id": "omarchy.clock", "format": "dddd HH:mm", @@ -38,9 +41,6 @@ } ], "right": [ - { - "id": "omacom.elsewhen" - }, { "id": "omarchy.tray" }, diff --git a/default/agents/skills/omarchy/plugins.md b/default/agents/skills/omarchy/plugins.md index bf1033b3..1c58c83a 100644 --- a/default/agents/skills/omarchy/plugins.md +++ b/default/agents/skills/omarchy/plugins.md @@ -13,9 +13,7 @@ inside a single long-running Quickshell process (`omarchy-shell`). $OMARCHY_PATH/config/omarchy/shell.json # Canonical defaults ``` -Packaged plugins such as Elsewhen (`elsewhen` package) update with `omarchy update` and are removed with pacman; -`omarchy plugin update` and `omarchy plugin remove` refuse them and name the -package. +Packaged plugins such as Elsewhen (`elsewhen` package) update with `omarchy update` and are removed with pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package. The shell hot-reloads `shell.json` on save — no restart needed for layout changes. `idle.screensaver` and `idle.lock` are seconds since user idle began. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 18a61896..dd8cc18a 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -90,16 +90,7 @@ The lower-level IPC methods remain available through `omarchy-shell shell ...`. ## Packaged plugins -A default plugin can ship as its own Arch package instead of in the Omarchy -checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs them at -`/usr/share/omarchy/plugins//`, a root the shell scans between -`$OMARCHY_PATH/shell/plugins` and `~/.config/omarchy/plugins`. A packaged -`omarchy.*` id is trusted like a bundled plugin and loads by default; any other -packaged id behaves like an installed plugin. Precedence is bundled, then -packaged, then user, so a checkout under `omarchy dev link` overrides the -package. Packaged plugins update through `omarchy update` and leave through -pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and -name the package, and Setup › Plugins › Remove does not list them. +A default plugin can ship as its own Arch package instead of in the Omarchy checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs it at `/usr/share/omarchy/plugins//`, a root the shell scans between `$OMARCHY_PATH/shell/plugins` and `~/.config/omarchy/plugins`. A packaged `omarchy.*` id is trusted like a bundled plugin and loads by default; any other packaged id behaves like an installed plugin. Precedence is bundled, then packaged, then user, so a checkout under `omarchy dev link` overrides the package. Packaged plugins update through `omarchy update` and leave through pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package, and Setup › Plugins › Remove does not list them. ## IPC diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index fd5d30be..163deb9c 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -50,8 +50,8 @@ if [[ -e $checkout || -L $checkout ]]; then fi fi -# The widget opens the right section of the default bar, just before the -# tray, which a machine without a shell.json takes on as soon as the shell +# The widget sits just before the clock in the center of the default bar, +# which a machine without a shell.json takes on as soon as the shell # restarts. A customized bar gets the entry written into its file rather than # placed over IPC: the shell this runs under cannot see a widget its scan # never reached and would refuse it, whereas the file it hot-reloads carries @@ -76,10 +76,10 @@ commit "$NORMALIZE | $entry_id"' def insert_at($section; $index): .bar.layout[$section] = .bar.layout[$section][:$index] + [{id: "omacom.elsewhen"}] + .bar.layout[$section][$index:]; . as $config - | (["left", "center", "right"] | map(select($config.bar.layout[.] | ids | index("omarchy.tray") != null)) | first) as $section + | (["left", "center", "right"] | map(select($config.bar.layout[.] | ids | index("omarchy.clock") != null)) | first) as $section | if $section != null then - insert_at($section; .bar.layout[$section] | ids | index("omarchy.tray")) + insert_at($section; .bar.layout[$section] | ids | index("omarchy.clock")) else - insert_at("right"; 0) + insert_at("center"; 0) end ' diff --git a/shell/README.md b/shell/README.md index 23a25ca6..53425139 100644 --- a/shell/README.md +++ b/shell/README.md @@ -142,16 +142,7 @@ You can still drop a plugin in without git: ### Packaged plugins -Some default plugins ship as their own Arch package rather than in this -checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs such a plugin, -root-owned, at `/usr/share/omarchy/plugins//`, and -the shell scans that root between its bundled plugins and the user's. A -packaged `omarchy.*` id is trusted exactly like a bundled one; a packaged -plugin under any other id is treated like a user plugin. They update with -`omarchy update` and are removed with pacman, so `omarchy plugin update` and -`omarchy plugin remove` refuse them and name the package instead. A bundled -copy in `$OMARCHY_PATH/shell/plugins` wins over the packaged one, which is how -a packaged plugin is developed under `omarchy dev link`. +Some default plugins ship as their own Arch package rather than in this checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs such a plugin, root-owned, at `/usr/share/omarchy/plugins//`, and the shell scans that root between its bundled plugins and the user's. A packaged `omarchy.*` id is trusted exactly like a bundled one; a packaged plugin under any other id is treated like a user plugin. They update with `omarchy update` and are removed with pacman, so `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package instead. A bundled copy in `$OMARCHY_PATH/shell/plugins` wins over the packaged one, which is how a packaged plugin is developed under `omarchy dev link`. The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index d9086d44..1cd47e33 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -33,6 +33,15 @@ jq -e ' ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default center anchor exists in center layout" +jq -e ' + def ids: map(.id // .); + (.bar.layout.center | ids) as $ids | + ($ids | index("omacom.elsewhen")) as $elsewhen | + ($ids | index("omarchy.clock")) as $clock | + $elsewhen != null and $clock == $elsewhen + 1 +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default center layout puts elsewhen immediately before the clock" + jq -e ' any(.bar.layout.center[]; (.id // .) == "omarchy.clock" and (.formatAlt // "") == "d MMMM \u0027W\u0027ww yyyy") ' "$ROOT/config/omarchy/shell.json" >/dev/null @@ -41,12 +50,11 @@ pass "default clock date format has no leading zero" jq -e ' def ids: map(.id // .); (.bar.layout.right | ids) as $ids | - ($ids | index("omacom.elsewhen")) as $elsewhen | ($ids | index("omarchy.tray")) as $tray | ($ids | index("omarchy.agents")) as $agents | - $elsewhen == 0 and $tray == 1 and $agents == $tray + 1 + $tray == 0 and $agents == $tray + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null -pass "default right layout opens with elsewhen and the tray, then agents" +pass "default right layout opens with the tray, then agents" ROOT="$ROOT" python3 <<'PY' import json diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index 438ba1d3..89585f22 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -89,14 +89,14 @@ run_migration grep -Fxq elsewhen "$CALL_LOG" || fail "migration installs the elsewhen package" "$(cat "$CALL_LOG")" pass "migration installs the elsewhen package" -[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents","omarchy.power"]' ]] || - fail "migration puts the widget just before the tray" "$(cat "$config")" -pass "migration puts the widget just before the tray" +[[ $(ids center) == '["omacom.elsewhen","omarchy.clock"]' && $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] || + fail "migration puts the widget just before the center clock" "$(cat "$config")" +pass "migration puts the widget just before the center clock" -[[ $(jq -c '.bar.layout.right[2]' "$config") == '{"id":"omarchy.agents","syncMode":"On"}' ]] || +[[ $(jq -c '.bar.layout.right[1]' "$config") == '{"id":"omarchy.agents","syncMode":"On"}' ]] || fail "migration keeps the settings of its neighbours" "$(cat "$config")" -[[ $(jq -c '.bar.layout.center[0]' "$config") == '{"format":"HH:mm","id":"omarchy.clock"}' ]] || - fail "migration leaves other sections alone" "$(cat "$config")" +[[ $(jq -c '.bar.layout.center[1]' "$config") == '{"format":"HH:mm","id":"omarchy.clock"}' ]] || + fail "migration keeps the clock settings" "$(cat "$config")" pass "migration leaves the rest of the layout alone" grep -q 'shell rescanPlugins' "$SHELL_CALLS" && grep -q 'shell reloadConfig' "$SHELL_CALLS" || @@ -117,17 +117,17 @@ cat >"$config" <<'JSON' } JSON run_migration -[[ $(ids center) == '["omarchy.clock","omacom.elsewhen","omarchy.tray"]' && $(ids right) == '["omarchy.power"]' ]] || - fail "migration follows the tray into another section and reads string entries" "$(cat "$config")" -pass "migration follows the tray into another section and reads string entries" +[[ $(ids center) == '["omacom.elsewhen","omarchy.clock","omarchy.tray"]' && $(ids right) == '["omarchy.power"]' ]] || + fail "migration reads string clock entries" "$(cat "$config")" +pass "migration reads string clock entries" cat >"$config" <<'JSON' { "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.agents" }, { "id": "omarchy.power" }] } } } JSON run_migration -[[ $(ids right) == '["omacom.elsewhen","omarchy.agents","omarchy.power"]' ]] || - fail "migration prepends to the right section when the tray is off the bar" "$(cat "$config")" -pass "migration prepends to the right section when the tray is off the bar" +[[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.agents","omarchy.power"]' ]] || + fail "migration prepends to the center section when the clock is off the bar" "$(cat "$config")" +pass "migration prepends to the center section when the clock is off the bar" cat >"$config" <<'JSON' { @@ -141,6 +141,15 @@ run_migration fail "migration leaves a widget the user already placed where it is" "$(cat "$config")" pass "migration leaves a widget the user already placed where it is" +# A customized clock keeps its section and settings; insert only its neighbour. +for section in left right; do + jq -n --arg section "$section" '{version: 1, bar: {layout: {center: ["omarchy.weather"], ($section): ["omarchy.menu", {id: "omarchy.clock", format: "HH:mm"}]}}}' >"$config" + run_migration + [[ $(ids "$section") == '["omarchy.menu","omacom.elsewhen","omarchy.clock"]' && $(ids center) == '["omarchy.weather"]' ]] || + fail "migration follows a clock customized into $section" "$(cat "$config")" + pass "migration follows a clock customized into $section" +done + # ------------------------------------------------------------- edge cases rm -f "$config" run_migration @@ -156,7 +165,7 @@ cat >"$config" <<'JSON' { "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } JSON SHELL_STATUS=1 run_migration -[[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.agents"]' ]] || +[[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.tray","omarchy.agents"]' ]] || fail "migration places the widget with no shell running" "$(cat "$config")" pass "migration places the widget with no shell running" @@ -201,7 +210,7 @@ JSON } assert_widget_placed() { - [[ $(ids right) == '["omacom.elsewhen","omarchy.tray","omarchy.power"]' ]] || + [[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.tray","omarchy.power"]' ]] || fail "$1 still gets the widget" "$(cat "$config")" } From e8a8236a22e9d05f4fccded51639d522a88903ca Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 17 Sep 2026 22:07:54 -0500 Subject: [PATCH 23/51] Preserve local Elsewhen work and fallback bar layouts Retire only checkouts whose refs and reflogs are reachable from recorded origin history, and preserve ignored files. Leave configs without an explicit supported bar layout untouched so migration does not replace the shell fallback with an almost empty bar. Co-Authored-By: Codex XHigh --- migrations/1789581661.sh | 13 ++-- .../elsewhen-default-migration-test.sh | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index 163deb9c..f581d4d0 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -29,15 +29,18 @@ omarchy-shell -q shell rescanPlugins || true # one that finds the package. A one-time gap of a few minutes, during the # update itself. upstream_checkout() { - local dir="$1" origin status + local dir="$1" origin status unpublished [[ -d $dir && ! -L $dir && -d $dir/.git ]] || return 1 origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 origin=${origin,,} origin=${origin%/} origin=${origin%.git} [[ $origin =~ ^([a-z+]+://)?([^/@]+@)?github\.com[/:]omacom/elsewhen$ ]] || return 1 - status=$(git -C "$dir" status --porcelain 2>/dev/null) || return 1 - [[ -z $status ]] + status=$(git -C "$dir" status --porcelain --untracked-files=all --ignored 2>/dev/null) || return 1 + [[ -z $status ]] || return 1 + # Local branches, stashes, and reflogs may hold work absent from a clean tree. + unpublished=$(git -C "$dir" rev-list HEAD --all --reflog --not --remotes=origin 2>/dev/null) || return 1 + [[ -z $unpublished ]] } checkout="$HOME/.config/omarchy/plugins/omacom.elsewhen" @@ -60,8 +63,8 @@ fi source omarchy-shell-config [[ -s $CONFIG_FILE ]] || exit 0 -# A file the shell cannot read is left for its owner to repair. -jq empty "$CONFIG_FILE" 2>/dev/null || exit 0 +# Preserve configs whose bar comes from the shell fallback instead of an explicit layout. +jq -e 'type == "object" and .version == 1 and (.bar | type == "object") and (.bar.layout | type == "object")' "$CONFIG_FILE" >/dev/null 2>&1 || exit 0 entry_id='def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;' diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index 89585f22..525094e5 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -156,6 +156,18 @@ run_migration [[ ! -e $config ]] || fail "migration writes no shell.json where the defaults apply" "$(cat "$config")" pass "migration writes no shell.json where the defaults apply" +for partial in \ + '{"version":1,"idle":{"lock":600}}' \ + '{"version":1,"bar":null,"idle":{"lock":600}}' \ + '{"version":1,"bar":{"position":"bottom"}}' \ + '{"bar":{"layout":{"center":["omarchy.clock"]}}}'; do + printf '%s\n' "$partial" >"$config" + before=$(sha256sum "$config") + run_migration + [[ $before == $(sha256sum "$config") ]] || fail "migration preserves fallback configuration" "$(cat "$config")" + pass "migration preserves fallback configuration: $partial" +done + printf '{ not json' >"$config" run_migration [[ $(cat "$config") == '{ not json' ]] || fail "migration leaves an unparsable config untouched" "$(cat "$config")" @@ -201,6 +213,7 @@ make_checkout() { printf 'import QtQuick\nItem {}\n' >"$checkout/Panel.qml" git -C "$checkout" add -A git -C "$checkout" commit -q -m "Elsewhen" + git -C "$checkout" update-ref refs/remotes/origin/main HEAD } reset_layout() { @@ -257,6 +270,62 @@ reset_layout run_migration assert_kept "a clone with an untracked file" +make_checkout https://github.com/omacom/elsewhen.git +printf 'import QtQuick\nItem { id: mine }\n' >"$checkout/Panel.qml" +git -C "$checkout" add Panel.qml +git -C "$checkout" commit -q -m "Local customization" +local_commit=$(git -C "$checkout" rev-parse HEAD) +reset_layout +run_migration +assert_kept "a clean clone with an unpublished commit" +[[ $(git -C "$checkout" rev-parse HEAD) == "$local_commit" ]] || fail "local commit survives" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" checkout -qb local-work +printf 'local branch\n' >"$checkout/Panel.qml" +git -C "$checkout" commit -qam "Unpublished branch" +git -C "$checkout" checkout -q --detach refs/remotes/origin/main +reset_layout +run_migration +assert_kept "an unpublished commit on another branch" + +make_checkout https://github.com/omacom/elsewhen.git +printf 'stashed work\n' >"$checkout/Panel.qml" +git -C "$checkout" stash push -q +reset_layout +run_migration +assert_kept "a clean clone with stashed work" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" config status.showUntrackedFiles no +printf 'private-notes\n' >"$checkout/.git/info/exclude" +printf 'ignored work\n' >"$checkout/private-notes" +reset_layout +run_migration +assert_kept "a clone with an ignored file" +[[ $(cat "$checkout/private-notes") == "ignored work" ]] || fail "ignored file survives" + +make_checkout https://github.com/omacom/elsewhen.git +printf 'recoverable work\n' >"$checkout/Panel.qml" +git -C "$checkout" commit -qam "Recoverable local commit" +git -C "$checkout" reset -q --hard refs/remotes/origin/main +reset_layout +run_migration +assert_kept "a local commit retained only by the reflog" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" update-ref -d refs/remotes/origin/main +reset_layout +run_migration +assert_kept "a clone without recorded upstream history" + +make_checkout https://github.com/omacom/elsewhen.git +git -C "$checkout" config status.showUntrackedFiles no +printf 'untracked work\n' >"$checkout/NOTES.md" +reset_layout +run_migration +assert_kept "untracked files hidden by the user Git configuration" + make_checkout https://github.com/someone/elsewhen-fork.git reset_layout run_migration From 5f34ce4581f7cd65b9555d821a61f58e6314821a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 18 Sep 2026 14:10:14 +0200 Subject: [PATCH 24/51] Detach 1Password from installer terminal --- bin/omarchy-install-service-1password | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/omarchy-install-service-1password b/bin/omarchy-install-service-1password index f59cab92..fc7238a6 100755 --- a/bin/omarchy-install-service-1password +++ b/bin/omarchy-install-service-1password @@ -28,7 +28,7 @@ echo "Installing 1Password extension for Chromium..." install_chromium_extension echo "Opening 1Password..." -uwsm-app -- 1password >/dev/null 2>&1 & +setsid uwsm-app -- 1password >/dev/null 2>&1 & echo "" echo "1Password has been installed. Restart Chromium to load the browser extension." From d174d4aa279ea7393d4fad4a80fed147866106b9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 18 Sep 2026 15:34:01 +0200 Subject: [PATCH 25/51] Add omarchy up alias --- bin/omarchy-update | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e8086..1738ebff 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,6 +1,7 @@ #!/bin/bash # omarchy:summary=Update Omarchy and system packages +# omarchy:alias=omarchy up # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true From d01ef2d5d0ab5048ad8e2c662bdbf1e7d65479bd Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:23:21 +0200 Subject: [PATCH 26/51] Let OWE own video backgrounds while it runs The background plugin now watches for the OWE daemon socket. While OWE is running, the desktop yields video playback to it and the shell keeps stills. The lock screen keeps its own playback. This lets Omarchy cooperate with OWE without OWE editing shell.json, so the engine can ship as a package. Add a package-list note that owe-wallpaper-engine must be added once it is packaged. --- install/omarchy-base.packages | 2 ++ manual/39-backgrounds.md | 2 +- shell/Ui/BackgroundMedia.qml | 6 +++++- shell/plugins/background/Background.qml | 25 ++++++++++++++++++++++++- test/shell.d/video-background-test.sh | 9 +++++++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 76e74110..b38c68b8 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -107,6 +107,8 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode +# TODO: add owe-wallpaper-engine here once it is packaged. The shell yields +# video backgrounds to it while its daemon runs. qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index f2cd51eb..f0291653 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,6 +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. +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. When the OWE wallpaper engine is running, it takes over video backgrounds and the shell keeps only stills. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index b281ae22..f3ab7743 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -8,6 +8,10 @@ Item { property int version: 0 property bool playbackEnabled: true property bool audioEnabled: false + // The desktop yields video playback to OWE while it runs, so two engines + // never decode the same file. The lock screen leaves this off and keeps its + // own playback. + property bool deferVideo: 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. @@ -37,7 +41,7 @@ Item { Loader { id: videoLoader anchors.fill: parent - active: root.path !== "" && root.video && !root.reloading + active: root.path !== "" && root.video && !root.reloading && !root.deferVideo source: "BackgroundVideo.qml" } diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index dea93a4c..f8929fa9 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -32,6 +32,25 @@ Item { // services so playback can stop whenever nothing can see the wallpaper. property var shell: null + // When the OWE wallpaper engine is running, it owns video backgrounds. This + // plugin keeps stills, which OWE hands back to it, and the lock screen keeps + // its own playback. + property bool oweActive: false + + Process { + id: oweStatusProc + command: ["bash", "-c", "test -S \"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/owe/owed.sock\""] + onExited: root.oweActive = (exitCode === 0) + } + + Timer { + id: oweStatusTimer + interval: 5000 + repeat: true + running: true + onTriggered: if (!oweStatusProc.running) oweStatusProc.running = true + } + // 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. @@ -204,7 +223,10 @@ Item { } } - Component.onCompleted: refreshBackground() + Component.onCompleted: { + oweStatusProc.running = true + refreshBackground() + } Variants { model: Quickshell.screens @@ -264,6 +286,7 @@ Item { anchors.fill: parent path: root.displayedBackground reloads: root.displayedReloads + deferVideo: root.oweActive playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere audioEnabled: panel.firstScreen onReadyChanged: { diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 7ebad86b..d3591b4f 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -87,6 +87,15 @@ assert( 'video switches bypass the image-only reveal stack and use the durable background path' ) assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') +assert( + backgroundQml.includes('property bool oweActive: false') && + backgroundQml.includes('/owe/owed.sock') && + /deferVideo: root\.oweActive/.test(backgroundQml) && + /property bool deferVideo: false/.test(mediaQml) && + /active: root\.path !== "" && root\.video && !root\.reloading && !root\.deferVideo/.test(mediaQml) && + !lockQml.includes('deferVideo'), + 'the desktop yields video backgrounds to OWE while it is running, and the lock keeps its own playback' +) assert( lockQml.includes('source: wallpaper.video ? null : wallpaper') && lockQml.includes('visible: !wallpaper.video') && From 02bb92bc74acd2d0b5ff01708f4043e4eec87c92 Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:36:07 +0200 Subject: [PATCH 27/51] Mark the desktop video path as the no-OWE fallback --- shell/plugins/background/Background.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index f8929fa9..21616940 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -34,7 +34,8 @@ Item { // When the OWE wallpaper engine is running, it owns video backgrounds. This // plugin keeps stills, which OWE hands back to it, and the lock screen keeps - // its own playback. + // its own playback. The desktop video path and its pause policy stay as the + // fallback for systems without OWE. property bool oweActive: false Process { From dbebc458db93bcffd4f453a2fa6af1bc12df4d4d Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:46:17 +0200 Subject: [PATCH 28/51] Replace the desktop video path with OWE The desktop background no longer plays videos. OWE owns video backgrounds, and the shell layer stays empty behind one. The shell keeps stills, which OWE hands back to it. Remove the desktop video pause plumbing that only existed to stop an unseen player: the lock, idle, and battery service lookups, the per-output fullscreen check, the first-screen audio opt-in, and the audio output in BackgroundVideo. The lock screen keeps its own silent playback. Update the background tests, the manual, and the package note. --- install/omarchy-base.packages | 4 +- manual/39-backgrounds.md | 2 +- shell/Ui/BackgroundMedia.qml | 19 +++------ shell/Ui/BackgroundVideo.qml | 23 +++------- shell/plugins/background/Background.qml | 57 ++----------------------- test/shell.d/video-background-test.sh | 51 +++++++++------------- 6 files changed, 37 insertions(+), 119 deletions(-) diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index b38c68b8..634b72aa 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -107,8 +107,8 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# TODO: add owe-wallpaper-engine here once it is packaged. The shell yields -# video backgrounds to it while its daemon runs. +# TODO: add owe-wallpaper-engine here once it is packaged. The shell keeps +# stills and the lock screen, and OWE owns desktop video backgrounds. qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index f0291653..7670ea61 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,6 +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. When the OWE wallpaper engine is running, it takes over video backgrounds and the shell keeps only stills. +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. Videos are played by the OWE wallpaper engine. It decodes once for every monitor and keeps playback silent, and it stops playback whenever nothing can see it. The lock screen keeps its own silent playback. A video wallpaper still costs far more power than a still one. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index f3ab7743..fa6b3345 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -7,11 +7,9 @@ Item { property string path: "" property int version: 0 property bool playbackEnabled: true - property bool audioEnabled: false - // The desktop yields video playback to OWE while it runs, so two engines - // never decode the same file. The lock screen leaves this off and keeps its - // own playback. - property bool deferVideo: false + // The desktop turns this off because OWE owns video backgrounds. The lock + // screen leaves it on and keeps its own playback. + property bool videoEnabled: true // 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. @@ -27,7 +25,7 @@ Item { // 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) : "" + readonly property url videoUrl: path && Util.isVideoPath(path) && videoEnabled ? Util.fileUrl(path) : "" Loader { id: imageLoader @@ -41,7 +39,7 @@ Item { Loader { id: videoLoader anchors.fill: parent - active: root.path !== "" && root.video && !root.reloading && !root.deferVideo + active: root.path !== "" && root.video && !root.reloading && root.videoEnabled source: "BackgroundVideo.qml" } @@ -68,13 +66,6 @@ Item { when: videoLoader.item !== null } - Binding { - target: videoLoader.item - property: "audioEnabled" - value: root.audioEnabled - when: videoLoader.item !== null - } - Component { id: imageComponent diff --git a/shell/Ui/BackgroundVideo.qml b/shell/Ui/BackgroundVideo.qml index 519e8bd9..0d0d34e1 100644 --- a/shell/Ui/BackgroundVideo.qml +++ b/shell/Ui/BackgroundVideo.qml @@ -1,15 +1,15 @@ 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. +// The lock screen is the only user. Playback is always silent, so this is 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. 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 @@ -74,23 +74,12 @@ Item { 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 - } - } - + // No audio output is built, so the lock never decodes an audio stream or + // opens an audio client. MediaPlayer { id: player source: root.mediaSource videoOutput: output - audioOutput: audioLoader.item loops: MediaPlayer.Infinite autoPlay: root.playbackEnabled onMediaStatusChanged: { diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 21616940..c51181ed 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -1,5 +1,4 @@ import Quickshell -import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -28,43 +27,6 @@ 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 - - // When the OWE wallpaper engine is running, it owns video backgrounds. This - // plugin keeps stills, which OWE hands back to it, and the lock screen keeps - // its own playback. The desktop video path and its pause policy stay as the - // fallback for systems without OWE. - property bool oweActive: false - - Process { - id: oweStatusProc - command: ["bash", "-c", "test -S \"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/owe/owed.sock\""] - onExited: root.oweActive = (exitCode === 0) - } - - Timer { - id: oweStatusTimer - interval: 5000 - repeat: true - running: true - onTriggered: if (!oweStatusProc.running) oweStatusProc.running = true - } - - // 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) } @@ -252,19 +214,6 @@ Item { // 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() { @@ -282,14 +231,14 @@ Item { WlrLayershell.keyboardFocus: WlrKeyboardFocus.None exclusionMode: ExclusionMode.Ignore + // OWE owns video backgrounds. This layer draws stills, and stays empty + // behind a video so OWE's own layer shows through. BackgroundMedia { id: base anchors.fill: parent path: root.displayedBackground reloads: root.displayedReloads - deferVideo: root.oweActive - playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere - audioEnabled: panel.firstScreen + videoEnabled: false onReadyChanged: { if (ready && root.finishingTransition) { root.incomingBackground = "" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index d3591b4f..f9eab0db 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -33,7 +33,7 @@ assert( videoQml.includes('autoPlay: root.playbackEnabled') && videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') && /imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && - /videoUrl: path && Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) : ""/.test(mediaQml), + /videoUrl: path && Util\.isVideoPath\(path\) && videoEnabled \? Util\.fileUrl\(path\) : ""/.test(mediaQml), 'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file' ) assert( @@ -62,16 +62,12 @@ assert( ) assert( !/^\s*Video\s*\{/m.test(videoQml) && - /property bool audioEnabled: false/.test(videoQml) && - /property bool audioEnabled: false/.test(mediaQml) && - /active: root\.audioEnabled && player\.hasAudio/.test(videoQml) && - /audioOutput: audioLoader\.item/.test(videoQml) && - /muted: root\.priming \|\| !root\.playbackEnabled/.test(videoQml) && - /property: "audioEnabled"\s*\n\s*value: root\.audioEnabled/.test(mediaQml) && - /firstScreen: Quickshell\.screens\.length > 0\s*\n\s*&& String\(Quickshell\.screens\[0\]\.name/.test(backgroundQml) && - backgroundQml.includes('audioEnabled: panel.firstScreen') && + !videoQml.includes('AudioOutput {') && + !videoQml.includes('audioOutput:') && + !mediaQml.includes('audioEnabled') && + !backgroundQml.includes('audioEnabled') && !lockQml.includes('audioEnabled'), - 'a sound track plays from the first monitor only, a silent file builds no audio output, and the lock stays quiet' + 'playback is silent everywhere, so no audio output or audio client is built' ) assert( /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && @@ -88,13 +84,20 @@ assert( ) assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') assert( - backgroundQml.includes('property bool oweActive: false') && - backgroundQml.includes('/owe/owed.sock') && - /deferVideo: root\.oweActive/.test(backgroundQml) && - /property bool deferVideo: false/.test(mediaQml) && - /active: root\.path !== "" && root\.video && !root\.reloading && !root\.deferVideo/.test(mediaQml) && - !lockQml.includes('deferVideo'), - 'the desktop yields video backgrounds to OWE while it is running, and the lock keeps its own playback' + backgroundQml.includes('videoEnabled: false') && + /property bool videoEnabled: true/.test(mediaQml) && + /active: root\.path !== "" && root\.video && !root\.reloading && root\.videoEnabled/.test(mediaQml) && + !lockQml.includes('videoEnabled'), + 'the desktop hands video backgrounds to OWE, and the lock keeps its own playback' +) +assert( + !backgroundQml.includes('playbackEnabled') && + !backgroundQml.includes('sessionObscured') && + !backgroundQml.includes('fullscreenHere') && + !backgroundQml.includes('Hyprland.monitorFor') && + !backgroundQml.includes('omarchy.lock') && + !backgroundQml.includes('omarchy.battery'), + 'the desktop no longer carries the shell video pause policy' ) assert( lockQml.includes('source: wallpaper.video ? null : wallpaper') && @@ -102,20 +105,6 @@ assert( lockQml.includes('visible: wallpaper.video'), 'lock screen bypasses its image effect for video output' ) -assert( - /sessionObscured:\s*lockActive \|\| screensaverActive/.test(backgroundQml) && - backgroundQml.includes('playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere') && - backgroundQml.includes('omarchy.lock') && - backgroundQml.includes('omarchy.idle') && - backgroundQml.includes('omarchy.battery'), - 'desktop playback stops while covered or on battery power-saver' -) -assert( - backgroundQml.includes('Hyprland.monitorFor(modelData)') && - /fullscreenHere: visibleWorkspace \? visibleWorkspace\.hasFullscreen : false/.test(backgroundQml) && - !backgroundQml.includes('ToplevelManager.activeToplevel'), - 'a fullscreen window pauses only the output it covers, wherever focus is' -) assert( /if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) && backgroundQml.includes('reloads: root.displayedReloads') && From 831626daea4acaaf745d336937da30d411cde635 Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:02:01 +0200 Subject: [PATCH 29/51] Note that OWE plays the desktop video audio --- manual/39-backgrounds.md | 2 +- test/shell.d/video-background-test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 7670ea61..6629ef27 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,6 +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. Videos are played by the OWE wallpaper engine. It decodes once for every monitor and keeps playback silent, and it stops playback whenever nothing can see it. The lock screen keeps its own silent playback. A video wallpaper still costs far more power than a still one. +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. Videos are played by the OWE wallpaper engine. It decodes once for every monitor and plays the video's sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen keeps its own silent playback. A video wallpaper still costs far more power than a still one. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index f9eab0db..12653721 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -67,7 +67,7 @@ assert( !mediaQml.includes('audioEnabled') && !backgroundQml.includes('audioEnabled') && !lockQml.includes('audioEnabled'), - 'playback is silent everywhere, so no audio output or audio client is built' + 'the shell builds no audio output: OWE plays the desktop video audio and the lock stays silent' ) assert( /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && From 2e3142a105cfce2f4c06036c84c6bf96a056d92f Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 21:28:04 +0200 Subject: [PATCH 30/51] Point the owe package note at the omarchy-pkgs pull request --- install/omarchy-base.packages | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 634b72aa..0ec925d0 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -107,8 +107,9 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# TODO: add owe-wallpaper-engine here once it is packaged. The shell keeps -# stills and the lock screen, and OWE owns desktop video backgrounds. +# TODO: add owe here once omarchy-pkgs#519 lands. The shell keeps stills and +# the lock screen, and OWE owns desktop video backgrounds. Version 0.1.1 or +# newer plays the video's audio. qt6-imageformats qt6-multimedia qt6-multimedia-ffmpeg From eff410f91e4c54b932db03670883706ca9e5c498 Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:49:47 +0200 Subject: [PATCH 31/51] Draw the lock screen video from the OWE lock feed The shell drops its own player: BackgroundMedia is image-only, and the lock loads Owe.LockFeedSurface through a Loader, so a system without the module shows no lock video instead of losing the whole lock screen. The feed pauses per output when the panel blanks or power saver turns on. The lock view keeps its still effect path and darkens the feed for legibility. QtMultimedia and the shell video pause policy are gone, and the base package list requires owe and owe-lockfeed instead. --- install/omarchy-base.packages | 8 +- shell/Ui/BackgroundMedia.qml | 56 ++---------- shell/Ui/BackgroundVideo.qml | 111 ------------------------ shell/Ui/qmldir | 1 - shell/plugins/background/Background.qml | 7 -- shell/plugins/lock/LockFeedSurface.qml | 11 +++ shell/plugins/lock/LockView.qml | 35 ++++++-- test/shell.d/video-background-test.sh | 111 ++++++++++-------------- 8 files changed, 92 insertions(+), 248 deletions(-) delete mode 100644 shell/Ui/BackgroundVideo.qml create mode 100644 shell/plugins/lock/LockFeedSurface.qml diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 0ec925d0..c292828f 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -107,12 +107,10 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# TODO: add owe here once omarchy-pkgs#519 lands. The shell keeps stills and -# the lock screen, and OWE owns desktop video backgrounds. Version 0.1.1 or -# newer plays the video's audio. +# OWE owns desktop video backgrounds and feeds the lock screen its frames. +owe +owe-lockfeed qt6-imageformats -qt6-multimedia -qt6-multimedia-ffmpeg quickshell ripgrep ruby diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index fa6b3345..aabde8d1 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -1,31 +1,19 @@ import QtQuick import qs.Commons +// The shell draws stills only. OWE owns video backgrounds on the desktop, and +// it feeds the lock screen through its own socket. Item { id: root property string path: "" property int version: 0 - property bool playbackEnabled: true - // The desktop turns this off because OWE owns video backgrounds. The lock - // screen leaves it on and keeps its own playback. - property bool videoEnabled: true - // 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 var current: 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) && videoEnabled ? Util.fileUrl(path) : "" + // Cache-bust images selected in a running lock session, so a theme switch + // that replaces the file behind an unchanged path shows the new pixels. + readonly property url imageUrl: path && !video ? Util.fileUrl(path) + (version ? "?v=" + version : "") : "" Loader { id: imageLoader @@ -34,38 +22,6 @@ Item { 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 && root.videoEnabled - 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 - } - Component { id: imageComponent diff --git a/shell/Ui/BackgroundVideo.qml b/shell/Ui/BackgroundVideo.qml deleted file mode 100644 index 0d0d34e1..00000000 --- a/shell/Ui/BackgroundVideo.qml +++ /dev/null @@ -1,111 +0,0 @@ -import QtQuick -import QtMultimedia - -// The lock screen is the only user. Playback is always silent, so this is 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. -Item { - id: root - - property url mediaSource: "" - property bool playbackEnabled: true - 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 - } - - // No audio output is built, so the lock never decodes an audio stream or - // opens an audio client. - MediaPlayer { - id: player - source: root.mediaSource - videoOutput: output - loops: MediaPlayer.Infinite - autoPlay: root.playbackEnabled - onMediaStatusChanged: { - if (mediaStatus !== MediaPlayer.LoadedMedia) return - - if (!root.playbackEnabled) { - root.priming = true - root.primingGeneration = root.mediaGeneration - root.frameReceived = false - primePauseTimer.restart() - } - player.play() - } - } - - Connections { - target: output.videoSink - function onVideoFrameChanged() { - if (player.mediaStatus !== MediaPlayer.BufferedMedia) return - if (!root.priming - || root.playbackEnabled - || root.primingGeneration !== root.mediaGeneration - || root.frameReceived) return - - root.frameReceived = true - framePauseTimer.restart() - } - } -} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 092caf6c..464e25b7 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -4,7 +4,6 @@ BarIndicator 1.0 BarIndicator.qml BarIconButton 1.0 BarIconButton.qml BarWidget 1.0 BarWidget.qml BackgroundMedia 1.0 BackgroundMedia.qml -BackgroundVideo 1.0 BackgroundVideo.qml BorderOverlay 1.0 BorderOverlay.qml BorderSurface 1.0 BorderSurface.qml Button 1.0 Button.qml diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index c51181ed..8c113e4b 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -16,7 +16,6 @@ Item { property string currentBackground: "" property string displayedBackground: "" - property int displayedReloads: 0 property string incomingBackground: "" property string oldBackground: "" property bool finishingTransition: false @@ -60,9 +59,6 @@ Item { if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { oldBackground = "" incomingBackground = "" - // 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 @@ -187,7 +183,6 @@ Item { } Component.onCompleted: { - oweStatusProc.running = true refreshBackground() } @@ -237,8 +232,6 @@ Item { id: base anchors.fill: parent path: root.displayedBackground - reloads: root.displayedReloads - videoEnabled: false onReadyChanged: { if (ready && root.finishingTransition) { root.incomingBackground = "" diff --git a/shell/plugins/lock/LockFeedSurface.qml b/shell/plugins/lock/LockFeedSurface.qml new file mode 100644 index 00000000..38bd4612 --- /dev/null +++ b/shell/plugins/lock/LockFeedSurface.qml @@ -0,0 +1,11 @@ +import QtQuick +import Owe.LockFeed + +// Loaded through a Loader from LockView, so a system without the module shows +// no lock video instead of losing the whole lock screen. +LockFeed { + id: root + + property bool feedEnabled: true + active: root.feedEnabled +} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index e7a430ac..9366b289 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -43,6 +43,9 @@ Item { ? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha") : Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha") + readonly property bool video: Util.isVideoPath(root.backgroundPath) + readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive + signal submitPassword(string password) signal passwordTextEdited(string password) signal clearFailureRequested() @@ -89,15 +92,33 @@ Item { BackgroundMedia { id: wallpaper anchors.fill: parent - path: root.loadBackground ? root.backgroundPath : "" + path: root.video ? "" : root.backgroundPath version: root.backgroundVersion - playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive + visible: !root.video + } + + // OWE decodes the video once and feeds these frames to every lock surface. + // The feed pauses when this output blanks or on battery power saver. + Loader { + id: feedLoader + anchors.fill: parent + active: root.video + source: "LockFeedSurface.qml" + visible: status === Loader.Ready + + Binding { + target: feedLoader.item + property: "feedEnabled" + value: root.feedActive + when: feedLoader.item !== null + restoreMode: Binding.RestoreNone + } } MultiEffect { anchors.fill: wallpaper - source: wallpaper.video ? null : wallpaper - visible: !wallpaper.video + source: root.video ? null : wallpaper + visible: !root.video autoPaddingEnabled: false blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 @@ -106,11 +127,11 @@ Item { contrast: -0.08 } - // Qt's video output cannot be sampled by MultiEffect on every renderer. + // The feed item 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 + anchors.fill: feedLoader + visible: root.video color: "#22000000" } diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 12653721..31ac754d 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -9,15 +9,13 @@ const fs = require('fs') const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8') const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') -const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8') const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') +const lockFeedQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockFeedSurface.qml'), 'utf8') const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8') const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8') -const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8') const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8') const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8') -const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8') const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') @@ -29,50 +27,45 @@ assert( 'shared media helper identifies video paths without truncating valid local names' ) assert( - videoQml.includes('loops: MediaPlayer.Infinite') && - videoQml.includes('autoPlay: root.playbackEnabled') && - videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') && - /imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && - /videoUrl: path && Util\.isVideoPath\(path\) && videoEnabled \? Util\.fileUrl\(path\) : ""/.test(mediaQml), - 'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file' + !fs.existsSync(path.join(root, 'shell/Ui/BackgroundVideo.qml')), + 'the shell keeps no video player of its own' ) assert( - videoQml.includes('MediaPlayer.LoadedMedia') && - videoQml.includes('primePauseTimer') && - videoQml.includes('mediaGeneration') && - videoQml.includes('videoSink') && - videoQml.includes('onVideoFrameChanged') && - videoQml.includes('MediaPlayer.BufferedMedia') && - videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') && - videoQml.includes('interval: 1000') && - videoQml.includes('interval: 50') && - videoQml.includes('frameReceived') && - videoQml.includes('output.clearOutput()') && - !videoQml.includes('KeepLastFrame') && - /onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) && - videoQml.includes('primingGeneration') && - videoQml.includes('player.play()') && - videoQml.includes('player.pause()'), - 'paused video sources are primed to display their first frame' + /imageUrl: path && !video \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) && + /active: root\.path !== "" && !root\.video/.test(mediaQml) && + !mediaQml.includes('videoUrl') && + !mediaQml.includes('videoLoader') && + !mediaQml.includes('videoEnabled'), + 'background media is image-only: it loads stills and leaves videos to OWE' ) assert( !/^\s*import QtMultimedia/m.test(mediaQml) && - mediaQml.includes('source: "BackgroundVideo.qml"'), - 'the still-image path never imports QtMultimedia, so image-only sessions do not map it' + !/^\s*import QtMultimedia/m.test(backgroundQml) && + !/^\s*import QtMultimedia/m.test(lockQml) && + !backgroundQml.includes('MediaPlayer') && + !lockQml.includes('MediaPlayer'), + 'no shell surface builds a Qt Multimedia pipeline' ) assert( - !/^\s*Video\s*\{/m.test(videoQml) && - !videoQml.includes('AudioOutput {') && - !videoQml.includes('audioOutput:') && - !mediaQml.includes('audioEnabled') && - !backgroundQml.includes('audioEnabled') && - !lockQml.includes('audioEnabled'), - 'the shell builds no audio output: OWE plays the desktop video audio and the lock stays silent' + /^import Owe\.LockFeed$/m.test(lockFeedQml) && + lockFeedQml.includes('LockFeed {') && + lockFeedQml.includes('active: root.feedEnabled') && + lockQml.includes('source: "LockFeedSurface.qml"') && + lockQml.includes('active: root.video') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && + /property: "feedEnabled"[\s\S]*?value: root\.feedActive/.test(lockQml), + 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video' ) assert( - /property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && - !videoQml.includes('Component.onDestruction'), - 'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel' + !/^import Owe\./m.test(lockQml), + 'the lock view itself carries no foreign import, so the lock still loads without the feed module' +) +assert( + lockQml.includes('path: root.video ? "" : root.backgroundPath') && + lockQml.includes('visible: !root.video') && + lockQml.includes('visible: root.video') && + !lockQml.includes('wallpaper.video'), + 'the lock screen keeps its image effect for stills and shows the feed for videos' ) assert( !mediaQml.includes('mipmap'), @@ -82,13 +75,12 @@ assert( /instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml), 'video switches bypass the image-only reveal stack and use the durable background path' ) -assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') +assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share still rendering') assert( - backgroundQml.includes('videoEnabled: false') && - /property bool videoEnabled: true/.test(mediaQml) && - /active: root\.path !== "" && root\.video && !root\.reloading && root\.videoEnabled/.test(mediaQml) && - !lockQml.includes('videoEnabled'), - 'the desktop hands video backgrounds to OWE, and the lock keeps its own playback' + !backgroundQml.includes('videoEnabled') && + !backgroundQml.includes('displayedReloads') && + !mediaQml.includes('displayedReloads'), + 'the desktop hands video backgrounds to OWE with no player switch to carry' ) assert( !backgroundQml.includes('playbackEnabled') && @@ -99,19 +91,6 @@ assert( !backgroundQml.includes('omarchy.battery'), 'the desktop no longer carries the shell video pause policy' ) -assert( - lockQml.includes('source: wallpaper.video ? null : wallpaper') && - lockQml.includes('visible: !wallpaper.video') && - lockQml.includes('visible: wallpaper.video'), - 'lock screen bypasses its image effect for video output' -) -assert( - /if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) && - backgroundQml.includes('reloads: root.displayedReloads') && - /active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) && - /onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml), - 'a theme switch that keeps the video path still reopens the replaced file' -) assert( barTextColor.includes('magick "$background_path[0]"'), 'bar colour sampling reads one frame instead of decoding a whole video' @@ -161,13 +140,12 @@ assert( 'a locked video wallpaper follows what each panel actually did, not only what the lock asked for' ) assert( - lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') && - lockView.includes('&& !root.powerSaverActive') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && /displaysBlank: root\.screenBlank\(/.test(lockService) && /powerSaverActive: root\.powerSaverActive/.test(lockService) && /function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) && /function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService), - 'the lock screen stops playback once displays go dark or power-saver is active' + 'the lock feed stops once displays go dark or power-saver is active' ) assert( batteryService.includes('property string activePowerProfile') && @@ -184,10 +162,6 @@ assert( 'theme switcher previews video-only themes, named preview files included' ) assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background') -assert( - multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'), - 'existing Quattro installations receive video playback dependencies' -) JS test_tmp=$(mktemp -d) @@ -301,8 +275,11 @@ timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \ timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) [[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed" -grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package" -grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package" +grep -qx 'owe' "$ROOT/install/omarchy-base.packages" || fail "OWE is a base package" +grep -qx 'owe-lockfeed' "$ROOT/install/omarchy-base.packages" || fail "the OWE lock feed module is a base package" +if grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages"; then + fail "Qt Multimedia is no longer needed by the shell" +fi pass "menu image generator creates thumbnails consumed by the picker" pass "direct picker generates and reuses still thumbnails" @@ -310,7 +287,7 @@ pass "direct picker omits videos whose thumbnails cannot be generated" pass "a rejected video is remembered so it costs nothing on the next open" pass "a timed out video is left to retry" pass "a locked video wallpaper follows the panels' real DPMS state" -pass "Qt Multimedia playback dependencies are declared" +pass "OWE and its lock feed module are declared" source <(awk ' /^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 } From b0bf4876b10619743e5b93b577e19a37cbbe45cc Mon Sep 17 00:00:00 2001 From: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:55:27 +0200 Subject: [PATCH 32/51] Describe the lock feed in the background manual --- manual/39-backgrounds.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index 6629ef27..f57d23d2 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -4,6 +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. Videos are played by the OWE wallpaper engine. It decodes once for every monitor and plays the video's sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen keeps its own silent playback. A video wallpaper still costs far more power than a still one. +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. Videos are played by the OWE wallpaper engine. It decodes the video once for all monitors and plays its sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen draws the same decode, muted, through OWE. A video wallpaper still costs far more power than a still one. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. From 9e3ff71f4805a4deb20605c5178a78dce7c578b9 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 19 Sep 2026 00:00:15 -0500 Subject: [PATCH 33/51] Simplify Elsewhen installation and bar migration Link the package into the existing plugin directory and let bar put handle enablement, clock-relative placement, and the missing-clock fallback. Preserve user checkouts and existing placements, and seed the same link for new users. This removes the Atreyu packaged-discovery prerequisite and the checkout cleanup and JSON rewrite machinery. --- config/omarchy/plugins/omacom.elsewhen | 1 + default/agents/skills/omarchy/plugins.md | 3 - docs/file-layout.md | 3 - docs/omarchy-shell.md | 4 +- migrations/1789581661.sh | 89 +--- shell/README.md | 5 - shell/plugins/README.md | 6 +- test/shell.d/config-test.sh | 4 +- .../elsewhen-default-migration-test.sh | 382 +++--------------- 9 files changed, 61 insertions(+), 436 deletions(-) create mode 120000 config/omarchy/plugins/omacom.elsewhen diff --git a/config/omarchy/plugins/omacom.elsewhen b/config/omarchy/plugins/omacom.elsewhen new file mode 120000 index 00000000..19840c51 --- /dev/null +++ b/config/omarchy/plugins/omacom.elsewhen @@ -0,0 +1 @@ +/usr/share/omarchy/plugins/omacom.elsewhen \ No newline at end of file diff --git a/default/agents/skills/omarchy/plugins.md b/default/agents/skills/omarchy/plugins.md index 1c58c83a..6e3ab069 100644 --- a/default/agents/skills/omarchy/plugins.md +++ b/default/agents/skills/omarchy/plugins.md @@ -9,12 +9,9 @@ inside a single long-running Quickshell process (`omarchy-shell`). ``` ~/.config/omarchy/shell.json # User overrides: bar, plugins, idle ~/.config/omarchy/plugins// # User-owned shell plugins -/usr/share/omarchy/plugins// # Packaged plugins (pacman), e.g. omacom.elsewhen $OMARCHY_PATH/config/omarchy/shell.json # Canonical defaults ``` -Packaged plugins such as Elsewhen (`elsewhen` package) update with `omarchy update` and are removed with pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package. - The shell hot-reloads `shell.json` on save — no restart needed for layout changes. `idle.screensaver` and `idle.lock` are seconds since user idle began. diff --git a/docs/file-layout.md b/docs/file-layout.md index 8bbcd5b2..f261085f 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -78,9 +78,6 @@ install/** ──► omarchy /usr/share/omarchy migrations/** ──► omarchy /usr/share/omarchy/migrations/ themes/** ──► omarchy /usr/share/omarchy/themes/ shell/** ──► omarchy /usr/share/omarchy/shell/ - (packaged plugins from their own - packages, e.g. elsewhen, land beside it - in /usr/share/omarchy/plugins//) version ──► omarchy /usr/share/omarchy/version + /etc/skel/.local/state/omarchy/migrations/* diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index dd8cc18a..6e999682 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -88,9 +88,9 @@ bar from the CLI — `use | reset | defaults | position | transparent | put | move | set`, with placement flags such as `--section` and `--index`. The lower-level IPC methods remain available through `omarchy-shell shell ...`. -## Packaged plugins +## Elsewhen -A default plugin can ship as its own Arch package instead of in the Omarchy checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs it at `/usr/share/omarchy/plugins//`, a root the shell scans between `$OMARCHY_PATH/shell/plugins` and `~/.config/omarchy/plugins`. A packaged `omarchy.*` id is trusted like a bundled plugin and loads by default; any other packaged id behaves like an installed plugin. Precedence is bundled, then packaged, then user, so a checkout under `omarchy dev link` overrides the package. Packaged plugins update through `omarchy update` and leave through pacman; `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package, and Setup › Plugins › Remove does not list them. +Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/plugins/omacom.elsewhen`. A symlink in `~/.config/omarchy/plugins/` makes it available to the shell. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. ## IPC diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index f581d4d0..10f18e66 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -1,88 +1,13 @@ echo "Install Elsewhen, the world clock plugin" -# Elsewhen ships as the elsewhen package rather than in this checkout; pacman -# puts it under /usr/share/omarchy/plugins, where the shell looks next to its -# bundled plugins. A package the mirror does not carry yet fails the migration -# on purpose: nothing before this line has changed anything, and the runner -# retries it on the next update or login rather than quietly leaving the -# machine without the plugin. omarchy-pkg-add elsewhen -# A shell that already knows the packaged root (dev-linked, or restarted -# since) picks the plugin up here; the one an update runs under predates it -# and is replaced by omarchy-update-restart right after the migrations. -omarchy-shell -q shell rescanPlugins || true - -# Before the package, the README had users run -# `omarchy plugin add https://github.com/omacom/elsewhen.git`, which left a -# git clone under ~/.config/omarchy/plugins. The packaged copy now shadows -# it: the shell logs a rejection on every scan, and `omarchy plugin update` -# keeps pulling a checkout nothing loads. A pristine clone of the upstream -# repo is retired the way `omarchy plugin remove` retires one (the repo -# remains upstream). Anything else -- a symlink, a plain directory, local -# changes, a fork, a checkout git cannot read -- is the user's and stays. This -# runs only once the package is in place, so the plugin never leaves a -# machine; its id stays in shell.json either way, which is what keeps it -# enabled. The shell an update runs under predates the packaged root, and it -# watches this directory: dropping the checkout empties the widget's slot in -# that shell until omarchy-update-restart replaces it a few steps later with -# one that finds the package. A one-time gap of a few minutes, during the -# update itself. -upstream_checkout() { - local dir="$1" origin status unpublished - [[ -d $dir && ! -L $dir && -d $dir/.git ]] || return 1 - origin=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1 - origin=${origin,,} - origin=${origin%/} - origin=${origin%.git} - [[ $origin =~ ^([a-z+]+://)?([^/@]+@)?github\.com[/:]omacom/elsewhen$ ]] || return 1 - status=$(git -C "$dir" status --porcelain --untracked-files=all --ignored 2>/dev/null) || return 1 - [[ -z $status ]] || return 1 - # Local branches, stashes, and reflogs may hold work absent from a clean tree. - unpublished=$(git -C "$dir" rev-list HEAD --all --reflog --not --remotes=origin 2>/dev/null) || return 1 - [[ -z $unpublished ]] -} - -checkout="$HOME/.config/omarchy/plugins/omacom.elsewhen" -if [[ -e $checkout || -L $checkout ]]; then - if upstream_checkout "$checkout"; then - rm -rf "$checkout" - echo "Retired the omacom.elsewhen checkout at $checkout in favour of the elsewhen package." - else - echo "The elsewhen package takes precedence over $checkout, which is left as it is." - fi +plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen" +mkdir -p "$(dirname "$plugin")" +if [[ ! -e $plugin && ! -L $plugin ]]; then + ln -s /usr/share/omarchy/plugins/omacom.elsewhen "$plugin" fi -# The widget sits just before the clock in the center of the default bar, -# which a machine without a shell.json takes on as soon as the shell -# restarts. A customized bar gets the entry written into its file rather than -# placed over IPC: the shell this runs under cannot see a widget its scan -# never reached and would refuse it, whereas the file it hot-reloads carries -# the entry through to the restart. A bar that already carries the widget -# keeps it where the user put it. -source omarchy-shell-config - -[[ -s $CONFIG_FILE ]] || exit 0 -# Preserve configs whose bar comes from the shell fallback instead of an explicit layout. -jq -e 'type == "object" and .version == 1 and (.bar | type == "object") and (.bar.layout | type == "object")' "$CONFIG_FILE" >/dev/null 2>&1 || exit 0 - -entry_id='def entry_id: if type == "object" then (.id // "" | tostring) else tostring end;' - -if jq -e "$entry_id"' - any((.bar.layout // {} | .left, .center, .right | arrays)[]; entry_id == "omacom.elsewhen") -' "$CONFIG_FILE" >/dev/null; then - exit 0 -fi - -commit "$NORMALIZE | $entry_id"' - def ids: map(entry_id); - def insert_at($section; $index): - .bar.layout[$section] = .bar.layout[$section][:$index] + [{id: "omacom.elsewhen"}] + .bar.layout[$section][$index:]; - . as $config - | (["left", "center", "right"] | map(select($config.bar.layout[.] | ids | index("omarchy.clock") != null)) | first) as $section - | if $section != null then - insert_at($section; .bar.layout[$section] | ids | index("omarchy.clock")) - else - insert_at("center"; 0) - end -' +omarchy-shell shell rescanPlugins +omarchy-bar put omacom.elsewhen --before omarchy.clock +omarchy-restart-shell diff --git a/shell/README.md b/shell/README.md index 53425139..ca07b520 100644 --- a/shell/README.md +++ b/shell/README.md @@ -140,10 +140,6 @@ You can still drop a plugin in without git: `barWidget.defaultSection`, or in the center when it is omitted, and can be moved with `omarchy bar move`; a full bar replaces the one in use. -### Packaged plugins - -Some default plugins ship as their own Arch package rather than in this checkout: Elsewhen (`omacom.elsewhen`) is the `elsewhen` package. pacman installs such a plugin, root-owned, at `/usr/share/omarchy/plugins//`, and the shell scans that root between its bundled plugins and the user's. A packaged `omarchy.*` id is trusted exactly like a bundled one; a packaged plugin under any other id is treated like a user plugin. They update with `omarchy update` and are removed with pacman, so `omarchy plugin update` and `omarchy plugin remove` refuse them and name the package instead. A bundled copy in `$OMARCHY_PATH/shell/plugins` wins over the packaged one, which is how a packaged plugin is developed under `omarchy dev link`. - The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, `omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. The `omarchy plugin` commands wrap those calls. `omarchy bar move` and @@ -228,7 +224,6 @@ customization from the shipped defaults lives in it. |-----------------------------------|----------------|--------------------------------------------------------| | `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list | | `~/.config/omarchy/plugins//` | user | drop-in third-party plugin source files | -| `/usr/share/omarchy/plugins//` | pacman | packaged plugins (e.g. `elsewhen`), updated by `omarchy update` | The `config/omarchy/shell.json` default config describes the fresh-install state. When the user has no `shell.json`, the shell uses diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 1a2d26fb..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -9,10 +9,7 @@ First-party non-bar plugins are enabled unless listed in `disabledPlugins[]`; at startup; other panels, overlays, and menus are loaded on demand. User-installed plugins live alongside these conceptually but on disk under -`~/.config/omarchy/plugins//` rather than in this directory. A few -default plugins arrive as their own package instead: pacman installs them at -`/usr/share/omarchy/plugins//`, where an `omarchy.*` id is trusted -like the plugins here, and a copy in this directory shadows the packaged one. +`~/.config/omarchy/plugins//` rather than in this directory. | Plugin | id | kinds | entry point | |---------------|---------------------------|-------------------------|---------------------------------------| @@ -31,7 +28,6 @@ like the plugins here, and a copy in this directory shadows the packaged one. | Power | `omarchy.power` | `bar-widget` | `panels/power/Panel.qml` | | Tailscale | `omarchy.tailscale` | `bar-widget` | `panels/tailscale/Panel.qml` | | Agents | `omarchy.agents` | `bar-widget` | `agents/Panel.qml` | -| Elsewhen | `omacom.elsewhen` | `bar-widget` | packaged: `elsewhen` installs `/usr/share/omarchy/plugins/omacom.elsewhen/` | | Weather | `omarchy.weather` | `bar-widget` | `panels/weather/BarWidget.qml` | | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 1cd47e33..82a51e6f 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -52,9 +52,9 @@ jq -e ' (.bar.layout.right | ids) as $ids | ($ids | index("omarchy.tray")) as $tray | ($ids | index("omarchy.agents")) as $agents | - $tray == 0 and $agents == $tray + 1 + $tray != null and $agents == $tray + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null -pass "default right layout opens with the tray, then agents" +pass "default right layout keeps agents next to the tray" ROOT="$ROOT" python3 <<'PY' import json diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index 525094e5..bda62fd6 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -3,361 +3,75 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - require_command jq -require_command git -migration="$ROOT/migrations/1789581661.sh" test_dir=$(mktemp -d) trap 'rm -rf "$test_dir"' EXIT +mkdir -p "$test_dir/bin" "$test_dir/home" +export CALL_LOG="$test_dir/calls" -mkdir -p "$test_dir/bin" -export INSTALLED_PACKAGES="$test_dir/installed" CALL_LOG="$test_dir/calls" SHELL_CALLS="$test_dir/shell-calls" -# The checkout cases use the real git; keep the developer's config (signing, -# hooks, safe.directory) out of what it sees. -export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 -export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com -export GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com - -# Keep the real package helpers, but contain every pacman transaction here. -cat >"$test_dir/bin/pacman" <<'SH' +cat >"$test_dir/bin/omarchy-pkg-add" <<'SH' #!/bin/bash -case "$1" in - -Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;; - -S) - [[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1 - shift 3 # -S --noconfirm --needed - printf '%s\n' "$@" >>"$INSTALLED_PACKAGES" - printf '%s\n' "$@" >>"$CALL_LOG" - ;; - *) exit 1 ;; -esac +printf 'package %s\n' "$*" >>"$CALL_LOG" +exit "${PACKAGE_STATUS:-0}" SH -cat >"$test_dir/bin/sudo" <<'SH' -#!/bin/bash -[[ $1 == "pacman" ]] || exit 1 -"$@" -SH -# The migration runs under a shell that predates the packaged root, or under -# none at all, so it must only ever ask the shell for best-effort refreshes. cat >"$test_dir/bin/omarchy-shell" <<'SH' #!/bin/bash -printf '%s\n' "$*" >>"$SHELL_CALLS" -[[ $1 != "-q" ]] || exit 0 -exit "${SHELL_STATUS:-0}" +printf '%s\n' "$*" >>"$CALL_LOG" +[[ $2 != "rescanPlugins" ]] || exit "${SCAN_STATUS:-0}" +printf '%s\n' "${TEST_PUT_RESULT:-ok}" +SH +cat >"$test_dir/bin/omarchy-restart-shell" <<'SH' +#!/bin/bash +printf 'restart\n' >>"$CALL_LOG" SH chmod +x "$test_dir/bin/"* -home="$test_dir/home" -config="$home/.config/omarchy/shell.json" -checkout="$home/.config/omarchy/plugins/omacom.elsewhen" -output="$test_dir/output" -mkdir -p "$home/.config/omarchy" - +plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen" run_migration() { : >"$CALL_LOG" - : >"$SHELL_CALLS" - HOME="$home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ - bash -euo pipefail "$migration" >"$output" + HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1789581661.sh" >"$test_dir/output" 2>&1 } -ids() { - jq -c ".bar.layout.$1 | map(if type == \"object\" then .id else . end)" "$config" -} - -# ------------------------------------------------------------ package install -: >"$INSTALLED_PACKAGES" -cat >"$config" <<'JSON' -{ - "version": 1, - "bar": { - "layout": { - "left": [{ "id": "omarchy.menu" }], - "center": [{ "id": "omarchy.clock", "format": "HH:mm" }], - "right": [ - { "id": "omarchy.tray" }, - { "id": "omarchy.agents", "syncMode": "On" }, - { "id": "omarchy.power" } - ] - } - }, - "plugins": [] -} -JSON - -run_migration -grep -Fxq elsewhen "$CALL_LOG" || fail "migration installs the elsewhen package" "$(cat "$CALL_LOG")" -pass "migration installs the elsewhen package" - -[[ $(ids center) == '["omacom.elsewhen","omarchy.clock"]' && $(ids right) == '["omarchy.tray","omarchy.agents","omarchy.power"]' ]] || - fail "migration puts the widget just before the center clock" "$(cat "$config")" -pass "migration puts the widget just before the center clock" - -[[ $(jq -c '.bar.layout.right[1]' "$config") == '{"id":"omarchy.agents","syncMode":"On"}' ]] || - fail "migration keeps the settings of its neighbours" "$(cat "$config")" -[[ $(jq -c '.bar.layout.center[1]' "$config") == '{"format":"HH:mm","id":"omarchy.clock"}' ]] || - fail "migration keeps the clock settings" "$(cat "$config")" -pass "migration leaves the rest of the layout alone" - -grep -q 'shell rescanPlugins' "$SHELL_CALLS" && grep -q 'shell reloadConfig' "$SHELL_CALLS" || - fail "migration asks the running shell to rescan and reload" "$(cat "$SHELL_CALLS")" -pass "migration asks the running shell to rescan and reload" - -before=$(sha256sum "$config") -run_migration -[[ $before == $(sha256sum "$config") ]] || fail "migration is idempotent" "$(cat "$config")" -[[ ! -s $CALL_LOG ]] || fail "migration does not reinstall a present package" "$(cat "$CALL_LOG")" -pass "migration is idempotent" - -# ------------------------------------------------------------- placements -cat >"$config" <<'JSON' -{ - "version": 1, - "bar": { "layout": { "left": [], "center": ["omarchy.clock", "omarchy.tray"], "right": ["omarchy.power"] } } -} -JSON -run_migration -[[ $(ids center) == '["omacom.elsewhen","omarchy.clock","omarchy.tray"]' && $(ids right) == '["omarchy.power"]' ]] || - fail "migration reads string clock entries" "$(cat "$config")" -pass "migration reads string clock entries" - -cat >"$config" <<'JSON' -{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.agents" }, { "id": "omarchy.power" }] } } } -JSON -run_migration -[[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.agents","omarchy.power"]' ]] || - fail "migration prepends to the center section when the clock is off the bar" "$(cat "$config")" -pass "migration prepends to the center section when the clock is off the bar" - -cat >"$config" <<'JSON' -{ - "version": 1, - "bar": { "layout": { "left": [{ "id": "omacom.elsewhen" }], "center": [], "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } -} -JSON -before=$(sha256sum "$config") -run_migration -[[ $before == $(sha256sum "$config") ]] || - fail "migration leaves a widget the user already placed where it is" "$(cat "$config")" -pass "migration leaves a widget the user already placed where it is" - -# A customized clock keeps its section and settings; insert only its neighbour. -for section in left right; do - jq -n --arg section "$section" '{version: 1, bar: {layout: {center: ["omarchy.weather"], ($section): ["omarchy.menu", {id: "omarchy.clock", format: "HH:mm"}]}}}' >"$config" - run_migration - [[ $(ids "$section") == '["omarchy.menu","omacom.elsewhen","omarchy.clock"]' && $(ids center) == '["omarchy.weather"]' ]] || - fail "migration follows a clock customized into $section" "$(cat "$config")" - pass "migration follows a clock customized into $section" -done - -# ------------------------------------------------------------- edge cases -rm -f "$config" -run_migration -[[ ! -e $config ]] || fail "migration writes no shell.json where the defaults apply" "$(cat "$config")" -pass "migration writes no shell.json where the defaults apply" - -for partial in \ - '{"version":1,"idle":{"lock":600}}' \ - '{"version":1,"bar":null,"idle":{"lock":600}}' \ - '{"version":1,"bar":{"position":"bottom"}}' \ - '{"bar":{"layout":{"center":["omarchy.clock"]}}}'; do - printf '%s\n' "$partial" >"$config" - before=$(sha256sum "$config") - run_migration - [[ $before == $(sha256sum "$config") ]] || fail "migration preserves fallback configuration" "$(cat "$config")" - pass "migration preserves fallback configuration: $partial" -done - -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" - -cat >"$config" <<'JSON' -{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } -JSON -SHELL_STATUS=1 run_migration -[[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.tray","omarchy.agents"]' ]] || - fail "migration places the widget with no shell running" "$(cat "$config")" -pass "migration places the widget with no shell running" - -# A package the mirror does not carry yet leaves the migration pending rather -# than half-done: the layout must not name a widget nothing can install. -: >"$INSTALLED_PACKAGES" -cat >"$config" <<'JSON' -{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.agents" }] } } } -JSON -before=$(sha256sum "$config") -if FAIL_INSTALL=1 run_migration 2>/dev/null; then - fail "a failed package installation must leave the migration pending" +if PACKAGE_STATUS=1 run_migration; then + fail "package failure stops the migration" fi -[[ $before == $(sha256sum "$config") ]] || - fail "a failed package installation leaves the layout untouched" "$(cat "$config")" -pass "a failed package installation is propagated and changes nothing" +[[ ! -e $plugin && ! -L $plugin && $(cat "$CALL_LOG") == "package elsewhen" ]] || + fail "package failure leaves the plugin and shell untouched" +pass "package failure stops before linking or changing the shell" -# ------------------------------------------------- pre-package checkouts -# Users who followed the README before the package existed hold a git clone -# under ~/.config/omarchy/plugins, which the packaged copy now shadows. Only a -# clone of the upstream repo with nothing local in it is retired; everything -# else is the user's and stays, and shell.json gets the widget either way. +run_migration +[[ $(readlink "$plugin") == /usr/share/omarchy/plugins/omacom.elsewhen ]] || fail "package link is installed" +[[ $(readlink "$ROOT/config/omarchy/plugins/omacom.elsewhen") == "$(readlink "$plugin")" ]] || fail "fresh installs use the same package link" +pass "migration and fresh installs link to the package" -# A clone made by `omarchy plugin add`: one commit, the given origin, nothing -# local. Built with init rather than clone so the origin can be any URL form. -make_checkout() { - local origin="$1" - rm -rf "$checkout" - mkdir -p "$checkout" - git -C "$checkout" init -q - git -C "$checkout" remote add origin "$origin" - printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" - printf 'import QtQuick\nItem {}\n' >"$checkout/Panel.qml" - git -C "$checkout" add -A - git -C "$checkout" commit -q -m "Elsewhen" - git -C "$checkout" update-ref refs/remotes/origin/main HEAD -} +expected=$'package elsewhen\nshell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}\nrestart' +[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan, placement and restart run in order" "$(cat "$CALL_LOG")" +pass "real bar helper enables and places before the clock, then restarts" -reset_layout() { - cat >"$config" <<'JSON' -{ "version": 1, "bar": { "layout": { "right": [{ "id": "omarchy.tray" }, { "id": "omarchy.power" }] } } } -JSON -} +run_migration +[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun" +pass "migration can be rerun with its package link present" -assert_widget_placed() { - [[ $(ids center) == '["omacom.elsewhen"]' && $(ids right) == '["omarchy.tray","omarchy.power"]' ]] || - fail "$1 still gets the widget" "$(cat "$config")" -} +rm "$plugin" +mkdir "$plugin" +printf 'local work\n' >"$plugin/notes" +run_migration +[[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved" +pass "existing checkout and local files are preserved" -assert_retired() { - local description="$1" - [[ ! -e $checkout && ! -L $checkout ]] || fail "$description is retired" "$(ls -la "$checkout")" - grep -q 'Retired the omacom.elsewhen checkout' "$output" || fail "$description is reported as retired" "$(cat "$output")" - assert_widget_placed "$description" - pass "$description is retired" -} +rm "$plugin/notes" +rmdir "$plugin" +ln -s "$test_dir/custom-plugin" "$plugin" +run_migration +[[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "existing symlink is preserved" +pass "existing symlink is preserved, including a missing target" -assert_kept() { - local description="$1" - [[ -e $checkout || -L $checkout ]] || fail "$description is kept" - grep -q 'takes precedence over' "$output" || fail "$description is reported as shadowed" "$(cat "$output")" - assert_widget_placed "$description" - pass "$description is kept" -} - -for origin in \ - https://github.com/omacom/elsewhen.git \ - https://github.com/omacom/elsewhen \ - git@github.com:omacom/elsewhen.git \ - ssh://git@github.com/omacom/elsewhen.git \ - https://GitHub.com/Omacom/Elsewhen/; do - make_checkout "$origin" - reset_layout - run_migration - assert_retired "a pristine clone with origin $origin" +for failure in 'SCAN_STATUS=1' 'TEST_PUT_RESULT=unknown'; do + if env "$failure" HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1789581661.sh" >"$test_dir/output" 2>&1; then + fail "$failure must leave the migration pending" + fi + pass "$failure leaves the migration pending" done - -make_checkout https://github.com/omacom/elsewhen.git -printf 'import QtQuick\nItem { id: mine }\n' >"$checkout/Panel.qml" -reset_layout -run_migration -assert_kept "a clone with a modified tracked file" -[[ $(cat "$checkout/Panel.qml") == $'import QtQuick\nItem { id: mine }' ]] || - fail "a kept clone keeps its local change" "$(cat "$checkout/Panel.qml")" -pass "a kept clone keeps its local change" - -make_checkout https://github.com/omacom/elsewhen.git -printf 'notes\n' >"$checkout/NOTES.md" -reset_layout -run_migration -assert_kept "a clone with an untracked file" - -make_checkout https://github.com/omacom/elsewhen.git -printf 'import QtQuick\nItem { id: mine }\n' >"$checkout/Panel.qml" -git -C "$checkout" add Panel.qml -git -C "$checkout" commit -q -m "Local customization" -local_commit=$(git -C "$checkout" rev-parse HEAD) -reset_layout -run_migration -assert_kept "a clean clone with an unpublished commit" -[[ $(git -C "$checkout" rev-parse HEAD) == "$local_commit" ]] || fail "local commit survives" - -make_checkout https://github.com/omacom/elsewhen.git -git -C "$checkout" checkout -qb local-work -printf 'local branch\n' >"$checkout/Panel.qml" -git -C "$checkout" commit -qam "Unpublished branch" -git -C "$checkout" checkout -q --detach refs/remotes/origin/main -reset_layout -run_migration -assert_kept "an unpublished commit on another branch" - -make_checkout https://github.com/omacom/elsewhen.git -printf 'stashed work\n' >"$checkout/Panel.qml" -git -C "$checkout" stash push -q -reset_layout -run_migration -assert_kept "a clean clone with stashed work" - -make_checkout https://github.com/omacom/elsewhen.git -git -C "$checkout" config status.showUntrackedFiles no -printf 'private-notes\n' >"$checkout/.git/info/exclude" -printf 'ignored work\n' >"$checkout/private-notes" -reset_layout -run_migration -assert_kept "a clone with an ignored file" -[[ $(cat "$checkout/private-notes") == "ignored work" ]] || fail "ignored file survives" - -make_checkout https://github.com/omacom/elsewhen.git -printf 'recoverable work\n' >"$checkout/Panel.qml" -git -C "$checkout" commit -qam "Recoverable local commit" -git -C "$checkout" reset -q --hard refs/remotes/origin/main -reset_layout -run_migration -assert_kept "a local commit retained only by the reflog" - -make_checkout https://github.com/omacom/elsewhen.git -git -C "$checkout" update-ref -d refs/remotes/origin/main -reset_layout -run_migration -assert_kept "a clone without recorded upstream history" - -make_checkout https://github.com/omacom/elsewhen.git -git -C "$checkout" config status.showUntrackedFiles no -printf 'untracked work\n' >"$checkout/NOTES.md" -reset_layout -run_migration -assert_kept "untracked files hidden by the user Git configuration" - -make_checkout https://github.com/someone/elsewhen-fork.git -reset_layout -run_migration -assert_kept "a clone of another repository" - -make_checkout https://github.com/omacom/elsewhen.git -git -C "$checkout" remote remove origin -reset_layout -run_migration -assert_kept "a clone without an origin remote" - -rm -rf "$checkout" -mkdir -p "$checkout" -printf '{ "id": "omacom.elsewhen" }\n' >"$checkout/manifest.json" -reset_layout -run_migration -assert_kept "a plain directory without .git" - -rm -rf "$checkout" -make_checkout https://github.com/omacom/elsewhen.git -mv "$checkout" "$test_dir/elsewhen-src" -ln -s "$test_dir/elsewhen-src" "$checkout" -reset_layout -run_migration -assert_kept "a symlinked checkout" -[[ -L $checkout && -f $test_dir/elsewhen-src/manifest.json ]] || - fail "a symlinked checkout stays a symlink with its target intact" "$(ls -la "$checkout" "$test_dir/elsewhen-src")" -pass "a symlinked checkout stays a symlink with its target intact" - -rm -f "$checkout" -reset_layout -run_migration -grep -q 'omacom.elsewhen checkout\|takes precedence' "$output" && fail "no checkout means no checkout report" "$(cat "$output")" -assert_widget_placed "a machine without a checkout" -pass "a machine without a checkout is left alone" From 16cc7d7a9dcf40320016dae37da8b97889d7cb58 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 19 Sep 2026 00:06:29 -0500 Subject: [PATCH 34/51] Leave the shell restart to the update flow Restarting immediately after the plugin rescan races Quickshell IPC handler creation and can crash the exiting shell. The normal update flow already restarts after migrations. Let this migration finish through live enablement and placement without adding timing workarounds. --- docs/omarchy-shell.md | 2 +- migrations/1789581661.sh | 1 - test/shell.d/elsewhen-default-migration-test.sh | 9 +++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 6e999682..d62661a7 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -90,7 +90,7 @@ The lower-level IPC methods remain available through `omarchy-shell shell ...`. ## Elsewhen -Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/plugins/omacom.elsewhen`. A symlink in `~/.config/omarchy/plugins/` makes it available to the shell. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. +Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/plugins/omacom.elsewhen`. A symlink in `~/.config/omarchy/plugins/` makes it available to the shell. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. The normal update flow restarts the shell after migrations; the migration does not interrupt plugin loading with an immediate restart. ## IPC diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index 10f18e66..5c022e1c 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -10,4 +10,3 @@ fi omarchy-shell shell rescanPlugins omarchy-bar put omacom.elsewhen --before omarchy.clock -omarchy-restart-shell diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index bda62fd6..c0a2de8f 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -23,7 +23,8 @@ printf '%s\n' "${TEST_PUT_RESULT:-ok}" SH cat >"$test_dir/bin/omarchy-restart-shell" <<'SH' #!/bin/bash -printf 'restart\n' >>"$CALL_LOG" +echo 'migration must leave the restart to omarchy update' >&2 +exit 1 SH chmod +x "$test_dir/bin/"* @@ -46,9 +47,9 @@ run_migration [[ $(readlink "$ROOT/config/omarchy/plugins/omacom.elsewhen") == "$(readlink "$plugin")" ]] || fail "fresh installs use the same package link" pass "migration and fresh installs link to the package" -expected=$'package elsewhen\nshell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}\nrestart' -[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan, placement and restart run in order" "$(cat "$CALL_LOG")" -pass "real bar helper enables and places before the clock, then restarts" +expected=$'package elsewhen\nshell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}' +[[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan and placement run in order" "$(cat "$CALL_LOG")" +pass "real bar helper enables and places before the clock without restarting during reload" run_migration [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun" From e38c1d1289252d2adb96372eeac48d02e489c5b7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 19 Sep 2026 21:53:09 +0200 Subject: [PATCH 35/51] Ignore claude --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 8de9215b..1de1faa6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ # Python bytecode (orchestrator) __pycache__/ *.pyc + +.claude/ From e265934bb16c76cb47144d4625d19626b1ab36f6 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 31 Aug 2026 15:43:14 -0400 Subject: [PATCH 36/51] Use Omasnap for screenshots --- agents/skills/visual-verification.md | 5 +- bin/omarchy-capture-screenshot | 81 +++--------------- bin/omarchy-clipboard-open | 2 +- config/imv/config | 4 +- default/agents/skills/omarchy/capture.md | 6 +- default/hypr/apps/screenshot-selection.lua | 5 +- default/hypr/apps/system.lua | 2 - default/uwsm/default | 4 +- docs/file-layout.md | 1 - install/omarchy-base.packages | 2 +- manual/12-screenshots-recording.md | 21 +++-- manual/14-omarchy-cli.md | 2 +- manual/46-faq.md | 6 +- migrations/1788129995.sh | 13 +++ shell/Commons/Util.qml | 2 +- test/shell.d/clipboard-test.sh | 12 +-- test/shell.d/omasnap-test.sh | 99 ++++++++++++++++++++++ test/shell.d/screenshot-sanity-test.sh | 20 ++--- 18 files changed, 169 insertions(+), 118 deletions(-) create mode 100644 migrations/1788129995.sh create mode 100644 test/shell.d/omasnap-test.sh diff --git a/agents/skills/visual-verification.md b/agents/skills/visual-verification.md index 56dc8e78..7df6caa1 100644 --- a/agents/skills/visual-verification.md +++ b/agents/skills/visual-verification.md @@ -15,10 +15,7 @@ Take a full-screen screenshot without opening the editor: omarchy capture screenshot fullscreen save ``` -The command prints the saved path and writes to the configured Pictures -directory. Use `omarchy screenshot` for the interactive smart-region flow. -Capture reference and candidate states as separate images when changing a -layer-shell surface or layout, then compare both. +The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive editor flow. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both. Record a short full-screen video for animation, transition, timing, capture, or screen-recording changes: diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 6f7b91bf..ec383fcd 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -2,81 +2,22 @@ # omarchy:summary=Take a screenshot # omarchy:group=capture -# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=] +# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save] # omarchy:examples=omarchy screenshot | omarchy capture screenshot region # omarchy:aliases=omarchy screenshot -[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs -OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}" - -if [[ ! -d $OUTPUT_DIR ]]; then - mkdir -p "$OUTPUT_DIR" - omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000 +if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then + export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR" fi -pkill slurp && exit 0 - -SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}" - -# Parse --editor flag from any position -ARGS=() +omasnap_args=() for arg in "$@"; do - if [[ $arg == --editor=* ]]; then - SCREENSHOT_EDITOR="${arg#--editor=}" - else - ARGS+=("$arg") - fi + case "$arg" in + copy) omasnap_args+=(--copy) ;; + save) omasnap_args+=(--save) ;; + slurp) ;; + *) omasnap_args+=("$arg") ;; + esac done -set -- "${ARGS[@]}" -MODE="${1:-smart}" -PROCESSING="${2:-slurp}" - -# The picker leaves the screen freeze running (PID on its first output line) -# so grim captures the frozen overlay rather than live content shifting -# during teardown. -# -# Software-composited cursors (Hyprland's fallback on GPUs without working -# hardware cursors) are baked into the frames grim captures, so force -# hardware cursors until after grim runs and restore the setting on exit. -NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int') - -set_no_hw_cursors() { - hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null || - hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null -} - -cleanup() { - [[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null - set_no_hw_cursors "$NO_HW_CURSORS" -} -trap cleanup EXIT - -set_no_hw_cursors 0 -{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze) - -[[ -z $SELECTION ]] && exit 0 - -FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png" -FILEPATH="$OUTPUT_DIR/$FILENAME" - -case "$PROCESSING" in - slurp) - grim -g "$SELECTION" "$FILEPATH" || exit 1 - echo "$FILEPATH" - wl-copy --type image/png <"$FILEPATH" - - # Best-effort: the screenshot is already saved and on the clipboard, so a - # notification outage must not report the capture itself as failed. - omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ - --image "$FILEPATH" \ - --exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true - ;; - copy) - grim -g "$SELECTION" - | wl-copy --type image/png - ;; - save) - grim -g "$SELECTION" "$FILEPATH" || exit 1 - echo "$FILEPATH" - ;; -esac +exec omasnap "${omasnap_args[@]}" diff --git a/bin/omarchy-clipboard-open b/bin/omarchy-clipboard-open index 98f26bb2..827fe263 100755 --- a/bin/omarchy-clipboard-open +++ b/bin/omarchy-clipboard-open @@ -30,7 +30,7 @@ open_image() { local path="$1" [[ -r $path ]] || exit 1 - exec tensaku-edit "$path" + exec omasnap "$path" } open_text() { diff --git a/config/imv/config b/config/imv/config index f96aa6a3..5de2530e 100644 --- a/config/imv/config +++ b/config/imv/config @@ -12,5 +12,5 @@ # Rotate the currently open image by 90 degrees = exec mogrify -rotate 90 "$imv_current_file" -# Edit the current image in Tensaku and quit the viewer - = exec tensaku-edit "$imv_current_file" & ; quit +# Edit the current image in Omasnap and quit the viewer + = exec omasnap "$imv_current_file" & ; quit diff --git a/default/agents/skills/omarchy/capture.md b/default/agents/skills/omarchy/capture.md index 621bc298..4390fe75 100644 --- a/default/agents/skills/omarchy/capture.md +++ b/default/agents/skills/omarchy/capture.md @@ -10,12 +10,10 @@ omarchy screenshot # Interactive smart-region flow omarchy capture screenshot region # Select a region omarchy capture screenshot windows # Pick a window omarchy capture screenshot fullscreen save # Full screen, straight to disk (no editor) +omarchy capture screenshot scroll # Capture and stitch a scrolling region ``` -The first argument picks the mode (`smart|region|windows|fullscreen`), the -second what happens with it (`slurp|copy|save`). `save` skips the annotation -editor and prints the saved path. Screenshots land in the configured Pictures -directory (override with `OMARCHY_SCREENSHOT_DIR`). +The first argument picks the Omasnap mode (`smart|region|windows|fullscreen|scroll`). With no second argument, the selection opens in Omasnap's annotation editor; `copy` or `save` skips the editor and sends the screenshot straight to that destination. Screenshots land in `~/Pictures/Screenshots` by default (override with `OMASNAP_SCREENSHOT_DIR`; the legacy `OMARCHY_SCREENSHOT_DIR` is also honored by the Omarchy command). ## Screen Recording diff --git a/default/hypr/apps/screenshot-selection.lua b/default/hypr/apps/screenshot-selection.lua index f4e3b2fd..4c93f703 100644 --- a/default/hypr/apps/screenshot-selection.lua +++ b/default/hypr/apps/screenshot-selection.lua @@ -1,2 +1,5 @@ --- Remove the 1px border around the slurp region selection used by screenshots. +-- Remove the 1px border around the slurp region selection used by recordings, OCR, and QR capture. hl.layer_rule({ match = { namespace = "selection" }, no_anim = true, animation = "none" }) + +-- Keep the Omasnap overlay immediate and out of screen shares. +hl.layer_rule({ match = { namespace = "^omasnap$" }, no_anim = true, animation = "none", no_screen_share = true }) diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index 789e3e03..3235e7b9 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -27,8 +27,6 @@ o.window("org.omarchy.about", { float = true }) o.window("org.omarchy.about", { center = true }) o.window("org.omarchy.about", { size = { 920, 480 } }) -o.window("dev.tensaku.Tensaku", { float = true }) -o.window("dev.tensaku.Tensaku", { center = true }) o.window("omacalc", { float = true }) -- Fullscreen screensaver. diff --git a/default/uwsm/default b/default/uwsm/default index d2ad7cc3..ba98b166 100644 --- a/default/uwsm/default +++ b/default/uwsm/default @@ -10,8 +10,8 @@ export TERMINAL=xdg-terminal-exec # Used by terminal programs to open files with the selected Omarchy default editor export EDITOR="omarchy-launch-editor --inline" -# Use a custom directory for screenshots (remember to make the directory!) -# export OMARCHY_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" +# Use a custom directory for screenshots +# export OMASNAP_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" # Use a custom directory for screenrecordings (remember to make the directory!) # export OMARCHY_SCREENRECORD_DIR="$HOME/Videos/Screencasts" diff --git a/docs/file-layout.md b/docs/file-layout.md index f261085f..73c48c4f 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -116,7 +116,6 @@ default/** ──► omarchy-settings /usr/share/omarchy ├─ hypr/toggles/*.lua (flags, │ single-window-aspect-ratio, window-no-gaps) /etc/skel/.local/state/omarchy/toggles/hypr/ ├─ nautilus-python/extensions/*.py /etc/skel/.local/share/nautilus-python/extensions/ - ├─ tensaku/state.toml /etc/skel/.local/state/tensaku/state.toml ├─ uwsm/env.d/10-omarchy /usr/share/uwsm/env.d/ ├─ environment.d/*.conf /usr/lib/environment.d/ ├─ fontconfig/conf.avail/50-omarchy.conf /usr/share/fontconfig/conf.avail/ diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 0a8e27cc..b3df3e20 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -96,6 +96,7 @@ omacalc omacut omawrite omarchy-nvim +omasnap pacman-contrib pamixer pinta @@ -114,7 +115,6 @@ qt6-multimedia-ffmpeg quickshell ripgrep ruby -tensaku sddm slurp socat diff --git a/manual/12-screenshots-recording.md b/manual/12-screenshots-recording.md index 9c476231..b3447b57 100644 --- a/manual/12-screenshots-recording.md +++ b/manual/12-screenshots-recording.md @@ -13,13 +13,13 @@ Everything you can grab off the screen hangs off the Print Screen key. One key o ## Screenshots -Hit `Print Screen` and the screen freezes so nothing shifts under you while you aim. Drag a box for a freeform region, or just click once and the shot snaps to whatever rectangle you clicked in — a window if you landed on one, the whole monitor if you landed on the bar or in a gap. Changed your mind? Hit `Print Screen` again to dismiss the picker. +Hit `Print Screen` and Omasnap captures the focused monitor before its overlay appears, so nothing shifts under you while you aim. Drag a freeform region, or use the tabs across the top to switch between Region, Scrolling Region, Window, and Fullscreen capture. Changed your mind? Hit `Print Screen` again to dismiss Omasnap. -The result goes two places at once: a PNG in your pictures directory, and the clipboard, so you can paste it straight into a chat window with `Super + V`. A notification pops up with a thumbnail. Click it (or hit `Super + Alt + ,` to invoke the last notification) and the shot opens in Tensaku, the annotation editor, where you can draw arrows and boxes on it before you send it. +After you select an area, Omasnap opens its annotation editor. It can draw arrows, lines, shapes, highlights, numbered markers, text, and secure redactions; crop or cut out part of the image; OCR its text; and add a backdrop. Press `Enter` to copy and save the finished PNG, `Ctrl + C` to copy it without saving, `Ctrl + S` to save it without copying, or `P` to pin it above your windows. -Files land in `~/Pictures` by default, named `screenshot-2026-08-13_14-22-05.png`. If you'd rather keep them in their own folder, set `OMARCHY_SCREENSHOT_DIR` — see [the FAQ](46-faq.md) for where to put session environment variables. Omarchy creates the directory for you if it isn't there. You can swap the editor too with `OMARCHY_SCREENSHOT_EDITOR`. +Files land in `~/Pictures/Screenshots` by default, with a name such as `screenshot-2026-08-13_14-22-05-firefox.png`. Set `OMASNAP_SCREENSHOT_DIR` to use another directory — see [the FAQ](46-faq.md) for where to put session environment variables. Omasnap creates the directory when it saves the first shot. -From the terminal, `omarchy screenshot` takes the same shot, and you can be explicit about it: `omarchy capture screenshot region` for freeform only, `windows` to snap to window and monitor rectangles, or `fullscreen` to skip the picker entirely and grab the focused monitor. A second argument of `copy` puts the shot only on the clipboard, and `save` only on disk. +From the terminal, `omarchy screenshot` opens the same overlay, and you can choose its starting mode: `omarchy capture screenshot region`, `windows`, `fullscreen`, or `scroll`. A second argument of `copy` or `save` bypasses the annotation editor and sends the shot straight to that destination. ### Driving the picker from the keyboard @@ -27,16 +27,19 @@ While the selection is up, you don't have to use the mouse at all: | Key | Function | | --- | -------- | +| `Space` | Step through Region, Scrolling Region, and Window modes | +| `S` | Toggle scrolling-region mode | +| `Super + Arrow keys` | Move among windows in Window mode | | `Return` | Capture the highlighted window | -| `Ctrl + Return` | Capture the whole screen | -| `Tab` / `Ctrl + Tab` | Highlight the next / previous window | -| Arrow keys | Highlight the window in that direction | +| `Ctrl + A` | Capture the full focused monitor | +| `R` | Restore the last region drawn in this session | +| `Esc` | Dismiss Omasnap | -The arrows and Tab move the cursor to the window they pick, so the highlight follows along and you can see what you're about to capture. These bindings only exist while a selection is on screen, so they can't collide with anything in your own config. +These bindings only exist while Omasnap is open, so they can't collide with anything in your own config. ## Screen recording -`Alt + Print Screen` opens _Trigger > Capture > Screenrecord_, which asks what you want on the soundtrack: no audio, desktop audio, desktop plus microphone, or desktop plus microphone plus webcam. That last one only shows up if you actually have a camera plugged in. Pick one and you get the same picker as a screenshot: drag a region, or click a window or monitor. +`Alt + Print Screen` opens _Trigger > Capture > Screenrecord_, which asks what you want on the soundtrack: no audio, desktop audio, desktop plus microphone, or desktop plus microphone plus webcam. That last one only shows up if you actually have a camera plugged in. Pick one and Omarchy's recording picker lets you drag a region or click a window or monitor. Recording runs on gpu-screen-recorder, which encodes on the GPU at 60fps and falls back to the CPU if it has to. The result is an MP4 in `~/Videos`, named `screenrecording-2026-08-13_14-22-05.mp4`. Set `OMARCHY_SCREENRECORD_DIR` to change that — but note that unlike the screenshot directory, this one has to exist already, or the recording refuses to start. diff --git a/manual/14-omarchy-cli.md b/manual/14-omarchy-cli.md index fa5d0a05..76f386e8 100644 --- a/manual/14-omarchy-cli.md +++ b/manual/14-omarchy-cli.md @@ -50,7 +50,7 @@ Capture commands — Screenshots and screen recording: omarchy capture qr Decode a QR code from a screenshot region omarchy capture screenrecording [--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] Start or stop screen recording omarchy capture screenrecording with webcam Pick a webcam and start a screen recording with it - omarchy capture screenshot [smart|region|windows|fullscreen] [slurp|copy|save] [--editor=] Take a screenshot + omarchy capture screenshot [smart|region|windows|fullscreen|scroll] [copy|save] Take a screenshot omarchy capture text Extract text from a screenshot region with OCR omarchy capture webcam resize Resize the active webcam recording overlay ``` diff --git a/manual/46-faq.md b/manual/46-faq.md index 21b11db9..8985439b 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -54,15 +54,15 @@ Automatic discovery, where printers on the network appear without being added, i ### How do I change where screenshots or screenrecordings are saved? -If you want screenshots to be saved to `~/Pictures/Screenshots` instead of just `~/Pictures`, you can add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): +Omasnap saves screenshots to `~/Pictures/Screenshots` by default. To use another directory, add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): ``` -export OMARCHY_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" +export OMASNAP_SCREENSHOT_DIR="$HOME/Pictures/Captures" ``` You can do the same for screenrecordings using `OMARCHY_SCREENRECORD_DIR`. -Just remember to create the directory you want to save to and restart Omarchy for this to take effect. +Omasnap creates its screenshot directory automatically. Create the screenrecording directory yourself, then restart Omarchy for either environment change to take effect. ### How do I get the speakers + webcam working on my Apple Studio Display? diff --git a/migrations/1788129995.sh b/migrations/1788129995.sh new file mode 100644 index 00000000..041a3ef8 --- /dev/null +++ b/migrations/1788129995.sh @@ -0,0 +1,13 @@ +echo "Replace Tensaku with Omasnap" + +omarchy-pkg-add omasnap + +imv_config="$HOME/.config/imv/config" +if [[ -f $imv_config ]]; then + sed -i --follow-symlinks \ + -e 's/^# Edit the current image in Tensaku and quit the viewer$/# Edit the current image in Omasnap and quit the viewer/' \ + -e 's|^ = exec tensaku-edit "$imv_current_file" & ; quit$| = exec omasnap "$imv_current_file" \& ; quit|' \ + "$imv_config" +fi + +omarchy-pkg-drop tensaku diff --git a/shell/Commons/Util.qml b/shell/Commons/Util.qml index 14af44fe..cacc98d2 100644 --- a/shell/Commons/Util.qml +++ b/shell/Commons/Util.qml @@ -61,7 +61,7 @@ QtObject { // Run an argv vector without a shell interpreting it: the constant `exec "$@"` // means the args only ever land in positional parameters, which bash expands // without re-tokenizing — so untrusted data ($(id), a filename) stays literal. - // The login shell (-l) keeps the PATH/session env GUI targets (tensaku, mpv, + // The login shell (-l) keeps the PATH/session env GUI targets (omasnap, mpv, // xdg-open) need. Prefer this over execDetached for anything built from input. function execArgv(argv) { Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv)) diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 962d529e..5580a694 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -277,12 +277,12 @@ printf '%s\n' "$1" >"$EDITOR_PATH_OUT" cat "$1" >"$EDITOR_TEXT_OUT" SH -cat >"$TMPDIR/bin/tensaku-edit" <<'SH' +cat >"$TMPDIR/bin/omasnap" <<'SH' #!/bin/bash -printf '%s\n' "$*" >"$TENSAKU_OUT" +printf '%s\n' "$*" >"$OMASNAP_OUT" SH -chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/tensaku-edit" +chmod +x "$TMPDIR/bin/wl-copy" "$TMPDIR/bin/wl-paste" "$TMPDIR/bin/wtype" "$TMPDIR/bin/omarchy-launch-browser" "$TMPDIR/bin/omarchy-launch-editor" "$TMPDIR/bin/omasnap" capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh") [[ $capture_output == '{"type":"text","text":"terminal copy"}' ]] || fail "clipboard capture records normal text events" @@ -513,8 +513,8 @@ pass "clipboard open helper opens text entries in editor" [[ $(<"$TMPDIR/editor-path") == "$TMPDIR"/state/omarchy/clipboard-open/clipboard.*.txt ]] || fail "clipboard open helper writes text entries to a temporary file" pass "clipboard open helper writes text entries to a temporary file" -TENSAKU_OUT="$TMPDIR/tensaku" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \ +OMASNAP_OUT="$TMPDIR/omasnap" HOME="$TMPDIR/home" PATH="$TMPDIR/bin:$PATH" \ "$ROOT/bin/omarchy-clipboard-open" --history-index 2 -[[ $(<"$TMPDIR/tensaku") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Tensaku" -pass "clipboard open helper opens image entries in Tensaku" +[[ $(<"$TMPDIR/omasnap") == "$TMPDIR/image.png" ]] || fail "clipboard open helper opens image entries in Omasnap" +pass "clipboard open helper opens image entries in Omasnap" diff --git a/test/shell.d/omasnap-test.sh b/test/shell.d/omasnap-test.sh new file mode 100644 index 00000000..342e5f8f --- /dev/null +++ b/test/shell.d/omasnap-test.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +packages="$ROOT/install/omarchy-base.packages" +migration="$ROOT/migrations/1788129995.sh" + +grep -qxF omasnap "$packages" || fail "fresh installs include Omasnap" +! grep -qxF tensaku "$packages" || fail "fresh installs no longer include Tensaku" +pass "fresh installs use Omasnap as the screenshot editor" + +grep -Fq 'namespace = "^omasnap$"' "$ROOT/default/hypr/apps/screenshot-selection.lua" || + fail "Omasnap has a layer rule" +grep -Fq 'no_screen_share = true' "$ROOT/default/hypr/apps/screenshot-selection.lua" || + fail "the Omasnap overlay is excluded from screen sharing" +! grep -Fq 'dev.tensaku.Tensaku' "$ROOT/default/hypr/apps/system.lua" || + fail "the removed Tensaku window rules are gone" +pass "Hyprland applies Omasnap's overlay policy without stale Tensaku rules" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/omasnap" <<'SH' +#!/bin/bash +printf '%s\t%s\n' "${OMASNAP_SCREENSHOT_DIR:-}" "$*" >>"$OMASNAP_TEST_LOG" +SH +chmod +x "$stub_bin/omasnap" + +capture_log="$test_tmp/capture.log" +OMASNAP_TEST_LOG="$capture_log" PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-capture-screenshot" +[[ $(<"$capture_log") == $'\t' ]] || fail "the default screenshot opens Omasnap without extra arguments" + +: >"$capture_log" +OMASNAP_TEST_LOG="$capture_log" OMARCHY_SCREENSHOT_DIR="$test_tmp/legacy-output" PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-capture-screenshot" windows copy +[[ $(<"$capture_log") == "$test_tmp/legacy-output"$'\twindows --copy' ]] || + fail "the screenshot command maps the legacy directory and copy argument to Omasnap" + +: >"$capture_log" +OMASNAP_TEST_LOG="$capture_log" OMARCHY_SCREENSHOT_DIR="$test_tmp/legacy-output" OMASNAP_SCREENSHOT_DIR="$test_tmp/native-output" PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-capture-screenshot" fullscreen save +[[ $(<"$capture_log") == "$test_tmp/native-output"$'\tfullscreen --save' ]] || + fail "the native Omasnap directory wins while legacy save syntax still works" + +: >"$capture_log" +OMASNAP_TEST_LOG="$capture_log" PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-capture-screenshot" region slurp +[[ $(<"$capture_log") == $'\tregion' ]] || fail "the former default slurp argument remains a harmless compatibility no-op" + +: >"$capture_log" +OMASNAP_TEST_LOG="$capture_log" PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-capture-screenshot" scroll --save +[[ $(<"$capture_log") == $'\tscroll --save' ]] || fail "native Omasnap modes and flags pass through unchanged" + +pass "the Omarchy screenshot route delegates compatible arguments to Omasnap" + +cat >"$stub_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf 'add\t%s\n' "$*" >>"$OMASNAP_MIGRATION_LOG" +SH +cat >"$stub_bin/omarchy-pkg-drop" <<'SH' +#!/bin/bash +printf 'drop\t%s\n' "$*" >>"$OMASNAP_MIGRATION_LOG" +SH +chmod +x "$stub_bin/omarchy-pkg-add" "$stub_bin/omarchy-pkg-drop" + +migration_home="$test_tmp/home" +mkdir -p "$migration_home/.config/imv" "$migration_home/dotfiles" +cat >"$migration_home/dotfiles/imv.config" <<'EOF' +[binds] + +# Edit the current image in Tensaku and quit the viewer + = exec tensaku-edit "$imv_current_file" & ; quit + = exec custom-editor "$imv_current_file" & ; quit +EOF +ln -s "$migration_home/dotfiles/imv.config" "$migration_home/.config/imv/config" + +migration_log="$test_tmp/migration.log" +OMASNAP_MIGRATION_LOG="$migration_log" HOME="$migration_home" PATH="$stub_bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null + +[[ $(sed -n '1p' "$migration_log") == $'add\tomasnap' ]] || fail "the migration installs Omasnap first" +[[ $(sed -n '2p' "$migration_log") == $'drop\ttensaku' ]] || fail "the migration removes Tensaku after Omasnap is ready" +grep -Fq '# Edit the current image in Omasnap and quit the viewer' "$migration_home/.config/imv/config" || + fail "the migration updates the stock imv editor comment" +grep -Fq ' = exec omasnap "$imv_current_file" & ; quit' "$migration_home/.config/imv/config" || + fail "the migration sends the stock imv edit binding to Omasnap" +grep -Fq ' = exec custom-editor "$imv_current_file" & ; quit' "$migration_home/.config/imv/config" || + fail "the migration preserves custom imv bindings" +[[ -L $migration_home/.config/imv/config ]] || fail "the migration preserves a dotfile-managed imv symlink" +[[ $(stat -c '%a' "$migration") == 644 ]] || fail "the Omasnap migration has mode 0644" + +pass "the migration swaps packages and safely updates only the stock imv binding" diff --git a/test/shell.d/screenshot-sanity-test.sh b/test/shell.d/screenshot-sanity-test.sh index 636642ed..756ffc65 100755 --- a/test/shell.d/screenshot-sanity-test.sh +++ b/test/shell.d/screenshot-sanity-test.sh @@ -25,13 +25,13 @@ if ! command -v quickshell >/dev/null 2>&1; then exit 0 fi -if pgrep -x slurp >/dev/null 2>&1; then - pass "slurp is already running; skipping screenshot sanity test" +if pgrep -x omasnap >/dev/null 2>&1; then + pass "omasnap is already running; skipping screenshot sanity test" exit 0 fi require_command hyprctl -require_command grim +require_command omasnap require_command jq require_command python3 @@ -118,13 +118,13 @@ jq -e ' fail_with_log "screenshot test shell rendered visible bar widgets" } -screenshot=$( - OMARCHY_PATH="$test_root" \ - OMARCHY_SCREENSHOT_DIR="$screenshot_dir" \ - HOME="$test_home" \ - PATH="$stub_bin:$ROOT/bin:$PATH" \ - "$ROOT/bin/omarchy" capture screenshot fullscreen save 2>"$screenshot_err" | tail -n 1 -) +OMARCHY_PATH="$test_root" \ +OMASNAP_SCREENSHOT_DIR="$screenshot_dir" \ +HOME="$test_home" \ +PATH="$stub_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy" capture screenshot fullscreen save >/dev/null 2>"$screenshot_err" + +screenshot=$(find "$screenshot_dir" -maxdepth 1 -type f -name '*.png' -print -quit) [[ -n $screenshot && -f $screenshot ]] || fail_with_log "fullscreen screenshot was captured" From 45748a2812f42e32f915b053caf4074e150e2048 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 20 Sep 2026 14:25:34 -0400 Subject: [PATCH 37/51] Remove obsolete Elsewhen plugin symlink --- config/omarchy/plugins/omacom.elsewhen | 1 - docs/omarchy-shell.md | 2 +- migrations/1789581661.sh | 6 ------ test/shell.d/elsewhen-default-migration-test.sh | 14 +++++++------- 4 files changed, 8 insertions(+), 15 deletions(-) delete mode 120000 config/omarchy/plugins/omacom.elsewhen diff --git a/config/omarchy/plugins/omacom.elsewhen b/config/omarchy/plugins/omacom.elsewhen deleted file mode 120000 index 19840c51..00000000 --- a/config/omarchy/plugins/omacom.elsewhen +++ /dev/null @@ -1 +0,0 @@ -/usr/share/omarchy/plugins/omacom.elsewhen \ No newline at end of file diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index d62661a7..b0da00e4 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -90,7 +90,7 @@ The lower-level IPC methods remain available through `omarchy-shell shell ...`. ## Elsewhen -Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/plugins/omacom.elsewhen`. A symlink in `~/.config/omarchy/plugins/` makes it available to the shell. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. The normal update flow restarts the shell after migrations; the migration does not interrupt plugin loading with an immediate restart. +Elsewhen (`omacom.elsewhen`) ships in the `elsewhen` package at `/usr/share/omarchy/shell/plugins/omacom.elsewhen`, where the shell discovers it automatically. New installs place it immediately before the clock; the migration uses `omarchy bar put omacom.elsewhen --before omarchy.clock`, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. Existing plugin directories and symlinks are left intact. The normal update flow restarts the shell after migrations; the migration does not interrupt plugin loading with an immediate restart. ## IPC diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index 5c022e1c..7a848550 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -2,11 +2,5 @@ echo "Install Elsewhen, the world clock plugin" omarchy-pkg-add elsewhen -plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen" -mkdir -p "$(dirname "$plugin")" -if [[ ! -e $plugin && ! -L $plugin ]]; then - ln -s /usr/share/omarchy/plugins/omacom.elsewhen "$plugin" -fi - omarchy-shell shell rescanPlugins omarchy-bar put omacom.elsewhen --before omarchy.clock diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index c0a2de8f..585f65ba 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -40,12 +40,12 @@ if PACKAGE_STATUS=1 run_migration; then fi [[ ! -e $plugin && ! -L $plugin && $(cat "$CALL_LOG") == "package elsewhen" ]] || fail "package failure leaves the plugin and shell untouched" -pass "package failure stops before linking or changing the shell" +pass "package failure stops before changing the shell" run_migration -[[ $(readlink "$plugin") == /usr/share/omarchy/plugins/omacom.elsewhen ]] || fail "package link is installed" -[[ $(readlink "$ROOT/config/omarchy/plugins/omacom.elsewhen") == "$(readlink "$plugin")" ]] || fail "fresh installs use the same package link" -pass "migration and fresh installs link to the package" +[[ ! -e $plugin && ! -L $plugin ]] || fail "migration does not create a user plugin link" +[[ ! -e $ROOT/config/omarchy/plugins/omacom.elsewhen && ! -L $ROOT/config/omarchy/plugins/omacom.elsewhen ]] || fail "fresh installs do not ship a user plugin link" +pass "migration and fresh installs rely on the packaged plugin directory" expected=$'package elsewhen\nshell rescanPlugins\nshell putBarWidget omacom.elsewhen {"before":"omarchy.clock"}' [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "install, scan and placement run in order" "$(cat "$CALL_LOG")" @@ -53,10 +53,10 @@ pass "real bar helper enables and places before the clock without restarting dur run_migration [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "migration can be rerun" -pass "migration can be rerun with its package link present" +[[ ! -e $plugin && ! -L $plugin ]] || fail "rerunning the migration does not create a user plugin link" +pass "migration can be rerun without a user plugin link" -rm "$plugin" -mkdir "$plugin" +mkdir -p "$plugin" printf 'local work\n' >"$plugin/notes" run_migration [[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved" From b423f4993d68b0db70ae778f8cd4b3f516e3c500 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 20 Sep 2026 20:36:19 -0500 Subject: [PATCH 38/51] Complete OWE service setup and lock feed fallback --- install/omarchy-base.packages | 6 +- install/user/first-run/enable-user-units.sh | 3 + manual/39-backgrounds.md | 2 + migrations/1786609204.sh | 4 +- migrations/1789764927.sh | 17 ++++ shell/plugins/background/Background.qml | 7 +- shell/plugins/lock/LockFeedSurface.qml | 4 - shell/plugins/lock/LockView.qml | 37 ++++----- shell/plugins/lock/Service.qml | 29 +++++++ shell/plugins/lock/poster.sh | 24 ++++++ test/shell.d/fixtures/owe-lock/shell.qml | 69 ++++++++++++++++ test/shell.d/owe-integration-test.sh | 90 +++++++++++++++++++++ test/shell.d/owe-lock-test.sh | 30 +++++++ test/shell.d/video-background-test.sh | 10 +-- 14 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 migrations/1789764927.sh create mode 100755 shell/plugins/lock/poster.sh create mode 100644 test/shell.d/fixtures/owe-lock/shell.qml create mode 100644 test/shell.d/owe-integration-test.sh create mode 100644 test/shell.d/owe-lock-test.sh diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index c292828f..e4dd69a2 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -95,6 +95,9 @@ omacalc omacut omawrite omarchy-nvim +# OWE owns desktop video backgrounds and feeds the lock screen its frames. +owe +owe-lockfeed pacman-contrib pamixer pinta @@ -107,9 +110,6 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# OWE owns desktop video backgrounds and feeds the lock screen its frames. -owe -owe-lockfeed qt6-imageformats quickshell ripgrep diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 9865fc20..e08885c9 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -14,8 +14,11 @@ set -euo pipefail systemctl --user daemon-reload systemctl --user enable --now \ bt-agent.service \ + owed.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ omarchy-crash-watch.service + +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index f57d23d2..3bc1f77b 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -7,3 +7,5 @@ You can do this most easily by going to _Install > Style > Background_ in the Om 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. Videos are played by the OWE wallpaper engine. It decodes the video once for all monitors and plays its sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen draws the same decode, muted, through OWE. A video wallpaper still costs far more power than a still one. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. + +Video backgrounds keep a cached still on the lock screen while playback is paused or unavailable. Animated GIFs play on the desktop and show a still frame on the lock screen. diff --git a/migrations/1786609204.sh b/migrations/1786609204.sh index b7833092..ce1442ca 100644 --- a/migrations/1786609204.sh +++ b/migrations/1786609204.sh @@ -1,3 +1 @@ -echo "Install native video wallpaper playback dependencies" - -omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg +echo "Native video dependencies are superseded by the OWE migration" diff --git a/migrations/1789764927.sh b/migrations/1789764927.sh new file mode 100644 index 00000000..e2d1541e --- /dev/null +++ b/migrations/1789764927.sh @@ -0,0 +1,17 @@ +echo "Enable OWE desktop video backgrounds and lock feed" + +omarchy-pkg-add owe owe-lockfeed +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync + +systemctl --user daemon-reload >/dev/null 2>&1 || true +if ! systemctl --user enable owed.service; then + wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/owed.service "$wants_dir/owed.service" +fi + +# A TTY update enables the next graphical login without starting a renderer +# against a missing Wayland session. A failed live start leaves this pending. +if [[ ${OMARCHY_UPGRADE_TO_QUATTRO_LIVE:-0} != 1 ]] && systemctl --user is-active --quiet graphical-session.target; then + systemctl --user start owed.service +fi diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 8c113e4b..b3dd7dab 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -182,9 +182,7 @@ Item { } } - Component.onCompleted: { - refreshBackground() - } + Component.onCompleted: refreshBackground() Variants { model: Quickshell.screens @@ -205,8 +203,7 @@ Item { // 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. A still - // wallpaper costs nothing to keep enabled, and a video one is throttled - // by pausing playback rather than by parking the layer. + // wallpaper costs nothing to keep enabled. OWE manages video layers. updatesEnabled: true property bool maskReady: false diff --git a/shell/plugins/lock/LockFeedSurface.qml b/shell/plugins/lock/LockFeedSurface.qml index 38bd4612..ecd1f7d4 100644 --- a/shell/plugins/lock/LockFeedSurface.qml +++ b/shell/plugins/lock/LockFeedSurface.qml @@ -4,8 +4,4 @@ import Owe.LockFeed // Loaded through a Loader from LockView, so a system without the module shows // no lock video instead of losing the whole lock screen. LockFeed { - id: root - - property bool feedEnabled: true - active: root.feedEnabled } diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 9366b289..7084fb0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -7,6 +7,7 @@ Item { id: root property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property bool fingerprintConfigured: false property bool authenticatingPassword: false @@ -91,34 +92,15 @@ Item { BackgroundMedia { id: wallpaper + objectName: "lockWallpaper" anchors.fill: parent - path: root.video ? "" : root.backgroundPath + path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : "" version: root.backgroundVersion - visible: !root.video - } - - // OWE decodes the video once and feeds these frames to every lock surface. - // The feed pauses when this output blanks or on battery power saver. - Loader { - id: feedLoader - anchors.fill: parent - active: root.video - source: "LockFeedSurface.qml" - visible: status === Loader.Ready - - Binding { - target: feedLoader.item - property: "feedEnabled" - value: root.feedActive - when: feedLoader.item !== null - restoreMode: Binding.RestoreNone - } } MultiEffect { anchors.fill: wallpaper - source: root.video ? null : wallpaper - visible: !root.video + source: wallpaper autoPaddingEnabled: false blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 @@ -127,6 +109,17 @@ Item { contrast: -0.08 } + // The cached poster stays behind the feed when policy pauses playback, + // the module is unavailable, or a new connection has not received a frame. + Loader { + id: feedLoader + objectName: "lockFeedLoader" + anchors.fill: parent + active: root.feedActive + source: "LockFeedSurface.qml" + visible: status === Loader.Ready + } + // The feed item cannot be sampled by MultiEffect on every renderer. // Keep video wallpapers visible and darken them slightly for legibility. Rectangle { diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 94d43b68..6e1f703e 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -28,6 +28,7 @@ Item { property string failureMessage: "" property int failedAttempts: 0 property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" @@ -113,6 +114,16 @@ Item { if (!readlinkProc.running) readlinkProc.running = true } + function refreshPoster() { + if (!root.videoBackground) { + root.videoPosterPath = "" + return + } + if (posterProc.running) return + posterProc.sourcePath = root.backgroundPath + posterProc.running = true + } + function refreshFingerprintStatus() { if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true } @@ -307,6 +318,7 @@ Item { id: lockView anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: root.authenticatingPassword @@ -339,6 +351,7 @@ Item { LockView { anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: false @@ -409,9 +422,25 @@ Item { onStreamFinished: { var next = String(text || "").trim() if (next !== root.backgroundPath) { + root.videoPosterPath = "" root.backgroundPath = next root.backgroundVersion += 1 } + root.refreshPoster() + } + } + } + + Process { + id: posterProc + property string sourcePath: "" + command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/poster.sh", sourcePath] + stdout: StdioCollector { id: posterOutput; waitForEnd: true } + onExited: function(exitCode) { + if (sourcePath !== root.backgroundPath) { + root.refreshPoster() + } else { + root.videoPosterPath = exitCode === 0 ? String(posterOutput.text || "").trim() : "" } } } diff --git a/shell/plugins/lock/poster.sh b/shell/plugins/lock/poster.sh new file mode 100755 index 00000000..836d01f0 --- /dev/null +++ b/shell/plugins/lock/poster.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# One cached frame shared by all lock outputs, including when OWE is paused. +set -euo pipefail + +source_path=$1 +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/lock-poster" +signature=$(stat -Lc '%s:%y:%z' "$source_path") +key=$(printf '%s\n%s' "$source_path" "$signature" | sha256sum | cut -d ' ' -f 1) +poster="$cache_dir/poster-$key.jpg" +mkdir -p "$cache_dir" +exec {lock_fd}>"$cache_dir/.lock" +flock -w 10 "$lock_fd" + +if [[ ! -s $poster ]]; then + temporary=$(mktemp "$cache_dir/.poster-XXXXXX.jpg") + trap 'rm -f "$temporary"' EXIT + timeout -k 1 5 ffmpegthumbnailer -i "$source_path" -o "$temporary" -s 1920 -q 8 {lock_fd}>&- + [[ -s $temporary ]] + mv -f "$temporary" "$poster" + find "$cache_dir" -maxdepth 1 -type f -name 'poster-*.jpg' ! -name "poster-$key.jpg" -delete +fi + +printf '%s\n' "$poster" diff --git a/test/shell.d/fixtures/owe-lock/shell.qml b/test/shell.d/fixtures/owe-lock/shell.qml new file mode 100644 index 00000000..dc9f88fa --- /dev/null +++ b/test/shell.d/fixtures/owe-lock/shell.qml @@ -0,0 +1,69 @@ +import QtQuick +import Quickshell + +ShellRoot { + id: root + property var view + property var wallpaper + property var feed + property int step: 0 + + Item { id: host; width: 1000; height: 700 } + + function findItem(item, name) { + if (item.objectName === name) return item + for (var i = 0; i < item.children.length; i++) { + var found = findItem(item.children[i], name) + if (found) return found + } + return null + } + + function check(condition, message) { + if (!condition) throw new Error(message) + } + + Timer { + interval: 150 + repeat: true + running: true + onTriggered: { + try { + if (root.step === 0) { + var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/LockView.qml") + check(component.status === Component.Ready, component.errorString()) + root.view = component.createObject(host, {width: 1000, height: 700, backgroundPath: "/still.png", loadBackground: false}) + check(root.view !== null, component.errorString()) + root.wallpaper = findItem(root.view, "lockWallpaper") + root.feed = findItem(root.view, "lockFeedLoader") + check(root.wallpaper.path === "", "hidden still must not decode") + root.view.backgroundPath = "/video.mp4" + root.view.videoPosterPath = "/poster.jpg" + } else if (root.step === 1) { + check(!root.feed.active && root.feed.item === null, "hidden video must not load a feed client") + root.view.loadBackground = true + root.view.powerSaverActive = true + } else if (root.step === 2) { + check(root.wallpaper.path === "/poster.jpg", "power saver keeps the video poster") + check(!root.feed.active, "power saver must not connect to the feed") + root.view.powerSaverActive = false + } else if (root.step === 3) { + check(root.feed.active, "visible video enables the isolated feed loader") + check(root.wallpaper.path === "/poster.jpg", "poster stays beneath the feed before its first frame") + root.view.displaysBlank = true + } else if (root.step === 4) { + check(!root.feed.active && root.feed.item === null, "blanking releases the feed client") + root.view.loadBackground = false + } else { + check(root.wallpaper.path === "", "unlock releases the poster image") + console.log("OWE_LOCK_TEST_PASS") + Qt.quit() + } + root.step++ + } catch (error) { + console.error("OWE_LOCK_TEST_FAIL: " + error) + Qt.quit() + } + } + } +} diff --git a/test/shell.d/owe-integration-test.sh b/test/shell.d/owe-integration-test.sh new file mode 100644 index 00000000..cde79db2 --- /dev/null +++ b/test/shell.d/owe-integration-test.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/home" +export TEST_CALLS="$work/calls" +for command in omarchy-pkg-add omarchy-hook-install systemctl; do + cat >"$work/bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$TEST_CALLS" +case "$*" in + '--user enable owed.service') [[ ${TEST_TTY:-0} == 0 ]] ;; + '--user is-active --quiet graphical-session.target') [[ ${TEST_TTY:-0} == 0 ]] ;; + '--user start owed.service') [[ ${TEST_START_FAIL:-0} == 0 ]] ;; + *) exit 0 ;; +esac +SH + chmod +x "$work/bin/$command" +done + +migration="$ROOT/migrations/1789764927.sh" +run_migration() { + HOME="$work/home" PATH="$work/bin:$PATH" bash -euo pipefail "$migration" +} +run_migration +run_migration +grep -Fx 'omarchy-pkg-add owe owe-lockfeed' "$TEST_CALLS" >/dev/null +grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null +grep -Fx 'systemctl --user start owed.service' "$TEST_CALLS" >/dev/null +pass "migration installs OWE, its theme refresh hook, and starts the graphical session service" + +: >"$TEST_CALLS" +TEST_TTY=1 run_migration +TEST_TTY=1 run_migration +[[ $(readlink "$work/home/.config/systemd/user/graphical-session.target.wants/owed.service") == /usr/lib/systemd/user/owed.service ]] +if grep -F 'systemctl --user start' "$TEST_CALLS" >/dev/null; then + fail "TTY migration must not start the renderer" +fi +pass "TTY migrations enable the next login without starting a renderer" + +: >"$TEST_CALLS" +OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 run_migration +if grep -F 'systemctl --user start' "$TEST_CALLS" >/dev/null; then + fail "Quattro upgrade must defer OWE until the new graphical session" +fi +pass "Quattro upgrade enables OWE without starting it in the old shell" + +if TEST_START_FAIL=1 run_migration; then + fail "a failed live service start leaves the migration pending" +fi +pass "a failed live service start fails the migration" + +: >"$TEST_CALLS" +HOME="$work/home" PATH="$work/bin:$PATH" bash "$ROOT/install/user/first-run/enable-user-units.sh" +grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null +grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null +pass "fresh installs enable OWE and install the theme refresh hook" + +cat >"$work/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +printf 'thumbnail\n' >>"$TEST_CALLS" +while (( $# )); do + case "$1" in + -o) output=$2; shift 2 ;; + *) shift ;; + esac +done +[[ ${TEST_POSTER_FAIL:-0} == 0 ]] || exit 1 +printf 'poster\n' >"$output" +SH +chmod +x "$work/bin/ffmpegthumbnailer" +source_path="$work/video with 'quotes'.mp4" +printf 'source\n' >"$source_path" +poster() { + PATH="$work/bin:$PATH" XDG_CACHE_HOME="$work/cache" bash "$ROOT/shell/plugins/lock/poster.sh" "$source_path" +} +: >"$TEST_CALLS" +first=$(poster) +[[ -s $first && $(poster) == "$first" ]] +[[ $(wc -l <"$TEST_CALLS") == 1 ]] +printf 'changed source\n' >>"$source_path" +second=$(poster) +[[ -s $second && $second != "$first" && ! -e $first ]] +pass "lock posters are cached, refreshed after source changes, and old frames are evicted" +printf 'broken source\n' >>"$source_path" +if TEST_POSTER_FAIL=1 poster; then fail "failed poster generation must fail"; fi +[[ -z $(find "$work/cache" -name '.poster-*.jpg' -print -quit) ]] +pass "failed poster generation never publishes a partial image" diff --git a/test/shell.d/owe-lock-test.sh b/test/shell.d/owe-lock-test.sh new file mode 100644 index 00000000..4f3831c7 --- /dev/null +++ b/test/shell.d/owe-lock-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell unavailable; skipping lock feed QML lifecycle" + exit 0 +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/config" "$work/runtime" "$work/home" +chmod 700 "$work/runtime" +cp "$SHELL_TEST_DIR/fixtures/owe-lock/shell.qml" "$work/config/shell.qml" +ln -s "$ROOT/shell/Ui" "$work/config/Ui" +ln -s "$ROOT/shell/Commons" "$work/config/Commons" + +# Offscreen rendering exercises the real QML bindings without taking a lock +# or connecting to the user's compositor. +HOME="$work/home" XDG_RUNTIME_DIR="$work/runtime" \ + OMARCHY_PATH="$ROOT" QT_QPA_PLATFORM=offscreen QT_QPA_PLATFORMTHEME= \ + QT_STYLE_OVERRIDE= QT_QUICK_BACKEND=software \ + timeout 15 quickshell -p "$work/config" --no-color >"$work/log" 2>&1 || { + cat "$work/log" >&2 + fail "lock feed QML lifecycle runs" + } +if ! grep -q 'OWE_LOCK_TEST_PASS' "$work/log" || grep -q 'OWE_LOCK_TEST_FAIL' "$work/log"; then + cat "$work/log" >&2 + fail "lock feed respects visibility and keeps its poster fallback" +fi +pass "lock feed respects visibility, power saver and blanking; hidden views release images" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 31ac754d..761c97a9 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -49,11 +49,9 @@ assert( assert( /^import Owe\.LockFeed$/m.test(lockFeedQml) && lockFeedQml.includes('LockFeed {') && - lockFeedQml.includes('active: root.feedEnabled') && lockQml.includes('source: "LockFeedSurface.qml"') && - lockQml.includes('active: root.video') && - /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && - /property: "feedEnabled"[\s\S]*?value: root\.feedActive/.test(lockQml), + lockQml.includes('active: root.feedActive') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml), 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video' ) assert( @@ -61,8 +59,8 @@ assert( 'the lock view itself carries no foreign import, so the lock still loads without the feed module' ) assert( - lockQml.includes('path: root.video ? "" : root.backgroundPath') && - lockQml.includes('visible: !root.video') && + lockQml.includes('path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""') && + lockQml.indexOf('id: feedLoader') > lockQml.indexOf('MultiEffect {') && lockQml.includes('visible: root.video') && !lockQml.includes('wallpaper.video'), 'the lock screen keeps its image effect for stills and shows the feed for videos' From 0d5232ed7bed85b6c099b452602ebb5de192d43a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 21 Sep 2026 03:38:10 +0200 Subject: [PATCH 39/51] Fix Elsewhen migration for dev checkouts --- migrations/1789581661.sh | 8 +++++++ .../elsewhen-default-migration-test.sh | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/migrations/1789581661.sh b/migrations/1789581661.sh index 7a848550..ad0dfd5e 100644 --- a/migrations/1789581661.sh +++ b/migrations/1789581661.sh @@ -2,5 +2,13 @@ echo "Install Elsewhen, the world clock plugin" omarchy-pkg-add elsewhen +# Dev checkouts do not contain plugins installed by system packages. +packaged_plugin="/usr/share/omarchy/shell/plugins/omacom.elsewhen" +user_plugin="$HOME/.config/omarchy/plugins/omacom.elsewhen" +if [[ ! $OMARCHY_PATH -ef /usr/share/omarchy && -d $packaged_plugin && ! -e $user_plugin && ! -L $user_plugin ]]; then + mkdir -p "${user_plugin%/*}" + ln -s "$packaged_plugin" "$user_plugin" +fi + omarchy-shell shell rescanPlugins omarchy-bar put omacom.elsewhen --before omarchy.clock diff --git a/test/shell.d/elsewhen-default-migration-test.sh b/test/shell.d/elsewhen-default-migration-test.sh index 585f65ba..0c2565f4 100644 --- a/test/shell.d/elsewhen-default-migration-test.sh +++ b/test/shell.d/elsewhen-default-migration-test.sh @@ -28,11 +28,14 @@ exit 1 SH chmod +x "$test_dir/bin/"* +mkdir -p "$test_dir/packaged/shell/plugins/omacom.elsewhen" +sed "s|/usr/share/omarchy|$test_dir/packaged|g" "$ROOT/migrations/1789581661.sh" >"$test_dir/migration.sh" + plugin="$test_dir/home/.config/omarchy/plugins/omacom.elsewhen" run_migration() { : >"$CALL_LOG" - HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ - bash -euo pipefail "$ROOT/migrations/1789581661.sh" >"$test_dir/output" 2>&1 + HOME="$test_dir/home" OMARCHY_PATH="${1:-$test_dir/packaged}" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ + bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1 } if PACKAGE_STATUS=1 run_migration; then @@ -56,22 +59,30 @@ run_migration [[ ! -e $plugin && ! -L $plugin ]] || fail "rerunning the migration does not create a user plugin link" pass "migration can be rerun without a user plugin link" +run_migration "$ROOT" +[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev checkout links the packaged plugin" +pass "dev checkout discovers Elsewhen through a user plugin link" +run_migration "$ROOT" +[[ $(readlink "$plugin") == "$test_dir/packaged/shell/plugins/omacom.elsewhen" ]] || fail "dev link survives a rerun" +pass "dev link is idempotent" +rm "$plugin" + mkdir -p "$plugin" printf 'local work\n' >"$plugin/notes" -run_migration +run_migration "$ROOT" [[ ! -L $plugin && $(cat "$plugin/notes") == "local work" ]] || fail "existing checkout is preserved" pass "existing checkout and local files are preserved" rm "$plugin/notes" rmdir "$plugin" ln -s "$test_dir/custom-plugin" "$plugin" -run_migration +run_migration "$ROOT" [[ $(readlink "$plugin") == "$test_dir/custom-plugin" ]] || fail "existing symlink is preserved" pass "existing symlink is preserved, including a missing target" for failure in 'SCAN_STATUS=1' 'TEST_PUT_RESULT=unknown'; do if env "$failure" HOME="$test_dir/home" OMARCHY_PATH="$ROOT" PATH="$test_dir/bin:$ROOT/bin:$PATH" \ - bash -euo pipefail "$ROOT/migrations/1789581661.sh" >"$test_dir/output" 2>&1; then + bash -euo pipefail "$test_dir/migration.sh" >"$test_dir/output" 2>&1; then fail "$failure must leave the migration pending" fi pass "$failure leaves the migration pending" From c0ba99670d5cc3a52832e079053e59182c9b6acd Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 21 Sep 2026 02:14:54 -0400 Subject: [PATCH 40/51] Clean up legacy screenshot tools in Omasnap migration --- migrations/1788129995.sh | 9 ++++-- test/shell.d/omasnap-test.sh | 63 +++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/migrations/1788129995.sh b/migrations/1788129995.sh index 041a3ef8..965e01db 100644 --- a/migrations/1788129995.sh +++ b/migrations/1788129995.sh @@ -1,13 +1,18 @@ -echo "Replace Tensaku with Omasnap" +echo "Replace Satty and Tensaku with Omasnap" omarchy-pkg-add omasnap +# The old source installer left a NoDisplay entry that overrides the packaged launcher. +rm -f "$HOME/.local/share/applications/omasnap.desktop" + imv_config="$HOME/.config/imv/config" if [[ -f $imv_config ]]; then sed -i --follow-symlinks \ -e 's/^# Edit the current image in Tensaku and quit the viewer$/# Edit the current image in Omasnap and quit the viewer/' \ + -e 's/^# Edit the current image in Satty and quit the viewer$/# Edit the current image in Omasnap and quit the viewer/' \ -e 's|^ = exec tensaku-edit "$imv_current_file" & ; quit$| = exec omasnap "$imv_current_file" \& ; quit|' \ + -e 's|^ = exec satty --filename "$imv_current_file" & ; quit$| = exec omasnap "$imv_current_file" \& ; quit|' \ "$imv_config" fi -omarchy-pkg-drop tensaku +omarchy-pkg-drop satty tensaku diff --git a/test/shell.d/omasnap-test.sh b/test/shell.d/omasnap-test.sh index 342e5f8f..abf3293c 100644 --- a/test/shell.d/omasnap-test.sh +++ b/test/shell.d/omasnap-test.sh @@ -9,7 +9,8 @@ migration="$ROOT/migrations/1788129995.sh" grep -qxF omasnap "$packages" || fail "fresh installs include Omasnap" ! grep -qxF tensaku "$packages" || fail "fresh installs no longer include Tensaku" -pass "fresh installs use Omasnap as the screenshot editor" +! grep -qxF satty "$packages" || fail "fresh installs no longer include Satty" +pass "fresh installs use Omasnap as the screenshot tool" grep -Fq 'namespace = "^omasnap$"' "$ROOT/default/hypr/apps/screenshot-selection.lua" || fail "Omasnap has a layer rule" @@ -63,6 +64,7 @@ pass "the Omarchy screenshot route delegates compatible arguments to Omasnap" cat >"$stub_bin/omarchy-pkg-add" <<'SH' #!/bin/bash printf 'add\t%s\n' "$*" >>"$OMASNAP_MIGRATION_LOG" +exit "${OMASNAP_PACKAGE_STATUS:-0}" SH cat >"$stub_bin/omarchy-pkg-drop" <<'SH' #!/bin/bash @@ -82,11 +84,16 @@ EOF ln -s "$migration_home/dotfiles/imv.config" "$migration_home/.config/imv/config" migration_log="$test_tmp/migration.log" -OMASNAP_MIGRATION_LOG="$migration_log" HOME="$migration_home" PATH="$stub_bin:$PATH" \ - bash -euo pipefail "$migration" >/dev/null +run_migration() { + : >"$migration_log" + OMASNAP_MIGRATION_LOG="$migration_log" HOME="$migration_home" PATH="$stub_bin:$PATH" \ + bash -euo pipefail "$migration" >/dev/null +} + +run_migration [[ $(sed -n '1p' "$migration_log") == $'add\tomasnap' ]] || fail "the migration installs Omasnap first" -[[ $(sed -n '2p' "$migration_log") == $'drop\ttensaku' ]] || fail "the migration removes Tensaku after Omasnap is ready" +[[ $(sed -n '2p' "$migration_log") == $'drop\tsatty tensaku' ]] || fail "the migration removes Satty and Tensaku after Omasnap is ready" grep -Fq '# Edit the current image in Omasnap and quit the viewer' "$migration_home/.config/imv/config" || fail "the migration updates the stock imv editor comment" grep -Fq ' = exec omasnap "$imv_current_file" & ; quit' "$migration_home/.config/imv/config" || @@ -97,3 +104,51 @@ grep -Fq ' = exec custom-editor "$imv_current_file" & ; quit' "$migratio [[ $(stat -c '%a' "$migration") == 644 ]] || fail "the Omasnap migration has mode 0644" pass "the migration swaps packages and safely updates only the stock imv binding" + +cat >"$migration_home/dotfiles/imv.config" <<'EOF' +[binds] + +# Edit the current image in Satty and quit the viewer + = exec satty --filename "$imv_current_file" & ; quit + = exec custom-editor "$imv_current_file" & ; quit +EOF +cp "$migration_home/dotfiles/imv.config" "$test_tmp/imv-before" +legacy_desktop="$migration_home/.local/share/applications/omasnap.desktop" +mkdir -p "$(dirname "$legacy_desktop")" +cat >"$legacy_desktop" <<'EOF' +[Desktop Entry] +Type=Application +Name=Omasnap +Exec=omasnap +NoDisplay=true +EOF +if OMASNAP_PACKAGE_STATUS=1 run_migration; then + fail "Omasnap install failure stops the migration" +fi +[[ $(<"$migration_log") == $'add\tomasnap' ]] || fail "install failure leaves old screenshot packages installed" +cmp -s "$test_tmp/imv-before" "$migration_home/dotfiles/imv.config" || fail "install failure leaves the imv binding untouched" +[[ -f $legacy_desktop ]] || fail "install failure preserves the existing launcher entry" +pass "Omasnap install failure preserves the previous screenshot setup" + +run_migration +[[ ! -e $legacy_desktop && ! -L $legacy_desktop ]] || fail "the migration removes the old user-local Omasnap desktop entry" +pass "the migration removes the desktop entry that hides the packaged launcher" +grep -Fq '# Edit the current image in Omasnap and quit the viewer' "$migration_home/.config/imv/config" || + fail "the migration updates the stock Satty comment" +grep -Fq ' = exec omasnap "$imv_current_file" & ; quit' "$migration_home/.config/imv/config" || + fail "the migration replaces the stock Satty binding before removing Satty" +grep -Fq ' = exec custom-editor "$imv_current_file" & ; quit' "$migration_home/.config/imv/config" || + fail "the Satty migration preserves custom bindings" +[[ -L $migration_home/.config/imv/config ]] || fail "the Satty migration preserves the imv symlink" +pass "the migration upgrades the stock Satty binding to Omasnap" + +cp "$migration_home/dotfiles/imv.config" "$test_tmp/imv-migrated" +run_migration +cmp -s "$test_tmp/imv-migrated" "$migration_home/dotfiles/imv.config" || fail "rerunning the migration preserves the updated imv config" +pass "the migration can be rerun" + +printf ' = exec custom-editor "$imv_current_file" & ; quit\n' >"$migration_home/dotfiles/imv.config" +cp "$migration_home/dotfiles/imv.config" "$test_tmp/imv-custom" +run_migration +cmp -s "$test_tmp/imv-custom" "$migration_home/dotfiles/imv.config" || fail "the migration preserves a custom edit shortcut" +pass "custom imv edit shortcuts remain unchanged" From 95120838d3c75f152879ee9d8eaa024bf3cf5404 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 21 Sep 2026 02:14:55 -0400 Subject: [PATCH 41/51] Document Omasnap's default capture flow --- agents/skills/visual-verification.md | 2 +- default/agents/skills/omarchy/capture.md | 5 +++-- manual/12-screenshots-recording.md | 11 ++++++----- manual/46-faq.md | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/agents/skills/visual-verification.md b/agents/skills/visual-verification.md index 7df6caa1..a8ed63d7 100644 --- a/agents/skills/visual-verification.md +++ b/agents/skills/visual-verification.md @@ -15,7 +15,7 @@ Take a full-screen screenshot without opening the editor: omarchy capture screenshot fullscreen save ``` -The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive editor flow. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both. +The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both. Record a short full-screen video for animation, transition, timing, capture, or screen-recording changes: diff --git a/default/agents/skills/omarchy/capture.md b/default/agents/skills/omarchy/capture.md index 4390fe75..a898db86 100644 --- a/default/agents/skills/omarchy/capture.md +++ b/default/agents/skills/omarchy/capture.md @@ -10,10 +10,11 @@ omarchy screenshot # Interactive smart-region flow omarchy capture screenshot region # Select a region omarchy capture screenshot windows # Pick a window omarchy capture screenshot fullscreen save # Full screen, straight to disk (no editor) -omarchy capture screenshot scroll # Capture and stitch a scrolling region +omarchy capture screenshot scroll # Capture and stitch a scrolling region +omarchy screenshot --editor=overlay # Opt into annotation before output ``` -The first argument picks the Omasnap mode (`smart|region|windows|fullscreen|scroll`). With no second argument, the selection opens in Omasnap's annotation editor; `copy` or `save` skips the editor and sends the screenshot straight to that destination. Screenshots land in `~/Pictures/Screenshots` by default (override with `OMASNAP_SCREENSHOT_DIR`; the legacy `OMARCHY_SCREENSHOT_DIR` is also honored by the Omarchy command). +The first argument picks the Omasnap mode (`smart|region|windows|fullscreen|scroll`). By default, a capture copies to the clipboard and shows a preview for 10 seconds. Use the preview's Edit action to annotate, or pass `--editor=overlay` or `--editor=window` to edit before output. A second argument of `copy` or `save` skips the preview and sends the screenshot straight to that destination. Saved screenshots land in `~/Pictures/Screenshots` by default (override with `OMASNAP_SCREENSHOT_DIR`; the legacy `OMARCHY_SCREENSHOT_DIR` is also honored by the Omarchy command). ## Screen Recording diff --git a/manual/12-screenshots-recording.md b/manual/12-screenshots-recording.md index b3447b57..41d6052d 100644 --- a/manual/12-screenshots-recording.md +++ b/manual/12-screenshots-recording.md @@ -13,13 +13,15 @@ Everything you can grab off the screen hangs off the Print Screen key. One key o ## Screenshots -Hit `Print Screen` and Omasnap captures the focused monitor before its overlay appears, so nothing shifts under you while you aim. Drag a freeform region, or use the tabs across the top to switch between Region, Scrolling Region, Window, and Fullscreen capture. Changed your mind? Hit `Print Screen` again to dismiss Omasnap. +Hit `Print Screen` and Omasnap captures the focused monitor before its overlay appears, so nothing shifts under you while you aim. Drag a freeform region, click a window to capture it, or click open space to capture the whole monitor. Press `S` before drawing to capture a scrolling region. Changed your mind? Hit `Print Screen` again to dismiss Omasnap. -After you select an area, Omasnap opens its annotation editor. It can draw arrows, lines, shapes, highlights, numbered markers, text, and secure redactions; crop or cut out part of the image; OCR its text; and add a backdrop. Press `Enter` to copy and save the finished PNG, `Ctrl + C` to copy it without saving, `Ctrl + S` to save it without copying, or `P` to pin it above your windows. +After you select an area, Omasnap copies the capture to the clipboard and shows a preview for 10 seconds. Use the preview's pin button or `Ctrl + P` to keep it on screen, or choose Edit to annotate it. -Files land in `~/Pictures/Screenshots` by default, with a name such as `screenshot-2026-08-13_14-22-05-firefox.png`. Set `OMASNAP_SCREENSHOT_DIR` to use another directory — see [the FAQ](46-faq.md) for where to put session environment variables. Omasnap creates the directory when it saves the first shot. +The annotation editor can draw arrows, lines, shapes, highlights, numbered markers, text, and secure redactions; crop or cut out part of the image; OCR its text; and add a backdrop. In the editor, press `Enter` to copy and save the finished PNG, `Ctrl + C` to copy it without saving, or `Ctrl + S` to save it without copying. -From the terminal, `omarchy screenshot` opens the same overlay, and you can choose its starting mode: `omarchy capture screenshot region`, `windows`, `fullscreen`, or `scroll`. A second argument of `copy` or `save` bypasses the annotation editor and sends the shot straight to that destination. +Saved screenshots land in `~/Pictures/Screenshots` by default, with a name such as `screenshot-2026-08-13_14-22-05-firefox.png`. Set `OMASNAP_SCREENSHOT_DIR` to use another directory — see [the FAQ](46-faq.md) for where to put session environment variables. Omasnap creates the directory when it saves the first shot. + +From the terminal, `omarchy screenshot` opens the same overlay, and you can choose its starting mode: `omarchy capture screenshot region`, `windows`, `fullscreen`, or `scroll`. A second argument of `copy` or `save` skips the preview and sends the shot straight to that destination. To edit before output, use `omarchy screenshot --editor=overlay` for a fullscreen editor or `omarchy screenshot --editor=window` for a separate window. ### Driving the picker from the keyboard @@ -27,7 +29,6 @@ While the selection is up, you don't have to use the mouse at all: | Key | Function | | --- | -------- | -| `Space` | Step through Region, Scrolling Region, and Window modes | | `S` | Toggle scrolling-region mode | | `Super + Arrow keys` | Move among windows in Window mode | | `Return` | Capture the highlighted window | diff --git a/manual/46-faq.md b/manual/46-faq.md index 8985439b..5def15d6 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -54,7 +54,7 @@ Automatic discovery, where printers on the network appear without being added, i ### How do I change where screenshots or screenrecordings are saved? -Omasnap saves screenshots to `~/Pictures/Screenshots` by default. To use another directory, add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): +Saved screenshots go to `~/Pictures/Screenshots` by default. To use another directory, add this to a file under `~/.config/uwsm/env.d/` (like `~/.config/uwsm/env.d/capture`): ``` export OMASNAP_SCREENSHOT_DIR="$HOME/Pictures/Captures" From b094787f1ad258bce3e4848109023a2a321bcb1f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 21 Sep 2026 10:09:24 +0200 Subject: [PATCH 42/51] Point shell documentation to the canonical reference --- manual/32-shell-plugins.md | 2 +- shell/README.md | 305 +------------------------------------ 2 files changed, 7 insertions(+), 300 deletions(-) diff --git a/manual/32-shell-plugins.md b/manual/32-shell-plugins.md index 38ae4001..fc83a16f 100644 --- a/manual/32-shell-plugins.md +++ b/manual/32-shell-plugins.md @@ -95,7 +95,7 @@ omarchy plugin validate ./my-plugin That runs the same checks the shell does at load time: the schema version, the required fields, an id that isn't reserved, entry points that are safe relative paths and actually exist, an entry point for every kind you claimed, and no symlinks anywhere inside the folder. -For the full picture, the source is the documentation: `shell/README.md` in the Omarchy repo covers the manifest schema, the shell's IPC contract, and the exact shape of `shell.json`, and `shell/plugins/README.md` lists every first-party plugin with its id, kinds, and entry points. +For plugin development, see the [shell reference](https://github.com/omacom/omarchy/blob/quattro/docs/omarchy-shell.md) and [first-party plugin catalog](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/README.md). ## Sharing yours with the world diff --git a/shell/README.md b/shell/README.md index ca07b520..5d464a0d 100644 --- a/shell/README.md +++ b/shell/README.md @@ -1,302 +1,9 @@ # Omarchy shell -`omarchy-shell` is a single long-running [Quickshell](https://quickshell.org/) -instance that hosts the Omarchy desktop. Hyprland autostart launches one shell -per graphical session; everything else — the bar, background switcher, panels, -and overlays — runs **inside** the shell as a plugin. +The Omarchy desktop runs in one long-lived Quickshell process. Its bar, panels, overlays, menus, and services are plugins hosted by `shell.qml`. -Hosting everything inside one shell means: - -- shared services and singletons live once, not once per process -- summoning a panel is an IPC call into a process that is already running, - not a fresh `quickshell -p ...` cold start -- third-party plugins can be loaded from disk without changing any source - code in Omarchy itself - -The runtime layout: - -``` -shell/ - shell.qml entry point (ShellRoot) - services/ - PluginRegistry.qml discovers, validates plugins, looks up enabled state in shell.json - BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p) - plugins/ - bar/ first-party plugins (see plugins/README.md) - image-picker/ - menu/ - notifications/ - panels/ - audio/ - bluetooth/ - monitor/ - network/ - power/ - weather/ - agents/ - services/ - battery/ - idle/ - osd/ - polkit/ -``` - -The plugin discovery path is documented in [plugins/README.md](plugins/README.md). - -## Plugin manifest - -Every plugin ships a `manifest.json` describing what it is and how the -shell should load it. Minimal example: - -```json -{ - "schemaVersion": 1, - "id": "my.org.cool-clock", - "name": "Cool clock", - "version": "1.0.0", - "author": "You", - "description": "A clock that does cool things", - "kinds": ["bar-widget"], - "entryPoints": { "barWidget": "Widget.qml" }, - "barWidget": { - "displayName": "Cool clock", - "category": "Time", - "allowMultiple": false, - "defaultSection": "left", - "defaults": { "format": "HH:mm" }, - "schema": [ - { "key": "format", "type": "string", "label": "Format" } - ] - } -} -``` - -Supported `kinds`: - -| Kind | What it is | -|--------------|--------------------------------------------------------------| -| `bar-widget` | A component that the active bar can drop into a section | -| `panel` | A persistent or summoned floating window (e.g. OSD) | -| `overlay` | A fullscreen overlay (e.g. background switcher) | -| `menu` | A summoned menu surface | -| `service` | A headless singleton, no UI | -| `bar` | A full bar option that can replace the built-in `omarchy.bar` | - -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). 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`. - -## Installing a third-party plugin - -A plugin is a **git repo** with a `manifest.json` at its root. Adding one -clones it straight into `~/.config/omarchy/plugins//` (named by the -manifest id); updating is a fast-forward pull of that checkout. - -```bash -omarchy plugin add https://github.com/acme/omarchy-weather.git -omarchy plugin update acme.weather # fetches, shows a diff, fast-forwards -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. 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 -arguments. Pass `--yes` to skip every prompt — this is the path for scripts and -AI agents: - -```bash -omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes -omarchy plugin update --yes -``` - -The installer never runs plugin code, install hooks, or sudo — it only clones -files, validates the manifest, and toggles enabled state over shell IPC. Since -an installed plugin is a plain git checkout, anything beyond add/update -(pinning a ref, switching branches) is ordinary git in the plugin directory. - -### Installing by hand - -You can still drop a plugin in without git: - -1. Put it in `~/.config/omarchy/plugins//` with a `manifest.json` - plus the QML referenced from its `entryPoints`. -2. `omarchy-shell shell rescanPlugins`. -3. `omarchy plugin enable `. Bar widgets start in - `barWidget.defaultSection`, or in the center when it is omitted, and can be - moved with `omarchy bar move`; a full bar replaces the one in use. - -The lower-level IPC equivalents remain available via `omarchy-shell shell rescanPlugins`, -`omarchy-shell shell enablePlugin '{}'`, and `omarchy-shell shell listPlugins`. -The `omarchy plugin` commands wrap those calls. `omarchy bar move` and -`omarchy bar set` edit the persisted widget layout in `shell.json`. - -To hack on a built-in plugin safely, clone it into user config instead of -editing the built-in source. The complete plugin directory is copied, including -every declared kind and local dependency. A built-in id such as -`omarchy.clock` becomes `.clock` (e.g. `dhh.clock`), with `My Clock` -as its display name. The username prefix keeps shared clones from colliding -with each other or with other plugin authors. - -```bash -omarchy plugin clone omarchy.clock -``` - -Cloning switches from the built-in to the new personal plugin, preserving an -existing bar widget's position and settings. Setup > Plugins > Clone provides -the interactive picker, then opens the new `.*` directory in `$EDITOR`. -Existing shortcuts and shell IPC calls made to the built-in id are routed to -the enabled clone, so cloning does not require changing its callers. Removing -an active clone switches back to its built-in source. -Saving a file anywhere under `~/.config/omarchy/plugins/` reloads plugin code -automatically; `omarchy-shell shell rescanPlugins` remains available to force a reload. - -First-party plugins under `shell/plugins/` are discovered the same way and load -by default. Disabling a non-widget records it in `disabledPlugins[]`; disabling -a widget removes it from the bar layout while leaving its component available -to add again. A full bar has no off state and is replaced by enabling another. - -## IPC contract - -The shell exposes a single `shell` IPC target plus whatever extra targets -individual plugins register (e.g. the bar's `bar` target for refresh -hooks, the image picker's `image-selector` target). `omarchy-menu` uses the -shell target to summon the first-party `omarchy.menu` plugin instead of -running a separate Quickshell instance. - -| Method | Returns | Effect | -|------------------------------------------|---------|-------------------------------------------------------| -| `ping` | `ok` | health check | -| `summon ` | `ok` / `unknown` | load + open a panel/overlay plugin | -| `hide ` | — | close a previously-summoned plugin | -| `toggle ` | — | summon if closed, hide if open | -| `call ` | string | call a method on an already-loaded plugin | -| `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code | -| `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` | -| `setPluginEnabled ` | `ok` / `unknown` | flip the persisted enabled bit (see note) | -| `listPlugins` | JSON | every discovered plugin, sorted by name | - -Direct invocation: - -``` -quickshell ipc -p $OMARCHY_PATH/shell call shell ping -``` - -Hyprland autostart launches the shell directly with `quickshell -p -$OMARCHY_PATH/shell`. Use `omarchy-restart-shell` to stop every running -instance of that config and launch one fresh shell process. - -A convenience wrapper, [`omarchy-shell`](../bin/omarchy-shell), forwards IPC -calls to the running shell. It does not start the shell. - -``` -omarchy-shell shell ping -omarchy-shell shell toggle omarchy.menu '{"menu":"root"}' -omarchy-shell shell listPlugins -omarchy-shell shell rescanPlugins -``` - -**Note on `setPluginEnabled`:** the `enabled` argument is a string. Only the -literal `"true"` enables the plugin; every other value (including `"True"`, -`"1"`, `"yes"`, or omitted) disables it. This keeps the IPC surface -type-stable across QML's `string`-only IPC arguments. - -## Persisted state - -There is one user config file. Everything that distinguishes your -customization from the shipped defaults lives in it. - -| Path | Owner | Purpose | -|-----------------------------------|----------------|--------------------------------------------------------| -| `~/.config/omarchy/shell.json` | the shell | full layout + per-entry settings + enabled plugin list | -| `~/.config/omarchy/plugins//` | user | drop-in third-party plugin source files | - -The `config/omarchy/shell.json` default config describes the -fresh-install state. When the user has no `shell.json`, the shell uses -the defaults verbatim. Once the user customizes anything, `shell.json` -becomes the authoritative file — we do **not** deep-merge defaults back in. - -### shell.json shape - -```json -{ - "version": 1, - "idle": { - "screensaver": 150, - "lock": 300 - }, - "bar": { - "id": "omarchy.bar", - "position": "top", - "transparent": false, - "centerAnchor": "omarchy.clock", - "layout": { - "left": [ { "id": "omarchy.menu" }, { "id": "omarchy.workspaces" } ], - "center": [ { "id": "omarchy.clock", "format": "HH:mm" } ], - "right": [ - { "id": "omarchy.audio" } - ] - } - }, - "plugins": [] -} -``` - -### Storage rules - -1. **The active bar option is `bar.id`.** Omit it or set it to `omarchy.bar` - to use the built-in bar. Set it to another plugin id whose manifest declares - `kind: "bar"` to replace the full bar. -2. **Every plugin instance is one entry.** Either in `bar.layout.
` - for bar widgets, or in `plugins[]` for panels, overlays, services, - menus, and anything else non-bar. -3. **Settings are inline on the entry.** No `config:` sub-object, no - separate per-plugin settings file, no merge layers. The fields on each - entry are the values the plugin sees. -4. **Built-in widget ids are namespaced.** Use ids such as `omarchy.clock`, - `omarchy.audio`, and `omarchy.network`. The migration rewrites older ids - like `Clock` and `AudioPanel` forward. -5. **Third-party enabled ⇔ present.** A third-party plugin is enabled iff - its id appears somewhere in shell.json. For full bar options, that means - `bar.id`; for bar widgets, plugin enable/disable adds/removes layout entries; - other plugin kinds are enabled the same way. First-party non-bar plugins - are enabled unless listed in `disabledPlugins[]`. -6. **Multiple instances** are allowed when a manifest sets - `allowMultiple: true`. Each instance is independent — e.g. two clock - widgets in different timezones are just two `{"id":"omarchy.clock", "timezone": ...}` - entries with their own values. -7. **Idle timings are top-level.** `idle.screensaver` and `idle.lock` - are seconds since user idle began, so the default lock fires at 300s - even if the 150s screensaver starts first. -8. **`version: 1` is required** at the top level. The shell will fall back - to defaults rather than load an unknown version. - -## Implementation history - -Built up in phases on this branch: - -- Phase 1 — `omarchy-shell phase 1: host the existing bar in a single shell` -- Phase 2 — `omarchy-shell phase 2: plugin registry and bar widget registry` -- Phase 3 — `omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin` -- Phase 4 — `omarchy-shell phase 4: absorb background-switcher as a plugin` -- Phase 5 — `omarchy-shell phase 5: docs, cleanup, and migration crumbs` -- Phase 6 — `omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races)` -- Phase 7 — `omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker` -- Phase 8a — `omarchy-shell phase 8a: unified shell.json with inline plugin settings` - -Shared services and Pipewire/UPower/Hyprland consolidation are explicitly -out of scope here and deferred to a follow-up after a review pass. +- [Shell reference](../docs/omarchy-shell.md) — plugin manifests, IPC, configuration, and shared UI contracts. +- [Plugin user guide](../manual/32-shell-plugins.md) — install, configure, and clone plugins. +- [First-party plugins](plugins/README.md) — bundled plugin IDs, kinds, and entry points. +- [Shell development guide](../agents/skills/shell-dev.md) — conventions for changing the desktop. +- [Theming reference](../docs/theming.md) — theme tokens, templates, and generated configuration. From 1aa1423eac60471b5d385b4c17dcb595add27572 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 21 Sep 2026 10:10:02 +0200 Subject: [PATCH 43/51] Retire the shipped Copy URL migration test --- .../copy-url-shortcut-migration-test.sh | 238 ------------------ 1 file changed, 238 deletions(-) delete mode 100644 test/shell.d/copy-url-shortcut-migration-test.sh diff --git a/test/shell.d/copy-url-shortcut-migration-test.sh b/test/shell.d/copy-url-shortcut-migration-test.sh deleted file mode 100644 index 934a57ee..00000000 --- a/test/shell.d/copy-url-shortcut-migration-test.sh +++ /dev/null @@ -1,238 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" - -require_command jq -require_command python3 - -migration="$ROOT/migrations/1786643346.sh" -test_dir=$(mktemp -d) -trap 'rm -rf "$test_dir"' EXIT - -home="$test_dir/home" -profile_root="$home/.config/chromium" -preferences="$profile_root/Default/Preferences" -mkdir -p "$(dirname "$preferences")" - -# Any id Chromium once derived from the extension's keyless load path; the -# repair keys off the registered command name, not the id. -ghost_id="ikkebdkaanlebnifjnbeiaklodhbjcci" -pinned_id="bgpiichlckmfanooecilcjemknkcpngb" - -write_stale_preferences() { - jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {($ghost): {commands: {"copy-url": {suggested_key: "Alt+Shift+L", was_assigned: true}}}, ($pinned): {commands: {"copy-url": {suggested_key: "Alt+Shift+L"}}}}}}' >"$preferences" -} - -stub_bin="$test_dir/bin" -mkdir -p "$stub_bin" - -cat >"$stub_bin/python3" <<'STUB' -#!/bin/bash -exit 127 -STUB -chmod +x "$stub_bin/python3" - -# Test stubs must delegate to the system interpreter, not a user shim that can -# route python3 back through the stubs and recurse. -REAL_PYTHON=$(PATH="$stub_bin:$PATH" command -p -v python3) -[[ $REAL_PYTHON != "$stub_bin/python3" ]] || fail "real Python resolution bypasses user shims" -export REAL_PYTHON -rm -f "$stub_bin/python3" - -run_migration() { - HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 -} - -# A running Chromium-family browser marks its profile root with a SingletonLock -# symlink to -, a target that never exists on disk. That lock — -# not the mere presence of a browser process — is what the migration waits on. -open_browser() { - mkdir -p "$profile_root" - ln -sfn "test-host-1234" "$profile_root/SingletonLock" -} -close_browser() { - rm -f "$profile_root/SingletonLock" -} - -# The affected profile being open prompts for the windows to be closed; -# declining (or having no terminal to ask in) defers the repair so a -# rewrite-on-exit cannot revert it. -printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum" -chmod +x "$stub_bin/gum" -write_stale_preferences -open_browser - -before_hash=$(sha256sum "$preferences" | cut -d' ' -f1) - -run_migration && fail "migration defers while the affected profile is open" -[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$before_hash" ]] || - fail "migration leaves preferences alone while the affected profile is open" -pass "migration defers the repair while the affected profile is open" - -# gum paints its prompt on stderr, so that stream has to stay attached: -# suppressing it leaves gum reading keys behind an unpainted screen, which -# reads as a hung update. -cat >"$stub_bin/gum" <<'STUB' -#!/bin/bash -echo "gum-prompt-painted" >&2 -exit 1 -STUB -prompt_stderr="$test_dir/prompt-stderr" -HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>"$prompt_stderr" && - fail "migration defers when the browser prompt is declined" -grep -q "gum-prompt-painted" "$prompt_stderr" || fail "migration keeps the browser prompt visible" -pass "migration keeps the browser prompt visible" - -# A browser holding a different profile root cannot revert this repair, so it -# must not hold the update: the repair goes through without ever reaching the -# prompt, which the still-declining gum stub would otherwise fail. -close_browser -mkdir -p "$home/.config/google-chrome" -ln -sfn "test-host-1234" "$home/.config/google-chrome/SingletonLock" -write_stale_preferences -run_migration || fail "migration repairs while a different profile root is open" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || - fail "migration repairs the shortcut while a different profile root is open" -pass "migration ignores a browser on a different profile root" -rm -f "$home/.config/google-chrome/SingletonLock" "$preferences.omarchy-copy-url-repair.bak" - -# Closing the affected profile and confirming the prompt lets the repair -# proceed. -write_stale_preferences -open_browser -cat >"$stub_bin/gum" <<'STUB' -#!/bin/bash -"$CLOSE_BROWSER" -touch "${GUM_CALLED:?}" -exit 0 -STUB -cat >"$stub_bin/close-browser" <<'STUB' -#!/bin/bash -rm -f "$HOME/.config/chromium/SingletonLock" -STUB -chmod +x "$stub_bin/gum" "$stub_bin/close-browser" -GUM_CALLED="$test_dir/gum-called" CLOSE_BROWSER="$stub_bin/close-browser" \ - HOME="$home" PATH="$stub_bin:$PATH" bash -euo pipefail "$migration" >/dev/null 2>&1 || - fail "migration proceeds once the profile is closed and the prompt confirmed" -[[ -e $test_dir/gum-called ]] || fail "migration asks before repairing under a running browser" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Alt+Shift+L"].extension == $pinned' "$preferences" >/dev/null || - fail "migration repairs after the browser prompt is confirmed" -pass "migration asks to close the browser and repairs on confirmation" -rm -f "$preferences.omarchy-copy-url-repair.bak" - -# With the affected profile closed the ghost registration moves to the pinned id. -printf '#!/bin/bash\nexit 1\n' >"$stub_bin/gum" -close_browser -write_stale_preferences -run_migration || fail "migration repairs the shortcut when no browser is running" - -jq -e --arg ghost "$ghost_id" --arg pinned "$pinned_id" ' - .extensions.commands["linux:Alt+Shift+L"].extension == $pinned and - (.extensions.settings | has($ghost) | not) and - .extensions.settings[$pinned].commands["copy-url"].was_assigned == true -' "$preferences" >/dev/null || fail "migration rebinds the Copy URL shortcut to the pinned extension id" -[[ -f $preferences.omarchy-copy-url-repair.bak ]] || - fail "migration backs up preferences before the repair" -pass "migration rebinds the Copy URL shortcut to the pinned extension id" - -# A repaired profile has no ghost registration left, so nothing is pending — -# even while that same profile is open. -rm "$preferences.omarchy-copy-url-repair.bak" -repaired_hash=$(sha256sum "$preferences" | cut -d' ' -f1) -open_browser -run_migration || fail "migration reruns cleanly after the repair" -[[ $(sha256sum "$preferences" | cut -d' ' -f1) == "$repaired_hash" && ! -e $preferences.omarchy-copy-url-repair.bak ]] || - fail "migration is idempotent after the repair" -pass "migration is idempotent after the repair" -close_browser - -# A remapped shortcut keeps the user's chosen key while moving to the pinned id. -jq -n --arg ghost "$ghost_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences" -run_migration || fail "migration repairs remapped shortcuts" -jq -e --arg pinned "$pinned_id" '.extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned' "$preferences" >/dev/null || - fail "migration keeps the remapped key while rebinding to the pinned id" -pass "migration keeps remapped shortcut keys" - -# When the pinned extension already holds a copy-url binding (the user fixed -# it by hand), the ghost is dropped rather than doubled into a second binding. -jq -n --arg ghost "$ghost_id" --arg pinned "$pinned_id" '{extensions: {commands: {"linux:Ctrl+Alt+P": {command_name: "copy-url", extension: $pinned, global: false}, "linux:Alt+Shift+L": {command_name: "copy-url", extension: $ghost, global: false}}, settings: {}}}' >"$preferences" -run_migration || fail "migration cleans ghosts alongside a manual repair" -jq -e --arg pinned "$pinned_id" ' - (.extensions.commands | has("linux:Alt+Shift+L") | not) and - .extensions.commands["linux:Ctrl+Alt+P"].extension == $pinned -' "$preferences" >/dev/null || fail "migration drops the ghost instead of double-binding the pinned extension" -pass "migration never double-binds the pinned extension" - -# A browser starting mid-repair may write stale Preferences back on exit, so -# the migration must stay pending for a later browser-free run to verify. A -# stub hands the repair call through and opens the profile right after it. -write_stale_preferences -close_browser -rm -f "$preferences.omarchy-copy-url-repair.bak" -cat >"$stub_bin/python3" <<'STUB' -#!/bin/bash -# Called as `python3 -c