From 45d2e807db5c4c4bde14d1c0b28e8cc3e57d5c68 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 1 Oct 2026 23:00:51 -0500 Subject: [PATCH 01/20] Install the Dell XPS 13 Panther Lake speaker firmware Explicitly select the Omarchy Cirrus firmware when the installed package predates the 1028:0e54 aliases. Apply it during hardware setup and through an update migration, and request a reboot for each user until the firmware can load. Require the same minimum version in the offline package list so newer Arch snapshots keep their genuine firmware. Co-Authored-By: GPT-6.1-Sol --- bin/omarchy-hw-dell-xps13-dx13260-ptl | 5 + install/hardware/all.sh | 1 + .../dell-xps13-ptl-speaker-firmware.sh | 12 ++ install/omarchy-other.packages | 2 + migrations/1790904845.sh | 11 ++ test/shell.d/xps13-ptl-firmware-test.sh | 181 ++++++++++++++++++ 6 files changed, 212 insertions(+) create mode 100755 bin/omarchy-hw-dell-xps13-dx13260-ptl create mode 100644 install/hardware/dell-xps13-ptl-speaker-firmware.sh create mode 100644 migrations/1790904845.sh create mode 100755 test/shell.d/xps13-ptl-firmware-test.sh diff --git a/bin/omarchy-hw-dell-xps13-dx13260-ptl b/bin/omarchy-hw-dell-xps13-dx13260-ptl new file mode 100755 index 00000000..18c063b0 --- /dev/null +++ b/bin/omarchy-hw-dell-xps13-dx13260-ptl @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU. + +omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 12add110..92f3fdea 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -18,6 +18,7 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" +run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-speaker-firmware.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" diff --git a/install/hardware/dell-xps13-ptl-speaker-firmware.sh b/install/hardware/dell-xps13-ptl-speaker-firmware.sh new file mode 100644 index 00000000..f36bceab --- /dev/null +++ b/install/hardware/dell-xps13-ptl-speaker-firmware.sh @@ -0,0 +1,12 @@ +if omarchy-hw-dell-xps13-dx13260-ptl; then + cirrus_package=$(pacman -Q linux-firmware-cirrus 2>/dev/null || true) + + # Keep newer Arch firmware when stable's snapshot supersedes the shim. + if [[ -z $cirrus_package ]] || (( $(vercmp "${cirrus_package#* }" "20260810-3") < 0 )); then + firmware_pending="${OMARCHY_XPS13_FIRMWARE_PENDING:-/run/omarchy/xps13-ptl-speaker-firmware}" + sudo install -Dm644 /dev/null "$firmware_pending" + + # [core] precedes [omarchy], so an unqualified target selects the older firmware. + sudo pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus + fi +fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index ee7607a7..65abbbe2 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -59,6 +59,8 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics dell-xps13-sidecar-amps +# Select the stable-snapshot shim or newer Arch firmware for the XPS 13 PTL. +linux-firmware-cirrus>=20260810-3 # Speaker tunings (LV2 limiter every tuning ends in) lsp-plugins-lv2 diff --git a/migrations/1790904845.sh b/migrations/1790904845.sh new file mode 100644 index 00000000..0f376be3 --- /dev/null +++ b/migrations/1790904845.sh @@ -0,0 +1,11 @@ +echo "Install the Dell XPS 13 Panther Lake speaker firmware" + +if omarchy-hw-dell-xps13-dx13260-ptl; then + source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh" + + # The marker lasts until reboot so every user migrating beforehand is prompted. + firmware_pending="${OMARCHY_XPS13_FIRMWARE_PENDING:-/run/omarchy/xps13-ptl-speaker-firmware}" + if [[ -e $firmware_pending ]]; then + omarchy-state set reboot-required + fi +fi diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh new file mode 100755 index 00000000..3d754160 --- /dev/null +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -0,0 +1,181 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leaf="$ROOT/install/hardware/dell-xps13-ptl-speaker-firmware.sh" +migration="$ROOT/migrations/1790904845.sh" + +grep -q 'run_logged .*hardware/dell-xps13-ptl-speaker-firmware.sh' "$ROOT/install/hardware/all.sh" || + fail "hardware setup installs the Panther Lake XPS 13 firmware" +pass "hardware setup installs the Panther Lake XPS 13 firmware" + +require_command vercmp +require_command pacman +real_pacman=$(command -v pacman) +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" + +firmware_target=$(grep -E '^(omarchy/)?linux-firmware-cirrus' "$ROOT/install/omarchy-other.packages") +mkdir -p "$test_tmp/db/local" "$test_tmp/db/sync" "$test_tmp/package" +cat >"$test_tmp/pacman.conf" <<'CONF' +[options] +Architecture = x86_64 +SigLevel = Never +[core] +Server = https://example.invalid/core +[omarchy] +Server = https://example.invalid/omarchy +CONF + +write_repo() { + local repo="$1" version="$2" + local entry="linux-firmware-cirrus-$version" + mkdir -p "$test_tmp/package/$entry" + cat >"$test_tmp/package/$entry/desc" <"$test_tmp/bin/omarchy-hw-match" <<'SH' +#!/bin/bash +[[ $TEST_PRODUCT_NAME == *"$1"* ]] +SH +cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH' +#!/bin/bash +[[ $TEST_INTEL_PTL == "1" ]] +SH +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +exec "$@" +SH +cat >"$test_tmp/bin/pacman" <<'SH' +#!/bin/bash +if [[ $1 == "-Q" ]]; then + [[ -s $TEST_VERSION_FILE ]] || exit 1 + printf 'linux-firmware-cirrus %s\n' "$(<"$TEST_VERSION_FILE")" +else + printf 'pacman %s\n' "$*" >>"$TEST_LOG" + [[ $* == "-S --noconfirm --needed omarchy/linux-firmware-cirrus" ]] || exit 1 + [[ ${TEST_INSTALL_FAILURE:-0} == "0" ]] || exit 1 + printf '20260810-3\n' >"$TEST_VERSION_FILE" +fi +SH +cat >"$test_tmp/bin/omarchy-state" <<'SH' +#!/bin/bash +printf 'state %s\n' "$*" >>"$TEST_LOG" +SH +chmod +x "$test_tmp/bin/"* + +export PATH="$test_tmp/bin:$ROOT/bin:$PATH" +export OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" +export OMARCHY_XPS13_FIRMWARE_PENDING="$test_tmp/run/pending" +export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1 +install_call="pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus" + +reset_fixture() { + : >"$TEST_LOG" + printf '%s' "$1" >"$TEST_VERSION_FILE" + rm -f "$OMARCHY_XPS13_FIRMWARE_PENDING" +} + +run_leaf() { + bash -eE -c 'source "$1"' bash "$leaf" +} + +run_migration() { + bash -euo pipefail "$migration" >/dev/null +} + +for model in "XPS 9350" "XPS 13 DX13261"; do + reset_fixture "20260810-2" + TEST_PRODUCT_NAME="$model" run_leaf + TEST_PRODUCT_NAME="$model" run_migration + [[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "other models receive no firmware repair" +done +reset_fixture "20260810-2" +TEST_INTEL_PTL=0 run_leaf +TEST_INTEL_PTL=0 run_migration +[[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "the Wildcat Lake variant receives no firmware repair" +pass "other models and the Wildcat Lake variant receive no firmware repair" + +for version in "" "20260810-2"; do + reset_fixture "$version" + run_leaf || fail "missing or old firmware is installed during hardware setup" + [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-3" ]] || + fail "hardware setup explicitly selects the Omarchy firmware" + [[ -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "the firmware repair records its pending reboot" +done +pass "hardware setup explicitly installs the Omarchy firmware when missing or old" + +for version in "20260810-3" "20260810-4" "20260910-2" "1:20260810-2"; do + reset_fixture "$version" + run_leaf + run_migration + [[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING && $(<"$TEST_VERSION_FILE") == "$version" ]] || + fail "sufficient or newer firmware is preserved without a reboot request" +done +pass "sufficient or newer firmware is preserved without a reboot request" + +reset_fixture "20260810-2" +if TEST_INSTALL_FAILURE=1 run_migration; then + fail "a failed firmware installation fails the migration" +fi +[[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] || + fail "a failed installation leaves the migration retryable without claiming success" +pass "a failed firmware installation fails the migration and remains retryable" + +: >"$TEST_LOG" +run_migration +[[ $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] || + fail "retrying the migration installs the firmware and requests a reboot" +pass "retrying the migration installs the firmware and requests a reboot" + +: >"$TEST_LOG" +run_migration +[[ $(<"$TEST_LOG") == "state set reboot-required" ]] || + fail "a second user before reboot is prompted without reinstalling" +pass "a second user before reboot is prompted without reinstalling" + +: >"$TEST_LOG" +rm -f "$OMARCHY_XPS13_FIRMWARE_PENDING" +run_migration +[[ ! -s $TEST_LOG ]] || fail "the migration is a no-op after reboot with repaired firmware" +pass "the migration is a no-op after reboot with repaired firmware" + +reset_fixture "20260810-2" +if OMARCHY_XPS13_FIRMWARE_PENDING="$TEST_VERSION_FILE/not-a-directory" run_migration 2>/dev/null; then + fail "failure to record the pending reboot fails the migration" +fi +[[ ! -s $TEST_LOG && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] || + fail "failure to record the reboot marker installs nothing" +pass "failure to record the reboot marker installs nothing" From 89fcd6a1b78ae699965121c9a1b56eea14f0133f Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 1 Oct 2026 23:09:06 -0500 Subject: [PATCH 02/20] Keep the firmware reboot marker destination fixed A caller-controlled marker path would let the privileged install command replace an unrelated file. Keep the production path fixed and redirect that literal only in isolated test copies. Cover hostile environment overrides without touching system files. Co-Authored-By: GPT-6.1-Sol Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../dell-xps13-ptl-speaker-firmware.sh | 2 +- migrations/1790904845.sh | 2 +- test/shell.d/xps13-ptl-firmware-test.sh | 37 ++++++++++++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/install/hardware/dell-xps13-ptl-speaker-firmware.sh b/install/hardware/dell-xps13-ptl-speaker-firmware.sh index f36bceab..e66d57fc 100644 --- a/install/hardware/dell-xps13-ptl-speaker-firmware.sh +++ b/install/hardware/dell-xps13-ptl-speaker-firmware.sh @@ -3,7 +3,7 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then # Keep newer Arch firmware when stable's snapshot supersedes the shim. if [[ -z $cirrus_package ]] || (( $(vercmp "${cirrus_package#* }" "20260810-3") < 0 )); then - firmware_pending="${OMARCHY_XPS13_FIRMWARE_PENDING:-/run/omarchy/xps13-ptl-speaker-firmware}" + firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" sudo install -Dm644 /dev/null "$firmware_pending" # [core] precedes [omarchy], so an unqualified target selects the older firmware. diff --git a/migrations/1790904845.sh b/migrations/1790904845.sh index 0f376be3..14c251d8 100644 --- a/migrations/1790904845.sh +++ b/migrations/1790904845.sh @@ -4,7 +4,7 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh" # The marker lasts until reboot so every user migrating beforehand is prompted. - firmware_pending="${OMARCHY_XPS13_FIRMWARE_PENDING:-/run/omarchy/xps13-ptl-speaker-firmware}" + firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" if [[ -e $firmware_pending ]]; then omarchy-state set reboot-required fi diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh index 3d754160..281415d9 100755 --- a/test/shell.d/xps13-ptl-firmware-test.sh +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -97,15 +97,23 @@ SH chmod +x "$test_tmp/bin/"* export PATH="$test_tmp/bin:$ROOT/bin:$PATH" -export OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" -export OMARCHY_XPS13_FIRMWARE_PENDING="$test_tmp/run/pending" +export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1 install_call="pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus" +firmware_pending="$test_tmp/run/pending" + +# Redirect the fixed filesystem path in isolated copies, not through production environment overrides. +export OMARCHY_PATH="$test_tmp/omarchy" +mkdir -p "$OMARCHY_PATH/install/hardware" "$OMARCHY_PATH/migrations" +sed "s|/run/omarchy/xps13-ptl-speaker-firmware|$firmware_pending|g" "$leaf" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" +sed "s|/run/omarchy/xps13-ptl-speaker-firmware|$firmware_pending|g" "$migration" >"$OMARCHY_PATH/migrations/${migration##*/}" +leaf="$OMARCHY_PATH/install/hardware/${leaf##*/}" +migration="$OMARCHY_PATH/migrations/${migration##*/}" reset_fixture() { : >"$TEST_LOG" printf '%s' "$1" >"$TEST_VERSION_FILE" - rm -f "$OMARCHY_XPS13_FIRMWARE_PENDING" + rm -f "$firmware_pending" } run_leaf() { @@ -120,12 +128,12 @@ for model in "XPS 9350" "XPS 13 DX13261"; do reset_fixture "20260810-2" TEST_PRODUCT_NAME="$model" run_leaf TEST_PRODUCT_NAME="$model" run_migration - [[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "other models receive no firmware repair" + [[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "other models receive no firmware repair" done reset_fixture "20260810-2" TEST_INTEL_PTL=0 run_leaf TEST_INTEL_PTL=0 run_migration -[[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "the Wildcat Lake variant receives no firmware repair" +[[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "the Wildcat Lake variant receives no firmware repair" pass "other models and the Wildcat Lake variant receive no firmware repair" for version in "" "20260810-2"; do @@ -133,7 +141,7 @@ for version in "" "20260810-2"; do run_leaf || fail "missing or old firmware is installed during hardware setup" [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-3" ]] || fail "hardware setup explicitly selects the Omarchy firmware" - [[ -e $OMARCHY_XPS13_FIRMWARE_PENDING ]] || fail "the firmware repair records its pending reboot" + [[ -e $firmware_pending ]] || fail "the firmware repair records its pending reboot" done pass "hardware setup explicitly installs the Omarchy firmware when missing or old" @@ -141,7 +149,7 @@ for version in "20260810-3" "20260810-4" "20260910-2" "1:20260810-2"; do reset_fixture "$version" run_leaf run_migration - [[ ! -s $TEST_LOG && ! -e $OMARCHY_XPS13_FIRMWARE_PENDING && $(<"$TEST_VERSION_FILE") == "$version" ]] || + [[ ! -s $TEST_LOG && ! -e $firmware_pending && $(<"$TEST_VERSION_FILE") == "$version" ]] || fail "sufficient or newer firmware is preserved without a reboot request" done pass "sufficient or newer firmware is preserved without a reboot request" @@ -167,15 +175,26 @@ run_migration pass "a second user before reboot is prompted without reinstalling" : >"$TEST_LOG" -rm -f "$OMARCHY_XPS13_FIRMWARE_PENDING" +rm -f "$firmware_pending" run_migration [[ ! -s $TEST_LOG ]] || fail "the migration is a no-op after reboot with repaired firmware" pass "the migration is a no-op after reboot with repaired firmware" reset_fixture "20260810-2" -if OMARCHY_XPS13_FIRMWARE_PENDING="$TEST_VERSION_FILE/not-a-directory" run_migration 2>/dev/null; then +rm -rf "$test_tmp/run" +touch "$test_tmp/run" +if run_migration 2>/dev/null; then fail "failure to record the pending reboot fails the migration" fi [[ ! -s $TEST_LOG && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] || fail "failure to record the reboot marker installs nothing" pass "failure to record the reboot marker installs nothing" + +rm -f "$test_tmp/run" +reset_fixture "20260810-2" +protected_file="$test_tmp/protected" +printf 'preserve me\n' >"$protected_file" +OMARCHY_XPS13_FIRMWARE_PENDING="$protected_file" run_migration +[[ $(<"$protected_file") == "preserve me" && -e $firmware_pending && $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] || + fail "the invoking user's environment cannot redirect the privileged marker write" +pass "the invoking user's environment cannot redirect the privileged marker write" From 8be14d5ac67a7a1a6928a40b5d8197b19683acfb Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 1 Oct 2026 23:41:22 -0500 Subject: [PATCH 03/20] Explain why the Cirrus firmware repair uses pacman directly Co-Authored-By: GPT-6.1-Sol --- install/hardware/dell-xps13-ptl-speaker-firmware.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/install/hardware/dell-xps13-ptl-speaker-firmware.sh b/install/hardware/dell-xps13-ptl-speaker-firmware.sh index e66d57fc..bd04e746 100644 --- a/install/hardware/dell-xps13-ptl-speaker-firmware.sh +++ b/install/hardware/dell-xps13-ptl-speaker-firmware.sh @@ -6,7 +6,8 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" sudo install -Dm644 /dev/null "$firmware_pending" - # [core] precedes [omarchy], so an unqualified target selects the older firmware. + # omarchy-pkg-add skips installed packages and cannot verify repo-qualified targets. + # Select [omarchy] explicitly because [core] carries the older firmware. sudo pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus fi fi From 96af8d4c0269b534ce0cdfdc70a555204823b3cf Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 1 Oct 2026 23:46:32 -0500 Subject: [PATCH 04/20] Escape firmware test fixture paths Quote the marker path as a Bash literal before escaping it for sed, so TMPDIR metacharacters survive both fixture copying and script evaluation. Rerun the firmware regression under a special-character TMPDIR while preserving the original PATH for real pacman resolution. Co-Authored-By: GPT-6.1-Sol Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- test/shell.d/xps13-ptl-firmware-test.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh index 281415d9..87a523a8 100755 --- a/test/shell.d/xps13-ptl-firmware-test.sh +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -96,6 +96,7 @@ printf 'state %s\n' "$*" >>"$TEST_LOG" SH chmod +x "$test_tmp/bin/"* +test_path="$PATH" export PATH="$test_tmp/bin:$ROOT/bin:$PATH" export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1 @@ -105,8 +106,10 @@ firmware_pending="$test_tmp/run/pending" # Redirect the fixed filesystem path in isolated copies, not through production environment overrides. export OMARCHY_PATH="$test_tmp/omarchy" mkdir -p "$OMARCHY_PATH/install/hardware" "$OMARCHY_PATH/migrations" -sed "s|/run/omarchy/xps13-ptl-speaker-firmware|$firmware_pending|g" "$leaf" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" -sed "s|/run/omarchy/xps13-ptl-speaker-firmware|$firmware_pending|g" "$migration" >"$OMARCHY_PATH/migrations/${migration##*/}" +printf -v marker_literal '%q' "$firmware_pending" +marker_replacement=$(printf '%s' "$marker_literal" | sed 's/[\\&|]/\\&/g') +sed "s|\"/run/omarchy/xps13-ptl-speaker-firmware\"|$marker_replacement|g" "$leaf" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" +sed "s|\"/run/omarchy/xps13-ptl-speaker-firmware\"|$marker_replacement|g" "$migration" >"$OMARCHY_PATH/migrations/${migration##*/}" leaf="$OMARCHY_PATH/install/hardware/${leaf##*/}" migration="$OMARCHY_PATH/migrations/${migration##*/}" @@ -198,3 +201,12 @@ OMARCHY_XPS13_FIRMWARE_PENDING="$protected_file" run_migration [[ $(<"$protected_file") == "preserve me" && -e $firmware_pending && $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] || fail "the invoking user's environment cannot redirect the privileged marker write" pass "the invoking user's environment cannot redirect the privileged marker write" + +if [[ ${TEST_FIRMWARE_SPECIAL_TMPDIR:-0} != "1" ]]; then + special_tmpdir="$test_tmp/"'tmp &|"\$fixture' + mkdir -p "$special_tmpdir" + PATH="$test_path" TMPDIR="$special_tmpdir" TEST_FIRMWARE_SPECIAL_TMPDIR=1 \ + bash "$ROOT/test/shell.d/xps13-ptl-firmware-test.sh" >"$test_tmp/special-path.log" 2>&1 || + fail "firmware fixtures work with shell and sed metacharacters in TMPDIR" "$(<"$test_tmp/special-path.log")" + pass "firmware fixtures work with shell and sed metacharacters in TMPDIR" +fi From 8f7dd71fdaa244f301ad2ce212e6813ec0c83454 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 3 Oct 2026 16:59:33 -0500 Subject: [PATCH 05/20] Apply XPS 13 Panther Lake display parameters Disable PSR2 selective fetch and Panel Replay on DX13260 Panther Lake through a Limine drop-in. Apply it during hardware setup and an update migration, rebuild once per machine, and request reboot until both parameters are booted. Reject diagnosed Limine build errors even when the wrapper returns success so interrupted repairs stay pending. Co-Authored-By: GPT-6.1-Sol XHigh --- install/hardware/all.sh | 1 + install/hardware/dell-xps13-ptl-display.sh | 10 ++ migrations/1790916392.sh | 27 +++++ test/shell.d/xps13-ptl-display-test.sh | 127 +++++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 install/hardware/dell-xps13-ptl-display.sh create mode 100644 migrations/1790916392.sh create mode 100755 test/shell.d/xps13-ptl-display-test.sh diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 92f3fdea..4394cc09 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -19,6 +19,7 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-speaker-firmware.sh" +run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-display.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" diff --git a/install/hardware/dell-xps13-ptl-display.sh b/install/hardware/dell-xps13-ptl-display.sh new file mode 100644 index 00000000..1d66b4bd --- /dev/null +++ b/install/hardware/dell-xps13-ptl-display.sh @@ -0,0 +1,10 @@ +if omarchy-hw-dell-xps13-dx13260-ptl; then + display_conf="/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf" + display_cmdline='KERNEL_CMDLINE[default]+=" xe.enable_psr2_sel_fetch=0 xe.enable_panel_replay=0"' + + if [[ ! -f $display_conf ]] || ! grep -Fxq "$display_cmdline" "$display_conf"; then + sudo mkdir -p /etc/limine-entry-tool.d + printf '%s\n' '# Dell XPS 13 Panther Lake display workaround' "$display_cmdline" | + sudo tee "$display_conf" >/dev/null + fi +fi diff --git a/migrations/1790916392.sh b/migrations/1790916392.sh new file mode 100644 index 00000000..cd201a9b --- /dev/null +++ b/migrations/1790916392.sh @@ -0,0 +1,27 @@ +echo "Apply the Dell XPS 13 Panther Lake display workaround" + +if omarchy-hw-dell-xps13-dx13260-ptl; then + source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-display.sh" + + display_rebuild_marker="/var/lib/omarchy/migrations/1790916392" + + # Record a successful machine-wide rebuild so other users do not repeat it. + if [[ ! -e $display_rebuild_marker ]]; then + if ! display_rebuild_output=$(sudo limine-mkinitcpio 2>&1); then + printf '%s\n' "$display_rebuild_output" >&2 + exit 1 + fi + printf '%s\n' "$display_rebuild_output" + + # Limine reports per-kernel build errors on stderr but can still exit zero. + if [[ $display_rebuild_output == *"ERROR:"* ]]; then + exit 1 + fi + sudo install -Dm644 /dev/null "$display_rebuild_marker" + fi + + if ! grep -Eq '(^| )xe.enable_psr2_sel_fetch=0( |$)' /proc/cmdline || + ! grep -Eq '(^| )xe.enable_panel_replay=0( |$)' /proc/cmdline; then + omarchy-state set reboot-required + fi +fi diff --git a/test/shell.d/xps13-ptl-display-test.sh b/test/shell.d/xps13-ptl-display-test.sh new file mode 100755 index 00000000..f02d9c00 --- /dev/null +++ b/test/shell.d/xps13-ptl-display-test.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leaf="$ROOT/install/hardware/dell-xps13-ptl-display.sh" +migration="$ROOT/migrations/1790916392.sh" + +grep -Fq 'run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-display.sh"' "$ROOT/install/hardware/all.sh" || + fail "hardware setup applies the XPS 13 Panther Lake display workaround" +pass "hardware setup applies the XPS 13 Panther Lake display workaround" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +export OMARCHY_PATH="$test_tmp/omarchy" +mkdir -p "$test_tmp/bin" "$OMARCHY_PATH/install/hardware" + +# Redirect fixed production paths only in temporary source copies. +redirect_path() { + local literal replacement + printf -v literal '%q' "$2" + replacement=$(printf '%s' "$literal" | sed 's/[\\&|]/\\&/g') + sed "s|$1|$replacement|g" +} + +display_conf="$test_tmp/limine/dell-xps13-ptl-display.conf" +rebuild_marker="$test_tmp/rebuilt" +running_cmdline="$test_tmp/cmdline" +redirect_path '"/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf"' "$display_conf" <"$leaf" | + redirect_path '/etc/limine-entry-tool.d$' "$test_tmp/limine" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" +redirect_path '"/var/lib/omarchy/migrations/1790916392"' "$rebuild_marker" <"$migration" | + redirect_path '/proc/cmdline' "$running_cmdline" >"$test_tmp/migration.sh" + +cat >"$test_tmp/bin/omarchy-hw-match" <<'SH' +#!/bin/bash +[[ $TEST_MODEL == *"$1"* ]] +SH +cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH' +#!/bin/bash +[[ $TEST_PTL == "1" ]] +SH +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" +exec "$@" +SH +cat >"$test_tmp/bin/limine-mkinitcpio" <<'SH' +#!/bin/bash +if [[ ${TEST_REBUILD_ERROR:-0} == "1" ]]; then + printf '\033[31mERROR: mkinitcpio failed for kernel fixture, skipping.\033[0m\n' >&2 + exit 0 +fi +declare -A KERNEL_CMDLINE=([default]="root=UUID=keep quiet") +source "$TEST_DISPLAY_CONF" +printf '%s\n' "${KERNEL_CMDLINE[default]}" >"$TEST_IMAGE_CMDLINE" +exit "${TEST_REBUILD_STATUS:-0}" +SH +cat >"$test_tmp/bin/omarchy-state" <<'SH' +#!/bin/bash +printf 'state %s\n' "$*" >>"$TEST_LOG" +SH +chmod +x "$test_tmp/bin/"* +export PATH="$test_tmp/bin:$ROOT/bin:$PATH" +export TEST_LOG="$test_tmp/calls" TEST_MODEL="XPS 13 DX13260" TEST_PTL=1 +export TEST_DISPLAY_CONF="$display_conf" TEST_IMAGE_CMDLINE="$test_tmp/image-cmdline" +printf '%s\n' 'root=UUID=keep quiet' >"$running_cmdline" + +run_leaf() { + : >"$TEST_LOG" + bash -euo pipefail -c 'source "$1"' bash "$OMARCHY_PATH/install/hardware/${leaf##*/}" +} +run_migration() { + : >"$TEST_LOG" + bash -euo pipefail "$test_tmp/migration.sh" >/dev/null +} + +TEST_MODEL="XPS 13 9340" run_leaf +[[ ! -e $display_conf && ! -s $TEST_LOG ]] || fail "other XPS models are unchanged" +TEST_PTL=0 run_leaf +[[ ! -e $display_conf && ! -s $TEST_LOG ]] || fail "DX13260 without Panther Lake is unchanged" +TEST_MODEL="ThinkPad X1" run_migration +[[ ! -e $display_conf && ! -e $rebuild_marker && ! -s $TEST_LOG ]] || fail "migration skips unrelated hardware" +pass "hardware setup and migration skip unrelated models and GPUs" + +run_leaf +declare -A KERNEL_CMDLINE=([default]="root=UUID=keep quiet") +source "$display_conf" +expected_cmdline='root=UUID=keep quiet xe.enable_psr2_sel_fetch=0 xe.enable_panel_replay=0' +[[ ${KERNEL_CMDLINE[default]} == "$expected_cmdline" ]] || fail "drop-in adds both parameters and preserves the root command line" +pass "drop-in adds both parameters and preserves the root command line" + +run_leaf +[[ ! -s $TEST_LOG ]] || fail "repeated hardware setup does not rewrite or duplicate parameters" +pass "repeated hardware setup does not rewrite or duplicate parameters" + +TEST_REBUILD_STATUS=1 run_migration && fail "a failed boot rebuild leaves the migration pending" +[[ ! -e $rebuild_marker ]] && ! grep -q '^state ' "$TEST_LOG" || + fail "a failed boot rebuild creates no completion marker or reboot state" +pass "a failed boot rebuild leaves the migration pending without recording completion" + +printf '%s\n' 'root=UUID=keep quiet' >"$TEST_IMAGE_CMDLINE" +TEST_REBUILD_ERROR=1 run_migration && fail "Limine's swallowed build error leaves the migration pending" +[[ ! -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == 'root=UUID=keep quiet' ]] && + ! grep -q '^state ' "$TEST_LOG" || fail "a skipped boot image cannot record migration completion" +pass "Limine's swallowed build error leaves the migration pending with its old image" + +run_migration +[[ -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == "$expected_cmdline" ]] || + fail "retry rebuilds the boot image with both parameters before recording completion" +grep -q '^state set reboot-required$' "$TEST_LOG" || fail "the migration requests a reboot" +pass "retry rebuilds the boot image with both parameters and requests a reboot" + +run_migration +[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || + fail "another user's migration skips the machine-wide rebuild and still requests reboot" +pass "another user's migration skips the machine-wide rebuild and still requests reboot" + +printf '%s\n' 'root=UUID=keep quiet xe.enable_psr2_sel_fetch=0' >"$running_cmdline" +run_migration +[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || fail "one booted parameter is insufficient" +printf '%s\n' 'root=UUID=keep quiet xe.enable_panel_replay=0' >"$running_cmdline" +run_migration +[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || fail "the other booted parameter is insufficient" +printf '%s\n' "$expected_cmdline" >"$running_cmdline" +run_migration +[[ ! -s $TEST_LOG ]] || fail "both booted parameters avoid an unnecessary reboot prompt" +pass "the reboot prompt remains until both parameters are booted" From 81df05533c55a4e41b0b66afd8fc139f3c11f388 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sat, 3 Oct 2026 17:09:14 -0500 Subject: [PATCH 06/20] Invalidate the display rebuild marker before repairing its drop-in A pending migration must rebuild after restoring its missing display configuration even when another user previously completed the machine-wide rebuild. Remove the fixed marker before writing the drop-in so failed writes and builds remain retryable. Fresh installation already rebuilds after hardware setup through the ISO finalizer. Co-Authored-By: GPT-6.1-Sol XHigh Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- install/hardware/dell-xps13-ptl-display.sh | 3 +++ migrations/1790916392.sh | 2 -- test/shell.d/xps13-ptl-display-test.sh | 19 +++++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/install/hardware/dell-xps13-ptl-display.sh b/install/hardware/dell-xps13-ptl-display.sh index 1d66b4bd..8affe0c5 100644 --- a/install/hardware/dell-xps13-ptl-display.sh +++ b/install/hardware/dell-xps13-ptl-display.sh @@ -1,8 +1,11 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then display_conf="/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf" + display_rebuild_marker="/var/lib/omarchy/migrations/1790916392" display_cmdline='KERNEL_CMDLINE[default]+=" xe.enable_psr2_sel_fetch=0 xe.enable_panel_replay=0"' if [[ ! -f $display_conf ]] || ! grep -Fxq "$display_cmdline" "$display_conf"; then + # A repaired drop-in invalidates the previous boot image rebuild. + sudo rm -f "$display_rebuild_marker" sudo mkdir -p /etc/limine-entry-tool.d printf '%s\n' '# Dell XPS 13 Panther Lake display workaround' "$display_cmdline" | sudo tee "$display_conf" >/dev/null diff --git a/migrations/1790916392.sh b/migrations/1790916392.sh index cd201a9b..eb11d98c 100644 --- a/migrations/1790916392.sh +++ b/migrations/1790916392.sh @@ -3,8 +3,6 @@ echo "Apply the Dell XPS 13 Panther Lake display workaround" if omarchy-hw-dell-xps13-dx13260-ptl; then source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-display.sh" - display_rebuild_marker="/var/lib/omarchy/migrations/1790916392" - # Record a successful machine-wide rebuild so other users do not repeat it. if [[ ! -e $display_rebuild_marker ]]; then if ! display_rebuild_output=$(sudo limine-mkinitcpio 2>&1); then diff --git a/test/shell.d/xps13-ptl-display-test.sh b/test/shell.d/xps13-ptl-display-test.sh index f02d9c00..7d96deae 100755 --- a/test/shell.d/xps13-ptl-display-test.sh +++ b/test/shell.d/xps13-ptl-display-test.sh @@ -27,9 +27,9 @@ display_conf="$test_tmp/limine/dell-xps13-ptl-display.conf" rebuild_marker="$test_tmp/rebuilt" running_cmdline="$test_tmp/cmdline" redirect_path '"/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf"' "$display_conf" <"$leaf" | + redirect_path '"/var/lib/omarchy/migrations/1790916392"' "$rebuild_marker" | redirect_path '/etc/limine-entry-tool.d$' "$test_tmp/limine" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" -redirect_path '"/var/lib/omarchy/migrations/1790916392"' "$rebuild_marker" <"$migration" | - redirect_path '/proc/cmdline' "$running_cmdline" >"$test_tmp/migration.sh" +redirect_path '/proc/cmdline' "$running_cmdline" <"$migration" >"$test_tmp/migration.sh" cat >"$test_tmp/bin/omarchy-hw-match" <<'SH' #!/bin/bash @@ -125,3 +125,18 @@ printf '%s\n' "$expected_cmdline" >"$running_cmdline" run_migration [[ ! -s $TEST_LOG ]] || fail "both booted parameters avoid an unnecessary reboot prompt" pass "the reboot prompt remains until both parameters are booted" + +rm "$display_conf" +printf '%s\n' 'root=UUID=keep quiet' >"$TEST_IMAGE_CMDLINE" +printf '%s\n' 'root=UUID=keep quiet' >"$running_cmdline" +TEST_REBUILD_STATUS=1 run_migration && fail "a failed rebuild after restoring the drop-in leaves the migration pending" +[[ -e $display_conf && ! -e $rebuild_marker ]] && ! grep -q '^state ' "$TEST_LOG" || + fail "restoring the drop-in invalidates the old rebuild marker before a failed rebuild" +pass "restoring the drop-in invalidates the old marker before a failed rebuild" + +run_migration +[[ -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == "$expected_cmdline" ]] && + grep -q '^sudo limine-mkinitcpio$' "$TEST_LOG" && + grep -q '^state set reboot-required$' "$TEST_LOG" || + fail "restoring a lost drop-in rebuilds even after a previous successful migration" +pass "restoring a lost drop-in rebuilds even after a previous successful migration" From 033b5ecd3de1a64f464c1fd3d9c716258e9451b6 Mon Sep 17 00:00:00 2001 From: Spencer Bull <7035687+spencerbull@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:30:29 -0500 Subject: [PATCH 07/20] Retain XPS 13 Panther Lake firmware during package refresh Constrain Cirrus firmware in the package transaction so a stable channel refresh cannot remove the required aliases. Recover old firmware independently of completed migrations, and request reboot only after verifying a successful repair. Preserve package exclusions through native pacman handling while keeping interactive conflict recovery answerable. Co-Authored-By: GPT-6.1-Sol High Co-Authored-By: GPT-6.1-Sol XHigh --- bin/omarchy-update-pacman | 99 ++++++++- test/shell.d/fixtures/sudo-boundary-test.sh | 2 + test/shell.d/update-package-conflict-test.sh | 23 ++ test/shell.d/update-pacman-test.sh | 2 + .../xps13-ptl-firmware-retention-test.sh | 206 ++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/xps13-ptl-firmware-retention-test.sh diff --git a/bin/omarchy-update-pacman b/bin/omarchy-update-pacman index 66f66731..c9273f3c 100755 --- a/bin/omarchy-update-pacman +++ b/bin/omarchy-update-pacman @@ -5,6 +5,93 @@ # omarchy:hidden=true # omarchy:requires-sudo=true +pacman_args=("$@") +query_args=() +declare -A firmware_targets needed_options +sync=0 upgrades=0 ask_bits=0 repair_reboot=0 +noninteractive=0 +operand="" option="" +end_options=0 +separator_index="" +for index in "${!pacman_args[@]}"; do + arg=${pacman_args[index]} + if [[ -n $operand ]]; then + if [[ $operand == "query" ]]; then + query_args+=("$option" "$arg") + elif [[ $operand == "ask" ]]; then + [[ $arg =~ ^[0-9]{1,10}$ ]] || { echo "Invalid pacman question mask: $arg" >&2; exit 2; } + ask_bits=$((10#$arg)) + noninteractive=1 + fi + operand="" + continue + fi + if [[ $arg == "--" ]]; then + end_options=1 + separator_index=$index + continue + fi + if (( end_options )); then + [[ $arg != "linux-firmware-cirrus" && $arg != */linux-firmware-cirrus ]] || firmware_targets[$index]=1 + continue + fi + case "$arg" in + --sync) sync=1 ;; + --sysupgrade) ((upgrades += 1)) ;; + --noconfirm) noninteractive=1 ;; + --confirm) noninteractive=0 ;; + --ask) operand=ask ;; + --ask=*) + mask=${arg#*=} + [[ $mask =~ ^[0-9]{1,10}$ ]] || { echo "Invalid pacman question mask: $mask" >&2; exit 2; } + ask_bits=$((10#$mask)) + noninteractive=1 + ;; + --config|--dbpath|--root|--sysroot|-b|-r) operand=query; option=$arg ;; + --config=*|--dbpath=*|--root=*|--sysroot=*) query_args+=("$arg") ;; + --ignore|--ignoregroup|--cachedir|--hookdir|--gpgdir|--logfile|--overwrite) operand=skip ;; + -b?*|-r?*) query_args+=("$arg") ;; + --needed) needed_options[$index]=1 ;; + --*) ;; + -*) + [[ $arg != *S* ]] || sync=1 + upgrade_flags=${arg//[^u]/} + ((upgrades += ${#upgrade_flags})) + ;; + linux-firmware-cirrus|*/linux-firmware-cirrus) firmware_targets[$index]=1 ;; + esac +done + +if (( sync && upgrades )) && omarchy-hw-dell-xps13-dx13260-ptl; then + cirrus_package=$(pacman "${query_args[@]}" -Q linux-firmware-cirrus 2>/dev/null || true) + old_firmware=0 + if [[ -z $cirrus_package ]] || (( $(vercmp "${cirrus_package#* }" "20260810-3") < 0 )); then + old_firmware=1 + fi + if (( upgrades > 1 || old_firmware || ${#firmware_targets[@]} )); then + pacman_args=() + original_args=("$@") + policy_inserted=0 + for index in "${!original_args[@]}"; do + arg=${original_args[index]} + if (( noninteractive )) && [[ $index == "$separator_index" ]]; then + pacman_args+=(--ask "$((ask_bits | 1))") + policy_inserted=1 + fi + [[ ! -v firmware_targets[$index] ]] || continue + # --needed skips an installed shim before -Suu selects the older core copy. + (( upgrades < 2 )) || [[ ! -v needed_options[$index] ]] || continue + pacman_args+=("$arg") + done + # Keep exclusions through pacman's own IgnorePkg/IgnoreGroup question handling. + if (( noninteractive && ! policy_inserted )); then + pacman_args+=(--ask "$((ask_bits | 1))") + fi + pacman_args+=('linux-firmware-cirrus>=20260810-3') + repair_reboot=$old_firmware + fi +fi + # Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which # reexecs both the system manager and every user manager. A pacman running # inside a user-session scope can be SIGKILLed by that reexec, abandoning the @@ -22,4 +109,14 @@ if [[ -n ${LC_ALL:-} ]]; then env_args+=(LC_ALL="$LC_ALL") fi -exec sudo env "${env_args[@]}" "${scope[@]}" pacman "$@" +if (( repair_reboot )); then + sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" || exit $? + cirrus_package=$(pacman "${query_args[@]}" -Q linux-firmware-cirrus 2>/dev/null || true) + if [[ -n $cirrus_package ]] && (( $(vercmp "${cirrus_package#* }" "20260810-3") >= 0 )); then + omarchy-state set reboot-required + else + echo "Speaker firmware was not repaired; check the transaction's package exclusions." >&2 + fi +else + exec sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" +fi diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 1fb123d0..544d2482 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -32,6 +32,8 @@ PY copy_boundary_file bin/omarchy-security-functions copy_boundary_file bin/omarchy-update-pacman copy_boundary_file default/omarchy/sudo-no-update/sudo +printf '#!/bin/bash\nexit 1\n' >"$SUDO_TEST_ROOT/bin/omarchy-hw-dell-xps13-dx13260-ptl" +chmod +x "$SUDO_TEST_ROOT/bin/omarchy-hw-dell-xps13-dx13260-ptl" cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB' #!/bin/bash diff --git a/test/shell.d/update-package-conflict-test.sh b/test/shell.d/update-package-conflict-test.sh index e05e8f7b..2f0d7053 100755 --- a/test/shell.d/update-package-conflict-test.sh +++ b/test/shell.d/update-package-conflict-test.sh @@ -31,6 +31,12 @@ STUB # for a person has to keep that stream. cat >"$stub_bin/pacman" <<'STUB' #!/bin/bash +if [[ $1 == "-Q" ]]; then + version=20260810-2 + (( $(cat "$PACMAN_ATTEMPTS") < 2 )) || version=20260810-3 + printf 'linux-firmware-cirrus %s\n' "$version" + exit 0 +fi attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) echo "$attempt" >"$PACMAN_ATTEMPTS" { @@ -47,7 +53,16 @@ fi echo "upgrade complete" STUB +cat >"$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" <<'STUB' +#!/bin/bash +[[ ${TEST_PTL_MATCH:-0} == 1 ]] +STUB +cat >"$stub_bin/omarchy-state" <<'STUB' +#!/bin/bash +exit 0 +STUB chmod +x "$stub_bin/sudo" "$stub_bin/systemd-run" "$stub_bin/pacman" +chmod +x "$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" "$stub_bin/omarchy-state" # Everything a blocked qemu-common upgrade leaves on stderr, and no more. The # ":: ... Remove qemu-block-gluster? [y/N]" pacman asked is deliberately absent: @@ -161,3 +176,11 @@ fi [[ $(call_line 1 args) == *"--noconfirm"* ]] || fail "a caller can ask for an interactive upgrade directly" pass "only the conflict handler can hand the upgrade to a person" + +write_conflict_report +TEST_PTL_MATCH=1 run_on_terminal || fail "the firmware minimum blocks interactive conflict recovery" +[[ $(call_line 1 args) == *'linux-firmware-cirrus>=20260810-3'* && $(call_line 1 args) == *'--ask 1'* ]] || + fail "the first matching-hardware transaction retains its firmware and exclusion policy" +[[ $(call_line 2 args) == *'linux-firmware-cirrus>=20260810-3'* && $(call_line 2 args) != *'--ask'* && $(call_line 2 args) != *'--noconfirm'* ]] || + fail "the matching-hardware conflict retry must let the person answer" +pass "Panther Lake firmware repair preserves interactive package conflict recovery" diff --git a/test/shell.d/update-pacman-test.sh b/test/shell.d/update-pacman-test.sh index 8af6766c..9f2b088c 100755 --- a/test/shell.d/update-pacman-test.sh +++ b/test/shell.d/update-pacman-test.sh @@ -15,6 +15,8 @@ cat >"$stub_bin/sudo" <<'STUB' printf '%s\n' "$*" >"$SUDO_CALL_LOG" STUB chmod +x "$stub_bin/sudo" +printf '#!/bin/bash\nexit 1\n' >"$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" +chmod +x "$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" run_helper() { PATH="$stub_bin:$PATH" SUDO_CALL_LOG="$test_tmp/call" "$ROOT/bin/omarchy-update-pacman" "$@" diff --git a/test/shell.d/xps13-ptl-firmware-retention-test.sh b/test/shell.d/xps13-ptl-firmware-retention-test.sh new file mode 100755 index 00000000..418276eb --- /dev/null +++ b/test/shell.d/xps13-ptl-firmware-retention-test.sh @@ -0,0 +1,206 @@ +#!/bin/bash +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" +require_command pacman +require_command pacman-conf +require_command vercmp +real_pacman=$(command -v pacman) +real_pacman_conf=$(command -v pacman-conf) +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/bin" "$test_tmp/db/local" "$test_tmp/db/sync" "$test_tmp/package" +printf '9\n' >"$test_tmp/db/local/ALPM_DB_VERSION" + +export RETENTION_REAL_PACMAN="$real_pacman" RETENTION_REAL_CONF="$real_pacman_conf" +export RETENTION_ROOT="$test_tmp" RETENTION_PTL=1 +cat >"$test_tmp/bin/pacman" <<'STUB' +#!/bin/bash +query=0 +for arg in "$@"; do + [[ $arg != -Q* && $arg != -Sg* ]] || query=1 +done +if (( query )); then + if [[ ${RETENTION_SIMULATE_SUCCESS:-0} == 1 && -e $RETENTION_ROOT/transaction-success && $* == *-Q* ]]; then + printf 'linux-firmware-cirrus 20260810-3\n' + exit 0 + fi + exec "$RETENTION_REAL_PACMAN" --config "$RETENTION_ROOT/pacman.conf" --dbpath "$RETENTION_ROOT/db" "$@" +fi +printf '%s\n' "$@" >"$RETENTION_ROOT/transaction-args" +[[ ${RETENTION_FAILURE:-0} == 0 ]] || exit "$RETENTION_FAILURE" +"$RETENTION_REAL_PACMAN" --config "$RETENTION_ROOT/pacman.conf" --dbpath "$RETENTION_ROOT/db" --print --print-format '%r/%n %v' "$@" || exit $? +touch "$RETENTION_ROOT/transaction-success" +STUB +cat >"$test_tmp/bin/omarchy-state" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$RETENTION_ROOT/state" +STUB +cat >"$test_tmp/bin/pacman-conf" <<'STUB' +#!/bin/bash +exec "$RETENTION_REAL_CONF" --config "$RETENTION_ROOT/pacman.conf" "$@" +STUB +cat >"$test_tmp/bin/omarchy-hw-dell-xps13-dx13260-ptl" <<'STUB' +#!/bin/bash +[[ $RETENTION_PTL == 1 ]] +STUB +cat >"$test_tmp/bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +cat >"$test_tmp/bin/systemd-run" <<'STUB' +#!/bin/bash +while [[ $1 == -* ]]; do shift; done +exec "$@" +STUB +chmod +x "$test_tmp/bin/"* + +write_repo() { + local repo="$1" version="$2" + local entry="linux-firmware-cirrus-$version" + mkdir -p "$test_tmp/package/$entry" + cat >"$test_tmp/package/$entry/desc" <"$test_tmp/db/local/linux-firmware-cirrus-$1/desc" + : >"$test_tmp/db/local/linux-firmware-cirrus-$1/files" + fi + cat >"$test_tmp/pacman.conf" <"$test_tmp/result" 2>"$test_tmp/errors" || status=$? + (( status == 0 )) || cat "$test_tmp/errors" >&2 + return "$status" +} +expect_package() { + [[ $(<"$test_tmp/result") == "$1" ]] || fail "$2" "$(<"$test_tmp/result") $(<"$test_tmp/errors")" + pass "$2" +} + +reset_fixture 20260810-3 +run_transaction -Suu --noconfirm +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "refresh selects the shim instead of downgrading to core" +run_transaction -Suu --needed --noconfirm +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "an installed target is retained even when --needed was supplied" +run_transaction --sync --sysupgrade --sysupgrade --noconfirm +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "long downgrade options retain the firmware minimum" +RETENTION_PTL=0 run_transaction -Suu --needed --noconfirm +expect_package 'core/linux-firmware-cirrus 20260810-2' "other hardware retains the original downgrade behavior" + +for installed in '' 20260810-2; do + reset_fixture "$installed" + run_transaction -Su --noconfirm + expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "normal update repairs missing or downgraded firmware independently of migrations" + [[ ! -e $test_tmp/state ]] || fail "print-only transactions request no reboot" +done +reset_fixture 20260810-2 +run_transaction -Su --needed --noconfirm +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "normal update with --needed still repairs old firmware" +for installed in 20260810-3 20260910-2 1:20260810-2; do + reset_fixture "$installed" + run_transaction -Su --noconfirm + expect_package '' "normal update preserves sufficient or newer local firmware without reinstalling" + [[ ! -e $test_tmp/state ]] || fail "unchanged firmware requests no reboot" +done +reset_fixture 20260810-3 +# --print omits IgnorePkg questions, so these cases verify the forwarded mask. +run_transaction -Suu --noconfirm --ignore 'linux-firmware-*' +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution retains the minimum target with command-line exclusions" +[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "package exclusions use pacman's native question policy" +run_transaction -Suu --noconfirm --ignoregroup=firmware,other +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution retains the minimum target with command-line group exclusions" +[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "group exclusions use pacman's native question policy" +reset_fixture 20260810-3 +write_repo core 20260910-2 +run_transaction -Suu --noconfirm +expect_package 'core/linux-firmware-cirrus 20260910-2' "refresh selects newer core firmware when available" + +for ignored in 'IgnorePkg = linux-firmware-*' 'IgnoreGroup = firm*'; do + for installed in 20260810-2 20260810-3; do + reset_fixture "$installed" + sed -i "/Architecture/a $ignored" "$test_tmp/pacman.conf" + run_transaction -Suu --noconfirm + expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution delegates configured exclusions to the native policy" + [[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "configured exclusions use pacman's native question policy" + [[ ! -e $test_tmp/state ]] || fail "excluded firmware requests no reboot" + done +done +reset_fixture 20260810-3 +rm "$test_tmp/db/sync/omarchy.db" +sed -i '/\[omarchy\]/,$d' "$test_tmp/pacman.conf" +if run_transaction -Suu --noconfirm; then + fail "unavailable firmware minimum fails the transaction" +fi +[[ ! -s $test_tmp/result ]] || fail "failed resolution schedules no downgrade" +pass "unavailable firmware minimum fails without scheduling a downgrade" +reset_fixture 20260810-2 +status=0 +RETENTION_FAILURE=23 run_transaction -Su --noconfirm || status=$? +[[ $status == 23 && ! -e $test_tmp/state ]] || fail "a failed firmware repair preserves its status and requests no reboot" +pass "a failed firmware repair preserves its status and requests no reboot" + +reset_fixture 20260810-2 +RETENTION_SIMULATE_SUCCESS=1 run_transaction -Su --noconfirm +[[ $(<"$test_tmp/state") == 'set reboot-required' ]] || fail "verified installed repair requests reboot without migrations" +pass "verified installed repair requests reboot without migrations" +for installed in 20260810-2 20260810-3; do + reset_fixture "$installed" + run_transaction -Su --needed --noconfirm linux-firmware-cirrus + expected='omarchy/linux-firmware-cirrus 20260810-3' + [[ $installed != 20260810-3 ]] || expected='' + expect_package "$expected" "explicit bare targets cannot bypass the firmware minimum or create duplicates" +done +reset_fixture 20260810-3 +run_transaction -Suu --noconfirm core/linux-firmware-cirrus +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "repo-qualified bare targets cannot bypass refresh retention" +reset_fixture '' +sed -i '/Architecture/a IgnoreGroup = firm*' "$test_tmp/pacman.conf" +run_transaction -Suu --noconfirm +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "missing firmware delegates sync-only group exclusions to the native policy" + +reset_fixture 20260810-3 +run_transaction -Suu --noconfirm --ask 4 +[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args" | tail -1) == 5 ]] || fail "existing question bits are preserved" +pass "existing question bits are preserved" + +reset_fixture 20260810-3 +run_transaction -Suu --noconfirm -- linux-firmware-cirrus +expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "the option separator remains valid with an explicit target" +reset_fixture 20260810-2 +run_transaction -Su --noconfirm --confirm +[[ $(<"$test_tmp/transaction-args") != *'--ask'* ]] || fail "a final --confirm keeps the interactive question policy" +pass "a final --confirm keeps the interactive question policy" +run_transaction -Su --confirm --noconfirm +[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "a final --noconfirm keeps noninteractive exclusions" +pass "a final --noconfirm keeps noninteractive exclusions" From 19e59410750e6421b9e3ec3cb88f645a554afbe0 Mon Sep 17 00:00:00 2001 From: Spencer Bull <7035687+spencerbull@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:46:53 -0500 Subject: [PATCH 08/20] Fail updates that leave required Panther Lake firmware unrepaired Honor package exclusions while returning failure when an installation leaves the required Cirrus firmware too old. Print-only and download-only operations remain successful without a repair or reboot request. Co-Authored-By: GPT-6.1-Sol High Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- bin/omarchy-update-pacman | 9 ++++++++- .../xps13-ptl-firmware-retention-test.sh | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-update-pacman b/bin/omarchy-update-pacman index c9273f3c..6d3a6c4c 100755 --- a/bin/omarchy-update-pacman +++ b/bin/omarchy-update-pacman @@ -10,6 +10,7 @@ query_args=() declare -A firmware_targets needed_options sync=0 upgrades=0 ask_bits=0 repair_reboot=0 noninteractive=0 +install_operation=1 operand="" option="" end_options=0 separator_index="" @@ -40,6 +41,9 @@ for index in "${!pacman_args[@]}"; do --sysupgrade) ((upgrades += 1)) ;; --noconfirm) noninteractive=1 ;; --confirm) noninteractive=0 ;; + --print|--downloadonly) install_operation=0 ;; + --print-format) install_operation=0; operand=skip ;; + --print-format=*) install_operation=0 ;; --ask) operand=ask ;; --ask=*) mask=${arg#*=} @@ -55,6 +59,7 @@ for index in "${!pacman_args[@]}"; do --*) ;; -*) [[ $arg != *S* ]] || sync=1 + [[ $arg != *p* && $arg != *w* ]] || install_operation=0 upgrade_flags=${arg//[^u]/} ((upgrades += ${#upgrade_flags})) ;; @@ -111,11 +116,13 @@ fi if (( repair_reboot )); then sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" || exit $? + (( install_operation )) || exit 0 cirrus_package=$(pacman "${query_args[@]}" -Q linux-firmware-cirrus 2>/dev/null || true) if [[ -n $cirrus_package ]] && (( $(vercmp "${cirrus_package#* }" "20260810-3") >= 0 )); then omarchy-state set reboot-required else - echo "Speaker firmware was not repaired; check the transaction's package exclusions." >&2 + echo "Speaker firmware requires linux-firmware-cirrus>=20260810-3. Check package exclusions and retry the update." >&2 + exit 1 fi else exec sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" diff --git a/test/shell.d/xps13-ptl-firmware-retention-test.sh b/test/shell.d/xps13-ptl-firmware-retention-test.sh index 418276eb..771d5509 100755 --- a/test/shell.d/xps13-ptl-firmware-retention-test.sh +++ b/test/shell.d/xps13-ptl-firmware-retention-test.sh @@ -99,6 +99,9 @@ CONF } run_transaction() { local status=0 + if [[ ${RETENTION_SIMULATE_SUCCESS:-0} != 1 && ${RETENTION_INSTALL_NO_CHANGE:-0} != 1 ]]; then + set -- --print "$@" + fi PATH="$test_tmp/bin:$PATH" "$ROOT/bin/omarchy-update-pacman" "$@" >"$test_tmp/result" 2>"$test_tmp/errors" || status=$? (( status == 0 )) || cat "$test_tmp/errors" >&2 return "$status" @@ -204,3 +207,19 @@ pass "a final --confirm keeps the interactive question policy" run_transaction -Su --confirm --noconfirm [[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "a final --noconfirm keeps noninteractive exclusions" pass "a final --noconfirm keeps noninteractive exclusions" +reset_fixture 20260810-2 +status=0 +RETENTION_INSTALL_NO_CHANGE=1 run_transaction -Su --noconfirm || status=$? +[[ $status == 1 && ! -e $test_tmp/state && $(<"$test_tmp/errors") == *'Check package exclusions and retry'* ]] || + fail "an excluded or declined installation cannot report a successful firmware repair" +pass "an excluded or declined installation fails without bypassing exclusions or requesting reboot" +for print_option in -Sup -Suw --print --downloadonly '--print-format=%n'; do + reset_fixture 20260810-2 + if [[ $print_option == -S* ]]; then + RETENTION_INSTALL_NO_CHANGE=1 run_transaction "$print_option" --noconfirm + else + RETENTION_INSTALL_NO_CHANGE=1 run_transaction -Su --noconfirm "$print_option" + fi + [[ ! -e $test_tmp/state ]] || fail "non-install modes never request reboot" +done +pass "short, long and formatted print/download-only transactions remain successful without repair" From 0d8232b9db162fe7d9db774ea8e2cebc62051ecd Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 6 Oct 2026 21:29:54 -0500 Subject: [PATCH 09/20] Install the XPS 13 PTL speaker alias package Install the separately owned firmware aliases through the package helper so stock firmware updates and stable refreshes retain them. Use a distinct migration to reach machines with the prior migration completed and preserve the reboot prompt until reboot. Restore the generic updater and leave display quirks to the kernel. Co-Authored-By: Codex GPT-6.1-Sol XHigh --- bin/omarchy-update-pacman | 106 +-------- install/hardware/all.sh | 1 - install/hardware/dell-xps13-ptl-display.sh | 13 - .../dell-xps13-ptl-speaker-firmware.sh | 10 +- install/omarchy-other.packages | 3 +- migrations/1790916392.sh | 25 -- migrations/{1790904845.sh => 1791265613.sh} | 0 test/shell.d/fixtures/sudo-boundary-test.sh | 2 - test/shell.d/update-package-conflict-test.sh | 23 -- test/shell.d/update-pacman-test.sh | 2 - test/shell.d/xps13-ptl-display-test.sh | 142 ----------- .../xps13-ptl-firmware-retention-test.sh | 225 ------------------ test/shell.d/xps13-ptl-firmware-test.sh | 96 ++------ 13 files changed, 28 insertions(+), 620 deletions(-) delete mode 100644 install/hardware/dell-xps13-ptl-display.sh delete mode 100644 migrations/1790916392.sh rename migrations/{1790904845.sh => 1791265613.sh} (100%) delete mode 100755 test/shell.d/xps13-ptl-display-test.sh delete mode 100755 test/shell.d/xps13-ptl-firmware-retention-test.sh diff --git a/bin/omarchy-update-pacman b/bin/omarchy-update-pacman index 6d3a6c4c..66f66731 100755 --- a/bin/omarchy-update-pacman +++ b/bin/omarchy-update-pacman @@ -5,98 +5,6 @@ # omarchy:hidden=true # omarchy:requires-sudo=true -pacman_args=("$@") -query_args=() -declare -A firmware_targets needed_options -sync=0 upgrades=0 ask_bits=0 repair_reboot=0 -noninteractive=0 -install_operation=1 -operand="" option="" -end_options=0 -separator_index="" -for index in "${!pacman_args[@]}"; do - arg=${pacman_args[index]} - if [[ -n $operand ]]; then - if [[ $operand == "query" ]]; then - query_args+=("$option" "$arg") - elif [[ $operand == "ask" ]]; then - [[ $arg =~ ^[0-9]{1,10}$ ]] || { echo "Invalid pacman question mask: $arg" >&2; exit 2; } - ask_bits=$((10#$arg)) - noninteractive=1 - fi - operand="" - continue - fi - if [[ $arg == "--" ]]; then - end_options=1 - separator_index=$index - continue - fi - if (( end_options )); then - [[ $arg != "linux-firmware-cirrus" && $arg != */linux-firmware-cirrus ]] || firmware_targets[$index]=1 - continue - fi - case "$arg" in - --sync) sync=1 ;; - --sysupgrade) ((upgrades += 1)) ;; - --noconfirm) noninteractive=1 ;; - --confirm) noninteractive=0 ;; - --print|--downloadonly) install_operation=0 ;; - --print-format) install_operation=0; operand=skip ;; - --print-format=*) install_operation=0 ;; - --ask) operand=ask ;; - --ask=*) - mask=${arg#*=} - [[ $mask =~ ^[0-9]{1,10}$ ]] || { echo "Invalid pacman question mask: $mask" >&2; exit 2; } - ask_bits=$((10#$mask)) - noninteractive=1 - ;; - --config|--dbpath|--root|--sysroot|-b|-r) operand=query; option=$arg ;; - --config=*|--dbpath=*|--root=*|--sysroot=*) query_args+=("$arg") ;; - --ignore|--ignoregroup|--cachedir|--hookdir|--gpgdir|--logfile|--overwrite) operand=skip ;; - -b?*|-r?*) query_args+=("$arg") ;; - --needed) needed_options[$index]=1 ;; - --*) ;; - -*) - [[ $arg != *S* ]] || sync=1 - [[ $arg != *p* && $arg != *w* ]] || install_operation=0 - upgrade_flags=${arg//[^u]/} - ((upgrades += ${#upgrade_flags})) - ;; - linux-firmware-cirrus|*/linux-firmware-cirrus) firmware_targets[$index]=1 ;; - esac -done - -if (( sync && upgrades )) && omarchy-hw-dell-xps13-dx13260-ptl; then - cirrus_package=$(pacman "${query_args[@]}" -Q linux-firmware-cirrus 2>/dev/null || true) - old_firmware=0 - if [[ -z $cirrus_package ]] || (( $(vercmp "${cirrus_package#* }" "20260810-3") < 0 )); then - old_firmware=1 - fi - if (( upgrades > 1 || old_firmware || ${#firmware_targets[@]} )); then - pacman_args=() - original_args=("$@") - policy_inserted=0 - for index in "${!original_args[@]}"; do - arg=${original_args[index]} - if (( noninteractive )) && [[ $index == "$separator_index" ]]; then - pacman_args+=(--ask "$((ask_bits | 1))") - policy_inserted=1 - fi - [[ ! -v firmware_targets[$index] ]] || continue - # --needed skips an installed shim before -Suu selects the older core copy. - (( upgrades < 2 )) || [[ ! -v needed_options[$index] ]] || continue - pacman_args+=("$arg") - done - # Keep exclusions through pacman's own IgnorePkg/IgnoreGroup question handling. - if (( noninteractive && ! policy_inserted )); then - pacman_args+=(--ask "$((ask_bits | 1))") - fi - pacman_args+=('linux-firmware-cirrus>=20260810-3') - repair_reboot=$old_firmware - fi -fi - # Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which # reexecs both the system manager and every user manager. A pacman running # inside a user-session scope can be SIGKILLed by that reexec, abandoning the @@ -114,16 +22,4 @@ if [[ -n ${LC_ALL:-} ]]; then env_args+=(LC_ALL="$LC_ALL") fi -if (( repair_reboot )); then - sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" || exit $? - (( install_operation )) || exit 0 - cirrus_package=$(pacman "${query_args[@]}" -Q linux-firmware-cirrus 2>/dev/null || true) - if [[ -n $cirrus_package ]] && (( $(vercmp "${cirrus_package#* }" "20260810-3") >= 0 )); then - omarchy-state set reboot-required - else - echo "Speaker firmware requires linux-firmware-cirrus>=20260810-3. Check package exclusions and retry the update." >&2 - exit 1 - fi -else - exec sudo env "${env_args[@]}" "${scope[@]}" pacman "${pacman_args[@]}" -fi +exec sudo env "${env_args[@]}" "${scope[@]}" pacman "$@" diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 4394cc09..92f3fdea 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -19,7 +19,6 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-speaker-firmware.sh" -run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-display.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" diff --git a/install/hardware/dell-xps13-ptl-display.sh b/install/hardware/dell-xps13-ptl-display.sh deleted file mode 100644 index 8affe0c5..00000000 --- a/install/hardware/dell-xps13-ptl-display.sh +++ /dev/null @@ -1,13 +0,0 @@ -if omarchy-hw-dell-xps13-dx13260-ptl; then - display_conf="/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf" - display_rebuild_marker="/var/lib/omarchy/migrations/1790916392" - display_cmdline='KERNEL_CMDLINE[default]+=" xe.enable_psr2_sel_fetch=0 xe.enable_panel_replay=0"' - - if [[ ! -f $display_conf ]] || ! grep -Fxq "$display_cmdline" "$display_conf"; then - # A repaired drop-in invalidates the previous boot image rebuild. - sudo rm -f "$display_rebuild_marker" - sudo mkdir -p /etc/limine-entry-tool.d - printf '%s\n' '# Dell XPS 13 Panther Lake display workaround' "$display_cmdline" | - sudo tee "$display_conf" >/dev/null - fi -fi diff --git a/install/hardware/dell-xps13-ptl-speaker-firmware.sh b/install/hardware/dell-xps13-ptl-speaker-firmware.sh index bd04e746..df9db731 100644 --- a/install/hardware/dell-xps13-ptl-speaker-firmware.sh +++ b/install/hardware/dell-xps13-ptl-speaker-firmware.sh @@ -1,13 +1,7 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then - cirrus_package=$(pacman -Q linux-firmware-cirrus 2>/dev/null || true) - - # Keep newer Arch firmware when stable's snapshot supersedes the shim. - if [[ -z $cirrus_package ]] || (( $(vercmp "${cirrus_package#* }" "20260810-3") < 0 )); then + if omarchy-pkg-missing linux-firmware-cirrus-dx13260; then firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" sudo install -Dm644 /dev/null "$firmware_pending" - - # omarchy-pkg-add skips installed packages and cannot verify repo-qualified targets. - # Select [omarchy] explicitly because [core] carries the older firmware. - sudo pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus fi + omarchy-pkg-add linux-firmware-cirrus-dx13260 fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 65abbbe2..1e70cb71 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -59,8 +59,7 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics dell-xps13-sidecar-amps -# Select the stable-snapshot shim or newer Arch firmware for the XPS 13 PTL. -linux-firmware-cirrus>=20260810-3 +linux-firmware-cirrus-dx13260 # Speaker tunings (LV2 limiter every tuning ends in) lsp-plugins-lv2 diff --git a/migrations/1790916392.sh b/migrations/1790916392.sh deleted file mode 100644 index eb11d98c..00000000 --- a/migrations/1790916392.sh +++ /dev/null @@ -1,25 +0,0 @@ -echo "Apply the Dell XPS 13 Panther Lake display workaround" - -if omarchy-hw-dell-xps13-dx13260-ptl; then - source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-display.sh" - - # Record a successful machine-wide rebuild so other users do not repeat it. - if [[ ! -e $display_rebuild_marker ]]; then - if ! display_rebuild_output=$(sudo limine-mkinitcpio 2>&1); then - printf '%s\n' "$display_rebuild_output" >&2 - exit 1 - fi - printf '%s\n' "$display_rebuild_output" - - # Limine reports per-kernel build errors on stderr but can still exit zero. - if [[ $display_rebuild_output == *"ERROR:"* ]]; then - exit 1 - fi - sudo install -Dm644 /dev/null "$display_rebuild_marker" - fi - - if ! grep -Eq '(^| )xe.enable_psr2_sel_fetch=0( |$)' /proc/cmdline || - ! grep -Eq '(^| )xe.enable_panel_replay=0( |$)' /proc/cmdline; then - omarchy-state set reboot-required - fi -fi diff --git a/migrations/1790904845.sh b/migrations/1791265613.sh similarity index 100% rename from migrations/1790904845.sh rename to migrations/1791265613.sh diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 544d2482..1fb123d0 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -32,8 +32,6 @@ PY copy_boundary_file bin/omarchy-security-functions copy_boundary_file bin/omarchy-update-pacman copy_boundary_file default/omarchy/sudo-no-update/sudo -printf '#!/bin/bash\nexit 1\n' >"$SUDO_TEST_ROOT/bin/omarchy-hw-dell-xps13-dx13260-ptl" -chmod +x "$SUDO_TEST_ROOT/bin/omarchy-hw-dell-xps13-dx13260-ptl" cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB' #!/bin/bash diff --git a/test/shell.d/update-package-conflict-test.sh b/test/shell.d/update-package-conflict-test.sh index 2f0d7053..e05e8f7b 100755 --- a/test/shell.d/update-package-conflict-test.sh +++ b/test/shell.d/update-package-conflict-test.sh @@ -31,12 +31,6 @@ STUB # for a person has to keep that stream. cat >"$stub_bin/pacman" <<'STUB' #!/bin/bash -if [[ $1 == "-Q" ]]; then - version=20260810-2 - (( $(cat "$PACMAN_ATTEMPTS") < 2 )) || version=20260810-3 - printf 'linux-firmware-cirrus %s\n' "$version" - exit 0 -fi attempt=$(($(cat "$PACMAN_ATTEMPTS") + 1)) echo "$attempt" >"$PACMAN_ATTEMPTS" { @@ -53,16 +47,7 @@ fi echo "upgrade complete" STUB -cat >"$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" <<'STUB' -#!/bin/bash -[[ ${TEST_PTL_MATCH:-0} == 1 ]] -STUB -cat >"$stub_bin/omarchy-state" <<'STUB' -#!/bin/bash -exit 0 -STUB chmod +x "$stub_bin/sudo" "$stub_bin/systemd-run" "$stub_bin/pacman" -chmod +x "$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" "$stub_bin/omarchy-state" # Everything a blocked qemu-common upgrade leaves on stderr, and no more. The # ":: ... Remove qemu-block-gluster? [y/N]" pacman asked is deliberately absent: @@ -176,11 +161,3 @@ fi [[ $(call_line 1 args) == *"--noconfirm"* ]] || fail "a caller can ask for an interactive upgrade directly" pass "only the conflict handler can hand the upgrade to a person" - -write_conflict_report -TEST_PTL_MATCH=1 run_on_terminal || fail "the firmware minimum blocks interactive conflict recovery" -[[ $(call_line 1 args) == *'linux-firmware-cirrus>=20260810-3'* && $(call_line 1 args) == *'--ask 1'* ]] || - fail "the first matching-hardware transaction retains its firmware and exclusion policy" -[[ $(call_line 2 args) == *'linux-firmware-cirrus>=20260810-3'* && $(call_line 2 args) != *'--ask'* && $(call_line 2 args) != *'--noconfirm'* ]] || - fail "the matching-hardware conflict retry must let the person answer" -pass "Panther Lake firmware repair preserves interactive package conflict recovery" diff --git a/test/shell.d/update-pacman-test.sh b/test/shell.d/update-pacman-test.sh index 9f2b088c..8af6766c 100755 --- a/test/shell.d/update-pacman-test.sh +++ b/test/shell.d/update-pacman-test.sh @@ -15,8 +15,6 @@ cat >"$stub_bin/sudo" <<'STUB' printf '%s\n' "$*" >"$SUDO_CALL_LOG" STUB chmod +x "$stub_bin/sudo" -printf '#!/bin/bash\nexit 1\n' >"$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" -chmod +x "$stub_bin/omarchy-hw-dell-xps13-dx13260-ptl" run_helper() { PATH="$stub_bin:$PATH" SUDO_CALL_LOG="$test_tmp/call" "$ROOT/bin/omarchy-update-pacman" "$@" diff --git a/test/shell.d/xps13-ptl-display-test.sh b/test/shell.d/xps13-ptl-display-test.sh deleted file mode 100755 index 7d96deae..00000000 --- a/test/shell.d/xps13-ptl-display-test.sh +++ /dev/null @@ -1,142 +0,0 @@ -#!/bin/bash - -set -euo pipefail -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -leaf="$ROOT/install/hardware/dell-xps13-ptl-display.sh" -migration="$ROOT/migrations/1790916392.sh" - -grep -Fq 'run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-display.sh"' "$ROOT/install/hardware/all.sh" || - fail "hardware setup applies the XPS 13 Panther Lake display workaround" -pass "hardware setup applies the XPS 13 Panther Lake display workaround" - -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT -export OMARCHY_PATH="$test_tmp/omarchy" -mkdir -p "$test_tmp/bin" "$OMARCHY_PATH/install/hardware" - -# Redirect fixed production paths only in temporary source copies. -redirect_path() { - local literal replacement - printf -v literal '%q' "$2" - replacement=$(printf '%s' "$literal" | sed 's/[\\&|]/\\&/g') - sed "s|$1|$replacement|g" -} - -display_conf="$test_tmp/limine/dell-xps13-ptl-display.conf" -rebuild_marker="$test_tmp/rebuilt" -running_cmdline="$test_tmp/cmdline" -redirect_path '"/etc/limine-entry-tool.d/dell-xps13-ptl-display.conf"' "$display_conf" <"$leaf" | - redirect_path '"/var/lib/omarchy/migrations/1790916392"' "$rebuild_marker" | - redirect_path '/etc/limine-entry-tool.d$' "$test_tmp/limine" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" -redirect_path '/proc/cmdline' "$running_cmdline" <"$migration" >"$test_tmp/migration.sh" - -cat >"$test_tmp/bin/omarchy-hw-match" <<'SH' -#!/bin/bash -[[ $TEST_MODEL == *"$1"* ]] -SH -cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH' -#!/bin/bash -[[ $TEST_PTL == "1" ]] -SH -cat >"$test_tmp/bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo %s\n' "$*" >>"$TEST_LOG" -exec "$@" -SH -cat >"$test_tmp/bin/limine-mkinitcpio" <<'SH' -#!/bin/bash -if [[ ${TEST_REBUILD_ERROR:-0} == "1" ]]; then - printf '\033[31mERROR: mkinitcpio failed for kernel fixture, skipping.\033[0m\n' >&2 - exit 0 -fi -declare -A KERNEL_CMDLINE=([default]="root=UUID=keep quiet") -source "$TEST_DISPLAY_CONF" -printf '%s\n' "${KERNEL_CMDLINE[default]}" >"$TEST_IMAGE_CMDLINE" -exit "${TEST_REBUILD_STATUS:-0}" -SH -cat >"$test_tmp/bin/omarchy-state" <<'SH' -#!/bin/bash -printf 'state %s\n' "$*" >>"$TEST_LOG" -SH -chmod +x "$test_tmp/bin/"* -export PATH="$test_tmp/bin:$ROOT/bin:$PATH" -export TEST_LOG="$test_tmp/calls" TEST_MODEL="XPS 13 DX13260" TEST_PTL=1 -export TEST_DISPLAY_CONF="$display_conf" TEST_IMAGE_CMDLINE="$test_tmp/image-cmdline" -printf '%s\n' 'root=UUID=keep quiet' >"$running_cmdline" - -run_leaf() { - : >"$TEST_LOG" - bash -euo pipefail -c 'source "$1"' bash "$OMARCHY_PATH/install/hardware/${leaf##*/}" -} -run_migration() { - : >"$TEST_LOG" - bash -euo pipefail "$test_tmp/migration.sh" >/dev/null -} - -TEST_MODEL="XPS 13 9340" run_leaf -[[ ! -e $display_conf && ! -s $TEST_LOG ]] || fail "other XPS models are unchanged" -TEST_PTL=0 run_leaf -[[ ! -e $display_conf && ! -s $TEST_LOG ]] || fail "DX13260 without Panther Lake is unchanged" -TEST_MODEL="ThinkPad X1" run_migration -[[ ! -e $display_conf && ! -e $rebuild_marker && ! -s $TEST_LOG ]] || fail "migration skips unrelated hardware" -pass "hardware setup and migration skip unrelated models and GPUs" - -run_leaf -declare -A KERNEL_CMDLINE=([default]="root=UUID=keep quiet") -source "$display_conf" -expected_cmdline='root=UUID=keep quiet xe.enable_psr2_sel_fetch=0 xe.enable_panel_replay=0' -[[ ${KERNEL_CMDLINE[default]} == "$expected_cmdline" ]] || fail "drop-in adds both parameters and preserves the root command line" -pass "drop-in adds both parameters and preserves the root command line" - -run_leaf -[[ ! -s $TEST_LOG ]] || fail "repeated hardware setup does not rewrite or duplicate parameters" -pass "repeated hardware setup does not rewrite or duplicate parameters" - -TEST_REBUILD_STATUS=1 run_migration && fail "a failed boot rebuild leaves the migration pending" -[[ ! -e $rebuild_marker ]] && ! grep -q '^state ' "$TEST_LOG" || - fail "a failed boot rebuild creates no completion marker or reboot state" -pass "a failed boot rebuild leaves the migration pending without recording completion" - -printf '%s\n' 'root=UUID=keep quiet' >"$TEST_IMAGE_CMDLINE" -TEST_REBUILD_ERROR=1 run_migration && fail "Limine's swallowed build error leaves the migration pending" -[[ ! -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == 'root=UUID=keep quiet' ]] && - ! grep -q '^state ' "$TEST_LOG" || fail "a skipped boot image cannot record migration completion" -pass "Limine's swallowed build error leaves the migration pending with its old image" - -run_migration -[[ -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == "$expected_cmdline" ]] || - fail "retry rebuilds the boot image with both parameters before recording completion" -grep -q '^state set reboot-required$' "$TEST_LOG" || fail "the migration requests a reboot" -pass "retry rebuilds the boot image with both parameters and requests a reboot" - -run_migration -[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || - fail "another user's migration skips the machine-wide rebuild and still requests reboot" -pass "another user's migration skips the machine-wide rebuild and still requests reboot" - -printf '%s\n' 'root=UUID=keep quiet xe.enable_psr2_sel_fetch=0' >"$running_cmdline" -run_migration -[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || fail "one booted parameter is insufficient" -printf '%s\n' 'root=UUID=keep quiet xe.enable_panel_replay=0' >"$running_cmdline" -run_migration -[[ $(<"$TEST_LOG") == 'state set reboot-required' ]] || fail "the other booted parameter is insufficient" -printf '%s\n' "$expected_cmdline" >"$running_cmdline" -run_migration -[[ ! -s $TEST_LOG ]] || fail "both booted parameters avoid an unnecessary reboot prompt" -pass "the reboot prompt remains until both parameters are booted" - -rm "$display_conf" -printf '%s\n' 'root=UUID=keep quiet' >"$TEST_IMAGE_CMDLINE" -printf '%s\n' 'root=UUID=keep quiet' >"$running_cmdline" -TEST_REBUILD_STATUS=1 run_migration && fail "a failed rebuild after restoring the drop-in leaves the migration pending" -[[ -e $display_conf && ! -e $rebuild_marker ]] && ! grep -q '^state ' "$TEST_LOG" || - fail "restoring the drop-in invalidates the old rebuild marker before a failed rebuild" -pass "restoring the drop-in invalidates the old marker before a failed rebuild" - -run_migration -[[ -e $rebuild_marker && $(<"$TEST_IMAGE_CMDLINE") == "$expected_cmdline" ]] && - grep -q '^sudo limine-mkinitcpio$' "$TEST_LOG" && - grep -q '^state set reboot-required$' "$TEST_LOG" || - fail "restoring a lost drop-in rebuilds even after a previous successful migration" -pass "restoring a lost drop-in rebuilds even after a previous successful migration" diff --git a/test/shell.d/xps13-ptl-firmware-retention-test.sh b/test/shell.d/xps13-ptl-firmware-retention-test.sh deleted file mode 100755 index 771d5509..00000000 --- a/test/shell.d/xps13-ptl-firmware-retention-test.sh +++ /dev/null @@ -1,225 +0,0 @@ -#!/bin/bash -set -euo pipefail - -source "$(dirname "$0")/base-test.sh" -require_command pacman -require_command pacman-conf -require_command vercmp -real_pacman=$(command -v pacman) -real_pacman_conf=$(command -v pacman-conf) -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT -mkdir -p "$test_tmp/bin" "$test_tmp/db/local" "$test_tmp/db/sync" "$test_tmp/package" -printf '9\n' >"$test_tmp/db/local/ALPM_DB_VERSION" - -export RETENTION_REAL_PACMAN="$real_pacman" RETENTION_REAL_CONF="$real_pacman_conf" -export RETENTION_ROOT="$test_tmp" RETENTION_PTL=1 -cat >"$test_tmp/bin/pacman" <<'STUB' -#!/bin/bash -query=0 -for arg in "$@"; do - [[ $arg != -Q* && $arg != -Sg* ]] || query=1 -done -if (( query )); then - if [[ ${RETENTION_SIMULATE_SUCCESS:-0} == 1 && -e $RETENTION_ROOT/transaction-success && $* == *-Q* ]]; then - printf 'linux-firmware-cirrus 20260810-3\n' - exit 0 - fi - exec "$RETENTION_REAL_PACMAN" --config "$RETENTION_ROOT/pacman.conf" --dbpath "$RETENTION_ROOT/db" "$@" -fi -printf '%s\n' "$@" >"$RETENTION_ROOT/transaction-args" -[[ ${RETENTION_FAILURE:-0} == 0 ]] || exit "$RETENTION_FAILURE" -"$RETENTION_REAL_PACMAN" --config "$RETENTION_ROOT/pacman.conf" --dbpath "$RETENTION_ROOT/db" --print --print-format '%r/%n %v' "$@" || exit $? -touch "$RETENTION_ROOT/transaction-success" -STUB -cat >"$test_tmp/bin/omarchy-state" <<'STUB' -#!/bin/bash -printf '%s\n' "$*" >>"$RETENTION_ROOT/state" -STUB -cat >"$test_tmp/bin/pacman-conf" <<'STUB' -#!/bin/bash -exec "$RETENTION_REAL_CONF" --config "$RETENTION_ROOT/pacman.conf" "$@" -STUB -cat >"$test_tmp/bin/omarchy-hw-dell-xps13-dx13260-ptl" <<'STUB' -#!/bin/bash -[[ $RETENTION_PTL == 1 ]] -STUB -cat >"$test_tmp/bin/sudo" <<'STUB' -#!/bin/bash -exec "$@" -STUB -cat >"$test_tmp/bin/systemd-run" <<'STUB' -#!/bin/bash -while [[ $1 == -* ]]; do shift; done -exec "$@" -STUB -chmod +x "$test_tmp/bin/"* - -write_repo() { - local repo="$1" version="$2" - local entry="linux-firmware-cirrus-$version" - mkdir -p "$test_tmp/package/$entry" - cat >"$test_tmp/package/$entry/desc" <"$test_tmp/db/local/linux-firmware-cirrus-$1/desc" - : >"$test_tmp/db/local/linux-firmware-cirrus-$1/files" - fi - cat >"$test_tmp/pacman.conf" <"$test_tmp/result" 2>"$test_tmp/errors" || status=$? - (( status == 0 )) || cat "$test_tmp/errors" >&2 - return "$status" -} -expect_package() { - [[ $(<"$test_tmp/result") == "$1" ]] || fail "$2" "$(<"$test_tmp/result") $(<"$test_tmp/errors")" - pass "$2" -} - -reset_fixture 20260810-3 -run_transaction -Suu --noconfirm -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "refresh selects the shim instead of downgrading to core" -run_transaction -Suu --needed --noconfirm -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "an installed target is retained even when --needed was supplied" -run_transaction --sync --sysupgrade --sysupgrade --noconfirm -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "long downgrade options retain the firmware minimum" -RETENTION_PTL=0 run_transaction -Suu --needed --noconfirm -expect_package 'core/linux-firmware-cirrus 20260810-2' "other hardware retains the original downgrade behavior" - -for installed in '' 20260810-2; do - reset_fixture "$installed" - run_transaction -Su --noconfirm - expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "normal update repairs missing or downgraded firmware independently of migrations" - [[ ! -e $test_tmp/state ]] || fail "print-only transactions request no reboot" -done -reset_fixture 20260810-2 -run_transaction -Su --needed --noconfirm -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "normal update with --needed still repairs old firmware" -for installed in 20260810-3 20260910-2 1:20260810-2; do - reset_fixture "$installed" - run_transaction -Su --noconfirm - expect_package '' "normal update preserves sufficient or newer local firmware without reinstalling" - [[ ! -e $test_tmp/state ]] || fail "unchanged firmware requests no reboot" -done -reset_fixture 20260810-3 -# --print omits IgnorePkg questions, so these cases verify the forwarded mask. -run_transaction -Suu --noconfirm --ignore 'linux-firmware-*' -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution retains the minimum target with command-line exclusions" -[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "package exclusions use pacman's native question policy" -run_transaction -Suu --noconfirm --ignoregroup=firmware,other -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution retains the minimum target with command-line group exclusions" -[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "group exclusions use pacman's native question policy" -reset_fixture 20260810-3 -write_repo core 20260910-2 -run_transaction -Suu --noconfirm -expect_package 'core/linux-firmware-cirrus 20260910-2' "refresh selects newer core firmware when available" - -for ignored in 'IgnorePkg = linux-firmware-*' 'IgnoreGroup = firm*'; do - for installed in 20260810-2 20260810-3; do - reset_fixture "$installed" - sed -i "/Architecture/a $ignored" "$test_tmp/pacman.conf" - run_transaction -Suu --noconfirm - expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "print resolution delegates configured exclusions to the native policy" - [[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "configured exclusions use pacman's native question policy" - [[ ! -e $test_tmp/state ]] || fail "excluded firmware requests no reboot" - done -done -reset_fixture 20260810-3 -rm "$test_tmp/db/sync/omarchy.db" -sed -i '/\[omarchy\]/,$d' "$test_tmp/pacman.conf" -if run_transaction -Suu --noconfirm; then - fail "unavailable firmware minimum fails the transaction" -fi -[[ ! -s $test_tmp/result ]] || fail "failed resolution schedules no downgrade" -pass "unavailable firmware minimum fails without scheduling a downgrade" -reset_fixture 20260810-2 -status=0 -RETENTION_FAILURE=23 run_transaction -Su --noconfirm || status=$? -[[ $status == 23 && ! -e $test_tmp/state ]] || fail "a failed firmware repair preserves its status and requests no reboot" -pass "a failed firmware repair preserves its status and requests no reboot" - -reset_fixture 20260810-2 -RETENTION_SIMULATE_SUCCESS=1 run_transaction -Su --noconfirm -[[ $(<"$test_tmp/state") == 'set reboot-required' ]] || fail "verified installed repair requests reboot without migrations" -pass "verified installed repair requests reboot without migrations" -for installed in 20260810-2 20260810-3; do - reset_fixture "$installed" - run_transaction -Su --needed --noconfirm linux-firmware-cirrus - expected='omarchy/linux-firmware-cirrus 20260810-3' - [[ $installed != 20260810-3 ]] || expected='' - expect_package "$expected" "explicit bare targets cannot bypass the firmware minimum or create duplicates" -done -reset_fixture 20260810-3 -run_transaction -Suu --noconfirm core/linux-firmware-cirrus -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "repo-qualified bare targets cannot bypass refresh retention" -reset_fixture '' -sed -i '/Architecture/a IgnoreGroup = firm*' "$test_tmp/pacman.conf" -run_transaction -Suu --noconfirm -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "missing firmware delegates sync-only group exclusions to the native policy" - -reset_fixture 20260810-3 -run_transaction -Suu --noconfirm --ask 4 -[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args" | tail -1) == 5 ]] || fail "existing question bits are preserved" -pass "existing question bits are preserved" - -reset_fixture 20260810-3 -run_transaction -Suu --noconfirm -- linux-firmware-cirrus -expect_package 'omarchy/linux-firmware-cirrus 20260810-3' "the option separator remains valid with an explicit target" -reset_fixture 20260810-2 -run_transaction -Su --noconfirm --confirm -[[ $(<"$test_tmp/transaction-args") != *'--ask'* ]] || fail "a final --confirm keeps the interactive question policy" -pass "a final --confirm keeps the interactive question policy" -run_transaction -Su --confirm --noconfirm -[[ $(sed -n '/^--ask$/{n;p;}' "$test_tmp/transaction-args") == 1 ]] || fail "a final --noconfirm keeps noninteractive exclusions" -pass "a final --noconfirm keeps noninteractive exclusions" -reset_fixture 20260810-2 -status=0 -RETENTION_INSTALL_NO_CHANGE=1 run_transaction -Su --noconfirm || status=$? -[[ $status == 1 && ! -e $test_tmp/state && $(<"$test_tmp/errors") == *'Check package exclusions and retry'* ]] || - fail "an excluded or declined installation cannot report a successful firmware repair" -pass "an excluded or declined installation fails without bypassing exclusions or requesting reboot" -for print_option in -Sup -Suw --print --downloadonly '--print-format=%n'; do - reset_fixture 20260810-2 - if [[ $print_option == -S* ]]; then - RETENTION_INSTALL_NO_CHANGE=1 run_transaction "$print_option" --noconfirm - else - RETENTION_INSTALL_NO_CHANGE=1 run_transaction -Su --noconfirm "$print_option" - fi - [[ ! -e $test_tmp/state ]] || fail "non-install modes never request reboot" -done -pass "short, long and formatted print/download-only transactions remain successful without repair" diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh index 87a523a8..2172c1fa 100755 --- a/test/shell.d/xps13-ptl-firmware-test.sh +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -5,67 +5,16 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" leaf="$ROOT/install/hardware/dell-xps13-ptl-speaker-firmware.sh" -migration="$ROOT/migrations/1790904845.sh" +migration="$ROOT/migrations/1791265613.sh" grep -q 'run_logged .*hardware/dell-xps13-ptl-speaker-firmware.sh' "$ROOT/install/hardware/all.sh" || fail "hardware setup installs the Panther Lake XPS 13 firmware" pass "hardware setup installs the Panther Lake XPS 13 firmware" -require_command vercmp -require_command pacman -real_pacman=$(command -v pacman) test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT mkdir -p "$test_tmp/bin" -firmware_target=$(grep -E '^(omarchy/)?linux-firmware-cirrus' "$ROOT/install/omarchy-other.packages") -mkdir -p "$test_tmp/db/local" "$test_tmp/db/sync" "$test_tmp/package" -cat >"$test_tmp/pacman.conf" <<'CONF' -[options] -Architecture = x86_64 -SigLevel = Never -[core] -Server = https://example.invalid/core -[omarchy] -Server = https://example.invalid/omarchy -CONF - -write_repo() { - local repo="$1" version="$2" - local entry="linux-firmware-cirrus-$version" - mkdir -p "$test_tmp/package/$entry" - cat >"$test_tmp/package/$entry/desc" <"$test_tmp/bin/omarchy-hw-match" <<'SH' #!/bin/bash [[ $TEST_PRODUCT_NAME == *"$1"* ]] @@ -81,13 +30,18 @@ SH cat >"$test_tmp/bin/pacman" <<'SH' #!/bin/bash if [[ $1 == "-Q" ]]; then - [[ -s $TEST_VERSION_FILE ]] || exit 1 - printf 'linux-firmware-cirrus %s\n' "$(<"$TEST_VERSION_FILE")" + if [[ ${@: -1} == "linux-firmware-cirrus-dx13260" ]]; then + [[ -e $TEST_ALIAS_FILE ]] || exit 1 + printf 'linux-firmware-cirrus-dx13260 20260810-1\n' + else + [[ -s $TEST_VERSION_FILE ]] || exit 1 + printf 'linux-firmware-cirrus %s\n' "$(<"$TEST_VERSION_FILE")" + fi else printf 'pacman %s\n' "$*" >>"$TEST_LOG" - [[ $* == "-S --noconfirm --needed omarchy/linux-firmware-cirrus" ]] || exit 1 + [[ $* == "-S --noconfirm --needed -- linux-firmware-cirrus-dx13260" ]] || exit 1 [[ ${TEST_INSTALL_FAILURE:-0} == "0" ]] || exit 1 - printf '20260810-3\n' >"$TEST_VERSION_FILE" + touch "$TEST_ALIAS_FILE" fi SH cat >"$test_tmp/bin/omarchy-state" <<'SH' @@ -98,9 +52,9 @@ chmod +x "$test_tmp/bin/"* test_path="$PATH" export PATH="$test_tmp/bin:$ROOT/bin:$PATH" -export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" +export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" TEST_ALIAS_FILE="$test_tmp/alias-installed" export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1 -install_call="pacman -S --noconfirm --needed omarchy/linux-firmware-cirrus" +install_call="pacman -S --noconfirm --needed -- linux-firmware-cirrus-dx13260" firmware_pending="$test_tmp/run/pending" # Redirect the fixed filesystem path in isolated copies, not through production environment overrides. @@ -116,7 +70,7 @@ migration="$OMARCHY_PATH/migrations/${migration##*/}" reset_fixture() { : >"$TEST_LOG" printf '%s' "$1" >"$TEST_VERSION_FILE" - rm -f "$firmware_pending" + rm -f "$firmware_pending" "$TEST_ALIAS_FILE" } run_leaf() { @@ -139,23 +93,21 @@ TEST_INTEL_PTL=0 run_migration [[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "the Wildcat Lake variant receives no firmware repair" pass "other models and the Wildcat Lake variant receive no firmware repair" -for version in "" "20260810-2"; do +for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916-1" "1:20260810-2"; do reset_fixture "$version" - run_leaf || fail "missing or old firmware is installed during hardware setup" - [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-3" ]] || - fail "hardware setup explicitly selects the Omarchy firmware" + run_leaf || fail "the alias package is installed during hardware setup" + [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "$version" && -e $TEST_ALIAS_FILE ]] || + fail "hardware setup installs the separate aliases without replacing stock firmware" [[ -e $firmware_pending ]] || fail "the firmware repair records its pending reboot" done -pass "hardware setup explicitly installs the Omarchy firmware when missing or old" +pass "hardware setup installs the package identity without replacing any stock firmware" -for version in "20260810-3" "20260810-4" "20260910-2" "1:20260810-2"; do - reset_fixture "$version" - run_leaf - run_migration - [[ ! -s $TEST_LOG && ! -e $firmware_pending && $(<"$TEST_VERSION_FILE") == "$version" ]] || - fail "sufficient or newer firmware is preserved without a reboot request" -done -pass "sufficient or newer firmware is preserved without a reboot request" +reset_fixture "20260810-2" +touch "$TEST_ALIAS_FILE" +run_leaf +run_migration +[[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "installed aliases are idempotent on stock firmware" +pass "installed aliases are idempotent on stock firmware" reset_fixture "20260810-2" if TEST_INSTALL_FAILURE=1 run_migration; then From 499f50ffd649091afaf7912fa2fe729a0bd28756 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 6 Oct 2026 21:47:56 -0500 Subject: [PATCH 10/20] Move speaker reboot bookkeeping into the migration Keep hardware setup limited to installing the firmware package. The migration owns the pending-reboot marker and preserves prompts for every user migrating before reboot. --- install/hardware/dell-xps13-ptl-speaker-firmware.sh | 4 ---- migrations/1791265613.sh | 6 +++++- test/shell.d/xps13-ptl-firmware-test.sh | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/install/hardware/dell-xps13-ptl-speaker-firmware.sh b/install/hardware/dell-xps13-ptl-speaker-firmware.sh index df9db731..fe698773 100644 --- a/install/hardware/dell-xps13-ptl-speaker-firmware.sh +++ b/install/hardware/dell-xps13-ptl-speaker-firmware.sh @@ -1,7 +1,3 @@ if omarchy-hw-dell-xps13-dx13260-ptl; then - if omarchy-pkg-missing linux-firmware-cirrus-dx13260; then - firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" - sudo install -Dm644 /dev/null "$firmware_pending" - fi omarchy-pkg-add linux-firmware-cirrus-dx13260 fi diff --git a/migrations/1791265613.sh b/migrations/1791265613.sh index 14c251d8..6b7a3084 100644 --- a/migrations/1791265613.sh +++ b/migrations/1791265613.sh @@ -1,10 +1,14 @@ echo "Install the Dell XPS 13 Panther Lake speaker firmware" if omarchy-hw-dell-xps13-dx13260-ptl; then + firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" + if omarchy-pkg-missing linux-firmware-cirrus-dx13260; then + sudo install -Dm644 /dev/null "$firmware_pending" + fi + source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh" # The marker lasts until reboot so every user migrating beforehand is prompted. - firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" if [[ -e $firmware_pending ]]; then omarchy-state set reboot-required fi diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh index 2172c1fa..da403d9b 100755 --- a/test/shell.d/xps13-ptl-firmware-test.sh +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -98,9 +98,10 @@ for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916- run_leaf || fail "the alias package is installed during hardware setup" [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "$version" && -e $TEST_ALIAS_FILE ]] || fail "hardware setup installs the separate aliases without replacing stock firmware" - [[ -e $firmware_pending ]] || fail "the firmware repair records its pending reboot" + [[ ! -e $firmware_pending ]] || fail "hardware setup leaves reboot bookkeeping to the migration" done pass "hardware setup installs the package identity without replacing any stock firmware" +pass "hardware setup leaves reboot bookkeeping to the migration" reset_fixture "20260810-2" touch "$TEST_ALIAS_FILE" From db21abdef00de03dcee54fd7222b2ab3205c300e Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 6 Oct 2026 21:56:49 -0500 Subject: [PATCH 11/20] Request a reboot directly in the PTL firmware migration Each pending user's one-time migration requests a reboot even when hardware setup has already installed the aliases. Let migration completion handle repeat runs and remove the shared marker and its privileged write. Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- migrations/1791265613.sh | 11 +--- test/shell.d/xps13-ptl-firmware-test.sh | 68 ++++++------------------- 2 files changed, 16 insertions(+), 63 deletions(-) diff --git a/migrations/1791265613.sh b/migrations/1791265613.sh index 6b7a3084..371da510 100644 --- a/migrations/1791265613.sh +++ b/migrations/1791265613.sh @@ -1,15 +1,6 @@ echo "Install the Dell XPS 13 Panther Lake speaker firmware" if omarchy-hw-dell-xps13-dx13260-ptl; then - firmware_pending="/run/omarchy/xps13-ptl-speaker-firmware" - if omarchy-pkg-missing linux-firmware-cirrus-dx13260; then - sudo install -Dm644 /dev/null "$firmware_pending" - fi - source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh" - - # The marker lasts until reboot so every user migrating beforehand is prompted. - if [[ -e $firmware_pending ]]; then - omarchy-state set reboot-required - fi + omarchy-state set reboot-required fi diff --git a/test/shell.d/xps13-ptl-firmware-test.sh b/test/shell.d/xps13-ptl-firmware-test.sh index da403d9b..16cd8f46 100755 --- a/test/shell.d/xps13-ptl-firmware-test.sh +++ b/test/shell.d/xps13-ptl-firmware-test.sh @@ -25,7 +25,11 @@ cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH' SH cat >"$test_tmp/bin/sudo" <<'SH' #!/bin/bash -exec "$@" +if [[ $1 == "pacman" ]]; then + exec "$@" +fi +printf 'Unexpected privileged command: %s\n' "$*" >&2 +exit 1 SH cat >"$test_tmp/bin/pacman" <<'SH' #!/bin/bash @@ -50,27 +54,16 @@ printf 'state %s\n' "$*" >>"$TEST_LOG" SH chmod +x "$test_tmp/bin/"* -test_path="$PATH" export PATH="$test_tmp/bin:$ROOT/bin:$PATH" export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" TEST_ALIAS_FILE="$test_tmp/alias-installed" export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1 install_call="pacman -S --noconfirm --needed -- linux-firmware-cirrus-dx13260" -firmware_pending="$test_tmp/run/pending" - -# Redirect the fixed filesystem path in isolated copies, not through production environment overrides. -export OMARCHY_PATH="$test_tmp/omarchy" -mkdir -p "$OMARCHY_PATH/install/hardware" "$OMARCHY_PATH/migrations" -printf -v marker_literal '%q' "$firmware_pending" -marker_replacement=$(printf '%s' "$marker_literal" | sed 's/[\\&|]/\\&/g') -sed "s|\"/run/omarchy/xps13-ptl-speaker-firmware\"|$marker_replacement|g" "$leaf" >"$OMARCHY_PATH/install/hardware/${leaf##*/}" -sed "s|\"/run/omarchy/xps13-ptl-speaker-firmware\"|$marker_replacement|g" "$migration" >"$OMARCHY_PATH/migrations/${migration##*/}" -leaf="$OMARCHY_PATH/install/hardware/${leaf##*/}" -migration="$OMARCHY_PATH/migrations/${migration##*/}" +export OMARCHY_PATH="$ROOT" reset_fixture() { : >"$TEST_LOG" printf '%s' "$1" >"$TEST_VERSION_FILE" - rm -f "$firmware_pending" "$TEST_ALIAS_FILE" + rm -f "$TEST_ALIAS_FILE" } run_leaf() { @@ -85,12 +78,12 @@ for model in "XPS 9350" "XPS 13 DX13261"; do reset_fixture "20260810-2" TEST_PRODUCT_NAME="$model" run_leaf TEST_PRODUCT_NAME="$model" run_migration - [[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "other models receive no firmware repair" + [[ ! -s $TEST_LOG ]] || fail "other models receive no firmware repair" done reset_fixture "20260810-2" TEST_INTEL_PTL=0 run_leaf TEST_INTEL_PTL=0 run_migration -[[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "the Wildcat Lake variant receives no firmware repair" +[[ ! -s $TEST_LOG ]] || fail "the Wildcat Lake variant receives no firmware repair" pass "other models and the Wildcat Lake variant receive no firmware repair" for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916-1" "1:20260810-2"; do @@ -98,7 +91,6 @@ for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916- run_leaf || fail "the alias package is installed during hardware setup" [[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "$version" && -e $TEST_ALIAS_FILE ]] || fail "hardware setup installs the separate aliases without replacing stock firmware" - [[ ! -e $firmware_pending ]] || fail "hardware setup leaves reboot bookkeeping to the migration" done pass "hardware setup installs the package identity without replacing any stock firmware" pass "hardware setup leaves reboot bookkeeping to the migration" @@ -106,10 +98,14 @@ pass "hardware setup leaves reboot bookkeeping to the migration" reset_fixture "20260810-2" touch "$TEST_ALIAS_FILE" run_leaf -run_migration -[[ ! -s $TEST_LOG && ! -e $firmware_pending ]] || fail "installed aliases are idempotent on stock firmware" +[[ ! -s $TEST_LOG ]] || fail "installed aliases are idempotent on stock firmware" pass "installed aliases are idempotent on stock firmware" +run_migration +[[ $(<"$TEST_LOG") == "state set reboot-required" ]] || + fail "the migration requests a reboot even if hardware setup already installed the aliases" +pass "the migration requests a reboot even if hardware setup already installed the aliases" + reset_fixture "20260810-2" if TEST_INSTALL_FAILURE=1 run_migration; then fail "a failed firmware installation fails the migration" @@ -129,37 +125,3 @@ run_migration [[ $(<"$TEST_LOG") == "state set reboot-required" ]] || fail "a second user before reboot is prompted without reinstalling" pass "a second user before reboot is prompted without reinstalling" - -: >"$TEST_LOG" -rm -f "$firmware_pending" -run_migration -[[ ! -s $TEST_LOG ]] || fail "the migration is a no-op after reboot with repaired firmware" -pass "the migration is a no-op after reboot with repaired firmware" - -reset_fixture "20260810-2" -rm -rf "$test_tmp/run" -touch "$test_tmp/run" -if run_migration 2>/dev/null; then - fail "failure to record the pending reboot fails the migration" -fi -[[ ! -s $TEST_LOG && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] || - fail "failure to record the reboot marker installs nothing" -pass "failure to record the reboot marker installs nothing" - -rm -f "$test_tmp/run" -reset_fixture "20260810-2" -protected_file="$test_tmp/protected" -printf 'preserve me\n' >"$protected_file" -OMARCHY_XPS13_FIRMWARE_PENDING="$protected_file" run_migration -[[ $(<"$protected_file") == "preserve me" && -e $firmware_pending && $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] || - fail "the invoking user's environment cannot redirect the privileged marker write" -pass "the invoking user's environment cannot redirect the privileged marker write" - -if [[ ${TEST_FIRMWARE_SPECIAL_TMPDIR:-0} != "1" ]]; then - special_tmpdir="$test_tmp/"'tmp &|"\$fixture' - mkdir -p "$special_tmpdir" - PATH="$test_path" TMPDIR="$special_tmpdir" TEST_FIRMWARE_SPECIAL_TMPDIR=1 \ - bash "$ROOT/test/shell.d/xps13-ptl-firmware-test.sh" >"$test_tmp/special-path.log" 2>&1 || - fail "firmware fixtures work with shell and sed metacharacters in TMPDIR" "$(<"$test_tmp/special-path.log")" - pass "firmware fixtures work with shell and sed metacharacters in TMPDIR" -fi From 83957145a7bee89cf91af9d0370e926efd1a01aa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 11:53:39 +0200 Subject: [PATCH 12/20] Replace Disk Usage TUI with Disktree (#14426) --- applications/Disk Usage.desktop | 8 ----- install/omarchy-base.packages | 2 +- manual/21-tuis.md | 4 --- manual/22-guis.md | 4 +++ migrations/1791345484.sh | 9 ++++++ test/shell.d/disktree-migration-test.sh | 39 +++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 13 deletions(-) delete mode 100644 applications/Disk Usage.desktop create mode 100644 migrations/1791345484.sh create mode 100644 test/shell.d/disktree-migration-test.sh diff --git a/applications/Disk Usage.desktop b/applications/Disk Usage.desktop deleted file mode 100644 index 1de40546..00000000 --- a/applications/Disk Usage.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Version=1.0 -Name=Disk Usage -Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /" -Terminal=false -Type=Application -Icon=disk-usage -StartupNotify=true diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index bc1fdf09..1981d883 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -21,12 +21,12 @@ cups cups-filters cups-pk-helper ddcutil +disktree-bin docker docker-buildx docker-compose dosfstools dotnet-runtime -dua-cli evince exfatprogs expac diff --git a/manual/21-tuis.md b/manual/21-tuis.md index c8e88646..4d91769f 100644 --- a/manual/21-tuis.md +++ b/manual/21-tuis.md @@ -26,10 +26,6 @@ You start it (or reattach to your existing session) with `Super + Ctrl + Return` Omarchy has packaged this as _About_ in the Omarchy menu (`Super + Space`). -## Disk Usage - -When the drive fills up and you have no idea what's eating it, launch _Disk Usage_ from the app launcher (`Super + Space`). It's [dua](https://github.com/Byron/dua-cli) in interactive mode pointed at the whole file system, so you can walk down into whatever directory is the culprit, sorted biggest first, and delete from right inside it. - ## Cliamp [Cliamp](https://www.cliamp.stream/) is a retro terminal music player inspired by Winamp 2.x, complete with built-in radio stations for lo-fi beats. Launch it with `Super + Shift + Alt + M`, or from the Omarchy menu under _Apps_. Press `?` for the full keybinding list. diff --git a/manual/22-guis.md b/manual/22-guis.md index 56bc701c..2726967d 100644 --- a/manual/22-guis.md +++ b/manual/22-guis.md @@ -8,6 +8,10 @@ Plug in a USB stick or an SD card and it's mounted automatically, so it just sho Double-clicking follows sensible defaults: images open in imv, video in mpv, PDFs in Document Viewer, and plain text in Neovim. +## Disktree + +When the drive fills up and you have no idea what's eating it, launch [Disktree](https://github.com/tobi/disktree) from the app launcher (`Super + Space`). It shows a graphical treemap of your home directory. Larger blocks take up more space; walk into directories with the keyboard or mouse, mark what should go, and review your selections before removing anything. Run `disktree --disk` to scan the whole disk. + ## Obsidian [Obsidian](https://obsidian.md/) is a free and highly extensible note taking application that uses simple Markdown files for storage. diff --git a/migrations/1791345484.sh b/migrations/1791345484.sh new file mode 100644 index 00000000..89b0d782 --- /dev/null +++ b/migrations/1791345484.sh @@ -0,0 +1,9 @@ +echo "Replace the Disk Usage TUI with Disktree" + +omarchy-pkg-add disktree-bin +omarchy-pkg-drop dua-cli + +rm -f "$HOME/.local/share/applications/Disk Usage.desktop" +if [[ -d $HOME/.local/share/applications ]]; then + update-desktop-database "$HOME/.local/share/applications" +fi diff --git a/test/shell.d/disktree-migration-test.sh b/test/shell.d/disktree-migration-test.sh new file mode 100644 index 00000000..3c898127 --- /dev/null +++ b/test/shell.d/disktree-migration-test.sh @@ -0,0 +1,39 @@ +#!/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 +mkdir -p "$test_tmp/bin" "$test_tmp/home/.local/share/applications" + +for helper in omarchy-pkg-add omarchy-pkg-drop update-desktop-database; do + cat > "$test_tmp/bin/$helper" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >> "$TEST_LOG" +SH + chmod +x "$test_tmp/bin/$helper" +done + +launcher="$test_tmp/home/.local/share/applications/Disk Usage.desktop" +printf '[Desktop Entry]\nExec=dua i /\n' > "$launcher" + +for (( attempt = 0; attempt < 2; attempt++ )); do + env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1791345484.sh" + [[ ! -e $launcher ]] || fail "the migration removes the old Disk Usage launcher" +done +pass "the Disktree migration removes the old launcher and can run twice" + +expected="omarchy-pkg-add disktree-bin +omarchy-pkg-drop dua-cli +update-desktop-database $test_tmp/home/.local/share/applications" +[[ $(cat "$test_tmp/log") == "$expected"$'\n'"$expected" ]] || fail "the migration installs Disktree before removing dua and refreshing launchers" +pass "the migration installs Disktree before removing dua and refreshing launchers" + +rm -r "$test_tmp/home/.local/share/applications" +env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \ + bash -euo pipefail "$ROOT/migrations/1791345484.sh" +[[ ! -d $test_tmp/home/.local/share/applications ]] || fail "the migration relies on the packaged Disktree launcher" +pass "the migration relies on the packaged Disktree launcher when no local launchers exist" From 2fa6d0ecc598800914ce0680cd4bc2b68dfc8318 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 12:03:54 +0200 Subject: [PATCH 13/20] Approve Grok migration removal automatically --- migrations/1790863209.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/1790863209.sh b/migrations/1790863209.sh index 9559e735..da7d8a3a 100644 --- a/migrations/1790863209.sh +++ b/migrations/1790863209.sh @@ -17,7 +17,7 @@ fi drop_npm_grok() { omarchy-cmd-present mise || return 0 if [[ -n $(mise ls -g "$npm_grok") ]]; then - mise unuse -g "$npm_grok" + mise unuse -y -g "$npm_grok" fi if [[ -n $(mise ls -i "$npm_grok") ]]; then mise uninstall -y --all "$npm_grok" From 402128b6a2f104495038f4d52b194ac3dbd2ed28 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 12:13:33 +0200 Subject: [PATCH 14/20] Automatically approve orphan removal during updates (#14428) --- bin/omarchy-update | 2 +- bin/omarchy-update-orphan-pkgs | 8 +++++--- docs/update-process.md | 6 +++--- test/shell.d/update-orphan-test.sh | 28 +++++++++++++++++++++++++--- test/shell.d/update-sequence-test.sh | 7 +++++++ 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-update b/bin/omarchy-update index 6e58f6dd..edb6410f 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -127,7 +127,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-system-pkgs omarchy-migrate - omarchy-update-orphan-pkgs + omarchy-update-orphan-pkgs -y omarchy-update-analyze-logs omarchy-update-status diff --git a/bin/omarchy-update-orphan-pkgs b/bin/omarchy-update-orphan-pkgs index 7869671a..d7405518 100755 --- a/bin/omarchy-update-orphan-pkgs +++ b/bin/omarchy-update-orphan-pkgs @@ -1,6 +1,8 @@ #!/bin/bash # omarchy:summary=Review and optionally remove orphaned system packages after updates +# omarchy:args=[-y] +# omarchy:examples=omarchy update orphan-pkgs | omarchy update orphan-pkgs -y # omarchy:requires-sudo=true set -e @@ -12,18 +14,18 @@ echo -e "\e[32m\nOrphan system packages\e[0m" printf ' %s\n' "${orphans[@]}" echo -if [[ ! -t 0 || ! -t 1 ]]; then +if [[ ${1:-} != "-y" && ( ! -t 0 || ! -t 1 ) ]]; then echo "${#orphans[@]} orphaned package(s) found. Re-run omarchy-update-orphan-pkgs in a terminal to review/remove them." echo exit 0 fi -if ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then +if [[ ${1:-} != "-y" ]] && ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then echo "Keeping orphaned packages." echo exit 0 fi echo -e "\e[32m\nRemoving orphan system packages\e[0m" -sudo pacman -Rns "${orphans[@]}" +omarchy-pkg-drop "${orphans[@]}" echo diff --git a/docs/update-process.md b/docs/update-process.md index b5e00925..3c7804c8 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -136,7 +136,7 @@ omarchy-update ├─ omarchy-update-stay-awake start ├─ run system-package updates ├─ run migrations - ├─ run orphan review and log analysis + ├─ automatically remove orphan packages and run log analysis ├─ omarchy-update-status │ └─ refresh or clear the shell update indicator ├─ restart marked services and the shell @@ -158,7 +158,7 @@ Important behavior: - This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. - Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal. - Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit. -- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately. +- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Conflict handoff reports and skips instead of blocking. Orphan packages are automatically removed during every update. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check. @@ -306,7 +306,7 @@ scripts. | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | | `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. | | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | -| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | +| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal unless passed `-y`, as the update pipeline does; standalone noninteractive mode only reports. | **Keep for now.** Automates cleanup during updates and supports standalone review. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | | `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | diff --git a/test/shell.d/update-orphan-test.sh b/test/shell.d/update-orphan-test.sh index 7d2b05cd..b2bc6715 100644 --- a/test/shell.d/update-orphan-test.sh +++ b/test/shell.d/update-orphan-test.sh @@ -23,7 +23,7 @@ SH } run_orphan_checker() { - HOME="$test_home" PATH="$stub_bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs" + HOME="$test_home" PATH="$stub_bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs" "$@" } write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then printf "old-lib\nunused-tool\n"; exit 0; fi; exit 1' @@ -35,7 +35,29 @@ grep -q '^ old-lib$' "$test_tmp/noninteractive.out" || fail "orphan checker lis grep -q 'Re-run omarchy-update-orphan-pkgs in a terminal' "$test_tmp/noninteractive.out" || fail "orphan checker does not remove packages non-interactively" pass "orphan checker only reports orphans non-interactively" +export REMOVAL_LOG="$test_tmp/removal.log" +write_stub pacman 'case $1 in + -Qtdq|-Qq) printf "old-lib\nunused-tool\n" ;; + -Rns) printf "%s\n" "$@" >"$REMOVAL_LOG" ;; + *) exit 1 ;; +esac' +write_stub sudo '"$@"' +run_orphan_checker -y >"$test_tmp/approved.out" 2>"$test_tmp/approved.err" +diff <(printf '%s\n' -Rns --noconfirm old-lib unused-tool) "$REMOVAL_LOG" || + fail "approved cleanup removes the orphan packages without pacman confirmation" +pass "approved cleanup removes orphans without Omarchy or pacman confirmation" + +write_stub sudo 'exit 42' +if run_orphan_checker -y >"$test_tmp/failure.out" 2>"$test_tmp/failure.err"; then + fail "approved cleanup reports removal failures" +fi +pass "approved cleanup reports removal failures" + +rm -f "$REMOVAL_LOG" write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then exit 0; fi; exit 1' -run_orphan_checker >"$test_tmp/none.out" 2>"$test_tmp/none.err" -[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist" +for mode in "" -y; do + run_orphan_checker $mode >"$test_tmp/none.out" 2>"$test_tmp/none.err" + [[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist" +done +[[ ! -e $REMOVAL_LOG ]] || fail "orphan checker does not remove packages when no orphans exist" pass "orphan checker stays quiet without orphans" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index 1f2a34bc..13fdfed1 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -35,6 +35,9 @@ for step in "${steps[@]}"; do cat >"$stub_bin/$step" <<'STUB' #!/bin/bash printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG" +if [[ ${0##*/} == "omarchy-update-orphan-pkgs" ]]; then + printf '%s\n' "$@" >"$STEP_LOG.orphan-args" +fi [[ ${FAILING_STEP:-} != "${0##*/}" ]] || exit 1 STUB chmod +x "$stub_bin/$step" @@ -84,6 +87,8 @@ run_update -y || fail "an update where everything works reports a failure" diff <(expected_steps) <(steps_run) >"$test_tmp/order" || fail "an update where everything works does not run every step in order" "$(cat "$test_tmp/order")" pass "an update where every step works runs all of them, in order" +[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "an unattended update approves orphan removal" +pass "an unattended update approves orphan removal" grep -q '^omarchy-update-system-pkgs unattended=1$' "$test_tmp/steps" || fail "-y does not mark the update unattended" @@ -93,6 +98,8 @@ diff <(expected_steps confirmed) <(steps_run) >"$test_tmp/order" || grep -q '^omarchy-update-system-pkgs unattended=$' "$test_tmp/steps" || fail "an update a person confirmed is treated as unattended" pass "-y is what marks an update unattended, not the update itself" +[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "a confirmed update approves orphan removal" +pass "a confirmed update approves orphan removal" # Migrations ship with the packages the upgrade installs and are written against # them. Running them against what is still on disk is the failure this ordering From 7b68fee056847e4be9debc5df7da3bac37194470 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:15:00 +0200 Subject: [PATCH 15/20] Add Slack to Install > Service (#14434) Slack is one of the most popular apps people install from the AUR. It now ships from the Omarchy package repository as slack-desktop, with native x86_64 and aarch64 builds. Install it the same way as Bitwarden: one menu entry that installs the package and then launches Slack. The commercial apps page of the manual gets a matching section. Co-authored-by: Claude Opus 5.5 (1M context) --- default/omarchy/omarchy-menu.jsonc | 1 + manual/24-commercial-apps-services.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 736cba1a..0b9ced63 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -227,6 +227,7 @@ "install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, "install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, "install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"}, + "install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"}, "install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"}, "install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"}, "install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"}, diff --git a/manual/24-commercial-apps-services.md b/manual/24-commercial-apps-services.md index 462c67ee..275c91a4 100644 --- a/manual/24-commercial-apps-services.md +++ b/manual/24-commercial-apps-services.md @@ -18,6 +18,10 @@ You start 1Password with `Super + Shift + /`. If it isn't installed yet, that ho You start Spotify using `Super + Shift + M`. Like 1Password, the hotkey kicks off the installation first if Spotify isn't installed yet (or use _Install > Service > Spotify_ from the Omarchy menu). +## Slack + +[Slack](https://slack.com/) is where a lot of teams do their chatting, both inside the company and with outside collaborators. Install it with _Install > Service > Slack_ from the Omarchy menu, and it'll open as soon as it's ready. + ## Dropbox [Dropbox](https://www.dropbox.com/) is a great way to sync files between machines while keeping a backup in the cloud. To set it up, select _Install > Service > Dropbox_ from the Omarchy menu. Once it's running, hover the tray in the top right of the bar and right-click the Dropbox icon to finish the setup. From b6f2c1cef7bbf3c3560b0d9e039d6f4c742b70bf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:15:38 +0200 Subject: [PATCH 16/20] Describe the agents panel's limit rows and sign-in link as they are (#14430) The manual still said the panel shows the percentage used and that an idle account keeps its last known limits, and the README that sign-in trouble is only text. Rows show the time to reset with the percentage on hover, and a lapsed sign-in is a Sign-in required link that signs the account in again. Co-authored-by: Claude Opus 5.5 --- manual/17-ai.md | 4 ++-- shell/plugins/agents/README.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index f740622a..aa661fff 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -38,7 +38,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre ### The agents panel -The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box. +The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan, a meter for each of its 5-hour session and weekly limits with the time until it resets (hover for the exact percentage), or the remaining prepaid balance, under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box. Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings. @@ -50,7 +50,7 @@ One account per provider is active, and every new session starts as it: `cx`, `c With more than one account, the panel shows each account's limits side by side, with the active one marked. By default Omarchy notifies you when the active account passes 95% of a limit, and clicking the notification switches to the account with the most headroom. Hover an account's line in the panel and click the Autoswitch that appears beside Use (or press `m`), or run `omarchy agent account mode auto`, to have it switch on its own instead; while it's on, Autoswitch takes Use's place, and clicking it again goes back to notifying. Add a number to change the threshold, as in `omarchy agent account mode auto 90`. If every account is over the threshold, you'll hear about it once, along with which account frees up first. While an active account is within 15 points of its threshold (80% at the default), its limits are checked every three minutes rather than every 15. -`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours shows its last known limits until a session runs in it again. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account. +`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours can show _Sign-in required_; click it to sign that account in again right in the panel, or start a session in it, which refreshes the sign-in on its own. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account. ### Crash diagnosis diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index d1081862..26af2f72 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -19,8 +19,9 @@ Every subscription on one page, limits first. per limit window: its meter and the time until it resets (the exact percentage on hover). A model-scoped allowance on the same clock (Claude's Fable weekly limit) is a tick on that window's meter rather than a line of its own; the - row's tooltip names it. Sign-in and endpoint trouble shows under the name in the urgent - color. Limits kept from an earlier check after a failed one dim, and their + row's tooltip names it. A lapsed or missing sign-in shows a _Sign-in required_ link that signs + that account in again from the panel; other endpoint trouble shows under the + name in the urgent color. Limits kept from an earlier check after a failed one dim, and their tooltip says how old they are. - **Accounts** — an agent with more than one subscription account (see `omarchy agent account`) lists each: name and plan on one line (the email on hover), and its own limit From 982290fa42aa45510b25c10127588d9eded8fcef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:41:30 +0200 Subject: [PATCH 17/20] Nicer order --- default/omarchy/omarchy-menu.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 0b9ced63..6cd399f3 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -185,8 +185,8 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"}, - "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, + "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, From d9b970dd626da8fdf8174dada67c5e564664e2f8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 14:41:27 +0200 Subject: [PATCH 18/20] Let users choose passwordless sudo duration (#14435) * Let users choose passwordless sudo duration * Warn in the menu bar when passwordless sudo is active * Fix sudo indicator hover behavior and repeated authentication * Disable passwordless sudo from the bar without a terminal * Shorten passwordless sudo indicator tooltip * Recover interrupted passwordless sudo duration switches * Allow sudo grant changes when listings require authentication * Start passwordless sudo setup with the duration question --- bin/omarchy-sudo-passwordless | 241 ++++++++++++++---- docs/passwordless-sudo.md | 16 +- manual/48-security.md | 4 +- .../bar/indicators/PasswordlessSudo.qml | 71 ++++++ .../bar/widgets/Indicators.manifest.json | 5 + shell/plugins/bar/widgets/Indicators.qml | 2 +- .../fixtures/passwordless-sudo-test.sh | 23 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 213 +++++++++++++++- .../passwordless-sudo-indicator-test.sh | 36 +++ 9 files changed, 552 insertions(+), 59 deletions(-) create mode 100644 shell/plugins/bar/indicators/PasswordlessSudo.qml create mode 100644 test/shell.d/passwordless-sudo-indicator-test.sh diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index b5f88a94..5164d71e 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,7 +1,7 @@ #!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. -# omarchy:args=[MINUTES] +# omarchy:args=[MINUTES|permanent|--active|--disable] # omarchy:requires-sudo=true if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then @@ -22,7 +22,6 @@ fi set -euo pipefail -readonly DEFAULT_MINUTES=15 readonly MAX_MINUTES=1440 readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -34,7 +33,7 @@ readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 usage() { - echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2 echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 exit 1 } @@ -43,6 +42,10 @@ valid_minutes() { [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) } +valid_duration() { + [[ $1 == "permanent" ]] || valid_minutes "$1" +} + valid_uid() { [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) } @@ -96,14 +99,39 @@ with_root_lock() { } rule_file() { - printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" + local uid=$((10#$1)) + if [[ ${2:-} == "permanent" ]]; then + printf '/etc/sudoers.d/99-omarchy-permanent-nopasswd-%s' "$uid" + else + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$uid" + fi } # The sudoers rule is the only grant record. A missing file is distinct from # an unreadable, unsafe, or administrator-modified file. read_grant() { - local file contents + local file permanent contents file=$(rule_file "$1") + permanent=$(rule_file "$1" permanent) + if [[ -e $permanent || -L $permanent ]]; then + verify_root_path "$permanent" && [[ -f $permanent ]] || return 2 + contents=$(/usr/bin/cat -- "$permanent") || return 2 + [[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOPASSWD:\ ALL$ ]] || return 2 + GRANT_NAME=${BASH_REMATCH[1]} + GRANT_DEADLINE="" + valid_account_name "$GRANT_NAME" || return 2 + if [[ -e $file || -L $file ]]; then + # A SIGKILL between publication and removal can leave both policies. + # Only accept a matching generated timed rule. Permanent access already + # exists, so old expiry callbacks must not revoke it; disable removes + # both files, and the next duration change finishes their replacement. + verify_root_path "$file" && [[ -f $file ]] || return 2 + contents=$(/usr/bin/cat -- "$file") || return 2 + [[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2 + [[ ${BASH_REMATCH[1]} == "$GRANT_NAME" ]] || return 2 + fi + return 0 + fi [[ -e $file || -L $file ]] || return "$STATUS_INACTIVE" verify_root_path "$file" && [[ -f $file ]] || return 2 contents=$(/usr/bin/cat -- "$file") || return 2 @@ -121,6 +149,9 @@ classify_generated_rule() { [[ -f $file && ! -L $file ]] || return 1 contents=$(/usr/bin/cat -- "$file") || return 2 suffix=${file##*/99-omarchy-nopasswd-} + if [[ $file == */99-omarchy-permanent-nopasswd-* ]]; then + suffix=${file##*/99-omarchy-permanent-nopasswd-} + fi # The legacy command wrote the caller's unvalidated name into both the # filename and the rule. That exact relationship is its fingerprint, so an @@ -138,14 +169,21 @@ classify_generated_rule() { valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] } -cleanup_uid_locked() { - local file - file=$(rule_file "$1") +cleanup_rule_file() { + local file=$1 [[ -e $file || -L $file ]] || return 0 verify_root_path "$file" && classify_generated_rule "$file" || return 1 /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]] } +cleanup_uid_locked() { + local file duration + for duration in temporary permanent; do + file=$(rule_file "$1" "$duration") + cleanup_rule_file "$file" || return 1 + done +} + # The generated prefix is reserved: boot cleanup and the package hook already # remove everything in it, and the legacy writer could produce a rule whose # body differs from its filename. Nothing unrecognized may stay live there, but @@ -263,6 +301,7 @@ migrate_locked() { expire_locked() { local status now if read_grant "$1"; then + [[ -n $GRANT_DEADLINE ]] || return 0 now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 [[ $now < $GRANT_DEADLINE ]] && return 0 cleanup_uid_locked "$1" @@ -281,6 +320,7 @@ status_locked() { resolve_account "$1" || return 2 if read_grant "$1"; then [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2 + [[ -n $GRANT_DEADLINE ]] || return 0 now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 if [[ $now < $GRANT_DEADLINE ]]; then return 0 @@ -300,7 +340,11 @@ finish_enable() { if cleanup_uid_locked "$uid"; then [[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true else - echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 + if [[ -n $timer ]]; then + echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 + else + echo "Could not revoke passwordless sudo. Administrator cleanup is required." >&2 + fi fi fi [[ -z $pending ]] || /usr/bin/rm -f -- "$pending" @@ -309,9 +353,15 @@ finish_enable() { enable_locked() ( local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status - resolve_account "$uid" && valid_minutes "$minutes" || return 1 - verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1 - file=$(rule_file "$uid") + resolve_account "$uid" && valid_duration "$minutes" || return 1 + verify_root_path /etc/sudoers.d || return 1 + if [[ $minutes != "permanent" ]]; then + verify_boot_cleanup || { + echo "Temporary sudo cleanup is unavailable or a settings package transaction is incomplete. Check the omarchy-settings installation." >&2 + return 1 + } + fi + file=$(rule_file "$uid" "$minutes") if read_grant "$uid"; then [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1 else @@ -320,39 +370,69 @@ enable_locked() ( fi trap finish_enable EXIT omarchy_security_install_signal_exit_traps - now=$(/usr/bin/date +%s) || return 1 - expires=$((now + 10#$minutes * 60)) - deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1 - /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + if [[ $minutes == "permanent" ]]; then + /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$ACCOUNT_NAME" >"$pending" || return 1 + else + now=$(/usr/bin/date +%s) || return 1 + expires=$((now + 10#$minutes * 60)) + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + fi /usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1 /usr/sbin/visudo -cf "$pending" >/dev/null || return 1 - token=$(/usr/bin/tr -d '-' &2 + return "$status" + fi ;; __disable) (($# == 1)) && verify_sudo_caller "$1" || return 1 @@ -382,6 +462,14 @@ root_dispatch() { esac } +show_enabled() { + if [[ $minutes == "permanent" ]]; then + echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it." + else + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." + fi +} + case "${1:-}" in __status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete) action=$1 @@ -391,9 +479,30 @@ case "${1:-}" in ;; esac +# Prefer matched policy tags to executing a privileged command, avoiding +# authentication/session logs under the default listing policy. Restrictive +# listpw settings require the root-status fallback. Neither path prompts or +# trusts cached credentials as evidence of passwordless access. +if [[ ${1:-} == "--active" ]]; then + (($# == 1)) || usage + uid=$(/usr/bin/id -u) + if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then + /usr/bin/grep -q '!authenticate' <<<"$policy" + else + # listpw=always can require authentication to list a passwordless grant. + # With a command, -k ignores cached credentials without invalidating them. + status=0 + /usr/bin/sudo -kn -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$? + # An internal inspection error still proves that sudo ran the helper + # without authentication. Keep the warning visible for unsafe policy. + (( status == 0 || status == 2 )) || exit 1 + fi + exit +fi + (($# <= 1)) || usage -minutes=${1:-$DEFAULT_MINUTES} -valid_minutes "$minutes" || usage +minutes=${1:-} +(($# == 0)) || [[ $minutes == "--disable" ]] || valid_duration "$minutes" || usage uid=$(/usr/bin/id -u) valid_uid "$uid" || { echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 @@ -411,38 +520,76 @@ omarchy_security_install_sudo_cleanup_traps exit 1 } -echo "Toggle passwordless sudo..." -if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then - if (($# == 0)); then - /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" - echo "Passwordless sudo has been DISABLED. Sudo will require a password again." - else - /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" - echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes." +# Inspect policy without authenticating. Only inspect the root-owned grant +# when policy already permits it without a password; otherwise the eventual +# enable action is the sole authentication boundary. enable_locked validates +# any existing policy before publishing, including expired or modified rules. +status=$STATUS_INACTIVE +if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then + if /usr/bin/grep -q '!authenticate' <<<"$policy"; then + status=0 + /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" || status=$? fi else - status=$? - if (( status != STATUS_INACTIVE )); then + # A listing password is independent of command authorization. An active + # grant can still be inspected/revoked without prompting under listpw=always. + status=0 + /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$? + if (( status == 1 )) && [[ $minutes != "--disable" ]]; then + # No noninteractive inspection is available. Offer the confirmed enable + # flow; its single authenticated action validates existing policy itself. + status=$STATUS_INACTIVE + fi +fi +if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then + /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __disable "$uid" + echo "Passwordless sudo has been DISABLED. Sudo will require a password again." +elif (( status == STATUS_INACTIVE )) && [[ $minutes == "--disable" ]]; then + echo "Passwordless sudo is already DISABLED." +elif (( status == 0 )) && [[ $minutes != "permanent" ]]; then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + show_enabled +else + if (( status != 0 && status != STATUS_INACTIVE )); then echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2 exit 1 fi + if [[ -z $minutes ]]; then + choice=$(/usr/bin/gum choose --header "How long should passwordless sudo stay enabled?" "15 minutes" "1 Hour" "1 Day" "Permanently") || exit 130 + case "$choice" in + "15 minutes") minutes=15 ;; + "1 Hour") minutes=60 ;; + "1 Day") minutes=1440 ;; + "Permanently") minutes=permanent ;; + *) exit 130 ;; + esac + fi + if [[ $minutes == "permanent" ]]; then + duration="permanently" + else + duration="for ${minutes} minutes" + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." + echo "execute ANY command as root WITHOUT a password $duration." echo "" echo "This is useful for AI agents that need to run sudo commands," echo "but it significantly weakens the security of your system." echo "Anyone or anything with access to your user account gets full root." echo "" - echo "Passwordless sudo will automatically disable after ${minutes} minutes," - echo "including if the machine reboots before the deadline." - echo "Run this command again to disable it early." + if [[ $minutes == "permanent" ]]; then + echo "Passwordless sudo will remain enabled across reboots until you disable it." + else + echo "Passwordless sudo will automatically disable after ${minutes} minutes," + echo "including if the machine reboots before the deadline." + fi + echo "Run this command again to disable it." echo "" - if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then + if /usr/bin/gum confirm "Enable passwordless sudo $duration? This is a significant security risk!"; then /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" echo "" - echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." + show_enabled else echo "Aborted. No changes made." fi diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 4aa6e31d..8ff7c48a 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -1,24 +1,28 @@ -# Temporary passwordless sudo +# Passwordless sudo -`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. +`omarchy-sudo-passwordless` publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. + +The menu bar's `PasswordlessSudo` indicator polls `--active` every five seconds and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow. ## Grant lifecycle -The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed. +The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed. -An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists. +An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface offers a duration picker after result `3`. Result `0` toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists. Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content. +Permanent grants live under `/etc/sudoers.d/99-omarchy-permanent-nopasswd-`, outside the temporary cleanup namespace. They have no `NOTAFTER` deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward. + ## Package ownership The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. -Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. +Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing temporary policy. The marker prevents a waiting publisher from creating a new timed grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New timed grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another timed grant. -The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. +The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. ## Validation diff --git a/manual/48-security.md b/manual/48-security.md index 6db0dff5..60d04745 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ asks how long to allow access: **15 minutes**, **1 Hour**, **1 Day**, or **Permanently**. A red warning icon appears beside the other menu bar indicators while access is active. Click it or run the command again to turn access off. You can also pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30`, or use `omarchy-sudo-passwordless permanent`. + +Timed access expires automatically, including immediately after resuming from a suspend that crossed the deadline. Restarting the computer ends it early. Permanent access survives reboots and stays enabled until you disable it. Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive. diff --git a/shell/plugins/bar/indicators/PasswordlessSudo.qml b/shell/plugins/bar/indicators/PasswordlessSudo.qml new file mode 100644 index 00000000..e91b1b53 --- /dev/null +++ b/shell/plugins/bar/indicators/PasswordlessSudo.qml @@ -0,0 +1,71 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarIndicator { + id: root + + property bool granted: false + property bool refreshPending: false + + active: granted + activeText: "󰟵" + inactiveText: "󰟵" + activeTooltipText: "Disable Passwordless Sudo" + inactiveTooltipText: "Passwordless Sudo" + useActiveColor: true + activeColor: Color.urgent + + function refresh() { + if (!root.bar) return + if (statusProc.running) { + root.refreshPending = true + return + } + root.refreshPending = false + statusProc.running = true + } + + onBarChanged: refresh() + Component.onCompleted: refresh() + + Connections { + target: root.indicatorHost + ignoreUnknownSignals: true + function onRefreshRequested() { root.refresh() } + } + + Timer { + interval: 5000 + repeat: true + running: !!root.bar + onTriggered: root.refresh() + } + + Process { + id: statusProc + command: ["omarchy-sudo-passwordless", "--active"] + onExited: function(exitCode, exitStatus) { + root.granted = exitCode === 0 && exitStatus === 0 + if (root.refreshPending) Qt.callLater(root.refresh) + } + } + + Process { + id: disableProc + command: ["omarchy-sudo-passwordless", "--disable"] + onExited: function(exitCode, exitStatus) { + if ((exitCode !== 0 || exitStatus !== 0) && root.bar) + root.bar.run('omarchy-notification-send "Could not disable passwordless sudo" "Check the sudo configuration and try again."') + if (root.indicatorHost) root.indicatorHost.refresh() + else root.refresh() + } + } + + onPressed: function() { + if (!root.bar || disableProc.running) return + if (root.granted) disableProc.running = true + else root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless") + } +} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 4d8f7420..4296c7f3 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -26,6 +26,11 @@ "placeholderText": "Search indicators...", "emptyText": "No indicators", "options": [ + { + "value": "PasswordlessSudo", + "label": "Passwordless sudo", + "description": "Warning while passwordless root access is enabled" + }, { "value": "Dictation", "label": "Dictation", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index c4fd9224..c2e05cb2 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/test/shell.d/fixtures/passwordless-sudo-test.sh b/test/shell.d/fixtures/passwordless-sudo-test.sh index 3f4f0090..54a90948 100644 --- a/test/shell.d/fixtures/passwordless-sudo-test.sh +++ b/test/shell.d/fixtures/passwordless-sudo-test.sh @@ -52,7 +52,9 @@ case "$name" in ;; mv) [[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1 + [[ ${TEST_REQUIRE_EXISTING_TARGET:-0} != 1 || -f ${@: -1} ]] || exit 1 /usr/bin/mv "$@" + [[ ${TEST_KILL_AFTER_PUBLISH:-0} != 1 ]] || /usr/bin/kill -KILL "$PPID" [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" ;; systemd-run) @@ -69,7 +71,15 @@ case "$name" in sudo) if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi if [[ ${1:-} == -k ]]; then exit 0; fi - if [[ ${1:-} == -N ]]; then shift; fi + if [[ ${1:-} == -n && ${3:-} == -l ]]; then + [[ ${TEST_POLICY_FAILURE:-0} != 1 ]] || exit 1 + default_policy=' Options: authenticate' + [[ ${TEST_STATUS:-3} == 3 ]] || default_policy=' Options: !authenticate' + printf '%s\n' "${TEST_POLICY:-$default_policy}" + exit 0 + fi + if [[ ${1:-} == -n ]]; then shift; fi + if [[ ${1:-} == -N || ${1:-} == -kn ]]; then shift; fi if [[ ${1:-} == -- ]]; then shift; fi if [[ ${TEST_MIGRATION:-0} == 1 ]]; then [[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1 @@ -78,7 +88,14 @@ case "$name" in [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" fi ;; - gum) exit 1 ;; + gum) + if [[ $1 == choose ]]; then + [[ ${TEST_CHOICE_CANCEL:-0} != 1 ]] || exit 130 + printf '%s\n' "${TEST_CHOICE:-15 minutes}" + else + exit "${TEST_CONFIRM_STATUS:-1}" + fi + ;; *) exit 99 ;; esac STUB @@ -120,6 +137,6 @@ assert_status() { (( actual == expected )) || fail "expected status $expected, got $actual from $*" } reset_grant() { - rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing" + rm -f "$test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-1000" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing" : >"$test_tmp/commands" } diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index d6b1a505..bc123a65 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -32,7 +32,7 @@ for status in 1 2 3; do else (( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement" fi - grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization" + grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" || fail "status must inspect policy without authentication" [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization" done pass "public status distinguishes inactive from errors and revokes authorization on exit" @@ -164,3 +164,214 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/ [[ ! -e $(rule_file 1000) ]] ) pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish" + +reset_grant +( + source "$library" + valid_duration permanent + ! valid_duration forever || fail "invalid duration was accepted" + enable_locked 1000 permanent + read_grant 1000 + [[ $GRANT_NAME == audituser && -z $GRANT_DEADLINE ]] + [[ $(stat -c '%a' "$(rule_file 1000 permanent)") == 440 ]] + /usr/sbin/visudo -cf "$(rule_file 1000 permanent)" >/dev/null + ! grep -q '^systemd-run ' "$test_tmp/commands" || fail "permanent grant started a timer" + TEST_EXPIRED=1 status_locked 1000 + TEST_EXPIRED=1 expire_locked 1000 + cleanup_all_locked + /usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' + [[ -f $(rule_file 1000 permanent) ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 1000 +) +pass "permanent access has no timer, survives expiry and temporary cleanup, and can be disabled" + +reset_grant +( + source "$library" + enable_locked 1000 15 + enable_locked 1000 permanent + [[ ! -e $(rule_file 1000) && -e $(rule_file 1000 permanent) ]] + TEST_EXPIRED=1 expire_locked 1000 + [[ -e $(rule_file 1000 permanent) ]] + enable_locked 1000 60 + [[ -e $(rule_file 1000) && ! -e $(rule_file 1000 permanent) ]] + TEST_EXPIRED=1 expire_locked 1000 + assert_status 3 status_locked 1000 + TEST_PUBLISH_FAIL=1 assert_status 1 enable_locked 1000 permanent + [[ ! -e $(rule_file 1000 permanent) ]] +) +pass "switching duration replaces the prior policy and old callbacks preserve permanent access" + +reset_grant +( + source "$library" + permanent=$(rule_file 1000 permanent) + printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$permanent" + assert_status 2 status_locked 1000 + assert_status 1 enable_locked 1000 permanent + assert_status 1 cleanup_uid_locked 1000 + [[ -e $permanent ]] + rm "$permanent" + enable_locked 1000 permanent + TEST_BAD_PATH="$permanent" assert_status 2 status_locked 1000 +) +pass "permanent policy rejects unsafe ownership and preserves administrator edits" + +for choice in '15 minutes' '1 Hour' '1 Day' Permanently; do + case "$choice" in + '15 minutes') expected=15 ;; + '1 Hour') expected=60 ;; + '1 Day') expected=1440 ;; + Permanently) expected=permanent ;; + esac + : >"$test_tmp/commands" + TEST_CHOICE="$choice" TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 + grep -q '^gum choose .*15 minutes 1 Hour 1 Day Permanently$' "$test_tmp/commands" + grep -q "^sudo -N -- .* __enable .* $expected$" "$test_tmp/commands" + [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] + if [[ $expected == permanent ]]; then + grep -q 'remain enabled across reboots until you disable it' "$test_tmp/public.log" + ! grep -q 'automatically disable' "$test_tmp/public.log" || fail "permanent grant claims automatic expiry" + fi +done +pass "each duration choice dispatches its exact grant duration with permanent-specific copy" + +: >"$test_tmp/commands" +assert_status 130 env TEST_CHOICE_CANCEL=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +! grep -q '__enable\|^gum confirm ' "$test_tmp/commands" || fail "cancelled picker continued enablement" +[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] +: >"$test_tmp/commands" +TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +! grep -q '^gum ' "$test_tmp/commands" || fail "disabling access offered a picker" +grep -q '__disable ' "$test_tmp/commands" +pass "cancelling the picker grants nothing and active access still toggles off" + +for confirm_status in 0 1; do + : >"$test_tmp/commands" + TEST_STATUS=0 TEST_CONFIRM_STATUS=$confirm_status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" permanent >"$test_tmp/public.log" 2>&1 + grep -q '^gum confirm Enable passwordless sudo permanently?' "$test_tmp/commands" + if (( confirm_status == 0 )); then + grep -q '__enable .* permanent$' "$test_tmp/commands" + else + ! grep -q '__enable ' "$test_tmp/commands" || fail "declining permanent access still enabled it" + fi +done +pass "changing active access to permanent always requires confirmation" + +reset_grant +( + source "$library" + enable_locked 1000 15 + TEST_REQUIRE_EXISTING_TARGET=1 enable_locked 1000 60 + cleanup_uid_locked 1000 + enable_locked 01000 permanent + status_locked 1000 + [[ -e $(rule_file 1000 permanent) ]] + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-01000 ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 01000 +) +pass "renewal replaces the existing rule atomically and zero-padded UIDs share one policy" + + +for policy in ' Options: !authenticate' ' Options: authenticate'; do + : >"$test_tmp/commands" + expected=1 + [[ $policy != *'!authenticate'* ]] || expected=0 + assert_status "$expected" env TEST_POLICY="$policy" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active + [[ $(wc -l <"$test_tmp/commands") == 1 ]] + grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" +done +assert_status 1 env TEST_POLICY_FAILURE=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +pass "menu probe reads policy tags without executing a privileged action or changing the timestamp" + +for status in 0 3; do + : >"$test_tmp/commands" + TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 + ! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "explicit disable offered enablement" + if (( status == 0 )); then + grep -q '__disable ' "$test_tmp/commands" + fi +done +pass "explicit disable never enables access even when a displayed grant has expired" + +: >"$test_tmp/commands" +TEST_STATUS=3 TEST_CHOICE='15 minutes' TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "enable must authenticate exactly one sudo call" +grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands" +! grep -q '^sudo -n -N -- .* __status ' "$test_tmp/commands" || fail "inactive flow attempted root status" +[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] +pass "enabling from inactive policy has exactly one password-capable sudo invocation" + +: >"$test_tmp/commands" +TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 +grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" || fail "disable must refuse interactive authentication" +! grep -q '^sudo -N -- ' "$test_tmp/commands" || fail "disable attempted an interactive sudo command" +pass "disabling active access is fully noninteractive" + +for duration in permanent 15; do + reset_grant + ( + source "$library" + if [[ $duration == permanent ]]; then + enable_locked 1000 15 + else + enable_locked 1000 permanent + fi + assert_status 137 env TEST_KILL_AFTER_PUBLISH=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 "$duration" + [[ -f $(rule_file 1000) && -f $(rule_file 1000 permanent) ]] + status_locked 1000 + TEST_EXPIRED=1 expire_locked 1000 + [[ -f $(rule_file 1000 permanent) ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 1000 + ) +done +pass "SIGKILL during either duration switch leaves access inspectable and revocable without expiring permanent policy" + +reset_grant +( + source "$library" + enable_locked 1000 permanent + timed=$(rule_file 1000) + printf 'otheruser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + assert_status 2 read_grant 1000 + assert_status 1 enable_locked 1000 15 + printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + TEST_BAD_PATH="$timed" assert_status 2 read_grant 1000 + rm "$timed" + ln -s "$(rule_file 1000 permanent)" "$timed" + assert_status 2 read_grant 1000 + rm "$timed" + printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + enable_locked 1000 15 + [[ -f $timed && ! -e $(rule_file 1000 permanent) ]] +) +pass "paired policy recovery rejects mismatched or unsafe rules and allows a complete duration change" + +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 +grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" +! grep -q '^gum\|^sudo -N -- ' "$test_tmp/commands" || fail "listpw=always disable prompted" +TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=1 TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 +[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "listing failure must leave one enable authentication" +grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands" +: >"$test_tmp/commands" +assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 +! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "unsafe grant was offered enablement" +pass "password-required listings do not block enabling, disabling, or active detection" + +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +grep -q '^sudo -kn -- .* __status ' "$test_tmp/commands" || fail "fallback probe must ignore cached credentials" +( + source "$library" + with_root_lock() { return 1; } + verify_sudo_caller() { return 0; } + assert_status 2 root_dispatch __status 1000 +) +pass "status lock failures remain inspection errors and an authenticated unsafe grant keeps its warning" diff --git a/test/shell.d/passwordless-sudo-indicator-test.sh b/test/shell.d/passwordless-sudo-indicator-test.sh new file mode 100644 index 00000000..129b3b82 --- /dev/null +++ b/test/shell.d/passwordless-sudo-indicator-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const read = name => fs.readFileSync(path.join(root, name), 'utf8') +const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml') +const widget = read('shell/plugins/bar/widgets/Indicators.qml') +const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json')) +assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray') +assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light') +assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color') +assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states') +assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks') +assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior') +assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--active"]'), 'indicator uses the noninteractive grant probe') +assert(indicator.includes('root.granted = exitCode === 0 && exitStatus === 0'), 'failed or interrupted probes do not claim an active grant') +assert(indicator.includes('interval: 5000') && indicator.includes('onTriggered: root.refresh()'), 'indicator refreshes after activation, revocation, and expiry') +assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--disable"]'), 'active indicator revokes access through a background process') +assert(indicator.includes('if (root.granted) disableProc.running = true'), 'active click bypasses the terminal launcher') +assert(indicator.includes('root.indicatorHost.refresh()'), 'disabling access refreshes all indicator instances immediately') +const press = new Function('root', 'disableProc', indicator.match(/onPressed: function\(\) \{([\s\S]*?)\n \}/)[1]) +const launched = [] +const button = { granted: true, bar: { run: command => launched.push(command) } } +const revoke = { running: false } +press(button, revoke) +assert(revoke.running && launched.length === 0, 'clicking active sudo starts background revocation without a terminal') +button.granted = false +press(button, revoke) +assert(launched.length === 0, 'repeat clicks during revocation cannot open the enable flow') +revoke.running = false +press(button, revoke) +assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup') +JS From 0a55a3c5544931bd314342e2a1f00bada577c574 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 19:42:41 +0200 Subject: [PATCH 19/20] Keep overlays sharp without parked surfaces (#14458) * Keep overlays sharp without parked surfaces * Refresh overlay monitor scale after live changes * Reset menu layout when its monitor changes --- default/hypr/apps/omarchy-shell.lua | 7 +- shell/Ui/OverlayWindow.qml | 81 ++++++++---- shell/plugins/menu/Menu.qml | 4 +- test/acceptance.d/base-test.sh | 8 +- test/acceptance.d/menu-test.sh | 2 +- test/acceptance.d/overlay-monitor-test.sh | 107 ++++++++++++++++ test/acceptance.d/shell-surfaces-test.sh | 14 +-- test/shell.d/image-picker-test.sh | 2 +- test/shell.d/overlay-window-test.sh | 143 ++++++++++++++++++---- 9 files changed, 299 insertions(+), 69 deletions(-) create mode 100644 test/acceptance.d/overlay-monitor-test.sh diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index a06630f8..e0b31856 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -6,10 +6,9 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation -- Launcher, image selector, emojis, clipboard overlays, the OSD, reminders, the -- Wi-Fi QR code and keyboard-driven panels should pop without compositor layer --- animations. The overlays stay mapped between opens and grow from a parked --- 1x1 when shown, which Hyprland would otherwise animate as a slide in from --- the corner. Panels keep their own QML opacity transition for normal --- open/close, and skip it for panel handoff. +-- animations. Overlays open as soon as their scale is ready, without a +-- compositor fade or slide. Panels keep their own QML opacity transition +-- for normal open/close, and skip it for panel handoff. hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel|omarchy-osd|omarchy-reminders|omarchy-network-qr)$" }, no_anim = true, animation = "none" }) -- Dev gallery is the main shell workbench; open it maximized like diff --git a/shell/Ui/OverlayWindow.qml b/shell/Ui/OverlayWindow.qml index 43a9c32c..bbca91e6 100644 --- a/shell/Ui/OverlayWindow.qml +++ b/shell/Ui/OverlayWindow.qml @@ -3,12 +3,9 @@ import Quickshell import Quickshell.Hyprland import Quickshell.Wayland -// A fullscreen overlay whose surface outlives each open. A fresh surface draws -// its first frames before the compositor sends its fractional scale, so an -// overlay mapped per open flashed blurry until the scale arrived. Closed, the -// surface parks as a 1x1, input-less, content-less layer below windows: small -// enough to cost nothing, off the overlay layer so it never blocks direct -// scanout for fullscreen apps. Opening only resizes and raises it. +// Map a fresh fullscreen surface on each open. Keep its first frames +// transparent until Qt receives the output's scale, so it opens sharp without +// retaining a parked surface that can be closed when a monitor disconnects. PanelWindow { id: window @@ -17,11 +14,31 @@ PanelWindow { property int shownLayer: WlrLayer.Overlay property int shownKeyboardFocus: WlrKeyboardFocus.Exclusive - // The surface no longer lands on the focused output by being mapped there, - // so it follows the focused monitor each time it is shown. Unset until the - // first show lets the compositor choose. property var targetScreen: null - property Region emptyRegion: Region {} + readonly property var targetMonitor: { + var monitors = Hyprland.monitors.values + for (var i = 0; i < monitors.length; i++) { + if (targetScreen && monitors[i].name === targetScreen.name) return monitors[i] + } + return null + } + + // Wait for fullscreen geometry as well as scale so no smaller opening frame + // is stretched across the output. Fractional scale uses units of 1/120; + // compare in those units to tolerate floating point rounding. + readonly property bool contentReady: shown && backingWindowVisible && !!targetScreen + && width === targetScreen.width && height === targetScreen.height + && !!targetMonitor && Math.round(devicePixelRatio * 120) === Math.round(targetMonitor.scale * 120) + property bool contentRevealed: false + + onContentReadyChanged: { + contentRevealed = false + // Resize and scale notifications arrive while Qt is still updating the + // window. Reveal on the next turn, once its content layout has caught up. + if (contentReady) Qt.callLater(function() { + if (window.contentReady) window.contentRevealed = true + }) + } function focusedScreen() { var monitor = Hyprland.focusedMonitor @@ -29,28 +46,44 @@ PanelWindow { for (var i = 0; i < Quickshell.screens.length; i++) { if (Quickshell.screens[i].name === name) return Quickshell.screens[i] } - return null + return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null } - onShownChanged: if (shown) targetScreen = focusedScreen() || targetScreen + // Live monitor scaling does not emit a Hyprland monitor event, so its cached + // scale can lag behind Qt. Refresh on opening and on scale changes while open. + onShownChanged: { + targetScreen = shown ? focusedScreen() : null + if (shown) Hyprland.refreshMonitors() + } + onDevicePixelRatioChanged: if (shown) Hyprland.refreshMonitors() - visible: true + Connections { + target: Quickshell + function onScreensChanged() { + if (window.shown && Quickshell.screens.indexOf(window.targetScreen) < 0) { + window.targetScreen = null + // Let the removed output finish closing its old window before mapping + // a replacement, rather than recreating it during Qt's teardown. + Qt.callLater(function() { + if (window.shown && !window.targetScreen) window.targetScreen = window.focusedScreen() + }) + } + } + } + + visible: shown && !!targetScreen && !!targetMonitor screen: targetScreen - anchors { top: true; left: true; bottom: shown; right: shown } - implicitWidth: 1 - implicitHeight: 1 - mask: shown ? null : emptyRegion + anchors { top: true; left: true; bottom: true; right: true } color: "transparent" exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom - WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None + WlrLayershell.layer: shownLayer + WlrLayershell.keyboardFocus: shownKeyboardFocus - // Draw nothing until the surface has actually grown. A frame drawn while it - // is still 1x1 holds only the scrim's color, which the compositor would - // stretch across the whole screen until the fullscreen frame arrives. + // Opacity preserves keyboard handling while the scale arrives, including + // search keystrokes and Escape pressed immediately after opening. Binding { target: window.contentItem - property: "visible" - value: window.shown && window.width > 1 && window.height > 1 + property: "opacity" + value: window.contentRevealed ? 1 : 0 } } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 1625375e..59ad1167 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -1075,7 +1075,7 @@ Item { // then on the card grows and shrinks downward instead of re-centering // on every resize, which made the menu jump around. The rows height is // frozen at the same moment, so the starting menu also caps how tall the - // card may grow from there. Closing unfreezes both. + // card may grow from there. Closing or changing screens unfreezes both. property int cardTop: -1 property int maxRowsHeight: -1 readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2)) @@ -1086,8 +1086,8 @@ Item { maxRowsHeight = root.visibleRowsHeight } } - // The surface stays mapped between opens, so closing is shown going false. onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 } + onTargetScreenChanged: { cardTop = -1; maxRowsHeight = -1 } Rectangle { anchors.fill: parent diff --git a/test/acceptance.d/base-test.sh b/test/acceptance.d/base-test.sh index 7bc9a1af..23ca1402 100644 --- a/test/acceptance.d/base-test.sh +++ b/test/acceptance.d/base-test.sh @@ -81,17 +81,11 @@ layer_absent() { ! layer_present "$1" } -# Overlays built on OverlayWindow (the menu, image selector, emoji picker, -# clipboard and the like) stay mapped between opens, parked 1x1 on the bottom -# layer, so they keep their fractional scale. Showing means on the overlay. +# Fullscreen overlays map on the overlay layer and unmap when closed. layer_on_overlay() { hyprctl -j layers | jq -e --arg ns "$1" '[.[].levels["3"][]? | select(.namespace == $ns)] | length > 0' } -layer_off_overlay() { - ! layer_on_overlay "$1" -} - # A layer can be mapped but parked off the monitor: the bar hides that way so # revealing it does not have to rebuild the surface. Assert on geometry when # what matters is that the user can actually see it. Layer boxes are local to diff --git a/test/acceptance.d/menu-test.sh b/test/acceptance.d/menu-test.sh index 64bdd0ff..c9fa806f 100644 --- a/test/acceptance.d/menu-test.sh +++ b/test/acceptance.d/menu-test.sh @@ -86,7 +86,7 @@ screenshot "success-menu-05-position-submenu" wtype -k Down -k Down -k Return wait_until "menu bar position changes to left" 20 bar_position_is "left" wait_until "menu bar becomes vertical" 20 bar_is_vertical -wait_until "menu closes after selecting a position" 15 layer_off_overlay "omarchy-menu" +wait_until "menu closes after selecting a position" 15 layer_absent "omarchy-menu" screenshot "success-menu-06-bar-left" if ((config_existed)); then diff --git a/test/acceptance.d/overlay-monitor-test.sh b/test/acceptance.d/overlay-monitor-test.sh new file mode 100644 index 00000000..6a97113f --- /dev/null +++ b/test/acceptance.d/overlay-monitor-test.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +original_monitors=$(hyprctl -j monitors) +remaining_monitor=$(jq -r 'map(select(.focused))[0].name // .[0].name' <<<"$original_monitors") +remaining_height=$(jq -r --arg name "$remaining_monitor" '.[] | select(.name == $name) | (if (.transform // 0) % 2 == 1 then .width else .height end) / .scale | ceil' <<<"$original_monitors") +# Make the source tall enough that a frozen centered menu would land below +# the remaining display, so a mapped but misplaced menu cannot pass recovery. +tall_height=$((remaining_height * 2 + 1600)) +virtual_monitor="" + +cleanup() { + omarchy-shell shell hide omarchy.menu >/dev/null 2>&1 || true + if [[ -n $virtual_monitor ]]; then + hyprctl output remove "$virtual_monitor" >/dev/null 2>&1 || true + fi + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +find_virtual_monitor() { + virtual_monitor=$(hyprctl -j monitors | jq -r --argjson previous "$original_monitors" '[.[].name | select(. as $name | $previous | map(.name) | index($name) == null)][0] // empty') + [[ -n $virtual_monitor ]] +} + +monitor_is_tall() { + hyprctl -j monitors | jq -e --arg name "$virtual_monitor" --argjson height "$tall_height" 'any(.[]; .name == $name and .height == $height and .scale == 1)' +} + +monitor_is_focused() { + hyprctl -j monitors | jq -e --arg name "$1" 'any(.[]; .name == $name and .focused)' +} + +menu_on_monitor() { + hyprctl -j layers | jq -e --arg name "$1" '[.[$name].levels["3"][]? | select(.namespace == "omarchy-menu")] | length == 1' +} + +monitor_contains() { + local monitor="$1" text="$2" + local snapshot="$ARTIFACTS/monitor-text-$$.png" + local status=0 + + if timeout 10 grim -o "$monitor" -s 2 "$snapshot" 2>/dev/null; then + tesseract "$snapshot" stdout --psm 11 2>/dev/null | grep -Fi -- "$text" >/dev/null || status=$? + else + status=1 + fi + rm -f "$snapshot" + return "$status" +} + +create_source_monitor() { + hyprctl output create headless >/dev/null + wait_until "temporary overlay monitor appears" 15 find_virtual_monitor + hyprctl eval "hl.monitor({ output = \"$virtual_monitor\", mode = \"1920x${tall_height}@60\", position = \"auto\", scale = 1 })" >/dev/null + wait_until "temporary overlay monitor has tall geometry" 15 monitor_is_tall + hyprctl dispatch "hl.dsp.focus({ monitor = \"$virtual_monitor\" })" >/dev/null + wait_until "temporary overlay monitor is focused" 15 monitor_is_focused "$virtual_monitor" +} + +remove_source_monitor() { + hyprctl output remove "$virtual_monitor" >/dev/null + virtual_monitor="" + hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null + wait_until "remaining monitor is focused" 15 monitor_is_focused "$remaining_monitor" +} + +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu starts closed" 15 layer_absent "omarchy-menu" + +# A closed overlay must drop its old output and reopen on a connected one. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "menu opens on temporary monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "menu renders on temporary monitor" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-01-closed-source" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "menu unmaps before monitor removal" 15 layer_absent "omarchy-menu" +remove_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "closed menu reopens on remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "reopened menu renders on remaining monitor" 15 monitor_contains "$remaining_monitor" "Apps" +screenshot "success-overlay-monitor-02-closed-recovered" +omarchy-shell shell hide omarchy.menu >/dev/null +wait_until "reopened menu closes" 15 layer_absent "omarchy-menu" + +# Typing freezes the menu's position. Recovery onto a shorter screen must +# unfreeze that position while preserving the search and keyboard handling. +create_source_monitor +omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null +wait_until "open recovery menu maps on tall monitor" 15 menu_on_monitor "$virtual_monitor" +wait_until "open recovery menu renders" 15 monitor_contains "$virtual_monitor" "Apps" +screenshot "success-overlay-monitor-03-open-root" +wtype "keybindings" +wait_until "menu search renders on tall monitor" 15 monitor_contains "$virtual_monitor" "Keybindings" +screenshot "success-overlay-monitor-04-open-search" +remove_source_monitor +wait_until "open menu moves to remaining monitor" 15 menu_on_monitor "$remaining_monitor" +wait_until "open menu search renders on shorter monitor" 15 monitor_contains "$remaining_monitor" "Keybindings" +screenshot "success-overlay-monitor-05-open-recovered" +wtype -k Escape +wait_until "recovered menu clears search through keyboard" 15 monitor_contains "$remaining_monitor" "Apps" +wtype -k Escape +wait_until "recovered menu closes through keyboard" 15 layer_absent "omarchy-menu" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index 7d7630ed..88a0ce79 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -29,7 +29,7 @@ wtype "rocket" sleep 1 screenshot "success-emoji-picker-search" wtype -k Return -wait_until "emoji picker selection closes" 15 layer_off_overlay "omarchy-emojis" +wait_until "emoji picker selection closes" 15 layer_absent "omarchy-emojis" # Seed two clipboard entries, search for the older one, and copy it back out. clipboard_token="Omarchy acceptance clipboard $(date +%s)" @@ -44,7 +44,7 @@ wtype "$clipboard_token" wait_until "clipboard search finds test text" 15 screen_contains "Omarchy acceptance clipboard" screenshot "success-clipboard-search" wtype -M shift -k Return -m shift -wait_until "clipboard selection closes" 15 layer_off_overlay "omarchy-clipboard" +wait_until "clipboard selection closes" 15 layer_absent "omarchy-clipboard" wait_until "clipboard selection restores test text" 15 bash -c '[[ $(wl-paste --no-newline) == "$1" ]]' _ "$clipboard_token" # Exercise the system branch without invoking any destructive action. @@ -53,7 +53,7 @@ wait_until "system menu opens" 15 layer_on_overlay "omarchy-menu" wait_until "system menu content is visible" 15 screen_contains "Shutdown" screenshot "success-system-menu" wtype -k Escape -wait_until "system menu closes" 15 layer_off_overlay "omarchy-menu" +wait_until "system menu closes" 15 layer_absent "omarchy-menu" # Preview both visual selectors and cancel without changing user state. These # cover thumbnail generation, the image-grid overlay, and current selection. @@ -62,14 +62,14 @@ wait_until "background selector opens" 30 layer_on_overlay "omarchy-image-select sleep 1 screenshot "success-background-selector" wtype -k Escape -wait_until "background selector closes" 15 layer_off_overlay "omarchy-image-selector" +wait_until "background selector closes" 15 layer_absent "omarchy-image-selector" launch_app "omarchy-theme-switcher" wait_until "theme selector opens" 30 layer_on_overlay "omarchy-image-selector" sleep 1 screenshot "success-theme-selector" wtype -k Escape -wait_until "theme selector closes" 15 layer_off_overlay "omarchy-image-selector" +wait_until "theme selector closes" 15 layer_absent "omarchy-image-selector" # Walk the reminder flow through each input screen, but dismiss before it # schedules a real timer in the test user's session. @@ -83,7 +83,7 @@ wtype -k Return wait_until "reminder message prompt opens" 15 screen_contains "Reminder message" screenshot "success-reminder-03-message-prompt" wtype -k Escape -wait_until "reminder flow closes" 15 layer_off_overlay "omarchy-reminders" +wait_until "reminder flow closes" 15 layer_absent "omarchy-reminders" # Render a real shell notification and clear it through the notification IPC. omarchy-shell notifications dismissAll >/dev/null @@ -111,7 +111,7 @@ screenshot "success-apps-menu-search" wtype -k Return wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite" -wait_until "apps menu closes after launching" 15 layer_off_overlay "omarchy-menu" +wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu" close_windows "(?i)omawrite" wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite" diff --git a/test/shell.d/image-picker-test.sh b/test/shell.d/image-picker-test.sh index c1cfc03e..a18ac870 100644 --- a/test/shell.d/image-picker-test.sh +++ b/test/shell.d/image-picker-test.sh @@ -147,7 +147,7 @@ assert( ) assert( /OverlayWindow \{\s*id: panel\s*shown: root\.opened\s*shownKeyboardFocus: root\.imagesLoaded \? WlrKeyboardFocus\.Exclusive : WlrKeyboardFocus\.None/.test(imagePickerQml), - 'image picker parks on OverlayWindow and takes the keyboard once images load' + 'image picker uses OverlayWindow and takes the keyboard once images load' ) assert( /model: visibleImages/.test(imagePickerQml) && diff --git a/test/shell.d/overlay-window-test.sh b/test/shell.d/overlay-window-test.sh index 969d2f86..51b57c5d 100644 --- a/test/shell.d/overlay-window-test.sh +++ b/test/shell.d/overlay-window-test.sh @@ -9,31 +9,122 @@ const fs = require('fs') const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') const overlay = read('shell/Ui/OverlayWindow.qml') -// Closed, the surface parks 1x1, input-less and without keyboard below -// windows: off the overlay layer, so it never blocks direct scanout. +// Closing must unmap, not leave a surface on an output that may disappear. assert( - overlay.includes('visible: true') && - overlay.includes('anchors { top: true; left: true; bottom: shown; right: shown }') && - overlay.includes('implicitWidth: 1') && - overlay.includes('implicitHeight: 1') && - overlay.includes('mask: shown ? null : emptyRegion') && - overlay.includes('WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom') && - overlay.includes('WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None'), - 'overlay window parks a 1x1 input-less surface below windows while hidden' + overlay.includes('visible: shown && !!targetScreen && !!targetMonitor') && + overlay.includes('anchors { top: true; left: true; bottom: true; right: true }') && + !overlay.includes('implicitWidth: 1') && !overlay.includes('WlrLayer.Bottom'), + 'closed overlays unmap instead of parking a surface' ) -// Content stays hidden until the surface has grown, so no frame drawn at 1x1 -// is stretched across the screen. +const readyExpression = /readonly property bool contentReady: ([^\n]*(?:\n &&[^\n]*)*)/.exec(overlay)[1] +const ready = new Function('shown', 'backingWindowVisible', 'width', 'height', 'targetScreen', 'targetMonitor', 'devicePixelRatio', `return ${readyExpression}`) +const screen = { name: 'eDP-1', width: 1200, height: 750 } +const monitor = { name: 'eDP-1', scale: 1.6 } +for (const scale of [2, 1, 1.6]) { + assertEqual(ready(true, true, 1200, 750, screen, monitor, scale), scale === 1.6, + `opening frames at ${scale}x wait for the monitor's 1.6x scale`) +} +for (const scale of [1, 1.25, 1.5, 2]) { + assert(ready(true, true, 1200, 750, screen, { scale }, scale), + `overlay reveals at the correct ${scale}x scale`) +} +assert(ready(true, true, 1200, 750, screen, { scale: 1.333333 }, 160 / 120), + 'scale comparison tolerates compositor floating point rounding') +assert(!ready(true, true, 500, 500, screen, monitor, 1.6), + 'initial window geometry never stretches content across the screen') +assert(!ready(true, false, 1200, 750, screen, monitor, 1.6), + 'content waits for a mapped backing window') +assert(!ready(false, true, 1200, 750, screen, monitor, 1.6), + 'closed content stays transparent even at the correct scale') +assert(!ready(true, true, 1200, 750, null, monitor, 1.6) && + !ready(true, true, 1200, 750, screen, null, 1.6), + 'content waits for a connected screen and its monitor information') assert( - /target: window\.contentItem\s*property: "visible"\s*value: window\.shown && window\.width > 1 && window\.height > 1/.test(overlay), - 'overlay window draws nothing until the surface has grown' + /target: window\.contentItem\s*property: "opacity"\s*value: window\.contentRevealed \? 1 : 0/.test(overlay), + 'transparent opening frames preserve immediate keyboard handling' ) -assert( - /onShownChanged: if \(shown\) targetScreen = focusedScreen\(\) \|\| targetScreen/.test(overlay) && - overlay.includes('screen: targetScreen'), - 'overlay window follows the focused monitor each time it is shown' -) +const revealBody = /onContentReadyChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +const reveal = new Function('window', 'Qt', `with (window) { ${revealBody} }`) +const reveals = [] +const revealQt = { callLater(callback) { reveals.push(callback) } } +const content = { contentReady: true, contentRevealed: false } +reveal(content, revealQt) +assert(!content.contentRevealed, 'scale and resize notifications finish before content reveals') +reveals.shift()() +assert(content.contentRevealed, 'settled content reveals on the next event-loop turn') +content.contentReady = false +reveal(content, revealQt) +assert(!content.contentRevealed, 'losing readiness immediately hides content') +content.contentReady = true +reveal(content, revealQt) +content.contentReady = false +reveal(content, revealQt) +reveals.shift()() +assert(!content.contentRevealed, 'a close or screen loss cancels a pending reveal') + +const focusedScreenBody = /function focusedScreen\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const focusedScreen = new Function('Hyprland', 'Quickshell', focusedScreenBody) +const external = { name: 'DP-1', width: 1920, height: 1080 } +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen, external] }), external, + 'overlay selects the focused output') +assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen] }), screen, + 'disconnecting the focused output falls back to a connected screen') +assertEqual(focusedScreen({ focusedMonitor: null }, { screens: [] }), null, + 'no outputs leaves the overlay unmapped') + +const shownHandler = /onShownChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1] +let refreshed = 0 +const Hyprland = { refreshMonitors() { refreshed++ } } +const changeShown = new Function('shown', 'focusedScreen', 'Hyprland', `let targetScreen = null; ${shownHandler}; return targetScreen`) +assertEqual(changeShown(false, () => external, Hyprland), null, 'closing drops the previous screen reference') +assertEqual(refreshed, 0, 'closing does not refresh monitor data') +assertEqual(changeShown(true, () => screen, Hyprland), screen, 'reopening selects a current screen') +assertEqual(refreshed, 1, 'opening refreshes monitor scales after a live change while closed') +const scaleHandler = /onDevicePixelRatioChanged: ([^\n]+)/.exec(overlay)[1] +const changeScale = new Function('shown', 'Hyprland', scaleHandler) +changeScale(false, Hyprland) +assertEqual(refreshed, 1, 'scale changes on hidden windows do not refresh monitor data') +const changedScreen = { name: 'eDP-1', width: 960, height: 600 } +assert(!ready(true, true, 960, 600, changedScreen, monitor, 2), + 'a stale monitor scale keeps content transparent after a live scale change') +Hyprland.refreshMonitors = () => { refreshed++; monitor.scale = 2 } +changeScale(true, Hyprland) +assertEqual(refreshed, 2, 'a showing window refreshes monitor scales when Qt receives a live change') +assert(ready(true, true, 960, 600, changedScreen, monitor, 2), + 'fresh monitor data lets content reveal at the new scale') + +const screensChangedBody = /function onScreensChanged\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1] +const screensChanged = new Function('window', 'Quickshell', 'Qt', screensChangedBody) +const pending = [] +const Qt = { callLater(callback) { pending.push(callback) } } +const window = { shown: true, targetScreen: external, focusedScreen: () => screen } +screensChanged(window, { screens: [screen] }, Qt) +assertEqual(window.targetScreen, null, 'screen removal unmaps before Qt finishes destroying the old window') +pending.shift()() +assertEqual(window.targetScreen, screen, 'a showing overlay moves off a disconnected screen') +window.targetScreen = screen +window.focusedScreen = () => external +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, screen, 'connecting another screen does not move a showing overlay') +window.shown = false +window.targetScreen = null +screensChanged(window, { screens: [screen, external] }, Qt) +assertEqual(window.targetScreen, null, 'screen changes leave a closed overlay unmapped') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.shown = false +pending.shift()() +assertEqual(window.targetScreen, null, 'closing before a deferred remap prevents a stale reopen') +window.shown = true +window.targetScreen = external +screensChanged(window, { screens: [screen] }, Qt) +window.targetScreen = screen +window.focusedScreen = () => external +pending.shift()() +assertEqual(window.targetScreen, screen, 'a deferred remap preserves the screen chosen by a newer open') const overlays = { 'shell/plugins/menu/Menu.qml': 'shown: root.opened && root.rowsLoaded', @@ -48,11 +139,11 @@ for (const [file, shown] of Object.entries(overlays)) { const qml = read(file) assert( qml.includes('OverlayWindow {') && qml.includes(shown) && !/PanelWindow \{\s*(id: panel\s*)?visible: root\.opened/.test(qml), - `${file} keeps its surface through OverlayWindow` + `${file} uses the shared overlay scale and lifecycle handling` ) } -// Window visibility never changes now, so close-time resets key off shown. +// Layout resets on a requested close, independent of mapping readiness. const menuQml = read('shell/plugins/menu/Menu.qml') assert( menuQml.includes('onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }') && @@ -60,9 +151,15 @@ assert( !/onVisibleChanged: if \(!visible\) \{ cardTop/.test(menuQml), 'the menu unfreezes its layout when the overlay hides' ) +const screenHandler = /onTargetScreenChanged: \{([^\n]*)\}/.exec(menuQml)[1] +const changeScreen = new Function('panel', `with (panel) { ${screenHandler} }`) +const menuPanel = { shown: true, cardTop: 945, maxRowsHeight: 510 } +changeScreen(menuPanel) +assertEqual(menuPanel.cardTop, -1, 'moving an open menu off a disconnected output unfreezes its old top edge') +assertEqual(menuPanel.maxRowsHeight, -1, 'moving an open menu drops the old output row-height limit') +assert(menuPanel.shown, 'resetting the layout keeps the menu open') -// A kept surface grows from its parked 1x1 when shown, which Hyprland animates -// as a slide in from the corner unless its layer rule turns animation off. +// Fullscreen overlays open without an additional compositor animation. const shellRules = read('default/hypr/apps/omarchy-shell.lua') const noAnim = /namespace = "\^\(([^)]*)\)\$" \}, no_anim = true/.exec(shellRules) assert(noAnim, 'the shell overlays share one no-animation layer rule') From 060c57b4aa416022343893c1a2e4eadae255257e Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Oct 2026 14:03:36 -0400 Subject: [PATCH 20/20] Enable overlay scrollbars in Chromium (#14456) * Enable overlay scrollbars in Chromium * Test overlay scrollbar migration * Simplify Chromium overlay scrollbar migration --------- Co-authored-by: David Heinemeier Hansson --- config/chromium-flags.conf | 2 +- migrations/1791376887.sh | 15 ++++++ ...omium-overlay-scrollbars-migration-test.sh | 46 +++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 migrations/1791376887.sh create mode 100644 test/shell.d/chromium-overlay-scrollbars-migration-test.sh diff --git a/config/chromium-flags.conf b/config/chromium-flags.conf index 1fe08716..8c6065c7 100644 --- a/config/chromium-flags.conf +++ b/config/chromium-flags.conf @@ -1,5 +1,5 @@ --ozone-platform=wayland --ozone-platform-hint=wayland --password-store=gnome-libsecret ---enable-features=TouchpadOverscrollHistoryNavigation +--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar --load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim diff --git a/migrations/1791376887.sh b/migrations/1791376887.sh new file mode 100644 index 00000000..6f666a7d --- /dev/null +++ b/migrations/1791376887.sh @@ -0,0 +1,15 @@ +echo "Enable overlay scrollbars in Chromium-based browsers" + +for flags_file in "$HOME"/.config/{chromium,chrome,microsoft-edge-stable,brave,brave-origin}-flags.conf; do + [[ -f $flags_file ]] || continue + grep -Eq -- '^--enable-features=([^,]+,)*OverlayScrollbar([,:]|$)' "$flags_file" && continue + + if grep -q -- '^--enable-features=' "$flags_file"; then + sed -i --follow-symlinks \ + -e '/^--enable-features=/ s/$/,OverlayScrollbar/' \ + -e '/^--enable-features=/ s/=,/=/' "$flags_file" + else + [[ -n $(tail -c1 "$flags_file") ]] && echo >>"$flags_file" + echo '--enable-features=OverlayScrollbar' >>"$flags_file" + fi +done diff --git a/test/shell.d/chromium-overlay-scrollbars-migration-test.sh b/test/shell.d/chromium-overlay-scrollbars-migration-test.sh new file mode 100644 index 00000000..50a4d7e5 --- /dev/null +++ b/test/shell.d/chromium-overlay-scrollbars-migration-test.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1791376887.sh" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +home="$test_tmp/home" +config_dir="$home/.config" +mkdir -p "$config_dir" "$test_tmp/targets" + +printf '%s\n' '--enable-features=TouchpadOverscrollHistoryNavigation' >"$config_dir/chromium-flags.conf" +printf '%s\n' '--enable-features=' >"$config_dir/chrome-flags.conf" +printf '%s' '--ozone-platform=wayland' >"$config_dir/microsoft-edge-stable-flags.conf" +printf '%s\n' '--enable-features=BraveFeature' >"$test_tmp/targets/brave-flags.conf" +ln -s "$test_tmp/targets/brave-flags.conf" "$config_dir/brave-flags.conf" + +run_migration() { + HOME="$home" bash -euo pipefail "$migration" >/dev/null +} + +run_migration +run_migration + +grep -Fxq -- '--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar' "$config_dir/chromium-flags.conf" || + fail "the migration preserves existing Chromium features" +overlay_count=$(grep -o 'OverlayScrollbar' "$config_dir/chromium-flags.conf" | wc -l) +(( overlay_count == 1 )) || fail "the migration does not duplicate the Chromium feature" +pass "the migration preserves existing features and can run twice" + +[[ $(cat "$config_dir/chrome-flags.conf") == '--enable-features=OverlayScrollbar' ]] || + fail "the migration fills an empty feature list" +[[ $(cat "$config_dir/microsoft-edge-stable-flags.conf") == $'--ozone-platform=wayland\n--enable-features=OverlayScrollbar' ]] || + fail "the migration adds a feature list with a missing trailing newline" +pass "the migration handles empty and missing feature lists" + +[[ -L $config_dir/brave-flags.conf ]] || fail "the migration preserves symlinked browser flags" +grep -Fxq -- '--enable-features=BraveFeature,OverlayScrollbar' "$config_dir/brave-flags.conf" || + fail "the migration updates the target of symlinked browser flags" +pass "the migration follows symlinked browser flags" + +[[ ! -e $config_dir/brave-origin-flags.conf ]] || fail "the migration leaves missing browser flags absent" +pass "the migration skips missing browser flags"