From 45d2e807db5c4c4bde14d1c0b28e8cc3e57d5c68 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 1 Oct 2026 23:00:51 -0500 Subject: [PATCH 01/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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/29] 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 9fe6a047153e14465c16e282f5bd69c60a8596e9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 12:55:33 +0200 Subject: [PATCH 15/29] Unify input methods with the installer keyboard selection --- bin/omarchy-input-method | 9 + bin/omarchy-provision-owner | 1 + bin/omarchy-restart-xcompose | 23 +- bin/omarchy-setup-input | 30 ++ bin/omarchy-upgrade-to-quattro | 2 + config/fcitx5/conf/quickphrase.conf | 2 + config/fcitx5/conf/wayland.conf | 1 + config/fcitx5/config | 20 + default/hypr/bindings/input-method.lua | 66 +++ default/hypr/bindings/jis.lua | 43 ++ default/hypr/input.lua | 23 +- default/hypr/keyboard.lua | 47 +++ default/hypr/omarchy.lua | 2 + default/input-methods/configure.py | 378 ++++++++++++++++++ .../input-methods/org.fcitx.Fcitx5.service | 5 + default/input-methods/presets.json | 7 + default/omarchy/omarchy-menu.jsonc | 1 + default/systemd/user/omarchy-fcitx5.service | 5 +- docs/input-methods.md | 21 + install/omarchy-base.packages | 4 + install/provisioning/setup-form.sh | 27 +- install/user/all.sh | 1 + install/user/first-run/enable-user-units.sh | 2 + install/user/input-method.sh | 1 + manual/34-keyboard-mouse-trackpad.md | 8 +- migrations/1791367434.sh | 4 + test/acceptance.d/input-methods-test.sh | 83 ++++ test/shell.d/hyprland-jis-bindings-test.sh | 128 ++++++ test/shell.d/hyprland-keyboard-layout-test.sh | 11 + test/shell.d/input-method-checks.py | 251 ++++++++++++ test/shell.d/input-method-test.sh | 6 + test/shell.d/owe-integration-test.sh | 9 +- test/shell.d/setup-form-test.sh | 15 + test/shell.d/systemd-test.sh | 2 +- 34 files changed, 1207 insertions(+), 31 deletions(-) create mode 100755 bin/omarchy-input-method create mode 100755 bin/omarchy-setup-input create mode 100644 config/fcitx5/conf/quickphrase.conf create mode 100644 config/fcitx5/conf/wayland.conf create mode 100644 config/fcitx5/config create mode 100644 default/hypr/bindings/input-method.lua create mode 100644 default/hypr/bindings/jis.lua create mode 100644 default/hypr/keyboard.lua create mode 100644 default/input-methods/configure.py create mode 100644 default/input-methods/org.fcitx.Fcitx5.service create mode 100644 default/input-methods/presets.json create mode 100644 docs/input-methods.md create mode 100644 install/user/input-method.sh create mode 100644 migrations/1791367434.sh create mode 100644 test/acceptance.d/input-methods-test.sh create mode 100755 test/shell.d/hyprland-jis-bindings-test.sh create mode 100644 test/shell.d/input-method-checks.py create mode 100644 test/shell.d/input-method-test.sh diff --git a/bin/omarchy-input-method b/bin/omarchy-input-method new file mode 100755 index 00000000..a299756f --- /dev/null +++ b/bin/omarchy-input-method @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Configure or inspect the input method selected at installation +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index a1a22559..d4166b45 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -599,6 +599,7 @@ keyboard_form() { done apply_keyboard "$keyboard" + omarchy_persist_input_selection } confirm_reboot() { diff --git a/bin/omarchy-restart-xcompose b/bin/omarchy-restart-xcompose index db65bd4e..26ef110c 100755 --- a/bin/omarchy-restart-xcompose +++ b/bin/omarchy-restart-xcompose @@ -2,9 +2,30 @@ # omarchy:summary=Restart the XCompose input method service (fcitx5) to apply new compose key settings. +set -euo pipefail + # A fcitx5 running outside the unit owns org.fcitx.Fcitx5, so the unit's # instance would exit on arrival and the stale process would keep serving the # old ~/.XCompose -- a restart that reports success and changes nothing. systemctl --user stop omarchy-fcitx5.service -pkill -x fcitx5 >/dev/null 2>&1 || true +pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true +for attempt in {1..50}; do + if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then + break + fi + sleep 0.1 +done +if pgrep -u "$UID" -x fcitx5 >/dev/null; then + # An activation concurrent with stop may have restarted the unit. + systemctl --user stop omarchy-fcitx5.service + pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true + for attempt in {1..50}; do + if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then break; fi + sleep 0.1 + done +fi +if pgrep -u "$UID" -x fcitx5 >/dev/null; then + echo "Fcitx did not stop; input service was not restarted." >&2 + exit 1 +fi systemctl --user start omarchy-fcitx5.service diff --git a/bin/omarchy-setup-input b/bin/omarchy-setup-input new file mode 100755 index 00000000..ff756b04 --- /dev/null +++ b/bin/omarchy-setup-input @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Set up Japanese, Korean, or Chinese typing +# omarchy:args=[mozc|hangul|pinyin|chewing] +# omarchy:examples=omarchy setup input | omarchy setup input mozc +# omarchy:requires-sudo=true + +set -euo pipefail + +if (( $# > 1 )); then + echo "Usage: omarchy setup input [mozc|hangul|pinyin|chewing]" >&2 + exit 1 +fi + +method=${1:-} +if [[ -z $method ]]; then + choice=$(jq -r 'to_entries[] | select(.key != "none") | .value.label' "$OMARCHY_PATH/default/input-methods/presets.json" | + gum choose --header "Select input language") + method=$(jq -r --arg label "$choice" 'to_entries[] | select(.value.label == $label) | .key' "$OMARCHY_PATH/default/input-methods/presets.json") +fi + +case "$method" in + mozc | hangul | pinyin | chewing) ;; + *) echo "Unknown input method: $method" >&2; exit 1 ;; +esac + +mapfile -t packages < <(jq -r --arg method "$method" '.[$method].packages[]' "$OMARCHY_PATH/default/input-methods/presets.json") +omarchy-pkg-add "${packages[@]}" +omarchy-input-method configure "$method" +echo "Input method added. The default switch is Ctrl+Shift+Space or the Japanese/Korean input key; existing custom keys are preserved." diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 9f4a8ae7..28131750 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1802,6 +1802,8 @@ copy_missing_config_defaults() { while IFS= read -r -d '' source_path; do rel="${source_path#$source_dir/}" is_retired_config_file "$rel" && continue + # The input migration preserves working shortcuts in existing IME profiles. + [[ $rel == "fcitx5/config" ]] && continue target_path="$target_dir/$rel" [[ -e $target_path || -L $target_path ]] && continue diff --git a/config/fcitx5/conf/quickphrase.conf b/config/fcitx5/conf/quickphrase.conf new file mode 100644 index 00000000..34905ac6 --- /dev/null +++ b/config/fcitx5/conf/quickphrase.conf @@ -0,0 +1,2 @@ +[TriggerKey] +0=Super+semicolon diff --git a/config/fcitx5/conf/wayland.conf b/config/fcitx5/conf/wayland.conf new file mode 100644 index 00000000..90f53161 --- /dev/null +++ b/config/fcitx5/conf/wayland.conf @@ -0,0 +1 @@ +Allow Overriding System XKB Settings=False diff --git a/config/fcitx5/config b/config/fcitx5/config new file mode 100644 index 00000000..3609ca02 --- /dev/null +++ b/config/fcitx5/config @@ -0,0 +1,20 @@ +# Leave Ctrl+Space to tmux/Herdr and the Super shortcuts to the desktop. +[Hotkey/TriggerKeys] +0=Control+Shift+space +1=Zenkaku_Hankaku +2=Hangul + +[Hotkey/ActivateKeys] +0=Henkan + +[Hotkey/DeactivateKeys] +0=Muhenkan + +[Hotkey/AltTriggerKeys] + +[Hotkey/EnumerateGroupForwardKeys] + +[Hotkey/EnumerateGroupBackwardKeys] + +[Behavior] +ActiveByDefault=False diff --git a/default/hypr/bindings/input-method.lua b/default/hypr/bindings/input-method.lua new file mode 100644 index 00000000..2fe813e5 --- /dev/null +++ b/default/hypr/bindings/input-method.lua @@ -0,0 +1,66 @@ +local paths = require("default.hypr.paths") + +-- Same terminal tag test as clipboard.lua. +local function active_window_is_terminal() + local window = hl.get_active_window() + if not window then + return false + end + + for _, tag in ipairs(window.tags or {}) do + if tag:gsub("%*$", "") == "terminal" then + return true + end + end + + return false +end + +-- Read on demand so adding an engine in a live desktop needs no reload. +local function has_input_method() + local file = io.open(paths.config_home .. "/fcitx5/profile", "r") + if not file then return false end + local in_item, found = false, false + for line in file:lines() do + if line:match("^%[") then + in_item = line:match("^%[Groups/%d+/Items/%d+%]$") ~= nil + elseif in_item then + local name = line:match("^Name=(.+)$") + if name and not name:match("^keyboard%-") then found = true end + end + end + file:close() + return found +end + +-- Preserve custom Ctrl + Space triggers. Without a +-- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has +-- Ctrl + Space. +local function fcitx5_toggles_on_ctrl_space() + local file = io.open(paths.config_home .. "/fcitx5/config", "r") + if not file then + return true + end + + local in_triggers, has_triggers, toggles = false, false, false + for line in file:lines() do + if line:match("^%[") then + in_triggers = line == "[Hotkey/TriggerKeys]" + has_triggers = has_triggers or in_triggers + elseif in_triggers and line:match("^%d+=Control%+space$") then + toggles = true + end + end + + file:close() + return toggles or not has_triggers +end + +-- Pass the tmux/Herdr prefix through and return to direct input, so the next +-- key reaches the multiplexer instead of the engine's composition. +o.bind("CTRL + SPACE", "Direct input for the terminal prefix", function() + if active_window_is_terminal() and has_input_method() and not fcitx5_toggles_on_ctrl_space() then + -- Wait for the local controller reply before dispatching the next key. + o.shell_succeeds("timeout 0.3 fcitx5-remote --check -c") + end +end, { non_consuming = true }) diff --git a/default/hypr/bindings/jis.lua b/default/hypr/bindings/jis.lua new file mode 100644 index 00000000..a2b9ab6d --- /dev/null +++ b/default/hypr/bindings/jis.lua @@ -0,0 +1,43 @@ +-- Defaults for Japanese (JIS) keyboards, which only apply when the installed +-- layout is jp. See default/hypr/keyboard.lua. + +local keyboard = require("default.hypr.keyboard") + +if not keyboard.jis() then + return +end + +-- Same down/up split as the universal clipboard shortcuts in clipboard.lua. +local function send_shortcut_once(mods, key) + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "down" })) + + hl.timer(function() + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "up" })) + end, { timeout = 50, type = "oneshot" }) +end + +local function active_window_class() + local window = hl.get_active_window() + return ((window and window.class) or ""):lower() +end + +-- On JIS, + is Shift + ;, so apps see Ctrl + Shift + semicolon and never zoom +-- in. Hand them Ctrl + keypad plus instead, which Chromium, Electron apps, +-- VS Code, and Firefox read as zoom in. Obsidian only listens for Ctrl + ^, +-- which JIS types without Shift. +local zoom_in_overrides = { + obsidian = { "CTRL", "asciicircum" }, +} + +o.bind("CTRL + SHIFT + semicolon", "Zoom in (JIS + key)", function() + local class = active_window_class() + + for pattern, chord in pairs(zoom_in_overrides) do + if class:find(pattern, 1, true) then + send_shortcut_once(chord[1], chord[2]) + return + end + end + + send_shortcut_once("CTRL", "KP_Add") +end) diff --git a/default/hypr/input.lua b/default/hypr/input.lua index a87ad994..3657abb3 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -1,32 +1,13 @@ -- https://wiki.hypr.land/Configuring/Basics/Variables/#input -local function read_vconsole() - local values = {} - local file = io.open("/etc/vconsole.conf", "r") - if not file then - return values - end - - for line in file:lines() do - local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") - if key and value then - value = value:gsub("%s+#.*$", "") - value = value:gsub('^"(.*)"$', "%1") - value = value:gsub("^'(.*)'$", "%1") - values[key] = value - end - end - - file:close() - return values -end +local keyboard = require("default.hypr.keyboard") -- Layouts that can't type Latin letters. Keep in sync with the list in -- etc/mkinitcpio.conf.d/omarchy_hooks.conf. local non_latin_layouts = " af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua " -local vconsole = read_vconsole() +local vconsole = keyboard.installed() local kb_layout = vconsole.XKBLAYOUT or "us" local kb_variant = vconsole.XKBVARIANT or "" diff --git a/default/hypr/keyboard.lua b/default/hypr/keyboard.lua new file mode 100644 index 00000000..46b9a4a5 --- /dev/null +++ b/default/hypr/keyboard.lua @@ -0,0 +1,47 @@ +-- The keyboard layout picked at install time. The installer and first-boot +-- setup persist it to /etc/vconsole.conf, so Hyprland reads it from there +-- instead of rewriting every user's config. + +local keyboard = {} + +local function read_values(path) + local values = {} + local file = io.open(path, "r") + if not file then + return values + end + + for line in file:lines() do + local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") + if key and value then + value = value:gsub("%s+#.*$", "") + value = value:gsub('^"(.*)"$', "%1") + value = value:gsub("^'(.*)'$", "%1") + values[key] = value + end + end + + file:close() + return values +end + +function keyboard.vconsole() + return read_values("/etc/vconsole.conf") +end + +function keyboard.installed() + local values = keyboard.vconsole() + if read_values("/etc/omarchy/input-method").XKB_LAYOUT == "kr" then + values.XKBLAYOUT, values.XKBVARIANT = "kr", "" + end + return values +end + +-- Japanese (JIS) keyboards put +, ^, and the Henkan/Muhenkan keys where US +-- keyboards don't, so a few defaults only apply when that layout leads. +function keyboard.jis() + local layout = keyboard.installed().XKBLAYOUT or "us" + return layout:match("^[^,]*") == "jp" +end + +return keyboard diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index abc7c4e0..1e84058a 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -8,6 +8,8 @@ require("default.hypr.autostart") if _G.omarchy_default_bindings ~= false then require("default.hypr.bindings.media") require("default.hypr.bindings.clipboard") + require("default.hypr.bindings.input-method") + require("default.hypr.bindings.jis") require("default.hypr.bindings.tiling") require("default.hypr.bindings.utilities") require("default.hypr.bindings.voxtype") diff --git a/default/input-methods/configure.py b/default/input-methods/configure.py new file mode 100644 index 00000000..0ec5751b --- /dev/null +++ b/default/input-methods/configure.py @@ -0,0 +1,378 @@ +"""One Fcitx setup path for offline finalization and a running desktop.""" + +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import sys +import time + + +ROOT = Path(os.environ["OMARCHY_PATH"]) +PRESETS = json.loads((ROOT / "default/input-methods/presets.json").read_text()) +NON_LATIN = set("af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua".split()) +CONTROLLER = ["busctl", "--user", "call", "org.fcitx.Fcitx5", "/controller", "org.fcitx.Fcitx.Controller1"] + + +def run(args, **kwargs): + return subprocess.run(args, check=True, text=True, capture_output=True, **kwargs).stdout.strip() + + +def read(path): + return path.read_text() if path.exists() else "" + + +def values(text): + result = {} + for line in text.splitlines(): + match = re.match(r"^\s*([\w ]+)\s*=\s*(.*?)\s*(?:\s+#.*)?$", line) + if match: + result[match[1].strip()] = match[2].strip("\"'") + return result + + +def sections(text): + result = {} + current = "" + for line in text.splitlines(): + match = re.match(r"^\s*\[([^]]+)\]\s*$", line) + if match: + current = match[1] + result.setdefault(current, {}) + elif current: + result[current].update(values(line)) + return result + + +def atomic_write(path, text): + # Follow a user's config symlink rather than replacing the link itself. + path = path.resolve() + if read(path) == text: + return False + path.parent.mkdir(parents=True, exist_ok=True) + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + fd, temporary = tempfile.mkstemp(prefix=".omarchy-input-", dir=path.parent) + try: + with os.fdopen(fd, "w") as file: + file.write(text) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return True + + +def key_list(entries): + # Fcitx canonicalizes modifiers (e.g. Shift+Super instead of Super+Shift). + return {frozenset(value.split("+")) for value in entries.values()} + + +def defaults(config_home, fresh=False): + changed = False + files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"] + for name in files: + target = config_home / "fcitx5" / name + original = read(target) + shipped = read(ROOT / "config/fcitx5" / name) + if name == "config" or name == "conf/quickphrase.conf": + existing = sections(original) + additions = [] + for section, entries in sections(shipped).items(): + if not fresh and section == "Hotkey/TriggerKeys": + continue + # Existing lists (including explicitly empty lists) are user choices. + # A fresh profile has no working IME shortcut to preserve. + stock_lists = { + "Hotkey/TriggerKeys": {"0": "Control+space", "1": "Zenkaku_Hankaku", "2": "Hangul"}, + "Hotkey/ActivateKeys": {"0": "Hangul_Hanja"}, + "Hotkey/DeactivateKeys": {"0": "Hangul_Romaja"}, + "Hotkey/AltTriggerKeys": {"0": "Shift_L"}, + "Hotkey/EnumerateGroupForwardKeys": {"0": "Super+space"}, + "Hotkey/EnumerateGroupBackwardKeys": {"0": "Super+Shift+space"}, + "TriggerKey": {"0": "Super+grave", "1": "Super+semicolon"}, + } + if fresh and section in existing and section in stock_lists and (key_list(existing[section]) == key_list(stock_lists[section]) or (not existing[section] and section in {"Hotkey/ActivateKeys", "Hotkey/DeactivateKeys"})): + original = re.sub(r"(?ms)^\[" + re.escape(section) + r"\]\s*\n.*?(?=^\[|\Z)", "", original) + existing.pop(section) + if section not in existing: + additions.append(f"[{section}]\n" + "".join(f"{key}={value}\n" for key, value in entries.items())) + if additions: + changed |= atomic_write(target, original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions)) + elif not target.exists() and not target.is_symlink(): + changed |= atomic_write(target, shipped) + return changed + + +def activation_default(): + data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")) + path = data_home / "dbus-1/services/org.fcitx.Fcitx5.service" + original = read(path) + # Repair the package's plain activation entry, but retain custom launchers. + packaged = values(original).get("Exec") in ("/usr/bin/fcitx5", "/usr/bin/fcitx5 -d") + if not original or (packaged and "SystemdService" not in values(original)): + return atomic_write(path, read(ROOT / "default/input-methods/org.fcitx.Fcitx5.service")) + return False + + +@contextmanager +def transaction(config_home, live=False): + data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")) + paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"]] + paths += [config_home / "fontconfig/conf.d/50-omarchy-input-method.conf", data_home / "dbus-1/services/org.fcitx.Fcitx5.service"] + before = {path.resolve(): path.read_text() if path.exists() else None for path in paths} + try: + yield + except BaseException as setup_error: + if live: + # Stop before restoring disk: a graceful stop saves the in-memory profile. + try: + run(["systemctl", "--user", "stop", "omarchy-fcitx5.service"]) + subprocess.run(["pkill", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for attempt in range(50): + if subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + break + time.sleep(0.1) + else: + raise RuntimeError("Fcitx did not stop for rollback; previous settings could not safely be restored") + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + raise RuntimeError(f"Could not stop Fcitx for input rollback: {error} (setup failed: {setup_error})") from error + for path, original in before.items(): + if original is None: + path.unlink(missing_ok=True) + else: + atomic_write(path, original) + if live: + # Restore memory as well as disk; otherwise Fcitx saves the failed change + # again on exit. Bus activation caches also need to forget the new entry. + reload_bus() + try: + run(["omarchy-restart-xcompose"]) + run(CONTROLLER + ["ReloadConfig"]) + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + print(f"Could not reload restored input settings: {error}", file=sys.stderr) + raise + + +def selection(): + vconsole = values(read(Path(os.environ.get("OMARCHY_VCONSOLE", "/etc/vconsole.conf")))) + preference = values(read(Path(os.environ.get("OMARCHY_INPUT_SELECTION", "/etc/omarchy/input-method")))) + layout = vconsole.get("XKBLAYOUT", "us").split(",")[0] or "us" + variant = vconsole.get("XKBVARIANT", "").split(",")[0] + if preference.get("XKB_LAYOUT") == "kr": + layout, variant = "kr", "" + method = preference.get("INPUT_METHOD", "mozc" if layout == "jp" else "none") + if method not in PRESETS: + raise ValueError(f"Unknown installed input method: {method}") + if layout in NON_LATIN: + layout, variant = "us", "" + group_layout = layout + (f"-{variant}" if variant else "") + if not re.fullmatch(r"[A-Za-z0-9_-]+", group_layout): + raise ValueError("Invalid installed keyboard layout") + return method, group_layout + + +def stock_profile(text): + if not text.strip(): + return True + groups = sections(text) + items = [data.get("Name", "") for key, data in groups.items() if re.fullmatch(r"Groups/\d+/Items/\d+", key)] + group = groups.get("Groups/0", {}) + return (items == ["keyboard-us"] + and list(key for key in groups if re.fullmatch(r"Groups/\d+", key)) == ["Groups/0"] + and group.get("Name", "Default") == "Default" + and group.get("Default Layout", "us") == "us" + and not groups.get("Groups/0/Items/0", {}).get("Layout")) + + +def profile_text(layout, method): + default_im = method if method != "none" else f"keyboard-{layout}" + text = f"[Groups/0]\nName=Default\nDefault Layout={layout}\nDefaultIM={default_im}\n\n[Groups/0/Items/0]\nName=keyboard-{layout}\nLayout=\n" + if method != "none": + text += f"\n[Groups/0/Items/1]\nName={method}\nLayout=\n" + return text + "\n[GroupOrder]\n0=Default\n" + + +def font_default(config_home, method): + variant = PRESETS[method]["font"] + path = config_home / "fontconfig/conf.d/50-omarchy-input-method.conf" + if not variant or path.exists() or path.is_symlink(): + return + # Only CJK fallback changes; retain the interface and terminal's Latin font. + rules = [] + for families, face in [("sans-serif|Liberation Sans", "Sans"), ("serif|Liberation Serif", "Serif"), ("monospace|JetBrainsMono Nerd Font", "Sans Mono")]: + for family in families.split("|"): + rules.append(f'{family}Noto {face} CJK {variant}') + atomic_write(path, '\n\n\n' + "\n".join(rules) + "\n\n") + + +def live_group(): + group = run(["fcitx5-remote", "-q"]) + info = json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["InputMethodGroupInfo", "s", group]))["data"] + layout, items = info[:2] + return group, layout, items + + +def live_set(group, layout, items): + args = [group, layout, str(len(items))] + for name, item_layout in items: + args.extend([name, item_layout]) + run(CONTROLLER + ["SetInputMethodGroupInfo", "ssa(ss)"] + args) + run(CONTROLLER + ["Save"]) + + +def reload_bus(): + # Offline finalization and updates over SSH may have no session bus. + try: + run(["busctl", "--user", "call", "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReloadConfig"]) + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + print(f"Input activation reload deferred until login: {error}", file=sys.stderr) + + +def available_methods(): + return json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["AvailableInputMethods"]))["data"][0] + + +def wait_ready(method): + for attempt in range(100): + try: + available = available_methods() + if any(item[0] == method for item in available) and live_group()[2]: + return + except (OSError, ValueError, KeyError, IndexError, subprocess.CalledProcessError): + pass + time.sleep(0.1) + raise RuntimeError(f"Fcitx did not load the {method} engine after restart") + + +def live_append(method): + group, layout, items = live_group() + if any(item[0] == method for item in items): + return + try: + live_set(group, layout, items + [[method, ""]]) + if not any(item[0] == method for item in live_group()[2]): + raise RuntimeError(f"Fcitx did not retain the {method} engine") + except BaseException: + live_set(group, layout, items) + raise + + +def select_live_method(method, profile): + # Clients reconnect asynchronously after restart. SetCurrentIM silently does + # nothing without a focused input context, so verify the saved group default. + for attempt in range(30): + run(["fcitx5-remote", "-s", method]) + run(["fcitx5-remote", "-c"]) + run(CONTROLLER + ["Save"]) + group = run(["fcitx5-remote", "-q"]) + for section, entries in sections(read(profile)).items(): + if re.fullmatch(r"Groups/\d+", section) and entries.get("Name") == group and entries.get("DefaultIM") == method: + return + time.sleep(0.1) + print(f"Input method added; select {method} in Fcitx settings to switch to it.", file=sys.stderr) + + +def configure(args): + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")) + running = subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + # Finalization can be retried after login. A live daemon owns its profile. + if args.seed and running: + return + with transaction(config_home, live=running and not args.defaults): + configure_inner(args, config_home, running) + + +def configure_inner(args, config_home, running): + if args.seed or args.defaults: + method, layout = selection() + else: + method, layout = args.method or selection()[0], None + if args.method: + method = args.method + profile = config_home / "fcitx5/profile" + original = read(profile) + if args.seed and not stock_profile(original): + return + if not args.seed and not args.defaults and not running: + raise RuntimeError("Log in to an Omarchy desktop before changing input methods") + if not args.seed and not args.defaults and method == "none": + raise ValueError("Choose an input engine to add") + + if args.seed: + atomic_write(profile, profile_text(layout, method)) + defaults(config_home, fresh=True) + activation_default() + font_default(config_home, method) + elif args.defaults: + fresh = stock_profile(original) + changed = defaults(config_home, fresh=fresh) + if activation_default(): + reload_bus() + # Upgrades can run through sudo/SSH without access to the live user's bus. + # Never overwrite its profile or restart input in that context. + if fresh: + if running: + try: + group, old_layout, items = live_group() + if old_layout == "us" and items == [["keyboard-us", ""]]: + new_items = [[f"keyboard-{layout}", ""]] + if method != "none": + if any(item[0] == method for item in available_methods()): + new_items.append([method, ""]) + else: + print(f"Finish input setup after login: omarchy setup input {method}", file=sys.stderr) + live_set(group, layout, new_items) + if live_group()[1:] != (layout, new_items): + live_set(group, old_layout, items) + raise RuntimeError("Fcitx did not retain the installed keyboard settings") + except (OSError, ValueError, KeyError, IndexError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"Input profile alignment deferred until login: {error}", file=sys.stderr) + else: + atomic_write(profile, profile_text(layout, method)) + if running and changed: + try: + run(CONTROLLER + ["ReloadConfig"]) + except (OSError, subprocess.CalledProcessError) as error: + print(f"Input defaults reload deferred until login: {error}", file=sys.stderr) + else: + # New addons are discovered only at startup. Supervision remains with the + # existing unit; never spawn a second fcitx instance ourselves. + activation_default() + defaults(config_home, fresh=stock_profile(original)) + reload_bus() + run(["omarchy-restart-xcompose"]) + wait_ready(method) + live_append(method) + # The selected language becomes the next active engine; leave Latin input + # active until the user deliberately switches. Keep all other group items. + select_live_method(method, profile) + font_default(config_home, method) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status") + configure_parser = commands.add_parser("configure") + mode = configure_parser.add_mutually_exclusive_group() + mode.add_argument("--seed", action="store_true") + mode.add_argument("--defaults", action="store_true") + configure_parser.add_argument("method", nargs="?", choices=PRESETS) + args = parser.parse_args() + if args.command == "status": + method, layout = selection() + print(json.dumps({"installed_method": method, "layout": layout})) + else: + configure(args) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + raise SystemExit(f"Input method setup failed: {error}") diff --git a/default/input-methods/org.fcitx.Fcitx5.service b/default/input-methods/org.fcitx.Fcitx5.service new file mode 100644 index 00000000..6be99d23 --- /dev/null +++ b/default/input-methods/org.fcitx.Fcitx5.service @@ -0,0 +1,5 @@ +# Installed per-user so D-Bus activation uses the supervised Omarchy unit. +[D-BUS Service] +Name=org.fcitx.Fcitx5 +Exec=/usr/bin/fcitx5 +SystemdService=omarchy-fcitx5.service diff --git a/default/input-methods/presets.json b/default/input-methods/presets.json new file mode 100644 index 00000000..1ea1855f --- /dev/null +++ b/default/input-methods/presets.json @@ -0,0 +1,7 @@ +{ + "none": {"label": "Keyboard only", "packages": [], "font": ""}, + "mozc": {"label": "Japanese (Mozc)", "packages": ["fcitx5-mozc"], "font": "JP"}, + "hangul": {"label": "Korean (Hangul)", "packages": ["fcitx5-hangul"], "font": "KR"}, + "pinyin": {"label": "Chinese (Simplified, Pinyin)", "packages": ["fcitx5-chinese-addons"], "font": "SC"}, + "chewing": {"label": "Chinese (Traditional, Zhuyin)", "packages": ["fcitx5-chewing"], "font": "TC"} +} diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 736cba1a..ac239452 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -128,6 +128,7 @@ "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, + "setup.input-method": {"icon":"","label":"Input Language","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'"}, "setup.network": {"icon":"󰛳","label":"Network","aliases":["network"]}, "setup.network.dns": {"icon":"󰇖","label":"DNS","aliases":["dns"]}, "setup.network.dns.dhcp": {"icon":"󰩟","label":"DHCP","checked":"[[ \"$(omarchy-dns)\" == \"DHCP\" ]]","action":"omarchy-dns DHCP"}, diff --git a/default/systemd/user/omarchy-fcitx5.service b/default/systemd/user/omarchy-fcitx5.service index 672c67bf..db6eb578 100644 --- a/default/systemd/user/omarchy-fcitx5.service +++ b/default/systemd/user/omarchy-fcitx5.service @@ -1,5 +1,5 @@ [Unit] -Description=Fcitx5 input method (XCompose sequences) +Description=Fcitx5 input methods and XCompose sequences # fcitx5 turns the CapsLock compose sequences in ~/.XCompose into text for # Wayland clients. # @@ -19,7 +19,8 @@ PartOf=graphical-session.target ConditionEnvironment=WAYLAND_DISPLAY [Service] -Type=simple +Type=dbus +BusName=org.fcitx.Fcitx5 # notificationitem duplicates the tray entry omarchy already renders itself. ExecStart=/usr/bin/fcitx5 --disable notificationitem # always, not on-failure: fcitx5 exits 0 when it detects another instance owning diff --git a/docs/input-methods.md b/docs/input-methods.md new file mode 100644 index 00000000..af1ca8c2 --- /dev/null +++ b/docs/input-methods.md @@ -0,0 +1,21 @@ +# Input methods + +The install keyboard picker selects a console keymap plus an optional Fcitx engine. `install/provisioning/setup-form.sh` is shared by the ISO configurator and deferred first-boot owner setup. Its rows are `label|console keymap|input method|desktop XKB override`; the last two fields are optional, so ordinary keyboard choices keep their existing meaning. + +The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_install.input_xkb_layout`. Both installation paths persist them in `/etc/omarchy/input-method` as `INPUT_METHOD` and `XKB_LAYOUT`. This file is parsed as data, never sourced. Korean has a real US console keymap and a `kr` desktop override; Japanese uses `jp106`, or US when the US-keyboard choice is selected. Console and disk-unlock input remain ordinary keymaps. + +`install/user/input-method.sh` calls `omarchy-input-method configure --seed` during user finalization, before graphical login. The standard engines are in the base package set so deferred provisioning and factory resets do not need network access to configure typing. `default/input-methods/presets.json` is the engine catalog used by the post-install selector. + +`default/input-methods/configure.py` owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single `keyboard-us` guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's `DefaultIM`, with `ActiveByDefault=False` so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved. + +`omarchy setup input` installs the selected engine, restarts the supervised Fcitx unit so it discovers new addons, and appends the engine through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`. It keeps the group name, layout, existing methods, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates `org.fcitx.Fcitx5` to `omarchy-fcitx5.service`; it is loaded before live restart. + +Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Input toggles on Ctrl+Shift+Space, Zenkaku/Hankaku, or Hangul; Henkan activates and Muhenkan deactivates. An existing configured list, including an empty list, is preserved. Stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation; a keyboard-only profile skips this step. Wayland and XCB addons do not override the compositor's XKB settings. + +The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or live setup so a retry can complete. Live rollback stops Fcitx before restoring disk, then reloads activation and restarts the unit. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command. + +Input selection does not change the system locale, repository region, desktop language, or Latin interface font. A new language setup seeds a CJK fallback preference once; additional languages do not overwrite it. + +Focused checks live in `test/shell.d/input-method-test.sh`, `setup-form-test.sh`, `hyprland-keyboard-layout-test.sh`, and `hyprland-jis-bindings-test.sh`. `test/acceptance.d/input-methods-test.sh` exercises real composition in a disposable installed VM and restores the original user configuration. The ISO repository's keyboard tests cover transport and target persistence. Browser input must be verified against the shipped browser versions; the language selector is not a place to guess flags from older guides. + +Default key values are checked against [Fcitx global configuration](https://github.com/fcitx/fcitx5/blob/master/src/lib/fcitx/globalconfig.cpp). diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 1981d883..fc991a09 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -34,7 +34,11 @@ eza fakeroot fastfetch fcitx5 +fcitx5-chewing +fcitx5-chinese-addons fcitx5-gtk +fcitx5-hangul +fcitx5-mozc fcitx5-qt fd ffmpeg diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index f4265d1d..8b82a77e 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -18,7 +18,8 @@ # so it never reaches the shell as SIGINT. Act on the status, never on a trap. # # Callers supply `notice ` for validation feedback, and set -# the variables these prompts write: keyboard, keyboard_label, username, +# the variables these prompts write: keyboard, keyboard_label, +# keyboard_input_method, keyboard_xkb_layout, username, # password, password_confirmation, full_name, email_address, hostname, timezone. OMARCHY_FORM_BACK=1 @@ -37,6 +38,8 @@ Azerbaijani|azerty Belarusian|by Belgian|be-latin1 Bulgarian|bg-cp1251 +Chinese (Simplified, Pinyin)|us|pinyin +Chinese (Traditional, Zhuyin)|us|chewing Croatian|croat Czech|cz Danish|dk-latin1 @@ -55,8 +58,10 @@ Hungarian|hu Icelandic|is-latin1 Irish|ie Italian|it -Japanese|jp106 +Japanese|jp106|mozc +Japanese (US keyboard)|us|mozc Kazakh|kazakh +Korean|us|hangul|kr Kyrgyz|kyrgyz Lao|la-latin1 Latvian|lv @@ -97,8 +102,22 @@ omarchy_prompt_keyboard() { gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") && status=0 || status=$? ((status == 0)) || return $status - keyboard_label="$choice" - keyboard=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}') + local selection + selection=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print; exit}') + [[ -n $selection ]] || return 1 + IFS='|' read -r keyboard_label keyboard keyboard_input_method keyboard_xkb_layout <<< "$selection" + keyboard_input_method=${keyboard_input_method:-none} + keyboard_xkb_layout=${keyboard_xkb_layout:-} +} + +# Root-side persistence. Console keymaps remain real keymaps; the input +# preference survives separately for every user of the installed system. +omarchy_persist_input_selection() { + local target_root=${1:-} method=${keyboard_input_method:-none} layout=${keyboard_xkb_layout:-} + case "$method" in none | mozc | hangul | pinyin | chewing) ;; *) return 1 ;; esac + case "$layout" in "" | kr) ;; *) return 1 ;; esac + mkdir -p "$target_root/etc/omarchy" + printf 'INPUT_METHOD=%s\nXKB_LAYOUT=%s\n' "$method" "$layout" > "$target_root/etc/omarchy/input-method" } omarchy_prompt_username() { diff --git a/install/user/all.sh b/install/user/all.sh index d81c3e75..3f41caa3 100644 --- a/install/user/all.sh +++ b/install/user/all.sh @@ -2,6 +2,7 @@ run_logged "$OMARCHY_INSTALL/user/theme.sh" run_logged "$OMARCHY_INSTALL/user/chromium.sh" run_logged "$OMARCHY_INSTALL/user/git.sh" run_logged "$OMARCHY_INSTALL/user/xcompose.sh" +run_logged "$OMARCHY_INSTALL/user/input-method.sh" run_logged "$OMARCHY_INSTALL/user/mise-work.sh" run_logged "$OMARCHY_INSTALL/user/hardware/asus/fix-audio-mixer.sh" diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index e08885c9..3728e653 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -11,6 +11,8 @@ set -euo pipefail +omarchy-input-method configure --defaults || echo "Input method defaults deferred; run omarchy setup input after login." >&2 + systemctl --user daemon-reload systemctl --user enable --now \ bt-agent.service \ diff --git a/install/user/input-method.sh b/install/user/input-method.sh new file mode 100644 index 00000000..f4901f77 --- /dev/null +++ b/install/user/input-method.sh @@ -0,0 +1 @@ +omarchy-input-method configure --seed diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 648e7713..202f46ad 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -59,7 +59,13 @@ On Dell XPS laptops with a haptic touchpad, you can also set the click strength ### Typing in Chinese, Japanese, and other languages -Omarchy runs the [fcitx5](https://fcitx-im.org/) input method framework as part of every session — it's what powers the CapsLock compose sequences. That means the plumbing for non-Latin input is already in place: install an input engine like `fcitx5-mozc` (Japanese) or `fcitx5-chinese-addons` (Chinese) with `omarchy pkg add`, plus `fcitx5-configtool` to add the engine to your input methods and set the key that switches between them. +Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Zhuyin) when the installer asks for your keyboard. Omarchy configures the keyboard and matching input engine automatically, including during offline first-boot setup. Japanese uses Mozc, Korean uses standard two-set Hangul, Simplified Chinese uses Pinyin, and Traditional Chinese uses Chewing's standard Zhuyin arrangement. Japanese (US keyboard) provides Mozc with a US keyboard layout. + +Typing starts in Latin mode. Press `Ctrl + Shift + Space` to switch input on or off. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key. + +To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language. + +On keyboards without dedicated input keys, use `Ctrl + Shift + Space`. This input shortcut takes precedence over app shortcuts such as code completion; you can change it in `fcitx5-configtool`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session. ### Use ALT as SUPER diff --git a/migrations/1791367434.sh b/migrations/1791367434.sh new file mode 100644 index 00000000..84b5c255 --- /dev/null +++ b/migrations/1791367434.sh @@ -0,0 +1,4 @@ +echo "Configure shared input-method defaults and free desktop shortcuts" + +omarchy-pkg-add fcitx5-mozc fcitx5-hangul fcitx5-chinese-addons fcitx5-chewing +omarchy-input-method configure --defaults diff --git a/test/acceptance.d/input-methods-test.sh b/test/acceptance.d/input-methods-test.sh new file mode 100644 index 00000000..c147e894 --- /dev/null +++ b/test/acceptance.d/input-methods-test.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# VM-only: exercises the shared setup command and real composition for every +# shipped engine. Restore the profile, shortcut settings, and font preference. +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +config_home=${XDG_CONFIG_HOME:-$HOME/.config} +data_home=${XDG_DATA_HOME:-$HOME/.local/share} +paths=("$config_home/fcitx5" "$config_home/fontconfig/conf.d/50-omarchy-input-method.conf" "$data_home/dbus-1/services/org.fcitx.Fcitx5.service") +for (( i = 0; i < ${#paths[@]}; i++ )); do + if [[ -e ${paths[i]} || -L ${paths[i]} ]]; then + cp -a "${paths[i]}" "$work/backup-$i" + fi +done + +cleanup() { + close_windows '^org\.omarchy\.ime-test$' + systemctl --user stop omarchy-fcitx5.service + for (( i = 0; i < ${#paths[@]}; i++ )); do + rm -rf "${paths[i]}" + if [[ -e $work/backup-$i || -L $work/backup-$i ]]; then + mkdir -p "$(dirname "${paths[i]}")" + cp -a "$work/backup-$i" "${paths[i]}" + fi + done + busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig >/dev/null + systemctl --user start omarchy-fcitx5.service + rm -rf "$work" +} +trap cleanup EXIT + +cat > "$work/reader" <<'SH' +#!/bin/bash +IFS= read -r text +printf '%s' "$text" > "$1" +sleep 1 +SH +chmod +x "$work/reader" + +installed=() +for method in mozc hangul pinyin chewing; do + omarchy-setup-input "$method" > "$ARTIFACTS/setup-input-$method.log" 2>&1 || + fail "$method setup completes" "$(cat "$ARTIFACTS/setup-input-$method.log")" + group=$(fcitx5-remote -q) + info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group") + installed+=("$method") + for retained in "${installed[@]}"; do + jq -e --arg method "$retained" '.data[1] | any(.[0] == $method)' <<< "$info" >/dev/null || fail "$retained is still registered" + done + owner_pid=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetConnectionUnixProcessID s org.fcitx.Fcitx5 | awk '{print $2}') + unit_pid=$(systemctl --user show omarchy-fcitx5.service --property MainPID --value) + [[ $owner_pid == "$unit_pid" ]] || fail "Fcitx bus owner is the supervised process" + + launch_app "foot --app-id=org.omarchy.ime-test $work/reader $work/result" + wait_until "$method test terminal opens" 15 window_present '^org\.omarchy\.ime-test$' + [[ $(fcitx5-remote) == "1" ]] || fail "$method setup starts in Latin mode" + fcitx5-remote -s "$method" + fcitx5-remote -c + wtype -M ctrl -M shift -k space -m shift -m ctrl + case "$method" in + mozc) wtype "nihongo"; wtype -k space ;; + hangul) wtype "gksrmf" ;; + pinyin) wtype "nihao"; wtype -k space ;; + chewing) wtype "su3cl3" ;; + esac + sleep 1 + screenshot "success-input-$method-composition" + wtype -k Return + sleep 0.3 + wtype -k Return + wait_until "$method commits text" 15 test -s "$work/result" + case "$method" in + mozc) grep -Fxq "日本語" "$work/result" || fail "Mozc commits Japanese" ;; + hangul) grep -Fxq "한글" "$work/result" || fail "Hangul commits Korean" ;; + *) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$method commits Han characters" ;; + esac + pass "$method commits composed text" + fcitx5-remote -c + close_windows '^org\.omarchy\.ime-test$' + rm -f "$work/result" +done diff --git a/test/shell.d/hyprland-jis-bindings-test.sh b/test/shell.d/hyprland-jis-bindings-test.sh new file mode 100755 index 00000000..5a842d8f --- /dev/null +++ b/test/shell.d/hyprland-jis-bindings-test.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +# Load the JIS bindings on a jp layout and fire one of them in a fake window, +# printing what it hands to Hyprland. TEST_FCITX5_CONFIG is the fcitx5 config, +# or unset for none. +fire() { + local keys="$1" class="$2" tags="${3-}" + local home="$test_dir/home" + + rm -rf "$home" + mkdir -p "$home/.config/fcitx5" + if [[ -v TEST_FCITX5_CONFIG ]]; then + printf '%s\n' "$TEST_FCITX5_CONFIG" >"$home/.config/fcitx5/config" + fi + + if [[ ${TEST_KEYBOARD_ONLY:-false} == "true" ]]; then + printf '[Groups/0/Items/0]\nName=keyboard-us\n' > "$home/.config/fcitx5/profile" + else + printf '[Groups/0/Items/0]\nName=keyboard-us\n[Groups/0/Items/1]\nName=mozc\n' > "$home/.config/fcitx5/profile" + fi + + HOME="$home" XDG_CONFIG_HOME="$home/.config" OMARCHY_PATH="$ROOT" \ + TEST_KEYS="$keys" TEST_CLASS="$class" TEST_TAGS="$tags" TEST_LAYOUT="${TEST_LAYOUT:-jp}" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local real_open = io.open +io.open = function(path, mode) + if path == "/etc/vconsole.conf" then + local file = io.tmpfile() + file:write("XKBLAYOUT=" .. os.getenv("TEST_LAYOUT") .. "\n") + file:seek("set") + return file + end + + return real_open(path, mode) +end + +local binds = {} +o = { + bind = function(keys, description, dispatcher, options) + binds[keys] = { dispatcher = dispatcher, options = options or {} } + end, +} + +local window = { class = os.getenv("TEST_CLASS"), tags = {} } +for tag in os.getenv("TEST_TAGS"):gmatch("[^,]+") do + table.insert(window.tags, tag) +end + +hl = { + dsp = { + send_key_state = function(key) + return ("send %s %s %s"):format(key.mods, key.key, key.state) + end, + }, + dispatch = function(action) print(action) end, + timer = function(callback) callback() end, + get_active_window = function() return window end, + exec_cmd = function(command) print("exec " .. command) end, +} + +o.shell_succeeds = function(command) print("exec " .. command) end + +require("default.hypr.bindings.input-method") +require("default.hypr.bindings.jis") + +local bind = binds[os.getenv("TEST_KEYS")] +if bind.options.non_consuming then + print("non_consuming") +end +bind.dispatcher() +LUA +} + +assert_fires() { + local description="$1" expected="$2" + shift 2 + local actual + + actual=$(fire "$@") + [[ $actual == "$expected" ]] || + fail "$description" "expected: $expected"$'\n'"actual: $actual" + pass "$description" +} + +zoom="CTRL + SHIFT + semicolon" +assert_fires "the JIS + key zooms in with Ctrl + keypad plus" \ + $'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" "chromium" +assert_fires "the JIS + key zooms in windows without a class" \ + $'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" "" +assert_fires "the JIS + key zooms Obsidian in with Ctrl + ^" \ + $'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "obsidian" +assert_fires "the JIS + key zooms the Obsidian Flatpak in with Ctrl + ^" \ + $'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "md.obsidian.Obsidian" + +# The prefix bind has to pass Ctrl + Space on, or tmux and Herdr never see it. +prefix="CTRL + SPACE" +freed=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n\n[Hotkey/ActivateKeys]\n0=Henkan' +TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix switches to direct input once Ctrl + Space no longer toggles" \ + $'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$freed assert_fires "the prefix leaves input alone outside terminals" \ + "non_consuming" "$prefix" "chromium" "browser" + +# While fcitx5 still toggles on Ctrl + Space, switching to direct input would +# undo the toggle it just made. +assert_fires "the prefix leaves input alone without an fcitx5 config" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$'[Behavior]\nShareInputState=No' assert_fires "the prefix leaves input alone with the default trigger keys" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Control+space' assert_fires "the prefix leaves input alone while Ctrl + Space is a trigger key" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" + +for layout in us kr; do + TEST_LAYOUT="$layout" TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix works with $layout keyboards too" \ + $'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "foot" "terminal" +done + +TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \ + "non_consuming" "$prefix" "foot" "terminal" diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh index cd2bc5e2..6bc4e4ac 100644 --- a/test/shell.d/hyprland-keyboard-layout-test.sh +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -13,6 +13,15 @@ local vconsole = os.getenv("OMARCHY_VCONSOLE") local real_open = io.open io.open = function(path, mode) + if path == "/etc/omarchy/input-method" then + local preference = os.getenv("TEST_INPUT_PREFERENCE") + if not preference then return nil end + local file = io.tmpfile() + file:write(preference) + file:seek("set") + return file + end + if path ~= "/etc/vconsole.conf" then return real_open(path, mode) end @@ -128,3 +137,5 @@ sddm_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$sddm_lua" | grep -o ' [[ $hooks_layouts == "$sddm_layouts" ]] || fail "greeter non-latin layout list stays in sync" "$(diff <(echo "$hooks_layouts") <(echo "$sddm_layouts"))" pass "greeter non-latin layout list stays in sync with the initramfs hook" + +TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\n XKB_LAYOUT="kr" # desktop only\n' assert_input "Korean selection overrides desktop layout and clears the US variant" "[kr] [] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n' diff --git a/test/shell.d/input-method-checks.py b/test/shell.d/input-method-checks.py new file mode 100644 index 00000000..0b595ea6 --- /dev/null +++ b/test/shell.d/input-method-checks.py @@ -0,0 +1,251 @@ +import argparse +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(os.environ["OMARCHY_PATH"]) +spec = importlib.util.spec_from_file_location("input_setup", ROOT / "default/input-methods/configure.py") +setup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(setup) + + +class InputMethodTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.config = self.home / "config" + self.vconsole = self.home / "vconsole" + self.preference = self.home / "preference" + self.vconsole.write_text("XKBLAYOUT=us\n") + self.preference.write_text("INPUT_METHOD=none\n") + env = {"XDG_CONFIG_HOME": str(self.config), "XDG_DATA_HOME": str(self.home / "data"), "OMARCHY_VCONSOLE": str(self.vconsole), "OMARCHY_INPUT_SELECTION": str(self.preference)} + self.environment = patch.dict(os.environ, env) + self.environment.start() + self.addCleanup(self.environment.stop) + self.process = patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, stdout="", stderr="")) + self.process.start() + self.addCleanup(self.process.stop) + + def seed(self, method=None): + setup.configure(argparse.Namespace(method=method, seed=True, defaults=False)) + + def test_every_installer_engine_is_seeded_and_inactive(self): + for method in ("mozc", "hangul", "pinyin", "chewing"): + with self.subTest(method=method): + profile = self.config / "fcitx5/profile" + profile.unlink(missing_ok=True) + self.preference.write_text(f"INPUT_METHOD={method}\nXKB_LAYOUT={'kr' if method == 'hangul' else ''}\n") + self.seed() + data = setup.sections(profile.read_text()) + self.assertEqual(data["Groups/0"]["DefaultIM"], method) + self.assertEqual(data["Groups/0/Items/1"]["Name"], method) + self.assertEqual(data["Groups/0/Items/0"]["Name"], "keyboard-kr" if method == "hangul" else "keyboard-us") + keys = setup.sections((self.config / "fcitx5/config").read_text()) + self.assertEqual(keys["Behavior"]["ActiveByDefault"], "False") + self.assertNotIn("Control+space", keys["Hotkey/TriggerKeys"].values()) + self.assertIn("Control+Shift+space", keys["Hotkey/TriggerKeys"].values()) + + def test_vconsole_variants_quotes_and_japanese_without_preference(self): + self.preference.unlink() + self.vconsole.write_text('XKBLAYOUT="jp" # JIS\n') + self.assertEqual(setup.selection(), ("mozc", "jp")) + self.vconsole.write_text("XKBLAYOUT='de'\nXKBVARIANT=nodeadkeys\n") + self.seed() + self.assertIn("Default Layout=de-nodeadkeys", (self.config / "fcitx5/profile").read_text()) + self.vconsole.write_text("XKBLAYOUT=ru\nXKBVARIANT=phonetic\n") + self.assertEqual(setup.selection(), ("none", "us")) + + def test_rerun_preserves_custom_multilingual_profile(self): + self.seed("mozc") + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + b"\n# custom configuration\n" + profile.write_bytes(original) + self.seed("hangul") + self.assertEqual(profile.read_bytes(), original) + + def test_stock_profile_is_repaired_but_custom_single_keyboard_is_not(self): + self.seed() + self.vconsole.write_text("XKBLAYOUT=fr\n") + self.seed() + profile = self.config / "fcitx5/profile" + self.assertIn("keyboard-fr", profile.read_text()) + self.seed("mozc") + self.assertNotIn("Name=mozc", profile.read_text()) + + def test_existing_shortcuts_and_addon_options_are_preserved(self): + path = self.config / "fcitx5/config" + setup.atomic_write(path, "# user's keys\n[Hotkey/TriggerKeys]\n0=Alt+space\n\n[Behavior]\nActiveByDefault=True\n") + quickphrase = self.config / "fcitx5/conf/quickphrase.conf" + setup.atomic_write(quickphrase, "[TriggerKey]\n\n") + setup.defaults(self.config, fresh=True) + config = setup.sections(path.read_text()) + self.assertEqual(config["Hotkey/TriggerKeys"], {"0": "Alt+space"}) + self.assertEqual(config["Behavior"], {"ActiveByDefault": "True"}) + self.assertEqual(quickphrase.read_text(), "[TriggerKey]\n\n") + + def test_generated_stock_shortcuts_are_repaired(self): + path = self.config / "fcitx5/config" + setup.atomic_write(path, "[Hotkey/TriggerKeys]\n0=Control+space\n1=Zenkaku_Hankaku\n2=Hangul\n\n[Hotkey/EnumerateGroupForwardKeys]\n0=Super+space\n") + setup.defaults(self.config, fresh=True) + config = setup.sections(path.read_text()) + self.assertEqual(config["Hotkey/EnumerateGroupForwardKeys"], {}) + self.assertNotIn("Control+space", config["Hotkey/TriggerKeys"].values()) + + def test_live_append_keeps_every_method_and_layout(self): + info = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"]]]}) + updated = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"], ["hangul", ""]]]}) + with patch.object(setup, "run", side_effect=["Work", info, "", "", "Work", updated]) as calls: + setup.live_append("hangul") + self.assertEqual(calls.call_args_list[2].args[0][-10:], ["ssa(ss)", "Work", "de-nodeadkeys", "3", "keyboard-de-nodeadkeys", "", "mozc", "jp", "hangul", ""]) + self.assertEqual(calls.call_args_list[3].args[0][-1], "Save") + info = json.dumps({"data": ["us", [["keyboard-us", ""], ["hangul", ""]]]}) + with patch.object(setup, "run", side_effect=["Default", info]) as calls: + setup.live_append("hangul") + self.assertEqual(calls.call_count, 2) + + def test_seed_in_a_live_desktop_is_a_harmless_retry(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)): + self.seed("mozc") + self.assertEqual(profile.read_bytes(), original) + + def test_failed_seed_rolls_back_so_it_can_be_retried(self): + with patch.object(setup, "defaults", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.seed("mozc") + self.assertFalse((self.config / "fcitx5/profile").exists()) + self.seed("mozc") + self.assertIn("Name=mozc", (self.config / "fcitx5/profile").read_text()) + + def test_failed_live_setup_restores_disk_and_restarts_the_daemon(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + events = [] + def append(method): + profile.write_text("failed in-memory state") + raise RuntimeError("save failed") + def command(args): + events.append(args) + if args == ["omarchy-restart-xcompose"] and len(events) > 2: + self.assertEqual(profile.read_bytes(), original) + return "" + probes = 0 + def process(args, **kwargs): + nonlocal probes + if args[0] == "pgrep": + probes += 1 + return subprocess.CompletedProcess([], 0 if probes == 1 else 1) + return subprocess.CompletedProcess([], 0) + with patch.object(setup.subprocess, "run", side_effect=process), patch.object(setup, "run", side_effect=command), patch.object(setup, "wait_ready"), patch.object(setup, "live_append", side_effect=append), patch.object(setup.time, "sleep"): + with self.assertRaisesRegex(RuntimeError, "save failed"): + setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False)) + self.assertEqual(profile.read_bytes(), original) + self.assertIn(["systemctl", "--user", "stop", "omarchy-fcitx5.service"], events) + self.assertEqual(events.count(["omarchy-restart-xcompose"]), 2) + + def test_readiness_checks_the_dbus_array_payload_and_group(self): + available = json.dumps({"data": [[["mozc", "Mozc", "", "", "", "ja", False]]]}) + with patch.object(setup, "run", return_value=available), patch.object(setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])): + setup.wait_ready("mozc") + + def test_a_silently_dropped_engine_is_rejected_and_group_restored(self): + old = ("Default", "us", [["keyboard-us", ""]]) + with patch.object(setup, "live_group", side_effect=[old, old]), patch.object(setup, "live_set") as setter: + with self.assertRaisesRegex(RuntimeError, "retain"): + setup.live_append("mozc") + self.assertEqual(setter.call_args_list[-1].args, old) + + def test_migration_without_a_bus_keeps_written_activation_and_defaults(self): + self.seed() + with patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")): + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertTrue((self.home / "data/dbus-1/services/org.fcitx.Fcitx5.service").exists()) + + def test_live_migration_without_a_bus_does_not_restart_or_rewrite_profile(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")) as calls: + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertEqual(profile.read_bytes(), original) + self.assertFalse(any(call.args[0] == ["omarchy-restart-xcompose"] for call in calls.call_args_list)) + + def test_migration_does_not_add_an_engine_the_old_daemon_cannot_load(self): + self.seed() + self.vconsole.write_text("XKBLAYOUT=jp\n") + self.preference.write_text("INPUT_METHOD=mozc\n") + old = ("Default", "us", [["keyboard-us", ""]]) + new = ("Default", "jp", [["keyboard-jp", ""]]) + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "live_group", side_effect=[old, new]), patch.object(setup, "available_methods", return_value=[]), patch.object(setup, "live_set") as setter: + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertEqual(setter.call_args.args, new) + + def test_custom_keyboard_overrides_are_not_stock(self): + self.assertFalse(setup.stock_profile(setup.profile_text("de", "none").replace("keyboard-de", "keyboard-us"))) + self.assertFalse(setup.stock_profile(setup.profile_text("us", "none").replace("Layout=\n", "Layout=ru\n"))) + + def test_selected_live_engine_is_verified_after_the_context_reconnects(self): + self.seed("mozc") + profile = self.config / "fcitx5/profile" + saves = 0 + def command(args): + nonlocal saves + if args == setup.CONTROLLER + ["Save"]: + saves += 1 + if saves == 2: + profile.write_text(profile.read_text().replace("DefaultIM=mozc", "DefaultIM=hangul")) + return "Default" if args == ["fcitx5-remote", "-q"] else "" + with patch.object(setup, "run", side_effect=command) as commands, patch.object(setup.time, "sleep"): + setup.select_live_method("hangul", profile) + self.assertEqual(saves, 2) + self.assertEqual(sum(call.args[0] == ["fcitx5-remote", "-c"] for call in commands.call_args_list), 2) + + def test_live_setup_does_not_validate_an_unused_install_layout(self): + self.seed("mozc") + self.vconsole.write_text("XKBLAYOUT=invalid value\n") + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", return_value=""), patch.object(setup, "wait_ready"), patch.object(setup, "live_append"), patch.object(setup, "select_live_method"): + setup.configure(argparse.Namespace(method="hangul", seed=False, defaults=False)) + + def test_live_configuration_requires_a_desktop(self): + with self.assertRaisesRegex(RuntimeError, "Log in"): + setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False)) + self.assertFalse((self.config / "fcitx5/profile").exists()) + + def test_atomic_updates_keep_symlinks_and_modes(self): + target = self.home / "real-config" + target.write_text("before") + target.chmod(0o600) + link = self.home / "link" + link.symlink_to(target) + setup.atomic_write(link, "after") + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_text(), "after") + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + + def test_input_preferences_are_data_and_invalid_values_fail(self): + self.preference.write_text("INPUT_METHOD=$(touch /tmp/should-not-exist)\n") + with self.assertRaises(ValueError): + self.seed() + + def test_font_fallback_does_not_replace_existing_preference(self): + setup.font_default(self.config, "mozc") + font = self.config / "fontconfig/conf.d/50-omarchy-input-method.conf" + self.assertIn("Noto Sans CJK JP", font.read_text()) + self.assertIn("Noto Sans Mono CJK JP", font.read_text()) + original = font.read_bytes() + setup.font_default(self.config, "hangul") + self.assertEqual(font.read_bytes(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/shell.d/input-method-test.sh b/test/shell.d/input-method-test.sh new file mode 100644 index 00000000..054d1d7b --- /dev/null +++ b/test/shell.d/input-method-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/input-method-checks.py" diff --git a/test/shell.d/owe-integration-test.sh b/test/shell.d/owe-integration-test.sh index cde79db2..c229dcab 100644 --- a/test/shell.d/owe-integration-test.sh +++ b/test/shell.d/owe-integration-test.sh @@ -6,11 +6,12 @@ work=$(mktemp -d) trap 'rm -rf "$work"' EXIT mkdir -p "$work/bin" "$work/home" export TEST_CALLS="$work/calls" -for command in omarchy-pkg-add omarchy-hook-install systemctl; do +for command in omarchy-pkg-add omarchy-hook-install omarchy-input-method systemctl; do cat >"$work/bin/$command" <<'SH' #!/bin/bash printf '%s %s\n' "${0##*/}" "$*" >>"$TEST_CALLS" case "$*" in + 'configure --defaults') [[ ${TEST_INPUT_FAIL:-0} == 0 ]] ;; '--user enable owed.service') [[ ${TEST_TTY:-0} == 0 ]] ;; '--user is-active --quiet graphical-session.target') [[ ${TEST_TTY:-0} == 0 ]] ;; '--user start owed.service') [[ ${TEST_START_FAIL:-0} == 0 ]] ;; @@ -58,6 +59,12 @@ grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null pass "fresh installs enable OWE and install the theme refresh hook" +: >"$TEST_CALLS" +HOME="$work/home" PATH="$work/bin:$PATH" TEST_INPUT_FAIL=1 bash "$ROOT/install/user/first-run/enable-user-units.sh" +grep -Fx 'omarchy-input-method configure --defaults' "$TEST_CALLS" >/dev/null +grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null +pass "failed input defaults do not block enabling first-run services" + cat >"$work/bin/ffmpegthumbnailer" <<'SH' #!/bin/bash printf 'thumbnail\n' >>"$TEST_CALLS" diff --git a/test/shell.d/setup-form-test.sh b/test/shell.d/setup-form-test.sh index 2b9878eb..1cefa560 100755 --- a/test/shell.d/setup-form-test.sh +++ b/test/shell.d/setup-form-test.sh @@ -63,6 +63,8 @@ trap 'if [[ ${FUNCNAME[0]:-} == "$PROMPT_FN" ]]; then printf "returned\n" >>"$MA printf 'keyboard=%s\n' "${keyboard:-}" printf 'keyboard_label=%s\n' "${keyboard_label:-}" +printf 'keyboard_input_method=%s\n' "${keyboard_input_method:-}" +printf 'keyboard_xkb_layout=%s\n' "${keyboard_xkb_layout:-}" printf 'username=%s\n' "${username:-}" printf 'password=%s\n' "${password:-}" printf 'password_confirmation=%s\n' "${password_confirmation:-}" @@ -133,6 +135,19 @@ assert_status 0 "keyboard prompt succeeds" grep -qF -- '--selected English (US)' "$GUM_ARGS" || fail "keyboard prompt preselects English (US)" pass "keyboard prompt maps the chosen label to its keymap" +for selection in 'Japanese|jp106|mozc|' 'Japanese (US keyboard)|us|mozc|' 'Korean|us|hangul|kr' 'Chinese (Simplified, Pinyin)|us|pinyin|' 'Chinese (Traditional, Zhuyin)|us|chewing|'; do + IFS='|' read -r label keymap method layout <<< "$selection" + run_prompt omarchy_prompt_keyboard "0:$label" + assert_status 0 "$label can be selected at install" + [[ $(field keyboard) == "$keymap" && $(field keyboard_input_method) == "$method" && $(field keyboard_xkb_layout) == "$layout" ]] || + fail "$label carries a real console keymap and a separate input preference" +done +pass "language keyboard choices carry their keymap, input method, and desktop override" + +keyboard_input_method=hangul keyboard_xkb_layout=kr omarchy_persist_input_selection "$tmp_dir/target" +[[ $(<"$tmp_dir/target/etc/omarchy/input-method") == $'INPUT_METHOD=hangul\nXKB_LAYOUT=kr' ]] || fail "first-boot setup persists the selection for user finalization" +pass "first-boot setup persists the selection for user finalization" + run_prompt omarchy_prompt_keyboard "1:" assert_status "$OMARCHY_FORM_BACK" "keyboard prompt reports Esc as back" assert_returned "keyboard prompt survives Esc under set -e" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index ce7b79b7..66074c8b 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -71,7 +71,7 @@ grep -Fx 'WantedBy=graphical-session.target' "$fcitx_service" >/dev/null || grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$fcitx_service" >/dev/null || fail "an update over SSH has a live user manager and no display; starting fcitx5 there wedges the unit active-but-blind, and Wants= will not replace it at graphical login" -grep -F 'pkill -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null || +grep -F 'pkill -u "$UID" -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null || fail "restart-xcompose cannot reload a fcitx5 running outside the unit, so it silently keeps serving the old table" grep -F 'omarchy-fcitx5.service' "$first_run_units" >/dev/null || From 7b68fee056847e4be9debc5df7da3bac37194470 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:15:00 +0200 Subject: [PATCH 16/29] 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 17/29] 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 18/29] 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 19/29] 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 0f14a8493457294c426210f1cd18874605fe4ca2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 15:19:06 +0200 Subject: [PATCH 20/29] Preserve inherited input keys and later keyboard changes --- default/hypr/keyboard.lua | 4 +++- default/input-methods/configure.py | 5 +++-- test/shell.d/hyprland-keyboard-layout-test.sh | 4 +++- test/shell.d/input-method-checks.py | 13 +++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/default/hypr/keyboard.lua b/default/hypr/keyboard.lua index 46b9a4a5..e8ec2457 100644 --- a/default/hypr/keyboard.lua +++ b/default/hypr/keyboard.lua @@ -31,7 +31,9 @@ end function keyboard.installed() local values = keyboard.vconsole() - if read_values("/etc/omarchy/input-method").XKB_LAYOUT == "kr" then + if read_values("/etc/omarchy/input-method").XKB_LAYOUT == "kr" + and (values.XKBLAYOUT or "us") == "us" + and (values.XKBVARIANT or "") == "" then values.XKBLAYOUT, values.XKBVARIANT = "kr", "" end return values diff --git a/default/input-methods/configure.py b/default/input-methods/configure.py index 0ec5751b..dc98f0ae 100644 --- a/default/input-methods/configure.py +++ b/default/input-methods/configure.py @@ -83,7 +83,8 @@ def defaults(config_home, fresh=False): existing = sections(original) additions = [] for section, entries in sections(shipped).items(): - if not fresh and section == "Hotkey/TriggerKeys": + # Missing hotkey sections also represent a choice: Fcitx's defaults. + if not fresh and section.startswith("Hotkey/"): continue # Existing lists (including explicitly empty lists) are user choices. # A fresh profile has no working IME shortcut to preserve. @@ -163,7 +164,7 @@ def selection(): preference = values(read(Path(os.environ.get("OMARCHY_INPUT_SELECTION", "/etc/omarchy/input-method")))) layout = vconsole.get("XKBLAYOUT", "us").split(",")[0] or "us" variant = vconsole.get("XKBVARIANT", "").split(",")[0] - if preference.get("XKB_LAYOUT") == "kr": + if preference.get("XKB_LAYOUT") == "kr" and vconsole.get("XKBLAYOUT", "us") == "us" and not variant: layout, variant = "kr", "" method = preference.get("INPUT_METHOD", "mozc" if layout == "jp" else "none") if method not in PRESETS: diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh index 6bc4e4ac..10aaddb6 100644 --- a/test/shell.d/hyprland-keyboard-layout-test.sh +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -138,4 +138,6 @@ sddm_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$sddm_lua" | grep -o ' fail "greeter non-latin layout list stays in sync" "$(diff <(echo "$hooks_layouts") <(echo "$sddm_layouts"))" pass "greeter non-latin layout list stays in sync with the initramfs hook" -TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\n XKB_LAYOUT="kr" # desktop only\n' assert_input "Korean selection overrides desktop layout and clears the US variant" "[kr] [] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n' +TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\n XKB_LAYOUT="kr" # desktop only\n' assert_input "Korean selection overrides its installer US layout" "[kr] [] [$base_options]" $'XKBLAYOUT=us\n' +TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later US variant overrides the Korean preference" "[us] [intl] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n' +TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later layout overrides the Korean preference" "[de] [nodeadkeys] [$base_options]" $'XKBLAYOUT=de\nXKBVARIANT=nodeadkeys\n' diff --git a/test/shell.d/input-method-checks.py b/test/shell.d/input-method-checks.py index 0b595ea6..98b0fb14 100644 --- a/test/shell.d/input-method-checks.py +++ b/test/shell.d/input-method-checks.py @@ -98,6 +98,19 @@ class InputMethodTest(unittest.TestCase): self.assertEqual(config["Hotkey/EnumerateGroupForwardKeys"], {}) self.assertNotIn("Control+space", config["Hotkey/TriggerKeys"].values()) + def test_existing_engine_inherited_hotkeys_are_preserved(self): + path = self.config / "fcitx5/config" + setup.atomic_write(path, "[Behavior]\nActiveByDefault=True\n") + setup.defaults(self.config, fresh=False) + self.assertEqual(path.read_text(), "[Behavior]\nActiveByDefault=True\n") + + def test_korean_preference_yields_to_a_later_keyboard_change(self): + self.preference.write_text("INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n") + self.assertEqual(setup.selection(), ("hangul", "kr")) + for layout, variant, expected in [("de", "nodeadkeys", "de-nodeadkeys"), ("us", "intl", "us-intl")]: + self.vconsole.write_text(f"XKBLAYOUT={layout}\nXKBVARIANT={variant}\n") + self.assertEqual(setup.selection(), ("hangul", expected)) + def test_live_append_keeps_every_method_and_layout(self): info = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"]]]}) updated = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"], ["hangul", ""]]]}) From 22514b59f2d98e01870aaed04b5b8a1e757b3b17 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 15:59:46 +0200 Subject: [PATCH 21/29] Exercise input engines across desktop toolkits --- test/acceptance.d/input-methods-test.sh | 114 ++++++++++++++++++++---- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/test/acceptance.d/input-methods-test.sh b/test/acceptance.d/input-methods-test.sh index c147e894..e0504b12 100644 --- a/test/acceptance.d/input-methods-test.sh +++ b/test/acceptance.d/input-methods-test.sh @@ -5,6 +5,19 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +selection=$(omarchy-input-method status) +initial_method=$(jq -r .installed_method <<< "$selection") +initial_layout=$(jq -r .layout <<< "$selection") +group=$(fcitx5-remote -q) +initial_info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group") +jq -e --arg layout "$initial_layout" '.data[0] == $layout' <<< "$initial_info" >/dev/null || + fail "installed keyboard layout reaches Fcitx" +if [[ $initial_method != "none" ]]; then + jq -e --arg method "$initial_method" '.data[1] | any(.[0] == $method)' <<< "$initial_info" >/dev/null || + fail "installer-selected engine is ready before live setup" +fi +pass "installer input selection reaches the first desktop session" + work=$(mktemp -d) config_home=${XDG_CONFIG_HOME:-$HOME/.config} data_home=${XDG_DATA_HOME:-$HOME/.local/share} @@ -17,6 +30,7 @@ done cleanup() { close_windows '^org\.omarchy\.ime-test$' + close_windows '^org\.omarchy\.ime-test-gtk$|^org\.qt-project\.qml$|^chrome-.*_omarchy-ime-entry\.html-Default$' systemctl --user stop omarchy-fcitx5.service for (( i = 0; i < ${#paths[@]}; i++ )); do rm -rf "${paths[i]}" @@ -39,6 +53,65 @@ sleep 1 SH chmod +x "$work/reader" +cat > "$work/entry.py" <<'PY' +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + +app = Gtk.Application(application_id="org.omarchy.ime-test-gtk") +def activate(app): + window = Gtk.ApplicationWindow(application=app, title="GTK input test") + window.set_default_size(600, 160) + entry = Gtk.Entry() + window.set_child(entry) + window.present() + entry.grab_focus() +app.connect("activate", activate) +app.run() +PY + +cat > "$work/entry.qml" <<'QML' +import QtQuick +import QtQuick.Controls +ApplicationWindow { + visible: true + width: 600 + height: 160 + title: "Qt input test" + TextField { + anchors.fill: parent + focus: true + } +} +QML + +cat > "$work/omarchy-ime-entry.html" <<'HTML' +Browser input test + +HTML + +compose() { + fcitx5-remote -s "$method" + fcitx5-remote -c + wtype -M ctrl -M shift -k space -m shift -m ctrl + case "$method" in + mozc) wtype "nihongo"; wtype -k space ;; + hangul) wtype "gksrmf" ;; + pinyin) wtype "nihao"; wtype -k space ;; + chewing) wtype "su3cl3" ;; + esac + sleep 1 +} + +verify_text() { + case "$method" in + mozc) grep -Fxq "日本語" "$work/result" || fail "$1 commits Japanese" ;; + hangul) grep -Fxq "한글" "$work/result" || fail "$1 commits Korean" ;; + *) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$1 commits Han characters" ;; + esac + pass "$1 commits composed text" +} + installed=() for method in mozc hangul pinyin chewing; do omarchy-setup-input "$method" > "$ARTIFACTS/setup-input-$method.log" 2>&1 || @@ -56,28 +129,37 @@ for method in mozc hangul pinyin chewing; do launch_app "foot --app-id=org.omarchy.ime-test $work/reader $work/result" wait_until "$method test terminal opens" 15 window_present '^org\.omarchy\.ime-test$' [[ $(fcitx5-remote) == "1" ]] || fail "$method setup starts in Latin mode" - fcitx5-remote -s "$method" - fcitx5-remote -c - wtype -M ctrl -M shift -k space -m shift -m ctrl - case "$method" in - mozc) wtype "nihongo"; wtype -k space ;; - hangul) wtype "gksrmf" ;; - pinyin) wtype "nihao"; wtype -k space ;; - chewing) wtype "su3cl3" ;; - esac - sleep 1 + compose screenshot "success-input-$method-composition" wtype -k Return sleep 0.3 wtype -k Return wait_until "$method commits text" 15 test -s "$work/result" - case "$method" in - mozc) grep -Fxq "日本語" "$work/result" || fail "Mozc commits Japanese" ;; - hangul) grep -Fxq "한글" "$work/result" || fail "Hangul commits Korean" ;; - *) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$method commits Han characters" ;; - esac - pass "$method commits composed text" + verify_text "$method terminal" fcitx5-remote -c close_windows '^org\.omarchy\.ime-test$' rm -f "$work/result" + + for toolkit in gtk qt browser; do + case "$toolkit" in + gtk) command="python $work/entry.py"; class='^org\.omarchy\.ime-test-gtk$' ;; + qt) command="qml6 $work/entry.qml"; class='^org\.qt-project\.qml$' ;; + browser) command="chromium --user-data-dir=$work/chromium --no-first-run --app=file://$work/omarchy-ime-entry.html"; class='^chrome-.*_omarchy-ime-entry\.html-Default$' ;; + esac + launch_app "uwsm app -- $command" + wait_until "$method $toolkit opens" 30 window_present "$class" + sleep 1 + compose + screenshot "success-input-$method-$toolkit-composition" + wtype -k Return + fcitx5-remote -c + wl-copy --clear + wtype -M ctrl -k a -k c -m ctrl + sleep 0.3 + wl-paste --no-newline > "$work/result" || fail "$method $toolkit copies committed text" + verify_text "$method $toolkit" + screenshot "success-input-$method-$toolkit-committed" + close_windows "$class" + wait_until "$toolkit closes" 15 window_absent "$class" + done done From fcda55944a23c19613982273856310149e582652 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 16:57:03 +0200 Subject: [PATCH 22/29] Show input modes in the keyboard indicator --- default/input-methods/indicator.py | 66 +++++++++++++++++++ install/user/first-run/welcome.sh | 5 ++ manual/34-keyboard-mouse-trackpad.md | 2 + .../bar/widgets/KeyboardLayout.manifest.json | 4 +- shell/plugins/bar/widgets/KeyboardLayout.qml | 53 +++++++++++++-- .../bar/widgets/KeyboardLayoutModel.js | 27 +++++++- test/shell.d/input-indicator-test.sh | 42 ++++++++++++ test/shell.d/keyboard-layout-test.sh | 16 +++++ 8 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 default/input-methods/indicator.py create mode 100755 test/shell.d/input-indicator-test.sh diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py new file mode 100644 index 00000000..95e022f4 --- /dev/null +++ b/default/input-methods/indicator.py @@ -0,0 +1,66 @@ +"""Live Fcitx state for the existing keyboard layout widget.""" + +import json +import sys + +from gi.repository import Gio, GLib + + +BUS = "org.fcitx.Fcitx5" +INTERFACE = "org.fcitx.Fcitx.Controller1" + + +def call(bus, method, args=None): + # Reading the bar must neither activate a stopped service nor wait indefinitely. + return bus.call_sync(BUS, "/controller", INTERFACE, method, args, None, + Gio.DBusCallFlags.NO_AUTO_START, 1000, None).unpack() + + +def snapshot(bus): + group = call(bus, "CurrentInputMethodGroup")[0] + methods = list(dict.fromkeys(item[0] for item in + call(bus, "InputMethodGroupInfo", GLib.Variant("(s)", (group,)))[1])) + info = call(bus, "CurrentInputMethodInfo") + return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]} + + +def cycle(bus): + state = snapshot(bus) + methods = state["methods"] + if len(methods) > 1 and state["current"] in methods: + following = methods[(methods.index(state["current"]) + 1) % len(methods)] + call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) + + +def watch(bus): + previous = None + + def refresh(): + nonlocal previous + try: + state = snapshot(bus) + except GLib.Error: + state = {"methods": [], "current": "", "name": "", "language": ""} + line = json.dumps(state, ensure_ascii=False) + if line != previous: + print(line, flush=True) + previous = line + return GLib.SOURCE_CONTINUE + + # Fcitx's controller has no current-method-changed signal. Keep one bus + # connection open instead of spawning a command on every poll. Polling also + # follows per-application input contexts and recovers after service restarts. + refresh() + GLib.timeout_add(500, refresh) + GLib.MainLoop().run() + + +if __name__ == "__main__": + try: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + if sys.argv[1:] == ["cycle"]: + cycle(bus) + else: + watch(bus) + except GLib.Error as error: + raise SystemExit(str(error)) diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 22e6629e..f345f32e 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -3,3 +3,8 @@ omarchy-notification-send -u critical -g  "Learn Keybindings" \ $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \ --exec omarchy-menu-keybindings + +if [[ $(omarchy-input-method status | jq -r .installed_method) == "mozc" ]]; then + omarchy-notification-send -u critical "Japanese input is ready" \ + $'Click the keyboard indicator in the bar to switch input.\nType nihongo, then press Space twice for character choices.' +fi diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 202f46ad..0dd32753 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -63,6 +63,8 @@ Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Typing starts in Latin mode. Press `Ctrl + Shift + Space` to switch input on or off. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key. +The keyboard indicator in the menu bar appears for non-English keyboards and whenever you have multiple layouts or input modes. Click it to switch input languages; Japanese displays あ for hiragana. With only one input mode, clicking opens input language setup. For Japanese character choices, switch to Japanese, type a word such as `nihongo`, then press Space twice. Press Enter to accept your choice. If you also have multiple keyboard layouts, right-click the indicator to cycle those. + To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language. On keyboards without dedicated input keys, use `Ctrl + Shift + Space`. This input shortcut takes precedence over app shortcuts such as code completion; you can change it in `fcitx5-configtool`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session. diff --git a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json index cc5ad783..2b161dc6 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.manifest.json +++ b/shell/plugins/bar/widgets/KeyboardLayout.manifest.json @@ -4,7 +4,7 @@ "name": "Keyboard layout", "version": "1.0.0", "author": "Omarchy", - "description": "Current xkb layout, click cycles", + "description": "Current keyboard layout and input language, click cycles", "kinds": [ "bar-widget" ], @@ -13,7 +13,7 @@ }, "barWidget": { "displayName": "Keyboard layout", - "description": "Current xkb layout, click cycles", + "description": "Current keyboard layout and input language, click cycles", "category": "Compositor", "allowMultiple": false }, diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 6c3e7206..82d77c46 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -36,6 +36,9 @@ BarWidget { // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs) + property var inputState: ({}) + readonly property bool multipleInputs: (inputState.methods || []).length > 1 + readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel) // A query already in flight was started before this event, so it may read the // layout the switch replaced. Remember the request and re-run once it lands @@ -228,7 +231,40 @@ BarWidget { onTriggered: root.refresh() } - visible: layoutLabel !== "" && multipleLayouts + Process { + id: inputWatcher + command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"] + running: true + stdout: SplitParser { + onRead: data => { + try { root.inputState = JSON.parse(data) } catch (e) {} + } + } + onExited: { + root.inputState = ({}) + inputRestart.restart() + } + } + + Timer { + id: inputRestart + interval: 5000 + onTriggered: inputWatcher.running = true + } + + Process { + id: inputCycle + property int pending: 0 + command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py", "cycle"] + onExited: { + if (pending > 0) { + pending-- + running = true + } + } + } + + visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs) implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight @@ -236,10 +272,19 @@ BarWidget { id: button anchors.fill: parent bar: root.bar - text: root.layoutLabel + text: root.inputLabel fontSize: Style.font.caption horizontalMargin: 6 - tooltipText: root.layoutFull - onPressed: function() { root.cycleLayout() } + tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) + onPressed: function(button) { + if (root.multipleInputs && button === Qt.LeftButton) { + if (inputCycle.running) inputCycle.pending++ + else inputCycle.running = true + } else if (root.multipleLayouts) { + root.cycleLayout() + } else if (!root.multipleInputs && root.bar) { + root.bar.run("omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'") + } + } } } diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js index f46ee555..69ece973 100644 --- a/shell/plugins/bar/widgets/KeyboardLayoutModel.js +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -110,6 +110,28 @@ function selectKeyboard(typed, namedByEvent) { }, keyboards[0]) } +function inputLabel(state, fallback) { + if (!state || !state.current || state.current.indexOf("keyboard-") === 0) return fallback + if (state.label) return String(state.label).substring(0, 3) + var labels = { mozc: "あ", hangul: "한", pinyin: "拼", chewing: "注" } + return labels[state.current] || shortLabel(state.language || state.name, {}) +} + +function inputTooltip(state, layout, multipleLayouts) { + if (!state || !state.methods || state.methods.length < 2) + return layout + (multipleLayouts ? "" : "\nClick to set up an input language") + var text = state.name + "\nClick to switch input language" + if (state.methods.indexOf("mozc") !== -1) + text += "\nJapanese: type a word, then Space twice for character choices" + if (multipleLayouts) text += "\nRight-click to switch keyboard layout" + return text +} + +function showIndicator(layoutLabel, multipleLayouts, multipleInputs) { + return multipleInputs || (layoutLabel !== "" && + (multipleLayouts || (layoutLabel !== "EN" && layoutLabel !== "ENG"))) +} + function layoutIndex(keyboard) { return (keyboard && keyboard.active_layout_index) || 0 } @@ -120,6 +142,9 @@ if (typeof module !== "undefined") { isTypedKeyboard: isTypedKeyboard, layoutBriefs: layoutBriefs, selectKeyboard: selectKeyboard, - shortLabel: shortLabel + shortLabel: shortLabel, + inputLabel: inputLabel, + inputTooltip: inputTooltip, + showIndicator: showIndicator } } diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh new file mode 100755 index 00000000..91c8e175 --- /dev/null +++ b/test/shell.d/input-indicator-test.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +OMARCHY_PATH="$ROOT" /usr/bin/python - <<'PY' +import importlib.util +import os +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py") +indicator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(indicator) + +with patch.object(indicator, "call", side_effect=[ + ("Default",), + ("us", [("keyboard-us", ""), ("mozc", ""), ("mozc", "")]), + ("mozc", "Mozc", "", "fcitx_mozc", "あ", "ja", "mozc", True, "", {}), +]): + state = indicator.snapshot(None) + assert state == {"methods": ["keyboard-us", "mozc"], "current": "mozc", "name": "Mozc", "label": "あ", "language": "ja"} +print("ok - live state deduplicates modes and reads language independently of the engine label") + +def check_cycle(current, methods, expected): + state = {"current": current, "methods": methods} + with patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call: + indicator.cycle(None) + if expected is None: + call.assert_not_called() + else: + assert call.call_args.args[1] == "SetCurrentIM" + assert call.call_args.args[2].unpack() == (expected,) + +check_cycle("keyboard-us", ["keyboard-us", "mozc"], "mozc") +check_cycle("mozc", ["keyboard-us", "mozc"], "keyboard-us") +check_cycle("mozc", ["keyboard-us", "mozc", "hangul"], "hangul") +check_cycle("hangul", ["keyboard-us", "mozc", "hangul"], "keyboard-us") +check_cycle("keyboard-us", ["keyboard-us"], None) +check_cycle("removed", ["keyboard-us", "mozc"], None) +check_cycle("", [], None) +print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts") +PY diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh index 5158186f..a2181b1b 100755 --- a/test/shell.d/keyboard-layout-test.sh +++ b/test/shell.d/keyboard-layout-test.sh @@ -7,6 +7,22 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' const model = requireFromRoot('shell/plugins/bar/widgets/KeyboardLayoutModel.js') +const japanese = { methods: ['keyboard-us', 'mozc'], current: 'mozc', name: 'Mozc', language: 'ja' } +assertEqual(model.inputLabel(japanese, 'EN'), 'あ', 'Japanese input has a distinct active label') +assertEqual(model.inputLabel({ ...japanese, label: 'ア' }, 'EN'), 'ア', 'the live engine mode takes precedence over its default glyph') +assertEqual(model.inputLabel({ ...japanese, current: 'keyboard-us' }, 'EN'), 'EN', 'Latin input retains the keyboard label') +assertEqual(model.inputLabel({}, 'EN'), 'EN', 'a stopped input service retains the keyboard label') +assertEqual(model.inputLabel({ current: 'hangul' }, 'EN'), '한', 'Korean input has a distinct label') +assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', 'other engines use their reported language') +assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Space twice'), true, 'Japanese explains character choices') +assertEqual(model.inputTooltip(japanese, 'English (US)', true).includes('Right-click'), true, 'combined input and layout switching stays discoverable') +assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French\nClick to set up an input language', 'one foreign mode offers input setup') +assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden') +assertEqual(model.showIndicator('ENG', false, false), false, 'English before the brief table loads stays hidden') +assertEqual(model.showIndicator('FR', false, false), true, 'a single non-English layout stays available') +assertEqual(model.showIndicator('EN', false, true), true, 'Latin with another input mode stays available') +assertEqual(model.showIndicator('EN', true, false), true, 'multiple keyboard layouts stay available') + // Trimmed from xkbcli list, keeping the format of every section it prints, // including the options nested under an option group: those quote their // description and print an empty brief, one indent deeper than a layout's. From cd73dc6cfdc22be958a37a2c4708c0aadd15fc1d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:17:02 +0200 Subject: [PATCH 23/29] Keep Pinyin setup prompts visible --- shell/plugins/notifications/NotificationLogic.js | 4 +++- test/shell.d/notifications-test.sh | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b152dc06..e4d69a53 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -195,7 +195,9 @@ function snapshotOf(notification, timestamp) { image: n.image || "", glyph: glyphFromHints(n.hints), execArgv: execArgvFromHints(n.hints), - urgency: n.urgency, + // Pinyin's first-use cloud prediction question needs an explicit answer. + // Its normal urgency otherwise dismisses it before users can read it. + urgency: n.appName === "Pinyin" && n.appIcon === "fcitx-pinyin" ? 2 : n.urgency, expireTimeout: expireTimeout, timestamp: timestamp === undefined ? Date.now() : timestamp } diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 48414ed4..52665b0a 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -271,6 +271,8 @@ const notification = { expireTimeout: 1.5 } const snapshot = notifications.snapshotOf(notification, 12345) +assertEqual(notifications.snapshotOf({ appName: 'Pinyin', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 2, 'Pinyin prompts stay visible until dismissed') +assertEqual(notifications.snapshotOf({ appName: 'Other', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 1, 'other notification senders retain their urgency') assertDeepEqual( { id: snapshot.id, From 2841563e6ce8aac18cbd17dd7d6885ca2dd4b040 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:17:02 +0200 Subject: [PATCH 24/29] Remember input selections before an app gains focus --- default/input-methods/indicator.py | 81 +++++++++++++++++++- shell/Commons/InputMethodState.qml | 36 +++++++++ shell/Commons/qmldir | 1 + shell/plugins/bar/widgets/KeyboardLayout.qml | 38 +-------- test/shell.d/input-indicator-test.sh | 32 ++++++++ 5 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 shell/Commons/InputMethodState.qml diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index 95e022f4..b5e6dd6c 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -1,6 +1,7 @@ """Live Fcitx state for the existing keyboard layout widget.""" import json +import os import sys from gi.repository import Gio, GLib @@ -32,27 +33,99 @@ def cycle(bus): call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) +class Indicator: + def __init__(self, bus): + self.bus = bus + self.pending = "" + self.last = "" + self.entries = {} + + def select_next(self): + state = snapshot(self.bus) + methods = state["methods"] + if len(methods) < 2: + return + current = self.pending or state["current"] or self.last or methods[0] + if current not in methods: + current = methods[0] + self.pending = methods[(methods.index(current) + 1) % len(methods)] + self.refresh() + + def refresh(self): + state = snapshot(self.bus) + methods = state["methods"] + if self.pending not in methods: + self.pending = "" + if self.pending and state["current"]: + # Before the first application gains focus Fcitx has no input context. + # Retain the click until it has one, without changing startup defaults. + call(self.bus, "SetCurrentIM", GLib.Variant("(s)", (self.pending,))) + state = snapshot(self.bus) + if state["current"] == self.pending: + self.pending = "" + if state["current"]: + self.last = state["current"] + else: + chosen = self.pending or (self.last if self.last in methods else "") or next(iter(methods), "") + if chosen not in self.entries: + self.entries = {entry[0]: entry for entry in call(self.bus, "AvailableInputMethods")[0]} + entry = self.entries.get(chosen) + if entry: + state.update(current=entry[0], name=entry[1], label=entry[4], language=entry[5]) + return state + + def watch(bus): previous = None + indicator = Indicator(bus) + loop = GLib.MainLoop() + buffer = "" + timer = 0 def refresh(): nonlocal previous try: - state = snapshot(bus) + state = indicator.refresh() except GLib.Error: + indicator.pending = "" + indicator.last = "" state = {"methods": [], "current": "", "name": "", "language": ""} line = json.dumps(state, ensure_ascii=False) if line != previous: print(line, flush=True) previous = line + + def poll(): + nonlocal timer + refresh() + timer = GLib.timeout_add(50 if indicator.pending else 500, poll) + return GLib.SOURCE_REMOVE + + def command(source, condition): + nonlocal buffer, timer + data = os.read(sys.stdin.fileno(), 4096) + if not data: + loop.quit() + return GLib.SOURCE_REMOVE + buffer += data.decode() + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line == "cycle": + try: + indicator.select_next() + except GLib.Error: + indicator.pending = "" + refresh() + GLib.source_remove(timer) + timer = GLib.timeout_add(50 if indicator.pending else 500, poll) return GLib.SOURCE_CONTINUE # Fcitx's controller has no current-method-changed signal. Keep one bus # connection open instead of spawning a command on every poll. Polling also # follows per-application input contexts and recovers after service restarts. - refresh() - GLib.timeout_add(500, refresh) - GLib.MainLoop().run() + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command) + poll() + loop.run() if __name__ == "__main__": diff --git a/shell/Commons/InputMethodState.qml b/shell/Commons/InputMethodState.qml new file mode 100644 index 00000000..bbcc757c --- /dev/null +++ b/shell/Commons/InputMethodState.qml @@ -0,0 +1,36 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property var state: ({}) + + function cycle() { + if (watcher.running) watcher.write("cycle\n") + } + + // One reader and pending selection for every monitor's keyboard widget. + Process { + id: watcher + command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"] + running: true + stdinEnabled: true + stdout: SplitParser { + onRead: data => { + try { root.state = JSON.parse(data) } catch (e) {} + } + } + onExited: { + root.state = ({}) + restart.restart() + } + } + + Timer { + id: restart + interval: 5000 + onTriggered: watcher.running = true + } +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 1c9101c1..f618d451 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -2,6 +2,7 @@ module qs.Commons singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml singleton IpcRegistry 1.0 IpcRegistry.qml +singleton InputMethodState 1.0 InputMethodState.qml singleton Style 1.0 Style.qml singleton Util 1.0 Util.qml ShellIpc 1.0 ShellIpc.qml diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 82d77c46..4cb8e5d7 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -36,7 +36,7 @@ BarWidget { // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs) - property var inputState: ({}) + readonly property var inputState: InputMethodState.state readonly property bool multipleInputs: (inputState.methods || []).length > 1 readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel) @@ -231,39 +231,6 @@ BarWidget { onTriggered: root.refresh() } - Process { - id: inputWatcher - command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"] - running: true - stdout: SplitParser { - onRead: data => { - try { root.inputState = JSON.parse(data) } catch (e) {} - } - } - onExited: { - root.inputState = ({}) - inputRestart.restart() - } - } - - Timer { - id: inputRestart - interval: 5000 - onTriggered: inputWatcher.running = true - } - - Process { - id: inputCycle - property int pending: 0 - command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py", "cycle"] - onExited: { - if (pending > 0) { - pending-- - running = true - } - } - } - visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs) implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight @@ -278,8 +245,7 @@ BarWidget { tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) onPressed: function(button) { if (root.multipleInputs && button === Qt.LeftButton) { - if (inputCycle.running) inputCycle.pending++ - else inputCycle.running = true + InputMethodState.cycle() } else if (root.multipleLayouts) { root.cycleLayout() } else if (!root.multipleInputs && root.bar) { diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index 91c8e175..7c3804f1 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -39,4 +39,36 @@ check_cycle("keyboard-us", ["keyboard-us"], None) check_cycle("removed", ["keyboard-us", "mozc"], None) check_cycle("", [], None) print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts") + +entries = [("keyboard-us", "English", "", "input-keyboard", "en", "en", True), + ("pinyin", "Pinyin", "", "fcitx-pinyin", "拼", "zh_CN", True)] +live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""} +selected = [] +def fake_call(bus, method, args=None): + if method == "AvailableInputMethods": + return (entries,) + if method == "SetCurrentIM": + selected.append(args.unpack()[0]) + live["current"] = selected[-1] + return () + raise AssertionError(method) + +with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call): + reader = indicator.Indicator(None) + assert reader.refresh()["current"] == "keyboard-us" + reader.select_next() + assert reader.refresh()["current"] == "pinyin" + assert reader.pending == "pinyin" and selected == [] + reader.select_next() + assert reader.pending == "keyboard-us" + reader.select_next() + live["current"] = "keyboard-us" + assert reader.refresh()["current"] == "pinyin" + assert reader.pending == "" and selected == ["pinyin"] + live["current"] = "" + reader.select_next() + live["methods"] = ["pinyin"] + reader.refresh() + assert reader.pending == "" +print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists") PY From 35f8eef1bc35de2a631887232bbccd795fc2c8eb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:35:02 +0200 Subject: [PATCH 25/29] Remove the Japanese input welcome notification --- install/user/first-run/welcome.sh | 5 ----- 1 file changed, 5 deletions(-) diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index f345f32e..22e6629e 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -3,8 +3,3 @@ omarchy-notification-send -u critical -g  "Learn Keybindings" \ $'Super + K for cheatsheet.\nSuper + Space for Omarchy Menu.' \ --exec omarchy-menu-keybindings - -if [[ $(omarchy-input-method status | jq -r .installed_method) == "mozc" ]]; then - omarchy-notification-send -u critical "Japanese input is ready" \ - $'Click the keyboard indicator in the bar to switch input.\nType nihongo, then press Space twice for character choices.' -fi From 036e501e589ecaccc6922ff3f517d70b4212a5c5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:35:03 +0200 Subject: [PATCH 26/29] Show explicit choices on notification cards --- .../notifications/NotificationLogic.js | 11 ++++++++- shell/plugins/notifications/Service.qml | 22 +++++++++++++++++- .../components/NotificationCard.qml | 23 +++++++++++++++++++ test/shell.d/notifications-test.sh | 4 ++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index e4d69a53..e69f1e06 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -185,6 +185,12 @@ function snapshotOf(notification, timestamp) { var id = n.id || 0 var expireTimeout = Number(n.expireTimeout || 0) if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0 + var actions = [] + for (var i = 0; n.actions && i < n.actions.length; i++) { + var action = n.actions[i] + if (action && action.identifier && action.identifier !== "default") + actions.push({ identifier: String(action.identifier), text: String(action.text || action.identifier) }) + } return { id: id, originalId: id, @@ -195,6 +201,7 @@ function snapshotOf(notification, timestamp) { image: n.image || "", glyph: glyphFromHints(n.hints), execArgv: execArgvFromHints(n.hints), + actionsJson: JSON.stringify(actions), // Pinyin's first-use cloud prediction question needs an explicit answer. // Its normal urgency otherwise dismisses it before users can read it. urgency: n.appName === "Pinyin" && n.appIcon === "fcitx-pinyin" ? 2 : n.urgency, @@ -205,7 +212,7 @@ function snapshotOf(notification, timestamp) { // Everything the popup card draws, and therefore everything an in-place // update has to write through to the row and its file. -var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"] +var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "actionsJson", "urgency", "expireTimeout"] function popupRoles() { return POPUP_ROLES @@ -265,6 +272,8 @@ function historyEntry(value, normalUrgency) { image: e.image || "", glyph: e.glyph || "", execArgv: e.execArgv || "", + // Libnotify actions belong to a live sender; never replay stale buttons. + actionsJson: "[]", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, timestamp: e.timestamp || 0 diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index cba13cbe..72706f77 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -235,7 +235,7 @@ Item { // about after the popup exists. readonly property var updateSignals: [ "summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged", - "imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged" + "imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged", "actionsChanged" ] // A client that updates a notification through replaces_id does not produce @@ -427,10 +427,26 @@ Item { // libnotify action — they just expect clicking the notification to // focus their window. Fall back to focusing the sending app by class so // that click-to-jump actually works. + if (!invoked && entry.actionsJson && entry.actionsJson !== "[]") return if (!invoked) focusApp(entry) dismissPopup(index) } + function invokePopupAction(index, identifier) { + if (index < 0 || index >= popupModel.count) return + var entry = popupModel.get(index) + var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null + if (!ref || !ref.actions) return + for (var i = 0; i < ref.actions.length; i++) { + var action = ref.actions[i] + if (action && action.identifier === identifier) { + action.invoke() + dismissPopup(index) + return + } + } + } + // Try to focus an existing Hyprland window matching the notification's // sender. The helper handles case-insensitive class matching. function focusApp(entry) { @@ -726,6 +742,7 @@ Item { image: "", glyph: "󰂚", execArgv: "", + actionsJson: "[]", urgency: NotificationUrgency.Low, expireTimeout: 0, timestamp: Date.now() @@ -1044,6 +1061,7 @@ Item { required property string body required property string image required property string glyph + required property string actionsJson required property int urgency required property double expireTimeout required property double timestamp @@ -1095,9 +1113,11 @@ Item { cornerRadius: service.cornerRadius fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : "" glyph: cardSlot.glyph + actions: JSON.parse(cardSlot.actionsJson || "[]") onCloseRequested: service.dismissPopup(cardSlot.index) onCardClicked: service.invokePopupDefault(cardSlot.index) + onActionClicked: identifier => service.invokePopupAction(cardSlot.index, identifier) } } } diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index d7df8d83..6b3a916d 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -26,6 +26,7 @@ BorderSurface { property int urgency: 1 property double timestamp: 0 property int cornerRadius: 0 + property var actions: [] // System monospace font injected by the container. property string fontFamily: "" @@ -34,6 +35,7 @@ BorderSurface { signal closeRequested() signal cardClicked() + signal actionClicked(string identifier) // Prefer per-notification media/avatar data, then fall back to the app icon. // The `check` flag avoids Qt's missing-texture placeholder for unknown names. readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon) @@ -193,6 +195,27 @@ BorderSurface { } } } + + Flow { + Layout.fillWidth: true + Layout.leftMargin: Style.space(12) + Layout.rightMargin: Style.space(12) + Layout.bottomMargin: visible ? Style.space(10) : 0 + visible: root.actions.length > 0 + spacing: Style.space(8) + + Repeater { + model: root.actions + delegate: Button { + required property var modelData + text: modelData.text + bordered: true + foreground: Color.notifications.text + fontFamily: root.fontFamily + onClicked: root.actionClicked(modelData.identifier) + } + } + } } // Hover-revealed close. Stacked after mainColumn so its MouseArea sits diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 52665b0a..ec3ad253 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -271,6 +271,10 @@ const notification = { expireTimeout: 1.5 } const snapshot = notifications.snapshotOf(notification, 12345) +assertDeepEqual(JSON.parse(notifications.snapshotOf({ actions: [ + { identifier: 'default', text: 'Open' }, { identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' } +]}).actionsJson), [{ identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }], 'explicit choices appear independently of the default card action') +assertEqual(notifications.popupEntry({ actionsJson: '[{"identifier":"yes","text":"Yes"}]' }, 1).actionsJson, '[]', 'restored notifications do not replay actions from a dead sender') assertEqual(notifications.snapshotOf({ appName: 'Pinyin', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 2, 'Pinyin prompts stay visible until dismissed') assertEqual(notifications.snapshotOf({ appName: 'Other', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 1, 'other notification senders retain their urgency') assertDeepEqual( From f273eed84041b9d7950cc81701338ef0b7006e49 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:48:56 +0200 Subject: [PATCH 27/29] Suppress the Cloudpinyin first-use prompt --- config/fcitx5/conf/pinyin.conf | 3 +++ default/input-methods/configure.py | 14 ++++++++++++-- shell/plugins/notifications/NotificationLogic.js | 4 +--- test/shell.d/input-method-checks.py | 13 +++++++++++++ test/shell.d/notifications-test.sh | 2 -- 5 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 config/fcitx5/conf/pinyin.conf diff --git a/config/fcitx5/conf/pinyin.conf b/config/fcitx5/conf/pinyin.conf new file mode 100644 index 00000000..590c3d43 --- /dev/null +++ b/config/fcitx5/conf/pinyin.conf @@ -0,0 +1,3 @@ +# Keep Pinyin offline without prompting to enable cloud prediction. +FirstRun=False +CloudPinyinEnabled=False diff --git a/default/input-methods/configure.py b/default/input-methods/configure.py index dc98f0ae..ab659636 100644 --- a/default/input-methods/configure.py +++ b/default/input-methods/configure.py @@ -74,7 +74,7 @@ def key_list(entries): def defaults(config_home, fresh=False): changed = False - files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"] + files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"] for name in files: target = config_home / "fcitx5" / name original = read(target) @@ -104,6 +104,16 @@ def defaults(config_home, fresh=False): additions.append(f"[{section}]\n" + "".join(f"{key}={value}\n" for key, value in entries.items())) if additions: changed |= atomic_write(target, original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions)) + elif name == "conf/pinyin.conf": + # Suppress the first-use cloud prompt while preserving any explicit + # cloud-prediction preference and all other existing engine settings. + if not original: + updated = shipped + elif "FirstRun" in values(original): + updated = re.sub(r"(?m)^\s*FirstRun\s*=.*$", "FirstRun=False", original) + else: + updated = "FirstRun=False\n" + original + changed |= atomic_write(target, updated) elif not target.exists() and not target.is_symlink(): changed |= atomic_write(target, shipped) return changed @@ -123,7 +133,7 @@ def activation_default(): @contextmanager def transaction(config_home, live=False): data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")) - paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"]] + paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"]] paths += [config_home / "fontconfig/conf.d/50-omarchy-input-method.conf", data_home / "dbus-1/services/org.fcitx.Fcitx5.service"] before = {path.resolve(): path.read_text() if path.exists() else None for path in paths} try: diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index e69f1e06..cabc3ac1 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -202,9 +202,7 @@ function snapshotOf(notification, timestamp) { glyph: glyphFromHints(n.hints), execArgv: execArgvFromHints(n.hints), actionsJson: JSON.stringify(actions), - // Pinyin's first-use cloud prediction question needs an explicit answer. - // Its normal urgency otherwise dismisses it before users can read it. - urgency: n.appName === "Pinyin" && n.appIcon === "fcitx-pinyin" ? 2 : n.urgency, + urgency: n.urgency, expireTimeout: expireTimeout, timestamp: timestamp === undefined ? Date.now() : timestamp } diff --git a/test/shell.d/input-method-checks.py b/test/shell.d/input-method-checks.py index 98b0fb14..981b0f18 100644 --- a/test/shell.d/input-method-checks.py +++ b/test/shell.d/input-method-checks.py @@ -16,6 +16,19 @@ spec.loader.exec_module(setup) class InputMethodTest(unittest.TestCase): + def test_pinyin_defaults_suppress_cloud_prompt(self): + setup.defaults(self.config, fresh=True) + pinyin = setup.values((self.config / "fcitx5/conf/pinyin.conf").read_text()) + self.assertEqual(pinyin["FirstRun"], "False") + self.assertEqual(pinyin["CloudPinyinEnabled"], "False") + + def test_pinyin_prompt_suppression_preserves_existing_preferences(self): + pinyin = self.config / "fcitx5/conf/pinyin.conf" + pinyin.parent.mkdir(parents=True) + pinyin.write_text("FirstRun=True\nCloudPinyinEnabled=True\nPageSize=7\n") + setup.defaults(self.config) + self.assertEqual(pinyin.read_text(), "FirstRun=False\nCloudPinyinEnabled=True\nPageSize=7\n") + def setUp(self): self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index ec3ad253..7d3b7494 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -275,8 +275,6 @@ assertDeepEqual(JSON.parse(notifications.snapshotOf({ actions: [ { identifier: 'default', text: 'Open' }, { identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' } ]}).actionsJson), [{ identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }], 'explicit choices appear independently of the default card action') assertEqual(notifications.popupEntry({ actionsJson: '[{"identifier":"yes","text":"Yes"}]' }, 1).actionsJson, '[]', 'restored notifications do not replay actions from a dead sender') -assertEqual(notifications.snapshotOf({ appName: 'Pinyin', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 2, 'Pinyin prompts stay visible until dismissed') -assertEqual(notifications.snapshotOf({ appName: 'Other', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 1, 'other notification senders retain their urgency') assertDeepEqual( { id: snapshot.id, From ee358c251b4ee58be3f233dcc4a1ddd7eb0c1c27 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 18:10:20 +0200 Subject: [PATCH 28/29] Show the input switching shortcut in keyboard hints --- bin/omarchy-menu-keybindings | 3 ++- shell/plugins/bar/widgets/KeyboardLayoutModel.js | 2 +- test/shell.d/keybindings-menu-test.sh | 3 +++ test/shell.d/keyboard-layout-test.sh | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 153a4d29..1d00e5f5 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -332,6 +332,7 @@ dynamic_bindings() { static_bindings() { echo "SHIFT ALT,L,Copy URL from Web App,sendshortcut,SHIFT ALT,L," echo "SHIFT ALT,D,Download Video from Web App,sendshortcut,SHIFT ALT,D," + echo "CTRL SHIFT,SPACE,Switch input language,exec,fcitx5-remote -t" } # Actions Omarchy binds to a second chord meant as an alternative, named one at @@ -533,7 +534,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v14\n' + printf 'v15\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js index 69ece973..b1ee1cf8 100644 --- a/shell/plugins/bar/widgets/KeyboardLayoutModel.js +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -120,7 +120,7 @@ function inputLabel(state, fallback) { function inputTooltip(state, layout, multipleLayouts) { if (!state || !state.methods || state.methods.length < 2) return layout + (multipleLayouts ? "" : "\nClick to set up an input language") - var text = state.name + "\nClick to switch input language" + var text = state.name + "\nClick to switch input language\nCtrl + Shift + Space to toggle input" if (state.methods.indexOf("mozc") !== -1) text += "\nJapanese: type a word, then Space twice for character choices" if (multipleLayouts) text += "\nRight-click to switch keyboard layout" diff --git a/test/shell.d/keybindings-menu-test.sh b/test/shell.d/keybindings-menu-test.sh index 5f962e6a..f5a06906 100755 --- a/test/shell.d/keybindings-menu-test.sh +++ b/test/shell.d/keybindings-menu-test.sh @@ -64,6 +64,9 @@ rendered=$(keybindings) grep -q 'SUPER + F *→ Full screen' <<<"$rendered" || fail "a chord with no alternative renders on its own" "$rendered" pass "the keybindings menu renders its entries" +grep -q 'CTRL SHIFT + SPACE *→ Switch input language' <<<"$rendered" || + fail "the input switch shortcut appears in the cheatsheet" "$rendered" +pass "the input switch shortcut appears in the cheatsheet" (( $(grep -c '→ Close window$' <<<"$rendered") == 1 )) || fail "an alternative chord joins the row of the first one" "$rendered" diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh index a2181b1b..07469343 100755 --- a/test/shell.d/keyboard-layout-test.sh +++ b/test/shell.d/keyboard-layout-test.sh @@ -15,6 +15,7 @@ assertEqual(model.inputLabel({}, 'EN'), 'EN', 'a stopped input service retains t assertEqual(model.inputLabel({ current: 'hangul' }, 'EN'), '한', 'Korean input has a distinct label') assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', 'other engines use their reported language') assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Space twice'), true, 'Japanese explains character choices') +assertEqual(model.inputTooltip(japanese, 'English (US)', false).includes('Ctrl + Shift + Space'), true, 'the input toggle shortcut is discoverable') assertEqual(model.inputTooltip(japanese, 'English (US)', true).includes('Right-click'), true, 'combined input and layout switching stays discoverable') assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French\nClick to set up an input language', 'one foreign mode offers input setup') assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden') From 40642c0d3524db851ba85a541fbd8f796d8e333e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 18:10:20 +0200 Subject: [PATCH 29/29] Verify input setup and composition stay quiet --- test/acceptance.d/input-methods-test.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/acceptance.d/input-methods-test.sh b/test/acceptance.d/input-methods-test.sh index e0504b12..f5c414a9 100644 --- a/test/acceptance.d/input-methods-test.sh +++ b/test/acceptance.d/input-methods-test.sh @@ -19,6 +19,8 @@ fi pass "installer input selection reaches the first desktop session" work=$(mktemp -d) +dbus-monitor --session "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" > "$ARTIFACTS/input-method-notifications.log" 2>&1 & +notification_monitor=$! config_home=${XDG_CONFIG_HOME:-$HOME/.config} data_home=${XDG_DATA_HOME:-$HOME/.local/share} paths=("$config_home/fcitx5" "$config_home/fontconfig/conf.d/50-omarchy-input-method.conf" "$data_home/dbus-1/services/org.fcitx.Fcitx5.service") @@ -29,6 +31,8 @@ for (( i = 0; i < ${#paths[@]}; i++ )); do done cleanup() { + kill "$notification_monitor" 2>/dev/null || true + wait "$notification_monitor" 2>/dev/null || true close_windows '^org\.omarchy\.ime-test$' close_windows '^org\.omarchy\.ime-test-gtk$|^org\.qt-project\.qml$|^chrome-.*_omarchy-ime-entry\.html-Default$' systemctl --user stop omarchy-fcitx5.service @@ -163,3 +167,8 @@ for method in mozc hangul pinyin chewing; do wait_until "$toolkit closes" 15 window_absent "$class" done done + +if grep -q 'member=Notify' "$ARTIFACTS/input-method-notifications.log"; then + fail "input setup and composition produce no additional notifications" "$(cat "$ARTIFACTS/input-method-notifications.log")" +fi +pass "all four input engines remain quiet during setup and composition"