From 44f68f4329e75fa61dfa8cc01e4737c15e48f8c2 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Mon, 14 Sep 2026 19:15:26 -0600 Subject: [PATCH 01/34] Require approval for new USB devices by default Co-authored-by: Outfoxxed --- bin/omarchy-provision-owner | 6 + bin/omarchy-remove-security-usb-authorization | 86 ++++ bin/omarchy-setup-security-usb-authorization | 112 +++++ bin/omarchy-usb-authorization-boot | 432 ++++++++++++++++++ bin/omarchy-usb-authorization-event | 40 ++ bin/omarchy-usb-authorization-restore-default | 23 + bin/omarchy-usb-authorization-review | 79 ++++ bin/omarchy-usb-authorization-watch | 23 + default/omarchy/omarchy-menu.jsonc | 4 + .../user/omarchy-usb-authorization.service | 17 + install/config/all.sh | 1 + install/config/usb-authorization.sh | 28 ++ install/helpers/usb-authorization.sh | 99 ++++ install/omarchy-base.packages | 1 + install/user/first-run/enable-user-units.sh | 3 +- manual/48-security.md | 15 +- migrations/1789433473.sh | 3 + test/shell.d/menu-test.sh | 7 + test/shell.d/usb-authorization-test.sh | 405 ++++++++++++++++ 19 files changed, 1382 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-remove-security-usb-authorization create mode 100755 bin/omarchy-setup-security-usb-authorization create mode 100755 bin/omarchy-usb-authorization-boot create mode 100755 bin/omarchy-usb-authorization-event create mode 100755 bin/omarchy-usb-authorization-restore-default create mode 100755 bin/omarchy-usb-authorization-review create mode 100755 bin/omarchy-usb-authorization-watch create mode 100644 default/systemd/user/omarchy-usb-authorization.service create mode 100644 install/config/usb-authorization.sh create mode 100644 install/helpers/usb-authorization.sh create mode 100644 migrations/1789433473.sh create mode 100755 test/shell.d/usb-authorization-test.sh diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index b5321a4d..f4bff333 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -747,6 +747,12 @@ create_user() { echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel + if omarchy-pkg-present usbguard; then + source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + usb_authorization_add_user "$username" + systemctl try-restart usbguard.service + fi + source "$OMARCHY_PATH/install/helpers/browser-policy.sh" for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue diff --git a/bin/omarchy-remove-security-usb-authorization b/bin/omarchy-remove-security-usb-authorization new file mode 100755 index 00000000..ba77a0f0 --- /dev/null +++ b/bin/omarchy-remove-security-usb-authorization @@ -0,0 +1,86 @@ +#!/bin/bash + +# omarchy:summary=Stop asking before new USB devices can access the system +# omarchy:args=[--boot-only] [--yes] +# omarchy:requires-sudo=true + +set -euo pipefail + +assume_yes=0 +boot_only=0 + +while (( $# > 0 )); do + case "$1" in + --yes) assume_yes=1 ;; + --boot-only) boot_only=1 ;; + -h | --help) + echo "Usage: omarchy-remove-security-usb-authorization [--boot-only] [--yes]" + echo + echo "Disables Omarchy's USB approval prompts and restores the kernel's" + echo "normal default-allow behavior." + echo "Use --boot-only to keep USBGuard enabled after the system starts." + exit 0 + ;; + *) + echo "omarchy-remove-security-usb-authorization: unknown option '$1'. Try --help." >&2 + exit 2 + ;; + esac + shift +done + +if (( boot_only )); then + if (( ! assume_yes )); then + gum style --foreground 212 --bold "Remove USB authorization from boot" + echo + echo "USBGuard will continue blocking unknown devices after its service starts." + echo "Devices can reach kernel drivers earlier during boot." + echo + gum confirm "Disable USB authorization from kernel boot?" || exit 130 + fi + + sudo omarchy-usb-authorization-boot disable + echo + echo "Boot-time USB authorization is disabled and takes effect after reboot." + exit 0 +fi + +state_dir="$HOME/.local/state/omarchy/usb-authorization" +unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" + +if (( ! assume_yes )); then + gum style --foreground 212 --bold "Remove USB device authorization" + echo + echo "New USB devices will be allowed to bind to kernel drivers without approval." + echo + gum confirm "Disable USB device authorization?" || exit 130 +fi + +# Rebuild a permissive boot image before stopping USBGuard. If rebuilding +# fails, the command stops here and leaves the complete policy enabled. +sudo omarchy-usb-authorization-boot disable + +systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true +rm -f "$unit" +systemctl --user daemon-reload + +if omarchy-pkg-present usbguard; then + sudo usbguard remove-user "$USER" 2>/dev/null || true +fi + +if omarchy-pkg-present usbguard; then + sudo systemctl disable --now usbguard.service 2>/dev/null || true + sudo omarchy-usb-authorization-restore-default +fi + +if [[ -d $state_dir/requests ]]; then + find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete + rmdir "$state_dir/requests" 2>/dev/null || true +fi + +rm -f "$state_dir/package-installed-by-omarchy" \ + "$state_dir/policy-generated-by-omarchy" \ + "$state_dir/service-enabled-by-omarchy" +rmdir "$state_dir" 2>/dev/null || true + +echo "USB device authorization is disabled." diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization new file mode 100755 index 00000000..0c21c558 --- /dev/null +++ b/bin/omarchy-setup-security-usb-authorization @@ -0,0 +1,112 @@ +#!/bin/bash + +# omarchy:summary=Ask before new USB devices can access the system +# omarchy:args=[--boot] [--yes] +# omarchy:requires-sudo=true + +set -euo pipefail + +assume_yes=0 +from_boot=0 + +while (( $# > 0 )); do + case "$1" in + --yes) assume_yes=1 ;; + --boot) from_boot=1 ;; + -h | --help) + echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes]" + echo + echo "Trusts USB devices that are connected now, then blocks new devices" + echo "until you approve them from an Omarchy notification." + echo + echo "--boot also blocks USB devices from the start of kernel boot. Connected" + echo "devices are added to USBGuard's policy, but remain unavailable before" + echo "USBGuard starts, including at an encrypted-disk password prompt." + exit 0 + ;; + *) + echo "omarchy-setup-security-usb-authorization: unknown option '$1'. Try --help." >&2 + exit 2 + ;; + esac + shift +done + +state_dir="$HOME/.local/state/omarchy/usb-authorization" +unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service" +unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" +rules_file=/etc/usbguard/rules.conf +daemon_config=/etc/usbguard/usbguard-daemon.conf + +source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + +if (( ! assume_yes )); then + if (( from_boot )); then + gum style --foreground 212 --bold "USB authorization from boot" + else + gum style --foreground 212 --bold "USB device authorization" + fi + echo + echo "Devices connected now will be trusted. Disconnect anything you do not trust." + echo "New USB devices will remain blocked until you approve them." + if (( from_boot )); then + echo + echo "Boot-time protection blocks all USB devices until USBGuard starts." + echo "A USB keyboard will not work at an encrypted-disk password prompt, even" + echo "when it is connected now. Continue only if unlock and recovery do not" + echo "depend on USB input." + fi + echo + if (( from_boot )); then + gum confirm "Enable USB authorization from kernel boot?" || exit 130 + else + gum confirm "Enable USB device authorization?" || exit 130 + fi +fi + +omarchy-pkg-add usbguard + +usb_authorization_require_secure_settings "$daemon_config" + +mkdir -p "$state_dir" "${unit%/*}" +chmod 700 "$state_dir" + +if ! usb_authorization_rules_present "$rules_file"; then + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") + trap 'rm -f "$policy"' EXIT + chmod 600 "$policy" + usb_authorization_generate_policy "$policy" + sudo install -Dm600 -o root -g root "$policy" "$rules_file" + touch "$state_dir/policy-generated-by-omarchy" +fi + +# This user can inspect USBGuard events and approve the device named by an +# event. Policy and daemon parameters remain unavailable through the IPC ACL. +sudo usbguard add-user "$USER" \ + --devices=list,listen,modify \ + --policy=list \ + --exceptions=listen + +sudo systemctl enable usbguard.service +# add-user ACL changes are loaded only when the daemon starts, so restart an +# existing service as well as starting a fresh one. +sudo systemctl restart usbguard.service + +ln -sfn "$unit_source" "$unit" +systemctl --user daemon-reload +systemctl --user enable --now omarchy-usb-authorization.service + +if (( from_boot )); then + # Take the trust snapshot only after USBGuard is enforcing the policy. Every + # ID is resolved by the daemon, and no device-controlled descriptor text is + # passed to a privileged command. + usb_authorization_trust_present_devices + sudo omarchy-usb-authorization-boot enable +fi + +echo +echo "USB device authorization is enabled." +echo "Devices connected during setup remain trusted; new devices require approval." +if (( from_boot )); then + echo "Boot-time authorization is enabled and takes effect after reboot." +fi diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot new file mode 100755 index 00000000..e72139a3 --- /dev/null +++ b/bin/omarchy-usb-authorization-boot @@ -0,0 +1,432 @@ +#!/bin/bash + +# omarchy:summary=Manage USB authorization from the start of kernel boot +# omarchy:args= +# omarchy:requires-sudo=true +# omarchy:hidden=true + +set -euo pipefail + +drop_in=/etc/limine-entry-tool.d/usb-authorization.conf +setting='KERNEL_CMDLINE[default]+=" usbcore.authorized_default=0"' +limine_conf=/boot/limine.conf +limine_defaults=/etc/default/limine +boot_path=/boot +kernel_parameter=usbcore.authorized_default=0 +snapshot_begin='# Omarchy USB authorization begin' +snapshot_setting='SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' +snapshot_end='# Omarchy USB authorization end' + +usb_authorization_cmdline_has_parameter() { + local cmdline="$1" parameter="$2" word + + for word in $cmdline; do + [[ $word == "$parameter" ]] && return 0 + done + + return 1 +} + +usb_authorization_secure_boot_enabled() { + local value variable + + for variable in /sys/firmware/efi/efivars/SecureBoot-*; do + [[ -r $variable ]] || continue + value=$(od -An -t u1 -j 4 -N 1 "$variable") + value=${value//[[:space:]]/} + [[ $value == "1" ]] && return 0 + done + + return 1 +} + +usb_authorization_verify_boot_entries() { + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(&2 + return 1 + fi + + paths=$(mktemp /tmp/omarchy-usb-boot-paths.XXXXXXXXXX) + section=$(mktemp /tmp/omarchy-usb-boot-cmdline.XXXXXXXXXX) + + if ! awk -v expected="$expected" -v machine_id="$machine_id" -v parameter="$kernel_parameter" ' + function contains_parameter(value, count, fields, field_index) { + count = split(value, fields, /[[:space:]]+/) + for (field_index = 1; field_index <= count; field_index++) { + if (fields[field_index] == parameter) return 1 + } + return 0 + } + + function finish_entry(found) { + if (!target_os || !is_kernel) return + entries++ + + if (has_cmdline) { + found = contains_parameter(cmdline) + if ((expected == "enabled" && !found) || (expected == "disabled" && found)) bad = 1 + } + + if (path == "") { + bad = 1 + } else { + print (has_cmdline ? "external" : "embedded") "\t" path + } + } + + /^\/[^/]/ { + finish_entry() + target_os = 0 + is_kernel = 0 + has_cmdline = 0 + cmdline = "" + path = "" + next + } + + /^[[:space:]]*comment:[[:space:]]*machine-id=/ { + target_os = index($0, "machine-id=" machine_id) > 0 + next + } + + /^[[:space:]]*\/\// { + finish_entry() + is_kernel = 0 + has_cmdline = 0 + cmdline = "" + path = "" + next + } + + /^[[:space:]]*comment:[[:space:]]*kernel-id=/ { + is_kernel = target_os + next + } + + is_kernel && /^[[:space:]]*path:[[:space:]]*/ { + path = $0 + sub(/^[[:space:]]*path:[[:space:]]*/, "", path) + next + } + + is_kernel && /^[[:space:]]*cmdline:[[:space:]]*/ { + has_cmdline = 1 + cmdline = $0 + sub(/^[[:space:]]*cmdline:[[:space:]]*/, "", cmdline) + next + } + + END { + finish_entry() + if (entries == 0) bad = 1 + exit bad + } + ' "$config" >"$paths"; then + rm -f "$paths" "$section" + echo "The generated Limine kernel entries do not all have USB authorization $expected." >&2 + return 1 + fi + + while IFS=$'\t' read -r mode path; do + if [[ $path != 'boot():/'* ]]; then + rm -f "$paths" "$section" + echo "Cannot verify the kernel image referenced by Limine: $path" >&2 + return 1 + fi + + image="$esp/${path#'boot():/'}" + image=${image%%#*} + if [[ ! -r $image ]]; then + rm -f "$paths" "$section" + echo "Cannot verify the kernel image referenced by Limine: $image" >&2 + return 1 + fi + + if [[ $path == *'#'* ]]; then + expected_hash=${path##*#} + actual_hash=$(b2sum "$image") + actual_hash=${actual_hash%% *} + if [[ $actual_hash != "$expected_hash" ]]; then + rm -f "$paths" "$section" + echo "The verification hash for $image does not match the generated Limine entry." >&2 + return 1 + fi + fi + + : >"$section" + if ! objcopy -O binary --only-section=.cmdline "$image" "$section" 2>/dev/null; then + [[ $mode == "external" ]] && continue + rm -f "$paths" "$section" + echo "Cannot inspect the embedded kernel command line in $image." >&2 + return 1 + fi + embedded_cmdline=$(tr '\0' '\n' <"$section") + + # Without Secure Boot, Limine's external command line takes precedence. + # Under Secure Boot, systemd-stub uses a nonempty embedded .cmdline. + if [[ $mode == "external" && $secure_boot == "disabled" ]]; then + continue + fi + + # A signed UKI can ignore Limine's external cmdline and use its embedded + # .cmdline instead. If one exists, both representations must agree. + if [[ -z $embedded_cmdline ]]; then + if [[ $mode == "external" || $expected == "disabled" ]]; then + continue + fi + rm -f "$paths" "$section" + echo "The generated kernel image $image has no verifiable kernel command line." >&2 + return 1 + fi + + if [[ $expected == "enabled" ]]; then + if ! usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then + rm -f "$paths" "$section" + echo "The generated kernel image $image does not enable boot-time USB authorization." >&2 + return 1 + fi + elif usb_authorization_cmdline_has_parameter "$embedded_cmdline" "$kernel_parameter"; then + rm -f "$paths" "$section" + echo "The generated kernel image $image still enables boot-time USB authorization." >&2 + return 1 + fi + done <"$paths" + + rm -f "$paths" "$section" +} + +usb_authorization_rebuild_and_verify() { + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(&2 + return 1 + fi + + temporary=$(mktemp "${config}.XXXXXXXXXX") + cp --preserve=mode,ownership "$config" "$temporary" + { + cat "$config" + printf '\n%s\n%s\n%s\n' "$snapshot_begin" "$snapshot_setting" "$snapshot_end" + } >"$temporary" + mv -f "$temporary" "$config" +} + +usb_authorization_disable_snapshot_setting() { + local config="$1" status temporary + + if usb_authorization_snapshot_block_status "$config"; then + status=0 + else + status=$? + fi + (( status == 1 )) && return 0 + if (( status != 0 )); then + echo "Refusing to modify the USB authorization block in $config." >&2 + return 1 + fi + + temporary=$(mktemp "${config}.XXXXXXXXXX") + cp --preserve=mode,ownership "$config" "$temporary" + awk -v begin="$snapshot_begin" ' + $0 == begin { skip = 2; next } + skip > 0 { skip--; next } + { print } + ' "$config" >"$temporary" + mv -f "$temporary" "$config" +} + +usb_authorization_rewrite_boot_cmdlines() { + local expected="$1" config="$2" machine_id="${3:-$("$temporary"; then + rm -f "$backup" "$temporary" + return 1 + fi + + mv -f "$temporary" "$config" + if ! limine-enroll-config; then + mv -f "$backup" "$config" + limine-enroll-config >/dev/null 2>&1 || true + echo "Restored $config because its bootloader verification data could not be updated." >&2 + return 1 + fi + + rm -f "$backup" +} + +main() { + local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary + + if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-boot must run as root" >&2 + exit 1 + fi + + case "${1:-}" in + enable) + if usb_authorization_snapshot_block_status "$limine_defaults"; then + snapshot_status=0 + else + snapshot_status=$? + fi + if (( snapshot_status == 2 )); then + echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 + exit 1 + fi + + # Quote the expected value because the Limine key contains glob brackets. + if [[ -e $drop_in ]] && [[ $(<"$drop_in") != "$setting" ]]; then + echo "Refusing to replace the existing $drop_in." >&2 + exit 1 + fi + + usb_authorization_enable_snapshot_setting "$limine_defaults" + + if [[ ! -e $drop_in ]]; then + install -d -m755 "${drop_in%/*}" + temporary=$(mktemp "${drop_in}.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT + printf '%s\n' "$setting" >"$temporary" + chmod 644 "$temporary" + mv -f "$temporary" "$drop_in" + trap - EXIT + fi + + if ! limine-mkinitcpio || + ! usb_authorization_rewrite_boot_cmdlines enabled "$limine_conf" || + ! usb_authorization_verify_boot_entries enabled "$limine_conf" "$boot_path"; then + echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 + exit 1 + fi + ;; + disable) + if usb_authorization_snapshot_block_status "$limine_defaults"; then + had_snapshot_setting=1 + else + snapshot_status=$? + if (( snapshot_status == 2 )); then + echo "Refusing to modify the USB authorization block in $limine_defaults." >&2 + exit 1 + fi + fi + + if [[ -e $drop_in ]]; then + if [[ $(<"$drop_in") != "$setting" ]]; then + echo "Refusing to remove the modified $drop_in." >&2 + exit 1 + fi + had_drop_in=1 + rm -f "$drop_in" + elif (( ! had_snapshot_setting )) && usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + exit 0 + fi + + usb_authorization_disable_snapshot_setting "$limine_defaults" + if ! limine-mkinitcpio || + ! usb_authorization_rewrite_boot_cmdlines disabled "$limine_conf" || + ! usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + if (( had_drop_in )); then + printf '%s\n' "$setting" >"$drop_in" + chmod 644 "$drop_in" + fi + if (( had_snapshot_setting )); then + usb_authorization_enable_snapshot_setting "$limine_defaults" + fi + echo "USBGuard remains required because the boot images could not be verified as default-allow." >&2 + exit 1 + fi + ;; + *) + echo "Usage: omarchy-usb-authorization-boot " >&2 + exit 2 + ;; + esac +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event new file mode 100755 index 00000000..53182715 --- /dev/null +++ b/bin/omarchy-usb-authorization-event @@ -0,0 +1,40 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Create an approval request for a blocked USB device + +set -euo pipefail + +[[ ${USBGUARD_IPC_SIGNAL:-} == "Device.PresenceChanged" ]] || exit 0 +[[ ${USBGUARD_DEVICE_EVENT:-} == "Insert" ]] || exit 0 +[[ ${USBGUARD_DEVICE_TARGET:-} == "block" ]] || exit 0 +[[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 +[[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 + +requests="$HOME/.local/state/omarchy/usb-authorization/requests" +mkdir -p "$requests" +chmod 700 "${requests%/*}" "$requests" + +# The token is safe to carry through the presentation launcher's command +# string. The device-controlled rule itself stays in a mode-0600 JSON file and +# is never interpolated into a shell command. +token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)" +request="$requests/$token.json" +temporary=$(mktemp "$requests/.request.XXXXXXXXXX") +trap 'rm -f "$temporary"' EXIT + +jq -n \ + --arg id "$USBGUARD_DEVICE_ID" \ + --arg rule "$USBGUARD_DEVICE_RULE" \ + '{id: $id, rule: $rule}' >"$temporary" +chmod 600 "$temporary" +mv -f "$temporary" "$request" + +omarchy-notification-send \ + --app-name omarchy-action \ + --urgency critical \ + --glyph 󰖀 \ + "USB accessory blocked" \ + "Click to review it. Keep it blocked unless you recognize the device." \ + --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-usb-authorization-review "$token" diff --git a/bin/omarchy-usb-authorization-restore-default b/bin/omarchy-usb-authorization-restore-default new file mode 100755 index 00000000..e5844db8 --- /dev/null +++ b/bin/omarchy-usb-authorization-restore-default @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Restore the kernel's default USB authorization state +# omarchy:requires-sudo=true +# omarchy:hidden=true + +set -euo pipefail + +if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-restore-default must run as root" >&2 + exit 1 +fi + +# USBGuard intentionally leaves root hubs in default-deny mode when it exits. +# Restore both the hub defaults and devices that are still connected. +shopt -s nullglob +for attribute in /sys/bus/usb/devices/usb*/authorized_default; do + echo 1 >"$attribute" +done +for attribute in /sys/bus/usb/devices/*/authorized; do + echo 1 >"$attribute" +done +shopt -u nullglob diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review new file mode 100755 index 00000000..aa3c16c4 --- /dev/null +++ b/bin/omarchy-usb-authorization-review @@ -0,0 +1,79 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Review a blocked USB device authorization request + +set -euo pipefail + +token="${1:-}" +if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then + echo "This USB authorization request is invalid or expired." >&2 + exit 1 +fi + +request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json" +if [[ ! -f $request || -L $request ]]; then + echo "This USB authorization request is no longer available." >&2 + exit 1 +fi + +id=$(jq -er '.id | select(test("^[0-9]+$"))' "$request") +expected_rule=$(jq -er '.rule | select(type == "string" and length > 0)' "$request") +read_blocked_rule() { + local line + + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then + printf '%s\n' "${line#"$id: "}" + return 0 + fi + done < <(usbguard list-devices --blocked) + + return 1 +} + +current_rule=$(read_blocked_rule || true) + +if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 +fi + +gum style --foreground 212 --bold "Blocked USB accessory" +echo +echo "The following details come from the device and can be forged:" +printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' +echo + +choice=$(gum choose \ + "Allow once" \ + "Always allow this device" \ + "Keep blocked") || exit 130 + +if [[ $choice != "Keep blocked" ]]; then + current_rule=$(read_blocked_rule || true) + if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 + fi +fi + +case "$choice" in +"Allow once") + # Target the complete identity rule instead of USBGuard's numeric ID. IDs can + # be reassigned when the daemon restarts while the review dialog is open. + usbguard allow-device "$expected_rule" + echo "USB accessory allowed until it is disconnected." + ;; +"Always allow this device") + usbguard allow-device --permanent "$expected_rule" + echo "USB accessory added to the trusted policy." + ;; +"Keep blocked") + echo "USB accessory remains blocked." + ;; +esac + +rm -f "$request" diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch new file mode 100755 index 00000000..8b22fdf4 --- /dev/null +++ b/bin/omarchy-usb-authorization-watch @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Watch USBGuard for devices awaiting user approval + +set -euo pipefail + +event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + +# A device inserted while the graphical session was stopped is already safely +# blocked by USBGuard. Surface it now so the user can approve it after login. +while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then + USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ + "$event_handler" + fi +done < <(usbguard list-devices --blocked) + +exec usbguard watch --wait --exec "$event_handler" diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 7583d8d8..c0dd2eda 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -181,6 +181,8 @@ "setup.config": {"icon":"","label":"Config"}, "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, + "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, + "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "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"}, @@ -298,6 +300,8 @@ "remove.security": {"icon":"","label":"Security","title":"Remove"}, "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, + "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, + "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, diff --git a/default/systemd/user/omarchy-usb-authorization.service b/default/systemd/user/omarchy-usb-authorization.service new file mode 100644 index 00000000..bfc27a61 --- /dev/null +++ b/default/systemd/user/omarchy-usb-authorization.service @@ -0,0 +1,17 @@ +[Unit] +Description=Ask before allowing new USB devices +After=graphical-session.target dbus.socket +Requires=dbus.socket +PartOf=graphical-session.target +ConditionEnvironment=OMARCHY_PATH +ConditionEnvironment=WAYLAND_DISPLAY + +[Service] +Type=simple +ExecCondition=/usr/bin/systemctl is-active --quiet usbguard.service +ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"' +Restart=always +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/install/config/all.sh b/install/config/all.sh index a221a398..3ff8d66a 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -7,5 +7,6 @@ run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" +run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh new file mode 100644 index 00000000..958b82ae --- /dev/null +++ b/install/config/usb-authorization.sh @@ -0,0 +1,28 @@ +echo "Configure USB device authorization" + +rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}" +daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}" + +source "$OMARCHY_INSTALL/helpers/usb-authorization.sh" +usb_authorization_require_secure_settings "$daemon_config" + +if [[ ! -s $rules_file ]]; then + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") + chmod 600 "$policy" + + if usb_authorization_generate_policy "$policy"; then + install -Dm600 -o root -g root "$policy" "$rules_file" + rm -f "$policy" + else + rm -f "$policy" + return 1 + fi +fi + +if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then + usb_authorization_add_user "$OMARCHY_INSTALL_USER" +fi + +# Installs are followed by a reboot, so enable the daemon without starting it +# in the live ISO's chroot. +systemctl enable usbguard.service diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh new file mode 100644 index 00000000..219a8e5b --- /dev/null +++ b/install/helpers/usb-authorization.sh @@ -0,0 +1,99 @@ +usb_authorization_config_value() { + local config="$1" key="$2" + local awk_command=(awk) + + if [[ ! -r $config ]]; then + if [[ $config == "/etc/usbguard/usbguard-daemon.conf" ]]; then + awk_command=(sudo awk) + else + echo "Cannot read USBGuard configuration: $config" >&2 + return 1 + fi + fi + + "${awk_command[@]}" -F= -v key="$key" ' + /^[[:space:]]*#/ { next } + $1 ~ "^[[:space:]]*" key "[[:space:]]*$" { + value=$0 + sub(/^[^=]*=[[:space:]]*/, "", value) + sub(/[[:space:]]*$/, "", value) + print value + exit + } + ' "$config" +} + +usb_authorization_rules_present() { + local rules="$1" + + if [[ -r $rules ]]; then + [[ -s $rules ]] + elif [[ $rules == "/etc/usbguard/rules.conf" ]]; then + sudo test -s "$rules" + else + return 1 + fi +} + +usb_authorization_require_secure_setting() { + local config="$1" key="$2" expected="$3" actual + + actual=$(usb_authorization_config_value "$config" "$key") + if [[ $actual != "$expected" ]]; then + echo "USBGuard's $key must be '$expected' before Omarchy enables it." >&2 + echo "Current value: ${actual:-} in $config" >&2 + return 1 + fi +} + +usb_authorization_require_secure_settings() { + local config="$1" + + usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block + usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy + usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy + usb_authorization_require_secure_setting "$config" AuthorizedDefault none + usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false +} + +usb_authorization_generate_policy() { + local policy="$1" + + usbguard generate-policy >"$policy" + if [[ ! -s $policy ]] || ! grep -q '^allow ' "$policy"; then + echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + return 1 + fi +} + +usb_authorization_add_user() { + local user="$1" + + # This user can inspect USBGuard events and approve the device named by an + # event. Policy and daemon parameters remain unavailable through the IPC ACL. + usbguard add-user "$user" \ + --devices=list,listen,modify \ + --policy=list \ + --exceptions=listen +} + +usb_authorization_trust_present_devices() { + local line id + local count=0 + local -a devices + + mapfile -t devices < <(usbguard list-devices) + + for line in "${devices[@]}"; do + if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then + id="${BASH_REMATCH[1]}" + usbguard allow-device --permanent "$id" + (( ++count )) + fi + done + + if (( count == 0 )); then + echo "USBGuard did not report any connected USB devices; boot authorization was not enabled." >&2 + return 1 + fi +} diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 76e74110..3579c27c 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -133,6 +133,7 @@ udiskie ufw ufw-docker unzip +usbguard usage uwsm vi diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 9865fc20..67addca5 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -18,4 +18,5 @@ systemctl --user enable --now \ omarchy-sleep-lock.service \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ - omarchy-crash-watch.service + omarchy-crash-watch.service \ + omarchy-usb-authorization.service diff --git a/manual/48-security.md b/manual/48-security.md index 45750398..76f1db24 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -6,7 +6,20 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys 2. *Firewall is enabled by default*: All incoming traffic is blocked by default except for port 53317 for [LocalSend](https://localsend.org/). Even ssh is off until you turn it on via _Setup > Security > SSHD_, which opens port 22 (rate limited against brute force) as part of the setup. We even lock down Docker access using the [ufw-docker](https://github.com/chaifeng/ufw-docker) setup to prevent that your containers are accidentally exposed to the world. 3. *Arch always have the latest updates*: Arch, the underlying distro that Omarchy is built on, is a rolling distribution. This means that any security vulnerability that's discovered and patched in any package is quickly available for install using `omarchy-update`. You're always running the latest, most secure versions of everything that way. 4. *Omarchy maintains its own packages and mirror*: Omarchy only relies on packages from Arch's own core/extra/multilib repositories and its own Omarchy Package Repository by default. You can install software directly from AUR, but the base install doesn't — only a few optional installs, like the third-party browsers, pull from the AUR. -5. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability. +5. *New USB devices require approval*: Omarchy trusts the USB devices present during installation, then blocks newly connected accessories until you approve them. This keeps an unknown accessory from reaching a matching kernel driver merely because it claims a supported device identity. +6. *Cloudflare protects us from DDoS*: All the Omarchy distribution infrastructure — the ISOs, the Omarchy packages, the Arch mirror — is protected behind Cloudflare's formidable DDoS shield and hosted on their CDN. This provides superb availability. + +## USB Device Authorization + +USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. + +You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. + +Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity. + +The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy. + +For machines whose disk unlock and recovery never depend on USB input, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. ## Changing your passwords diff --git a/migrations/1789433473.sh b/migrations/1789433473.sh new file mode 100644 index 00000000..47a59c55 --- /dev/null +++ b/migrations/1789433473.sh @@ -0,0 +1,3 @@ +echo "Enable approval for newly connected USB devices" + +omarchy-setup-security-usb-authorization --yes diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index a424379b..cf8b7a35 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -324,6 +324,13 @@ assert( defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'), 'menu places Passwordless Sudo under Setup > Security' ) +assert( + defaultById['setup.security.usb-authorization'].action.includes('omarchy-setup-security-usb-authorization') + && defaultById['remove.security.usb-authorization'].action.includes('omarchy-remove-security-usb-authorization') + && defaultById['setup.security.usb-authorization-boot'].action.includes('omarchy-setup-security-usb-authorization --boot') + && defaultById['remove.security.usb-authorization-boot'].action.includes('omarchy-remove-security-usb-authorization --boot-only'), + 'menu can enable and remove USB device authorization under Security' +) assert( !defaultById['trigger.toggle.direct-boot'] && !defaultById['trigger.toggle.passwordless-sudo'], 'menu removes the relocated toggles from Trigger > Toggle' diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh new file mode 100755 index 00000000..68c8ed56 --- /dev/null +++ b/test/shell.d/usb-authorization-test.sh @@ -0,0 +1,405 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT + +stub_bin="$scratch/bin" +home="$scratch/home" +rules="$scratch/etc/usbguard/rules.conf" +daemon_config="$scratch/etc/usbguard/usbguard-daemon.conf" +calls="$scratch/calls" + +mkdir -p "$stub_bin" "$home" "${rules%/*}" +: >"$rules" +: >"$calls" + +cat >"$daemon_config" <<'CONF' +ImplicitPolicyTarget=block +PresentDevicePolicy=apply-policy +InsertedDevicePolicy=apply-policy +AuthorizedDefault=none +RestoreControllerDeviceState=false +CONF + +cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-pkg-present" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +printf 'pkg-add <%s>\n' "$*" >>"$CALLS" +STUB +cat >"$stub_bin/omarchy-pkg-drop" <<'STUB' +#!/bin/bash +printf 'pkg-drop <%s>\n' "$*" >>"$CALLS" +STUB +cat >"$stub_bin/install" <<'STUB' +#!/bin/bash +if [[ $1 == -Dm600 && $2 == -o && $3 == root && $4 == -g && $5 == root ]]; then + exec /usr/bin/install -Dm600 "$6" "$7" +fi +exec /usr/bin/install "$@" +STUB +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl <%s>\n' "$*" >>"$CALLS" +if [[ $* == "is-enabled --quiet usbguard.service" ]]; then + exit 1 +fi +STUB +cat >"$stub_bin/usbguard" <<'STUB' +#!/bin/bash +case "$1" in +generate-policy) + echo 'usbguard ' >>"$CALLS" + echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + ;; +list-devices) + if [[ ${2:-} == "--blocked" ]]; then + if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then + rule="${BLOCKED_DEVICE_RULE:?}" + if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then + rule=$(<"$BLOCKED_DEVICE_RULE_FILE") + fi + printf '17: %s\n' "$rule" + fi + else + echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' + fi + ;; +*) + printf 'usbguard' >>"$CALLS" + printf ' <%s>' "$@" >>"$CALLS" + printf '\n' >>"$CALLS" + ;; +esac +STUB +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +printf 'sudo' >>"$CALLS" +printf ' <%s>' "$@" >>"$CALLS" +printf '\n' >>"$CALLS" +if [[ $1 == awk && ${!#} == /etc/usbguard/usbguard-daemon.conf ]]; then + args=("$@") + args[-1]="$TEST_DAEMON_CONFIG" + exec "${args[@]}" +elif [[ $1 == test && $2 == -s && $3 == /etc/usbguard/rules.conf ]]; then + exec /usr/bin/test -s "$TEST_RULES" +elif [[ $1 == install && $2 == -Dm600 && $3 == -o && $4 == root && $5 == -g && $6 == root && $8 == /etc/usbguard/rules.conf ]]; then + exec /usr/bin/install -Dm600 "$7" "$TEST_RULES" +elif [[ $1 == omarchy-usb-authorization-restore-default ]]; then + echo 1 >"$TEST_SYSFS/usb1/authorized_default" + echo 1 >"$TEST_SYSFS/1-2/authorized" + exit 0 +elif [[ $1 == omarchy-usb-authorization-boot ]]; then + exit 0 +fi +exec "$@" +STUB +cat >"$stub_bin/gum" <<'STUB' +#!/bin/bash +case "$1" in +style) exit 0 ;; +choose) + if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then + printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE" + fi + printf '%s\n' "${GUM_CHOICE:-Keep blocked}" + ;; +confirm) exit 0 ;; +esac +STUB +cat >"$stub_bin/omarchy-notification-send" <<'STUB' +#!/bin/bash +printf 'notification' >>"$CALLS" +printf ' <%s>' "$@" >>"$CALLS" +printf '\n' >>"$CALLS" +STUB +chmod +x "$stub_bin"/* + +export HOME="$home" +export USER=tester +export OMARCHY_PATH="$ROOT" +export CALLS="$calls" +export PATH="$stub_bin:$ROOT/bin:$PATH" +export TEST_DAEMON_CONFIG="$daemon_config" +export TEST_RULES="$rules" + +if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then + fail "the fixed-path USB authorization restore helper requires root" +fi +if "$ROOT/bin/omarchy-usb-authorization-boot" enable >/dev/null 2>&1; then + fail "the fixed-path boot authorization helper requires root" +fi +! grep -q 'OMARCHY_USB_AUTHORIZATION_.*ROOT\|OMARCHY_USB_AUTHORIZATION_.*FILE\|OMARCHY_USB_AUTHORIZATION_.*CONFIG' \ + "$ROOT/bin/omarchy-setup-security-usb-authorization" \ + "$ROOT/bin/omarchy-remove-security-usb-authorization" \ + "$ROOT/bin/omarchy-usb-authorization-boot" || + fail "user-facing USB authorization commands do not elevate caller-selected paths" +pass "USB authorization keeps privileged paths fixed" + +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-output" + +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || + fail "USB authorization setup trusts devices present during enrollment" +[[ $(stat -c %a "$rules") == 600 ]] || fail "USB authorization policy is root-private" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "USB authorization grants only the IPC access needed by the approval flow" +grep -Fqx 'systemctl ' "$calls" || + fail "USB authorization reloads its user ACL before the watcher starts" +[[ -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || + fail "USB authorization installs the graphical-session watcher" +grep -Fqx 'pkg-add ' "$calls" || fail "USB authorization ensures USBGuard is installed" +pass "USB authorization enrolls present devices before enabling default-deny" + +generate_count=$(grep -c '^usbguard $' "$calls") +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-existing-output" +generate_count_after=$(grep -c '^usbguard $' "$calls") +(( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" +pass "USB authorization setup preserves an existing policy" + +: >"$calls" +"$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" +grep -Fqx 'usbguard <--permanent> <4>' "$calls" || + fail "boot authorization trusts the connected root controller" +grep -Fqx 'usbguard <--permanent> <5>' "$calls" || + fail "boot authorization trusts the connected USB devices" +grep -Fqx 'sudo ' "$calls" || + fail "boot authorization uses the fixed-path boot-image helper" +grep -Fq 'takes effect after reboot' "$scratch/setup-boot-output" || + fail "boot authorization explains when the change becomes active" +pass "USB authorization can snapshot connected devices for early-boot denial" + +boot_conf="$scratch/limine.conf" +machine_id=$("$scratch/embedded-cmdline" +historical_uki="$scratch/embedded.efi_sha256_fixture" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +embedded_hash=$(b2sum "$historical_uki") +embedded_hash=${embedded_hash%% *} +cat >"$boot_conf" </dev/null; then + fail "boot authorization rejects Limine's false-success status when entries were not rebuilt" +fi +usb_authorization_rewrite_boot_cmdlines enabled "$boot_conf" "$machine_id" +usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" disabled || + fail "a non-Secure-Boot snapshot uses its updated external command line" +if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "an external allow parameter cannot hide a stale embedded UKI command line" +fi +printf 'quiet splash usbcore.authorized_default=0\0' >"$scratch/embedded-cmdline" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +updated_embedded_hash=$(b2sum "$historical_uki") +updated_embedded_hash=${updated_embedded_hash%% *} +sed -i "s/embedded.efi_sha256_fixture#$embedded_hash/embedded.efi_sha256_fixture#$updated_embedded_hash/" "$boot_conf" +usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled || + fail "boot authorization updates current and snapshot entries for this machine" +[[ $(grep -c 'usbcore.authorized_default=0' "$boot_conf") == 3 ]] || + fail "boot authorization leaves another operating system's kernel entries unchanged" +printf 'stale image' >"$scratch/vmlinuz-linux" +if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "boot authorization rejects a stale Limine verification hash" +fi +: >"$scratch/vmlinuz-linux" +if usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then + fail "boot authorization detects a stale deny-by-default boot entry during removal" +fi +usb_authorization_rewrite_boot_cmdlines disabled "$boot_conf" "$machine_id" +printf 'quiet splash\0' >"$scratch/embedded-cmdline" +objcopy -I binary -O pei-x86-64 -B i386:x86-64 "$scratch/embedded-cmdline" "$historical_uki" +objcopy --rename-section .data=.cmdline,alloc,load,readonly,data,contents "$historical_uki" +disabled_embedded_hash=$(b2sum "$historical_uki") +disabled_embedded_hash=${disabled_embedded_hash%% *} +sed -i "s/embedded.efi_sha256_fixture#$updated_embedded_hash/embedded.efi_sha256_fixture#$disabled_embedded_hash/" "$boot_conf" +usb_authorization_rebuild_and_verify disabled "$boot_conf" "$scratch" "$machine_id" enabled || + fail "boot authorization removes the parameter from current and snapshot entries" + +snapshot_config="$scratch/limine-defaults" +printf 'TARGET_OS_NAME="Omarchy"\n' >"$snapshot_config" +usb_authorization_enable_snapshot_setting "$snapshot_config" +grep -Fqx 'SNAPSHOT_KERNEL_PARAMETERS+=usbcore.authorized_default=0' "$snapshot_config" || + fail "future Limine snapshots inherit boot-time USB authorization" +usb_authorization_enable_snapshot_setting "$snapshot_config" +[[ $(grep -Fxc '# Omarchy USB authorization begin' "$snapshot_config") == 1 ]] || + fail "the snapshot setting remains idempotent" +usb_authorization_disable_snapshot_setting "$snapshot_config" +! grep -Fq 'usbcore.authorized_default=0' "$snapshot_config" || + fail "removing boot authorization restores future snapshot defaults" +pass "boot authorization verifies generated entries despite Limine false-success exits" + +: >"$calls" +"$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output" +grep -Fqx 'sudo ' "$calls" || + fail "boot-only removal rebuilds the permissive boot policy" +! grep -Fq 'systemctl <--user disable --now omarchy-usb-authorization.service>' "$calls" || + fail "boot-only removal leaves the userspace approval watcher enabled" +grep -Fq 'takes effect after reboot' "$scratch/remove-boot-output" || + fail "boot-only removal explains when the change becomes active" +pass "early-boot denial can be removed without disabling USBGuard" + +grep -qx 'usbguard' "$ROOT/install/omarchy-base.packages" || fail "USBGuard is installed by default" +grep -Fq 'config/usb-authorization.sh' "$ROOT/install/config/all.sh" || + fail "fresh installs configure the USB authorization policy" +grep -Fq 'omarchy-usb-authorization.service' "$ROOT/install/user/first-run/enable-user-units.sh" || + fail "fresh installs enable the USB approval watcher" +grep -Fq 'usb_authorization_add_user "$username"' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning grants the new owner approval access" +grep -Fq 'systemctl try-restart usbguard.service' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning reloads the new owner ACL" +grep -Fqx 'omarchy-setup-security-usb-authorization --yes' "$ROOT/migrations/1789433473.sh" || + fail "existing installs enable USB authorization during migration" + +: >"$rules" +: >"$calls" +export OMARCHY_INSTALL="$ROOT/install" +export OMARCHY_INSTALL_USER=tester +export OMARCHY_USB_AUTHORIZATION_RULES_FILE="$rules" +export OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG="$daemon_config" +bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-output" +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || + fail "fresh installation enrolls devices present during installation" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "fresh installation grants the owner narrowly scoped approval access" +grep -Fqx 'systemctl ' "$calls" || + fail "fresh installation enables USBGuard for the first boot" +pass "USB authorization is the default for fresh and existing installations" + +malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' +export BLOCKED_DEVICE_RULE="$malicious_rule" +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" + +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +[[ -n $request ]] || fail "a blocked USB device creates a review request" +[[ $(stat -c %a "$request") == 600 ]] || fail "device-controlled request data stays private" +[[ $(jq -r .id "$request") == 17 ]] || fail "the request records the USBGuard device id" +[[ $(jq -r .rule "$request") == "$malicious_rule" ]] || fail "the request preserves the identity snapshot" +! grep -Fq "$malicious_rule" "$calls" || fail "device text never enters the notification's launch command" +[[ ! -e $scratch/injected ]] || fail "device text cannot execute during notification" +token=$(basename "${request%.json}") +grep -Eq "notification .* <$token>$" "$calls" || + fail "the notification action carries only an opaque request token" +pass "blocked device metadata remains data across the desktop notification boundary" + +GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" +grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" +[[ ! -e $request ]] || fail "a completed review consumes its request" +pass "USB review allows a still-matching device once" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" +grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" +pass "USB review supports explicit persistent trust" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +device_rule_file="$scratch/blocked-device-rule" +printf '%s\n' "$malicious_rule" >"$device_rule_file" +replacement_rule='block id 1d50:60c7 name "Replacement gadget" hash "replacement"' +allow_count=$(grep -c '^usbguard ' "$calls") +if BLOCKED_DEVICE_RULE_FILE="$device_rule_file" \ + GUM_REASSIGN_RULE="$replacement_rule" \ + GUM_CHOICE='Always allow this device' \ + "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-reassigned-output" 2>&1; then + fail "review rejects a USBGuard ID reassigned while the approval dialog is open" +fi +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || + fail "an ID reassigned during review cannot authorize a different device" +[[ ! -e $request ]] || fail "a reassigned-ID review request is discarded" +pass "USB review revalidates identity after the approval dialog" + +USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_EVENT=Insert \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) +token=$(basename "${request%.json}") +allow_count=$(grep -c '^usbguard ' "$calls") +if BLOCKED_DEVICE_PRESENT=0 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-stale-output" 2>&1; then + fail "review rejects a request after its device disappears" +fi +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || + fail "a reused USBGuard id cannot authorize a different device" +[[ ! -e $request ]] || fail "a stale review request is discarded" +pass "USB review binds approval to the device snapshot" + +sysfs="$scratch/sys/bus/usb/devices" +mkdir -p "$sysfs/usb1" "$sysfs/1-2" +echo 0 >"$sysfs/usb1/authorized_default" +echo 0 >"$sysfs/1-2/authorized" +export TEST_SYSFS="$sysfs" + +"$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/remove-output" + +[[ $(<"$sysfs/usb1/authorized_default") == 1 ]] || fail "removal restores root-hub default authorization" +[[ $(<"$sysfs/1-2/authorized") == 1 ]] || fail "removal reauthorizes devices left connected" +grep -Fqx 'systemctl ' "$calls" || fail "removal stops a service Omarchy enabled" +grep -Fqx 'usbguard ' "$calls" || fail "removal drops the user's USBGuard IPC access" +grep -Fqx 'sudo ' "$calls" || + fail "removal restores USB authorization only through the fixed-path root helper" +grep -Fqx 'sudo ' "$calls" || + fail "removing USB authorization also removes early-boot denial" +! grep -Fqx 'pkg-drop ' "$calls" || fail "disabling keeps the default USBGuard package installed" +[[ ! -e $home/.config/systemd/user/omarchy-usb-authorization.service ]] || + fail "removal deletes the graphical-session watcher" +pass "USB authorization removal restores the original default-allow behavior" From 7309746ac842a30774be8da850ce358306bc76df Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:05:25 -0600 Subject: [PATCH 02/34] Fix USB approval alerts after policy changes and reconnects --- bin/omarchy-usb-authorization-event | 44 ++++++++++++++-- bin/omarchy-usb-authorization-watch | 15 +----- test/shell.d/usb-authorization-test.sh | 72 +++++++++++++++++++++++--- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 53182715..31031127 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -5,9 +5,30 @@ set -euo pipefail -[[ ${USBGUARD_IPC_SIGNAL:-} == "Device.PresenceChanged" ]] || exit 0 -[[ ${USBGUARD_DEVICE_EVENT:-} == "Insert" ]] || exit 0 -[[ ${USBGUARD_DEVICE_TARGET:-} == "block" ]] || exit 0 +case "${USBGUARD_IPC_SIGNAL:-}" in +IPC.Connected) + # USBGuard scans present devices before accepting IPC connections. Repeat + # this on every reconnect: temporary approvals disappear on daemon restart. + blocked=$(usbguard list-devices --blocked) || exit 1 + while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + fi + done <<<"$blocked" + exit 0 + ;; +Device.PolicyApplied) + # PresenceChanged precedes policy evaluation, even for trusted devices. + # PolicyApplied also covers block-to-block and allow-to-block transitions. + [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 + ;; +*) exit 0 ;; +esac + [[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 [[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 @@ -20,6 +41,23 @@ chmod 700 "${requests%/*}" "$requests" # is never interpolated into a shell command. token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | sha256sum | cut -d' ' -f1)" request="$requests/$token.json" +# watch executes handlers concurrently. A reconnect scan and a policy event +# can refer to the same device; publish only one pending request. +exec 9>"$requests/.lock" +flock 9 +[[ ! -e $request ]] || exit 0 + +# Discard events overtaken by another policy decision or device replacement. +blocked=$(usbguard list-devices --blocked) || exit 1 +current=0 +while IFS= read -r line; do + if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then + current=1 + break + fi +done <<<"$blocked" +(( current )) || exit 0 + temporary=$(mktemp "$requests/.request.XXXXXXXXXX") trap 'rm -f "$temporary"' EXIT diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch index 8b22fdf4..302c7968 100755 --- a/bin/omarchy-usb-authorization-watch +++ b/bin/omarchy-usb-authorization-watch @@ -7,17 +7,6 @@ set -euo pipefail event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" -# A device inserted while the graphical session was stopped is already safely -# blocked by USBGuard. Surface it now so the user can approve it after login. -while IFS= read -r line; do - if [[ $line =~ ^([0-9]+):[[:space:]](block.*)$ ]]; then - USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ - USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ - USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ - USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ - "$event_handler" - fi -done < <(usbguard list-devices --blocked) - +# The handler scans blocked devices on IPC.Connected, including reconnects +# after a daemon restart, once the event subscription is established. exec usbguard watch --wait --exec "$event_handler" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 68c8ed56..69d5687c 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -307,10 +307,35 @@ pass "USB authorization is the default for fresh and existing installations" malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +notification_count=$(grep -c '^notification' "$calls" || true) +for presence in Insert Present; do + USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ + USBGUARD_DEVICE_EVENT="$presence" \ + USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +done +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_TARGET_NEW=allow \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] || + fail "a trusted device's transient blocked presence must not prompt" +BLOCKED_DEVICE_PRESENT=0 \ + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls" || true) == "$notification_count" ]] || + fail "a stale blocked event must not prompt after authorization or removal" +pass "USB alerts use the final policy and discard stale events" +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" @@ -326,15 +351,46 @@ grep -Eq "notification .* <$token>$" "$calls" fail "the notification action carries only an opaque request token" pass "blocked device metadata remains data across the desktop notification boundary" +notification_count=$(grep -c '^notification' "$calls") +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +wait "$scan_pid" +[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] || + fail "overlapping scan and policy events must not duplicate pending prompts" +pass "USB reconnect scans and policy events deduplicate pending requests" + GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" [[ ! -e $request ]] || fail "a completed review consumes its request" pass "USB review allows a still-matching device once" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +# A daemon restart discards the temporary approval. The IPC reconnect must +# recover the blocked device even when its startup policy events were missed. +notification_count=$(grep -c '^notification' "$calls") +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "reconnection recreates a consumed Allow once request" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "reconnection prompts again for a temporary approval lost on restart" +GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_OLD=allow \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "a later allow-to-block policy transition creates a request" +GUM_CHOICE='Keep blocked' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >/dev/null +pass "USB approval recovers after IPC reconnects and policy transitions" + +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) @@ -343,10 +399,10 @@ GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-revie grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" pass "USB review supports explicit persistent trust" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) @@ -366,10 +422,10 @@ fi [[ ! -e $request ]] || fail "a reassigned-ID review request is discarded" pass "USB review revalidates identity after the approval dialog" -USBGUARD_IPC_SIGNAL=Device.PresenceChanged \ +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ - USBGUARD_DEVICE_TARGET=block \ + USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="$malicious_rule" \ "$ROOT/bin/omarchy-usb-authorization-event" request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) From 4ea410d6a1afe9b4d8a964be23d2fd9f578065ec Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:35:57 -0600 Subject: [PATCH 03/34] Retry USB approval notifications after delivery failure --- bin/omarchy-usb-authorization-event | 9 +++++++-- test/shell.d/usb-authorization-test.sh | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 31031127..a26fd6c6 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -68,11 +68,16 @@ jq -n \ chmod 600 "$temporary" mv -f "$temporary" "$request" -omarchy-notification-send \ +if ! omarchy-notification-send \ --app-name omarchy-action \ --urgency critical \ --glyph 󰖀 \ "USB accessory blocked" \ "Click to review it. Keep it blocked unless you recognize the device." \ --exec omarchy-launch-floating-terminal-with-presentation \ - omarchy-usb-authorization-review "$token" + omarchy-usb-authorization-review "$token"; then + # Only a delivered notification may suppress subsequent attempts. Keep the + # lock until failure cleanup so another event cannot skip an unsent request. + rm -f "$request" + exit 1 +fi diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 69d5687c..1cebe809 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -123,6 +123,7 @@ cat >"$stub_bin/omarchy-notification-send" <<'STUB' printf 'notification' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" +exit "${NOTIFICATION_FAIL:-0}" STUB chmod +x "$stub_bin"/* @@ -439,6 +440,18 @@ fi [[ ! -e $request ]] || fail "a stale review request is discarded" pass "USB review binds approval to the device snapshot" +notification_count=$(grep -c '^notification' "$calls") +if NOTIFICATION_FAIL=1 USBGUARD_IPC_SIGNAL=IPC.Connected \ + "$ROOT/bin/omarchy-usb-authorization-event"; then + fail "a failed notification must report failure" +fi +[[ ! -e $request ]] || fail "a failed notification must not suppress retries" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "reconnection retries a failed notification" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || + fail "reconnection must attempt delivery after an earlier send failure" +pass "failed USB notifications can be retried on reconnection" + sysfs="$scratch/sys/bus/usb/devices" mkdir -p "$sysfs/usb1" "$sysfs/1-2" echo 0 >"$sysfs/usb1/authorized_default" From 666800eb0c06e11de5bf7bd04282e4b112eef5de Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 13:35:57 -0600 Subject: [PATCH 04/34] Persist USB boot policy in Limine snapshot manifests --- bin/omarchy-usb-authorization-boot | 77 ++++++++++++++++++++++++-- test/shell.d/usb-authorization-test.sh | 44 +++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index e72139a3..6ffb010c 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -338,6 +338,75 @@ usb_authorization_rewrite_boot_cmdlines() { rm -f "$backup" } +usb_authorization_rewrite_snapshot_manifest() { + local expected="$1" manifest="$2" temporary + + [[ -e $manifest ]] || return 0 + temporary=$(mktemp "${manifest}.omarchy-usb.XXXXXXXXXX") + if ! cp --preserve=mode,ownership "$manifest" "$temporary"; then + rm -f "$temporary" + return 1 + fi + + # Limine uses the cached lines when present, and structured command lines + # otherwise. Update both, including nested kernels and restore entries. + if ! jq --arg expected "$expected" --arg parameter "$kernel_parameter" ' + def cmdline: + if type != "string" then error("Invalid snapshot command line") else + [splits("[[:space:]]+") | select(length > 0 and . != $parameter)] + | if $expected == "enabled" then . + [$parameter] else . end + | join(" ") + end; + def lines: + map(if test("^\\s*(cmdline|kernel_cmdline):"; "i") then + capture("^(?\\s*(?:cmdline|kernel_cmdline):\\s*)(?.*)$"; "i") + | .prefix + (.value | cmdline) + else . end); + def kernel: + .cmdlineDetails |= map(.cmdline |= cmdline | .snapshotCmdline |= cmdline) + | .allInConfig |= lines + | .allInSnapshotConfig |= lines + | .subKernels |= map(kernel); + if .jsonFormatVersion != "1.3.0" or (.snapshotEntries | type) != "array" then + error("Unsupported Limine snapshot manifest format") + else + .snapshotEntries |= map(.kernelEntries |= map(kernel)) + end + ' "$manifest" >"$temporary"; then + rm -f "$temporary" + echo "Cannot update stored USB boot policy in $manifest." >&2 + return 1 + fi + + mv -f "$temporary" "$manifest" +} + +usb_authorization_sync_and_verify() ( + local expected="$1" config="$2" esp="$3" + local machine_id="${4:-$(/run/lock/boot-partition.lock + flock --timeout 30 9 || return 1 + + # Recover/upgrade the manifest first, including backups if necessary. + limine-snapper-sync --no-mutex --no-hooks || return 1 + for manifest in \ + "$esp/$machine_id/limine_history/snapshots.json" \ + "$esp/$machine_id/limine_history/snapshots.json.old" \ + "/var/cache/boot/$machine_id/lss/snapshots.json"; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" || return 1 + done + + usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine_id" || return 1 + # Verify the regenerated menu, so synchronization cannot mask stale stored + # command lines with a one-time edit to limine.conf. + limine-snapper-sync --no-mutex --no-hooks || return 1 + limine-enroll-config || return 1 + usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" +) + main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary @@ -377,8 +446,7 @@ main() { fi if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines enabled "$limine_conf" || - ! usb_authorization_verify_boot_entries enabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify enabled "$limine_conf" "$boot_path"; then echo "Boot-time USB authorization remains configured in $drop_in; rerun this command after fixing the boot-image error." >&2 exit 1 fi @@ -401,14 +469,11 @@ main() { fi had_drop_in=1 rm -f "$drop_in" - elif (( ! had_snapshot_setting )) && usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then - exit 0 fi usb_authorization_disable_snapshot_setting "$limine_defaults" if ! limine-mkinitcpio || - ! usb_authorization_rewrite_boot_cmdlines disabled "$limine_conf" || - ! usb_authorization_verify_boot_entries disabled "$limine_conf" "$boot_path"; then + ! usb_authorization_sync_and_verify disabled "$limine_conf" "$boot_path"; then if (( had_drop_in )); then printf '%s\n' "$setting" >"$drop_in" chmod 644 "$drop_in" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 1cebe809..784906de 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -269,6 +269,50 @@ usb_authorization_disable_snapshot_setting "$snapshot_config" fail "removing boot authorization restores future snapshot defaults" pass "boot authorization verifies generated entries despite Limine false-success exits" +manifest="$scratch/snapshots.json" +jq -n --arg hash "$boot_hash" ' + def kernel: + {cmdlineDetails: [{limineKey: "CMDLINE", cmdline: "quiet rootflags=subvol=@", snapshotCmdline: "quiet rootflags=subvol=/snapshot"}], + allInConfig: [" cmdline: quiet rootflags=subvol=@"], + allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/vmlinuz-linux#" + $hash, "cmdline: quiet rootflags=subvol=/snapshot"], + subKernels: [], properties: {untouched: "usbcore.authorized_default=0"}}; + {jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel | .allInSnapshotConfig = []]]}], uuid: "preserve-me"} +' >"$manifest" +cp "$manifest" "$scratch/manifest-original.json" +for expected in enabled disabled; do + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cp "$manifest" "$scratch/manifest-once.json" + usb_authorization_rewrite_snapshot_manifest "$expected" "$manifest" + cmp -s "$manifest" "$scratch/manifest-once.json" || fail "manifest updates are idempotent" + # Model both Limine render paths: cached lines and structured details. + for representation in cached structured; do + { + printf '/Omarchy\ncomment: machine-id=%s\n //linux\n' "$machine_id" + if [[ $representation == "cached" ]]; then + jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$manifest" + else + printf 'comment: kernel-id=linux\npath: boot():/vmlinuz-linux#%s\n' "$boot_hash" + jq -r '"cmdline: " + .snapshotEntries[0].kernelEntries[0].subKernels[0].cmdlineDetails[0].snapshotCmdline' "$manifest" + fi + } >"$scratch/regenerated.conf" + usb_authorization_verify_boot_entries "$expected" "$scratch/regenerated.conf" "$scratch" "$machine_id" disabled || + fail "snapshot synchronization preserves $expected policy from $representation entries" + done + jq -e '.snapshotEntries[0].kernelEntries[0].properties.untouched == "usbcore.authorized_default=0"' "$manifest" >/dev/null || + fail "manifest changes preserve unrelated metadata" +done +jq -S . "$manifest" >"$scratch/manifest-after.json" +jq -S . "$scratch/manifest-original.json" >"$scratch/manifest-before.json" +cmp -s "$scratch/manifest-before.json" "$scratch/manifest-after.json" || + fail "disable restores all stored command lines without changing other snapshot data" +printf '{bad json' >"$scratch/bad-manifest.json" +if usb_authorization_rewrite_snapshot_manifest enabled "$scratch/bad-manifest.json" 2>/dev/null; then + fail "invalid manifests must fail closed" +fi +[[ $(<"$scratch/bad-manifest.json") == '{bad json' ]] || fail "failed manifest updates preserve the original" +pass "USB boot policy survives snapshot regeneration from cached and structured manifests" + + : >"$calls" "$ROOT/bin/omarchy-remove-security-usb-authorization" --boot-only --yes >"$scratch/remove-boot-output" grep -Fqx 'sudo ' "$calls" || From 79db1a182696ad696af8f09ee9b8b9efa36beab5 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:06:32 -0600 Subject: [PATCH 05/34] Allow USB authorization setup with an empty device inventory --- install/helpers/usb-authorization.sh | 14 ++++++++--- test/shell.d/usb-authorization-test.sh | 32 +++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index 219a8e5b..a99fbfb9 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -59,11 +59,19 @@ usb_authorization_require_secure_settings() { usb_authorization_generate_policy() { local policy="$1" - usbguard generate-policy >"$policy" - if [[ ! -s $policy ]] || ! grep -q '^allow ' "$policy"; then - echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + if ! usbguard generate-policy >"$policy"; then + echo "USBGuard could not enumerate devices; nothing was enabled." >&2 return 1 fi + if ! grep -q '^allow ' "$policy"; then + if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then + echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 + return 1 + fi + # A successful empty inventory is valid on machines without USB controllers. + # Keep the file nonempty so re-enabling preserves this default-deny policy. + echo '# No USB devices were present during enrollment.' >>"$policy" + fi } usb_authorization_add_user() { diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 784906de..fd05258d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -60,7 +60,10 @@ cat >"$stub_bin/usbguard" <<'STUB' case "$1" in generate-policy) echo 'usbguard ' >>"$CALLS" - echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + [[ ${GENERATE_FAIL:-0} == 0 ]] || exit 1 + if [[ ${GENERATE_EMPTY:-0} == 0 ]]; then + echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + fi ;; list-devices) if [[ ${2:-} == "--blocked" ]]; then @@ -168,6 +171,21 @@ generate_count_after=$(grep -c '^usbguard $' "$calls") (( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" pass "USB authorization setup preserves an existing policy" +: >"$rules" +GENERATE_EMPTY=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-output" +[[ -s $rules ]] || fail "empty USB inventory must persist an initialized policy" +! grep -q '^allow ' "$rules" || fail "empty inventory must not add permissive rules" +generate_count=$(grep -c '^usbguard $' "$calls") +"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-again-output" +[[ $(grep -c '^usbguard $' "$calls") == "$generate_count" ]] || + fail "re-enabling an empty policy must not enroll newly attached devices" +: >"$rules" +if GENERATE_FAIL=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-failed-output" 2>&1; then + fail "enumeration failure must not be accepted as an empty USB inventory" +fi +[[ ! -s $rules ]] || fail "failed enumeration must not install a policy" +pass "USB setup accepts an empty inventory while rejecting enumeration failure" + : >"$calls" "$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" grep -Fqx 'usbguard <--permanent> <4>' "$calls" || @@ -350,6 +368,18 @@ grep -Fqx 'systemctl ' "$calls" || fail "fresh installation enables USBGuard for the first boot" pass "USB authorization is the default for fresh and existing installations" +: >"$rules" +GENERATE_EMPTY=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-empty-output" +[[ -s $rules ]] || fail "fresh install accepts a successful empty USB inventory" +! grep -q '^allow ' "$rules" || fail "empty fresh-install inventory stays default-deny" +: >"$rules" +: >"$calls" +if GENERATE_FAIL=1 bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-enumeration-failed" 2>&1; then + fail "fresh install must reject a generator failure" +fi +! grep -Fq 'systemctl ' "$calls" || fail "failed enumeration must not enable USBGuard" +pass "fresh installation distinguishes no USB hardware from enumeration failure" + malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" notification_count=$(grep -c '^notification' "$calls" || true) From 9e20c2a185fdb2e5abb88e507ce443314e2041c8 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:06:32 -0600 Subject: [PATCH 06/34] Retry USB approval delivery when notifications become available --- bin/omarchy-usb-authorization-event | 76 +++++++++++++++----------- test/shell.d/usb-authorization-test.sh | 43 +++++++++++---- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index a26fd6c6..8a93a1c0 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -16,7 +16,7 @@ IPC.Connected) USBGUARD_DEVICE_ID="${BASH_REMATCH[1]}" \ USBGUARD_DEVICE_TARGET_NEW=block \ USBGUARD_DEVICE_RULE="${BASH_REMATCH[2]}" \ - "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" || true fi done <<<"$blocked" exit 0 @@ -44,40 +44,52 @@ request="$requests/$token.json" # watch executes handlers concurrently. A reconnect scan and a policy event # can refer to the same device; publish only one pending request. exec 9>"$requests/.lock" -flock 9 -[[ ! -e $request ]] || exit 0 +# Keep retrying without needing another USB event. Handlers belong to the +# watcher service and are stopped with the graphical session. +while true; do + flock 9 + [[ ! -e $request ]] || exit 0 -# Discard events overtaken by another policy decision or device replacement. -blocked=$(usbguard list-devices --blocked) || exit 1 -current=0 -while IFS= read -r line; do - if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then - current=1 - break + # Discard events overtaken by another policy decision or device replacement. + blocked=$(usbguard list-devices --blocked) || exit 1 + current=0 + while IFS= read -r line; do + if [[ $line == "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" ]]; then + current=1 + break + fi + done <<<"$blocked" + (( current )) || exit 0 + + # Login and shell restarts can leave D-Bus alive before notifications are ready. + # Bound each wait so disappeared devices are rechecked between attempts. + if ! omarchy-notification-wait 1; then + flock -u 9 + sleep 1 + continue fi -done <<<"$blocked" -(( current )) || exit 0 -temporary=$(mktemp "$requests/.request.XXXXXXXXXX") -trap 'rm -f "$temporary"' EXIT + temporary=$(mktemp "$requests/.request.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT -jq -n \ - --arg id "$USBGUARD_DEVICE_ID" \ - --arg rule "$USBGUARD_DEVICE_RULE" \ - '{id: $id, rule: $rule}' >"$temporary" -chmod 600 "$temporary" -mv -f "$temporary" "$request" + jq -n \ + --arg id "$USBGUARD_DEVICE_ID" \ + --arg rule "$USBGUARD_DEVICE_RULE" \ + '{id: $id, rule: $rule}' >"$temporary" + chmod 600 "$temporary" + mv -f "$temporary" "$request" -if ! omarchy-notification-send \ - --app-name omarchy-action \ - --urgency critical \ - --glyph 󰖀 \ - "USB accessory blocked" \ - "Click to review it. Keep it blocked unless you recognize the device." \ - --exec omarchy-launch-floating-terminal-with-presentation \ - omarchy-usb-authorization-review "$token"; then - # Only a delivered notification may suppress subsequent attempts. Keep the - # lock until failure cleanup so another event cannot skip an unsent request. + if omarchy-notification-send \ + --app-name omarchy-action \ + --urgency critical \ + --glyph 󰖀 \ + "USB accessory blocked" \ + "Click to review it. Keep it blocked unless you recognize the device." \ + --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-usb-authorization-review "$token"; then + exit 0 + fi rm -f "$request" - exit 1 -fi + flock -u 9 + sleep 1 +done diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index fd05258d..6017e18d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -67,6 +67,7 @@ generate-policy) ;; list-devices) if [[ ${2:-} == "--blocked" ]]; then + if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then rule="${BLOCKED_DEVICE_RULE:?}" if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then @@ -126,7 +127,17 @@ cat >"$stub_bin/omarchy-notification-send" <<'STUB' printf 'notification' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" -exit "${NOTIFICATION_FAIL:-0}" +if [[ -n ${NOTIFICATION_FAILURE_MARKER:-} && ! -e $NOTIFICATION_FAILURE_MARKER ]]; then + touch "$NOTIFICATION_FAILURE_MARKER" + exit 1 +fi +STUB +cat >"$stub_bin/omarchy-notification-wait" <<'STUB' +#!/bin/bash +if [[ -n ${NOTIFICATION_READY_FILE:-} ]]; then + touch "$NOTIFICATION_READY_FILE.waited" + [[ -e $NOTIFICATION_READY_FILE ]] +fi STUB chmod +x "$stub_bin"/* @@ -515,16 +526,26 @@ fi pass "USB review binds approval to the device snapshot" notification_count=$(grep -c '^notification' "$calls") -if NOTIFICATION_FAIL=1 USBGUARD_IPC_SIGNAL=IPC.Connected \ - "$ROOT/bin/omarchy-usb-authorization-event"; then - fail "a failed notification must report failure" -fi -[[ ! -e $request ]] || fail "a failed notification must not suppress retries" -USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" -[[ -f $request ]] || fail "reconnection retries a failed notification" -[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || - fail "reconnection must attempt delivery after an earlier send failure" -pass "failed USB notifications can be retried on reconnection" +printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" +NOTIFICATION_READY_FILE="$scratch/notification-ready" \ + NOTIFICATION_FAILURE_MARKER="$scratch/first-send-failed" \ + BLOCKED_DEVICES_FILE="$scratch/two-blocked" \ + USBGUARD_IPC_SIGNAL=IPC.Connected \ + timeout 10 "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +for ((attempt=0; attempt<50; attempt++)); do + [[ -e $scratch/notification-ready.waited ]] && break + sleep 0.02 +done +[[ -e $scratch/notification-ready.waited ]] || fail "startup waits for notification readiness" +[[ $(grep -c '^notification' "$calls") == "$notification_count" ]] || + fail "startup does not send before the notification server is ready" +touch "$scratch/notification-ready" +wait "$scan_pid" || fail "startup scan must retry and finish without another USB event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] || + fail "two blocked devices must be notified despite a failed first send" +[[ -f $request ]] || fail "successful retry leaves a reviewable request" +pass "USB startup waits for notifications and automatically retries delivery for all devices" sysfs="$scratch/sys/bus/usb/devices" mkdir -p "$sysfs/usb1" "$sysfs/1-2" From 54bacefbf4be83b16f1d42b29b89386991dc6aeb Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 15 Sep 2026 14:11:46 -0600 Subject: [PATCH 07/34] Explain USB availability in older recovery snapshots --- bin/omarchy-setup-security-usb-authorization | 10 ++++++++-- manual/48-security.md | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index 0c21c558..c059b0fb 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -22,6 +22,9 @@ while (( $# > 0 )); do echo "--boot also blocks USB devices from the start of kernel boot. Connected" echo "devices are added to USBGuard's policy, but remain unavailable before" echo "USBGuard starts, including at an encrypted-disk password prompt." + echo "Older snapshots without USBGuard keep all USB disabled after startup," + echo "including keyboards, network adapters, and storage. Use --boot only if" + echo "disk unlock and recovery work without any USB devices." exit 0 ;; *) @@ -53,8 +56,11 @@ if (( ! assume_yes )); then echo echo "Boot-time protection blocks all USB devices until USBGuard starts." echo "A USB keyboard will not work at an encrypted-disk password prompt, even" - echo "when it is connected now. Continue only if unlock and recovery do not" - echo "depend on USB input." + echo "when it is connected now." + echo + echo "Older snapshots without USBGuard keep ALL USB disabled after startup," + echo "including keyboards, network adapters, and storage." + echo "Continue only if disk unlock and recovery work without any USB devices." fi echo if (( from_boot )); then diff --git a/manual/48-security.md b/manual/48-security.md index 76f1db24..ecce35a2 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -19,7 +19,9 @@ Only approve hardware you recognize. A malicious USB device can forge the manufa The policy protects devices connected after USBGuard starts during boot. It does not replace operating-system and driver security updates, and USB devices needed before the service starts, such as a keyboard used to unlock an encrypted disk, remain governed by the kernel's boot-time policy. -For machines whose disk unlock and recovery never depend on USB input, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. +For machines whose disk unlock and recovery never depend on any USB devices, _Setup > Security > USB at Boot_ can extend the block to the beginning of kernel boot. Omarchy adds every currently connected device to USBGuard's trusted policy, sets `usbcore.authorized_default=0`, and updates current and Limine snapshot boot entries. The trusted policy applies only once USBGuard starts: even a trusted USB keyboard remains unavailable at an earlier encrypted-disk password prompt. + +Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. ## Changing your passwords From 67d173c9f942649f3d030b9b0f7505d959327c50 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 16 Sep 2026 16:05:59 -0600 Subject: [PATCH 08/34] Defer USB enrollment until owner provisioning completes --- bin/omarchy-provision-owner | 12 ++++---- install/config/usb-authorization.sh | 22 ++++++-------- install/helpers/usb-authorization.sh | 37 +++++++++++++++++++---- manual/48-security.md | 2 ++ test/shell.d/usb-authorization-test.sh | 41 ++++++++++++++++++++++---- 5 files changed, 85 insertions(+), 29 deletions(-) diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index f4bff333..a4f2d996 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -747,12 +747,6 @@ create_user() { echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel chmod 440 /etc/sudoers.d/00-omarchy-wheel - if omarchy-pkg-present usbguard; then - source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" - usb_authorization_add_user "$username" - systemctl try-restart usbguard.service - fi - source "$OMARCHY_PATH/install/helpers/browser-policy.sh" for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue @@ -1040,6 +1034,12 @@ run_provisioning() { limine-update fi + if omarchy-pkg-present usbguard; then + log_step "enrolling the owner's USB devices" + source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" + usb_authorization_provision_owner "$username" + fi + log_step "cleaning up provisioning state" cleanup_oem_state log_step "first-boot setup complete" diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh index 958b82ae..6b0747e8 100644 --- a/install/config/usb-authorization.sh +++ b/install/config/usb-authorization.sh @@ -1,5 +1,12 @@ echo "Configure USB device authorization" +# A deferred install has only the builder's peripherals attached. The owner +# needs working input before there is an account or a session for approvals. +if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then + systemctl disable usbguard.service + return 0 +fi + rules_file="${OMARCHY_USB_AUTHORIZATION_RULES_FILE:-/etc/usbguard/rules.conf}" daemon_config="${OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG:-/etc/usbguard/usbguard-daemon.conf}" @@ -7,21 +14,10 @@ source "$OMARCHY_INSTALL/helpers/usb-authorization.sh" usb_authorization_require_secure_settings "$daemon_config" if [[ ! -s $rules_file ]]; then - policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") - chmod 600 "$policy" - - if usb_authorization_generate_policy "$policy"; then - install -Dm600 -o root -g root "$policy" "$rules_file" - rm -f "$policy" - else - rm -f "$policy" - return 1 - fi + usb_authorization_install_policy "$rules_file" fi -if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then - usb_authorization_add_user "$OMARCHY_INSTALL_USER" -fi +usb_authorization_add_user "$OMARCHY_INSTALL_USER" # Installs are followed by a reboot, so enable the daemon without starting it # in the live ISO's chroot. diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index a99fbfb9..70e36628 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -49,11 +49,11 @@ usb_authorization_require_secure_setting() { usb_authorization_require_secure_settings() { local config="$1" - usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block - usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy - usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy - usb_authorization_require_secure_setting "$config" AuthorizedDefault none - usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false + usb_authorization_require_secure_setting "$config" ImplicitPolicyTarget block || return 1 + usb_authorization_require_secure_setting "$config" PresentDevicePolicy apply-policy || return 1 + usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1 + usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1 + usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1 } usb_authorization_generate_policy() { @@ -85,6 +85,33 @@ usb_authorization_add_user() { --exceptions=listen } +usb_authorization_install_policy() { + local rules="$1" policy status=0 + + policy=$(mktemp "${TMPDIR:-/tmp}/omarchy-usb-policy.XXXXXXXXXX") || return 1 + if usb_authorization_generate_policy "$policy"; then + install -Dm600 -o root -g root "$policy" "$rules" || status=$? + else + status=1 + fi + rm -f "$policy" + return "$status" +} + +usb_authorization_provision_owner() { + local user="$1" + local rules="${2:-/etc/usbguard/rules.conf}" + local config="${3:-/etc/usbguard/usbguard-daemon.conf}" + + usb_authorization_require_secure_settings "$config" || return 1 + # Enroll the owner's hardware, replacing any policy inherited from the + # builder. Do this only after the interactive provisioning steps finish. + usb_authorization_install_policy "$rules" || return 1 + usb_authorization_add_user "$user" || return 1 + systemctl enable usbguard.service || return 1 + systemctl restart usbguard.service +} + usb_authorization_trust_present_devices() { local line id local count=0 diff --git a/manual/48-security.md b/manual/48-security.md index ecce35a2..01e805b6 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -13,6 +13,8 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. +On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard. + You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. Only approve hardware you recognize. A malicious USB device can forge the manufacturer, model, serial number, and device class shown during review. Permanent approval is a convenience for accessories you control, not proof of the device's identity. diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 6017e18d..c04d3acc 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -62,7 +62,7 @@ generate-policy) echo 'usbguard ' >>"$CALLS" [[ ${GENERATE_FAIL:-0} == 0 ]] || exit 1 if [[ ${GENERATE_EMPTY:-0} == 0 ]]; then - echo 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' + printf '%s\n' "${GENERATED_RULE:-allow id 1d6b:0002 name \"Linux Foundation root hub\" hash \"root\"}" fi ;; list-devices) @@ -357,10 +357,8 @@ grep -Fq 'config/usb-authorization.sh' "$ROOT/install/config/all.sh" || fail "fresh installs configure the USB authorization policy" grep -Fq 'omarchy-usb-authorization.service' "$ROOT/install/user/first-run/enable-user-units.sh" || fail "fresh installs enable the USB approval watcher" -grep -Fq 'usb_authorization_add_user "$username"' "$ROOT/bin/omarchy-provision-owner" || - fail "deferred provisioning grants the new owner approval access" -grep -Fq 'systemctl try-restart usbguard.service' "$ROOT/bin/omarchy-provision-owner" || - fail "deferred provisioning reloads the new owner ACL" +grep -Fq 'usb_authorization_provision_owner "$username"' "$ROOT/bin/omarchy-provision-owner" || + fail "deferred provisioning enrolls the owner before finishing" grep -Fqx 'omarchy-setup-security-usb-authorization --yes' "$ROOT/migrations/1789433473.sh" || fail "existing installs enable USB authorization during migration" @@ -391,6 +389,39 @@ fi ! grep -Fq 'systemctl ' "$calls" || fail "failed enumeration must not enable USBGuard" pass "fresh installation distinguishes no USB hardware from enumeration failure" +: >"$rules" +: >"$calls" +OMARCHY_INSTALL_USER="" bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-deferred-output" +[[ ! -s $rules ]] || fail "deferred installation must not enroll the builder's hardware" +! grep -q '^usbguard' "$calls" || fail "deferred installation must not enumerate or grant owner ACLs" +grep -Fqx 'systemctl ' "$calls" || fail "deferred installation leaves enforcement disabled" +! grep -Fq 'systemctl ' "$calls" || fail "deferred installation must not enable enforcement" + +source "$ROOT/install/helpers/usb-authorization.sh" +echo 'allow id 1234:0001 name "Builder keyboard" hash "builder"' >"$rules" +owner_rule='allow id 1234:0002 name "Owner keyboard" hash "owner"' +: >"$calls" +GENERATED_RULE="$owner_rule" usb_authorization_provision_owner owner "$rules" "$daemon_config" +[[ $(<"$rules") == "$owner_rule" ]] || fail "owner enrollment must replace inherited builder trust" +grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || + fail "owner provisioning grants approval access" +[[ $(tail -2 "$calls") == $'systemctl \nsystemctl ' ]] || + fail "enforcement starts only after owner policy and ACL setup" + +: >"$calls" +if GENERATE_FAIL=1 usb_authorization_provision_owner owner "$rules" "$daemon_config" >"$scratch/owner-failed" 2>&1; then + fail "owner enrollment must reject enumeration failure" +fi +[[ $(<"$rules") == "$owner_rule" ]] || fail "failed enrollment preserves the old policy" +! grep -q '^systemctl' "$calls" || fail "failed owner enrollment must not enable enforcement" +: >"$calls" +sed 's/ImplicitPolicyTarget=block/ImplicitPolicyTarget=allow/' "$daemon_config" >"$scratch/insecure-daemon.conf" +if usb_authorization_provision_owner owner "$rules" "$scratch/insecure-daemon.conf" >"$scratch/owner-insecure" 2>&1; then + fail "owner enrollment rejects insecure settings even when called in a conditional" +fi +[[ ! -s $calls ]] || fail "invalid settings must prevent all enrollment changes" +pass "deferred installs enroll owner hardware before enabling enforcement" + malicious_rule='block id 07a6:8513 name "$(touch '"$scratch"'/injected)" hash "attacker" with-interface 02:06:00' export BLOCKED_DEVICE_RULE="$malicious_rule" notification_count=$(grep -c '^notification' "$calls" || true) From 58a9c95bf6f3f0eab49b2f75ce2a39c9f867cf74 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Wed, 16 Sep 2026 16:06:13 -0600 Subject: [PATCH 09/34] Verify USB approval before reporting success --- bin/omarchy-usb-authorization-review | 47 +++++++++++++++++---- test/shell.d/usb-authorization-test.sh | 56 +++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index aa3c16c4..432a3199 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -60,16 +60,49 @@ if [[ $choice != "Keep blocked" ]]; then fi fi +approval_failed() { + echo "Could not confirm USB approval. The request has been kept; try again." >&2 + exit 1 +} + case "$choice" in -"Allow once") +"Allow once" | "Always allow this device") # Target the complete identity rule instead of USBGuard's numeric ID. IDs can # be reassigned when the daemon restarts while the review dialog is open. - usbguard allow-device "$expected_rule" - echo "USB accessory allowed until it is disconnected." - ;; -"Always allow this device") - usbguard allow-device --permanent "$expected_rule" - echo "USB accessory added to the trusted policy." + options=() + if [[ $choice == "Always allow this device" ]]; then + options+=(--permanent) + fi + usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed + + # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can + # return success without authorizing anything. Verify the exact device. + allowed_rule="allow ${expected_rule#block }" + allowed=$(usbguard list-devices --allowed) || approval_failed + grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed + + if [[ $choice == "Always allow this device" ]]; then + # Let USBGuard match policy rules: saved rules can omit the device's port. + # --show-devices indents the devices matching each policy rule. + policy=$(usbguard list-rules --show-devices) || approval_failed + policy_allows=0 + matched=0 + while IFS= read -r line; do + if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then + policy_allows=0 + if [[ $line =~ ^[0-9]+:[[:space:]]allow[[:space:]] ]]; then + policy_allows=1 + fi + elif (( policy_allows )) && [[ $line == $'\t'"$id: $allowed_rule" ]]; then + matched=1 + break + fi + done <<<"$policy" + (( matched )) || approval_failed + echo "USB accessory added to the trusted policy." + else + echo "USB accessory allowed until it is disconnected." + fi ;; "Keep blocked") echo "USB accessory remains blocked." diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c04d3acc..584f2f77 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -66,7 +66,13 @@ generate-policy) fi ;; list-devices) - if [[ ${2:-} == "--blocked" ]]; then + if [[ ${2:-} == "--allowed" ]]; then + [[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE ]]; then + printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + fi + elif [[ ${2:-} == "--blocked" ]]; then + [[ ! -f $TEST_ALLOWED_RULE ]] || exit 0 if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then rule="${BLOCKED_DEVICE_RULE:?}" @@ -80,10 +86,27 @@ list-devices) echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi ;; +list-rules) + [[ ${APPROVAL_POLICY_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_SAVED_RULE ]]; then + printf '40: %s\n' "$(<"$TEST_SAVED_RULE")" + printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + fi + ;; *) printf 'usbguard' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" + if [[ $1 == "allow-device" && ${!#} == block* ]]; then + [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 + if [[ ${APPROVAL_SILENT_FAIL:-0} == 0 ]]; then + rule="${!#}" + printf '%s\n' "${APPROVAL_REPLACEMENT_RULE:-allow ${rule#block }}" >"$TEST_ALLOWED_RULE" + if [[ $2 == "--permanent" && ${APPROVAL_NO_POLICY:-0} == 0 ]]; then + cp "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + fi + fi + fi ;; esac STUB @@ -148,6 +171,8 @@ export CALLS="$calls" export PATH="$stub_bin:$ROOT/bin:$PATH" export TEST_DAEMON_CONFIG="$daemon_config" export TEST_RULES="$rules" +export TEST_ALLOWED_RULE="$scratch/allowed-rule" +export TEST_SAVED_RULE="$scratch/saved-rule" if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then fail "the fixed-path USB authorization restore helper requires root" @@ -486,6 +511,8 @@ grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review c [[ ! -e $request ]] || fail "a completed review consumes its request" pass "USB review allows a still-matching device once" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + # A daemon restart discards the temporary approval. The IPC reconnect must # recover the blocked device even when its startup policy events were missed. notification_count=$(grep -c '^notification' "$calls") @@ -515,6 +542,7 @@ token=$(basename "${request%.json}") GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" pass "USB review supports explicit persistent trust" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ @@ -556,6 +584,32 @@ fi [[ ! -e $request ]] || fail "a stale review request is discarded" pass "USB review binds approval to the device snapshot" +for choice in 'Allow once' 'Always allow this device'; do + for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" + value=1 + [[ $failure != "APPROVAL_REPLACEMENT_RULE" ]] || value='allow id 9999:9999 hash "replacement"' + if env "$failure=$value" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-failed" 2>&1; then + fail "$choice must reject $failure" + fi + [[ -f $request ]] || fail "unverified approval must retain the request" + ! grep -Eq 'USB accessory (allowed until|added to)' "$scratch/review-failed" || fail "failed approval must not report success" + done +done +for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then + fail "permanent approval must reject $failure" + fi + [[ -f $request ]] || fail "unverified permanent approval must retain the request" +done +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-retry" +[[ ! -e $request ]] || fail "a verified retry consumes the retained request" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB approvals verify device and permanent policy before consuming requests" + notification_count=$(grep -c '^notification' "$calls") printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" NOTIFICATION_READY_FILE="$scratch/notification-ready" \ From afad8530622c871b64f7b775a96cbe50e26c1218 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 11:02:10 -0600 Subject: [PATCH 10/34] Disable restored USB policy until factory owner enrollment --- bin/omarchy-system-factory-reset | 8 ++ test/shell.d/usb-factory-reset-test.sh | 104 +++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100755 test/shell.d/usb-factory-reset-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..217ad00a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -330,6 +330,14 @@ stage_full_reset() { [[ -f $unit_src/omarchy-provision-owner.service && -x $next/usr/bin/omarchy-provision-owner ]] || fail "the factory snapshot predates provisioning support; cannot reset from it" + # A normal install's factory snapshot includes the original owner's USB + # policy and enabled daemon. Keep input usable during the new owner's setup; + # provisioning enrolls their devices before enabling USBGuard again. + if [[ -f $next/usr/lib/systemd/system/usbguard.service ]]; then + systemctl --root="$next" disable usbguard.service >>"$LOG_FILE" 2>&1 || + fail "could not disable USBGuard in the factory system (see $LOG_FILE)" + fi + log "Scrubbing machine identity from the factory system" systemd-id128 new >"$next/etc/machine-id" rm -f "$next"/etc/ssh/ssh_host_* diff --git a/test/shell.d/usb-factory-reset-test.sh b/test/shell.d/usb-factory-reset-test.sh new file mode 100755 index 00000000..e3d71404 --- /dev/null +++ b/test/shell.d/usb-factory-reset-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +# Exercise reset staging in a temporary root with the real offline systemctl. +# Never invoke the reset command's main function or any actual disk operations. +python - "$ROOT" <<'PY' +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile + +repo = Path(sys.argv[1]) +source = (repo / 'bin/omarchy-system-factory-reset').read_text() +functions = [] +for name in ['install_provisioning_units', 'sanitize_factory_baseline', 'stage_full_reset']: + match = re.search(r'^' + name + r'\(\) \{\n.*?^\}', source, re.M | re.S) + assert match, name + functions.append(match.group()) + +with tempfile.TemporaryDirectory(prefix='omarchy-usb-reset-') as tmp: + scratch = Path(tmp) + for scenario in ['enabled', 'disabled', 'absent', 'disable-failure']: + root = scratch / scenario + top = root / 'top' + factory = top / '@factory' + for rel in ['etc/systemd/system/basic.target.wants', 'usr/lib/systemd/system', + 'etc/usbguard', 'usr/bin', 'usr/share/omarchy/install/provisioning']: + (factory / rel).mkdir(parents=True, exist_ok=True) + (top / '@').mkdir() + (top / '@' / 'original-root').touch() + (factory / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/bash\n') + (factory / 'etc/machine-id').write_text('old-machine-id\n') + policy = 'allow id 1234:0001 name "Original owner keyboard" hash "old-owner"\n' + (factory / 'etc/usbguard/rules.conf').write_text(policy) + unit = factory / 'usr/lib/systemd/system/usbguard.service' + enabled_path = Path('etc/systemd/system/basic.target.wants/usbguard.service') + if scenario != 'absent': + unit.write_text('[Unit]\nDescription=Fixture USBGuard\n[Service]\nExecStart=/bin/true\n[Install]\nWantedBy=basic.target\n') + if scenario in ['enabled', 'disable-failure']: + (factory / enabled_path).symlink_to('/usr/lib/systemd/system/usbguard.service') + owner = factory / 'usr/bin/omarchy-provision-owner' + owner.touch() + owner.chmod(0o755) + for name in ['omarchy-provision-owner.service', 'omarchy-system-factory-reset-finish.service']: + shutil.copyfile(repo / 'install/provisioning' / name, + factory / 'usr/share/omarchy/install/provisioning' / name) + harness = '''#!/bin/bash +set -euo pipefail +TOP_MNT="$1/top" +NEXT_NAME=@next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$1/log" +log() { :; } +fail() { echo "$*" >&2; exit 1; } +btrfs() { + if [[ $1 == subvolume && $2 == snapshot ]]; then + cp -a "$3" "$4" + elif [[ $1 == property ]]; then + : + else + return 90 + fi +} +passwd() { :; } +systemd-id128() { echo new-machine-id; } +encrypted_install() { return 1; } +rebuild_next_boot() { :; } +sync() { :; } +systemctl() { + # All service changes must address the staged root, never the live machine. + [[ $1 == "--root=$TOP_MNT/$NEXT_NAME" && $2 == disable && $3 == usbguard.service ]] || return 91 + [[ $2 != "${FAIL_OPERATION:-}" ]] || return 92 + command systemctl "$@" +} +''' + '\n\n'.join(functions) + '\nstage_full_reset\n' + runner = root / 'run' + runner.write_text(harness) + env = os.environ.copy() + env['FAIL_OPERATION'] = 'disable' if scenario == 'disable-failure' else '' + result = subprocess.run(['bash', str(runner), str(root)], env=env, capture_output=True, text=True) + if scenario == 'disable-failure': + assert result.returncode != 0, 'failed disable must abort reset staging' + assert (top / '@' / 'original-root').exists(), 'failure must not activate the reset root' + assert 'could not disable USBGuard' in result.stderr + else: + assert result.returncode == 0, result.stderr + staged = top / '@' + assert not (staged / enabled_path).is_symlink(), 'reset must not start USBGuard before enrollment' + assert (staged / 'var/lib/omarchy/provisioning/pending').exists() + assert (staged / 'etc/usbguard/rules.conf').read_text() == policy + if scenario == 'enabled': + assert (factory / enabled_path).is_symlink(), 'only the staged clone is disabled' + # The owner-enrollment helper enables the daemon after policy and + # ACL setup. Confirm an ordinary enable can reverse this disable. + subprocess.run(['systemctl', '--root=' + str(staged), 'enable', 'usbguard.service'], + check=True, capture_output=True, text=True) + assert (staged / enabled_path).is_symlink() + print('ok - factory USB enrollment staging: ' + scenario) +PY From 90d604f41d6ae52c6e53ca26ab3de311c7580420 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 12:04:02 -0600 Subject: [PATCH 11/34] Expire USB approval deduplication across daemon and watcher restarts --- bin/omarchy-usb-authorization-event | 72 ++++++++++++++++++++------ bin/omarchy-usb-authorization-watch | 5 ++ test/shell.d/usb-authorization-test.sh | 64 ++++++++++++++++++++++- 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/bin/omarchy-usb-authorization-event b/bin/omarchy-usb-authorization-event index 8a93a1c0..0d05001f 100755 --- a/bin/omarchy-usb-authorization-event +++ b/bin/omarchy-usb-authorization-event @@ -6,7 +6,43 @@ set -euo pipefail case "${USBGUARD_IPC_SIGNAL:-}" in -IPC.Connected) +IPC.Connected) ;; +Device.PolicyApplied) + # PresenceChanged precedes policy evaluation, even for trusted devices. + [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 + ;; +Device.PresenceChanged) + [[ ${USBGUARD_DEVICE_EVENT:-} == "Remove" ]] || exit 0 + ;; +*) exit 0 ;; +esac + +requests="$HOME/.local/state/omarchy/usb-authorization/requests" +mkdir -p "$requests" +chmod 700 "${requests%/*}" "$requests" +exec 9>"$requests/.lock" + +request_generation() { + local daemon_id + + # USBGuard reuses device IDs after restarting. Include both the daemon + # invocation and watcher lifetime so old prompts cannot suppress new ones. + daemon_id=$(systemctl show usbguard.service --property=InvocationID --value) || return 1 + [[ $daemon_id =~ ^[0-9a-f]{32}$ ]] || return 1 + printf '%s:%s' "${OMARCHY_USB_AUTHORIZATION_WATCH_ID:-standalone}" "$daemon_id" +} + +if [[ $USBGUARD_IPC_SIGNAL == "IPC.Connected" ]]; then + flock 9 + generation=$(request_generation) || exit 1 + for pending in "$requests"/request-*.json; do + [[ -f $pending ]] || continue + if ! jq -e --arg generation "$generation" '.generation == $generation' "$pending" >/dev/null 2>&1; then + rm -f "$pending" + fi + done + flock -u 9 + # USBGuard scans present devices before accepting IPC connections. Repeat # this on every reconnect: temporary approvals disappear on daemon restart. blocked=$(usbguard list-devices --blocked) || exit 1 @@ -20,21 +56,14 @@ IPC.Connected) fi done <<<"$blocked" exit 0 - ;; -Device.PolicyApplied) - # PresenceChanged precedes policy evaluation, even for trusted devices. - # PolicyApplied also covers block-to-block and allow-to-block transitions. - [[ ${USBGUARD_DEVICE_TARGET_NEW:-} == "block" ]] || exit 0 - ;; -*) exit 0 ;; -esac +fi [[ ${USBGUARD_DEVICE_ID:-} =~ ^[0-9]+$ ]] || exit 0 [[ -n ${USBGUARD_DEVICE_RULE:-} ]] || exit 0 -requests="$HOME/.local/state/omarchy/usb-authorization/requests" -mkdir -p "$requests" -chmod 700 "${requests%/*}" "$requests" +# A removed device's final target may be allow even though a blocked request +# remains. Its identity attributes are unchanged; normalize the target. +USBGUARD_DEVICE_RULE="block ${USBGUARD_DEVICE_RULE#* }" # The token is safe to carry through the presentation launcher's command # string. The device-controlled rule itself stays in a mode-0600 JSON file and @@ -43,12 +72,24 @@ token="request-$(printf '%s\0%s' "$USBGUARD_DEVICE_ID" "$USBGUARD_DEVICE_RULE" | request="$requests/$token.json" # watch executes handlers concurrently. A reconnect scan and a policy event # can refer to the same device; publish only one pending request. -exec 9>"$requests/.lock" +if [[ $USBGUARD_IPC_SIGNAL == "Device.PresenceChanged" ]]; then + flock 9 + blocked=$(usbguard list-devices --blocked) || exit 1 + # A delayed removal event must not erase a request for a reappeared device. + if ! grep -Fqx -- "$USBGUARD_DEVICE_ID: $USBGUARD_DEVICE_RULE" <<<"$blocked"; then + rm -f "$request" + fi + exit 0 +fi + # Keep retrying without needing another USB event. Handlers belong to the # watcher service and are stopped with the graphical session. while true; do flock 9 - [[ ! -e $request ]] || exit 0 + generation=$(request_generation) || exit 1 + if [[ -f $request ]] && jq -e --arg generation "$generation" '.generation == $generation' "$request" >/dev/null 2>&1; then + exit 0 + fi # Discard events overtaken by another policy decision or device replacement. blocked=$(usbguard list-devices --blocked) || exit 1 @@ -75,7 +116,8 @@ while true; do jq -n \ --arg id "$USBGUARD_DEVICE_ID" \ --arg rule "$USBGUARD_DEVICE_RULE" \ - '{id: $id, rule: $rule}' >"$temporary" + --arg generation "$generation" \ + '{id: $id, rule: $rule, generation: $generation}' >"$temporary" chmod 600 "$temporary" mv -f "$temporary" "$request" diff --git a/bin/omarchy-usb-authorization-watch b/bin/omarchy-usb-authorization-watch index 302c7968..46ce10d3 100755 --- a/bin/omarchy-usb-authorization-watch +++ b/bin/omarchy-usb-authorization-watch @@ -5,6 +5,11 @@ set -euo pipefail +# Starting a new graphical-session watcher must offer fresh prompts even if +# their old notification actions were dismissed and aged out of history. +OMARCHY_USB_AUTHORIZATION_WATCH_ID=$(cat /proc/sys/kernel/random/uuid) +export OMARCHY_USB_AUTHORIZATION_WATCH_ID + event_handler="$OMARCHY_PATH/bin/omarchy-usb-authorization-event" # The handler scans blocked devices on IPC.Connected, including reconnects diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index 584f2f77..c9a5a825 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -51,7 +51,9 @@ STUB cat >"$stub_bin/systemctl" <<'STUB' #!/bin/bash printf 'systemctl <%s>\n' "$*" >>"$CALLS" -if [[ $* == "is-enabled --quiet usbguard.service" ]]; then +if [[ $* == "show usbguard.service --property=InvocationID --value" ]]; then + printf '%s\n' "${TEST_DAEMON_INVOCATION:-11111111111111111111111111111111}" +elif [[ $* == "is-enabled --quiet usbguard.service" ]]; then exit 1 fi STUB @@ -97,6 +99,9 @@ list-rules) printf 'usbguard' >>"$CALLS" printf ' <%s>' "$@" >>"$CALLS" printf '\n' >>"$CALLS" + if [[ $1 == "watch" && -n ${TEST_WATCH_ID_FILE:-} ]]; then + printf '%s\n' "$OMARCHY_USB_AUTHORIZATION_WATCH_ID" >"$TEST_WATCH_ID_FILE" + fi if [[ $1 == "allow-device" && ${!#} == block* ]]; then [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 if [[ ${APPROVAL_SILENT_FAIL:-0} == 0 ]]; then @@ -506,6 +511,63 @@ wait "$scan_pid" fail "overlapping scan and policy events must not duplicate pending prompts" pass "USB reconnect scans and policy events deduplicate pending requests" +# Dismissing/canceling a notification leaves its request, even after the shell +# discards the archived action. Reusing the ID after daemon restart must prompt. +notification_count=$(grep -c '^notification' "$calls") +export TEST_DAEMON_INVOCATION=22222222222222222222222222222222 +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" & +scan_pid=$! +USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ + USBGUARD_DEVICE_ID=17 \ + USBGUARD_DEVICE_TARGET_NEW=block \ + USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +wait "$scan_pid" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "reused IDs must get exactly one new notification after daemon restart" +[[ -f $request ]] || fail "the recovered notification must have a reviewable request" + +# Starting another watcher/session also expires old notification deduplication. +notification_count=$(grep -c '^notification' "$calls") +export OMARCHY_USB_AUTHORIZATION_WATCH_ID=new-session +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "a new watcher must recover an orphaned approval action" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 1)) ]] || + fail "same-generation scans must still deduplicate" + +# A removal that arrives late must not erase a new connection's request. +USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \ + USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="$malicious_rule" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ -f $request ]] || fail "delayed removal must preserve a currently blocked device's request" +BLOCKED_DEVICE_PRESENT=0 \ + USBGUARD_IPC_SIGNAL=Device.PresenceChanged USBGUARD_DEVICE_EVENT=Remove \ + USBGUARD_DEVICE_ID=17 USBGUARD_DEVICE_RULE="${malicious_rule/#block/allow}" \ + "$ROOT/bin/omarchy-usb-authorization-event" +[[ ! -e $request ]] || fail "device removal must clean up its pending request" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 2)) ]] || + fail "a reappeared device must receive a fresh notification" + +# Upgrade from requests written before generations existed, and remove stale +# requests for devices no longer in the inventory. +jq 'del(.generation)' "$request" >"$scratch/legacy-request" +mv "$scratch/legacy-request" "$request" +legacy_orphan="${request%/*}/request-$(printf '%064d' 0).json" +printf '{"id":"99","rule":"block id 0000:0000"}\n' >"$legacy_orphan" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +[[ $(grep -c '^notification' "$calls") == $((notification_count + 3)) ]] || + fail "legacy request markers must not suppress notification recovery" +[[ ! -e $legacy_orphan ]] || fail "reconnect must clean up old-generation orphan requests" + +TEST_WATCH_ID_FILE="$scratch/watch-one" "$ROOT/bin/omarchy-usb-authorization-watch" +TEST_WATCH_ID_FILE="$scratch/watch-two" "$ROOT/bin/omarchy-usb-authorization-watch" +[[ -s $scratch/watch-one && $(<"$scratch/watch-one") != "$(<"$scratch/watch-two")" ]] || + fail "each watcher must export a fresh lifetime identifier" +pass "USB prompts recover across daemon and session generations and device removal" + GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-once-output" grep -Fqx "usbguard <$malicious_rule>" "$calls" || fail "review can allow the exact device once" [[ ! -e $request ]] || fail "a completed review consumes its request" From db6ab5a8bf928f78bcabe04a19030d7df579159e Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Thu, 17 Sep 2026 15:55:32 -0600 Subject: [PATCH 12/34] Resume USB approval verification after transient failures --- bin/omarchy-usb-authorization-review | 130 +++++++++++++++---------- test/shell.d/usb-authorization-test.sh | 105 +++++++++++++++++++- 2 files changed, 184 insertions(+), 51 deletions(-) diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index 432a3199..630e7612 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -12,72 +12,103 @@ if [[ ! $token =~ ^request-[0-9a-f]{64}$ ]]; then fi request="$HOME/.local/state/omarchy/usb-authorization/requests/$token.json" +# Serialize request updates with watcher events, but release the lock while the +# user chooses. A changed request must never inherit an old dialog's approval. +if [[ ! -d ${request%/*} ]]; then + echo "This USB authorization request is no longer available." >&2 + exit 1 +fi +exec 9>"${request%/*}/.lock" +flock 9 if [[ ! -f $request || -L $request ]]; then echo "This USB authorization request is no longer available." >&2 exit 1 fi -id=$(jq -er '.id | select(test("^[0-9]+$"))' "$request") -expected_rule=$(jq -er '.rule | select(type == "string" and length > 0)' "$request") -read_blocked_rule() { - local line - - while IFS= read -r line; do - if [[ $line == "$id: "* ]]; then - printf '%s\n' "${line#"$id: "}" - return 0 - fi - done < <(usbguard list-devices --blocked) - - return 1 -} - -current_rule=$(read_blocked_rule || true) - -if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then - rm -f "$request" - echo "That USB device was removed or changed. Connect it again to review it." >&2 - exit 1 -fi - -gum style --foreground 212 --bold "Blocked USB accessory" -echo -echo "The following details come from the device and can be forged:" -printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' -echo - -choice=$(gum choose \ - "Allow once" \ - "Always allow this device" \ - "Keep blocked") || exit 130 - -if [[ $choice != "Keep blocked" ]]; then - current_rule=$(read_blocked_rule || true) - if [[ -z $current_rule || $current_rule != "$expected_rule" ]]; then - rm -f "$request" - echo "That USB device was removed or changed. Connect it again to review it." >&2 - exit 1 - fi -fi +snapshot=$(cat "$request") +id=$(jq -er '.id | select(test("^[0-9]+$"))' <<<"$snapshot") +expected_rule=$(jq -er '.rule | select(type == "string" and startswith("block "))' <<<"$snapshot") +choice=$(jq -er '.approval // "" | select(. == "" or . == "Allow once" or . == "Always allow this device")' <<<"$snapshot") +allowed_rule="allow ${expected_rule#block }" approval_failed() { echo "Could not confirm USB approval. The request has been kept; try again." >&2 exit 1 } +read_current_rule() { + local devices line + + current_rule="" + devices=$(usbguard list-devices) || approval_failed + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then + current_rule="${line#"$id: "}" + return + fi + done <<<"$devices" +} + +validate_device() { + read_current_rule + if [[ $current_rule == "$expected_rule" ]] || + [[ -n $choice && $current_rule == "$allowed_rule" ]]; then + return + fi + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 +} + +validate_device + +if [[ -z $choice ]]; then + flock -u 9 + gum style --foreground 212 --bold "Blocked USB accessory" + echo + echo "The following details come from the device and can be forged:" + printf '%s\n' "$current_rule" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' + echo + + choice=$(gum choose \ + "Allow once" \ + "Always allow this device" \ + "Keep blocked") || exit 130 + + flock 9 + if [[ ! -f $request || -L $request || $(cat "$request") != "$snapshot" ]]; then + echo "This USB authorization request changed. Open the latest notification to review it." >&2 + exit 1 + fi + # A fresh choice can authorize only the blocked identity shown in the dialog. + read_current_rule + if [[ $current_rule != "$expected_rule" ]]; then + rm -f "$request" + echo "That USB device was removed or changed. Connect it again to review it." >&2 + exit 1 + fi +fi + case "$choice" in "Allow once" | "Always allow this device") - # Target the complete identity rule instead of USBGuard's numeric ID. IDs can - # be reassigned when the daemon restarts while the review dialog is open. - options=() - if [[ $choice == "Always allow this device" ]]; then - options+=(--permanent) + # Save intent before applying policy: a later query can fail after the device + # was authorized. Retry must verify that approval, including persistent trust. + temporary=$(mktemp "${request%/*}/.request.XXXXXXXXXX") + trap 'rm -f "$temporary"' EXIT + jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary" + mv -f "$temporary" "$request" + + if [[ $current_rule == "$expected_rule" ]]; then + # Match the complete identity, since numeric IDs can be reassigned. + options=() + if [[ $choice == "Always allow this device" ]]; then + options+=(--permanent) + fi + usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed fi - usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can # return success without authorizing anything. Verify the exact device. - allowed_rule="allow ${expected_rule#block }" allowed=$(usbguard list-devices --allowed) || approval_failed grep -Fqx -- "$id: $allowed_rule" <<<"$allowed" || approval_failed @@ -107,6 +138,7 @@ case "$choice" in "Keep blocked") echo "USB accessory remains blocked." ;; +*) exit 1 ;; esac rm -f "$request" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c9a5a825..d6eba4c9 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -74,6 +74,7 @@ list-devices) printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" fi elif [[ ${2:-} == "--blocked" ]]; then + [[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1 [[ ! -f $TEST_ALLOWED_RULE ]] || exit 0 if [[ -n ${BLOCKED_DEVICES_FILE:-} ]]; then cat "$BLOCKED_DEVICES_FILE"; exit 0; fi if [[ ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then @@ -84,6 +85,17 @@ list-devices) printf '17: %s\n' "$rule" fi else + [[ ${BLOCKED_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE ]]; then + [[ ${APPROVAL_QUERY_FAIL:-0} == 0 ]] || exit 1 + printf '17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + elif [[ -n ${BLOCKED_DEVICE_RULE:-} && ${BLOCKED_DEVICE_PRESENT:-1} == 1 ]]; then + rule="$BLOCKED_DEVICE_RULE" + if [[ -n ${BLOCKED_DEVICE_RULE_FILE:-} && -f $BLOCKED_DEVICE_RULE_FILE ]]; then + rule=$(<"$BLOCKED_DEVICE_RULE_FILE") + fi + printf '17: %s\n' "$rule" + fi echo '4: allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi @@ -142,6 +154,12 @@ cat >"$stub_bin/gum" <<'STUB' case "$1" in style) exit 0 ;; choose) + echo "gum choose" >>"$CALLS" + [[ ${GUM_CANCEL:-0} == 0 ]] || exit 130 + if [[ -n ${GUM_REPLACE_REQUEST:-} ]]; then + jq '.generation = "replacement-generation"' "$GUM_REPLACE_REQUEST" >"$GUM_REPLACE_REQUEST.new" + mv "$GUM_REPLACE_REQUEST.new" "$GUM_REPLACE_REQUEST" + fi if [[ -n ${GUM_REASSIGN_RULE:-} ]]; then printf '%s\n' "$GUM_REASSIGN_RULE" >"$BLOCKED_DEVICE_RULE_FILE" fi @@ -648,7 +666,7 @@ pass "USB review binds approval to the device snapshot" for choice in 'Allow once' 'Always allow this device'; do for failure in APPROVAL_SILENT_FAIL APPROVAL_EXIT_FAIL APPROVAL_QUERY_FAIL APPROVAL_REPLACEMENT_RULE; do - rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request" USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" value=1 [[ $failure != "APPROVAL_REPLACEMENT_RULE" ]] || value='allow id 9999:9999 hash "replacement"' @@ -660,7 +678,8 @@ for choice in 'Allow once' 'Always allow this device'; do done done for failure in APPROVAL_NO_POLICY APPROVAL_POLICY_QUERY_FAIL; do - rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" "$request" + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" if env "$failure=1" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-no-policy" 2>&1; then fail "permanent approval must reject $failure" fi @@ -672,6 +691,88 @@ GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-revie rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" pass "USB approvals verify device and permanent policy before consuming requests" +# Exercise retries without resetting the device state left by the first attempt. +for choice in 'Allow once' 'Always allow this device'; do + USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" + failure=APPROVAL_QUERY_FAIL + [[ $choice != 'Always allow this device' ]] || failure=APPROVAL_POLICY_QUERY_FAIL + if env "$failure=1" GUM_CHOICE="$choice" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-first" 2>&1; then + fail "transient verification failure must retain the approval" + fi + [[ -s $TEST_ALLOWED_RULE && -f $request ]] || fail "fixture must retain an already-allowed device" + [[ $(jq -r .approval "$request") == "$choice" ]] || fail "retry must remember the selected approval" + [[ $(stat -c %a "$request") == 600 ]] || fail "saved approval intent stays private" + allow_count=$(grep -c '^usbguard ' "$calls") + prompt_count=$(grep -c '^gum choose' "$calls") + GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/retry-second" + [[ ! -e $request ]] || fail "verified approval retry consumes the request" + [[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "already-allowed retry must not reapply authorization" + [[ $(grep -c '^gum choose' "$calls") == "$prompt_count" ]] || fail "retry must resume the original approval without another choice" + if [[ $choice == 'Always allow this device' ]]; then + grep -Fq 'added to the trusted policy' "$scratch/retry-second" || fail "permanent retry must finish permanent verification" + else + grep -Fq 'allowed until it is disconnected' "$scratch/retry-second" || fail "once retry must finish temporary verification" + fi + rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +done +pass "USB approval retries resume successful temporary and permanent authorization" + +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy" 2>&1; then + fail "permanent approval requires saved policy" +fi +allow_count=$(grep -c '^usbguard ' "$calls") +if GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then + fail "retry cannot downgrade permanent approval when policy is missing" +fi +[[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device" +for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do + if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then + fail "retry must fail when inventory cannot be read" + fi + [[ -f $request ]] || fail "inventory query failures must preserve pending approvals" +done +printf '%s\n' "allow ${replacement_rule#block }" >"$TEST_ALLOWED_RULE" +if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-retry" 2>&1; then + fail "retry must reject a replacement allowed device" +fi +[[ ! -e $request ]] || fail "a proven replacement consumes the stale request" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "replacement retry must not authorize anything" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB approval retries preserve permanent intent and validate allowed identity" + +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if BLOCKED_QUERY_FAIL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/fresh-query-failure" 2>&1; then + fail "fresh review must fail on inventory query failure" +fi +[[ -f $request ]] || fail "fresh inventory failure must not delete the request" +jq '.approval = "invalid"' "$request" >"$scratch/invalid-approval" +mv "$scratch/invalid-approval" "$request" +if "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/invalid-intent" 2>&1; then + fail "invalid saved intent must not authorize" +fi +jq 'del(.approval)' "$request" >"$scratch/fresh-request" +mv "$scratch/fresh-request" "$request" +if GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/cancel" 2>&1; then + fail "canceled review must not authorize" +fi +jq -e '.approval == null' "$request" >/dev/null || fail "canceling must not store an approval" +if GUM_REPLACE_REQUEST="$request" GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/replaced-request" 2>&1; then + fail "open dialog must not approve a replaced request" +fi +[[ $(jq -r .generation "$request") == 'replacement-generation' ]] || fail "stale dialog must leave replacement request intact" +[[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "canceled or superseded requests cannot authorize" +rm -f "$request" +USBGUARD_IPC_SIGNAL=IPC.Connected "$ROOT/bin/omarchy-usb-authorization-event" +if APPROVAL_EXIT_FAIL=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-first" 2>&1; then + fail "failed apply must retain a blocked request" +fi +GUM_CANCEL=1 "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/blocked-retry-second" +[[ -s $TEST_SAVED_RULE && ! -e $request ]] || fail "blocked retry must apply its saved permanent choice" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +pass "USB review preserves transient failures and rejects canceled or replaced dialogs" + notification_count=$(grep -c '^notification' "$calls") printf '17: %s\n18: %s\n' "$malicious_rule" 'block id 1234:5678 name "Second blocked device" hash "second"' >"$scratch/two-blocked" NOTIFICATION_READY_FILE="$scratch/notification-ready" \ From de72b64872cec8987a1c5db27e9727f5dae80d50 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 18 Sep 2026 12:43:29 -0600 Subject: [PATCH 13/34] Re-sign historical UKIs before changing USB boot policy --- bin/omarchy-usb-authorization-boot | 188 ++++++++++++++++++ manual/48-security.md | 2 + test/shell.d/usb-boot-archives-test.sh | 255 +++++++++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100755 test/shell.d/usb-boot-archives-test.sh diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 6ffb010c..1db602e1 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -381,6 +381,188 @@ usb_authorization_rewrite_snapshot_manifest() { mv -f "$temporary" "$manifest" } +usb_authorization_verify_signature() { + # sbctl verify can return zero for unsigned or unreadable files. Check its + # structured result, including the exact path, instead of just the exit code. + if ! sbctl verify --json "$1" | jq -e --arg file "$1" \ + 'length == 1 and .[0].file_name == $file and .[0].is_signed == 1' >/dev/null; then + echo "Cannot verify the Secure Boot signature of $1 using this machine's signing keys." >&2 + return 1 + fi +} + +# Called with Limine's boot-partition lock held. Prepare signed copies of +# conflicting historical UKIs before changing the selected boot policy. +usb_authorization_reconcile_archived_images() ( + local expected="$1" config="$2" esp="$3" machine_id="$4" + local cache="${5:-/var/cache/boot/$machine_id/lss/snapshots.json}" + local history="$esp/$machine_id/limine_history" staging manifest names name image + local cmdline hash_command old_digest new_digest old_hash new_hash replacement index=0 + local published=0 committed=0 section layout image_base image_size alignment address + local -a manifests=() originals=() + + usb_authorization_secure_boot_enabled || return 0 + [[ -d $history ]] || return 0 + staging=$(mktemp -d "$esp/.omarchy-usb-uki.XXXXXXXXXX") || return 1 + trap ' + if (( published && ! committed )); then + for ((index=0; index<${#originals[@]}; index++)); do + cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true + done + limine-enroll-config >/dev/null 2>&1 || true + fi + rm -rf "$staging" + ' EXIT + + for manifest in "$history/snapshots.json" "$history/snapshots.json.old" "$cache"; do + [[ -f $manifest ]] || continue + jq -e '.jsonFormatVersion == "1.3.0" and (.snapshotEntries | type == "array")' "$manifest" >/dev/null || return 1 + manifests+=("$manifest") + done + (( ${#manifests[@]} )) || return 0 + names=$(jq -sr '[.[] | .snapshotEntries[] | .. | objects | .imageDetails? // empty | .[] | .fileHashName] | unique[]' "${manifests[@]}") || return 1 + printf '[]\n' >"$staging/replacements.json" + + while IFS= read -r name; do + [[ -n $name ]] || continue + # Only image names, never arbitrary paths supplied by a manifest. + if [[ $name == */* || $name == "." || $name == ".." ]]; then + echo "Invalid archived kernel image name: $name" >&2 + return 1 + fi + image="$history/$name" + [[ -f $image && ! -L $image ]] || continue + : >"$staging/cmdline" + objcopy -O binary --only-section=.cmdline "$image" "$staging/cmdline" 2>/dev/null || continue + cmdline=$(tr '\0' '\n' <"$staging/cmdline") + [[ -n $cmdline ]] || continue + if usb_authorization_cmdline_has_parameter "$cmdline" "$kernel_parameter"; then + [[ $expected == "disabled" ]] || continue + else + [[ $expected == "enabled" ]] || continue + fi + + # Multi-profile images and signed PCR policies need their original builder. + # Do not silently invalidate their additional boot/unlock guarantees. + for section in .profile .pcrsig; do + objcopy -O binary --only-section="$section" "$image" "$staging/section" || return 1 + if [[ -s $staging/section ]]; then + echo "Rebuild archived image $image with its original signing policy before changing USB boot protection ($section)." >&2 + return 1 + fi + done + if [[ ! $name =~ ^(.+)_(sha1|sha256|b3|xxh)_([0-9a-f]+)$ ]]; then + echo "Cannot verify the content hash of archived image $image." >&2 + return 1 + fi + case "${BASH_REMATCH[2]}" in + sha1) hash_command=sha1sum ;; + sha256) hash_command=sha256sum ;; + b3) hash_command=b3sum ;; + xxh) hash_command=xxhsum ;; + esac + old_digest=${BASH_REMATCH[3]} + new_digest=$("$hash_command" "$image") || return 1 + if [[ ${new_digest%% *} != "$old_digest" ]]; then + echo "Archived image hash mismatch: $image" >&2 + return 1 + fi + # Verify the old signature before signing a modified copy with this machine's key. + usb_authorization_verify_signature "$image" || return 1 + cp "$image" "$staging/unsigned.efi" || return 1 + sbattach --remove "$staging/unsigned.efi" || return 1 + # Preserve the embedded boot arguments and change only our exact USB + # parameter. Do not turn a signed command line into an external override. + printf '%s' "$cmdline" | jq -Rrs --arg parameter "$kernel_parameter" --arg expected "$expected" ' + [scan("[^[:space:]]+|[[:space:]]+") | select(. != $parameter)] + | join("") | sub("[[:space:]]+$"; "") + | if $expected == "enabled" then . + " " + $parameter else . end + ' >"$staging/new-cmdline" || return 1 + printf '\0' >>"$staging/new-cmdline" + # objcopy --update-section retains PE VirtualSize and can truncate a longer + # command line. Add a fresh section after the existing virtual image instead. + layout=$(objdump -p "$staging/unsigned.efi" | awk ' + $1 == "ImageBase" { base = $2 } + $1 == "SizeOfImage" { size = $2 } + $1 == "SectionAlignment" { alignment = $2 } + END { + if (base !~ /^[0-9a-fA-F]+$/ || size !~ /^[0-9a-fA-F]+$/ || alignment !~ /^[0-9a-fA-F]+$/) exit 1 + print "0x" base, "0x" size, "0x" alignment + } + ') || return 1 + read -r image_base image_size alignment <<<"$layout" + (( alignment > 0 )) || return 1 + address=$((image_base + (image_size + alignment - 1) / alignment * alignment)) + objcopy --remove-section=.cmdline "$staging/unsigned.efi" "$staging/without-cmdline.efi" || return 1 + objcopy --add-section .cmdline="$staging/new-cmdline" \ + --change-section-vma .cmdline="$address" \ + --set-section-flags .cmdline=alloc,load,readonly,data,contents \ + "$staging/without-cmdline.efi" "$staging/image.efi" || return 1 + sbctl sign "$staging/image.efi" || return 1 + usb_authorization_verify_signature "$staging/image.efi" || return 1 + objcopy -O binary --only-section=.cmdline "$staging/image.efi" "$staging/cmdline" || return 1 + cmp -s "$staging/cmdline" "$staging/new-cmdline" || return 1 + new_digest=$("$hash_command" "$staging/image.efi") || return 1 + replacement="${name%"$old_digest"}${new_digest%% *}" + old_hash=$(b2sum "$image") || return 1 + new_hash=$(b2sum "$staging/image.efi") || return 1 + # Publish copies first: every old reference remains usable until its manifest changes. + if [[ -e $history/$replacement || -L $history/$replacement ]]; then + [[ ! -L $history/$replacement ]] && cmp -s "$staging/image.efi" "$history/$replacement" || return 1 + else + mv "$staging/image.efi" "$history/$replacement" || return 1 + fi + jq --arg old "$name" --arg new "$replacement" \ + --arg old_hash "${old_hash%% *}" --arg new_hash "${new_hash%% *}" \ + '. + [{old: $old, new: $new, old_hash: $old_hash, new_hash: $new_hash}]' \ + "$staging/replacements.json" >"$staging/next.json" || return 1 + mv "$staging/next.json" "$staging/replacements.json" || return 1 + done <<<"$names" + jq -e 'length > 0' "$staging/replacements.json" >/dev/null || return 0 + + originals=("$config" "${manifests[@]}") + for ((index=0; index<${#originals[@]}; index++)); do + cp --preserve=mode,ownership "${originals[index]}" "$staging/original.$index" || return 1 + cp --preserve=mode,ownership "${originals[index]}" "$staging/updated.$index" || return 1 + # Update cached menu lines, structured image metadata, restore paths and + # both hashes. The new file name remains a valid Limine content address. + if (( index == 0 )); then + jq -Rrs --slurpfile replacements "$staging/replacements.json" ' + split("\n") | map(reduce $replacements[0][] as $r (. ; + if contains($r.old) then split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash) + else . end)) | join("\n") + ' "$staging/original.$index" >"$staging/updated.$index" || return 1 + else + jq --slurpfile replacements "$staging/replacements.json" ' + walk(if type == "string" then + reduce $replacements[0][] as $r (. ; split($r.old) | join($r.new) | split($r.old_hash) | join($r.new_hash)) + else . end) + ' "$staging/original.$index" >"$staging/updated.$index" || return 1 + fi + done + published=1 + for ((index=0; index<${#originals[@]}; index++)); do + mv -f "$staging/updated.$index" "${originals[index]}" || return 1 + done + limine-enroll-config || return 1 + committed=1 +) + +usb_authorization_prepare_archived_images() ( + local expected="$1" config="$2" esp="$3" machine_id="${4:-$(/run/lock/boot-partition.lock + flock --timeout 30 9 || return 1 + limine-snapper-sync --no-mutex --no-hooks || return 1 + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine_id" +) + usb_authorization_sync_and_verify() ( local expected="$1" config="$2" esp="$3" local machine_id="${4:-$( Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. +With Secure Boot enabled, changing USB boot protection may require access to your signing keys to update older snapshot boot images. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. + ## Changing your passwords You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. diff --git a/test/shell.d/usb-boot-archives-test.sh b/test/shell.d/usb-boot-archives-test.sh new file mode 100755 index 00000000..f222f5ad --- /dev/null +++ b/test/shell.d/usb-boot-archives-test.sh @@ -0,0 +1,255 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/bin/omarchy-usb-authorization-boot" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +machine=12345678901234567890123456789012 +export calls="$scratch/calls" +: >"$calls" + +# Optional integration run uses real sbctl keys and sbattach in isolated paths. +sbctl() { + printf '%s\n' "$1" >>"$calls" + [[ ${SIGN_FAIL:-0} != 1 || $1 != sign ]] || return 1 + [[ ${VERIFY_FAIL:-0} != 1 || $1 != verify ]] || return 1 + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + /usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@" + elif [[ $1 == verify ]]; then + jq -n --arg file "${!#}" --argjson signed "${TEST_SIGNED:-1}" '[{file_name: $file, is_signed: $signed}]' + fi +} +sbattach() { + if [[ -n ${USB_TEST_SBATTACH:-} ]]; then + "$USB_TEST_SBATTACH" "$@" + fi +} +omarchy-cmd-missing() { return 1; } +limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; } +usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; } + +fixture() { + local embedded="$1" hash digest image_base + esp="$scratch/case-$RANDOM" + history="$esp/$machine/limine_history" + manifest="$history/snapshots.json" + cache="$esp/cache.json" + config="$esp/limine.conf" + mkdir -p "$history" + printf '%s\0' "$embedded" >"$esp/cmdline" + cp /usr/lib/systemd/boot/efi/linuxx64.efi.stub "$esp/image.efi" + image_base=$(objdump -p "$esp/image.efi" | awk '$1 == "ImageBase" {print "0x" $2}') + objcopy --add-section .cmdline="$esp/cmdline" --change-section-vma .cmdline="$((image_base + 0x100000))" "$esp/image.efi" + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + sbctl sign "$esp/image.efi" >/dev/null + fi + digest=$(sha256sum "$esp/image.efi"); digest=${digest%% *} + old_name="test.efi_sha256_$digest" + cp "$esp/image.efi" "$history/$old_name" + hash=$(b2sum "$esp/image.efi"); hash=${hash%% *} + printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n comment: kernel-id=linux\n path: boot():/%s/limine_history/%s#%s\n cmdline: %s rootflags=subvol=/snapshot\n' \ + "$machine" "$machine" "$old_name" "$hash" "$embedded" >"$config" + jq -n --arg name "$old_name" --arg hash "$hash" --arg machine "$machine" --arg cmdline "$embedded" ' + def kernel: + {imageDetails: [{fileHashName: $name, fileName: "test.efi", + snapshotFilePathLine: "/" + $machine + "/limine_history/" + $name + "#" + $hash, + filePathLine: "/EFI/Linux/test.efi#" + $hash, properties: {HASH: "#" + $hash}}], + cmdlineDetails: [{cmdline: $cmdline, snapshotCmdline: $cmdline + " rootflags=subvol=/snapshot"}], + allInConfig: ["path: boot():/EFI/Linux/test.efi#" + $hash, "cmdline: " + $cmdline], + allInSnapshotConfig: ["comment: kernel-id=linux", "path: boot():/" + $machine + "/limine_history/" + $name + "#" + $hash, "cmdline: " + $cmdline + " rootflags=subvol=/snapshot"], subKernels: []}; + {jsonFormatVersion: "1.3.0", snapshotEntries: [{kernelEntries: [kernel | .subKernels = [kernel]]}]} + ' >"$manifest" + cp "$manifest" "$manifest.old" + cp "$manifest" "$cache" + cp "$manifest" "$esp/before.json" + cp "$config" "$esp/before.conf" + : >"$calls" +} + +for expected in enabled disabled; do + old='quiet' + [[ $expected != disabled ]] || old+=' usbcore.authorized_default=0' + fixture "$old" + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache" + new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest") + [[ $new_name != "$old_name" && -f $history/$new_name ]] || fail "conflicting archives get new content-addressed copies" + cmp "$history/$old_name" "$esp/image.efi" || fail "original archived image remains unchanged" + digest=$(sha256sum "$history/$new_name") + [[ $new_name == *"${digest%% *}" ]] || fail "replacement name matches its content hash" + objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section" + embedded=$(tr '\0' '\n' <"$esp/section") + wanted=quiet + [[ $expected != enabled ]] || wanted+=' usbcore.authorized_default=0' + [[ $embedded == "$wanted" ]] || fail "replacement must preserve embedded boot arguments and change only USB policy" + objcopy -O binary --only-section=.text "$history/$old_name" "$esp/text.before" + objcopy -O binary --only-section=.text "$history/$new_name" "$esp/text.after" + cmp "$esp/text.before" "$esp/text.after" || fail "archive conversion must not alter executable code" + [[ $(grep -c '^sign$' "$calls") == 1 && $(grep -c '^verify$' "$calls") == 2 ]] || fail "deduplicated image must be verified before and after signing" + for file in "$manifest" "$manifest.old" "$cache"; do + ! grep -Fq "$old_name" "$file" || fail "no old image references may survive in manifests" + usb_authorization_rewrite_snapshot_manifest "$expected" "$file" + # Both cached and structured restore representations must regenerate valid entries. + for format in cached structured; do + { + printf '/Omarchy\ncomment: machine-id=%s\n //snapshot\n' "$machine" + if [[ $format == cached ]]; then + jq -r '.snapshotEntries[0].kernelEntries[0].allInSnapshotConfig[]' "$file" + else + printf 'comment: kernel-id=linux\n' + jq -r '.snapshotEntries[0].kernelEntries[0].subKernels[0] | "path: boot():" + .imageDetails[0].snapshotFilePathLine, "cmdline: " + .cmdlineDetails[0].snapshotCmdline' "$file" + fi + } >"$esp/regenerated.conf" + usb_authorization_verify_boot_entries "$expected" "$esp/regenerated.conf" "$esp" "$machine" enabled || fail "regenerated $format $expected snapshot must verify" + done + done + usb_authorization_rewrite_boot_cmdlines "$expected" "$config" "$machine" + usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine" enabled || fail "$expected archive transition must verify under Secure Boot" + usb_authorization_reconcile_archived_images "$expected" "$config" "$esp" "$machine" "$cache" + [[ $(grep -c '^sign$' "$calls") == 1 ]] || fail "retry must not resign an already-compatible archive" + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + usb_authorization_verify_signature "$history/$new_name" + fi + pass "USB $expected transition updates signed history and all cached/structured references" +done + +fixture quiet +old_hash=$(b2sum "$esp/image.efi"); old_hash=${old_hash%% *} +printf ' //current\n comment: kernel-id=current\n path: boot():/image.efi#%s\n cmdline: quiet\n' "$old_hash" >>"$config" +usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +grep -Fq "path: boot():/image.efi#$old_hash" "$config" || fail "preflight must not rewrite hashes of unchanged current images sharing archive content" +pass "USB archive preflight preserves unchanged current-image hashes" + +fixture 'quiet rd.test="two spaces"' +usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +new_name=$(jq -r '.snapshotEntries[0].kernelEntries[0].imageDetails[0].fileHashName' "$manifest") +objcopy -O binary --only-section=.cmdline "$history/$new_name" "$esp/section" +[[ $(tr '\0' '\n' <"$esp/section") == 'quiet rd.test="two spaces" usbcore.authorized_default=0' ]] || fail "unrelated quoted arguments must retain their whitespace" +pass "USB archive policy changes preserve unrelated embedded arguments and executable code" + +for failure in SIGN_FAIL VERIFY_FAIL ENROLL_FAIL; do + fixture quiet + if (export "$failure=1"; usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache") >"$esp/failure.log" 2>&1; then + fail "$failure must prevent publication" + fi + cmp "$config" "$esp/before.conf" || fail "$failure must preserve the original boot menu" + for file in "$manifest" "$manifest.old" "$cache"; do + cmp "$file" "$esp/before.json" || fail "$failure must preserve every manifest" + done + cmp "$history/$old_name" "$esp/image.efi" || fail "$failure must preserve the archived image" +done +pass "USB archive signing, verification and enrollment failures preserve original references" + +fixture quiet +printf corrupt >>"$history/$old_name" +if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/hash-failure" 2>&1; then + fail "corrupt original archive must never be signed" +fi +[[ ! -s $calls ]] || fail "hash failure must occur before signing" +cmp "$config" "$esp/before.conf" +SECURE_BOOT=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" +[[ ! -s $calls ]] || fail "non-Secure-Boot transitions must not rewrite archive images" +pass "USB archive conversion rejects corruption and skips non-Secure-Boot systems" + +if [[ -z ${USB_TEST_SBCTL_CONFIG:-} ]]; then + fixture quiet + if TEST_SIGNED=0 usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/unsigned" 2>&1; then + fail "a false-success sbctl result cannot authorize signing" + fi + ! grep -qx sign "$calls" || fail "an unsigned original must not be resigned" +fi +for missing in menu manifest; do + fixture quiet + if [[ $missing == menu ]]; then + sed -i '/cmdline:/d' "$config" + else + jq '.snapshotEntries[0].kernelEntries[0].cmdlineDetails = []' "$manifest" >"$esp/modified" + mv "$esp/modified" "$manifest" + fi + usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" || fail "embedded-only $missing must support policy changes" + usb_authorization_rewrite_boot_cmdlines enabled "$config" "$machine" + usb_authorization_verify_boot_entries enabled "$config" "$esp" "$machine" enabled || fail "embedded-only $missing must retain a valid command line" +done +for section in .profile .pcrsig; do + fixture quiet + printf protected >"$esp/protected" + objcopy --add-section "$section=$esp/protected" "$history/$old_name" 2>/dev/null + if usb_authorization_reconcile_archived_images enabled "$config" "$esp" "$machine" "$cache" >"$esp/protected-failure" 2>&1; then + fail "a $section image must require its original builder" + fi + grep -Fq "($section)" "$esp/protected-failure" || fail "unsupported signing policies need an actionable error" + [[ ! -s $calls ]] || fail "unsupported signing policies must not be resigned" +done +pass "USB archive conversion requires authentic signatures and preserves boot/unlock requirements" + +# Package hooks use this same lock. Installing the optional signature tool +# must finish before preparation takes the lock, while sync must run under it. +fixture quiet +prepare=$(declare -f usb_authorization_prepare_archived_images) +prepare=${prepare//\/run\/lock\/boot-partition.lock/$scratch\/preflight.lock} +eval "$prepare" +omarchy-cmd-missing() { return 0; } +omarchy-pkg-add() { + flock --nonblock "$scratch/preflight.lock" true || fail "package installation must precede the boot lock" + printf 'package\n' >>"$calls" +} +limine-snapper-sync() { + [[ $* == '--no-mutex --no-hooks' ]] || fail "preparation must use the already-held lock" + if flock --nonblock "$scratch/preflight.lock" true; then + fail "snapshot synchronization must hold the boot lock" + fi +} +usb_authorization_prepare_archived_images enabled "$config" "$esp" "$machine" +grep -qx package "$calls" || fail "missing signature tooling must be installed before preparing archives" +pass "USB archive preparation installs optional tools before taking Limine's lock" + +# The disposable Lab runs the actual root-only main function with only boot paths +# and external build/sync commands redirected into this fixture. +if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then + (( EUID == 0 )) || fail "main-path fixtures require the disposable guest's root" + usb_authorization_prepare_archived_images() { + usb_authorization_reconcile_archived_images "$1" "$2" "$3" "$machine" "$cache" + } + limine-mkinitcpio() { return 0; } + usb_authorization_sync_and_verify() { + local file + for file in "$manifest" "$manifest.old" "$cache"; do + usb_authorization_rewrite_snapshot_manifest "$1" "$file" || return 1 + done + usb_authorization_rewrite_boot_cmdlines "$1" "$2" "$machine" || return 1 + usb_authorization_verify_boot_entries "$1" "$2" "$3" "$machine" enabled + } + for action in enable disable; do + old=quiet + [[ $action != disable ]] || old+=' usbcore.authorized_default=0' + fixture "$old" + drop_in="$esp/entry-tool/usb-authorization.conf" + limine_defaults="$esp/defaults" + limine_conf="$config" + boot_path="$esp" + printf '# fixture defaults\n' >"$limine_defaults" + if [[ $action == disable ]]; then + mkdir -p "${drop_in%/*}" + printf '%s\n' "$setting" >"$drop_in" + usb_authorization_enable_snapshot_setting "$limine_defaults" + fi + cp "$limine_defaults" "$esp/defaults.before" + if (export SIGN_FAIL=1; main "$action") >"$esp/main-failed" 2>&1; then + fail "$action must stop if archive preparation fails" + fi + cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes" + if [[ $action == enable ]]; then + [[ ! -e $drop_in ]] || fail "enable preflight failure must not create the boot setting" + else + [[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting" + fi + (main "$action") + if [[ $action == enable ]]; then + [[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy" + else + [[ ! -e $drop_in ]] || fail "successful disable clears the boot policy" + fi + pass "USB $action main path prepares archives before changing boot policy" + done +fi From e12c8ed4521403a691fb6992e1952f679ff977d7 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 18 Sep 2026 13:30:03 -0600 Subject: [PATCH 14/34] Restore signed boot files when USB boot policy changes fail --- bin/omarchy-usb-authorization-boot | 136 ++++++++++++++++- manual/48-security.md | 2 +- test/shell.d/usb-authorization-test.sh | 2 + test/shell.d/usb-boot-archives-test.sh | 6 +- test/shell.d/usb-boot-enrollment-test.sh | 183 +++++++++++++++++++++++ 5 files changed, 320 insertions(+), 9 deletions(-) create mode 100755 test/shell.d/usb-boot-enrollment-test.sh diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 1db602e1..2fdd4cbe 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -328,9 +328,11 @@ usb_authorization_rewrite_boot_cmdlines() { fi mv -f "$temporary" "$config" - if ! limine-enroll-config; then + if ! usb_authorization_enroll_config "$config" "${config%/*}"; then mv -f "$backup" "$config" - limine-enroll-config >/dev/null 2>&1 || true + if ! usb_authorization_secure_boot_enabled; then + limine-enroll-config >/dev/null 2>&1 || true + fi echo "Restored $config because its bootloader verification data could not be updated." >&2 return 1 fi @@ -391,6 +393,109 @@ usb_authorization_verify_signature() { fi } +# Read the same architecture and enrollment setting as the installed Limine tool. +usb_authorization_limine_settings() ( + source /usr/lib/limine/limine-common-functions + load_config || return 1 + printf '%s %s\n' "$(limine_efi_arch | tr '[:upper:]' '[:lower:]')" "${ENABLE_ENROLL_LIMINE_CONFIG:-no}" +) + +usb_authorization_verify_bootloader() ( + local config="$1" esp="$2" architecture enrollment image check digest + read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1 + [[ -n $architecture ]] || return 1 + image="$esp/EFI/limine/limine_$architecture.efi" + usb_authorization_verify_signature "$image" || return 1 + check=$(mktemp) || return 1 + trap 'rm -f "$check"' EXIT + cp "$image" "$check" || return 1 + if [[ $enrollment == "yes" ]]; then + digest=$(b2sum "$config") || return 1 + limine enroll-config "$check" "${digest%% *}" --quiet || return 1 + else + limine enroll-config "$check" --reset --quiet || return 1 + fi + # Enrollment edits fixed bytes in the executable. Reapplying the expected + # value must be a no-op, including when enrollment is explicitly disabled. + if ! cmp -s "$image" "$check"; then + echo "Limine's signed executable does not match the configured menu enrollment." >&2 + return 1 + fi +) + +usb_authorization_enroll_config() { + limine-enroll-config || return 1 + if usb_authorization_secure_boot_enabled; then + usb_authorization_verify_bootloader "$1" "$2" || return 1 + fi +} + +# Kernel rebuild hooks also reset and re-sign Limine. Checkpoint the complete +# boot files, not just the menu: rebuilding can overwrite images it references. +# The outer process holds the real boot lock; the private mount namespace gives +# nested Limine tools their own lock inode so they cannot deadlock against it. +usb_authorization_boot_transaction() ( + local action="$1" state architecture enrollment index manifest changed=0 committed=0 restored=1 + local machine_id="$(/run/lock/boot-partition.lock + flock --timeout 30 8 || return 1 + read -r architecture enrollment < <(usb_authorization_limine_settings) || return 1 + [[ -n $architecture ]] || return 1 + usb_authorization_verify_signature "$boot_path/EFI/limine/limine_$architecture.efi" || return 1 + state=$(mktemp -d /var/tmp/omarchy-usb-boot.XXXXXXXXXX) || return 1 + trap ' + if (( changed && ! committed )); then + echo "USB boot policy failed; restoring the previous boot files and settings." >&2 + for ((index=0; index<${#paths[@]}; index++)); do + if [[ -d $state/$index && ! -L $state/$index ]]; then + mkdir -p "${paths[index]}" && cp -a "$state/$index/." "${paths[index]}/" || restored=0 + elif [[ -e $state/$index || -L $state/$index ]]; then + cp -a --remove-destination "$state/$index" "${paths[index]}" || restored=0 + else + rm -rf -- "${paths[index]}" || restored=0 + fi + done + # New unreferenced images are harmless, but a newly created cached backup + # manifest must not reintroduce the failed policy on a later recovery. + for manifest in snapshots.json snapshots.json.old; do + [[ -e $state/0/$machine_id/limine_history/$manifest ]] || + rm -f "$boot_path/$machine_id/limine_history/$manifest" || restored=0 + [[ -e $state/1/lss/$manifest ]] || rm -f "$cache/lss/$manifest" || restored=0 + done + sync -f "$boot_path" || restored=0 + fi + if (( restored )); then + rm -rf "$state" + else + echo "Boot recovery was incomplete. Preserve and restore the checkpoint at $state before rebooting." >&2 + fi + ' EXIT + trap 'exit 1' HUP INT TERM + for ((index=0; index<${#paths[@]}; index++)); do + if [[ -d ${paths[index]} ]]; then + cp -a "${paths[index]}/." "$state/$index" || return 1 + elif [[ -e ${paths[index]} || -L ${paths[index]} ]]; then + cp -a "${paths[index]}" "$state/$index" || return 1 + fi + done + : >"$state/lock" + changed=1 + usb_authorization_run_transaction "$state" "$action" || return 1 + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" || return 1 + sync -f "$boot_path" || return 1 + committed=1 +) + +usb_authorization_run_transaction() { + unshare --mount --propagation private /bin/bash -c ' + mount --bind "$1/lock" /run/lock/boot-partition.lock || exit 1 + source "$2" + usb_authorization_main "$3" + ' bash "$1" "${BASH_SOURCE[0]}" "$2" +} + # Called with Limine's boot-partition lock held. Prepare signed copies of # conflicting historical UKIs before changing the selected boot policy. usb_authorization_reconcile_archived_images() ( @@ -409,7 +514,6 @@ usb_authorization_reconcile_archived_images() ( for ((index=0; index<${#originals[@]}; index++)); do cp --preserve=mode,ownership "$staging/original.$index" "${originals[index]}" || true done - limine-enroll-config >/dev/null 2>&1 || true fi rm -rf "$staging" ' EXIT @@ -544,7 +648,7 @@ usb_authorization_reconcile_archived_images() ( for ((index=0; index<${#originals[@]}; index++)); do mv -f "$staging/updated.$index" "${originals[index]}" || return 1 done - limine-enroll-config || return 1 + usb_authorization_enroll_config "$config" "$esp" || return 1 committed=1 ) @@ -586,11 +690,11 @@ usb_authorization_sync_and_verify() ( # Verify the regenerated menu, so synchronization cannot mask stale stored # command lines with a one-time edit to limine.conf. limine-snapper-sync --no-mutex --no-hooks || return 1 - limine-enroll-config || return 1 + usb_authorization_enroll_config "$config" "$esp" || return 1 usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" ) -main() { +usb_authorization_main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary if (( EUID != 0 )); then @@ -680,6 +784,26 @@ main() { esac } +main() { + if (( EUID != 0 )); then + echo "Error: omarchy-usb-authorization-boot must run as root" >&2 + return 1 + fi + case "${1:-}" in + enable|disable) + if usb_authorization_secure_boot_enabled; then + usb_authorization_boot_transaction "$1" + else + usb_authorization_main "$1" + fi + ;; + *) + echo "Usage: omarchy-usb-authorization-boot " >&2 + return 2 + ;; + esac +} + if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@" fi diff --git a/manual/48-security.md b/manual/48-security.md index c1b37d15..6525317c 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -25,7 +25,7 @@ For machines whose disk unlock and recovery never depend on any USB devices, _Se Older snapshots created before USBGuard was installed and enabled keep all USB disabled even after startup, including keyboards, network adapters, and storage. Trusting the devices connected now does not make them available in those snapshots. Enable boot-time protection only if you can unlock and recover the machine without any USB devices. _Remove > Security > USB at Boot_ restores the normal early-boot behavior while keeping USBGuard active after startup. -With Secure Boot enabled, changing USB boot protection may require access to your signing keys to update older snapshot boot images. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. +With Secure Boot enabled, changing USB boot protection requires access to your signing keys and enough free space on the system drive for a temporary copy of the boot files. If signing fails, Omarchy restores the previous boot files and settings. If an image uses a custom measured-boot policy or multiple boot profiles, rebuild it with its original signing setup before retrying. ## Changing your passwords diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index d6eba4c9..c3d64ba3 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -295,6 +295,8 @@ CONF source "$ROOT/bin/omarchy-usb-authorization-boot" limine-mkinitcpio() { return 0; } limine-enroll-config() { return 0; } +# Signed bootloader enrollment and rollback have a dedicated real-PE suite. +usb_authorization_verify_bootloader() { return 0; } if usb_authorization_rebuild_and_verify enabled "$boot_conf" "$scratch" "$machine_id" enabled 2>/dev/null; then fail "boot authorization rejects Limine's false-success status when entries were not rebuilt" diff --git a/test/shell.d/usb-boot-archives-test.sh b/test/shell.d/usb-boot-archives-test.sh index f222f5ad..1da27dea 100755 --- a/test/shell.d/usb-boot-archives-test.sh +++ b/test/shell.d/usb-boot-archives-test.sh @@ -29,6 +29,8 @@ sbattach() { omarchy-cmd-missing() { return 1; } limine-enroll-config() { [[ ${ENROLL_FAIL:-0} != 1 ]]; } usb_authorization_secure_boot_enabled() { [[ ${SECURE_BOOT:-1} == 1 ]]; } +# Bootloader enrollment/rollback is exercised with real EFI binaries in its own suite. +usb_authorization_verify_bootloader() { return 0; } fixture() { local embedded="$1" hash digest image_base @@ -235,7 +237,7 @@ if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then usb_authorization_enable_snapshot_setting "$limine_defaults" fi cp "$limine_defaults" "$esp/defaults.before" - if (export SIGN_FAIL=1; main "$action") >"$esp/main-failed" 2>&1; then + if (export SIGN_FAIL=1; usb_authorization_main "$action") >"$esp/main-failed" 2>&1; then fail "$action must stop if archive preparation fails" fi cmp "$limine_defaults" "$esp/defaults.before" || fail "$action failure must precede snapshot-setting changes" @@ -244,7 +246,7 @@ if [[ ${USB_TEST_MAIN:-0} == 1 ]]; then else [[ $(<"$drop_in") == "$setting" ]] || fail "disable preflight failure must preserve the boot setting" fi - (main "$action") + (usb_authorization_main "$action") if [[ $action == enable ]]; then [[ $(<"$drop_in") == "$setting" ]] || fail "successful enable sets the boot policy" else diff --git a/test/shell.d/usb-boot-enrollment-test.sh b/test/shell.d/usb-boot-enrollment-test.sh new file mode 100755 index 00000000..a1d98d57 --- /dev/null +++ b/test/shell.d/usb-boot-enrollment-test.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/bin/omarchy-usb-authorization-boot" + +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +usb_authorization_secure_boot_enabled() { return 0; } +usb_authorization_limine_settings() { printf 'x64 %s\n' "${ENROLLMENT:-yes}"; } + +# Real PE enrollment always runs. An optional isolated sbctl configuration also +# exercises real signatures; default runs track signed content without host keys. +sbctl() { + if [[ $1 == sign && ${SIGN_FAIL:-0} == 1 ]]; then + return 1 + fi + if [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]]; then + /usr/bin/sbctl --config "$USB_TEST_SBCTL_CONFIG" "$@" + elif [[ $1 == sign ]]; then + sha256sum "${!#}" | cut -d ' ' -f1 >>"$scratch/signed" + elif [[ $1 == verify ]]; then + local digest signed=0 + digest=$(sha256sum "${!#}") + if grep -qx "${digest%% *}" "$scratch/signed"; then signed=1; fi + jq -n --arg file "${!#}" --argjson signed "$signed" '[{file_name: $file, is_signed: $signed}]' + fi +} + +fixture() { + case_dir=$(mktemp -d "$scratch/case.XXXXXXXX") + boot_path="$case_dir/boot" + cache="$case_dir/cache" + limine_conf="$boot_path/limine.conf" + limine_defaults="$case_dir/defaults" + drop_in="$case_dir/dropins/usb-authorization.conf" + image="$boot_path/EFI/limine/limine_x64.efi" + mkdir -p "${image%/*}" "$cache/lss" "${drop_in%/*}" + printf 'initial menu\n' >"$limine_conf" + printf 'initial kernel\n' >"$boot_path/kernel.efi" + printf 'initial snapshot\n' >"$cache/lss/snapshots.json" + printf 'initial defaults\n' >"$limine_defaults" + : >"$scratch/signed" + cp /usr/share/limine/BOOTX64.EFI "$image" + if [[ ${ENROLLMENT:-yes} == yes ]]; then + digest=$(b2sum "$limine_conf") + limine enroll-config "$image" "${digest%% *}" --quiet + fi + sbctl sign "$image" >/dev/null + cp "$image" "$case_dir/original.efi" + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" +} + +# Matches the installed wrapper: restore unsigned binary, enroll, attempt sign, +# then mask failure with a successful final sync. No real ESP is ever touched. +limine-enroll-config() { + cp /usr/share/limine/BOOTX64.EFI "$image" + if [[ ${ENROLLMENT:-yes} == yes && ${SKIP_ENROLLMENT:-0} != 1 ]]; then + local digest + digest=$(b2sum "$limine_conf") + limine enroll-config "$image" "${digest%% *}" --quiet || return 1 + fi + sbctl sign "$image" || true + sync -f "$image" +} + +# Relocate the outer real flock into scratch. The runner stands in for the +# namespace child and deliberately mutates the same files as rebuild hooks. +transaction=$(declare -f usb_authorization_boot_transaction) +transaction=${transaction//\/run\/lock\/boot-partition.lock/$scratch\/boot.lock} +eval "$transaction" +usb_authorization_run_transaction() { + if flock --nonblock "$scratch/boot.lock" true; then + fail "the real boot lock must cover the entire transaction" + fi + printf 'changed menu\n' >"$limine_conf" + printf 'changed kernel\n' >"$boot_path/kernel.efi" + printf 'changed metadata\n' >"$cache/lss/snapshots.json" + printf 'new cached backup\n' >"$cache/lss/snapshots.json.old" + mkdir -p "$boot_path/$("$boot_path/$("$limine_defaults" + printf '%s\n' "$setting" >"$drop_in" + if [[ ${EARLY_FAILURE:-0} == 1 ]]; then + cp /usr/share/limine/BOOTX64.EFI "$image" + return 1 + fi + if [[ ${UNCHECKED_HOOK:-0} == 1 ]]; then + limine-enroll-config + else + usb_authorization_enroll_config "$limine_conf" "$boot_path" + fi +} + +for failure in SIGN_FAIL SKIP_ENROLLMENT EARLY_FAILURE; do + fixture + if (export "$failure=1"; usb_authorization_boot_transaction enable "$cache") >"$case_dir/failure.log" 2>&1; then + fail "$failure must reject the boot transition" + fi + cmp "$image" "$case_dir/original.efi" || fail "$failure must restore the signed bootloader byte-for-byte without signing" + [[ $(<"$limine_conf") == 'initial menu' ]] || fail "$failure must restore matching menu" + [[ $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "$failure must restore images referenced by that menu" + [[ $(<"$cache/lss/snapshots.json") == 'initial snapshot' ]] || fail "$failure must restore cached snapshot metadata" + [[ ! -e $cache/lss/snapshots.json.old && ! -e $boot_path/$("$drop_in" +if (SIGN_FAIL=1 UNCHECKED_HOOK=1 usb_authorization_boot_transaction disable "$cache") >"$case_dir/failure.log" 2>&1; then + fail "final verification must reject false-success rebuild hooks" +fi +cmp "$image" "$case_dir/original.efi" || fail "false-success hooks must restore original signed Limine" +[[ $(<"$drop_in") == 'original opt-in' ]] || fail "failed disable must preserve an existing drop-in" +pass "USB boot transitions independently verify enrollment after external hooks" + +for enrollment in yes no; do + export ENROLLMENT="$enrollment" + fixture + usb_authorization_boot_transaction enable "$cache" + [[ $(<"$limine_conf") == 'changed menu' && -e $drop_in ]] || fail "successful transition must commit" + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" +done +pass "USB boot transitions commit verified signing with enrollment enabled or disabled" + +# Disposable Lab only: exercise the actual namespace runner and both lock +# inodes, with the installed enrollment/restore functions and real signatures. +if [[ ${USB_TEST_NAMESPACE:-0} == 1 ]]; then + (( EUID == 0 )) || fail "namespace tests require root in the disposable guest" + [[ -n ${USB_TEST_SBCTL_CONFIG:-} ]] || fail "namespace tests require isolated signing keys" + export ENROLLMENT=yes + for outcome in failure success; do + fixture + cp /usr/share/limine/BOOTX64.EFI "$case_dir/limine_x64.efi" + tar -cf "$case_dir/limine.bak" -C "$case_dir" limine_x64.efi + lock_inode=$(stat -Lc '%d:%i' /run/lock/boot-partition.lock) + # Source the shipped functions in both the parent and namespace child. + sed '/^if \[\[ ${BASH_SOURCE\[0\]} == "\$0" \]\]; then/,$d' "$ROOT/bin/omarchy-usb-authorization-boot" >"$case_dir/helper" + for variable in scratch case_dir boot_path cache limine_conf limine_defaults drop_in image lock_inode; do + printf '%s=%q\n' "$variable" "${!variable}" >>"$case_dir/helper" + done + declare -f sbctl usb_authorization_limine_settings usb_authorization_secure_boot_enabled >>"$case_dir/helper" + cat >>"$case_dir/helper" <<'SCRIPT' +usb_authorization_main() { + [[ $(stat -Lc '%d:%i' /run/lock/boot-partition.lock) != "$lock_inode" ]] || return 1 + exec 9>/run/lock/boot-partition.lock + flock --nonblock 9 || return 1 + printf 'changed menu\n' >"$limine_conf" + printf 'changed kernel\n' >"$boot_path/kernel.efi" + source /usr/lib/limine/limine-common-functions + LIMINE_DIR_PATH="${image%/*}" + LIMINE_EFI_FILE=limine_x64.efi + BINARY_TARGET_PATH="$image" + BINARY_BACKUP_PATH="$case_dir/limine.bak" + LIMINE_CONFIG_PATH="$limine_conf" + ENABLE_ENROLL_LIMINE_CONFIG=yes + is_sb_installed() { return 0; } + is_uefi() { return 0; } + limine-enroll-config() { enroll_config; } + usb_authorization_enroll_config "$limine_conf" "$boot_path" +} +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + usb_authorization_boot_transaction "$1" "$cache" +fi +SCRIPT + if [[ $outcome == failure ]]; then + if SIGN_FAIL=1 bash "$case_dir/helper" enable >"$case_dir/namespace.log" 2>&1; then + fail "actual namespace must reject installed wrapper's false success" + fi + grep -Fq 'Failed to sign' "$case_dir/namespace.log" || fail "failure must reach the installed enrollment function" + cmp "$image" "$case_dir/original.efi" || fail "namespace rollback restores original signed bootloader" + [[ $(<"$limine_conf") == 'initial menu' && $(<"$boot_path/kernel.efi") == 'initial kernel' ]] || fail "namespace rollback restores matching boot files" + else + bash "$case_dir/helper" disable >"$case_dir/namespace.log" 2>&1 + [[ $(<"$limine_conf") == 'changed menu' ]] || fail "namespace success commits" + fi + usb_authorization_verify_bootloader "$limine_conf" "$boot_path" + flock --nonblock /run/lock/boot-partition.lock true || fail "outer lock must be released" + done + pass "USB real namespace isolates nested locks and recovers installed Limine signing failure" +fi From 7d4773311e77d3543395223254da9a5a2565df33 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 23 Sep 2026 09:45:24 +0100 Subject: [PATCH 15/34] Pin factory-reset elevation to the packaged command Use the installed executable for non-root self-elevation, including calls from a development checkout or symlink. Reported-by: Aleksej --- bin/omarchy-system-factory-reset | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 43348e5a..c248221c 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -29,6 +29,7 @@ set -euo pipefail PROVISIONING_DIR=/var/lib/omarchy/provisioning +PACKAGED_PATH=/usr/bin/omarchy-system-factory-reset OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" TOP_MNT=/run/omarchy-system-factory-reset/top NEXT_NAME=@omarchy-reset-next @@ -37,9 +38,10 @@ LOG_FILE=/var/log/omarchy-system-factory-reset.log # Self-elevate so the menu entry (and a bare shell invocation) need no sudo # prefix. The caller's gum theme vars are forwarded as `env` arguments rather # than via `sudo -E`, so styling survives even under an env_reset sudoers. +# Always elevate the packaged copy, including when invoked from a dev checkout. if (( EUID != 0 )); then mapfile -t gum_env < <(env | grep '^GUM_' || true) - exec sudo env "${gum_env[@]}" "$0" "$@" + exec sudo env "${gum_env[@]}" "$PACKAGED_PATH" "$@" fi export PATH="$OMARCHY_PATH/bin:$PATH" From 4dd5414bfcd60994b492c1474fe99bbef0e8a5d5 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 23 Sep 2026 09:45:24 +0100 Subject: [PATCH 16/34] Test factory-reset elevation paths and arguments --- test/shell.d/factory-reset-elevation-test.sh | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 test/shell.d/factory-reset-elevation-test.sh diff --git a/test/shell.d/factory-reset-elevation-test.sh b/test/shell.d/factory-reset-elevation-test.sh new file mode 100755 index 00000000..20a894bf --- /dev/null +++ b/test/shell.d/factory-reset-elevation-test.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail +source "$(dirname "$0")/base-test.sh" + +if (( EUID == 0 )); then + skip "factory-reset self-elevation requires an unprivileged caller" + exit 0 +fi + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/checkout with spaces" + +# Exercise the real startup, but never include the destructive reset body. +awk ' + /^export PATH=/ { found = 1; print "exit 99"; exit } + { print } + END { if (!found) exit 1 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/checkout with spaces/reset" +chmod +x "$tmp/checkout with spaces/reset" +ln -s "$tmp/checkout with spaces/reset" "$tmp/reset-link" + +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +printf '%s\0' "$@" >"$CALL_LOG" +exit "${SUDO_STATUS:-0}" +SH +chmod +x "$tmp/bin/sudo" + +args=('argument with spaces' '' '--flag' '$(touch should-not-run)' $'two\nlines') +expected=(env 'GUM_INPUT_PROMPT=Reset this computer? ' /usr/bin/omarchy-system-factory-reset "${args[@]}") +for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/reset' "$tmp/reset-link"; do + for status in 0 1 127; do + actual_status=0 + (cd "$tmp" && env -i HOME="$tmp" PATH="$tmp/bin:/usr/bin:/bin" \ + OMARCHY_PATH="$tmp/checkout with spaces" PACKAGED_PATH="$tmp/wrong-target" \ + GUM_INPUT_PROMPT='Reset this computer? ' CALL_LOG="$tmp/call" SUDO_STATUS="$status" \ + "$invocation" "${args[@]}") || actual_status=$? + (( actual_status == status )) || fail "elevation status is preserved for $invocation" + mapfile -d '' -t actual <"$tmp/call" + (( ${#actual[@]} == ${#expected[@]} )) || fail "elevation argument count is preserved" + for i in "${!expected[@]}"; do + [[ ${actual[i]} == "${expected[i]}" ]] || fail "elevation argument $i is preserved for $invocation" + done + [[ ! -e $tmp/should-not-run ]] || fail "caller arguments were executed" + done + pass "factory reset pins $invocation to the packaged command and preserves arguments, styling and status" +done From 45a64331394f62b0b853bf5a14805f94e66bdc7c Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 24 Sep 2026 22:42:46 +0100 Subject: [PATCH 17/34] Refuse factory reset handoff to a different installed version --- bin/omarchy-system-factory-reset | 6 ++++++ docs/file-layout.md | 2 ++ 2 files changed, 8 insertions(+) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index c248221c..325475f7 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -35,6 +35,12 @@ TOP_MNT=/run/omarchy-system-factory-reset/top NEXT_NAME=@omarchy-reset-next LOG_FILE=/var/log/omarchy-system-factory-reset.log +# Never replace a newer checkout reset with a different installed implementation. +if ! /usr/bin/cmp -s -- "${BASH_SOURCE[0]}" "$PACKAGED_PATH"; then + echo "Factory reset differs from the installed command. Install the matching Omarchy package before resetting." >&2 + exit 1 +fi + # Self-elevate so the menu entry (and a bare shell invocation) need no sudo # prefix. The caller's gum theme vars are forwarded as `env` arguments rather # than via `sudo -E`, so styling survives even under an env_reset sudoers. diff --git a/docs/file-layout.md b/docs/file-layout.md index 73c48c4f..19e2bfb5 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -201,6 +201,8 @@ yet and silently runs the packaged copy of one it has. The drop-in is validated with `visudo -c` before install and removed by `omarchy-dev-unlink`; unlike `/etc/omarchy.conf`, it takes effect without a reboot. +Factory reset is an exception: it always self-elevates through `/usr/bin/omarchy-system-factory-reset` and refuses a checkout copy that differs from the installed command, including when invoked with `sudo`. Install the matching package before resetting so a newer checkout cannot silently hand off to older account-scrubbing code. + ## Runtime finalization (`omarchy-provision-user`) Runs once per user. It does **not** copy `~/.config/**`, `~/.bashrc`, From 99e3c8ca2f17a89b1e34090d4dee9c59d61a00a4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 24 Sep 2026 22:42:46 +0100 Subject: [PATCH 18/34] Test factory reset version mismatches and packaged invocation --- test/shell.d/factory-reset-elevation-test.sh | 36 +++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/test/shell.d/factory-reset-elevation-test.sh b/test/shell.d/factory-reset-elevation-test.sh index 20a894bf..da47d88f 100755 --- a/test/shell.d/factory-reset-elevation-test.sh +++ b/test/shell.d/factory-reset-elevation-test.sh @@ -17,7 +17,10 @@ awk ' { print } END { if (!found) exit 1 } ' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/checkout with spaces/reset" -chmod +x "$tmp/checkout with spaces/reset" +grep -Fxq 'PACKAGED_PATH=/usr/bin/omarchy-system-factory-reset' "$ROOT/bin/omarchy-system-factory-reset" || fail "factory reset pins the packaged path" +sed -i "s|^PACKAGED_PATH=.*|PACKAGED_PATH=\"$tmp/packaged-reset\"|" "$tmp/checkout with spaces/reset" +cp "$tmp/checkout with spaces/reset" "$tmp/packaged-reset" +chmod +x "$tmp/checkout with spaces/reset" "$tmp/packaged-reset" ln -s "$tmp/checkout with spaces/reset" "$tmp/reset-link" cat >"$tmp/bin/sudo" <<'SH' @@ -28,11 +31,11 @@ SH chmod +x "$tmp/bin/sudo" args=('argument with spaces' '' '--flag' '$(touch should-not-run)' $'two\nlines') -expected=(env 'GUM_INPUT_PROMPT=Reset this computer? ' /usr/bin/omarchy-system-factory-reset "${args[@]}") -for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/reset' "$tmp/reset-link"; do +expected=(env 'GUM_INPUT_PROMPT=Reset this computer? ' "$tmp/packaged-reset" "${args[@]}") +for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/reset' "$tmp/reset-link" "$tmp/packaged-reset" reset; do for status in 0 1 127; do actual_status=0 - (cd "$tmp" && env -i HOME="$tmp" PATH="$tmp/bin:/usr/bin:/bin" \ + (cd "$tmp" && env -i HOME="$tmp" PATH="$tmp/bin:$tmp/checkout with spaces:/usr/bin:/bin" \ OMARCHY_PATH="$tmp/checkout with spaces" PACKAGED_PATH="$tmp/wrong-target" \ GUM_INPUT_PROMPT='Reset this computer? ' CALL_LOG="$tmp/call" SUDO_STATUS="$status" \ "$invocation" "${args[@]}") || actual_status=$? @@ -46,3 +49,28 @@ for invocation in "$tmp/checkout with spaces/reset" './checkout with spaces/rese done pass "factory reset pins $invocation to the packaged command and preserves arguments, styling and status" done + +if unshare -Ur true >/dev/null 2>&1; then + status=0 + unshare -Ur "$tmp/packaged-reset" || status=$? + (( status == 99 )) || fail "matching root invocation did not reach the guarded body" + pass "matching packaged root invocation reaches the reset body" +else + skip "user namespaces unavailable; root invocation coverage" +fi + +# A checkout must not hand off to older installed code, even with explicit sudo. +printf '\n# older packaged copy\n' >>"$tmp/packaged-reset" +rm -f "$tmp/call" +if CALL_LOG="$tmp/call" PATH="$tmp/bin:/usr/bin:/bin" "$tmp/reset-link" >"$tmp/mismatch" 2>&1; then + fail "mismatched reset implementation was accepted" +fi +[[ ! -e $tmp/call ]] || fail "mismatched reset requested elevation" +grep -Fq 'Install the matching Omarchy package' "$tmp/mismatch" || fail "mismatch explains recovery" +if unshare -Ur true >/dev/null 2>&1; then + status=0 + unshare -Ur "$tmp/reset-link" >"$tmp/root-mismatch" 2>&1 || status=$? + (( status == 1 )) || fail "root invocation did not refuse mismatch before the reset body" + pass "root checkout invocation refuses a mismatched packaged reset" +fi +pass "mismatched checkout refuses before elevation" From 60d6b255da422592c63234903fc908104247c2e6 Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Fri, 25 Sep 2026 14:13:48 -0600 Subject: [PATCH 19/34] Require approval for new Thunderbolt accessories --- AGENTS.md | 2 +- bin/omarchy-provision-owner | 3 + ...-remove-security-thunderbolt-authorization | 10 + bin/omarchy-remove-security-thunderbolt-boot | 9 + bin/omarchy-security-functions | 137 ++++ ...y-setup-security-thunderbolt-authorization | 42 ++ bin/omarchy-setup-security-thunderbolt-boot | 23 + bin/omarchy-system-factory-reset | 13 + bin/omarchy-thunderbolt-authorization-admin | 17 + bin/omarchy-thunderbolt-authorization-approve | 25 + ...chy-thunderbolt-authorization-boot-enabled | 8 + bin/omarchy-thunderbolt-authorization-daemon | 16 + bin/omarchy-thunderbolt-authorization-review | 10 + bin/omarchy-thunderbolt-authorization-watch | 9 + default/omarchy/omarchy-menu.jsonc | 4 + default/polkit/org.omarchy.thunderbolt.policy | 14 + .../omarchy-thunderbolt-authorization.service | 16 + docs/thunderbolt-authorization.md | 43 ++ .../rules.d/40-omarchy-thunderbolt.rules | 17 + .../bolt.service.d/omarchy-authorization.conf | 3 + .../omarchy-thunderbolt-authorization.service | 26 + install/config/all.sh | 1 + install/config/thunderbolt-authorization.sh | 10 + install/helpers/thunderbolt-policy.sh | 319 +++++++++ install/helpers/thunderbolt-review.sh | 254 +++++++ install/helpers/thunderbolt-setup.sh | 398 +++++++++++ install/user/first-run/enable-user-units.sh | 3 +- manual/48-security.md | 20 + migrations/1790344599.sh | 2 + .../fixtures/thunderbolt/bolt-exercise.sh | 87 +++ .../fixtures/thunderbolt/bolt-integration.py | 185 +++++ .../fixtures/thunderbolt/bolt-removal.sh | 95 +++ .../fixtures/thunderbolt/policy-test.sh | 640 ++++++++++++++++++ .../fixtures/thunderbolt/startup-test.sh | 31 + .../shell.d/thunderbolt-authorization-test.sh | 13 + 35 files changed, 2503 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-remove-security-thunderbolt-authorization create mode 100755 bin/omarchy-remove-security-thunderbolt-boot create mode 100755 bin/omarchy-security-functions create mode 100755 bin/omarchy-setup-security-thunderbolt-authorization create mode 100755 bin/omarchy-setup-security-thunderbolt-boot create mode 100755 bin/omarchy-thunderbolt-authorization-admin create mode 100755 bin/omarchy-thunderbolt-authorization-approve create mode 100755 bin/omarchy-thunderbolt-authorization-boot-enabled create mode 100755 bin/omarchy-thunderbolt-authorization-daemon create mode 100755 bin/omarchy-thunderbolt-authorization-review create mode 100755 bin/omarchy-thunderbolt-authorization-watch create mode 100644 default/polkit/org.omarchy.thunderbolt.policy create mode 100644 default/systemd/user/omarchy-thunderbolt-authorization.service create mode 100644 docs/thunderbolt-authorization.md create mode 100644 etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules create mode 100644 etc/systemd/system/bolt.service.d/omarchy-authorization.conf create mode 100644 etc/systemd/system/omarchy-thunderbolt-authorization.service create mode 100644 install/config/thunderbolt-authorization.sh create mode 100644 install/helpers/thunderbolt-policy.sh create mode 100644 install/helpers/thunderbolt-review.sh create mode 100644 install/helpers/thunderbolt-setup.sh create mode 100644 migrations/1790344599.sh create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-exercise.sh create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-integration.py create mode 100644 test/shell.d/fixtures/thunderbolt/bolt-removal.sh create mode 100644 test/shell.d/fixtures/thunderbolt/policy-test.sh create mode 100644 test/shell.d/fixtures/thunderbolt/startup-test.sh create mode 100755 test/shell.d/thunderbolt-authorization-test.sh diff --git a/AGENTS.md b/AGENTS.md index e4a0084e..6de19cb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience: - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) -- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) +- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index a4f2d996..f28136ee 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -1040,6 +1040,9 @@ run_provisioning() { usb_authorization_provision_owner "$username" fi + log_step "enrolling the owner's Thunderbolt accessories" + /usr/bin/omarchy-thunderbolt-authorization-admin owner + log_step "cleaning up provisioning state" cleanup_oem_state log_step "first-boot setup complete" diff --git a/bin/omarchy-remove-security-thunderbolt-authorization b/bin/omarchy-remove-security-thunderbolt-authorization new file mode 100755 index 00000000..0910041d --- /dev/null +++ b/bin/omarchy-remove-security-thunderbolt-authorization @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:summary=Restore Bolt's default authorization behavior + +set -euo pipefail + +gum confirm "Restore Bolt's previous policy? New accessories may be authorized automatically when IOMMU protection is active." || exit 0 +sudo /usr/bin/omarchy-thunderbolt-authorization-admin disable +systemctl --user disable --now omarchy-thunderbolt-authorization.service +echo "Previous Bolt policy restored. Saved Omarchy trust is retained for re-enabling." diff --git a/bin/omarchy-remove-security-thunderbolt-boot b/bin/omarchy-remove-security-thunderbolt-boot new file mode 100755 index 00000000..8657e527 --- /dev/null +++ b/bin/omarchy-remove-security-thunderbolt-boot @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Restore saved Thunderbolt firmware boot permissions + +set -euo pipefail + +gum confirm "Restore the previous Thunderbolt firmware boot allowlists? Device approval will remain enabled." || exit 0 +sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-disable +echo "Previous firmware boot permissions restored and verified." diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000..890d4430 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,137 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal helpers for command-scoped sudo authentication + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 + executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 + [[ $executable == "/usr/bin/bash" && + ( ${argv[0]:-} == "/bin/bash" || ${argv[0]:-} == "/usr/bin/bash" ) && + ${argv[1]:-} == "-p" ]] + ' omarchy-bash-startup "$$" +} + +omarchy_security_sanitize_bash_environment() { + local script=$1 + shift + local entry name environment_fd environment_pid + local -a unsets=() + + # Read the raw environment: privileged Bash ignores exported functions, but + # leaves their records for ordinary child interpreters to import later. + exec {environment_fd}< <(/usr/bin/env -0) + environment_pid=$! + while IFS= read -r -d '' entry <&"$environment_fd"; do + name=${entry%%=*} + case "$name" in + BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%) + unsets+=(-u "$name") + ;; + esac + done + exec {environment_fd}<&- + wait "$environment_pid" || return 1 + if (( ${#unsets[@]} > 0 )); then + exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@" + fi +} + +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi +} + +omarchy_security_sudo_supports_no_update() { + local help + help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help" +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_enable_no_update_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + if ! omarchy_security_sudo_supports_no_update; then + echo "This sudo does not support --no-update; refusing mixed-trust work." >&2 + return 1 + fi + if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then + echo "The command-scoped sudo wrapper is missing." >&2 + return 1 + fi + PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" + OMARCHY_SUDO_NO_UPDATE=1 + export PATH OMARCHY_SUDO_NO_UPDATE +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" +} + +omarchy_security_assert_root_directory() { + local path=$1 expected_mode=$2 canonical owner actual_mode + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-setup-security-thunderbolt-authorization b/bin/omarchy-setup-security-thunderbolt-authorization new file mode 100755 index 00000000..45eb2652 --- /dev/null +++ b/bin/omarchy-setup-security-thunderbolt-authorization @@ -0,0 +1,42 @@ +#!/bin/bash + +# omarchy:summary=Require approval for new Thunderbolt PCIe accessories +# omarchy:args=[--quiet|status|recover] + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" + +if [[ ${1:-} == "status" ]]; then + tb_status +elif [[ ${1:-} == "recover" && $# == 1 ]]; then + sudo /usr/bin/omarchy-thunderbolt-authorization-admin recover +elif [[ $# == 0 || ( $# == 1 && $1 == "--quiet" ) ]]; then + if [[ ${1:-} != "--quiet" ]]; then + if [[ -f $TB_PENDING ]]; then tb_status; fi + echo "First setup, including a deferred setup, trusts connected accessories. Re-enabling a completed setup preserves saved trust." + gum confirm "Require approval for new Thunderbolt accessories?" || exit 0 + fi + # System units and rules are shipped by omarchy-settings. + sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy + sudo systemctl daemon-reload + action=enable + if [[ ${1:-} == "--quiet" ]]; then + action=migrate + fi + sudo /usr/bin/omarchy-thunderbolt-authorization-admin "$action" + systemctl --user daemon-reload + systemctl --user enable --now omarchy-thunderbolt-authorization.service + if [[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]; then + for ((attempt=0; attempt<10; attempt++)); do + if tb_public_snapshot >/dev/null 2>&1; then break; fi + sleep 1 + done + tb_status + elif [[ -f $TB_PENDING ]]; then + tb_status + fi +else + echo "Usage: omarchy-setup-security-thunderbolt-authorization [--quiet|status|recover]" >&2 + exit 1 +fi diff --git a/bin/omarchy-setup-security-thunderbolt-boot b/bin/omarchy-setup-security-thunderbolt-boot new file mode 100755 index 00000000..23909db3 --- /dev/null +++ b/bin/omarchy-setup-security-thunderbolt-boot @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Clear supported Thunderbolt firmware boot allowlists +# omarchy:args=[recover] + +set -euo pipefail + +if [[ ${1:-} == "recover" && $# == 1 ]]; then + sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-recover +elif [[ $# == 0 ]]; then + gum style --bold "Thunderbolt at Boot" + echo "This trusts connected accessories for use after Linux starts, then clears the firmware boot allowlist." + echo "Trusted Thunderbolt keyboards, storage, and network adapters may be unavailable at disk unlock." + echo "You must be able to unlock and recover without Thunderbolt accessories." + echo "All controllers must be connected and support a boot allowlist in user or secure mode." + echo "Older snapshots or another operating system may restore firmware boot permissions." + gum confirm "Clear firmware boot access with these recovery limits?" || exit 0 + sudo /usr/bin/omarchy-thunderbolt-authorization-admin boot-enable + echo "Firmware boot allowlists cleared and verified. Current connections were not disconnected." +else + echo "Usage: omarchy-setup-security-thunderbolt-boot [recover]" >&2 + exit 1 +fi diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 087a6169..7f266648 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -320,6 +320,12 @@ scrub_factory_accounts() { sanitize_factory_baseline() { local factory="$1" btrfs property set -ts "$factory" ro false + if [[ -d $factory/var/lib/omarchy/thunderbolt-authorization || -f $factory/etc/omarchy/thunderbolt-authorization.enabled || -f $factory/etc/omarchy/thunderbolt-authorization.pending ]]; then + if ! /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not reset Thunderbolt authorization in the factory baseline" + fi + fi if ! scrub_factory_accounts "$factory"; then btrfs property set -ts "$factory" ro true @@ -368,6 +374,13 @@ stage_full_reset() { scrub_factory_accounts "$next" || fail "could not remove account credentials from the factory system (see $LOG_FILE)" + # Leave the new owner's input available before interactive enrollment. Clear + # inherited accessory identities in both roots; never change the live daemon. + if [[ -d $next/var/lib/omarchy/thunderbolt-authorization || -f $next/etc/omarchy/thunderbolt-authorization.enabled || -f $next/etc/omarchy/thunderbolt-authorization.pending ]]; then + /usr/bin/omarchy-thunderbolt-authorization-admin reset-root "$next" || + fail "could not reset Thunderbolt authorization in the staged root" + fi + # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and # /etc/shadow, which the new wheel user could mount and read — and a second diff --git a/bin/omarchy-thunderbolt-authorization-admin b/bin/omarchy-thunderbolt-authorization-admin new file mode 100755 index 00000000..28eec766 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-admin @@ -0,0 +1,17 @@ +#!/bin/bash -p + +# omarchy:summary=Manage system Thunderbolt authorization settings +# omarchy:hidden=true + +# This root entrypoint uses installed code and rejects unsafe Bash startup. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh +source /usr/share/omarchy/install/helpers/thunderbolt-setup.sh +tb_admin "$@" diff --git a/bin/omarchy-thunderbolt-authorization-approve b/bin/omarchy-thunderbolt-authorization-approve new file mode 100755 index 00000000..30b86db0 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-approve @@ -0,0 +1,25 @@ +#!/bin/bash -p + +# omarchy:summary=Approve one verified Thunderbolt connection +# omarchy:hidden=true + +# pkexec permits this exact program for an active local wheel user. Suppress +# startup injection before reading input; it cannot dispatch general root work. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh + +[[ $# == 2 && ( $1 == "true" || $1 == "false" ) ]] || exit 64 +(( ${#2} <= 16384 )) || exit 64 +jq -e 'type == "object"' <<< "$2" >/dev/null +exec 9> "$TB_LOCK" +flock -x 9 +result=$(tb_approve "$2" "$1" true) +tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$result" +printf '%s\n' "$result" diff --git a/bin/omarchy-thunderbolt-authorization-boot-enabled b/bin/omarchy-thunderbolt-authorization-boot-enabled new file mode 100755 index 00000000..3fb023d8 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-boot-enabled @@ -0,0 +1,8 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Check whether Thunderbolt firmware boot access is restricted + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +tb_public_snapshot | jq -e .boot_protection >/dev/null diff --git a/bin/omarchy-thunderbolt-authorization-daemon b/bin/omarchy-thunderbolt-authorization-daemon new file mode 100755 index 00000000..39f3dd0e --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-daemon @@ -0,0 +1,16 @@ +#!/bin/bash -p + +# omarchy:summary=Restore trusted Thunderbolt devices and publish their status +# omarchy:hidden=true + +# Keep root startup independent of a caller's environment or checkout. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset DBUS_SYSTEM_BUS_ADDRESS DBUS_SESSION_BUS_ADDRESS +source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh +tb_daemon diff --git a/bin/omarchy-thunderbolt-authorization-review b/bin/omarchy-thunderbolt-authorization-review new file mode 100755 index 00000000..6b555464 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-review @@ -0,0 +1,10 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Handle Thunderbolt device approval review + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" +[[ $# == 1 ]] || exit 64 +tb_review "$1" diff --git a/bin/omarchy-thunderbolt-authorization-watch b/bin/omarchy-thunderbolt-authorization-watch new file mode 100755 index 00000000..20746c75 --- /dev/null +++ b/bin/omarchy-thunderbolt-authorization-watch @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Handle Thunderbolt device approval watch + +set -euo pipefail +source "$OMARCHY_PATH/install/helpers/thunderbolt-policy.sh" +source "$OMARCHY_PATH/install/helpers/thunderbolt-review.sh" +tb_watch diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 9018db72..184e8118 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -179,6 +179,8 @@ "setup.plugin.remove": {"icon":"󰭌","label":"Remove Plugin","when":"compgen -G \"$HOME/.config/omarchy/plugins/*/manifest.json\"","action":"omarchy-menu-plugin remove"}, "setup.security": {"icon":"","label":"Security"}, "setup.config": {"icon":"","label":"Config"}, + "setup.security.thunderbolt": {"icon":"󱐋","label":"Thunderbolt","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization"}, + "setup.security.thunderbolt-boot": {"icon":"󱐋","label":"Thunderbolt Boot","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-boot"}, "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, @@ -298,6 +300,8 @@ "remove.windows": {"icon":"󰍲","label":"Windows","when":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm remove'"}, "remove.preinstalls": {"icon":"󰏓","label":"Preinstalls","when":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-preinstalls"}, "remove.security": {"icon":"","label":"Security","title":"Remove"}, + "remove.security.thunderbolt": {"icon":"󱐋","label":"Thunderbolt","when":"[[ -f /etc/omarchy/thunderbolt-authorization.enabled || -f /etc/omarchy/thunderbolt-authorization.pending ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-authorization"}, + "remove.security.thunderbolt-boot": {"icon":"󱐋","label":"Thunderbolt Boot","when":"omarchy-thunderbolt-authorization-boot-enabled","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-thunderbolt-boot"}, "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, diff --git a/default/polkit/org.omarchy.thunderbolt.policy b/default/polkit/org.omarchy.thunderbolt.policy new file mode 100644 index 00000000..e849f583 --- /dev/null +++ b/default/polkit/org.omarchy.thunderbolt.policy @@ -0,0 +1,14 @@ + + + + + Approve a connected Thunderbolt accessory + Authentication is required to approve this Thunderbolt accessory + /usr/bin/omarchy-thunderbolt-authorization-approve + + auth_admin + auth_admin + auth_admin + + + diff --git a/default/systemd/user/omarchy-thunderbolt-authorization.service b/default/systemd/user/omarchy-thunderbolt-authorization.service new file mode 100644 index 00000000..b7c808c5 --- /dev/null +++ b/default/systemd/user/omarchy-thunderbolt-authorization.service @@ -0,0 +1,16 @@ +[Unit] +Description=Offer approval for blocked Thunderbolt accessories +After=graphical-session.target +PartOf=graphical-session.target +ConditionEnvironment=WAYLAND_DISPLAY +ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.enabled +ConditionPathExists=|/etc/omarchy/thunderbolt-authorization.pending + +[Service] +ExecStart=/usr/bin/omarchy-thunderbolt-authorization-watch +Restart=always +RestartSec=2 +UMask=0077 + +[Install] +WantedBy=graphical-session.target diff --git a/docs/thunderbolt-authorization.md b/docs/thunderbolt-authorization.md new file mode 100644 index 00000000..82ce8b8e --- /dev/null +++ b/docs/thunderbolt-authorization.md @@ -0,0 +1,43 @@ +# Thunderbolt authorization + +Omarchy uses Bolt for Thunderbolt/USB4 PCIe authorization. USB functions remain separate and need USB authorization. The end-user controls and firmware limitations are documented in [the Security manual](../manual/48-security.md#thunderbolt-device-authorization). + +## Policy and services + +Bolt's default IOMMU policy can enroll new devices without asking. Omarchy keeps `AuthMode=disabled` in `/var/lib/boltd/boltd.conf` and authorizes individual devices through Bolt's D-Bus API. This does not disable Bolt or prevent explicit authorization. Setting only `DefaultPolicy=manual` would still leave IOMMU auto-enrollment enabled. + +The root `omarchy-thunderbolt-authorization.service` holds explicit consent in `/var/lib/omarchy/thunderbolt-authorization/policy.json`. Bolt can import devices authorized by firmware, so its database is not treated as consent. Initial setup captures currently connected accessories; subsequent enables preserve that inventory, including an initially empty inventory. Always-allow approvals authorize, read back the real status, enroll with manual policy, verify storage, and save consent. Trusted devices are restored even without an unlocked graphical session. No operation globally enables Bolt to authorize one device. + +Omarchy records which Bolt entries it enrolled, including their store timestamps, separately from consent. Removing protection converts only matching, successfully approved entries from manual to automatic policy before restoring Bolt's global mode; this includes disconnected accessories. Existing manual records and externally recreated records retain their policies. Secure keys are kept. The handoff verifies the stored policies after restarting Bolt and checkpoints policies, firmware allowlists, configuration and services for rollback. Automatic policies resume Bolt's normal firmware allowlist behavior, so every controller must be online for a recoverable handoff. A completed handoff clears enrollment ownership while retaining the trust list for re-enabling. + +The Bolt startup drop-in sets the persisted mode before hardware enumeration whenever `/etc/omarchy/thunderbolt-authorization.enabled` exists. The Omarchy service checks the live mode again. Firmware security `none` remains a bypass: software cannot prevent driver loading after firmware has already opened a PCIe tunnel. This produces a warning instead of disconnecting active storage. + +Secure-capable devices require both a stored Bolt record and a saved key for automatic trust. An explicit permanent approval pins the current sysfs directory and key descriptor, seeds an empty kernel key before authorization, and verifies Bolt imported it before saving consent. Automatic reconciliation cannot initialize a missing key and retains its read-only sysfs sandbox. First activation is deferred when a captured secure-capable device lacks a saved Bolt key: the `.pending` marker starts a setup notification, while existing Bolt configuration and services remain untouched. Installation can finish; only explicit setup after safe disconnection clears the pending state. Initial identity capture uses Bolt's numeric device/vendor fallback when descriptive sysfs attributes are absent. + +The runtime is Bash, with `busctl --json=short` for Bolt's structured D-Bus replies and `jq` for JSON. The root daemon publishes a root-owned snapshot in `/run/omarchy-thunderbolt-authorization`. The user service polls that snapshot and refuses unavailable, stale, or mismatched generations. It does not infer final policy from presence signals. Requests use opaque tokens and bind the policy-service generation, Bolt bus owner, device path, connection timestamp, sysfs inode, and device identity. The inode distinguishes same-second reconnects. Device text is displayed as untrusted data. Failed delivery retries; lost approval responses retain intent and offer another notification. Watcher restarts preserve unfinished intent only while the exact connection and policy-service incarnation remain unchanged. A changed device or daemon requires a new request. + +The terminal dialog saves an approval intent; the background watcher submits it through `pkexec` to the fixed installed `/usr/bin/omarchy-thunderbolt-authorization-approve` helper. Polkit grants active local wheel users this individual approval action. The helper validates its arguments and the live connection, then independently verifies authorization and any saved trust before returning success. Root entrypoints use protected Bash startup and fixed installed libraries; they discard caller-supplied D-Bus addresses. A shared lock serializes setup, reconciliation, and approvals. General setup uses a separate administrator command invoked with `sudo` from the terminal. Blanket Bolt management requires administrator authentication while protection is enabled. These controls protect against unsolicited accessories, not malicious software already running as an administrator. Identifiers can be forged; Bolt's secure connection keys provide additional verification on supported hardware. + +## Firmware boot access + +The optional boot control applies only to online controllers with a firmware BootACL in `user` or `secure` mode. It captures connected accessories, changes stored policies to manual, clears each allowlist, and independently reads back the results. Reconciliation keeps firmware imports and returning controllers from silently restoring automatic boot permissions. Unsupported or offline controllers produce explicit warnings/errors. + +Changes retain the previous policy and firmware lists. A failed transition restores matching state, or leaves a recovery checkpoint and reports failure. Device-authorization setup has a separate checkpoint that includes service state and firmware settings when boot protection was active. Restore commands are described in the manual. Firmware modified by another OS or an older snapshot is outside the current system's control; this is not an early-boot kernel authorization switch. + +Deferred owner setup leaves ordinary Bolt behavior in place until the owner finishes provisioning. Factory reset disables the copied service and removes previous-owner trust, recovery data, Bolt identities, and keys from both staged and retained roots. It does not change the live daemon. + +## Validation + +Run policy, notification, rollback, deferred-owner, and real offline-systemctl tests with: + +```sh +bash test/shell.d/thunderbolt-authorization-test.sh +``` + +The optional integration suite runs the installed `boltd` against upstream's UMockdev Thunderbolt fixture and a private D-Bus. Its hardware simulator uses Python/GObject; all Omarchy policy code and test assertions execute Bash. It needs `python-gobject`, `python-dbusmock`, `umockdev`, Bolt, and a Bolt source checkout (validated with 0.9.11): + +```sh +umockdev-wrapper env BOLT_TEST_SOURCE=/path/to/bolt bash test/shell.d/thunderbolt-authorization-test.sh +``` + +It covers user/secure modes, IOMMU default denial, verified permanent and one-time approval, daemon restart, stale requests, firmware ACL restoration, and a separate Bash daemon publishing status after reconnecting to Bolt. Removal cases exercise the real setup and approval functions, automatic reconnection with retained secure keys, preservation of existing manual policies, disconnected accessories, and rollback of policies, firmware ACLs and the real private approval daemon after a setup failure. A service-control bridge manages only the fixture's private processes. These tests simulate kernel devices and firmware. They do not demonstrate physical DMA isolation or real firmware enforcement during boot. Systemd packaging, the installed approval helper, and the actual desktop Polkit rules require installed-guest validation; source-only dev linking does not install system units or rules. diff --git a/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules b/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules new file mode 100644 index 00000000..3f37d36a --- /dev/null +++ b/etc/polkit-1/rules.d/40-omarchy-thunderbolt.rules @@ -0,0 +1,17 @@ +polkit.addRule(function(action, subject) { + if (action.id === "org.omarchy.thunderbolt.approve" && + subject.active && subject.local && subject.isInGroup("wheel")) { + return polkit.Result.YES; + } + // Bolt's vendor rule grants wheel passwordless management, including enabling + // automatic approval of every IOMMU device. Keep blanket policy changes behind + // administrator authentication; the narrow Omarchy action approves one device. + if (action.id === "org.freedesktop.bolt.manage" && subject.user !== "root") { + try { + polkit.spawn(["/usr/bin/test", "-f", "/etc/omarchy/thunderbolt-authorization.enabled"]); + return polkit.Result.AUTH_ADMIN; + } catch (error) { + // Protection is disabled: retain the packaged Bolt permission rules. + } + } +}); diff --git a/etc/systemd/system/bolt.service.d/omarchy-authorization.conf b/etc/systemd/system/bolt.service.d/omarchy-authorization.conf new file mode 100644 index 00000000..96b2645b --- /dev/null +++ b/etc/systemd/system/bolt.service.d/omarchy-authorization.conf @@ -0,0 +1,3 @@ +[Service] +# Runs before Bolt enumerates hardware, including D-Bus/udev activation. +ExecStartPre=/usr/bin/omarchy-thunderbolt-authorization-admin guard diff --git a/etc/systemd/system/omarchy-thunderbolt-authorization.service b/etc/systemd/system/omarchy-thunderbolt-authorization.service new file mode 100644 index 00000000..c2b5e7a6 --- /dev/null +++ b/etc/systemd/system/omarchy-thunderbolt-authorization.service @@ -0,0 +1,26 @@ +[Unit] +Description=Restore approved Thunderbolt devices and handle new approvals +Wants=bolt.service +After=bolt.service polkit.service +ConditionPathExists=/etc/omarchy/thunderbolt-authorization.enabled + +[Service] +Type=simple +ExecStart=/usr/bin/omarchy-thunderbolt-authorization-daemon +RuntimeDirectory=omarchy-thunderbolt-authorization +RuntimeDirectoryMode=0755 +Restart=on-failure +RestartSec=2 +UMask=0077 +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/var/lib/omarchy/thunderbolt-authorization /run/lock +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/install/config/all.sh b/install/config/all.sh index 3ff8d66a..09b23293 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -10,3 +10,4 @@ run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" +run_logged "$OMARCHY_INSTALL/config/thunderbolt-authorization.sh" diff --git a/install/config/thunderbolt-authorization.sh b/install/config/thunderbolt-authorization.sh new file mode 100644 index 00000000..e2e4f2c5 --- /dev/null +++ b/install/config/thunderbolt-authorization.sh @@ -0,0 +1,10 @@ +echo "Configure Thunderbolt device authorization" + +install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.thunderbolt.policy" /usr/share/polkit-1/actions/org.omarchy.thunderbolt.policy + +# The builder's accessories must not become the new owner's trust policy. +# Keep ordinary Bolt behavior until interactive owner setup has finished. +if [[ -n ${OMARCHY_INSTALL_USER:-} ]]; then + /usr/bin/omarchy-thunderbolt-authorization-admin prepare + systemctl enable omarchy-thunderbolt-authorization.service +fi diff --git a/install/helpers/thunderbolt-policy.sh b/install/helpers/thunderbolt-policy.sh new file mode 100644 index 00000000..f8f93030 --- /dev/null +++ b/install/helpers/thunderbolt-policy.sh @@ -0,0 +1,319 @@ +# Shared by the root controller and session commands. These paths are fixed at +# privileged entrypoints; tests may source the functions in an isolated fixture. +TB_STATE=/var/lib/omarchy/thunderbolt-authorization/policy.json +TB_CONFIG=/var/lib/boltd/boltd.conf +TB_MARKER=/etc/omarchy/thunderbolt-authorization.enabled +TB_PENDING=/etc/omarchy/thunderbolt-authorization.pending +TB_RUNTIME=/run/omarchy-thunderbolt-authorization +TB_LOCK=/run/lock/omarchy-thunderbolt-authorization.lock +TB_SERVICE=omarchy-thunderbolt-authorization.service +TB_BOLT=org.freedesktop.bolt +TB_MANAGER=org.freedesktop.bolt1.Manager +TB_DEVICE=org.freedesktop.bolt1.Device +TB_DOMAIN=org.freedesktop.bolt1.Domain +TB_PATH=/org/freedesktop/bolt +TB_SYSFS=/sys/bus/thunderbolt/devices +TB_TRUST_JQ='def trusted($state): $state.trusted[.Uid] == {Uid,Name,Vendor} and + ((.Secure | not) or (.Stored and (.Key | IN("have","new"))));' + +tb_fail() { printf '%s\n' "$*" >&2; return 1; } + +tb_json_write() { + local target=$1 mode=${2:-600} temporary + temporary=$(mktemp "${target%/*}/.thunderbolt.XXXXXX") || return 1 + if jq -cS . > "$temporary" && chmod "$mode" "$temporary" && sync "$temporary" && + mv -fT -- "$temporary" "$target" && sync "${target%/*}"; then + return 0 + else + rm -f -- "$temporary" + return 1 + fi +} + +tb_state() { + jq -ce 'select(.version == 1 and (.trusted | type == "object") and (.enabled | type == "boolean"))' "$TB_STATE" +} + +tb_bus() { + busctl --system --auto-start=no --timeout=10 --json=short "$@" +} + +tb_owner() { + tb_bus call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$TB_BOLT" | jq -er '.data[0]' +} + +tb_properties() { + tb_bus call "$1" "$2" org.freedesktop.DBus.Properties GetAll s "$3" | jq -ce '.data[0] | map_values(.data)' +} + +tb_set() { + local owner=$1 path=$2 interface=$3 key=$4 value=$5 signature=${6:-s} current + local -a values=() + current=$(tb_properties "$owner" "$path" "$interface") || return 1 + if ! jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null; then + if [[ $signature == "as" ]]; then + mapfile -t values < <(jq -r '.[]' <<< "$value") + tb_bus set-property "$owner" "$path" "$interface" "$key" as "${#values[@]}" "${values[@]}" >/dev/null || return 1 + else + tb_bus set-property "$owner" "$path" "$interface" "$key" s "$(jq -r . <<< "$value")" >/dev/null || return 1 + fi + fi + # Bolt rejects no-op BootACL writes; even skipped writes are read back. + current=$(tb_properties "$owner" "$path" "$interface") || return 1 + jq -e --arg key "$key" --argjson value "$value" '.[$key] == $value' <<< "$current" >/dev/null || + tb_fail "Bolt did not retain the requested $key" +} + +tb_inventory() { + local owner manager paths path item inode domains='[]' devices='[]' stored='[]' + owner=$(tb_owner) || return 1 + manager=$(tb_properties "$owner" "$TB_PATH" "$TB_MANAGER") || return 1 + paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDomains | jq -cer '.data[0]') || return 1 + while IFS= read -r path; do + item=$(tb_properties "$owner" "$path" "$TB_DOMAIN" | jq -c --arg path "$path" '. + {path:$path}') || return 1 + domains=$(jq -c --argjson item "$item" '. + [$item]' <<< "$domains") || return 1 + done < <(jq -r '.[]' <<< "$paths") + paths=$(tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" ListDevices | jq -cer '.data[0]') || return 1 + while IFS= read -r path; do + item=$(tb_properties "$owner" "$path" "$TB_DEVICE" | jq -c --arg path "$path" '. + {path:$path}') || return 1 + [[ $(jq -r .Type <<< "$item") == "peripheral" ]] || continue + if jq -e .Stored <<< "$item" >/dev/null; then + stored=$(jq -c --argjson item "$item" '. + [$item]' <<< "$stored") || return 1 + fi + [[ $(jq -r .Status <<< "$item") != "disconnected" ]] || continue + inode=$(stat -Lc '%i' -- "$(jq -r .SysfsPath <<< "$item")") || return 1 + item=$(jq -c --arg inode "$inode" '. + {inode:$inode}' <<< "$item") || return 1 + devices=$(jq -c --argjson item "$item" '. + [$item]' <<< "$devices") || return 1 + done < <(jq -r '.[]' <<< "$paths") + [[ $(tb_owner) == "$owner" ]] || { tb_fail "Bolt restarted while reading its inventory"; return 1; } + jq -cn --arg owner "$owner" --argjson manager "$manager" --argjson domains "$domains" \ + --argjson devices "$devices" --argjson stored "$stored" ' + {owner:$owner,manager:$manager,domains:$domains,stored:$stored,devices:[$devices[] | + . + {Secure:((.Domain as $domain | any($domains[]; .Uid == $domain and .SecurityLevel == "secure")) + and (.AuthFlags | contains("nokey") | not))}]}' +} + +tb_read_inventory() { + local inventory + inventory=$(tb_inventory) || return 1 + jq -e '.manager.AuthMode == "disabled"' <<< "$inventory" >/dev/null || { + tb_fail "Bolt automatic authorization is enabled; protection is not active" + return 1 + } + printf '%s\n' "$inventory" +} + +tb_identity() { + jq -c --arg owner "$1" --arg generation "$2" \ + '{owner:$owner,generation:$generation,path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime}' +} + +tb_snapshot() { + local state inventory generation recovery=false + state=$(tb_state) || return 1 + inventory=$(tb_read_inventory) || return 1 + generation=$(< "$TB_RUNTIME/generation") || return 1 + [[ -f ${TB_STATE%/*}/boot-recovery.json ]] && recovery=true + jq -c --argjson state "$state" --arg generation "$generation" --argjson recovery "$recovery" \ + --arg error "${1:-}" --argjson time "$(date +%s)" "$TB_TRUST_JQ"' + .owner as $owner | + [.devices[] | . as $d | { + identity: ({owner:$owner,generation:$generation} + ($d | {path,inode,Uid,Name,Vendor,Parent,SysfsPath,ConnectTime})), + trusted: ($d | trusted($state)), + status:.Status,stored:.Stored,policy:.Policy,flags:.AuthFlags + }] as $devices | + {available:true,time:$time,owner:$owner,generation:$generation,devices:$devices,error:$error, + boot_protection:($state.boot_protection // false), + domains:[.domains[] | {Uid,SecurityLevel,IOMMU,BootACL}], + warnings: ([ + (if $recovery then "A firmware boot-access change needs recovery. Boot protection is not confirmed." else empty end), + (.domains[] | if .SecurityLevel == "none" then + "This controller allows PCIe connections in firmware. Select user authorization in firmware settings; Omarchy cannot block its devices before their drivers load." + else empty end), + (.domains[] | .SecurityLevel as $level | select(["none","user","secure","dponly","usbonly","nopcie"] | index($level) | not) | + "The controller authorization mode could not be verified."), + (.domains[] | select($state.boot_protection and (.SysfsPath == "" or + (.SecurityLevel != "user" and .SecurityLevel != "secure") or (.BootACL | length == 0) or any(.BootACL[]; . != ""))) | + "The firmware boot allowlist is not confirmed empty on every controller. Check Thunderbolt Boot before relying on boot protection."), + ($devices[] | select((.status | startswith("authorized")) and (.trusted | not) and (.flags | contains("boot"))) | + "An untrusted device was authorized by firmware before Linux started. Disconnect it safely to apply approval on its next connection.") + ] | unique)}' <<< "$inventory" +} + +tb_current() { + local expected=$1 inventory owner generation device identity + inventory=$(tb_read_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + generation=$(< "$TB_RUNTIME/generation") || return 1 + while IFS= read -r device; do + identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1 + if jq -e --argjson expected "$expected" '. == $expected' <<< "$identity" >/dev/null; then + jq -cn --arg owner "$owner" --argjson device "$device" '{owner:$owner,device:$device}' + return 0 + fi + done < <(jq -c '.devices[]' <<< "$inventory") + tb_fail "That Thunderbolt device was removed or changed. Open its latest notification." +} + +tb_prepare_key() ( + local identity=$1 current key key_fd + current=$(tb_current "$identity") || return 1 + # Bolt creates keys for stored devices itself. For a new device, disabled + # AuthMode prevents enrollment before authorization. Seed the kernel key so + # Authorize provisions it and the subsequent enrollment imports it into Bolt. + jq -e '.device.Secure and (.device.Stored | not)' <<< "$current" >/dev/null || return 0 + cd -- "$(jq -r .device.SysfsPath <<< "$current")" || return 1 + [[ $(stat -Lc '%i' .) == "$(jq -r .device.inode <<< "$current")" && + $(cat unique_id) == "$(jq -r .device.Uid <<< "$current")" ]] || { + tb_fail 'The Thunderbolt connection changed before key setup'; return 1; + } + # Pin the attribute in the original directory; do not follow a replacement + # connection at the same path, or truncate an existing key on a retry. + exec {key_fd}<>key || return 1 + key=$(cat key) || return 1 + if [[ -z $key ]]; then + [[ $(cat authorized) == "0" ]] || { tb_fail 'Reconnect the device before saving secure approval'; return 1; } + key=$(openssl rand -hex 32) || return 1 + [[ $key =~ ^[0-9a-f]{64}$ ]] || return 1 + tb_current "$identity" >/dev/null || return 1 + printf '%s' "$key" >&"$key_fd" || return 1 + [[ $(cat key) == "$key" ]] || { tb_fail 'Could not verify the Thunderbolt key'; return 1; } + else + [[ $key =~ ^[0-9a-fA-F]{64}$ ]] || { tb_fail 'The Thunderbolt key is invalid'; return 1; } + fi +) + +tb_approve() { + local identity=$1 permanent=$2 initialize=${3:-false} current owner path status state + state=$(tb_state) || return 1 + jq -e .enabled <<< "$state" >/dev/null || { tb_fail "Thunderbolt protection is disabled"; return 1; } + current=$(tb_current "$identity") || return 1 + owner=$(jq -r .owner <<< "$current") + path=$(jq -r .device.path <<< "$current") + status=$(jq -r .device.Status <<< "$current") + case "$status" in + connected|auth-error) + if [[ $permanent == "true" ]]; then + if [[ $initialize == "true" ]]; then + tb_prepare_key "$identity" || return 1 + else + jq -e '(.device.Secure | not) or (.device.Stored and (.device.Key | IN("have","new")))' <<< "$current" >/dev/null || { + tb_fail 'A secure key requires explicit device approval'; return 1; + } + fi + fi + current=$(tb_current "$identity") || return 1 + tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null || return 1 ;; + authorized|authorized-newkey|authorized-secure) ;; + *) tb_fail "Device authorization is still in progress; try again"; return 1 ;; + esac + current=$(tb_current "$identity") || return 1 + jq -e '.device.Status | IN("authorized","authorized-newkey","authorized-secure")' <<< "$current" >/dev/null || { + tb_fail "Could not confirm device authorization; try again"; return 1; + } + if [[ $permanent == "true" ]]; then + if ! jq -e .device.Stored <<< "$current" >/dev/null; then + # Save enrollment intent before Bolt writes its record, so a lost reply + # can be retried without confusing our record with a pre-existing one. + state=$(jq --argjson device "$(jq -c .device <<< "$current")" \ + '.enrolled[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state") || return 1 + tb_json_write "$TB_STATE" <<< "$state" || return 1 + tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$(jq -r .device.Uid <<< "$current")" manual '' >/dev/null || return 1 + fi + current=$(tb_current "$identity") || return 1 + jq -e .device.Stored <<< "$current" >/dev/null || { tb_fail "Could not confirm saved device information; try again"; return 1; } + jq -e '(.device.Secure | not) or (.device.Key | IN("have","new"))' <<< "$current" >/dev/null || { + tb_fail 'Secure approval has no saved key. Safely disconnect, reconnect, and approve the device again.'; return 1; + } + jq --argjson device "$(jq -c .device <<< "$current")" ' + if .enrolled[$device.Uid] == ($device | {Uid,Name,Vendor}) then + .enrolled[$device.Uid] = ($device | {Uid,Name,Vendor,StoreTime}) + else . end | + .trusted[$device.Uid] = ($device | {Uid,Name,Vendor})' <<< "$state" | + tb_json_write "$TB_STATE" || return 1 + fi + tb_snapshot +} + +tb_boot_apply() { + local saved=$1 inventory owner item uid policies acls value path + inventory=$(tb_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + jq -e --argjson saved "$saved" ' + (($saved.policies | keys) - [.stored[].Uid] | length == 0) and + (($saved.acls | keys) - [.domains[] | select(.SysfsPath != "") | .Uid] | length == 0)' <<< "$inventory" >/dev/null || { + tb_fail "Reconnect the original controllers and restore missing Bolt records before recovery"; return 1; + } + while IFS= read -r item; do + uid=$(jq -r .Uid <<< "$item") + value=$(jq -c --arg uid "$uid" '.policies[$uid] // empty' <<< "$saved") || return 1 + [[ -n $value ]] || continue + tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DEVICE" Policy "$value" || return 1 + done < <(jq -c '.stored[]' <<< "$inventory") + # Changing a device policy can update firmware ACLs, so restore ACLs last. + while IFS= read -r item; do + uid=$(jq -r .Uid <<< "$item") + value=$(jq -c --arg uid "$uid" '.acls[$uid] // empty' <<< "$saved") || return 1 + [[ -n $value ]] || continue + [[ $(jq length <<< "$value") == "$(jq '.BootACL | length' <<< "$item")" ]] || { + tb_fail "Firmware boot allowlist capacity changed"; return 1; + } + tb_set "$owner" "$(jq -r .path <<< "$item")" "$TB_DOMAIN" BootACL "$value" as || return 1 + done < <(jq -c '.domains[]' <<< "$inventory") +} + +tb_reconcile() { + local inventory state owner generation device identity error='' saved + state=$(tb_state) || return 1 + inventory=$(tb_read_inventory) || return 1 + owner=$(jq -r .owner <<< "$inventory") + generation=$(< "$TB_RUNTIME/generation") || return 1 + if jq -e .boot_protection <<< "$state" >/dev/null; then + saved=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries), + acls:(.domains | map(select(.SysfsPath != "" and (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length>0))) | + map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1 + tb_boot_apply "$saved" || return 1 + fi + while IFS= read -r device; do + identity=$(tb_identity "$owner" "$generation" <<< "$device") || return 1 + if ! tb_approve "$identity" true >/dev/null; then + error='A trusted Thunderbolt device could not be restored. Check the system service log.' + fi + done < <(jq -c --argjson state "$state" "$TB_TRUST_JQ"'.devices | sort_by(.SysfsPath | length)[] | + select((.Status == "connected" or .Status == "auth-error") and (.AuthFlags | contains("nopcie") | not)) | + # Never silently initialize a missing secure binding on a later connection. + select(trusted($state))' <<< "$inventory") + tb_snapshot "$error" +} + +tb_daemon() { + local snapshot error + install -d -m 755 "$TB_RUNTIME" + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + chmod 644 "$TB_RUNTIME/generation" + while [[ -f $TB_MARKER ]]; do + ( + flock -x 9 + if snapshot=$(tb_reconcile 2> "$TB_RUNTIME/error"); then + tb_json_write "$TB_RUNTIME/snapshot.json" 644 <<< "$snapshot" + else + cat "$TB_RUNTIME/error" >&2 + error=$(< "$TB_RUNTIME/error") + jq -cn --arg error "$error" --argjson time "$(date +%s)" '{available:false,time:$time,error:$error}' | + tb_json_write "$TB_RUNTIME/snapshot.json" 644 + fi + ) 9> "$TB_LOCK" + sleep 2 + done +} + +tb_public_snapshot() { + local snapshot generation + snapshot=$(cat "$TB_RUNTIME/snapshot.json") || return 1 + generation=$(< "$TB_RUNTIME/generation") || return 1 + jq -e --argjson now "$(date +%s)" --arg generation "$generation" \ + '.available and .generation == $generation and .time <= $now and $now - .time < 20' <<< "$snapshot" >/dev/null || { + tb_fail 'Thunderbolt policy is unavailable or could not be verified.'; return 1; + } + printf '%s\n' "$snapshot" +} diff --git a/install/helpers/thunderbolt-review.sh b/install/helpers/thunderbolt-review.sh new file mode 100644 index 00000000..0e6913fd --- /dev/null +++ b/install/helpers/thunderbolt-review.sh @@ -0,0 +1,254 @@ +# Session-side notification and terminal dialog functions. + +tb_requests() { + TB_REQUESTS="$HOME/.local/state/omarchy/thunderbolt-authorization/requests" + mkdir -p -m 700 "$TB_REQUESTS" + chmod 700 "${TB_REQUESTS%/*}" "$TB_REQUESTS" +} + +tb_safe_text() { + jq -r 'gsub("[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]"; "?")' +} + +tb_notify() { + local token=$1 retry=${2:-false} title='Thunderbolt accessory blocked' description + description='Click to review it. Keep it blocked unless you recognize the device.' + if [[ $retry == "true" ]]; then + title='Thunderbolt approval needs retry' + description='The last approval could not be confirmed. Click to retry it.' + fi + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical --glyph '󱐋' \ + "$title" "$description" --exec omarchy-launch-floating-terminal-with-presentation \ + omarchy-thunderbolt-authorization-review "$token" +} + +tb_attention() { + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \ + 'Thunderbolt protection needs attention' 'The approval or firmware policy could not be confirmed. Click for details.' \ + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization status +} + +tb_setup_notice() { + omarchy-notification-wait 1 >/dev/null || return 1 + timeout 10 omarchy-notification-send --app-name omarchy-action --urgency critical \ + 'Finish Thunderbolt protection setup' 'A connected accessory needs secure enrollment. Protection is not enabled. Click to finish safely.' \ + --exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-thunderbolt-authorization +} + +tb_token() { + local digest + digest=$(jq -cS --arg watch "$2" '{watch:$watch,identity:.}' <<< "$1" | sha256sum) || return 1 + printf 'request-%s\n' "${digest%% *}" +} + +tb_request_read() { + [[ -f $1 && ! -L $1 ]] || return 1 + jq -ce 'select(.identity | type == "object")' "$1" +} + +tb_deliver() { + local path=$1 request token + request=$(tb_request_read "$path") || return 1 + if ! jq -e '.notified // false' <<< "$request" >/dev/null; then + token=${path##*/} + if tb_notify "${token%.json}" "$(jq -r '.approval != null' <<< "$request")"; then + jq '.notified=true' <<< "$request" | tb_json_write "$path" + else + return 1 + fi + fi +} + +tb_request_apply() { + # The watcher has no terminal. Polkit permits this one fixed helper for an + # active local administrator; it cannot dispatch general setup operations. + pkexec --disable-internal-agent /usr/bin/omarchy-thunderbolt-authorization-approve "$1" "$2" +} + +tb_request_process() { + local path=$1 request identity permanent result error_file error + request=$(tb_request_read "$path") || return 1 + if jq -e '.pending // false' <<< "$request" >/dev/null; then + identity=$(jq -c .identity <<< "$request") + permanent=$(jq -r '.approval == "Always allow this device"' <<< "$request") + error_file=$(mktemp "$TB_REQUESTS/.error.XXXXXX") || return 1 + if result=$(tb_request_apply "$permanent" "$identity" 2> "$error_file") && + jq -e --argjson identity "$identity" --argjson permanent "$permanent" \ + 'any(.devices[]; .identity==$identity and (.status | IN("authorized","authorized-newkey","authorized-secure")) and + ($permanent == false or .trusted))' <<< "$result" >/dev/null; then + jq --argjson result "$result" '.pending=false | .result=$result | del(.error)' <<< "$request" | + tb_json_write "$path" || return 1 + else + error=$(cat "$error_file") + [[ -n $error ]] || error='Could not confirm approval. Click the retry notification.' + jq --arg error "$error" '.pending=false | .notified=false | .error=$error' <<< "$request" | + tb_json_write "$path" || return 1 + fi + rm -f -- "$error_file" + fi + tb_deliver "$path" +} + +tb_scan() { + local watch=$1 snapshot path request device identity token existing + snapshot=$(tb_public_snapshot) || return 1 + ( + flock -x 9 + for path in "$TB_REQUESTS"/request-*.json; do + [[ -e $path || -L $path ]] || continue + if ! request=$(tb_request_read "$path"); then + rm -f -- "$path" + continue + fi + identity=$(jq -c .identity <<< "$request") + device=$(jq -c --argjson identity "$identity" '.devices[] | select(.identity==$identity)' <<< "$snapshot") || exit 1 + if [[ -z $device ]] || { ! jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null && + ! jq -e '.approval != null' <<< "$request" >/dev/null; }; then + rm -- "$path" + continue + fi + if [[ $(jq -r .watch <<< "$request") != "$watch" ]]; then + rm -- "$path" + # Resume an interrupted attempt only for the same exact connection. + jq -e '.approval != null and .result == null' <<< "$request" >/dev/null || continue + token=$(tb_token "$identity" "$watch") || exit 1 + path="$TB_REQUESTS/$token.json" + request=$(jq -c --arg watch "$watch" '.watch=$watch | .notified=false' <<< "$request") || exit 1 + tb_json_write "$path" <<< "$request" || exit 1 + fi + tb_request_process "$path" || true + done + snapshot=$(tb_public_snapshot) || exit 1 + while IFS= read -r device; do + identity=$(jq -c .identity <<< "$device") + token=$(tb_token "$identity" "$watch") || exit 1 + path="$TB_REQUESTS/$token.json" + if [[ ! -f $path ]]; then + jq -cn --argjson identity "$identity" --arg watch "$watch" \ + '{identity:$identity,watch:$watch,notified:false}' | tb_json_write "$path" || exit 1 + if ! tb_deliver "$path"; then + rm -f -- "$path" + fi + fi + done < <(jq -c '.devices[] | select((.trusted | not) and (.status | IN("connected","auth-error")) and + (.flags | contains("nopcie") | not))' <<< "$snapshot") + ) 9> "$TB_REQUESTS/.lock" || return 1 + printf '%s\n' "$snapshot" +} + +tb_watch() { + local watch snapshot warning warned='' failures=0 + tb_requests + watch=$(cat /proc/sys/kernel/random/uuid) + while true; do + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + if [[ $warned != "pending" ]] && tb_setup_notice; then warned=pending; fi + sleep 2 + continue + fi + if snapshot=$(tb_scan "$watch"); then + failures=0 + warning=$(jq -c '[.generation,.warnings,.error]' <<< "$snapshot") + if jq -e '(.warnings | length > 0) or .error != ""' <<< "$snapshot" >/dev/null; then + if [[ $warning != "$warned" ]] && tb_attention; then + warned=$warning + fi + else + warned='' + fi + else + failures=$((failures + 1)) + if (( failures >= 3 )) && [[ $warned != "unavailable" ]] && tb_attention; then + warned=unavailable + fi + fi + sleep 2 + done +} + +tb_find_device() { + local snapshot + snapshot=$(tb_public_snapshot) || return 1 + jq -ce --argjson identity "$1" '.devices[] | select(.identity==$identity)' <<< "$snapshot" || + tb_fail 'That Thunderbolt device was removed or changed. Open its latest notification.' +} + +tb_review() { + local token=$1 path request identity device choice latest index + [[ $token =~ ^request-[0-9a-f]{64}$ ]] || { tb_fail 'Invalid or expired Thunderbolt request'; return 1; } + tb_requests + path="$TB_REQUESTS/$token.json" + request=$(tb_request_read "$path") || return 1 + identity=$(jq -c .identity <<< "$request") + device=$(tb_find_device "$identity") || return 1 + choice=$(jq -r '.approval // empty' <<< "$request") + if [[ -z $choice ]]; then + jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || { tb_fail 'This device is no longer blocked'; return 1; } + gum style --foreground 212 --bold 'Blocked Thunderbolt accessory' + printf '\nThese details come from the device and can be forged:\n' + for index in Name Vendor Uid; do + printf '%s: ' "$index" + jq --arg key "$index" '.[$key]' <<< "$identity" | tb_safe_text + done + printf '\nApproval gives this accessory access to its PCIe drivers.\n\n' + choice=$(gum choose 'Allow once' 'Always allow this device' 'Keep blocked') || return 0 + fi + case "$choice" in + 'Allow once'|'Always allow this device'|'Keep blocked') ;; + *) tb_fail 'Invalid approval choice'; return 1 ;; + esac + ( + flock -x 9 + latest=$(tb_request_read "$path") || exit 1 + [[ $latest == "$request" ]] || { tb_fail 'This request changed. Open the latest notification.'; exit 1; } + device=$(tb_find_device "$identity") || exit 1 + if [[ $choice == "Keep blocked" ]]; then + printf 'Thunderbolt accessory remains blocked.\n' + else + if ! jq -e '.approval != null' <<< "$request" >/dev/null; then + jq -e '.status | IN("connected","auth-error")' <<< "$device" >/dev/null || exit 1 + fi + jq --arg choice "$choice" '.approval=$choice | .pending=true | del(.error,.result)' <<< "$request" | + tb_json_write "$path" + fi + ) 9> "$TB_REQUESTS/.lock" || return 1 + if [[ $choice != "Keep blocked" ]]; then + for ((index=0; index<60; index++)); do + latest=$(tb_request_read "$path") || return 1 + if jq -e '.result != null' <<< "$latest" >/dev/null; then + rm -- "$path" + if [[ $choice == "Always allow this device" ]]; then + printf 'Thunderbolt accessory trusted.\n' + else + printf 'Thunderbolt accessory allowed for this connection.\n' + fi + return 0 + elif jq -e '.error != null' <<< "$latest" >/dev/null; then + jq .error <<< "$latest" | tb_safe_text >&2 + return 1 + fi + sleep 1 + done + tb_fail 'Approval is still pending. The watcher will retain and retry the request.' + fi +} + +tb_status() { + local snapshot + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + printf 'Thunderbolt protection is deferred. Previous Bolt behavior remains active.\nKeep a keyboard/input device available independently of Thunderbolt. Safely disconnect the Thunderbolt accessories, then run Setup > Security > Thunderbolt. Reconnect each accessory and choose Always allow to save its secure key.\n' + return 0 + fi + snapshot=$(tb_public_snapshot) || return 1 + printf 'Thunderbolt device approval is enabled.\nTrusted accessories reconnect automatically; new accessories require approval.\n' + if jq -e '.domains | length == 0' <<< "$snapshot" >/dev/null; then + printf 'No Thunderbolt controller is currently visible. Its firmware policy will be checked when it appears.\n' + fi + jq '.warnings[], (.error | select(. != ""))' <<< "$snapshot" | while IFS= read -r warning; do + printf 'Attention: ' + tb_safe_text <<< "$warning" + done + printf 'Firmware boot access is separate; see the Security manual before relying on pre-boot protection.\n' +} diff --git a/install/helpers/thunderbolt-setup.sh b/install/helpers/thunderbolt-setup.sh new file mode 100644 index 00000000..70ffc48e --- /dev/null +++ b/install/helpers/thunderbolt-setup.sh @@ -0,0 +1,398 @@ +# Root setup functions. The caller sources thunderbolt-policy.sh first. + +tb_config_authmode() { + local path=$1 + if [[ -f $path ]]; then + awk ' + /^[[:space:]]*\[/ { section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) } + section && /^[[:space:]]*AuthMode[[:space:]]*=/ { + sub(/^[^=]*=[[:space:]]*/, ""); sub(/[[:space:]]*$/, ""); mode=$0 + } + END { print mode == "" ? "enabled" : mode } + ' "$path" + else + printf 'enabled\n' + fi +} + +tb_config_write() { + local path=$1 mode=$2 temporary + [[ $mode == "enabled" || $mode == "disabled" ]] || return 1 + install -d -m 700 "${path%/*}" || return 1 + temporary=$(mktemp "${path%/*}/.config.XXXXXX") || return 1 + if awk -v mode="$mode" ' + /^[[:space:]]*\[/ { + section = ($0 ~ /^[[:space:]]*\[config\][[:space:]]*$/) + if (section) { found=1; print; print "AuthMode = " mode; next } + } + section && /^[[:space:]]*AuthMode[[:space:]]*=/ { next } + { print } + END { if (!found) print "\n[config]\nAuthMode = " mode } + ' "$(if [[ -e $path ]]; then printf '%s' "$path"; else printf /dev/null; fi)" > "$temporary" && + sync "$temporary" && mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then + return 0 + else + rm -f -- "$temporary" + return 1 + fi +} + +tb_guard() { + if [[ -f $TB_MARKER ]]; then + tb_state | jq -e .enabled >/dev/null || { tb_fail 'Thunderbolt policy and enable marker disagree'; return 1; } + tb_config_write "$TB_CONFIG" disabled + fi +} + +tb_capture() { + local path uid name vendor trusted='{}' + for path in "$TB_SYSFS"/*; do + [[ -f $path/unique_id && $path != *-0 ]] || continue + uid=$(cat "$path/unique_id") || return 1 + # Bolt uses the numeric attribute when a descriptive name is absent. + if [[ -e $path/device_name ]]; then + name=$(cat "$path/device_name") || return 1 + else + name=$(cat "$path/device") || return 1 + fi + if [[ -e $path/vendor_name ]]; then + vendor=$(cat "$path/vendor_name") || return 1 + else + vendor=$(cat "$path/vendor") || return 1 + fi + [[ -n $uid ]] || { tb_fail 'A connected Thunderbolt device has no identity'; return 1; } + trusted=$(jq -c --arg uid "$uid" --arg name "$name" --arg vendor "$vendor" \ + '.[$uid] = {Uid:$uid,Name:$name,Vendor:$vendor}' <<< "$trusted") || return 1 + done + printf '%s\n' "$trusted" +} + +tb_capture_ready() { + local fresh=${1:-false} offline=${2:-false} path uid inode inventory='' + # Do not relax an active policy, or capture new devices when re-enabling an + # existing policy. This preflight only guards the initial trust inventory. + [[ ! -f $TB_MARKER ]] || return 0 + if [[ -f $TB_STATE && ! -f $TB_PENDING && $fresh != "true" ]]; then + tb_state | jq -e '.enrollment_pending // false' >/dev/null || return 0 + fi + for path in "$TB_SYSFS"/*; do + [[ -f $path/unique_id && $path != *-0 && -e $path/key ]] || continue + uid=$(cat "$path/unique_id") || return 1 + [[ $uid =~ ^[0-9a-fA-F-]{36}$ ]] || { tb_fail 'A connected Thunderbolt device has an invalid identity'; return 1; } + path=$(realpath -e -- "$path") || return 1 + inode=$(stat -Lc '%i' -- "$path") || return 1 + # A key file alone can be orphaned after a failed Bolt record write. Ask + # Bolt whether this exact connection has a stored record and key. Offline + # prepare must not accept the live installer's daemon as the target store. + if [[ $offline != "true" && -z $inventory ]]; then + inventory=$(tb_inventory) || inventory='{}' + fi + if [[ $offline == "true" ]] || ! jq -e --arg uid "$uid" --arg path "$path" --arg inode "$inode" \ + 'any(.devices[]?; .Uid==$uid and .SysfsPath==$path and .inode==$inode and .Stored and (.Key | IN("have","new")))' <<< "$inventory" >/dev/null; then + # An active keyless dock cannot receive a key without disconnecting it. + # Preserve input/storage and finish installation with setup deferred. + install -d -m 755 "${TB_PENDING%/*}" || return 1 + install -m 644 /dev/null "$TB_PENDING" || return 1 + printf 'Thunderbolt protection is deferred: safely disconnect accessories without saved secure keys, keep independent input available, then run Setup > Security > Thunderbolt.\n' >&2 + return 2 + fi + done +} + +tb_prepare() { + local fresh=${1:-false} state='{}' trusted mode + if [[ -f $TB_STATE ]]; then + state=$(tb_state) || return 1 + fi + if [[ ! -f $TB_STATE || -f $TB_PENDING || $fresh == "true" ]] || jq -e '.enrollment_pending // false' <<< "$state" >/dev/null; then + trusted=$(tb_capture) || return 1 + mode=$(jq -r '.original_authmode // empty' <<< "$state") || return 1 + [[ -n $mode ]] || mode=$(tb_config_authmode "$TB_CONFIG") || return 1 + [[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; } + state=$(jq -c --arg mode "$mode" --argjson trusted "$trusted" \ + 'del(.enrollment_pending) | . + {version:1,enabled:true,trusted:$trusted,original_authmode:$mode}' <<< "$state") || return 1 + else + mode=$(jq -r .original_authmode <<< "$state") || return 1 + [[ $mode == "enabled" || $mode == "disabled" ]] || { tb_fail 'Unsupported existing Bolt authorization mode'; return 1; } + state=$(jq -c '.enabled=true' <<< "$state") || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + tb_json_write "$TB_STATE" <<< "$state" || return 1 + tb_config_write "$TB_CONFIG" disabled || return 1 + install -d -m 755 "${TB_MARKER%/*}" || return 1 + install -m 644 /dev/null "$TB_MARKER" +} + +tb_boot_saved() { + jq -c '{policies:(.stored | map({key:.Uid,value:.Policy}) | from_entries), + acls:(.domains | map({key:.Uid,value:.BootACL}) | from_entries)}' +} + +tb_boot_change() { + local enabled=$1 state inventory saved after desired backup="${TB_STATE%/*}/boot-recovery.json" + state=$(tb_state) || return 1 + jq -e .enabled <<< "$state" >/dev/null || { tb_fail 'Enable Thunderbolt device authorization first'; return 1; } + if [[ $enabled == "false" ]] && ! jq -e .boot_protection <<< "$state" >/dev/null; then + return 0 + fi + [[ ! -e $backup ]] || { tb_fail 'Recover the previous firmware boot-access change before retrying'; return 1; } + inventory=$(tb_read_inventory) || return 1 + jq -e '.domains | length > 0 and all(.[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || { + tb_fail 'Connect every Thunderbolt controller before changing firmware boot access'; return 1; + } + saved=$(tb_boot_saved <<< "$inventory" | jq -c --argjson state "$state" '. + {state:$state}') || return 1 + if [[ $enabled == "true" ]]; then + jq -e 'all(.domains[]; (.SecurityLevel == "user" or .SecurityLevel == "secure") and (.BootACL|length > 0))' <<< "$inventory" >/dev/null || { + tb_fail 'This controller has no supported firmware boot allowlist. Disable Thunderbolt pre-boot/PCIe boot support in firmware settings; Omarchy cannot verify that setting.'; return 1; + } + desired=$(jq -c '{policies:(.stored | map({key:.Uid,value:"manual"}) | from_entries), + acls:(.domains | map({key:.Uid,value:(.BootACL | map(""))}) | from_entries)}' <<< "$inventory") || return 1 + after=$(jq -c --argjson saved "$saved" --argjson devices "$(jq -c .devices <<< "$inventory")" ' + if .boot_protection then . else .boot_original=($saved | {policies,acls}) end | + .boot_protection=true | .trusted += ($devices | map({key:.Uid,value:{Uid,Name,Vendor}}) | from_entries)' <<< "$state") || return 1 + else + desired=$(jq -ce .boot_original <<< "$state") || return 1 + after=$(jq -c 'del(.boot_original) | .boot_protection=false' <<< "$state") || return 1 + fi + tb_json_write "$backup" <<< "$saved" || return 1 + if tb_boot_apply "$desired" && tb_json_write "$TB_STATE" <<< "$after"; then + rm -- "$backup" + else + if tb_boot_apply "$saved" && tb_json_write "$TB_STATE" <<< "$state"; then + rm -- "$backup" || return 1 + tb_fail 'Firmware change failed; previous state restored' + else + tb_fail "Firmware change failed; recovery is incomplete. Recovery data retained at $backup" + fi + return 1 + fi +} + +tb_boot_recover() { + local backup="${TB_STATE%/*}/boot-recovery.json" saved + saved=$(jq -ce . "$backup") || return 1 + tb_boot_apply "$saved" || return 1 + jq .state <<< "$saved" | tb_json_write "$TB_STATE" || return 1 + rm -- "$backup" +} + +tb_unit_state() { + local properties + properties=$(systemctl show -p ActiveState -p UnitFileState "$1") || return 1 + jq -Rn '[inputs | split("=") | {key:.[0],value:.[1]}] | from_entries | + {active:(.ActiveState == "active"),enabled:(.UnitFileState == "enabled")}' <<< "$properties" +} + +tb_handoff_policies() { + local state=$1 + # StoreTime distinguishes a record recreated outside Omarchy. A completed + # enrollment and matching consent are both required, including offline + # accessories. Never promote somebody else's manual policy. + jq -c --argjson state "$state" '{policies:([.stored[] | + select(.Policy == "manual" and .StoreTime > 0 and + $state.enrolled[.Uid] == {Uid,Name,Vendor,StoreTime} and + $state.trusted[.Uid] == {Uid,Name,Vendor}) | + {key:.Uid,value:"auto"}] | from_entries),acls:{}}' +} + +tb_config_checkpoint() { + local backup="${TB_STATE%/*}/setup-recovery.json" path original files='{}' units='{}' unit value saved inventory data mode state + [[ ! -e $backup && ! -e ${TB_STATE%/*}/boot-recovery.json ]] || { + tb_fail 'A previous setup change needs recovery before continuing'; return 1; + } + for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do + original=null + if [[ -e $path ]]; then + data=$(base64 -w0 -- "$path") || return 1 + mode=$(stat -c '%a' -- "$path") || return 1 + original=$(jq -cn --arg data "$data" --arg mode "$mode" '{data:$data,mode:$mode}') || return 1 + fi + files=$(jq -c --arg path "$path" --argjson original "$original" '.[$path]=$original' <<< "$files") || return 1 + done + for unit in bolt.service "$TB_SERVICE"; do + value=$(tb_unit_state "$unit") || return 1 + units=$(jq -c --arg unit "$unit" --argjson value "$value" '.[$unit]=$value' <<< "$units") || return 1 + done + saved=$(jq -cn --argjson files "$files" --argjson units "$units" '{files:$files,units:$units}') || return 1 + state='{}' + if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi + # A handoff to auto also changes Bolt's firmware allowlists. Checkpoint the + # policies and ACLs even when the optional boot protection was never used. + if jq -e '.enabled and (.boot_protection or ((.enrolled // {}) | length > 0))' <<< "$state" >/dev/null; then + inventory=$(tb_read_inventory) || return 1 + jq -e 'all(.domains[]; .SysfsPath != "")' <<< "$inventory" >/dev/null || { + tb_fail 'Reconnect every Thunderbolt controller before changing device policies'; return 1; + } + saved=$(jq -c --argjson firmware "$(tb_boot_saved <<< "$inventory")" '.firmware=$firmware' <<< "$saved") || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + tb_json_write "$backup" <<< "$saved" +} + +tb_config_restore() { + local backup="${TB_STATE%/*}/setup-recovery.json" saved path original temporary unit + saved=$(jq -ce . "$backup") || return 1 + systemctl stop "$TB_SERVICE" bolt.service || return 1 + for path in "$TB_STATE" "$TB_CONFIG" "$TB_MARKER"; do + original=$(jq -c --arg path "$path" '.files[$path]' <<< "$saved") || return 1 + if [[ $original == "null" ]]; then + rm -f -- "$path" || return 1 + else + mkdir -p -- "${path%/*}" || return 1 + temporary=$(mktemp "${path%/*}/.restore.XXXXXX") || return 1 + if jq -r .data <<< "$original" | base64 -d > "$temporary" && + chmod "$(jq -r .mode <<< "$original")" "$temporary" && sync "$temporary" && + mv -fT -- "$temporary" "$path" && sync "${path%/*}"; then + : + else + rm -f -- "$temporary" + return 1 + fi + fi + done + if jq -e .firmware <<< "$saved" >/dev/null; then + systemctl start bolt.service || return 1 + tb_boot_apply "$(jq -c .firmware <<< "$saved")" || return 1 + fi + rm -f -- "${TB_STATE%/*}/boot-recovery.json" || return 1 + if jq -e --arg unit "$TB_SERVICE" '.units[$unit].enabled' <<< "$saved" >/dev/null; then + systemctl enable "$TB_SERVICE" || return 1 + else + systemctl disable "$TB_SERVICE" || return 1 + fi + for unit in bolt.service "$TB_SERVICE"; do + if jq -e --arg unit "$unit" '.units[$unit].active' <<< "$saved" >/dev/null; then + systemctl start "$unit" || return 1 + else + systemctl stop "$unit" || return 1 + fi + done + rm -- "$backup" +} + +tb_transaction() { + tb_config_checkpoint || return 1 + if "$@"; then + rm -- "${TB_STATE%/*}/setup-recovery.json" + else + if tb_config_restore; then + tb_fail 'Thunderbolt setup failed; previous configuration restored' + else + tb_fail 'Thunderbolt setup failed; restoration is incomplete. Run the setup recovery command.' + fi + return 1 + fi +} + +tb_enable() { + systemctl stop "$TB_SERVICE" bolt.service || return 1 + tb_prepare "${1:-false}" || return 1 + systemctl daemon-reload || return 1 + systemctl start bolt.service || return 1 + tb_read_inventory >/dev/null || return 1 + systemctl enable --now "$TB_SERVICE" +} + +tb_disable() { + local state mode policies + systemctl stop "$TB_SERVICE" || return 1 + tb_boot_change false || return 1 + state=$(tb_state) || return 1 + mode=$(jq -er .original_authmode <<< "$state") || return 1 + policies=$(tb_read_inventory | tb_handoff_policies "$state") || return 1 + tb_boot_apply "$policies" || return 1 + systemctl stop bolt.service || return 1 + tb_config_write "$TB_CONFIG" "$mode" || return 1 + rm -f -- "$TB_MARKER" || return 1 + jq '.enabled=false | del(.enrolled)' <<< "$state" | tb_json_write "$TB_STATE" || return 1 + systemctl disable "$TB_SERVICE" || return 1 + systemctl start bolt.service || return 1 + tb_inventory | jq -e --arg mode "$mode" --argjson policies "$policies" ' + .manager.AuthMode==$mode and + (([.stored[] | {key:.Uid,value:.Policy}] | from_entries) as $actual | + all($policies.policies | to_entries[]; $actual[.key]==.value))' >/dev/null +} + +tb_cancel_pending() { + local state + if [[ -f $TB_STATE ]]; then + state=$(tb_state) || return 1 + else + state=$(jq -cn --arg mode "$(tb_config_authmode "$TB_CONFIG")" \ + '{version:1,enabled:false,trusted:{},enrollment_pending:true,original_authmode:$mode}') || return 1 + fi + install -d -m 700 "${TB_STATE%/*}" || return 1 + jq '.enabled=false | .enrollment_pending=true' <<< "$state" | tb_json_write "$TB_STATE" || return 1 + rm -- "$TB_PENDING" +} + +tb_reset_root() { + local root directory + root=$(realpath -e -- "$1") || return 1 + [[ $root != "/" && -d $root ]] || { tb_fail 'Refusing to reset the running system'; return 1; } + [[ -e $root$TB_MARKER || -e $root$TB_PENDING || -d $root${TB_STATE%/*} ]] || return 0 + # Resolve fixed paths inside the cloned root, never through an outside link. + for directory in etc etc/omarchy var var/lib var/lib/omarchy var/lib/boltd; do + [[ ! -L $root/$directory ]] || { tb_fail 'Refusing a linked factory policy directory'; return 1; } + done + systemctl --root="$root" disable "$TB_SERVICE" || return 1 + rm -f -- "$root$TB_MARKER" || return 1 + rm -f -- "$root$TB_PENDING" || return 1 + rm -rf -- "$root${TB_STATE%/*}" || return 1 + for directory in devices keys domains; do + rm -rf -- "$root/var/lib/boltd/$directory" || return 1 + done + tb_config_write "$root/var/lib/boltd/boltd.conf" enabled +} + +tb_admin() { + local action=${1:-} status=0 state + if [[ $action != "guard" ]]; then + exec 9> "$TB_LOCK" + flock -x 9 || return 1 + fi + case "$action" in + prepare|enable|owner|migrate) + # A deferred setup needs explicit consent after the accessories have + # been disconnected; a later migration must not silently finish it. + if [[ -f $TB_PENDING && $action != "enable" ]]; then return 0; fi + if [[ $action == "migrate" && -f $TB_STATE ]] && tb_state | jq -e '.enabled == false' >/dev/null; then return 0; fi + if tb_capture_ready "$(if [[ $action == "owner" ]]; then echo true; else echo false; fi)" \ + "$(if [[ $action == "prepare" ]]; then echo true; else echo false; fi)"; then + : + else + status=$? + if (( status == 2 )); then return 0; else return "$status"; fi + fi ;; + esac + case "$action" in + guard) tb_guard ;; + prepare) tb_prepare && rm -f -- "$TB_PENDING" ;; + enable) tb_transaction tb_enable && rm -f -- "$TB_PENDING" ;; + owner) tb_transaction tb_enable true && rm -f -- "$TB_PENDING" ;; + migrate) + state='{"enabled":true}' + if [[ -f $TB_STATE ]]; then state=$(tb_state) || return 1; fi + if jq -e .enabled <<< "$state" >/dev/null; then + tb_transaction tb_enable + fi ;; + disable) + if [[ -f $TB_PENDING && ! -f $TB_MARKER ]]; then + tb_cancel_pending + else + tb_transaction tb_disable + fi ;; + recover) tb_config_restore ;; + boot-enable|boot-disable|boot-recover) + systemctl stop "$TB_SERVICE" || return 1 + case "$action" in + boot-enable) tb_boot_change true || status=$? ;; + boot-disable) tb_boot_change false || status=$? ;; + boot-recover) tb_boot_recover || status=$? ;; + esac + systemctl start "$TB_SERVICE" || return 1 + return "$status" ;; + reset-root) [[ $# == 2 ]] && tb_reset_root "$2" ;; + *) tb_fail 'Unknown Thunderbolt setup action' ;; + esac +} diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 2bf1fb8a..1cf68380 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -20,6 +20,7 @@ systemctl --user enable --now \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ omarchy-crash-watch.service \ - omarchy-usb-authorization.service + omarchy-usb-authorization.service \ + omarchy-thunderbolt-authorization.service omarchy-hook-install theme-set /usr/share/owe/10-owe-sync diff --git a/manual/48-security.md b/manual/48-security.md index 6525317c..cc0b5b60 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -48,3 +48,23 @@ Be clear-eyed about this one: while it's on, anything running as your user can d The public key for all ISO signatures and Omarchy repo package is `40DFB630FF42BCFFB047046CF0134EE680CAC571` ([verify at openpgp.org](https://keys.openpgp.org/search?q=pkgs%40omarchy.org)). The `omarchy/omarchy-keyring` package contains this as well and will be used to rollout any potential updates seamlessly. You can find the signature for any ISO release by adding .sig to the URL. Like https://iso.omarchy.org/omarchy-x.x.x.iso.sig. + +## Thunderbolt device authorization + +Omarchy requires approval before connecting new Thunderbolt and USB4 PCIe accessories on controllers that support authorization. Devices connected during initial setup are trusted. New devices show a persistent notification with **Allow once**, **Always allow this device**, and **Keep blocked**. Trusted devices reconnect automatically, including while the screen is locked. IOMMU memory protection stays enabled; it does not count as consent to load a device's driver. + +On controllers in secure mode, permanent approval also saves the accessory's authentication key. If a connected accessory has no saved key during initial setup, Omarchy defers Thunderbolt protection and keeps the previous Bolt behavior so your keyboard and storage remain usable. A **Finish Thunderbolt protection setup** notification explains how to continue: keep input available independently of Thunderbolt, safely disconnect the accessories, enable protection, then reconnect and approve each one. Losing a previously saved secure key requires a new explicit approval; Omarchy does not silently create a replacement key while restoring trusted devices. + +_Setup > Security > Thunderbolt_ enables protection. _Remove > Security > Thunderbolt_ restores the previous Bolt behavior, which may automatically approve new devices when IOMMU protection is available. Re-enabling keeps your saved trust list. Omarchy never disconnects active storage when changing these settings. + +When you remove protection, devices enrolled by Omarchy for permanent approval reconnect through Bolt's automatic policy, keeping their secure keys. Devices with a manual Bolt policy from before setup retain that policy. Bolt also resumes managing firmware boot allowlists for automatically approved devices. Keep all Thunderbolt controllers connected during removal so Omarchy can restore their settings if the change fails. + +Firmware can authorize PCIe accessories before Linux runs. A controller configured with security mode `none` bypasses software approval; select user authorization or secure authorization in its firmware settings. Omarchy reports these limitations instead of claiming that the controller is protected. Existing firmware boot allowlists can also authorize devices early. USB accessories attached through a dock require separate USB authorization. + +Approval does not repair a vulnerable driver. Device names and identifiers can be forged; secure connection keys are used where Bolt and the hardware support them. + +_Setup > Security > Thunderbolt Boot_ can clear and verify supported firmware boot allowlists. It captures connected accessories for authorization after Linux starts, prevents Bolt from repopulating those allowlists from automatic device policies, and retains the previous lists for _Remove > Security > Thunderbolt Boot_. This option requires every controller to be connected and to expose a boot allowlist in `user` or `secure` mode. Other hardware needs firmware settings instead; Omarchy cannot verify those settings automatically. + +Enable this only if you can unlock and recover without any Thunderbolt accessories. Even a trusted keyboard or disk may remain unavailable until the policy service starts. Older snapshots and other operating systems can restore their own firmware permissions. A failed change restores the previous settings when possible; an incomplete restoration retains recovery data. Reconnect the original controllers and run `omarchy setup security thunderbolt-boot recover` to retry that recovery. + +If enabling or removing device authorization fails and reports incomplete restoration, run `omarchy setup security thunderbolt-authorization recover` before trying again. This restores the configuration and service state saved before that change. diff --git a/migrations/1790344599.sh b/migrations/1790344599.sh new file mode 100644 index 00000000..ee48034d --- /dev/null +++ b/migrations/1790344599.sh @@ -0,0 +1,2 @@ +echo "Require approval for new Thunderbolt accessories" +omarchy-setup-security-thunderbolt-authorization --quiet diff --git a/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh b/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh new file mode 100644 index 00000000..9f853698 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-exercise.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Runs against the private real boltd created by bolt-integration.py. No policy, +# busctl, firmware reads, or writes are mocked here. +set -euo pipefail +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +TB_STATE="$TB_TEST_DIR/policy.json" +TB_RUNTIME="$TB_TEST_DIR/runtime" +mkdir -p "$TB_RUNTIME" +check() { jq -e "$1" "$2" >/dev/null; } +reject() { if "$@"; then echo 'Unexpected approval success' >&2; exit 1; fi; } +case "$1" in + approve) + echo '{"version":1,"enabled":true,"trusted":{}}' | tb_json_write "$TB_STATE" + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + tb_snapshot > "$TB_TEST_DIR/before.json" + capture=$(tb_capture) + tb_inventory | jq -e --arg uid "$TB_TEST_UID" --argjson capture "$capture" \ + '.devices[] | select(.Uid==$uid) | $capture[$uid]=={Uid,Name,Vendor}' >/dev/null + check 'all(.devices[]; .status=="connected" and (.stored|not))' "$TB_TEST_DIR/before.json" + id=$(jq -c --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .identity' "$TB_TEST_DIR/before.json") + printf '%s\n' "$id" > "$TB_TEST_DIR/identity.json" + tb_approve "$id" true true > "$TB_TEST_DIR/after.json" + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized" and .policy=="manual"' "$TB_TEST_DIR/after.json" >/dev/null + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null + check 'all(.domains[0].BootACL[]; .=="")' "$TB_TEST_DIR/after.json" + inventory=$(tb_inventory) + if [[ $TB_TEST_SECURITY == "secure" ]]; then + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.Uid==$uid) | .Stored and .Key=="have"' <<< "$inventory" >/dev/null + [[ -s $TB_TEST_KEYS/$TB_TEST_UID ]] + sha256sum "$TB_TEST_KEYS/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum" + fi + owner=$(jq -r .owner <<< "$inventory") + device=$(jq -c --arg uid "$TB_TEST_UID" '.stored[] | select(.Uid==$uid)' <<< "$inventory") + domain=$(jq -c '.domains[0]' <<< "$inventory") + device_path=$(jq -r .path <<< "$device"); domain_path=$(jq -r .path <<< "$domain") + tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"auto"' + acl=$(jq -c --arg uid "$TB_TEST_UID" '.BootACL | map("") | .[0]=$uid' <<< "$domain") + tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$acl" as + tb_boot_change true + check .boot_protection "$TB_STATE" + tb_inventory > "$TB_TEST_DIR/inventory.json" + check 'all(.domains[0].BootACL[]; .=="") and .stored[0].Policy=="manual"' "$TB_TEST_DIR/inventory.json" + tb_boot_change false + check '.boot_protection==false' "$TB_STATE" + tb_inventory > "$TB_TEST_DIR/inventory.json" + jq -e --argjson acl "$acl" '.domains[0].BootACL==$acl and .stored[0].Policy=="auto"' "$TB_TEST_DIR/inventory.json" >/dev/null + jq --arg other "$TB_TEST_OTHER" 'del(.trusted[$other])' "$TB_STATE" | tb_json_write "$TB_STATE" + tb_set "$owner" "$device_path" "$TB_DEVICE" Policy '"manual"' + tb_set "$owner" "$domain_path" "$TB_DOMAIN" BootACL "$(jq -c 'map("")' <<< "$acl")" as + ;; + restart) + cat /proc/sys/kernel/random/uuid > "$TB_RUNTIME/generation" + reject tb_approve "$(cat "$TB_TEST_DIR/identity.json")" true + tb_reconcile > "$TB_TEST_DIR/after.json" + jq -e --arg uid "$TB_TEST_UID" '.devices[] | select(.identity.Uid==$uid) | .trusted and .status=="authorized"' "$TB_TEST_DIR/after.json" >/dev/null + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="connected"' "$TB_TEST_DIR/after.json" >/dev/null + tb_inventory | jq -e '.manager.AuthMode=="disabled"' >/dev/null + if [[ $TB_TEST_SECURITY == "secure" ]]; then + # Bolt must challenge with the originally saved key, not provision a new + # key merely because a reconnecting device advertises the same identity. + tb_inventory | jq -e --arg uid "$TB_TEST_UID" \ + '.devices[] | select(.Uid==$uid) | .Key=="have" and (.AuthFlags | contains("secure"))' >/dev/null + sha256sum --status -c "$TB_TEST_DIR/key-checksum" + fi + ;; + daemon) + TB_MARKER="$TB_TEST_DIR/enabled" TB_LOCK="$TB_TEST_DIR/root.lock" + touch "$TB_MARKER" + tb_daemon + ;; + status) + snapshot=$(tb_public_snapshot) + jq -e --arg owner "$(tb_owner)" '.owner==$owner' <<< "$snapshot" >/dev/null + printf '%s\n' "$snapshot" + ;; + once) + snapshot=$(tb_public_snapshot) + id=$(jq -c --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .identity' <<< "$snapshot") + exec 9> "$TB_TEST_DIR/root.lock" + flock -x 9 + tb_approve "$id" false > "$TB_TEST_DIR/once.json" + jq -e --arg uid "$TB_TEST_OTHER" '.devices[] | select(.identity.Uid==$uid) | .status=="authorized" and (.trusted|not)' "$TB_TEST_DIR/once.json" >/dev/null + ;; + *) exit 64 ;; +esac diff --git a/test/shell.d/fixtures/thunderbolt/bolt-integration.py b/test/shell.d/fixtures/thunderbolt/bolt-integration.py new file mode 100644 index 00000000..77a111bc --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-integration.py @@ -0,0 +1,185 @@ +"""Only the hardware fixture is Python: upstream Bolt uses UMockdev/GObject. + +All Omarchy behavior and assertions run in bolt-exercise.sh against real boltd, +real busctl, a private system bus, and disposable sysfs/firmware/store state. +""" +import importlib.machinery +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import time +import unittest + +root = Path(sys.argv.pop(1)) +loader = importlib.machinery.SourceFileLoader('bolt_upstream', str(Path(os.environ['BOLT_TEST_SOURCE']) / 'tests/test-integration')) +spec = importlib.util.spec_from_loader(loader.name, loader) +u = importlib.util.module_from_spec(spec) +loader.exec_module(u) + + +class ApprovalIntegration(u.BoltTest): + def exercise(self, security, numeric=False): + self.user_config(AuthMode='disabled', DefaultPolicy='manual') + _, host = self.add_domain_host(security=security, iommu='1') + path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0') + _, other = self.add_device(host, 2, 'Unknown', 'Example', authorized=0, key=None, boot='0') + if numeric: + # Linux omits these files when the peripheral has no descriptive names. + for attribute in ('device_name', 'vendor_name'): + Path(self.testbed.get_root_dir(), path.lstrip('/'), attribute).unlink() + self.daemon_start() + self.polkitd_start() + self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage']) + env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(), + 'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other, + 'TB_TEST_KEYS': str(Path(self.dbpath, 'keys')), 'TB_TEST_SECURITY': security} + command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh')] + subprocess.run(command + ['approve'], env=env, check=True) + self.daemon_stop() + self.testbed.set_attribute(path, 'authorized', '0') + if security == 'secure': + self.testbed.set_attribute(path, 'key', '\n') + self.daemon_start() + subprocess.run(command + ['restart'], env=env, check=True) + # The real Bash daemon publishes a fresh readable snapshot and reconnects + # to Bolt after a restart. No custom Omarchy D-Bus server is involved. + child = subprocess.Popen(command + ['daemon'], env=env) + try: + for _ in range(100): + result = subprocess.run(command + ['status'], env=env, capture_output=True) + if result.returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError('Bash daemon did not publish a valid snapshot') + subprocess.run(command + ['once'], env=env, check=True) + self.daemon_stop() + self.daemon_start() + for _ in range(100): + if subprocess.run(command + ['status'], env=env, capture_output=True).returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError('Bash daemon did not publish the new Bolt generation') + finally: + child.terminate() + child.wait(timeout=15) + self.daemon_stop() + + def test_user_approval(self): + self.exercise('user') + + def test_secure_approval(self): + self.exercise('secure') + + def test_numeric_identity(self): + self.exercise('user', numeric=True) + + def removal(self, security, boot=False, disconnected=False): + self.user_config(AuthMode='disabled') + _, host = self.add_domain_host(security=security, iommu='1') + path, uid = self.add_device(host, 1, 'Dock', 'Example', authorized=0, key='' if security == 'secure' else None, boot='0') + other_path, other = self.add_device(host, 2, 'Existing', 'Example', authorized=0, key=None, boot='0') + self.daemon_start() + self.polkitd_start() + self.polkitd.SetAllowed(['org.freedesktop.bolt.authorize', 'org.freedesktop.bolt.enroll', 'org.freedesktop.bolt.manage']) + env = os.environ | {'ROOT': str(root), 'UMOCKDEV_DIR': self.testbed.get_root_dir(), + 'TB_TEST_DIR': self.rundir, 'TB_TEST_UID': uid, 'TB_TEST_OTHER': other, + 'TB_TEST_STORE': self.dbpath, 'TB_TEST_SECURITY': security} + command = ['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-removal.sh')] + service = 'omarchy-thunderbolt-authorization.service' + child = None + + def operation(action, unit): + nonlocal child + if unit == 'bolt.service': + if action == 'stop': + self.daemon_stop() + elif self.daemon is None: + self.daemon_start() + elif unit == service: + if action == 'stop' and child is not None: + child.terminate() + child.wait(timeout=15) + child = None + elif action == 'start' and child is None: + child = subprocess.Popen(['bash', str(root / 'test/shell.d/fixtures/thunderbolt/bolt-exercise.sh'), 'daemon'], env=env) + else: + raise RuntimeError('Unexpected private service: ' + unit) + marker = Path(self.rundir, unit + '.active') + if action == 'start': + marker.touch() + else: + marker.unlink(missing_ok=True) + + def run(action): + process = subprocess.Popen(command + [action], env=env) + control = Path(self.rundir, 'control') + deadline = time.monotonic() + 90 + try: + while process.poll() is None: + if time.monotonic() > deadline: + raise RuntimeError('Private setup timed out: ' + action) + if control.exists(): + operation(*control.read_text().strip().split()) + control.unlink() + time.sleep(0.05) + if process.returncode: + raise subprocess.CalledProcessError(process.returncode, command + [action]) + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=15) + + def wait_for(action): + for _ in range(50): + result = subprocess.run(command + [action], env=env, capture_output=True, text=True) + if result.returncode == 0: + print(result.stdout, end='', flush=True) + return + time.sleep(0.1) + raise RuntimeError(action + ': ' + result.stdout + result.stderr) + + def unplug(): + for device_path in (path, other_path): + self.testbed.uevent(device_path, 'remove') + self.testbed.remove_device(device_path) + wait_for('disconnected') + + try: + Path(self.rundir, 'bolt.service.active').touch() + run('approve') + operation('start', service) + Path(self.rundir, service + '.enabled').touch() + if boot: + run('boot-enable') + run('rollback') + if disconnected: + unplug() + run('disable') + if not disconnected: + unplug() + for number, device_uid, name in ((1, uid, 'Dock'), (2, other, 'Existing')): + attributes = ['device_name', name, 'device', '0x23', 'vendor_name', 'Example', + 'vendor', '0x23', 'authorized', '0', 'unique_id', device_uid, 'boot', '0'] + if number == 1 and security == 'secure': + attributes += ['key', '\n'] + self.testbed.add_device('thunderbolt', '0-' + str(number), host, attributes, ['DEVTYPE', 'thunderbolt_device']) + wait_for('reconnected') + finally: + operation('stop', service) + self.daemon_stop() + + def test_user_removal(self): + self.removal('user') + + def test_secure_removal(self): + self.removal('secure', boot=True, disconnected=True) + + +names = sys.argv[1:] or ('test_user_approval', 'test_secure_approval', 'test_numeric_identity', 'test_user_removal', 'test_secure_removal') +suite = unittest.TestSuite(ApprovalIntegration(name) for name in names) +result = unittest.TextTestRunner(verbosity=2).run(suite) +sys.exit(not result.wasSuccessful()) diff --git a/test/shell.d/fixtures/thunderbolt/bolt-removal.sh b/test/shell.d/fixtures/thunderbolt/bolt-removal.sh new file mode 100644 index 00000000..e4db4975 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/bolt-removal.sh @@ -0,0 +1,95 @@ +#!/bin/bash + +# Real Bolt policy/approval/setup calls. Only systemd is bridged to the private +# daemon processes owned by bolt-integration.py; never contact host systemd. +set -euo pipefail +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +TB_STATE="$TB_TEST_DIR/policy.json" TB_CONFIG="$TB_TEST_STORE/boltd.conf" +TB_MARKER="$TB_TEST_DIR/enabled" TB_PENDING="$TB_TEST_DIR/pending" +TB_RUNTIME="$TB_TEST_DIR/runtime" TB_LOCK="$TB_TEST_DIR/root.lock" +mkdir -p "$TB_RUNTIME" + +service_request() { + printf '%s\n' "$*" > "$TB_TEST_DIR/control.tmp" + mv "$TB_TEST_DIR/control.tmp" "$TB_TEST_DIR/control" + for (( attempt=0; attempt<300; attempt++ )); do + [[ -f $TB_TEST_DIR/control ]] || return 0 + sleep 0.05 + done + echo "Private service operation timed out: $*" >&2 + return 1 +} +systemctl() { + local action=$1 unit + shift + printf '%s\n' "$action $*" >> "$TB_TEST_DIR/services.log" + case "$action" in + show) + unit=${!#} + if [[ -f $TB_TEST_DIR/$unit.active ]]; then echo ActiveState=active; else echo ActiveState=inactive; fi + if [[ -f $TB_TEST_DIR/$unit.enabled ]]; then echo UnitFileState=enabled; else echo UnitFileState=disabled; fi ;; + start|stop) + for unit in "$@"; do service_request "$action" "$unit" || return 1; done ;; + enable) + unit=${!#} + touch "$TB_TEST_DIR/$unit.enabled" + if [[ $1 == "--now" ]]; then service_request start "$unit"; fi ;; + disable) + if [[ -f $TB_TEST_DIR/fail-disable ]]; then rm "$TB_TEST_DIR/fail-disable"; return 1; fi + rm -f -- "$TB_TEST_DIR/$1.enabled" ;; + daemon-reload) ;; + *) echo "Unexpected systemctl action: $action $*" >&2; return 1 ;; + esac +} +check_inventory() { tb_inventory | jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" "$1" >/dev/null; } +case "$1" in + approve) + jq -cn '{version:1,enabled:true,trusted:{},original_authmode:"enabled"}' > "$TB_STATE" + echo removal-test > "$TB_RUNTIME/generation" + touch "$TB_MARKER" + identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_UID" '.devices[]|select(.identity.Uid==$uid)|.identity') + tb_approve "$identity" true true >/dev/null + # An existing manual record, even explicitly trusted through Omarchy, + # must keep the policy its administrator chose before enrollment. + inventory=$(tb_inventory) + owner=$(jq -r .owner <<< "$inventory") + path=$(jq -r --arg uid "$TB_TEST_OTHER" '.devices[]|select(.Uid==$uid)|.path' <<< "$inventory") + tb_bus call "$owner" "$path" "$TB_DEVICE" Authorize s '' >/dev/null + tb_bus call "$owner" "$TB_PATH" "$TB_MANAGER" EnrollDevice sss "$TB_TEST_OTHER" manual '' >/dev/null + identity=$(tb_snapshot | jq -c --arg uid "$TB_TEST_OTHER" '.devices[]|select(.identity.Uid==$uid)|.identity') + tb_approve "$identity" true true >/dev/null + jq -e --arg uid "$TB_TEST_UID" --arg other "$TB_TEST_OTHER" \ + '.enrolled[$uid].StoreTime>0 and .enrolled[$other]==null and .trusted[$other]!=null' "$TB_STATE" >/dev/null + if [[ $TB_TEST_SECURITY == "secure" ]]; then + sha256sum "$TB_TEST_STORE/keys/$TB_TEST_UID" > "$TB_TEST_DIR/key-checksum" + fi ;; + boot-enable) tb_admin boot-enable ;; + rollback) + cp "$TB_STATE" "$TB_TEST_DIR/old-policy.json" + tb_inventory | tb_boot_saved > "$TB_TEST_DIR/old-firmware.json" + touch "$TB_TEST_DIR/fail-disable" + if tb_admin disable; then echo 'Failed removal unexpectedly succeeded' >&2; exit 1; fi + cmp "$TB_STATE" "$TB_TEST_DIR/old-policy.json" + cmp <(tb_inventory | tb_boot_saved | jq -cS .) <(jq -cS . "$TB_TEST_DIR/old-firmware.json") + [[ -f $TB_MARKER && -f $TB_TEST_DIR/$TB_SERVICE.active && -f $TB_TEST_DIR/$TB_SERVICE.enabled && ! -f $TB_TEST_DIR/setup-recovery.json ]] + check_inventory '.manager.AuthMode=="disabled"' + echo 'Removal failure restored policy, firmware ACLs and approval service' ;; + disable) + tb_admin disable + [[ ! -f $TB_MARKER && ! -f $TB_TEST_DIR/$TB_SERVICE.active && ! -f $TB_TEST_DIR/$TB_SERVICE.enabled ]] + jq -e '.enabled==false and .enrolled==null' "$TB_STATE" >/dev/null + check_inventory '.manager.AuthMode=="enabled" and any(.stored[]; .Uid==$uid and .Policy=="auto") and any(.stored[]; .Uid==$other and .Policy=="manual")' + if [[ $TB_TEST_SECURITY == "secure" ]]; then sha256sum --status -c "$TB_TEST_DIR/key-checksum"; fi + echo 'Removal handed our record back to Bolt and preserved existing manual policy' ;; + disconnected) + check_inventory 'any(.stored[]; .Uid==$uid and .Status=="disconnected") and any(.stored[]; .Uid==$other and .Status=="disconnected")' ;; + reconnected) + check_inventory 'any(.devices[]; .Uid==$uid and .Policy=="auto" and (.Status|startswith("authorized"))) and any(.devices[]; .Uid==$other and .Policy=="manual" and .Status=="connected")' + if [[ $TB_TEST_SECURITY == "secure" ]]; then + check_inventory 'any(.devices[]; .Uid==$uid and .Key=="have" and (.AuthFlags|contains("secure")))' + sha256sum --status -c "$TB_TEST_DIR/key-checksum" + fi + echo "Automatic reconnect succeeded in $TB_TEST_SECURITY mode; pre-existing manual device stayed blocked" ;; + *) exit 64 ;; +esac diff --git a/test/shell.d/fixtures/thunderbolt/policy-test.sh b/test/shell.d/fixtures/thunderbolt/policy-test.sh new file mode 100644 index 00000000..de41c622 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/policy-test.sh @@ -0,0 +1,640 @@ +#!/bin/bash + +# These behavior cases were first run as the existing Python assertions against +# the Bash implementation. Fixtures replace only Bolt, systemd and desktop I/O. +set -euo pipefail +source "$ROOT/test/shell.d/base-test.sh" +source "$ROOT/install/helpers/thunderbolt-policy.sh" +source "$ROOT/install/helpers/thunderbolt-setup.sh" +source "$ROOT/install/helpers/thunderbolt-review.sh" + +fixture() { + T=$(mktemp -d) + trap 'rm -rf -- "$T"' EXIT + export HOME="$T/home" + TB_STATE="$T/policy.json" TB_CONFIG="$T/boltd.conf" TB_MARKER="$T/enabled" + TB_PENDING="$T/pending" TB_SYSFS="$T/sysfs" TB_KEYS="$T/keys" + TB_RUNTIME="$T/runtime" TB_LOCK="$T/root.lock" + mkdir -p "$TB_RUNTIME" "$HOME" "$TB_SYSFS" "$TB_KEYS" + echo generation-a > "$TB_RUNTIME/generation" + echo '{"version":1,"enabled":true,"trusted":{},"original_authmode":"enabled"}' > "$TB_STATE" + cat > "$T/inventory" <<'JSON' +{"owner":":1.5","manager":{"AuthMode":"disabled"},"domains":[{"Uid":"host","SecurityLevel":"user","IOMMU":true,"SysfsPath":"/sys/domain","path":"/domain","BootACL":["device-1",""]}],"devices":[{"Uid":"device-1","Name":"Dock","Vendor":"Vendor","Parent":"host","SysfsPath":"/sys/devices/test/0-1","ConnectTime":1,"Status":"connected","Stored":false,"Policy":"default","AuthFlags":"","path":"/devices/1","inode":"1"}]} +JSON + : > "$T/operations"; : > "$T/units"; : > "$T/notifications" + echo '{}' > "$T/capture" + tb_requests +} + +edit_inventory() { jq "$@" "$T/inventory" > "$T/next"; mv "$T/next" "$T/inventory"; } +edit_state() { jq "$@" "$TB_STATE" | tb_json_write "$TB_STATE"; } +check() { jq -e "$1" "$2" >/dev/null || fail "$1" "$(cat "$2")"; } +reject() { if ("$@") > "$T/output" 2> "$T/error"; then fail "unexpected success: $*"; fi; } +identity() { tb_snapshot | jq -c '.devices[0].identity'; } + +tb_inventory() { + [[ ! -f $T/inventory-failure ]] || { tb_fail 'inventory unavailable'; return 1; } + jq '. + {stored:[.devices[] | select(.Stored)]}' "$T/inventory" +} +tb_properties() { jq -c --arg path "$2" '(.devices + .domains)[] | select(.path==$path)' "$T/inventory"; } +tb_bus() { + local command=$1 path=$3 member=$5 value + printf '%s\n' "$member" >> "$T/operations" + case "$member" in + Authorize) + [[ ! -f $T/authorize-failure ]] || return 1 + if [[ ! -f $T/authorize-noop ]]; then edit_inventory --arg path "$path" '(.devices[] | select(.path==$path)).Status="authorized"'; fi ;; + EnrollDevice) + if [[ ! -f $T/enroll-noop ]]; then + edit_inventory '(.devices[0] | .Stored=true | .Policy="manual" | .StoreTime=100) as $d | .devices[0]=$d' + if [[ -f $TB_SYSFS/0-1/key && $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ && ! -f $T/key-store-failure ]]; then + edit_inventory '.devices[0].Key="have"' + fi + fi + if [[ -f $T/enroll-lost-reply ]]; then rm "$T/enroll-lost-reply"; return 1; fi ;; + Policy|BootACL) + if [[ $member == "BootACL" && -f $T/boot-failure ]] || [[ $member == "Policy" && ${7:-} == "auto" && -f $T/restore-failure ]]; then + tb_fail 'firmware rejected write'; return 1 + fi + [[ $member != "Policy" || ! -f $T/policy-noop ]] || return 0 + if [[ $member == "BootACL" ]]; then + shift 7 + value=$(jq -cn '$ARGS.positional' --args "$@") + else + value=$(jq -cn --arg value "$7" '$value') + fi + edit_inventory --arg path "$path" --arg key "$member" --argjson value "$value" \ + '((.devices[],.domains[]) | select(.path==$path))[$key]=$value' + if [[ $member == "Policy" ]]; then + # Real Bolt changes firmware ACLs when a stored policy changes. + edit_inventory --arg path "$path" --arg value "$7" ' + (.devices[] | select(.path==$path) | .Uid) as $uid | + .domains[].BootACL |= (if $value=="auto" and (index($uid)==null) then + (index("") // 0) as $slot | .[$slot]=$uid + elif $value!="auto" then map(if .==$uid then "" else . end) else . end)' + fi ;; + *) tb_fail "Unexpected Bolt call: $*"; return 1 ;; + esac +} +tb_capture() { [[ ! -f $T/capture-failure ]] && cat "$T/capture"; } +tb_unit_state() { jq -cn --arg unit "$1" '{active:($unit=="bolt.service"),enabled:false}'; } +systemctl() { + if [[ $1 == --root=* ]]; then command systemctl "$@"; return; fi + printf '%s\n' "$*" >> "$T/units" + if [[ -f $T/unit-failure && $* == "$(cat "$T/unit-failure")" ]]; then + [[ -f $T/repeat-failure ]] || rm "$T/unit-failure" + tb_fail 'systemd failed'; return 1 + fi + if [[ $* == "start bolt.service" && -f $TB_CONFIG ]]; then + edit_inventory --arg mode "$(tb_config_authmode "$TB_CONFIG")" '.manager.AuthMode=$mode' + if [[ ! -f $TB_MARKER && -f $T/restart-policy-failure ]]; then + rm "$T/restart-policy-failure" + edit_inventory '.devices[0].Policy="manual"' + fi + fi +} +tb_public_snapshot() { cat "$T/public"; } +tb_notify() { + echo "$1" >> "$T/notifications" + if [[ -f $T/notify-failure ]]; then rm "$T/notify-failure"; return 1; fi +} +tb_attention() { echo attention >> "$T/notifications"; } +gum() { if [[ $1 == "choose" ]]; then cat "$T/choice"; fi; } +tb_request_apply() { + jq --argjson identity "$2" --argjson permanent "$1" \ + '(.devices[] | select(.identity==$identity)) |= (.status="authorized" | .trusted=$permanent | .stored=$permanent)' "$T/public" > "$T/next" + mv "$T/next" "$T/public" + [[ ! -f $T/lost-reply ]] || { tb_fail 'reply lost'; return 1; } + cat "$T/public" +} +request() { local paths=("$TB_REQUESTS"/request-*.json); printf '%s\n' "${paths[0]}"; } +request_count() { local paths; shopt -s nullglob; paths=("$TB_REQUESTS"/request-*.json); echo "${#paths[@]}"; } +public_fixture() { tb_snapshot > "$T/public"; } +boot_fixture() { edit_inventory '.devices[0] |= (.Stored=true | .Policy="auto")'; } +review_tick() { + local path watch + path=$(request); watch=$(jq -r .watch "$path") + tb_scan "$watch" >/dev/null +} + +test_iommu_is_not_consent() { + edit_inventory '.devices[0] |= (.Stored=true | .Policy="iommu")' + tb_reconcile > "$T/result" + [[ ! -s $T/operations ]]; check '.devices[0].trusted==false' "$T/result" +} +test_once_is_not_saved() { + tb_approve "$(identity)" false > "$T/result" + check '.warnings==[]' "$T/result"; check '.trusted=={}' "$TB_STATE" + edit_inventory '.devices[0].Status="connected"' + tb_reconcile >/dev/null + [[ $(cat "$T/operations") == "Authorize" ]] +} +test_always_survives_restart() { + tb_approve "$(identity)" true >/dev/null + edit_inventory '.owner=":1.9" | .devices[0] |= (.Status="connected" | .inode="2")' + echo generation-b > "$TB_RUNTIME/generation" + tb_reconcile > "$T/result" + check '.devices[0].status=="authorized" and .devices[0].trusted' "$T/result" + check '.manager.AuthMode=="disabled"' "$T/inventory" +} +test_enrollment_lost_reply_retains_ownership() { + touch "$T/enroll-lost-reply" + reject tb_approve "$(identity)" true + check '.trusted=={} and .enrolled["device-1"].StoreTime==null' "$TB_STATE" + tb_approve "$(identity)" true >/dev/null + check '.enrolled["device-1"].StoreTime==100 and .trusted["device-1"]!=null' "$TB_STATE" +} +secure_fixture() { + mkdir -p "$TB_SYSFS/0-1" + echo 11111111-2222-3333-4444-555555555555 > "$TB_SYSFS/0-1/unique_id" + echo 0 > "$TB_SYSFS/0-1/authorized" + : > "$TB_SYSFS/0-1/key" + edit_inventory --arg path "$TB_SYSFS/0-1" --arg inode "$(stat -Lc %i "$TB_SYSFS/0-1")" \ + '.domains[0].SecurityLevel="secure" | .devices[0] |= (.Secure=true | .Key="missing" | + .Uid="11111111-2222-3333-4444-555555555555" | .SysfsPath=$path | .inode=$inode)' +} +test_secure_approval_initializes_before_trust() { + secure_fixture + tb_approve "$(identity)" true true > "$T/result" + [[ $(cat "$TB_SYSFS/0-1/key") =~ ^[0-9a-f]{64}$ ]] + check '.devices[0].trusted and .devices[0].stored' "$T/result" + cp "$TB_SYSFS/0-1/key" "$T/original-key" + tb_approve "$(identity)" true true >/dev/null + cmp "$TB_SYSFS/0-1/key" "$T/original-key" +} +test_secure_automatic_restore_cannot_initialize_keys() { + secure_fixture + jq '.devices[0] | {(.Uid):{Uid,Name,Vendor}}' "$T/inventory" > "$T/trusted" + edit_state --slurpfile trusted "$T/trusted" '.trusted=$trusted[0]' + tb_reconcile > "$T/result" + check '.devices[0].trusted==false' "$T/result" + [[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]] + reject tb_approve "$(identity)" true + [[ ! -s $T/operations ]] +} +test_secure_missing_saved_key_cannot_claim_success() { + secure_fixture + touch "$T/key-store-failure" + reject tb_approve "$(identity)" true true + check '.trusted=={}' "$TB_STATE" + tb_snapshot > "$T/result"; check '.devices[0].trusted==false' "$T/result" +} +test_secure_key_failure_does_not_authorize() { + secure_fixture + openssl() { return 1; } + reject tb_approve "$(identity)" true true + [[ ! -s $T/operations && ! -s $TB_SYSFS/0-1/key ]] + unset -f openssl + echo invalid > "$TB_SYSFS/0-1/key" + reject tb_approve "$(identity)" true true + [[ ! -s $T/operations ]] +} +test_authorized_keyless_device_requires_explicit_reconnect() { + secure_fixture + edit_inventory '.devices[0].Status="authorized"' + echo 1 > "$TB_SYSFS/0-1/authorized" + reject tb_approve "$(identity)" true true + rg -q 'disconnect, reconnect' "$T/error" + [[ ! -s $TB_SYSFS/0-1/key ]]; check '.trusted=={}' "$TB_STATE" + edit_inventory '.devices[0].Status="connected"' + tb_reconcile >/dev/null + [[ $(cat "$T/operations") == "EnrollDevice" ]] +} +test_stale_identity_rejected() { + local id field + id=$(identity) + for field in inode Name ConnectTime; do + cp "$T/inventory" "$T/original" + edit_inventory --arg field "$field" '.devices[0][$field]="replacement"' + reject tb_approve "$id" true + mv "$T/original" "$T/inventory" + done + edit_inventory '.owner=":1.9"'; reject tb_approve "$id" true + [[ ! -s $T/operations ]] +} +test_false_success_rejected() { + local id; id=$(identity) + touch "$T/authorize-noop"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/authorize-noop"; touch "$T/enroll-noop" + reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/enroll-noop"; tb_approve "$id" true > "$T/result" + check '.devices[0].trusted' "$T/result" + [[ $(rg -c '^Authorize$' "$T/operations") == 2 ]] +} +test_write_failure_retains_retry() { + local id; id=$(identity) + eval "$(declare -f tb_json_write | sed '1s/tb_json_write/disk_write/')" + tb_json_write() { [[ ! -f $T/disk-full ]] && disk_write "$@"; } + touch "$T/disk-full"; reject tb_approve "$id" true; check '.trusted=={}' "$TB_STATE" + rm "$T/disk-full"; tb_approve "$id" true > "$T/result" + check '.devices[0].trusted' "$T/result" + [[ $(rg -c '^Authorize$' "$T/operations") == 1 ]] +} +test_firmware_bypass_warns() { + edit_inventory '.domains[0].SecurityLevel="none" | .devices[0] |= (.Status="authorized" | .AuthFlags="boot")' + tb_snapshot > "$T/result"; check '.warnings|length==2' "$T/result"; [[ ! -s $T/operations ]] +} +test_global_enable_not_protected() { + edit_inventory '.manager.AuthMode="enabled"' + reject tb_snapshot; rg -q 'not active' "$T/error" + reject tb_reconcile +} +test_display_only_needs_no_approval() { + edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}' + edit_inventory '.devices[0].AuthFlags="nopcie"' + tb_reconcile > "$T/result"; check '.error==""' "$T/result"; [[ ! -s $T/operations ]] +} +test_empty_capture_preserved() { + rm "$TB_STATE"; tb_prepare + touch "$T/capture-failure"; tb_prepare + check '.trusted=={}' "$TB_STATE"; [[ $(tb_config_authmode "$TB_CONFIG") == "disabled" ]] +} +test_capture_failure_does_not_publish() { + rm "$TB_STATE"; touch "$T/capture-failure"; reject tb_prepare + [[ ! -e $TB_STATE && ! -e $TB_MARKER ]] +} +test_capture_optional_names_match_numeric_identity() { + source "$ROOT/install/helpers/thunderbolt-setup.sh" + mkdir -p "$TB_SYSFS/0-1" + echo device-1 > "$TB_SYSFS/0-1/unique_id" + echo 0x1234 > "$TB_SYSFS/0-1/device" + echo 0xab > "$TB_SYSFS/0-1/vendor" + tb_capture > "$T/result" + check '.["device-1"]=={Uid:"device-1",Name:"0x1234",Vendor:"0xab"}' "$T/result" + echo Dock > "$TB_SYSFS/0-1/device_name" + tb_capture > "$T/result"; check '.["device-1"].Name=="Dock" and .["device-1"].Vendor=="0xab"' "$T/result" + rm "$TB_SYSFS/0-1/device_name"; echo Vendor > "$TB_SYSFS/0-1/vendor_name" + tb_capture > "$T/result"; check '.["device-1"].Name=="0x1234" and .["device-1"].Vendor=="Vendor"' "$T/result" + mkdir "$TB_SYSFS/0-1/device_name" + reject tb_capture +} +test_first_activation_defers_without_touching_bolt() { + secure_fixture; rm "$TB_STATE" + printf '[config]\nAuthMode=enabled\n' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original" + tb_admin owner + [[ -f $TB_PENDING && ! -f $TB_STATE && ! -f $TB_MARKER && ! -s $T/units ]] + cmp "$TB_CONFIG" "$T/original" + rm -rf "$TB_SYSFS/0-1" + tb_admin migrate + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + tb_admin enable + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_saved_secure_binding_allows_initial_capture() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0] |= (.Stored=true | .Key="have")' + tb_admin owner + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_key_without_record_defers_initial_capture() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0].Key="have"' + tb_admin owner + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] +} +test_offline_prepare_cannot_trust_installers_bolt() { + secure_fixture; rm "$TB_STATE" + edit_inventory '.devices[0] |= (.Stored=true | .Key="have")' + tb_admin prepare + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] +} +test_deferred_opt_out_retains_initial_enrollment() { + secure_fixture; rm "$TB_STATE" + tb_admin owner + tb_admin disable + [[ ! -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + check '.enabled==false and .enrollment_pending==true' "$TB_STATE" + tb_admin migrate + [[ ! -f $TB_PENDING && ! -s $T/units ]] + tb_admin enable + [[ -f $TB_PENDING && ! -f $TB_MARKER && ! -s $T/units ]] + rm -rf "$TB_SYSFS/0-1" + echo '{"new-device":{}}' > "$T/capture" + tb_admin enable + check '.enabled and .enrollment_pending==null and .trusted=={"new-device":{}}' "$TB_STATE" + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_pending_notice_retries_without_claiming_protection() { + touch "$TB_PENDING" + local count=0 notices=0 + sleep() { count=$((count + 1)); (( count < 3 )) || exit 99; } + tb_setup_notice() { notices=$((notices + 1)); (( notices > 1 )) && echo pending >> "$T/notifications"; } + reject tb_watch + [[ $(cat "$T/notifications") == "pending" ]] + tb_status > "$T/result"; rg -q 'protection is deferred' "$T/result" +} +test_active_policy_is_not_deferred() { + secure_fixture; touch "$TB_MARKER" + tb_admin owner + [[ ! -f $TB_PENDING && -f $TB_MARKER ]] +} +test_pending_only_factory_reset_reaches_both_roots() { + local function_name root="$T/top/@factory" + local TOP_MNT="$T/top" NEXT_NAME=@next PROVISIONING_DIR=/var/lib/omarchy/provisioning LOG_FILE="$T/reset.log" swap_done=0 + mkdir -p "$root/etc/omarchy" "$root/etc/systemd/system" "$root/usr/bin" \ + "$root/usr/share/omarchy/install/provisioning" "$T/top/@" + touch "$root/etc/omarchy/thunderbolt-authorization.pending" "$root/usr/bin/omarchy-provision-owner" + chmod +x "$root/usr/bin/omarchy-provision-owner" + echo 'root:x:0:0:root:/root:/bin/bash' > "$root/etc/passwd" + cp "$ROOT/install/provisioning/omarchy-provision-owner.service" \ + "$ROOT/install/provisioning/omarchy-system-factory-reset-finish.service" "$root/usr/share/omarchy/install/provisioning/" + cp "$ROOT/etc/systemd/system/omarchy-thunderbolt-authorization.service" "$root/etc/systemd/system/" + for function_name in install_provisioning_units scrub_factory_accounts sanitize_factory_baseline stage_full_reset; do + eval "$(sed -n "/^$function_name() {$/,/^}/p" "$ROOT/bin/omarchy-system-factory-reset")" + done + log() { :; } + btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then cp -a "$3" "$4" + elif [[ $1 == "property" ]]; then : + else return 1; fi + } + usermod() { [[ $1 == "--root" && $2 == "$TOP_MNT/"* && $3 == "--password" && $4 == "!" && $5 == "root" ]]; } + systemd-id128() { echo new-machine-id; } + encrypted_install() { return 1; } + rebuild_next_boot() { :; } + sync() { :; } + systemctl() { + [[ ( $1 == "--root=$TOP_MNT/@next" || $1 == "--root=$TOP_MNT/@factory" ) && $2 == "disable" && $3 == "$TB_SERVICE" ]] || return 1 + command systemctl "$@" + } + function /usr/bin/omarchy-thunderbolt-authorization-admin() { + [[ $1 == "reset-root" ]] || return 1 + printf '%s\n' "$2" >> "$T/reset-roots" + ( + source "$ROOT/install/helpers/thunderbolt-policy.sh" + source "$ROOT/install/helpers/thunderbolt-setup.sh" + tb_reset_root "$2" + ) + } + stage_full_reset + [[ $(wc -l < "$T/reset-roots") == 2 && $swap_done == 1 ]] + [[ ! -f $TOP_MNT/@/etc/omarchy/thunderbolt-authorization.pending && + ! -f $TOP_MNT/@factory/etc/omarchy/thunderbolt-authorization.pending ]] + [[ -f $TOP_MNT/@/var/lib/omarchy/provisioning/pending ]] +} +test_config_preserves_settings() { + rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG" + tb_prepare; rg -q '^DefaultPolicy=manual$' "$TB_CONFIG" + check '.original_authmode=="enabled"' "$TB_STATE" +} +test_fresh_owner_replaces_trust() { + rm "$TB_STATE"; echo '{"old":{}}' > "$T/capture"; tb_prepare + echo '{"new":{}}' > "$T/capture"; tb_prepare true + check '.trusted=={"new":{}} and .original_authmode=="enabled"' "$TB_STATE" +} +test_enable_rollback() { + rm "$TB_STATE"; printf '[config]\nAuthMode=enabled\nDefaultPolicy=manual\n' > "$TB_CONFIG" + cp "$TB_CONFIG" "$T/original"; echo 'start bolt.service' > "$T/unit-failure" + reject tb_transaction tb_enable + cmp "$TB_CONFIG" "$T/original"; [[ ! -e $TB_STATE && ! -e $TB_MARKER && ! -e $T/setup-recovery.json ]] + [[ $(tail -2 "$T/units") == $'start bolt.service\nstop omarchy-thunderbolt-authorization.service' ]] +} +test_failed_restore_keeps_checkpoint() { + rm "$TB_STATE"; echo '[config]' > "$TB_CONFIG"; cp "$TB_CONFIG" "$T/original" + echo 'stop omarchy-thunderbolt-authorization.service bolt.service' > "$T/unit-failure"; touch "$T/repeat-failure" + reject tb_transaction tb_enable; rg -q 'restoration is incomplete' "$T/error" + [[ -f $T/setup-recovery.json ]]; reject tb_transaction tb_enable; rg -q 'needs recovery' "$T/error" + echo partial > "$TB_CONFIG"; rm "$T/unit-failure" + tb_config_restore; cmp "$TB_CONFIG" "$T/original" +} +test_disable_restores_matching_boot_state() { + boot_fixture; tb_prepare; tb_boot_change true; cp "$TB_STATE" "$T/original" + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + reject tb_transaction tb_disable + cmp "$TB_STATE" "$T/original"; [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory" +} +test_disable_hands_off_only_our_records() { + tb_prepare + tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices += [(.devices[0] | .Uid="existing" | .path="/devices/2" | .Policy="manual")]' + tb_approve "$(tb_snapshot | jq -c '.devices[1].identity')" true >/dev/null + tb_admin disable + check '.enabled==false and .enrolled==null and (.trusted|length)==2' "$TB_STATE" + check '.manager.AuthMode=="enabled" and .devices[0].Policy=="auto" and .devices[1].Policy=="manual"' "$T/inventory" + [[ ! -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + # After a completed handoff, an administrator may choose manual themselves. + edit_inventory '.devices[0].Policy="manual"' + tb_admin enable + tb_admin disable + check 'all(.devices[]; .Policy=="manual")' "$T/inventory" +} +test_disable_hands_off_disconnected_device() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices[0].Status="disconnected"' + tb_admin disable + check '.devices[0].Policy=="auto"' "$T/inventory" +} +test_disable_preserves_recreated_manual_record() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.devices[0].StoreTime=200' + tb_admin disable + check '.devices[0].Policy=="manual"' "$T/inventory" +} +test_disable_preserves_original_disabled_mode() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_state '.original_authmode="disabled"' + tb_admin disable + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="auto"' "$T/inventory" +} +test_disable_handoff_after_boot_protection() { + tb_prepare; tb_boot_change true + tb_approve "$(identity)" true >/dev/null + tb_admin disable + check '.enabled==false and .boot_protection==false' "$TB_STATE" + check '.devices[0].Policy=="auto" and (.domains[0].BootACL|index("device-1"))!=null' "$T/inventory" +} +test_disable_handoff_rolls_back_policies_and_acl() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.domains[0].BootACL=["unrelated",""]' + cp "$TB_STATE" "$T/original" + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + reject tb_admin disable + cmp "$TB_STATE" "$T/original" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["unrelated",""]' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_failure_keeps_protection() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + tb_unit_state() { echo '{"active":true,"enabled":true}'; } + touch "$T/restore-failure" + reject tb_admin disable + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] + ! rg -q '^disable ' "$T/units" + [[ $(tail -1 "$T/units") == "start omarchy-thunderbolt-authorization.service" ]] +} +test_disable_handoff_verifies_policy_writes() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + touch "$T/policy-noop" + reject tb_admin disable + rg -q 'did not retain the requested Policy' "$T/error" + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_verifies_policy_after_restart() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + touch "$T/restart-policy-failure" + reject tb_admin disable + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual"' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_handoff_incomplete_rollback_is_recoverable() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + echo 'disable omarchy-thunderbolt-authorization.service' > "$T/unit-failure" + touch "$T/boot-failure" + reject tb_admin disable + rg -q 'restoration is incomplete' "$T/error" + [[ -f $T/setup-recovery.json ]] + rm "$T/boot-failure" + tb_admin recover + check '.enabled and .enrolled["device-1"]!=null' "$TB_STATE" + check '.manager.AuthMode=="disabled" and .devices[0].Policy=="manual" and .domains[0].BootACL==["device-1",""]' "$T/inventory" + [[ -f $TB_MARKER && ! -f $T/setup-recovery.json ]] +} +test_disable_offline_controller_is_unchanged() { + tb_prepare; tb_approve "$(identity)" true >/dev/null + edit_inventory '.domains[0].SysfsPath=""' + reject tb_admin disable + rg -q 'Reconnect every Thunderbolt controller' "$T/error" + check '.enabled' "$TB_STATE" + [[ ! -s $T/units && ! -f $T/setup-recovery.json ]] +} +test_boot_policy_reconnection_and_restore() { + boot_fixture; tb_boot_change true + check '.devices[0].Policy=="manual" and .domains[0].BootACL==["",""]' "$T/inventory" + check '.boot_protection and .trusted["device-1"]!=null' "$TB_STATE" + edit_inventory '.devices[0].Policy="auto" | .domains[0].BootACL=["device-1",""]' + tb_snapshot > "$T/result"; check '.warnings|length>0' "$T/result" + tb_reconcile > "$T/result"; check '.error==""' "$T/result" + check '.domains[0].BootACL==["",""]' "$T/inventory" + tb_boot_change false + check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1",""]' "$T/inventory" +} +test_boot_change_rollback() { + boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]' + cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" + reject tb_boot_change true + check '.devices[0].Policy=="auto" and .domains[0].BootACL==["device-1","other"]' "$T/inventory" + cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]] +} +test_boot_recovery_retained() { + boot_fixture; edit_inventory '.domains[0].BootACL=["device-1","other"]' + cp "$TB_STATE" "$T/original"; touch "$T/boot-failure" "$T/restore-failure" + reject tb_boot_change true; rg -q 'recovery is incomplete' "$T/error"; [[ -f $T/boot-recovery.json ]] + rm "$T/boot-failure" "$T/restore-failure"; tb_boot_recover + cmp <(jq -cS . "$TB_STATE") <(jq -cS . "$T/original"); [[ ! -e $T/boot-recovery.json ]] +} +test_unsupported_boot_cannot_succeed() { + boot_fixture; cp "$T/inventory" "$T/original" + local expression + for expression in '.SysfsPath=""' '.SecurityLevel="none"' '.BootACL=[]'; do + cp "$T/original" "$T/inventory"; edit_inventory ".domains[0] |= ($expression)" + reject tb_boot_change true + done + [[ ! -s $T/operations ]] +} +test_install_defers_owner() { + install() { :; } + function /usr/bin/omarchy-thunderbolt-authorization-admin() { echo "helper $*"; } + export OMARCHY_PATH=$ROOT OMARCHY_INSTALL_USER='' + source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output" + ! rg -q helper "$T/output"; [[ ! -s $T/units ]] + OMARCHY_INSTALL_USER=lab + source "$ROOT/install/config/thunderbolt-authorization.sh" > "$T/output" + rg -q 'helper prepare' "$T/output"; rg -q '^enable omarchy-thunderbolt-authorization.service$' "$T/units" +} +test_offline_reset_scrubs_private_state() { + source "$ROOT/install/helpers/thunderbolt-policy.sh" + local root="$T/clone" unit="$TB_SERVICE" + mkdir -p "$root/etc/systemd/system" "$root${TB_STATE%/*}" "$root${TB_MARKER%/*}" + cp "$ROOT/etc/systemd/system/$unit" "$root/etc/systemd/system/" + command systemctl --root="$root" enable "$unit" >/dev/null 2>&1 + touch "$root$TB_MARKER" "$root$TB_STATE" "$root${TB_STATE%/*}/setup-recovery.json" + local kind + for kind in devices keys domains; do mkdir -p "$root/var/lib/boltd/$kind"; touch "$root/var/lib/boltd/$kind/old"; done + tb_reset_root "$root" + [[ ! -e $root$TB_MARKER && ! -e $root${TB_STATE%/*} && ! -e $root/etc/systemd/system/multi-user.target.wants/$unit ]] + [[ $(ls "$root/var/lib/boltd") == "boltd.conf" && $(tb_config_authmode "$root/var/lib/boltd/boltd.conf") == "enabled" ]] +} +test_live_reset_refused() { reject tb_reset_root /; } +test_trusted_reconnect_no_alert() { + edit_state '.trusted["device-1"]={Uid:"device-1",Name:"Dock",Vendor:"Vendor"}' + public_fixture; tb_scan watch1 >/dev/null; [[ ! -s $T/notifications ]] +} +test_unavailable_warns_once() { + tb_scan() { return 1; } + sleep() { count=$((count+1)); ((count<4)) || exit 99; } + local count=0 + reject tb_watch + [[ $(cat "$T/notifications") == "attention" ]] +} +test_failed_delivery_retries_other_devices() { + edit_inventory '.devices += [(.devices[0] | .Uid="device-2")]'; public_fixture; touch "$T/notify-failure" + tb_scan watch1 >/dev/null; [[ $(request_count) == 1 ]] + tb_scan watch1 >/dev/null; [[ $(request_count) == 2 && $(wc -l < "$T/notifications") == 3 ]] +} +test_restarts_restore_requests() { + public_fixture; tb_scan watch1 >/dev/null; local old; old=$(request) + tb_scan watch1 >/dev/null; [[ $(wc -l < "$T/notifications") == 1 ]] + edit_inventory '.owner=":1.9"'; public_fixture + tb_scan watch1 >/dev/null; [[ $(request) != "$old" ]] + tb_scan watch2 >/dev/null; [[ $(request_count) == 1 && $(wc -l < "$T/notifications") == 3 ]] +} +test_inventory_failure_preserves_request() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + rm "$T/public"; reject tb_scan watch1; [[ -f $path ]] +} +test_replacement_during_dialog_rejected() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + gum() { + if [[ $1 == "choose" ]]; then + jq '.devices[0].identity.Uid="replacement"' "$T/public" > "$T/next"; mv "$T/next" "$T/public" + echo 'Always allow this device' + fi + } + reject tb_review "${path##*/}" # A filename is not a valid token. + reject tb_review "$(basename "$path" .json)" + check '.devices[0].status=="connected"' "$T/public" +} +test_lost_reply_keeps_approval_intent() { + public_fixture; tb_scan watch1 >/dev/null; local path; path=$(request) + echo 'Always allow this device' > "$T/choice"; touch "$T/lost-reply" + sleep() { review_tick; } + reject tb_review "$(basename "$path" .json)" + check '.approval=="Always allow this device"' "$path" + tb_scan watch1 >/dev/null; [[ -f $path && $(wc -l < "$T/notifications") == 2 ]] + tb_scan watch2 >/dev/null; [[ ! -f $path && $(wc -l < "$T/notifications") == 3 ]]; path=$(request) + rm "$T/lost-reply"; gum() { fail 'Saved approval must not ask again'; } + tb_review "$(basename "$path" .json)" >/dev/null + [[ ! -f $path ]] +} +test_disconnect_and_untrusted_text() { + edit_inventory '.devices[0].Name="$(touch /tmp/untrusted)\u001b[2J"' + public_fixture; tb_scan watch1 >/dev/null + [[ $(basename "$(request)") =~ ^request-[0-9a-f]{64}.json$ ]] + [[ $(jq '.devices[0].Name' "$T/inventory" | tb_safe_text) != *$'\e'* ]] + edit_inventory '.devices=[]'; public_fixture; tb_scan watch1 >/dev/null; [[ $(request_count) == 0 ]] +} +test_snapshot_freshness_and_disabled_state() { + # Exercise the real public reader, including freshness after daemon failure. + source "$ROOT/install/helpers/thunderbolt-policy.sh" + TB_RUNTIME="$T/runtime" + jq -cn --argjson now "$(date +%s)" '{available:true,time:$now,generation:"generation-a"}' > "$TB_RUNTIME/snapshot.json" + tb_public_snapshot >/dev/null + jq '.time-=30' "$TB_RUNTIME/snapshot.json" > "$T/next"; mv "$T/next" "$TB_RUNTIME/snapshot.json" + reject tb_public_snapshot + echo generation-b > "$TB_RUNTIME/generation"; reject tb_public_snapshot +} +test_invalid_state_not_skipped_by_migration() { + echo '{"enabled":false}' > "$TB_STATE"; reject tb_admin migrate + [[ ! -s $T/units ]] +} + +for case in $(declare -F | awk '$3 ~ /^test_/ {print $3}'); do + (fixture; "$case") + pass "Thunderbolt ${case#test_}" +done diff --git a/test/shell.d/fixtures/thunderbolt/startup-test.sh b/test/shell.d/fixtures/thunderbolt/startup-test.sh new file mode 100644 index 00000000..4fc71f51 --- /dev/null +++ b/test/shell.d/fixtures/thunderbolt/startup-test.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -euo pipefail +source "$ROOT/test/shell.d/base-test.sh" +fixture=$(mktemp -d) +trap 'rm -rf -- "$fixture"' EXIT +for name in admin approve daemon; do + # Map the security library and stop before service work, including when the + # suite itself runs as root. Startup checks remain the production code. + sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \ + -e 's|source /usr/share/omarchy/install/helpers/thunderbolt-policy.sh|exit 126|' \ + "$ROOT/bin/omarchy-thunderbolt-authorization-$name" > "$fixture/entry" + chmod 755 "$fixture/entry" + printf 'set -p\n' > "$fixture/decoy" + if BASH_ENV="$fixture/decoy" /usr/bin/bash "$fixture/entry" -p; then + fail "$name accepts ordinary Bash with a decoy -p" + fi + printf 'touch "%s"\n' "$fixture/injected" > "$fixture/startup" + # The entrypoint must suppress startup files and exported functions before + # checking EUID or reaching the fixture's service-work boundary. + if BASH_ENV="$fixture/startup" "$fixture/entry" >/dev/null 2>&1; then + fail "$name passed the fixture service-work boundary" + fi + [[ ! -e $fixture/injected ]] || fail "$name executed BASH_ENV" + if /usr/bin/env 'BASH_FUNC_source%%=() { echo injected > "$TB_TEST_INJECTED"; }' \ + TB_TEST_INJECTED="$fixture/injected" "$fixture/entry" >/dev/null 2>&1; then + fail "$name passed the fixture boundary with an exported function" + fi + [[ ! -e $fixture/injected ]] || fail "$name imported an exported function" + pass "Thunderbolt $name rejects unsafe Bash startup" +done diff --git a/test/shell.d/thunderbolt-authorization-test.sh b/test/shell.d/thunderbolt-authorization-test.sh new file mode 100755 index 00000000..c10a84cf --- /dev/null +++ b/test/shell.d/thunderbolt-authorization-test.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +bash "$ROOT/test/shell.d/fixtures/thunderbolt/policy-test.sh" +bash "$ROOT/test/shell.d/fixtures/thunderbolt/startup-test.sh" + +if [[ -n ${BOLT_TEST_SOURCE:-} ]]; then + /usr/bin/python3 "$ROOT/test/shell.d/fixtures/thunderbolt/bolt-integration.py" "$ROOT" +else + skip "real boltd integration needs BOLT_TEST_SOURCE and an UMockdev environment" +fi From c3cdcb1e826a5655f87eeb787a5b28ad6e5c892b Mon Sep 17 00:00:00 2001 From: acrogenesis Date: Tue, 29 Sep 2026 10:16:56 -0600 Subject: [PATCH 20/34] Keep USB approvals portable and preserve approval prompts --- bin/omarchy-remove-security-usb-authorization | 21 +- bin/omarchy-setup-security-usb-authorization | 84 +++++++- bin/omarchy-usb-authorization-approve | 25 +++ bin/omarchy-usb-authorization-review | 12 +- default/polkit/org.omarchy.usb.policy | 14 ++ .../user/omarchy-usb-authorization.service | 1 - etc/polkit-1/rules.d/40-omarchy-usb.rules | 6 + install/config/usb-authorization.sh | 2 + install/helpers/usb-authorization-policy.sh | 95 +++++++++ install/helpers/usb-authorization.sh | 28 ++- manual/48-security.md | 4 + migrations/1790608617.sh | 7 + .../fixtures/usb-authorization/match.cpp | 57 ++++++ test/shell.d/usb-authorization-policy-test.sh | 47 +++++ test/shell.d/usb-authorization-test.sh | 184 ++++++++++++++++-- 15 files changed, 535 insertions(+), 52 deletions(-) create mode 100755 bin/omarchy-usb-authorization-approve create mode 100644 default/polkit/org.omarchy.usb.policy create mode 100644 etc/polkit-1/rules.d/40-omarchy-usb.rules create mode 100644 install/helpers/usb-authorization-policy.sh create mode 100644 migrations/1790608617.sh create mode 100644 test/shell.d/fixtures/usb-authorization/match.cpp create mode 100755 test/shell.d/usb-authorization-policy-test.sh diff --git a/bin/omarchy-remove-security-usb-authorization b/bin/omarchy-remove-security-usb-authorization index ba77a0f0..b3961391 100755 --- a/bin/omarchy-remove-security-usb-authorization +++ b/bin/omarchy-remove-security-usb-authorization @@ -60,19 +60,22 @@ fi # fails, the command stops here and leaves the complete policy enabled. sudo omarchy-usb-authorization-boot disable +if omarchy-pkg-present usbguard; then + # Keep the watcher and its IPC access until blocking has actually stopped. + # In particular, a failed stop must not strand a running daemon without UI. + sudo systemctl disable --now usbguard.service + if systemctl is-active --quiet usbguard.service || systemctl is-enabled --quiet usbguard.service; then + echo "USBGuard is still active or enabled; approval prompts have been preserved." >&2 + exit 1 + fi + sudo omarchy-usb-authorization-restore-default + sudo usbguard remove-user "$USER" +fi + systemctl --user disable --now omarchy-usb-authorization.service 2>/dev/null || true rm -f "$unit" systemctl --user daemon-reload -if omarchy-pkg-present usbguard; then - sudo usbguard remove-user "$USER" 2>/dev/null || true -fi - -if omarchy-pkg-present usbguard; then - sudo systemctl disable --now usbguard.service 2>/dev/null || true - sudo omarchy-usb-authorization-restore-default -fi - if [[ -d $state_dir/requests ]]; then find "$state_dir/requests" -mindepth 1 -maxdepth 1 -type f -delete rmdir "$state_dir/requests" 2>/dev/null || true diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index c059b0fb..ee68641d 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -1,20 +1,22 @@ #!/bin/bash # omarchy:summary=Ask before new USB devices can access the system -# omarchy:args=[--boot] [--yes] +# omarchy:args=[--boot] [--yes] [--review-existing] # omarchy:requires-sudo=true set -euo pipefail assume_yes=0 from_boot=0 +review_existing=0 while (( $# > 0 )); do case "$1" in --yes) assume_yes=1 ;; --boot) from_boot=1 ;; + --review-existing) review_existing=1 ;; -h | --help) - echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes]" + echo "Usage: omarchy-setup-security-usb-authorization [--boot] [--yes] [--review-existing]" echo echo "Trusts USB devices that are connected now, then blocks new devices" echo "until you approve them from an Omarchy notification." @@ -25,6 +27,9 @@ while (( $# > 0 )); do echo "Older snapshots without USBGuard keep all USB disabled after startup," echo "including keyboards, network adapters, and storage. Use --boot only if" echo "disk unlock and recovery work without any USB devices." + echo + echo "--review-existing reviews connected devices with older port-bound approvals." + echo "Each portable approval requires confirmation; existing rules are preserved." exit 0 ;; *) @@ -43,6 +48,58 @@ daemon_config=/etc/usbguard/usbguard-daemon.conf source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" +if (( review_existing )); then + # Older Omarchy rules have no per-rule ownership marker. They cannot safely + # be distinguished from intentionally restricted administrator rules. + (( ! assume_yes && ! from_boot )) || { echo "Review requires individual confirmation; do not combine it with --yes or --boot." >&2; exit 2; } + policy=$(usbguard list-rules) + declare -A reviewed=() + # Keep stdin attached to the terminal for gum (and any sudo prompt). + mapfile -t policy_lines <<<"$policy" + for line in "${policy_lines[@]}"; do + if [[ $line =~ ^[0-9]+:[[:space:]] ]]; then + rule=${line#*: } + [[ $rule == allow\ * ]] || continue + portable=$(usb_authorization_portable_rule "$rule") || continue + [[ ${portable% label *} != "$rule" ]] || continue + devices=$(usbguard list-devices "$rule") + mapfile -t candidates <<<"$devices" + for device in "${candidates[@]}"; do + [[ $device =~ ^([0-9]+):[[:space:]](allow[[:space:]].*)$ ]] || continue + id=${BASH_REMATCH[1]} current=${BASH_REMATCH[2]} + [[ ! -v reviewed[$id] ]] || continue + reviewed[$id]=1 + echo + printf '%s\n' "$current" | LC_ALL=C tr -c '\11\12\15\40-\176' '?' + echo "This device has a location-bound approval. Details are supplied by the device." + if gum confirm "Trust this device on any USB port or hub?"; then + sudo /usr/bin/omarchy-usb-authorization-approve "$id" "$current" + fi + done + fi + done + echo "Review complete. Disconnected devices can be approved when reconnected." + exit 0 +fi + +# The watcher runs in the session's environment, which may differ from a +# command invoked in a checkout. Fail before enabling blocking on mismatched +# or incomplete installations, including dangling dev-link unit symlinks. +session_environment=$(systemctl --user show-environment) +grep -Fqx -- "OMARCHY_PATH=$OMARCHY_PATH" <<<"$session_environment" || { + echo "The session and this command use different Omarchy installations. Update the session before enabling USB protection." >&2 + exit 1 +} +for required in "$unit_source" /usr/share/omarchy/install/helpers/usb-authorization-policy.sh; do + [[ -r $required ]] || { echo "USB authorization installation is incomplete: $required" >&2; exit 1; } +done +for required in "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-event" \ + "$OMARCHY_PATH/bin/omarchy-usb-authorization-review" \ + /usr/bin/omarchy-usb-authorization-approve; do + [[ -r $required && -x $required ]] || { echo "USB authorization command is unavailable: $required" >&2; exit 1; } +done + if (( ! assume_yes )); then if (( from_boot )); then gum style --foreground 212 --bold "USB authorization from boot" @@ -93,19 +150,27 @@ sudo usbguard add-user "$USER" \ --policy=list \ --exceptions=listen +sudo install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy + +# Install a real file: a link into a checkout can become dangling on a branch +# switch. Start the waiting watcher before USBGuard can block new devices. +unit_temporary=$(mktemp "${unit}.XXXXXXXXXX") +install -m644 "$unit_source" "$unit_temporary" +mv -fT "$unit_temporary" "$unit" +systemctl --user daemon-reload +systemctl --user enable omarchy-usb-authorization.service +systemctl --user restart omarchy-usb-authorization.service +systemctl --user is-active --quiet omarchy-usb-authorization.service + sudo systemctl enable usbguard.service # add-user ACL changes are loaded only when the daemon starts, so restart an # existing service as well as starting a fresh one. sudo systemctl restart usbguard.service - -ln -sfn "$unit_source" "$unit" -systemctl --user daemon-reload -systemctl --user enable --now omarchy-usb-authorization.service +systemctl is-active --quiet usbguard.service +systemctl --user is-active --quiet omarchy-usb-authorization.service if (( from_boot )); then - # Take the trust snapshot only after USBGuard is enforcing the policy. Every - # ID is resolved by the daemon, and no device-controlled descriptor text is - # passed to a privileged command. + # The fixed helper independently verifies each full live device snapshot. usb_authorization_trust_present_devices sudo omarchy-usb-authorization-boot enable fi @@ -113,6 +178,7 @@ fi echo echo "USB device authorization is enabled." echo "Devices connected during setup remain trusted; new devices require approval." +echo "To review older port-bound approvals, run: omarchy-setup-security-usb-authorization --review-existing" if (( from_boot )); then echo "Boot-time authorization is enabled and takes effect after reboot." fi diff --git a/bin/omarchy-usb-authorization-approve b/bin/omarchy-usb-authorization-approve new file mode 100755 index 00000000..99ecc640 --- /dev/null +++ b/bin/omarchy-usb-authorization-approve @@ -0,0 +1,25 @@ +#!/bin/bash -p + +# omarchy:hidden=true +# omarchy:summary=Save portable trust for one verified USB device + +# This narrow Polkit boundary accepts a live device snapshot, never a policy or +# file path. Privileged Bash suppresses startup code from the caller's environment. +if [[ $- != *p* ]]; then exit 126; fi +source /usr/bin/omarchy-security-functions || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 +omarchy_security_sanitize_bash_environment "$0" "$@" +set -euo pipefail +(( EUID == 0 )) || exit 126 +export PATH=/usr/bin:/bin LC_ALL=C +unset USBGUARD_DAEMON_CONF USBGUARD_DEBUG +source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh + +(( $# == 2 )) || exit 64 +# Omarchy persists to this file. Refuse custom daemon storage rather than +# mistaking a stale copy of a rule for a successful persistent decision. +rules_file=$(awk -F= '/^[[:space:]]*RuleFile[[:space:]]*=/ { value=$2; gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); print value; exit }' /etc/usbguard/usbguard-daemon.conf) +[[ $rules_file == "/etc/usbguard/rules.conf" ]] || exit 1 +exec 8>/run/omarchy-usb-authorization.lock +flock -x 8 +usb_authorization_permanent "$1" "$2" diff --git a/bin/omarchy-usb-authorization-review b/bin/omarchy-usb-authorization-review index 630e7612..aab25309 100755 --- a/bin/omarchy-usb-authorization-review +++ b/bin/omarchy-usb-authorization-review @@ -98,13 +98,11 @@ case "$choice" in jq --arg approval "$choice" '.approval = $approval' "$request" >"$temporary" mv -f "$temporary" "$request" - if [[ $current_rule == "$expected_rule" ]]; then + if [[ $choice == "Always allow this device" ]]; then + pkexec /usr/bin/omarchy-usb-authorization-approve "$id" "$current_rule" || approval_failed + elif [[ $current_rule == "$expected_rule" ]]; then # Match the complete identity, since numeric IDs can be reassigned. - options=() - if [[ $choice == "Always allow this device" ]]; then - options+=(--permanent) - fi - usbguard allow-device "${options[@]}" "$expected_rule" || approval_failed + usbguard allow-device "$expected_rule" || approval_failed fi # Rule-based USBGuard commands swallow applyDevicePolicy exceptions and can @@ -115,7 +113,7 @@ case "$choice" in if [[ $choice == "Always allow this device" ]]; then # Let USBGuard match policy rules: saved rules can omit the device's port. # --show-devices indents the devices matching each policy rule. - policy=$(usbguard list-rules --show-devices) || approval_failed + policy=$(usbguard list-rules --label omarchy-usb-authorization-v1 --show-devices) || approval_failed policy_allows=0 matched=0 while IFS= read -r line; do diff --git a/default/polkit/org.omarchy.usb.policy b/default/polkit/org.omarchy.usb.policy new file mode 100644 index 00000000..1f76638e --- /dev/null +++ b/default/polkit/org.omarchy.usb.policy @@ -0,0 +1,14 @@ + + + + + Trust a connected USB accessory on any port + Authentication is required to trust this USB accessory + /usr/bin/omarchy-usb-authorization-approve + + auth_admin + auth_admin + auth_admin + + + diff --git a/default/systemd/user/omarchy-usb-authorization.service b/default/systemd/user/omarchy-usb-authorization.service index bfc27a61..6a775c8f 100644 --- a/default/systemd/user/omarchy-usb-authorization.service +++ b/default/systemd/user/omarchy-usb-authorization.service @@ -8,7 +8,6 @@ ConditionEnvironment=WAYLAND_DISPLAY [Service] Type=simple -ExecCondition=/usr/bin/systemctl is-active --quiet usbguard.service ExecStart=/usr/bin/bash -lc 'exec "$OMARCHY_PATH/bin/omarchy-usb-authorization-watch"' Restart=always RestartSec=2 diff --git a/etc/polkit-1/rules.d/40-omarchy-usb.rules b/etc/polkit-1/rules.d/40-omarchy-usb.rules new file mode 100644 index 00000000..1469681d --- /dev/null +++ b/etc/polkit-1/rules.d/40-omarchy-usb.rules @@ -0,0 +1,6 @@ +polkit.addRule(function(action, subject) { + if (action.id === "org.omarchy.usb.approve" && + subject.active && subject.local && subject.isInGroup("wheel")) { + return polkit.Result.YES; + } +}); diff --git a/install/config/usb-authorization.sh b/install/config/usb-authorization.sh index 6b0747e8..32605617 100644 --- a/install/config/usb-authorization.sh +++ b/install/config/usb-authorization.sh @@ -1,5 +1,7 @@ echo "Configure USB device authorization" +install -Dm644 "$OMARCHY_PATH/default/polkit/org.omarchy.usb.policy" /usr/share/polkit-1/actions/org.omarchy.usb.policy + # A deferred install has only the builder's peripherals attached. The owner # needs working input before there is an account or a session for approvals. if [[ -z ${OMARCHY_INSTALL_USER:-} ]]; then diff --git a/install/helpers/usb-authorization-policy.sh b/install/helpers/usb-authorization-policy.sh new file mode 100644 index 00000000..a0d1da1e --- /dev/null +++ b/install/helpers/usb-authorization-policy.sh @@ -0,0 +1,95 @@ +# Device identity is portable; the full live rule is still used when granting +# access to the connection shown in the approval dialog. +usb_authorization_portable_rule() { + local rule="$1" token="" char quoted=0 escaped=0 index + local -a tokens=() portable=() + + # Split canonical USBGuard output without evaluating device-controlled text. + # An attribute name inside a quoted name/serial is never a rule attribute. + for (( index=0; index<${#rule}; index++ )); do + char=${rule:index:1} + if (( escaped )); then + token+="$char" + escaped=0 + elif (( quoted )) && [[ $char == '\' ]]; then + token+="$char" + escaped=1 + elif [[ $char == '"' ]]; then + token+="$char" + quoted=$((1 - quoted)) + elif (( ! quoted )) && [[ $char == " " ]]; then + if [[ -n $token ]]; then tokens+=("$token"); token=""; fi + else + token+="$char" + fi + done + (( ! quoted && ! escaped )) || return 1 + if [[ -n $token ]]; then tokens+=("$token"); fi + [[ ${tokens[0]:-} == "allow" || ${tokens[0]:-} == "block" ]] || return 1 + portable=(allow) + for (( index=1; index<${#tokens[@]}; index++ )); do + case "${tokens[index]}" in + via-port | parent-hash) + (( ++index < ${#tokens[@]} )) || return 1 + [[ ${tokens[index]} == \"*\" ]] || return 1 + ;; + label) return 1 ;; + *) portable+=("${tokens[index]}") ;; + esac + done + local IFS=" " + printf '%s label "omarchy-usb-authorization-v1"\n' "${portable[*]}" +} + +usb_authorization_permanent() { + local id="$1" expected="$2" devices current="" allowed portable policy line target new_id matches rule saved=0 + + [[ $id =~ ^[0-9]+$ && ( $expected == block\ * || $expected == allow\ * ) ]] || return 1 + (( ${#expected} <= 16384 )) || return 1 + devices=$(usbguard list-devices) || return 1 + while IFS= read -r line; do + if [[ $line == "$id: "* ]]; then current=${line#"$id: "}; fi + done <<<"$devices" + [[ $current == "$expected" ]] || return 1 + allowed="allow ${expected#* }" + portable=$(usb_authorization_portable_rule "$current") || return 1 + usbguard-rule-parser "$portable" >/dev/null || return 1 + + # Respect earlier administrator deny rules. Do not report permanent success + # when a saved allow would lose to an explicit block/reject on reconnect. + policy=$(usbguard list-rules) || return 1 + while IFS= read -r line; do + if [[ $line =~ ^[0-9]+:[[:space:]](allow|block|reject)([[:space:]]|$) ]]; then + target=${BASH_REMATCH[1]} + rule=${line#*: } + # --show-devices filters by current target, hiding deny rules for a + # temporarily allowed device. A match query compares identity instead. + matches=$(usbguard list-devices "match${rule#"$target"}") || return 1 + if grep -Fqx -- "$id: $current" <<<"$matches"; then + if [[ $target != "allow" ]]; then + echo "An administrator rule blocks this device. Review that rule before saving approval." >&2 + return 1 + fi + break + fi + fi + done <<<"$policy" + while IFS= read -r line; do + if [[ $line =~ ^([0-9]+):[[:space:]] && ${line#*: } == "$portable" ]]; then + saved=1 + fi + done <<<"$policy" + + # A retry can reuse only an exact owned rule present in both the daemon and + # its persistent file. Never remove/reorder rules around manual restrictions. + if (( ! saved )) || ! grep -Fqx -- "$portable" /etc/usbguard/rules.conf; then + new_id=$(usbguard append-rule "$portable") || return 1 + [[ $new_id =~ ^[0-9]+$ ]] || return 1 + grep -Fqx -- "$portable" /etc/usbguard/rules.conf || return 1 + fi + if [[ $current == block\ * ]]; then + usbguard allow-device "$expected" || return 1 + fi + devices=$(usbguard list-devices --allowed) || return 1 + grep -Fqx -- "$1: $allowed" <<<"$devices" +} diff --git a/install/helpers/usb-authorization.sh b/install/helpers/usb-authorization.sh index 70e36628..a4b0606d 100644 --- a/install/helpers/usb-authorization.sh +++ b/install/helpers/usb-authorization.sh @@ -1,3 +1,5 @@ +source "${BASH_SOURCE[0]%/*}/usb-authorization-policy.sh" + usb_authorization_config_value() { local config="$1" key="$2" local awk_command=(awk) @@ -54,15 +56,24 @@ usb_authorization_require_secure_settings() { usb_authorization_require_secure_setting "$config" InsertedDevicePolicy apply-policy || return 1 usb_authorization_require_secure_setting "$config" AuthorizedDefault none || return 1 usb_authorization_require_secure_setting "$config" RestoreControllerDeviceState false || return 1 + usb_authorization_require_secure_setting "$config" RuleFile /etc/usbguard/rules.conf || return 1 } usb_authorization_generate_policy() { - local policy="$1" + local policy="$1" generated line - if ! usbguard generate-policy >"$policy"; then + if ! generated=$(usbguard generate-policy); then echo "USBGuard could not enumerate devices; nothing was enabled." >&2 return 1 fi + : >"$policy" + while IFS= read -r line; do + if [[ $line == allow\ * ]]; then + usb_authorization_portable_rule "$line" >>"$policy" || return 1 + elif [[ -n $line ]]; then + printf '%s\n' "$line" >>"$policy" + fi + done <<<"$generated" if ! grep -q '^allow ' "$policy"; then if grep -q '^[[:space:]]*[^#[:space:]]' "$policy"; then echo "USBGuard did not generate a usable policy; nothing was enabled." >&2 @@ -72,6 +83,7 @@ usb_authorization_generate_policy() { # Keep the file nonempty so re-enabling preserves this default-deny policy. echo '# No USB devices were present during enrollment.' >>"$policy" fi + usbguard-rule-parser -f "$policy" >/dev/null } usb_authorization_add_user() { @@ -113,16 +125,16 @@ usb_authorization_provision_owner() { } usb_authorization_trust_present_devices() { - local line id + local line id devices local count=0 - local -a devices + devices=$(usbguard list-devices) || return 1 + local -a inventory + mapfile -t inventory <<<"$devices" - mapfile -t devices < <(usbguard list-devices) - - for line in "${devices[@]}"; do + for line in "${inventory[@]}"; do if [[ $line =~ ^([0-9]+):[[:space:]] ]]; then id="${BASH_REMATCH[1]}" - usbguard allow-device --permanent "$id" + sudo /usr/bin/omarchy-usb-authorization-approve "$id" "${line#*: }" || return 1 (( ++count )) fi done diff --git a/manual/48-security.md b/manual/48-security.md index cc0b5b60..a9fe4647 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -13,6 +13,10 @@ Omarchy takes security extremely seriously. This is meant to be an operating sys USB device authorization is enabled by default. Omarchy uses USBGuard to trust the devices connected during installation and block USB devices it has not seen before. When a device is blocked, click the persistent notification and choose whether to allow it once, always allow it, or keep it blocked. +**Always allow this device** trusts the same device identity across USB ports and hubs, including after reboot. Changes to its identity still require approval. A hub may need its own approval before devices connected through it become available. + +Older approvals can be tied to a port or hub. Run `omarchy-setup-security-usb-authorization --review-existing` to review connected devices and confirm which should be trusted on any port. Existing rules are preserved. For a disconnected device, reconnect it and choose **Always allow this device** if prompted. + On a preinstalled machine that asks you to create your account at first boot, USB protection starts at the end of that setup. It trusts the devices connected then, so your keyboard works throughout account setup even if it differs from the installer's keyboard. You can turn the protection off from _Remove > Security > USB Device Authorization_. _Setup > Security > USB Device Authorization_ turns it back on with the existing trusted-device policy. diff --git a/migrations/1790608617.sh b/migrations/1790608617.sh new file mode 100644 index 00000000..7c1aa722 --- /dev/null +++ b/migrations/1790608617.sh @@ -0,0 +1,7 @@ +echo "Repair USB approval prompts and enable portable device approvals" + +# Preserve an explicit opt-out. Existing users with an active/enabled daemon +# need a working watcher even if their old checkout link has become dangling. +if systemctl is-enabled --quiet usbguard.service || systemctl is-active --quiet usbguard.service; then + omarchy-setup-security-usb-authorization --yes +fi diff --git a/test/shell.d/fixtures/usb-authorization/match.cpp b/test/shell.d/fixtures/usb-authorization/match.cpp new file mode 100644 index 00000000..5c26df69 --- /dev/null +++ b/test/shell.d/fixtures/usb-authorization/match.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include + +static std::string read(const char* path) { + std::ifstream file(path); + std::string text; + std::getline(file, text); + if (text.empty()) throw std::runtime_error("empty rule"); + return text; +} + +static std::string replace(std::string text, const std::string& from, const std::string& to) { + const auto index = text.find(from); + if (index == std::string::npos) throw std::runtime_error("missing attribute"); + text.replace(index, from.size(), to); + return text; +} + +static void check(const char* name, const usbguard::Rule& policy, const usbguard::Rule& device, bool expected) { + if (policy.appliesTo(device) != expected) throw std::runtime_error(name); + std::cout << "ok - native USBGuard: " << name << '\n'; +} + +int main(int argc, char** argv) { + if (argc != 3) return 2; + // Test the policy produced by the real Bash implementation, not a C++ copy. + const auto policy = usbguard::Rule::fromString(read(argv[1])); + const auto text = read(argv[2]); + const auto device = usbguard::Rule::fromString(text); + check("original connection", policy, device, true); + auto moved = device; + moved.setViaPort("9-9.9"); + check("another port", policy, moved, true); + moved.setParentHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + check("another hub", policy, moved, true); + auto reloaded = usbguard::Rule::fromString(policy.toString()); + check("serialized policy reload on new hub", reloaded, moved, true); + check("reconnect on original port", reloaded, device, true); + auto different = moved; + different.setHash("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="); + check("different hash denied", policy, different, false); + different = moved; + different.setSerial("different"); + check("different serial denied", policy, different, false); + different = moved; + different.setName("different"); + check("different name denied", policy, different, false); + check("different product denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046d:c53b")), false); + check("different vendor denied", policy, usbguard::Rule::fromString(replace(text, "046d:c53a", "046e:c53a")), false); + check("different interface denied", policy, usbguard::Rule::fromString(replace(text, "03:00:00", "08:06:50")), false); + check("different connection type denied", policy, usbguard::Rule::fromString(replace(text, "with-connect-type \"unknown\"", "with-connect-type \"hotplug\"")), false); + // Existing unmarked/manual rules remain location-bound. + check("legacy rule still restricts ports and hubs", device, moved, false); +} diff --git a/test/shell.d/usb-authorization-policy-test.sh b/test/shell.d/usb-authorization-policy-test.sh new file mode 100755 index 00000000..97fc35a8 --- /dev/null +++ b/test/shell.d/usb-authorization-policy-test.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$ROOT/install/helpers/usb-authorization-policy.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT + +identity='id 046d:c53a serial "" name "USB Receiver" hash "KCUFt1MumW4Pfs/8YXWzGQB6Hsnm8qkDqFVjfY0NIBY="' +interfaces='with-interface { 03:01:01 03:01:02 03:00:00 } with-connect-type "unknown"' +location='parent-hash "m7yTNWlczwBbYn+uRP6TRsY5AceKmAZ0Et6mgy58+/o=" via-port "3-1.1.2.1"' +device="allow $identity $location $interfaces" +portable=$(usb_authorization_portable_rule "$device") +[[ $portable == "allow $identity $interfaces"' label "omarchy-usb-authorization-v1"' ]] || fail "only device location is removed" +[[ $(usb_authorization_portable_rule "${device/#allow/block}") == "$portable" ]] || fail "blocked approvals use the same portable identity" +pass "portable USB rules retain every device identity attribute" + +# A canonical escaped name cannot cause its contents to become attributes. +name='name "Receiver \" via-port \"fake\" parent-hash \"fake\" $(touch /tmp/usb-injection)"' +hostile="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $location $interfaces" +expected="allow id 046d:c53a serial \"\\\\\" $name hash \"hash\" $interfaces"' label "omarchy-usb-authorization-v1"' +[[ $(usb_authorization_portable_rule "$hostile") == "$expected" ]] || fail "quoted attribute text must remain data" +if usb_authorization_portable_rule 'allow id 046d:c53a name "unterminated' >/dev/null; then fail "reject unmatched quote"; fi +if usb_authorization_portable_rule 'allow id 046d:c53a via-port' >/dev/null; then fail "reject missing location value"; fi +pass "USB portable parsing preserves escaped and hostile descriptor text" + +# Exercise the actual root entrypoint startup boundary without root work. +sed -e "s|source /usr/bin/omarchy-security-functions|source $ROOT/bin/omarchy-security-functions|" \ + -e 's|source /usr/share/omarchy/install/helpers/usb-authorization-policy.sh|exit 126|' \ + "$ROOT/bin/omarchy-usb-authorization-approve" >"$scratch/entry" +chmod 755 "$scratch/entry" +printf 'set -p\n' >"$scratch/decoy" +if BASH_ENV="$scratch/decoy" /usr/bin/bash "$scratch/entry" -p; then fail "ordinary Bash with decoy -p is rejected"; fi +printf 'touch "%s"\n' "$scratch/injected" >"$scratch/startup" +if BASH_ENV="$scratch/startup" "$scratch/entry" >/dev/null 2>&1; then fail "startup fixture boundary"; fi +if /usr/bin/env 'BASH_FUNC_source%%=() { touch "$USB_TEST_INJECTED"; }' USB_TEST_INJECTED="$scratch/injected" "$scratch/entry" >/dev/null 2>&1; then fail "exported function fixture boundary"; fi +[[ ! -e $scratch/injected ]] || fail "privileged entrypoint executes caller startup code" +pass "USB privileged approval rejects unsafe Bash startup" + +if ! command -v g++ >/dev/null || [[ ! -f /usr/include/usbguard/Rule.hpp ]]; then + skip "native USBGuard matcher requires g++ and USBGuard development headers" + exit 0 +fi +printf '%s\n' "$device" >"$scratch/device" +printf '%s\n' "$portable" >"$scratch/policy" +g++ -std=c++17 "$ROOT/test/shell.d/fixtures/usb-authorization/match.cpp" -lusbguard -o "$scratch/match" +"$scratch/match" "$scratch/policy" "$scratch/device" diff --git a/test/shell.d/usb-authorization-test.sh b/test/shell.d/usb-authorization-test.sh index c3d64ba3..7a10635d 100755 --- a/test/shell.d/usb-authorization-test.sh +++ b/test/shell.d/usb-authorization-test.sh @@ -23,6 +23,7 @@ PresentDevicePolicy=apply-policy InsertedDevicePolicy=apply-policy AuthorizedDefault=none RestoreControllerDeviceState=false +RuleFile=/etc/usbguard/rules.conf CONF cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' @@ -43,6 +44,7 @@ printf 'pkg-drop <%s>\n' "$*" >>"$CALLS" STUB cat >"$stub_bin/install" <<'STUB' #!/bin/bash +if [[ ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then exit 0; fi if [[ $1 == -Dm600 && $2 == -o && $3 == root && $4 == -g && $5 == root ]]; then exec /usr/bin/install -Dm600 "$6" "$7" fi @@ -53,6 +55,17 @@ cat >"$stub_bin/systemctl" <<'STUB' printf 'systemctl <%s>\n' "$*" >>"$CALLS" if [[ $* == "show usbguard.service --property=InvocationID --value" ]]; then printf '%s\n' "${TEST_DAEMON_INVOCATION:-11111111111111111111111111111111}" +elif [[ $* == "--user show-environment" ]]; then + echo "OMARCHY_PATH=${TEST_SESSION_ROOT:-$OMARCHY_PATH}" +elif [[ $* == "--user restart omarchy-usb-authorization.service" ]]; then + [[ ${TEST_WATCHER_FAIL:-0} == 0 ]] +elif [[ $* == "restart usbguard.service" ]]; then + touch "$TEST_GUARD_ACTIVE" +elif [[ $* == "disable --now usbguard.service" ]]; then + [[ ${TEST_STOP_FAIL:-0} == 0 ]] || exit 1 + [[ ${TEST_STOP_LIES:-0} == 1 ]] || rm -f "$TEST_GUARD_ACTIVE" +elif [[ $* == "is-active --quiet usbguard.service" ]]; then + [[ -f $TEST_GUARD_ACTIVE ]] elif [[ $* == "is-enabled --quiet usbguard.service" ]]; then exit 1 fi @@ -100,11 +113,22 @@ list-devices) echo '5: allow id 0627:0001 name "QEMU USB Tablet" hash "tablet"' fi ;; +append-rule) + printf 'usbguard <%s>\n' "$2" >>"$CALLS" + [[ ${APPROVAL_EXIT_FAIL:-0} == 0 ]] || exit 1 + if [[ ${APPROVAL_NO_POLICY:-0} == 0 ]]; then printf '%s\n' "$2" >"$TEST_SAVED_RULE"; fi + echo 40 + ;; list-rules) - [[ ${APPROVAL_POLICY_QUERY_FAIL:-0} == 0 ]] || exit 1 + if [[ -f $TEST_ALLOWED_RULE && ${APPROVAL_POLICY_QUERY_FAIL:-0} == 1 ]]; then exit 1; fi + if [[ -n ${TEST_POLICY_LIST:-} ]]; then cat "$TEST_POLICY_LIST"; fi if [[ -f $TEST_SAVED_RULE ]]; then printf '40: %s\n' "$(<"$TEST_SAVED_RULE")" - printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + if [[ -f $TEST_ALLOWED_RULE ]]; then + printf '\t17: %s\n' "$(<"$TEST_ALLOWED_RULE")" + elif [[ -n ${BLOCKED_DEVICE_RULE:-} ]]; then + printf '\t17: %s\n' "$BLOCKED_DEVICE_RULE" + fi fi ;; *) @@ -140,6 +164,11 @@ elif [[ $1 == test && $2 == -s && $3 == /etc/usbguard/rules.conf ]]; then exec /usr/bin/test -s "$TEST_RULES" elif [[ $1 == install && $2 == -Dm600 && $3 == -o && $4 == root && $5 == -g && $6 == root && $8 == /etc/usbguard/rules.conf ]]; then exec /usr/bin/install -Dm600 "$7" "$TEST_RULES" +elif [[ $1 == install && ${!#} == /usr/share/polkit-1/actions/org.omarchy.usb.policy ]]; then + exit 0 +elif [[ $1 == /usr/bin/omarchy-usb-authorization-approve ]]; then + shift + exec "$USB_APPROVE_FIXTURE" "$@" elif [[ $1 == omarchy-usb-authorization-restore-default ]]; then echo 1 >"$TEST_SYSFS/usb1/authorized_default" echo 1 >"$TEST_SYSFS/1-2/authorized" @@ -165,7 +194,13 @@ choose) fi printf '%s\n' "${GUM_CHOICE:-Keep blocked}" ;; -confirm) exit 0 ;; +confirm) + if [[ ${GUM_REQUIRE_STDIN:-0} == 1 ]]; then + read -r consent + [[ $consent == yes ]] || exit 1 + fi + [[ ${GUM_DECLINE:-0} == 0 ]] + ;; esac STUB cat >"$stub_bin/omarchy-notification-send" <<'STUB' @@ -185,6 +220,16 @@ if [[ -n ${NOTIFICATION_READY_FILE:-} ]]; then [[ -e $NOTIFICATION_READY_FILE ]] fi STUB +cat >"$stub_bin/pkexec" <<'STUB' +#!/bin/bash +[[ $1 == /usr/bin/omarchy-usb-authorization-approve ]] || exit 1 +shift +exec "$USB_APPROVE_FIXTURE" "$@" +STUB +cat >"$stub_bin/usbguard-rule-parser" <<'STUB' +#!/bin/bash +exit 0 +STUB chmod +x "$stub_bin"/* export HOME="$home" @@ -196,6 +241,26 @@ export TEST_DAEMON_CONFIG="$daemon_config" export TEST_RULES="$rules" export TEST_ALLOWED_RULE="$scratch/allowed-rule" export TEST_SAVED_RULE="$scratch/saved-rule" +export TEST_GUARD_ACTIVE="$scratch/guard-active" +export USB_APPROVE_FIXTURE="$scratch/approve" +export USB_POLICY_FIXTURE="$scratch/policy.sh" +sed "s|/etc/usbguard/rules.conf|$TEST_SAVED_RULE|g" "$ROOT/install/helpers/usb-authorization-policy.sh" >"$USB_POLICY_FIXTURE" +cat >"$USB_APPROVE_FIXTURE" <<'STUB' +#!/bin/bash +set -euo pipefail +source "$USB_POLICY_FIXTURE" +# Boot inventory uses the same helper; permanent behavior is exercised below. +if [[ $1 == 4 || $1 == 5 ]]; then + printf 'boot-approve <%s> <%s>\n' "$1" "$2" >>"$CALLS" + exit 0 +fi +usb_authorization_permanent "$@" +STUB +chmod +x "$USB_APPROVE_FIXTURE" +sed -e "s|/usr/bin/omarchy-usb-authorization-approve|$USB_APPROVE_FIXTURE|g" \ + -e "s|/usr/share/omarchy/install/helpers/usb-authorization-policy.sh|$USB_POLICY_FIXTURE|g" \ + "$ROOT/bin/omarchy-setup-security-usb-authorization" >"$scratch/setup" +chmod +x "$scratch/setup" if "$ROOT/bin/omarchy-usb-authorization-restore-default" >/dev/null 2>&1; then fail "the fixed-path USB authorization restore helper requires root" @@ -210,46 +275,76 @@ fi fail "user-facing USB authorization commands do not elevate caller-selected paths" pass "USB authorization keeps privileged paths fixed" -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-output" +"$scratch/setup" --yes >"$scratch/setup-output" -grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" || fail "USB authorization setup trusts devices present during enrollment" [[ $(stat -c %a "$rules") == 600 ]] || fail "USB authorization policy is root-private" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "USB authorization grants only the IPC access needed by the approval flow" grep -Fqx 'systemctl ' "$calls" || fail "USB authorization reloads its user ACL before the watcher starts" -[[ -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || +[[ -f $home/.config/systemd/user/omarchy-usb-authorization.service && ! -L $home/.config/systemd/user/omarchy-usb-authorization.service ]] || fail "USB authorization installs the graphical-session watcher" grep -Fqx 'pkg-add ' "$calls" || fail "USB authorization ensures USBGuard is installed" pass "USB authorization enrolls present devices before enabling default-deny" generate_count=$(grep -c '^usbguard $' "$calls") -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-existing-output" +"$scratch/setup" --yes >"$scratch/setup-existing-output" generate_count_after=$(grep -c '^usbguard $' "$calls") (( generate_count_after == generate_count )) || fail "re-enabling preserves the existing trusted-device policy" pass "USB authorization setup preserves an existing policy" +# Failures before the watcher is ready must never start enforcement. A copied +# unit also repairs the dangling checkout link reported on the affected host. +unit="$home/.config/systemd/user/omarchy-usb-authorization.service" +rm -f "$unit" +ln -s "$scratch/deleted-checkout/service" "$unit" +"$scratch/setup" --yes >/dev/null +[[ -f $unit && ! -L $unit ]] || fail "setup repairs a dangling watcher unit" +for failure in missing-unit missing-helper session-mismatch watcher-start; do + rm -f "$TEST_GUARD_ACTIVE" + : >"$calls" + cp "$scratch/setup" "$scratch/failing-setup" + options=() + case "$failure" in + missing-unit) sed -i "s|^unit_source=.*|unit_source=$scratch/missing-unit|" "$scratch/failing-setup" ;; + missing-helper) sed -i "s|$USB_POLICY_FIXTURE|$scratch/missing-helper|g" "$scratch/failing-setup" ;; + session-mismatch) options+=(TEST_SESSION_ROOT=/missing-runtime) ;; + watcher-start) options+=(TEST_WATCHER_FAIL=1) ;; + esac + if env "${options[@]}" "$scratch/failing-setup" --yes >"$scratch/$failure.log" 2>&1; then + fail "setup must reject $failure" + fi + [[ ! -e $TEST_GUARD_ACTIVE ]] || fail "$failure starts blocking without a watcher" + ! grep -Eq 'systemctl <(enable|restart) usbguard.service>' "$calls" || fail "$failure enables enforcement" +done +"$scratch/setup" --yes >/dev/null +watch_line=$(grep -n '^systemctl <--user restart omarchy-usb-authorization.service>' "$calls" | tail -1 | cut -d: -f1) +guard_line=$(grep -n '^systemctl ' "$calls" | tail -1 | cut -d: -f1) +(( watch_line < guard_line )) || fail "watcher must start before enforcement" +pass "USB setup repairs dangling units and refuses enforcement without a working watcher" + : >"$rules" -GENERATE_EMPTY=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-output" +GENERATE_EMPTY=1 "$scratch/setup" --yes >"$scratch/setup-empty-output" [[ -s $rules ]] || fail "empty USB inventory must persist an initialized policy" ! grep -q '^allow ' "$rules" || fail "empty inventory must not add permissive rules" generate_count=$(grep -c '^usbguard $' "$calls") -"$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-empty-again-output" +"$scratch/setup" --yes >"$scratch/setup-empty-again-output" [[ $(grep -c '^usbguard $' "$calls") == "$generate_count" ]] || fail "re-enabling an empty policy must not enroll newly attached devices" : >"$rules" -if GENERATE_FAIL=1 "$ROOT/bin/omarchy-setup-security-usb-authorization" --yes >"$scratch/setup-failed-output" 2>&1; then +if GENERATE_FAIL=1 "$scratch/setup" --yes >"$scratch/setup-failed-output" 2>&1; then fail "enumeration failure must not be accepted as an empty USB inventory" fi [[ ! -s $rules ]] || fail "failed enumeration must not install a policy" pass "USB setup accepts an empty inventory while rejecting enumeration failure" : >"$calls" -"$ROOT/bin/omarchy-setup-security-usb-authorization" --boot --yes >"$scratch/setup-boot-output" -grep -Fqx 'usbguard <--permanent> <4>' "$calls" || +"$scratch/setup" --boot --yes >"$scratch/setup-boot-output" +grep -Fq 'boot-approve <4>' "$calls" || fail "boot authorization trusts the connected root controller" -grep -Fqx 'usbguard <--permanent> <5>' "$calls" || +grep -Fq 'boot-approve <5>' "$calls" || fail "boot authorization trusts the connected USB devices" grep -Fqx 'sudo ' "$calls" || fail "boot authorization uses the fixed-path boot-image helper" @@ -419,7 +514,7 @@ export OMARCHY_INSTALL_USER=tester export OMARCHY_USB_AUTHORIZATION_RULES_FILE="$rules" export OMARCHY_USB_AUTHORIZATION_DAEMON_CONFIG="$daemon_config" bash -euo pipefail -c 'source "$OMARCHY_INSTALL/config/usb-authorization.sh"' >"$scratch/install-output" -grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root"' "$rules" || +grep -qx 'allow id 1d6b:0002 name "Linux Foundation root hub" hash "root" label "omarchy-usb-authorization-v1"' "$rules" || fail "fresh installation enrolls devices present during installation" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "fresh installation grants the owner narrowly scoped approval access" @@ -452,7 +547,7 @@ echo 'allow id 1234:0001 name "Builder keyboard" hash "builder"' >"$rules" owner_rule='allow id 1234:0002 name "Owner keyboard" hash "owner"' : >"$calls" GENERATED_RULE="$owner_rule" usb_authorization_provision_owner owner "$rules" "$daemon_config" -[[ $(<"$rules") == "$owner_rule" ]] || fail "owner enrollment must replace inherited builder trust" +[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "owner enrollment must replace inherited builder trust" grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy=list> <--exceptions=listen>' "$calls" || fail "owner provisioning grants approval access" [[ $(tail -2 "$calls") == $'systemctl \nsystemctl ' ]] || @@ -462,7 +557,7 @@ grep -Fqx 'usbguard <--devices=list,listen,modify> <--policy= if GENERATE_FAIL=1 usb_authorization_provision_owner owner "$rules" "$daemon_config" >"$scratch/owner-failed" 2>&1; then fail "owner enrollment must reject enumeration failure" fi -[[ $(<"$rules") == "$owner_rule" ]] || fail "failed enrollment preserves the old policy" +[[ $(<"$rules") == "$owner_rule"' label "omarchy-usb-authorization-v1"' ]] || fail "failed enrollment preserves the old policy" ! grep -q '^systemctl' "$calls" || fail "failed owner enrollment must not enable enforcement" : >"$calls" sed 's/ImplicitPolicyTarget=block/ImplicitPolicyTarget=allow/' "$daemon_config" >"$scratch/insecure-daemon.conf" @@ -622,10 +717,37 @@ USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ request=$(find "$home/.local/state/omarchy/usb-authorization/requests" -maxdepth 1 -name 'request-*.json' -print -quit) token=$(basename "${request%.json}") GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/review-always-output" -grep -Fqx "usbguard <--permanent> <$malicious_rule>" "$calls" || fail "review can persist an exact-device rule" +grep -Fq 'usbguard ' "$calls") +"$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }" +[[ $(grep -c '^usbguard ' "$calls") == "$append_count" ]] || fail "confirmed permanent retry must preserve rule ordering without duplicate rules" pass "USB review supports explicit persistent trust" rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" +# Unmarked rules might be intentional manual restrictions. Review is opt-in, +# never accepted through --yes, and only the selected live identity is added. +legacy_rule='allow id 046d:c53a name "Receiver" hash "receiver" parent-hash "hub-a" via-port "1-2"' +printf '%s\n' "$legacy_rule" >"$TEST_ALLOWED_RULE" +printf '12: %s\n\t17: %s\n' "$legacy_rule" "$legacy_rule" >"$scratch/legacy-policy" +cp "$scratch/legacy-policy" "$scratch/legacy-before" +if "$scratch/setup" --review-existing --yes >/dev/null 2>&1; then fail "legacy migration requires individual consent"; fi +TEST_POLICY_LIST="$scratch/legacy-policy" GUM_DECLINE=1 "$scratch/setup" --review-existing >/dev/null +[[ ! -e $TEST_SAVED_RULE ]] || fail "declining migration adds portable trust" +printf 'yes\nyes\nyes\n' | TEST_POLICY_LIST="$scratch/legacy-policy" GUM_REQUIRE_STDIN=1 "$scratch/setup" --review-existing >/dev/null +[[ $(<"$TEST_SAVED_RULE") == 'allow id 046d:c53a name "Receiver" hash "receiver" label "omarchy-usb-authorization-v1"' ]] || fail "legacy review saves only identity constraints" +cmp -s "$scratch/legacy-policy" "$scratch/legacy-before" || fail "legacy review rewrites existing manual rules" +rm -f "$TEST_ALLOWED_RULE" "$TEST_SAVED_RULE" + +printf '12: block id 07a6:8513\n\t17: %s\n' "$malicious_rule" >"$scratch/deny-policy" +if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "permanent approval must respect earlier manual denial"; fi +[[ ! -e $TEST_SAVED_RULE && ! -e $TEST_ALLOWED_RULE ]] || fail "manual denial changed policy or authorization" +printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE" +if TEST_POLICY_LIST="$scratch/deny-policy" "$USB_APPROVE_FIXTURE" 17 "allow ${malicious_rule#block }" >/dev/null 2>&1; then fail "temporary allow cannot hide an earlier manual denial"; fi +rm -f "$TEST_ALLOWED_RULE" +printf '12: block\n' >"$scratch/deny-all-policy" +if TEST_POLICY_LIST="$scratch/deny-all-policy" "$USB_APPROVE_FIXTURE" 17 "$malicious_rule" >/dev/null 2>&1; then fail "unconditional manual denial must be preserved"; fi +pass "USB legacy review requires consent and preserves manual policy" + USBGUARD_IPC_SIGNAL=Device.PolicyApplied \ USBGUARD_DEVICE_ID=17 \ USBGUARD_DEVICE_EVENT=Insert \ @@ -724,11 +846,12 @@ if APPROVAL_NO_POLICY=1 GUM_CHOICE='Always allow this device' "$ROOT/bin/omarchy fail "permanent approval requires saved policy" fi allow_count=$(grep -c '^usbguard ' "$calls") -if GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then +if APPROVAL_NO_POLICY=1 GUM_CHOICE='Allow once' "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/missing-policy-retry" 2>&1; then fail "retry cannot downgrade permanent approval when policy is missing" fi [[ -f $request && $(jq -r .approval "$request") == 'Always allow this device' ]] || fail "missing policy retains original permanent intent" [[ $(grep -c '^usbguard ' "$calls") == "$allow_count" ]] || fail "retry must not repeat authorization on an allowed device" +printf '%s\n' "allow ${malicious_rule#block }" >"$TEST_ALLOWED_RULE" for failure in BLOCKED_QUERY_FAIL APPROVAL_QUERY_FAIL; do if env "$failure=1" "$ROOT/bin/omarchy-usb-authorization-review" "$token" >"$scratch/query-retry" 2>&1; then fail "retry must fail when inventory cannot be read" @@ -803,6 +926,18 @@ echo 0 >"$sysfs/usb1/authorized_default" echo 0 >"$sysfs/1-2/authorized" export TEST_SYSFS="$sysfs" +for failure in TEST_STOP_FAIL TEST_STOP_LIES; do + touch "$TEST_GUARD_ACTIVE" + : >"$calls" + if env "$failure=1" "$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/$failure.log" 2>&1; then + fail "removal must reject $failure" + fi + [[ -f $unit ]] || fail "failed daemon stop removes watcher" + ! grep -q '^usbguard ' "$calls" || fail "failed daemon stop revokes watcher access" + ! grep -q '^systemctl <--user disable' "$calls" || fail "failed daemon stop disables watcher" +done +pass "USB removal preserves approval prompts if stopping enforcement fails" + "$ROOT/bin/omarchy-remove-security-usb-authorization" --yes >"$scratch/remove-output" [[ $(<"$sysfs/usb1/authorized_default") == 1 ]] || fail "removal restores root-hub default authorization" @@ -817,3 +952,16 @@ grep -Fqx 'sudo ' "$calls" || [[ ! -e $home/.config/systemd/user/omarchy-usb-authorization.service ]] || fail "removal deletes the graphical-session watcher" pass "USB authorization removal restores the original default-allow behavior" + +cat >"$stub_bin/omarchy-setup-security-usb-authorization" <<'STUB' +#!/bin/bash +printf 'migrate-setup <%s>\n' "$*" >>"$CALLS" +STUB +chmod +x "$stub_bin/omarchy-setup-security-usb-authorization" +: >"$calls" +bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null +! grep -q '^migrate-setup' "$calls" || fail "migration overrides USB opt-out" +touch "$TEST_GUARD_ACTIVE" +bash -euo pipefail "$ROOT/migrations/1790608617.sh" >/dev/null +grep -Fqx 'migrate-setup <--yes>' "$calls" || fail "migration repairs an active daemon without a watcher" +pass "USB migration repairs enabled protection and preserves opt-out" From a54bf5fd9572996d65bc446ce115509254b05e11 Mon Sep 17 00:00:00 2001 From: manuaudio Date: Thu, 8 Oct 2026 08:43:00 -0700 Subject: [PATCH 21/34] [verified] Repair background links after WebP conversion --- migrations/1791470456.sh | 38 ++++++++++++ .../shell.d/webp-background-migration-test.sh | 58 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 migrations/1791470456.sh create mode 100644 test/shell.d/webp-background-migration-test.sh diff --git a/migrations/1791470456.sh b/migrations/1791470456.sh new file mode 100644 index 00000000..539e0ed7 --- /dev/null +++ b/migrations/1791470456.sh @@ -0,0 +1,38 @@ +echo "Repair background selections after bundled images moved to WebP" + +replacement_webp() { + local background="$1" + local theme="${2:-}" + local replacement + + [[ ! -e $background ]] || return 1 + + case ${background,,} in + *.jpg | *.jpeg | *.png) replacement="${background%.*}.webp" ;; + *) return 1 ;; + esac + + if [[ ! -f $replacement ]]; then + [[ -n $theme && ${background%/*} == "$HOME/.local/state/omarchy/current/theme/backgrounds" ]] || return 1 + [[ -f $OMARCHY_PATH/themes/$theme/backgrounds/${replacement##*/} ]] || return 1 + fi + + printf '%s\n' "$replacement" +} + +current_background="$HOME/.local/state/omarchy/current/background" +if [[ -L $current_background ]]; then + background=$(readlink "$current_background") + if replacement=$(replacement_webp "$background"); then + ln -sfn "$replacement" "$current_background" + fi +fi + +for state_file in "$HOME/.local/state/omarchy/theme-backgrounds/"*; do + [[ -f $state_file ]] || continue + + background=$(<"$state_file") + if replacement=$(replacement_webp "$background" "${state_file##*/}"); then + printf '%s\n' "$replacement" >"$state_file" + fi +done diff --git a/test/shell.d/webp-background-migration-test.sh b/test/shell.d/webp-background-migration-test.sh new file mode 100644 index 00000000..59eefe9e --- /dev/null +++ b/test/shell.d/webp-background-migration-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +home="$test_dir/home" +migration="$ROOT/migrations/1791470456.sh" + +reset_home() { + rm -rf "$home" + mkdir -p "$home/.local/state/omarchy/current/theme/backgrounds" "$home/.local/state/omarchy/theme-backgrounds" +} + +run_migration() { + HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >"$test_dir/output" +} + +backgrounds="$home/.local/state/omarchy/current/theme/backgrounds" +current="$home/.local/state/omarchy/current/background" +remembered="$home/.local/state/omarchy/theme-backgrounds/tokyo-night" +remembered_png="$home/.local/state/omarchy/theme-backgrounds/catppuccin" + +reset_home +touch "$backgrounds/current.webp" +ln -s "$backgrounds/current.jpg" "$current" +printf '%s\n' "$backgrounds/2-swirl-buck.jpg" >"$remembered" +printf '%s\n' "$backgrounds/omarchy.png" >"$remembered_png" +run_migration +[[ $(readlink "$current") == "$backgrounds/current.webp" ]] || fail "the current background moves from the removed JPEG to its WebP replacement" +[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "an inactive theme's remembered JPEG moves to its bundled WebP replacement" +[[ $(<"$remembered_png") == "$backgrounds/omarchy.webp" ]] || fail "an inactive theme's remembered PNG moves to its bundled WebP replacement" +pass "converted bundled backgrounds keep their current and remembered selections" + +run_migration +[[ $(readlink "$current") == "$backgrounds/current.webp" ]] || fail "the repair can be rerun" +[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "the remembered JPEG repair can be rerun" +[[ $(<"$remembered_png") == "$backgrounds/omarchy.webp" ]] || fail "the remembered PNG repair can be rerun" +pass "the background repair is idempotent" + +reset_home +touch "$backgrounds/kept.jpg" "$backgrounds/kept.webp" +ln -s "$backgrounds/kept.jpg" "$current" +printf '%s\n' "$backgrounds/kept.jpg" >"$remembered" +run_migration +[[ $(readlink "$current") == "$backgrounds/kept.jpg" ]] || fail "an existing JPEG background is preserved" +[[ $(<"$remembered") == "$backgrounds/kept.jpg" ]] || fail "an existing remembered JPEG is preserved" +pass "existing backgrounds are not replaced just because a WebP shares their stem" + +reset_home +ln -s "$backgrounds/missing.jpeg" "$current" +printf '%s\n' "$backgrounds/missing.png" >"$remembered" +run_migration +[[ $(readlink "$current") == "$backgrounds/missing.jpeg" ]] || fail "a dangling selection without an exact WebP replacement is preserved" +[[ $(<"$remembered") == "$backgrounds/missing.png" ]] || fail "a dangling remembered selection without an exact WebP replacement is preserved" +pass "unrelated dangling background paths are left alone" From b6844dcc331aa4192fe5ea90e06f4cd3b45780a6 Mon Sep 17 00:00:00 2001 From: manuaudio Date: Thu, 8 Oct 2026 08:52:21 -0700 Subject: [PATCH 22/34] Fix WebP background migration boundaries --- migrations/1791470456.sh | 40 +++++++++++++++---- .../shell.d/webp-background-migration-test.sh | 37 ++++++++++++++++- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/migrations/1791470456.sh b/migrations/1791470456.sh index 539e0ed7..5213458c 100644 --- a/migrations/1791470456.sh +++ b/migrations/1791470456.sh @@ -1,30 +1,56 @@ echo "Repair background selections after bundled images moved to WebP" +theme_webp_source() { + local theme="$1" + local background="$2" + local original_name=${background##*/} + local replacement_name="${original_name%.*}.webp" + local user_theme_backgrounds="$HOME/.config/omarchy/themes/$theme/backgrounds" + local bundled_theme_backgrounds="$OMARCHY_PATH/themes/$theme/backgrounds" + + if [[ -f $user_theme_backgrounds/$original_name || -f $bundled_theme_backgrounds/$original_name ]]; then + return 1 + elif [[ -f $user_theme_backgrounds/$replacement_name ]]; then + printf '%s\n' "$user_theme_backgrounds/$replacement_name" + elif [[ -f $bundled_theme_backgrounds/$replacement_name ]]; then + printf '%s\n' "$bundled_theme_backgrounds/$replacement_name" + else + return 1 + fi +} + replacement_webp() { local background="$1" local theme="${2:-}" local replacement - [[ ! -e $background ]] || return 1 - case ${background,,} in *.jpg | *.jpeg | *.png) replacement="${background%.*}.webp" ;; *) return 1 ;; esac - if [[ ! -f $replacement ]]; then - [[ -n $theme && ${background%/*} == "$HOME/.local/state/omarchy/current/theme/backgrounds" ]] || return 1 - [[ -f $OMARCHY_PATH/themes/$theme/backgrounds/${replacement##*/} ]] || return 1 + if [[ -n $theme && ${background%/*} == "$HOME/.local/state/omarchy/current/theme/backgrounds" ]]; then + theme_webp_source "$theme" "$background" >/dev/null || return 1 + printf '%s\n' "$replacement" + return 0 fi + [[ ! -e $background && -f $replacement ]] || return 1 printf '%s\n' "$replacement" } -current_background="$HOME/.local/state/omarchy/current/background" +current_dir="$HOME/.local/state/omarchy/current" +current_background="$current_dir/background" if [[ -L $current_background ]]; then + active_theme=$(cat "$current_dir/theme.name" 2>/dev/null || true) background=$(readlink "$current_background") - if replacement=$(replacement_webp "$background"); then + if replacement=$(replacement_webp "$background" "$active_theme"); then + if [[ ! -f $replacement ]]; then + source=$(theme_webp_source "$active_theme" "$background") + cp "$source" "$replacement" + fi ln -sfn "$replacement" "$current_background" + omarchy-shell -q background set "$replacement" fi fi diff --git a/test/shell.d/webp-background-migration-test.sh b/test/shell.d/webp-background-migration-test.sh index 59eefe9e..1d98eba3 100644 --- a/test/shell.d/webp-background-migration-test.sh +++ b/test/shell.d/webp-background-migration-test.sh @@ -8,14 +8,23 @@ test_dir=$(mktemp -d) trap 'rm -rf "$test_dir"' EXIT home="$test_dir/home" migration="$ROOT/migrations/1791470456.sh" +mkdir -p "$test_dir/bin" +call_log="$test_dir/shell-calls" +cat >"$test_dir/bin/omarchy-shell" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$CALL_LOG" +SH +chmod +x "$test_dir/bin/omarchy-shell" reset_home() { rm -rf "$home" mkdir -p "$home/.local/state/omarchy/current/theme/backgrounds" "$home/.local/state/omarchy/theme-backgrounds" + : >"$call_log" } run_migration() { - HOME="$home" OMARCHY_PATH="$ROOT" bash -euo pipefail "$migration" >"$test_dir/output" + HOME="$home" OMARCHY_PATH="$ROOT" CALL_LOG="$call_log" PATH="$test_dir/bin:$PATH" \ + bash -euo pipefail "$migration" >"$test_dir/output" } backgrounds="$home/.local/state/omarchy/current/theme/backgrounds" @@ -30,6 +39,7 @@ printf '%s\n' "$backgrounds/2-swirl-buck.jpg" >"$remembered" printf '%s\n' "$backgrounds/omarchy.png" >"$remembered_png" run_migration [[ $(readlink "$current") == "$backgrounds/current.webp" ]] || fail "the current background moves from the removed JPEG to its WebP replacement" +[[ $(<"$call_log") == "-q background set $backgrounds/current.webp" ]] || fail "the running shell is told about the repaired current background" [[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "an inactive theme's remembered JPEG moves to its bundled WebP replacement" [[ $(<"$remembered_png") == "$backgrounds/omarchy.webp" ]] || fail "an inactive theme's remembered PNG moves to its bundled WebP replacement" pass "converted bundled backgrounds keep their current and remembered selections" @@ -56,3 +66,28 @@ run_migration [[ $(readlink "$current") == "$backgrounds/missing.jpeg" ]] || fail "a dangling selection without an exact WebP replacement is preserved" [[ $(<"$remembered") == "$backgrounds/missing.png" ]] || fail "a dangling remembered selection without an exact WebP replacement is preserved" pass "unrelated dangling background paths are left alone" + +reset_home +mkdir -p "$home/.config/omarchy/themes/custom/backgrounds" +touch "$backgrounds/cover.webp" "$home/.config/omarchy/themes/custom/backgrounds/cover.jpg" +custom_remembered="$home/.local/state/omarchy/theme-backgrounds/custom" +printf '%s\n' "$backgrounds/cover.jpg" >"$custom_remembered" +run_migration +[[ $(<"$custom_remembered") == "$backgrounds/cover.jpg" ]] || fail "an inactive custom theme's existing JPEG is not confused with the active theme's WebP" +pass "remembered selections are checked against their named theme" + +reset_home +touch "$backgrounds/2-swirl-buck.jpg" +printf '%s\n' "$backgrounds/2-swirl-buck.jpg" >"$remembered" +run_migration +[[ $(<"$remembered") == "$backgrounds/2-swirl-buck.webp" ]] || fail "an active-theme filename collision does not hide an inactive theme's WebP replacement" +pass "active theme files do not control inactive remembered repairs" + +reset_home +printf 'tokyo-night\n' >"$home/.local/state/omarchy/current/theme.name" +touch "$backgrounds/2-swirl-buck.jpg" +ln -s "$backgrounds/2-swirl-buck.jpg" "$current" +run_migration +[[ -f $backgrounds/2-swirl-buck.webp ]] || fail "the active staged theme receives its bundled WebP replacement" +[[ $(readlink "$current") == "$backgrounds/2-swirl-buck.webp" ]] || fail "a selected staged JPEG moves before a later refresh can strand it" +pass "the active staged theme is repaired before its old bundled image disappears" From 54fc12db76fbb6ed06575ff57839e73c7dbbf817 Mon Sep 17 00:00:00 2001 From: Neil Chudleigh Date: Fri, 9 Oct 2026 10:04:59 -0400 Subject: [PATCH 23/34] Stop push-to-talk even when other keys are pressed during the hold (#14645) Hyprland skips a release bind once any other key or mouse button is released while its key is held (KeybindManager::shadowKeybinds), and when the modifiers changed since the press. That is what makes a bind like "bindr = SUPER, SUPER_L" tap-only, but it means typing or clicking while holding F9 or Right Alt never runs omarchy-dictation stop, so the recording keeps going. o.bind_hold binds a press and a release action together. The release half is transparent (it can't be shadowed), ignores modifiers, and is non-consuming, so it doesn't take the key from apps when pressed with other modifiers (Shift+F9 still reaches the app). It runs only after its own press ran, so releasing the key without the rest of a chord does nothing. Dictation's push-to-talk keys use it. Co-authored-by: superduck00 <294879536+superduck00@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- default/hypr/bindings/dictation.lua | 6 +-- default/hypr/helpers.lua | 57 +++++++++++++++++++++++ test/shell.d/bind-hold-test.sh | 71 +++++++++++++++++++++++++++++ test/shell.d/dictation-test.sh | 13 ++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 test/shell.d/bind-hold-test.sh diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua index b486d794..aa3e2c69 100644 --- a/default/hypr/bindings/dictation.lua +++ b/default/hypr/bindings/dictation.lua @@ -1,9 +1,7 @@ if o.shell_succeeds("omarchy-default-dictation") then o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") - o.bind("F9", "Start dictation (push-to-talk)", "omarchy-dictation start") - o.bind("F9", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true }) + o.bind_hold("F9", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop") -- A modifier's mask changes between its press and release. Match the keysym -- independently of that mask; AltGr layouts use a different keysym. - o.bind("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", { ignore_mods = true }) - o.bind("ALT + Alt_R", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true, ignore_mods = true }) + o.bind_hold("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { ignore_mods = true }) end diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index 64e13c75..bd5e109f 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -206,6 +206,63 @@ function o.bind_toggle(keys, description, toggle, options) o.bind(keys, description, "omarchy-toggle-" .. toggle, options) end +-- Bind one action to a key's press and another to its release, as for +-- push-to-talk. Hyprland skips a plain release bind once another key or mouse +-- button is released during the hold, or once the modifiers have changed, so +-- typing while holding the key would never run the release. The release half +-- here is transparent and ignores modifiers, and runs only after its own press +-- did, so letting go of the key without the rest of the chord does nothing. It +-- is also non-consuming: a release bind that ignores modifiers would otherwise +-- take the key from apps whenever it is pressed with other modifiers, such as +-- Shift+F9, or without them, such as a plain x for SUPER + X. +function o.bind_hold(keys, press_description, press, release_description, release, options) + local held = false + press, release = command_from(press, press_description), command_from(release, release_description) + + local function run(dispatcher) + if type(dispatcher) == "string" then + hl.exec_cmd(dispatcher) + elseif type(dispatcher) == "function" then + dispatcher() + else + hl.dispatch(dispatcher) + end + end + + local function on_press() + held = true + run(press) + end + + local function on_release() + if held then + held = false + run(release) + end + end + + if type(press) == "string" then + o.bind_commands[on_press] = press + end + if type(release) == "string" then + o.bind_commands[on_release] = release + end + + local press_options, release_options = {}, {} + for key, value in pairs(options or {}) do + press_options[key], release_options[key] = value, value + end + -- Options that change when the press half fires would break the release half. + for _, key in ipairs({ "repeating", "long_press", "click", "drag" }) do + release_options[key] = nil + end + release_options.release, release_options.transparent = true, true + release_options.ignore_mods, release_options.non_consuming = true, true + + o.bind(keys, press_description, on_press, press_options) + o.bind(keys, release_description, on_release, release_options) +end + function o.notify(message) return "omarchy-notification-send -u low " .. shell_quote(message) end diff --git a/test/shell.d/bind-hold-test.sh b/test/shell.d/bind-hold-test.sh new file mode 100644 index 00000000..a28f90bc --- /dev/null +++ b/test/shell.d/bind-hold-test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command lua + +HOME="$(mktemp -d)" OMARCHY_PATH="$ROOT" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local binds, ran = {}, {} +hl = setmetatable({ + dsp = setmetatable({}, { + __index = function(_, name) + return function(arg) return { dsp = name, arg = arg } end + end, + }), + bind = function(keys, dispatcher, opts) + table.insert(binds, { keys = keys, dispatcher = dispatcher, opts = opts }) + end, + exec_cmd = function(command) table.insert(ran, command) end, + dispatch = function(dispatcher) table.insert(ran, dispatcher.dsp .. " " .. dispatcher.arg) end, +}, { + __index = function() + return function() return {} end + end, +}) + +require("default.hypr.helpers") + +local function ran_since(fn) + local before = #ran + fn() + return table.concat({ table.unpack(ran, before + 1) }, "\n") +end + +local options = { locked = true } +o.bind_hold("SUPER + X", "Start", "start-cmd", "Stop", "stop-cmd", options) +local press, release = binds[1], binds[2] +assert(press.keys == "SUPER + X" and release.keys == "SUPER + X", "both halves bind the same keys") +assert(press.opts.description == "Start" and release.opts.description == "Stop", "each half keeps its description") +assert(not press.opts.release and not press.opts.transparent, "the press half is an ordinary bind") +assert(release.opts.release and release.opts.transparent and release.opts.ignore_mods, + "the release half survives keys typed and modifiers changed during the hold") +assert(release.opts.non_consuming, "the release half leaves the key to apps when its press did not run") +assert(press.opts.locked and release.opts.locked, "caller options apply to both halves") +assert(options.release == nil and options.description == nil, "caller options are not modified") +assert(o.bind_commands[press.dispatcher] == "start-cmd" and o.bind_commands[release.dispatcher] == "stop-cmd", + "the keybindings menu can run each half") + +assert(ran_since(release.dispatcher) == "", "a release without its press does nothing") +assert(ran_since(press.dispatcher) == "start-cmd", "the press runs its command") +assert(ran_since(release.dispatcher) == "stop-cmd", "the release ends what the press started") +assert(ran_since(release.dispatcher) == "", "a release runs once per press") + +o.bind_hold("SUPER + R", "Start", "start-cmd", "Stop", "stop-cmd", + { repeating = true, long_press = true, click = true, drag = true, locked = true }) +local repeat_release, repeat_press = table.remove(binds), table.remove(binds) +assert(repeat_press.opts.repeating, "the press half keeps its options") +for _, key in ipairs({ "repeating", "long_press", "click", "drag" }) do + assert(repeat_release.opts[key] == nil, "the release half drops " .. key) +end +assert(repeat_release.opts.locked, "the release half keeps other options") + +o.bind_hold("SUPER + Z", "Start", hl.dsp.exec_cmd("start-dsp"), "Stop", function() table.insert(ran, "stop-fn") end) +assert(ran_since(binds[3].dispatcher) == "exec_cmd start-dsp", "dispatchers are dispatched") +assert(ran_since(binds[4].dispatcher) == "stop-fn", "functions are called") +assert(ran_since(binds[2].dispatcher) == "", "each hold binding tracks its own press") +LUA +pass "hold bindings pair each release with its press" diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh index 57c67638..f8793310 100644 --- a/test/shell.d/dictation-test.sh +++ b/test/shell.d/dictation-test.sh @@ -219,12 +219,25 @@ o = { return configured end, bind = function(...) table.insert(bindings, {...}) end, + bind_hold = function(keys, _, press, _, release, options) + table.insert(bindings, { keys, press, options }) + table.insert(bindings, { keys, release, options }) + end, } dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 0, "unconfigured dictation leaves application keys available") configured = true dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 5, "any configured backend receives the same bindings") +local function binds(keys, command) + for _, binding in ipairs(bindings) do + if binding[1] == keys and binding[2] == command then return true end + end +end +for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do + assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"), + keys .. " starts dictation on press and stops it on release") +end LUA pass "dictation shortcuts require a selection without limiting backend names" From 9d48fc402edf51ae089a7c8c1f7b1a8611744bd8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 17:02:42 +0200 Subject: [PATCH 24/34] Install gliff by default and show when a remote session is active (#14712) * Install gliff by default Gliff is the Hyprland remote desktop over SSH. Ship it in the base package set for new installs, with a migration for existing ones. Co-Authored-By: Claude Fable 5.1 * Show a bar indicator while a gliff remote session is active The machine being controlled should make it obvious that someone is on it. A first-party remote-session service watches Hyprland's screencast events, which gliff-server triggers through ext-image-copy-capture, and probes for the server process, picking up the ssh client address from its environment. The RemoteSession indicator in the default tray lights up in the urgent colour while a session runs, and a notification announces each session start and end. Co-Authored-By: Claude Fable 5.1 * Address review of the remote session indicator Probe only the current user's gliff-server processes, forward refresh through the third-party service proxy so cloned indicator widgets can re-probe, exercise the Hyprland screencast event path in the service test with a grim capture instead of calling refresh by hand, and describe the indicator as status only in the manual. Co-Authored-By: Claude Fable 5.1 * Drop the remote session notifications The bar indicator is the signal; a toast on every connect and disconnect is noise on top of it. Co-Authored-By: Claude Fable 5.1 * Tighten remote session detection after review Hyprland reports a screencast stopped as soon as frames pause for half a second and says nothing more when the server later exits, so poll every two seconds while a session is active instead of trusting a stop event. Match only the screencast event, not its screencastv2 twin, and debounce the burst around one transition so it costs one probe. Move the probe into its own script that the service and the test both run, poll for state in the service test instead of fixed delays, and drop the unused session count. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- install/omarchy-base.packages | 1 + manual/05-the-top-bar.md | 2 +- migrations/1791542175.sh | 3 + shell/plugins/README.md | 1 + .../plugins/bar/indicators/RemoteSession.qml | 26 ++++++ .../bar/widgets/Indicators.manifest.json | 5 + shell/plugins/bar/widgets/Indicators.qml | 2 +- .../remote-session/RemoteSessionModel.js | 31 +++++++ .../services/remote-session/Service.qml | 79 ++++++++++++++++ .../services/remote-session/manifest.json | 14 +++ .../plugins/services/remote-session/probe.sh | 11 +++ shell/services/PluginFirstPartyServiceApi.qml | 7 ++ shell/shell.qml | 16 +++- .../fixtures/indicator-contract/shell.qml | 25 +++++ .../shell.d/fixtures/remote-session/shell.qml | 84 +++++++++++++++++ test/shell.d/remote-session-service-test.sh | 92 +++++++++++++++++++ test/shell.d/remote-session-test.sh | 88 ++++++++++++++++++ 17 files changed, 483 insertions(+), 4 deletions(-) create mode 100644 migrations/1791542175.sh create mode 100644 shell/plugins/bar/indicators/RemoteSession.qml create mode 100644 shell/plugins/services/remote-session/RemoteSessionModel.js create mode 100644 shell/plugins/services/remote-session/Service.qml create mode 100644 shell/plugins/services/remote-session/manifest.json create mode 100755 shell/plugins/services/remote-session/probe.sh create mode 100644 test/shell.d/fixtures/remote-session/shell.qml create mode 100644 test/shell.d/remote-session-service-test.sh create mode 100644 test/shell.d/remote-session-test.sh diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 35fd3feb..69cd36c7 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -43,6 +43,7 @@ fontconfig foot fzf git +gliff gnome-keyring gnome-themes-extra grim diff --git a/manual/05-the-top-bar.md b/manual/05-the-top-bar.md index 41646560..9f7b4e79 100644 --- a/manual/05-the-top-bar.md +++ b/manual/05-the-top-bar.md @@ -73,7 +73,7 @@ Removing either service takes its widget back off the bar. ## Indicators -The little cluster in the center is the indicators widget. These are status glyphs for modes you've turned on: do not disturb, night light, a queued [reminder](09-reminders.md), an active screen recording, stay awake, and [dictation](11-text-extraction-dictation.md). They light up when the mode is active and otherwise stay out of the way — hover to the left of the clock (above it on a vertical bar) to peek at the inactive ones. Clicking an indicator toggles that mode. +The little cluster in the center is the indicators widget. These are status glyphs for modes you've turned on: do not disturb, night light, a queued [reminder](09-reminders.md), an active screen recording, stay awake, and [dictation](11-text-extraction-dictation.md). They light up when the mode is active and otherwise stay out of the way — hover to the left of the clock (above it on a vertical bar) to peek at the inactive ones. Clicking an indicator toggles that mode. One more is status only: a remote session lights up red while someone is connected to this machine through gliff. If you'd rather they were always visible, set `alwaysShow` to `true` on the widget. And if you only care about some of them, list the ones you want in `items`: `["Dnd", "Reminder", "NightLight"]`. You can have more than one indicators widget, so different sections can show different subsets. diff --git a/migrations/1791542175.sh b/migrations/1791542175.sh new file mode 100644 index 00000000..40bc4ba4 --- /dev/null +++ b/migrations/1791542175.sh @@ -0,0 +1,3 @@ +echo "Install Gliff, the Hyprland remote desktop over SSH" + +omarchy-pkg-add gliff diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 092dd20c..4acd9ee2 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -34,6 +34,7 @@ User-installed plugins live alongside these conceptually but on disk under | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | | Idle | `omarchy.idle` | `service` | `services/idle/Service.qml` | | Night light | `omarchy.nightlight` | `service` | `services/nightlight/Service.qml` | +| Remote session | `omarchy.remote-session` | `service` | `services/remote-session/Service.qml` | | Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` | | OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` | | Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` | diff --git a/shell/plugins/bar/indicators/RemoteSession.qml b/shell/plugins/bar/indicators/RemoteSession.qml new file mode 100644 index 00000000..0f0eef7d --- /dev/null +++ b/shell/plugins/bar/indicators/RemoteSession.qml @@ -0,0 +1,26 @@ +import QtQuick +import qs.Commons as Commons +import qs.Ui + +BarIndicator { + id: root + + readonly property var remoteSessionService: bar?.shell?.firstPartyServiceFor("omarchy.remote-session") + readonly property var peers: remoteSessionService && Array.isArray(remoteSessionService.peers) ? remoteSessionService.peers : [] + + active: remoteSessionService ? remoteSessionService.active : false + activeText: "󰢹" + inactiveText: "󰢹" + activeTooltipText: peers.length > 0 ? "Remote session from " + peers.join(", ") : "Remote session" + inactiveTooltipText: "Remote Session" + useActiveColor: true + activeColor: Commons.Color.urgent + + Connections { + target: root.indicatorHost + ignoreUnknownSignals: true + function onRefreshRequested() { + if (root.remoteSessionService) root.remoteSessionService.refresh() + } + } +} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 9e9ee40b..a2447aae 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -36,6 +36,11 @@ "label": "Screen recording", "description": "GPU screen recorder status" }, + { + "value": "RemoteSession", + "label": "Remote session", + "description": "Warning while someone is connected through gliff" + }, { "value": "Reminder", "label": "Reminder", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index d5679267..7d4fe564 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: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "RemoteSession", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/shell/plugins/services/remote-session/RemoteSessionModel.js b/shell/plugins/services/remote-session/RemoteSessionModel.js new file mode 100644 index 00000000..39a8ccac --- /dev/null +++ b/shell/plugins/services/remote-session/RemoteSessionModel.js @@ -0,0 +1,31 @@ +// Hyprland emits this whenever a screencopy session starts or stops, which +// covers gliff-server (ext-image-copy-capture) as well as screen recorders +// and browser screen shares, so the event is only a cue to re-probe. The +// matching screencastv2 event always accompanies it and is left alone so one +// transition costs one probe. +function isCaptureEvent(name) { + return String(name || "") === "screencast" +} + +// Parses the probe output: one " [peer]" line per running gliff-server, +// where peer is the ssh client address when the server was spawned over ssh. +function stateFromOutput(text) { + var lines = String(text || "").split("\n") + var active = false + var peers = [] + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].trim().split(/\s+/) + if (parts[0] === "") continue + active = true + var peer = parts[1] || "" + if (peer !== "" && peers.indexOf(peer) === -1) peers.push(peer) + } + return { active: active, peers: peers } +} + +if (typeof module !== "undefined") { + module.exports = { + isCaptureEvent: isCaptureEvent, + stateFromOutput: stateFromOutput + } +} diff --git a/shell/plugins/services/remote-session/Service.qml b/shell/plugins/services/remote-session/Service.qml new file mode 100644 index 00000000..5d3d734f --- /dev/null +++ b/shell/plugins/services/remote-session/Service.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import "RemoteSessionModel.js" as RemoteSessionModel + +// Watches for gliff-server, the Hyprland remote desktop, so the machine being +// controlled can see that someone is on it: the RemoteSession bar indicator +// reads `active` and `peers`. +Item { + id: root + + // Injected by omarchy-shell (the first-party service loader). + property var shell: null + + property bool stateLoaded: false + property bool active: false + property var peers: [] + + property bool refreshPending: false + + function refresh() { + if (statusProbe.running) { + root.refreshPending = true + return + } + root.refreshPending = false + statusProbe.running = true + } + + function applyProbe(text) { + var state = RemoteSessionModel.stateFromOutput(text) + root.peers = state.peers + root.active = state.active + root.stateLoaded = true + } + + Component.onCompleted: refresh() + + // gliff-server captures the screen through ext-image-copy-capture, so a + // session start surfaces as a Hyprland screencast event. The probe decides + // whether it was gliff or another screencopy client, and a short debounce + // folds the event bursts Hyprland sends around one transition into one probe. + Connections { + target: Hyprland + function onRawEvent(event) { + if (RemoteSessionModel.isCaptureEvent(event ? event.name : "")) eventDebounce.restart() + } + } + + Timer { + id: eventDebounce + interval: 150 + repeat: false + onTriggered: root.refresh() + } + + // The end of a session often has no event: Hyprland reports the screencast + // stopped as soon as frames pause for half a second, and says nothing more + // when the server later exits. So poll while a session is active. + Timer { + interval: 2000 + repeat: true + running: root.active + onTriggered: root.refresh() + } + + Process { + id: statusProbe + command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/probe.sh"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.applyProbe(text) + } + onExited: function() { + if (root.refreshPending) Qt.callLater(root.refresh) + } + } +} diff --git a/shell/plugins/services/remote-session/manifest.json b/shell/plugins/services/remote-session/manifest.json new file mode 100644 index 00000000..cf38ad31 --- /dev/null +++ b/shell/plugins/services/remote-session/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.remote-session", + "name": "Remote Session", + "version": "1.0.0", + "author": "Omarchy", + "description": "Detects a gliff remote-desktop session on this machine for the bar indicator.", + "kinds": [ + "service" + ], + "entryPoints": { + "service": "Service.qml" + } +} diff --git a/shell/plugins/services/remote-session/probe.sh b/shell/plugins/services/remote-session/probe.sh new file mode 100755 index 00000000..41e48bae --- /dev/null +++ b/shell/plugins/services/remote-session/probe.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Lists this user's running gliff-server processes, one " [peer]" line +# each. The peer is the ssh client address when the server was spawned over +# ssh (the real path); a local --listen server has none. Another account's +# server captures its own desktop, not this one, so other users are skipped. + +for pid in $(pgrep -x -u "$(id -u)" gliff-server); do + peer=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=\([^ ]*\).*/\1/p') + printf '%s %s\n' "$pid" "$peer" +done diff --git a/shell/services/PluginFirstPartyServiceApi.qml b/shell/services/PluginFirstPartyServiceApi.qml index 1f052629..e39dfd54 100644 --- a/shell/services/PluginFirstPartyServiceApi.qml +++ b/shell/services/PluginFirstPartyServiceApi.qml @@ -11,6 +11,8 @@ QtObject { property bool doNotDisturb: false property var activePlayer: null property var sourcePlayers: [] + property bool active: false + property var peers: [] property var _setIdleEnabled: null property var _setNightlight: null @@ -18,6 +20,7 @@ QtObject { property var _runAction: null property var _playerKey: null property var _selectPlayer: null + property var _refresh: null function setIdleEnabled(value) { if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value) @@ -43,4 +46,8 @@ QtObject { function selectPlayer(playerId) { if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || "")) } + + function refresh() { + if (serviceId === "omarchy.remote-session" && _refresh) _refresh() + } } diff --git a/shell/shell.qml b/shell/shell.qml index ea6e6de8..b89831ed 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -456,7 +456,7 @@ ShellRoot { function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) { var id = String(requestedId || "") - var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"] if (allowed.indexOf(id) === -1) return null var proxyKey = cacheKey + "::" + id if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey] @@ -490,6 +490,10 @@ ShellRoot { _selectPlayer: function(playerKey) { var target = service() if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey) + }, + _refresh: function() { + var target = service() + if (target && typeof target.refresh === "function") target.refresh() } }) if (!api) return null @@ -513,6 +517,14 @@ ShellRoot { var target = service() return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : [] }) + api.active = Qt.binding(function() { + var target = service() + return target ? target.active === true : false + }) + api.peers = Qt.binding(function() { + var target = service() + return target && Array.isArray(target.peers) ? target.peers : [] + }) var next = ({}) for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing] next[proxyKey] = api @@ -588,7 +600,7 @@ ShellRoot { // property, even though the resulting proxy is otherwise acyclic. var firstPartyServices = ({}) if (barCapabilities) { - var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"] + var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"] for (var i = 0; i < serviceIds.length; i++) { var serviceId = serviceIds[i] firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId) diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index 4b3f04c3..79b19e22 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -41,12 +41,23 @@ ShellRoot { } } + QtObject { + id: remoteSessionService + property bool active: false + property var peers: [] + property int refreshes: 0 + function refresh() { + refreshes++ + } + } + QtObject { id: mockShell function firstPartyServiceFor(id) { if (id === "omarchy.notifications") return notificationService if (id === "omarchy.idle") return idleService if (id === "omarchy.nightlight") return nightlightService + if (id === "omarchy.remote-session") return remoteSessionService return null } } @@ -74,6 +85,7 @@ ShellRoot { QtObject { id: indicatorHost property bool revealInactiveIndicators: true + signal refreshRequested() } function createIndicator(name) { @@ -198,6 +210,19 @@ ShellRoot { root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording") } + var remoteSession = root.createIndicator("RemoteSession") + if (remoteSession) { + remoteSession.moduleName = "RemoteSession" + root.injectBar(remoteSession) + root.assertTrue(remoteSession.active === false, "Remote Session starts inactive without a gliff server") + remoteSessionService.active = true + remoteSessionService.peers = ["10.0.0.5"] + root.assertTrue(remoteSession.active === true, "Remote Session follows the service state") + root.assertTrue(remoteSession.tooltipText === "Remote session from 10.0.0.5", "Remote Session tooltip names the connected peer") + indicatorHost.refreshRequested() + root.assertTrue(remoteSessionService.refreshes === 1, "Remote Session refresh re-probes the service") + } + var stayAwake = root.createIndicator("StayAwake") if (stayAwake) { stayAwake.moduleName = "StayAwake" diff --git a/test/shell.d/fixtures/remote-session/shell.qml b/test/shell.d/fixtures/remote-session/shell.qml new file mode 100644 index 00000000..9b090f06 --- /dev/null +++ b/test/shell.d/fixtures/remote-session/shell.qml @@ -0,0 +1,84 @@ +import QtQuick +import Quickshell + +// Drives the real remote-session service against a stub gliff-server on PATH: +// idle at start, active with the ssh peer once a server runs, idle again after +// it exits. The service is never refreshed by hand: a one-shot grim capture +// raises the same Hyprland screencast event that gliff-server does, so the +// event path is what gets exercised. +ShellRoot { + id: root + + property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property string stubPidFile: Quickshell.env("OMARCHY_QML_TEST_STUB_PID") + property var failures: [] + property var service: null + + function fail(message) { + failures.push(String(message)) + } + + function assertTrue(condition, message) { + if (!condition) fail(message) + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult() { + var payload = JSON.stringify({ ok: failures.length === 0, failures: failures }) + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + + function captureFrame() { + Quickshell.execDetached(["grim", "-g", "0,0 1x1", Quickshell.env("OMARCHY_QML_TEST_FRAME")]) + } + + // Polls `condition` every 100 ms for up to `timeout` ms, then continues with + // `next` either way, recording `message` as a failure on timeout. `tick`, + // when given, runs every 500 ms while waiting. + function waitFor(condition, timeout, message, next, tick) { + var timer = Qt.createQmlObject("import QtQuick; Timer { repeat: true; interval: 100 }", root) + var waited = 0 + timer.triggered.connect(function() { + waited += timer.interval + if (tick && waited % 500 === 0) tick() + if (!condition() && waited < timeout) return + timer.stop() + timer.destroy() + root.assertTrue(condition(), message) + next() + }) + timer.start() + } + + Component.onCompleted: { + var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/Service.qml") + if (component.status !== Component.Ready) { + fail("remote session service failed to load: " + component.errorString()) + writeResult() + return + } + service = component.createObject(root, { shell: null }) + if (!service) { + fail("remote session service failed to instantiate: " + component.errorString()) + writeResult() + return + } + + waitFor(function() { return service.stateLoaded === true }, 3000, "service probes on startup", function() { + root.assertTrue(service.active === false, "service starts idle without a gliff server") + Quickshell.execDetached(["bash", "-c", "SSH_CONNECTION='10.0.0.5 51234 10.0.0.1 22' gliff-server --stdio & echo $! > " + root.shellQuote(root.stubPidFile)]) + // Each capture raises a screencast event; the first one after the stub + // is up is what should flip the service, with no refresh() by hand. + waitFor(function() { return service.active === true }, 4000, "a screencast event makes the service report the session while gliff-server runs", function() { + root.assertTrue(JSON.stringify(service.peers) === JSON.stringify(["10.0.0.5"]), "service reports the ssh peer, got " + JSON.stringify(service.peers)) + Quickshell.execDetached(["bash", "-c", "kill \"$(cat " + root.shellQuote(root.stubPidFile) + ")\""]) + waitFor(function() { return service.active === false }, 5000, "service returns to idle once gliff-server exits", function() { + root.writeResult() + }) + }, root.captureFrame) + }) + } +} diff --git a/test/shell.d/remote-session-service-test.sh b/test/shell.d/remote-session-service-test.sh new file mode 100644 index 00000000..b3abefbe --- /dev/null +++ b/test/shell.d/remote-session-service-test.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + if [[ -n $TMPDIR && -f $TMPDIR/stub.pid ]]; then + kill "$(<"$TMPDIR/stub.pid")" 2>/dev/null || true + fi + if [[ -n $TMPDIR && -d $TMPDIR ]]; then + rm -rf "$TMPDIR" + fi +} +trap cleanup EXIT + +require_compositor "remote session service test" + +if ! command -v quickshell >/dev/null 2>&1; then + skip "quickshell not installed; skipping remote session service test" + exit 0 +fi + +require_command jq +require_command grim + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/remote-session" +mkdir -p "$config_dir" "$TMPDIR/home" "$TMPDIR/bin" +cp "$SHELL_TEST_DIR/fixtures/remote-session/shell.qml" "$config_dir/shell.qml" + +# A stand-in for the real server: the probe matches it by process name, so it +# must stay a script called gliff-server rather than exec into sleep, and it +# takes its sleep down with it when killed. Its ssh peer comes from the +# environment the fixture launches it with. It cannot capture the screen, so +# the fixture uses grim for the screencast event. +cat >"$TMPDIR/bin/gliff-server" <<'SH' +#!/bin/bash +sleep 30 & +trap 'kill $!' TERM EXIT +wait $! +SH + +chmod +x "$TMPDIR/bin/gliff-server" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +OMARCHY_QML_TEST_STUB_PID="$TMPDIR/stub.pid" \ +OMARCHY_QML_TEST_FRAME="$TMPDIR/frame.png" \ +HOME="$TMPDIR/home" \ +XDG_CONFIG_HOME="$TMPDIR/home/.config" \ +XDG_CACHE_HOME="$TMPDIR/home/.cache" \ +XDG_STATE_HOME="$TMPDIR/home/.local/state" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$TMPDIR/bin:$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..150}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,160p' "$log" >&2 + fail "remote session quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,160p' "$log" >&2 + fail "remote session service test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + jq . "$result" >&2 + sed -n '1,160p' "$log" >&2 + fail "remote session service tracks a gliff-server session" +fi +pass "remote session service tracks a gliff-server session" + +[[ -s $TMPDIR/frame.png ]] || fail "the fixture raised screencast events through grim" +pass "the fixture raised screencast events through grim" + diff --git a/test/shell.d/remote-session-test.sh b/test/shell.d/remote-session-test.sh new file mode 100644 index 00000000..ea856eb6 --- /dev/null +++ b/test/shell.d/remote-session-test.sh @@ -0,0 +1,88 @@ +#!/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 model = requireFromRoot('shell/plugins/services/remote-session/RemoteSessionModel.js') + +assert(model.isCaptureEvent('screencast'), 'the screencast event triggers a probe') +assert(!model.isCaptureEvent('screencastv2'), 'the paired screencastv2 event does not double the probe') +assert(!model.isCaptureEvent('openwindow') && !model.isCaptureEvent(''), 'unrelated Hyprland events are ignored') + +assertDeepEqual(model.stateFromOutput(''), { active: false, peers: [] }, 'no gliff-server means no session') +assertDeepEqual(model.stateFromOutput('\n'), { active: false, peers: [] }, 'blank output means no session') +assertDeepEqual(model.stateFromOutput('4321 \n'), { active: true, peers: [] }, 'a local server without ssh is a session with no known peer') +assertDeepEqual(model.stateFromOutput('4321 10.0.0.5\n'), { active: true, peers: ['10.0.0.5'] }, 'an ssh-spawned server reports the client address') +assertDeepEqual(model.stateFromOutput('1 10.0.0.5\n2 10.0.0.5\n3 fd00::9\n'), { active: true, peers: ['10.0.0.5', 'fd00::9'] }, 'peers are listed once each') + +const service = read('shell/plugins/services/remote-session/Service.qml') +const manifest = JSON.parse(read('shell/plugins/services/remote-session/manifest.json')) +assertEqual(manifest.id, 'omarchy.remote-session', 'service manifest id matches the indicator lookup') +assert(manifest.kinds.includes('service') && manifest.entryPoints.service === 'Service.qml', 'service manifest declares a service entry point') +assert(service.includes('target: Hyprland') && service.includes('isCaptureEvent(event ? event.name : "")'), 'service re-probes on Hyprland screencast events') +assert(service.includes('eventDebounce.restart()'), 'service debounces event bursts into one probe') +assert(service.includes('/shell/plugins/services/remote-session/probe.sh"'), 'service runs the shared probe script') +assert(service.includes('running: root.active') && service.includes('interval: 2000'), 'service polls only while a session is active, since the stop event is not reliable') +assert(!service.includes('omarchy-notification-send'), 'a session is shown by the indicator alone, with no notification') + +const indicator = read('shell/plugins/bar/indicators/RemoteSession.qml') +const widget = read('shell/plugins/bar/widgets/Indicators.qml') +const widgetManifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json')) +assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "ScreenRecording", "RemoteSession"/), 'remote session is included in the default indicator tray') +assert(widgetManifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'RemoteSession'), 'remote session is configurable alongside the other indicators') +assert(indicator.includes('firstPartyServiceFor("omarchy.remote-session")'), 'indicator reads the remote session service') +assert(indicator.includes('"Remote session from " + peers.join(", ")'), 'active tooltip names the connected peers') +assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Commons.Color.urgent'), 'active remote session uses the theme danger color') +assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'remote session keeps the same icon in both states') +assert(!indicator.includes('visible:'), 'remote session uses the shared indicator visibility and hover behavior') +assert(indicator.includes('root.remoteSessionService.refresh()'), 'indicator refresh re-probes the service') + +const shell = read('shell/shell.qml') +assert((shell.match(/"omarchy\.remote-session"/g) || []).length >= 2, 'third-party bar clones can read the remote session service') +const api = read('shell/services/PluginFirstPartyServiceApi.qml') +assert(api.includes('property bool active: false') && api.includes('property var peers: []'), 'service proxy exposes the remote session state') +assert(api.includes('serviceId === "omarchy.remote-session" && _refresh') && shell.includes('_refresh: function()'), 'service proxy forwards refresh for cloned indicator widgets') +JS + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT +mkdir -p "$TMPDIR/bin" "$TMPDIR/proc/100" "$TMPDIR/proc/200" + +probe_script="$ROOT/shell/plugins/services/remote-session/probe.sh" +bash -n "$probe_script" || fail "remote session probe is valid bash" +pass "remote session probe is valid bash" + +cat >"$TMPDIR/bin/pgrep" <<'SH' +#!/bin/bash +[[ $1 == "-x" && $2 == "-u" && $3 == "$(id -u)" && $4 == "gliff-server" ]] || exit 2 +[[ -n ${GLIFF_PIDS:-} ]] || exit 1 +printf '%s\n' $GLIFF_PIDS +SH +chmod +x "$TMPDIR/bin/pgrep" + +# The probe reads /proc//environ; point a copy at fixtures instead. +sed "s|/proc/\$pid/environ|$TMPDIR/proc/\$pid/environ|" "$probe_script" >"$TMPDIR/probe.sh" +grep -q "$TMPDIR/proc" "$TMPDIR/probe.sh" || fail "probe reads the server environment from /proc" +pass "probe reads the server environment from /proc" + +probe() { + PATH="$TMPDIR/bin:$PATH" GLIFF_PIDS="${1:-}" bash "$TMPDIR/probe.sh" +} + +[[ -z $(probe) ]] || fail "probe prints nothing without a gliff server" +pass "probe prints nothing without a gliff server" + +printf 'HOME=/home/x\0SSH_CONNECTION=10.0.0.5 51234 10.0.0.1 22\0TERM=foot\0' >"$TMPDIR/proc/100/environ" +printf 'HOME=/home/x\0TERM=foot\0' >"$TMPDIR/proc/200/environ" + +[[ $(probe "100") == "100 10.0.0.5" ]] || fail "probe reports the ssh client address of a spawned server" +pass "probe reports the ssh client address of a spawned server" + +[[ $(probe "200") == "200 " ]] || fail "probe reports a local server with an empty peer" +pass "probe reports a local server with an empty peer" + +(( $(probe "100 200" | wc -l) == 2 )) || fail "probe lists every server" +pass "probe lists every server" From f03bf06742a2ed4535d326e3220c685bca5aa0ed Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 23:26:20 +0200 Subject: [PATCH 25/34] Stop the boot from waiting on a black screen for the console to answer About 1 boot in 10 of an installed system stops on a black screen at "Starting Switch Root" until a key is pressed. systemd 257 and later asks the console for its size with ANSI escape sequences when PID 1 starts and waits for the reply; with Plymouth holding the VT the reply sometimes never comes. Measured in QEMU on repeated boots of one install: 3 hangs in 30 and 1 in 40 on the prebuilt UKI, 7 in 40 on the stock initramfs, every one of them ended by pressing Enter; none in 40 with a serial console, none in 40 with plymouth.enable=0, and none in 80 with systemd.tty.term.console=dumb, which makes systemd skip the query. systemd fixed two variants of this in v261; Arch's 261.2 still shows it. The parameter goes where the last Plymouth boot fix went (3f725531, initramfs_async=0): into the packaged omarchy-defaults.conf for fresh installs, and a migration rebuilds the boot image on existing systems, once per machine, only where the booted command line lacks it. Nothing visible changes under "quiet splash". Refs #2931 --- etc/limine-entry-tool.d/omarchy-defaults.conf | 9 +++ migrations/1791581004.sh | 31 +++++++++ .../console-no-ansi-query-migration-test.sh | 66 +++++++++++++++++++ test/shell.d/limine-defaults-test.sh | 4 ++ 4 files changed, 110 insertions(+) create mode 100644 migrations/1791581004.sh create mode 100644 test/shell.d/console-no-ansi-query-migration-test.sh diff --git a/etc/limine-entry-tool.d/omarchy-defaults.conf b/etc/limine-entry-tool.d/omarchy-defaults.conf index ec0fa96f..1e421365 100644 --- a/etc/limine-entry-tool.d/omarchy-defaults.conf +++ b/etc/limine-entry-tool.d/omarchy-defaults.conf @@ -9,6 +9,15 @@ KERNEL_CMDLINE[default]+=" quiet splash loglevel=0 systemd.show_status=false rd. # is fixed upstream. KERNEL_CMDLINE[default]+=" initramfs_async=0" +# systemd 257 and later asks the console for its size with ANSI escape +# sequences when PID 1 starts, and waits for the reply. With Plymouth holding +# the VT the reply sometimes never comes, and the boot sits on a black screen +# at "Starting Switch Root" until a key is pressed: about 1 boot in 10. A dumb +# $TERM for the console makes systemd skip the query. Nothing visible changes +# under "quiet splash". Drop it once a systemd release boots clean in a +# repeated boot test. +KERNEL_CMDLINE[default]+=" systemd.tty.term.console=dumb" + CUSTOM_UKI_NAME="omarchy" ENABLE_LIMINE_FALLBACK=yes diff --git a/migrations/1791581004.sh b/migrations/1791581004.sh new file mode 100644 index 00000000..c95a74ff --- /dev/null +++ b/migrations/1791581004.sh @@ -0,0 +1,31 @@ +echo "Stop the boot from waiting on a black screen for the console to answer" + +# systemd asks the console for its size when PID 1 starts and waits for the +# reply. With Plymouth holding the screen the reply sometimes never comes, and +# about 1 boot in 10 sits on a black screen until a key is pressed. +# omarchy-defaults.conf now carries systemd.tty.term.console=dumb, which makes +# systemd skip the query. The package has already written that file by the +# time this runs; the boot image still holds the old command line until it is +# rebuilt. + +defaults_conf="${OMARCHY_LIMINE_DEFAULTS_CONF:-/etc/limine-entry-tool.d/omarchy-defaults.conf}" +running_cmdline="${OMARCHY_RUNNING_CMDLINE:-/proc/cmdline}" +rebuild_marker="${OMARCHY_LIMINE_REBUILD_MARKER:-/var/lib/omarchy/migrations/1791581004}" +param="systemd.tty.term.console=dumb" + +omarchy-cmd-present limine-mkinitcpio || exit 0 +[[ -f $defaults_conf && -r $running_cmdline ]] || exit 0 + +# The running kernel keeps its old command line until reboot, so a marker +# records the machine-wide rebuild: another user's migration must not repeat +# it before then, while a missing marker still retries an interrupted rebuild. +[[ ! -e $rebuild_marker ]] || exit 0 + +# A machine that boots with it already, or whose defaults were edited to leave +# it out, has nothing to rebuild. +[[ " $(<"$running_cmdline") " != *" $param "* ]] || exit 0 +grep -Eq "^KERNEL_CMDLINE\[default\]\+=\".*${param//./\\.}.*\"" "$defaults_conf" || exit 0 + +echo "The booted kernel is missing $param; rebuilding the boot image" +sudo limine-mkinitcpio +sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/test/shell.d/console-no-ansi-query-migration-test.sh b/test/shell.d/console-no-ansi-query-migration-test.sh new file mode 100644 index 00000000..d0c2b1d0 --- /dev/null +++ b/test/shell.d/console-no-ansi-query-migration-test.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# +# The migration that puts systemd.tty.term.console=dumb into effect on an +# existing install: the package has already written the parameter into +# omarchy-defaults.conf, so what is left is rebuilding the boot image, once per +# machine, and only where it is needed. + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration="$ROOT/migrations/1791581004.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +stub_bin="$test_dir/bin" +mkdir -p "$stub_bin" + +# sudo just drops the prefix; limine-mkinitcpio records that it ran. +printf '#!/bin/bash\nexec "$@"\n' >"$stub_bin/sudo" +printf '#!/bin/bash\necho rebuilt >>"${REBUILDS:?}"\n' >"$stub_bin/limine-mkinitcpio" +chmod +x "$stub_bin"/* + +old_cmdline="root=PARTUUID=1 rw quiet splash initramfs_async=0" +new_cmdline="$old_cmdline systemd.tty.term.console=dumb" + +run() { # booted cmdline, defaults conf + : >"$test_dir/rebuilds" + printf '%s\n' "$1" >"$test_dir/cmdline" + PATH="$stub_bin:$ROOT/bin:$PATH" REBUILDS="$test_dir/rebuilds" \ + OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$2" \ + OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" bash "$migration" >/dev/null +} +rebuilds() { wc -l <"$test_dir/rebuilds"; } + +run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" +[[ $(rebuilds) == 1 && -e $test_dir/marker ]] || + fail "a machine booted without the parameter did not get its boot image rebuilt" +pass "a machine booted without the parameter gets its boot image rebuilt, with the packaged defaults" + +run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" +[[ $(rebuilds) == 0 ]] || fail "the rebuild ran a second time before a reboot" +pass "the rebuild runs once per machine, not once per user" + +rm -f "$test_dir/marker" +run "$new_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" +[[ $(rebuilds) == 0 && ! -e $test_dir/marker ]] || fail "a machine that already boots with the parameter was rebuilt" +pass "a machine that already boots with the parameter is left alone" + +grep -v "tty.term.console" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" >"$test_dir/edited.conf" +run "$old_cmdline" "$test_dir/edited.conf" +[[ $(rebuilds) == 0 ]] || fail "a machine whose defaults leave the parameter out was rebuilt anyway" +pass "a machine whose defaults were edited to leave the parameter out is left alone" + +# No Limine: the stand-in goes, and the PATH holds only what the migration +# needs, so a limine-mkinitcpio installed on the machine running this test is +# not found either. +rm "$stub_bin/limine-mkinitcpio" +mkdir -p "$test_dir/tools" +for tool in bash grep install cat; do ln -s "$(command -v "$tool")" "$test_dir/tools/$tool"; done +: >"$test_dir/rebuilds" +printf '%s\n' "$old_cmdline" >"$test_dir/cmdline" +PATH="$stub_bin:$ROOT/bin:$test_dir/tools" REBUILDS="$test_dir/rebuilds" \ + OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \ + OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" "$test_dir/tools/bash" "$migration" >/dev/null +[[ ! -e $test_dir/marker ]] || fail "a machine without Limine was marked as rebuilt" +pass "a machine without Limine is left alone" diff --git a/test/shell.d/limine-defaults-test.sh b/test/shell.d/limine-defaults-test.sh index 21adc94f..f69e9535 100644 --- a/test/shell.d/limine-defaults-test.sh +++ b/test/shell.d/limine-defaults-test.sh @@ -10,6 +10,10 @@ grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" || fail "the packaged Limine defaults still unpack the initramfs synchronously" pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt" +grep -Fxq 'KERNEL_CMDLINE[default]+=" systemd.tty.term.console=dumb"' "$packaged_defaults" || + fail "the packaged Limine defaults let PID 1 wait for the console to answer a size query" +pass "packaged Limine defaults keep PID 1 from waiting on the console" + grep -Fxq 'BOOT_ORDER="linux-t2, linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' "$packaged_defaults" || fail "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere" pass "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere" From 295a926ffdd8115edf1a3ecd710e43856fae5dbf Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 23:52:07 +0200 Subject: [PATCH 26/34] Test the boot image migration as omarchy-migrate runs it, and a failed rebuild The test ran the migration with plain bash. omarchy-migrate runs it with bash -euo pipefail, and the migration depends on that: it is what stops a failed limine-mkinitcpio before the marker is written, so the next update tries again. The stand-in rebuild could not fail, so nothing held that promise. The test now uses the same options and adds the case: a rebuild that fails ends the migration with an error and leaves no marker, and the next run rebuilds and marks it. --- .../console-no-ansi-query-migration-test.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/shell.d/console-no-ansi-query-migration-test.sh b/test/shell.d/console-no-ansi-query-migration-test.sh index d0c2b1d0..e087b0c4 100644 --- a/test/shell.d/console-no-ansi-query-migration-test.sh +++ b/test/shell.d/console-no-ansi-query-migration-test.sh @@ -15,9 +15,10 @@ trap 'rm -rf "$test_dir"' EXIT stub_bin="$test_dir/bin" mkdir -p "$stub_bin" -# sudo just drops the prefix; limine-mkinitcpio records that it ran. +# sudo just drops the prefix; limine-mkinitcpio records that it ran, and fails +# when told to. printf '#!/bin/bash\nexec "$@"\n' >"$stub_bin/sudo" -printf '#!/bin/bash\necho rebuilt >>"${REBUILDS:?}"\n' >"$stub_bin/limine-mkinitcpio" +printf '#!/bin/bash\necho rebuilt >>"${REBUILDS:?}"\n[[ -z ${REBUILD_FAILS:-} ]]\n' >"$stub_bin/limine-mkinitcpio" chmod +x "$stub_bin"/* old_cmdline="root=PARTUUID=1 rw quiet splash initramfs_async=0" @@ -28,14 +29,22 @@ run() { # booted cmdline, defaults conf printf '%s\n' "$1" >"$test_dir/cmdline" PATH="$stub_bin:$ROOT/bin:$PATH" REBUILDS="$test_dir/rebuilds" \ OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$2" \ - OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" bash "$migration" >/dev/null + OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" bash -euo pipefail "$migration" >/dev/null } rebuilds() { wc -l <"$test_dir/rebuilds"; } +# omarchy-migrate runs a migration with bash -euo pipefail, and so does run: +# that is what stops a failed rebuild before the marker is written. +if REBUILD_FAILS=1 run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"; then + fail "a rebuild that failed was reported as a success" +fi +[[ $(rebuilds) == 1 && ! -e $test_dir/marker ]] || fail "a failed rebuild was marked as done" +pass "a rebuild that fails stops the migration and leaves no marker" + run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" [[ $(rebuilds) == 1 && -e $test_dir/marker ]] || fail "a machine booted without the parameter did not get its boot image rebuilt" -pass "a machine booted without the parameter gets its boot image rebuilt, with the packaged defaults" +pass "the next run rebuilds the boot image, with the packaged defaults, and marks it" run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" [[ $(rebuilds) == 0 ]] || fail "the rebuild ran a second time before a reboot" @@ -61,6 +70,6 @@ for tool in bash grep install cat; do ln -s "$(command -v "$tool")" "$test_dir/t printf '%s\n' "$old_cmdline" >"$test_dir/cmdline" PATH="$stub_bin:$ROOT/bin:$test_dir/tools" REBUILDS="$test_dir/rebuilds" \ OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \ - OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" "$test_dir/tools/bash" "$migration" >/dev/null + OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" "$test_dir/tools/bash" -euo pipefail "$migration" >/dev/null [[ ! -e $test_dir/marker ]] || fail "a machine without Limine was marked as rebuilt" pass "a machine without Limine is left alone" From 3824577c123bcaa0badd821338127c52a32ac620 Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Fri, 9 Oct 2026 23:54:17 +0200 Subject: [PATCH 27/34] Mark the boot image migration done only when a boot entry has the parameter limine-mkinitcpio carries on past a kernel it could not build, with a full boot partition for one, and still exits 0. The migration then wrote its marker over an unchanged boot image, and no later update would try again. After the rebuild it now looks at the boot menu: if the menu has command lines and none carries the parameter, the migration fails and stays pending. A menu with no command lines in it, where they are inside the boot images, cannot be checked this way and is accepted as before. --- migrations/1791581004.sh | 14 +++++++++++++ .../console-no-ansi-query-migration-test.sh | 20 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/migrations/1791581004.sh b/migrations/1791581004.sh index c95a74ff..2fda0f4f 100644 --- a/migrations/1791581004.sh +++ b/migrations/1791581004.sh @@ -12,6 +12,8 @@ defaults_conf="${OMARCHY_LIMINE_DEFAULTS_CONF:-/etc/limine-entry-tool.d/omarchy- running_cmdline="${OMARCHY_RUNNING_CMDLINE:-/proc/cmdline}" rebuild_marker="${OMARCHY_LIMINE_REBUILD_MARKER:-/var/lib/omarchy/migrations/1791581004}" param="systemd.tty.term.console=dumb" +esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine 2>/dev/null | tail -n 1) +limine_conf="${OMARCHY_LIMINE_CONF:-${esp:-/boot}/limine.conf}" omarchy-cmd-present limine-mkinitcpio || exit 0 [[ -f $defaults_conf && -r $running_cmdline ]] || exit 0 @@ -28,4 +30,16 @@ grep -Eq "^KERNEL_CMDLINE\[default\]\+=\".*${param//./\\.}.*\"" "$defaults_conf" echo "The booted kernel is missing $param; rebuilding the boot image" sudo limine-mkinitcpio + +# limine-mkinitcpio carries on past a kernel it could not build, a full boot +# partition for one, and still exits 0. Done means a boot entry has the +# parameter: without that the migration stays pending and the next update +# tries again. A menu with no command lines in it (they are inside the boot +# images then) has nothing to look at, and is taken at its word. +if sudo grep -Eq '^[[:space:]]*cmdline:' "$limine_conf" 2>/dev/null && + ! sudo grep -Eq "^[[:space:]]*cmdline:.*[[:space:]]${param//./\\.}([[:space:]]|\$)" "$limine_conf"; then + echo "The boot menu still has no entry with $param after the rebuild" >&2 + exit 1 +fi + sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/test/shell.d/console-no-ansi-query-migration-test.sh b/test/shell.d/console-no-ansi-query-migration-test.sh index e087b0c4..3c977546 100644 --- a/test/shell.d/console-no-ansi-query-migration-test.sh +++ b/test/shell.d/console-no-ansi-query-migration-test.sh @@ -18,7 +18,14 @@ mkdir -p "$stub_bin" # sudo just drops the prefix; limine-mkinitcpio records that it ran, and fails # when told to. printf '#!/bin/bash\nexec "$@"\n' >"$stub_bin/sudo" -printf '#!/bin/bash\necho rebuilt >>"${REBUILDS:?}"\n[[ -z ${REBUILD_FAILS:-} ]]\n' >"$stub_bin/limine-mkinitcpio" +# A rebuild writes the new command line into the boot menu, unless it is the +# kind that builds nothing and still exits 0. +cat >"$stub_bin/limine-mkinitcpio" <<'STUB' +#!/bin/bash +echo rebuilt >>"${REBUILDS:?}" +[[ -z ${REBUILD_FAILS:-} ]] || exit 1 +[[ -n ${REBUILD_BUILDS_NOTHING:-} ]] || printf ' cmdline: %s\n' "${NEW_CMDLINE:?}" >"${LIMINE_CONF:?}" +STUB chmod +x "$stub_bin"/* old_cmdline="root=PARTUUID=1 rw quiet splash initramfs_async=0" @@ -27,7 +34,9 @@ new_cmdline="$old_cmdline systemd.tty.term.console=dumb" run() { # booted cmdline, defaults conf : >"$test_dir/rebuilds" printf '%s\n' "$1" >"$test_dir/cmdline" + printf ' cmdline: %s\n' "$1" >"$test_dir/limine.conf" PATH="$stub_bin:$ROOT/bin:$PATH" REBUILDS="$test_dir/rebuilds" \ + LIMINE_CONF="$test_dir/limine.conf" NEW_CMDLINE="$new_cmdline" OMARCHY_LIMINE_CONF="$test_dir/limine.conf" \ OMARCHY_RUNNING_CMDLINE="$test_dir/cmdline" OMARCHY_LIMINE_DEFAULTS_CONF="$2" \ OMARCHY_LIMINE_REBUILD_MARKER="$test_dir/marker" bash -euo pipefail "$migration" >/dev/null } @@ -41,6 +50,13 @@ fi [[ $(rebuilds) == 1 && ! -e $test_dir/marker ]] || fail "a failed rebuild was marked as done" pass "a rebuild that fails stops the migration and leaves no marker" +# limine-mkinitcpio exits 0 past a kernel it could not build. +if REBUILD_BUILDS_NOTHING=1 run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf"; then + fail "a rebuild that changed nothing was reported as a success" +fi +[[ ! -e $test_dir/marker ]] || fail "a rebuild that changed nothing was marked as done" +pass "a rebuild that exits 0 without changing the boot menu leaves the migration pending" + run "$old_cmdline" "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" [[ $(rebuilds) == 1 && -e $test_dir/marker ]] || fail "a machine booted without the parameter did not get its boot image rebuilt" @@ -65,7 +81,7 @@ pass "a machine whose defaults were edited to leave the parameter out is left al # not found either. rm "$stub_bin/limine-mkinitcpio" mkdir -p "$test_dir/tools" -for tool in bash grep install cat; do ln -s "$(command -v "$tool")" "$test_dir/tools/$tool"; done +for tool in bash grep install cat sed tail; do ln -s "$(command -v "$tool")" "$test_dir/tools/$tool"; done : >"$test_dir/rebuilds" printf '%s\n' "$old_cmdline" >"$test_dir/cmdline" PATH="$stub_bin:$ROOT/bin:$test_dir/tools" REBUILDS="$test_dir/rebuilds" \ From 452835da8b5e9d7de621191c78f1d9b045c30eae Mon Sep 17 00:00:00 2001 From: Emir Beganovic Date: Sat, 10 Oct 2026 00:06:46 +0200 Subject: [PATCH 28/34] Read the boot loader's config only where the migration needs it The migration read ESP_PATH from /etc/default/limine at its top. On a machine without that file sed fails, and under bash -euo pipefail the migration stopped there with an error, before the check that lets a machine without Limine skip it. The read moves to after the rebuild, the one place the path is used; a path given for the tests is taken first, and a missing file means /boot. --- migrations/1791581004.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/migrations/1791581004.sh b/migrations/1791581004.sh index 2fda0f4f..233f3aeb 100644 --- a/migrations/1791581004.sh +++ b/migrations/1791581004.sh @@ -12,8 +12,6 @@ defaults_conf="${OMARCHY_LIMINE_DEFAULTS_CONF:-/etc/limine-entry-tool.d/omarchy- running_cmdline="${OMARCHY_RUNNING_CMDLINE:-/proc/cmdline}" rebuild_marker="${OMARCHY_LIMINE_REBUILD_MARKER:-/var/lib/omarchy/migrations/1791581004}" param="systemd.tty.term.console=dumb" -esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine 2>/dev/null | tail -n 1) -limine_conf="${OMARCHY_LIMINE_CONF:-${esp:-/boot}/limine.conf}" omarchy-cmd-present limine-mkinitcpio || exit 0 [[ -f $defaults_conf && -r $running_cmdline ]] || exit 0 @@ -31,6 +29,17 @@ grep -Eq "^KERNEL_CMDLINE\[default\]\+=\".*${param//./\\.}.*\"" "$defaults_conf" echo "The booted kernel is missing $param; rebuilding the boot image" sudo limine-mkinitcpio +# The boot menu is on the ESP, which /etc/default/limine names; /boot when it +# does not, or is not there. +limine_conf="${OMARCHY_LIMINE_CONF:-}" +if [[ -z $limine_conf ]]; then + esp="" + if [[ -r /etc/default/limine ]]; then + esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine | tail -n 1) + fi + limine_conf="${esp:-/boot}/limine.conf" +fi + # limine-mkinitcpio carries on past a kernel it could not build, a full boot # partition for one, and still exits 0. Done means a boot entry has the # parameter: without that the migration stays pending and the next update From e1b0e5e9bb72b15dc494458925655bf32656382b Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 10 Oct 2026 02:15:56 +0200 Subject: [PATCH 29/34] Converge omarchy-mac and omarchy-mx-mac into upstream Omarchy (#14431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add omarchy-hw-platform to tell Apple Silicon and Qualcomm machines apart aarch64 covers Apple Silicon Macs, Snapdragon laptops, Raspberry Pis and VMs, so the CPU architecture can't pick platform setup. omarchy-hw-platform prints apple-silicon, qualcomm, generic-aarch64 or generic from the vendor prefixes in the device tree's root compatible (/proc/device-tree, then /sys/firmware/devicetree/base) and the CPU, and fails when they contradict each other. Its qualcomm answer uses the same evidence as the dragon branch's omarchy-hw-qualcomm-soc, and the fixtures check the two agree. omarchy-hw-apple-silicon is the Apple predicate on top of it. An image built away from its hardware names its target in a root-owned manifest, /var/lib/omarchy/image/target (format=1, platform=). While the root is being built rather than booted, the detector answers from it and never reads the build host's device tree; a booted system always answers from its hardware. As root it restarts in an empty environment, so no variable picks the platform for privileged setup. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> * Reject platform gates keyed only on the CPU architecture An architecture check is right for an ABI, a binary or a repository's $arch, not for picking a platform. The lint lists each shipped check after review and fails on a new one, so platform setup goes through omarchy-hw-platform. * Compose default packages from base, architecture and platform lists omarchy-reinstall-pkgs installed the base list on every machine. A platform's default set is now the base list, then install/omarchy-aarch64.packages on aarch64, then the platform's own list when it has one (install/omarchy-qualcomm.packages adds linux-firmware-qcom). omarchy-pkg-defaults prints the set for the detected platform, or for the one named, and reinstall installs it. x86_64 still gets exactly the base list, and an image build gets its target's set. * Early-load thunderbolt only where the kernel builds the module linux-aarch64 has no thunderbolt module, and mkinitcpio fails the whole image over a MODULES entry it cannot find. The optional form, thunderbolt?, is not enough: a systemd initramfs copies MODULES as written into modules-load.d, where "thunderbolt?" names no module, and a kernel without the module would still list it there. thunderbolt_module.conf now adds plain thunderbolt only when modinfo finds it as a module for the kernel being built. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Compose mkinitcpio HOOKS from an early baseline drop-in omarchy_hooks.conf assigned HOOKS outright, and mkinitcpio sources drop-ins in name order, so it ran after any numbered drop-in (90-*.conf) and threw its hooks away. HOOKS now comes from 00-omarchy-hooks.conf, which sorts first, and later drop-ins add to it. The NVIDIA kms filter and the vconsole handling stay in omarchy_hooks.conf. With the drop-ins Omarchy writes, machines keep the same HOOKS, MODULES and FILES; a user drop-in that sets HOOKS and sorts before omarchy_hooks.conf now takes effect instead of being overwritten. Migration 1786605598 now sources the baseline first, since without it the migration would read empty HOOKS and also rebuild hybrid systems. It skips a machine that lacks the baseline. * Resolve the LUKS parent from raw lsblk output lsblk -s prints the ancestor tree with box-drawing prefixes ("└─/dev/nvme0n1p6"), so the crypto_LUKS parent came back as a path that does not exist and first-boot setup stopped with "Could not locate the LUKS device to re-key". -r prints raw names. Factory reset uses the same lookup. * Check the factory-reset passphrase against keyslots only cryptsetup open tries enrolled tokens before the key file, so a TPM2 or FIDO2 token could authorize the re-key with a wrong passphrase. * Test LUKS parent lookup during owner setup and factory reset Exercise raw ancestry output without invoking provisioning or reset. Cover btrfs subvolume roots, absent encryption, failed probes, and explicit cryptdevice specifications. * Cover LUKS parent lookup through an LVM volume group * Journal the first-boot LUKS re-key so an interrupted attempt resumes A first boot killed after the staged install key's slot was retired left the key file behind, and every retry then stopped at "the staged LUKS key no longer unlocks". A lost key file with the boot-time auto-unlock still configured let setup finish with the disk unlocking unattended. The re-key now runs from install/provisioning/luks-rekey.sh and records its phase and slot numbers (never key material) in /var/lib/omarchy/provisioning/luks-rekey.state. It reuses the owner's slot instead of adding a duplicate, retires LUKS1 slots too, and destroys the staged key only after the volume proves it opens nothing. Setup refuses to finish while any part of the provisioning unlock remains. The journal ends at phase=done and leaves together with pending, so a retry after the staged key is retired must use the password the disk already holds. Key checks exclude LUKS2 tokens: cryptsetup open tries an enrolled token (say a TPM2 slot a previous owner left behind) before the key and reports its slot whatever key it was given. Slot listings read only the keyslot section, since a luks2-keyring token looks like a keyslot in luksDump. * Change the root volume's LUKS key before the login and root passwords Installation gives the login user, root and the disk holding / one password, but omarchy-drive-password changed only the LUKS key, so the three drifted apart. For the disk holding / it now checks the current password opens the disk, changes the LUKS key, confirms the new key opens the disk and the old one no longer does, and only then sets the login and root passwords. Any other encrypted drive changes its LUKS key alone. Every drive now asks for the current password with gum instead of cryptsetup's prompt. A journal in ~/.local/state/omarchy/drive-password.state (disk UUID, key slots and phase, never a password, written with the re-key journal helpers) lets the next run finish an interrupted change. The rerun accepts only the old key (removing the slot the interrupted change added) or the new one (retiring the old slot), so another key such as a recovery passphrase never becomes the login password. luksChangeKey is judged by whether the new key opens the disk, not by its exit status, and key checks exclude LUKS2 tokens, which cryptsetup open would otherwise accept in place of any password. Runs are serialised, cryptsetup calls hold a disk lock, a / whose LUKS partition cannot be resolved fails closed, and passwords are never traced. A new password equal to the current one is refused, and --pbkdf argon2id is passed only to LUKS2, so LUKS1 drives can change their password again. * Add omarchy-lifecycle-dispatch for platform boot operations Provisioning, factory reset and update call a fixed set of operations through it. A platform whose boot chain the generic Limine path can't drive registers a directory of root-owned entrypoints in the dispatcher; every other platform gets a no-op and keeps its generic path. Apple Silicon is registered to omarchy-mac-boot's /usr/lib/omarchy/mac-boot, with its required operations failing clearly when the package or an entrypoint is missing. docs/lifecycle-dispatch.md is the contract, including how a Qualcomm boot package would plug in. * Let the platform's boot package own first-boot setup's boot chain and key slots Owner provisioning asks provision-prepare before the owner form, hands the staged unlock to provision-commit and provision-verify where the platform implements both, and records the kept LUKS slot through luks-slots before the staged key is destroyed; the disk password change records the owner's new slot the same way. On x86, Snapdragon and generic aarch64 every call is a no-op and first boot is unchanged. * Reset through the platform's boot package where it owns the boot chain Where a platform implements the reset operations, reset-prepare and reset-verify stage and prove the factory root's boot files, the throwaway slot is added only after that, and reset-commit gets the key on standard input once the factory root is active. A failure before the switch rolls the boot state back and revokes the slot. Elsewhere the Limine UKI path is unchanged. * Check the platform's boot chain after an update omarchy-update-boot runs update-verify after AUR packages, the last sudo-capable step, so it also covers the initramfs rebuilds they trigger. It follows third-party build code, so like AUR it authenticates through the no-update wrapper. A failed verification finishes the update's remaining steps, then exits non-zero without offering the reboot. Where the platform does not implement it nothing runs and nothing asks for root. * Hand a machine to its platform's migration through lifecycle dispatch migrations/1790347292.sh resolves the new optional migrate operation and, where the platform's boot package implements it, runs it as root. The entrypoint moves the machine onto the platform's official package set, idempotently and resumably; its failure leaves the migration pending. Once it succeeds, a root-owned marker spares other accounts the migration and its root prompt. Everywhere else, Macs without the boot package included, the migration is a no-op, and an undetermined platform keeps it pending. * Refuse packages tagged for another platform in a pacman hook A platform package names its platform with groups=('omarchy-platform-'), being what omarchy-hw-platform prints. omarchy-settings ships a pre-transaction hook, the guard it runs and a copy of the detector, so the guard is resident before the runtime and any platform package. The guard reads tags from the synced databases and refuses a transaction that installs a package tagged for another platform; a transaction without a tagged package passes without asking which machine this is. Image builds answer from the image's root-owned target manifest, as the detector does. The guard ships on aarch64 only: no platform package is built for x86_64, so x86 transactions run no hook. Hardware setup starts by proving the guard is resident and checking the packages an installer placed without it. On generic aarch64, where no platform packages exist, a missing guard is logged, not fatal, and on x86 it is expected. docs/platform-guard.md is the contract. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> * Enable Voxtype's Vulkan backend only when it is installed omarchy-hw-vulkan reports a Vulkan runtime, not whether this voxtype build has a Vulkan backend. The aarch64 voxtype-bin package ships only the CPU and ONNX binaries, so on any aarch64 machine with a Vulkan driver setup ran `voxtype setup gpu --enable` and printed "Vulkan backend not installed" in the middle of an otherwise working install. Require the backend binary too. x86_64 voxtype-bin always ships /usr/lib/voxtype/voxtype-vulkan, so x86 installs still switch to the GPU backend as before. Ported from omacom/omarchy-mac#483. Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com> * Link the system Widevine CDM into Brave The widevine package registers its CDM for Chromium and Firefox only. Brave looks for the CDM in its own install directory, finds nothing, and DRM sites (Netflix, Apple TV+, Spotify) fail with a blank player and no useful error. This hits machines where Widevine comes from that package, such as aarch64 ones, since no Widevine component is published for them. Link /opt/WidevineCdm/chromium into /opt/brave-bin and /opt/brave-origin-bin through as_root when Brave or Brave Origin is installed, and add a migration that repairs existing installs. Both do nothing when the widevine package is absent or the browser already has a CDM. A standard x86 install has no widevine package, so nothing changes there. Ported from omacom/omarchy-mac#474, extended to Brave Origin. Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com> * Report failed setup steps on the console With file logging, run_logged sends a failing leaf's output and its "Failed:" marker to the install log only, so the caller sees setup stop with an exit status and nothing else. A missing command (exit 127) was the case that made this hard to diagnose. Print the failed script, its exit status and the log path on stderr. Stdout logging mode already shows the failure marker and is unchanged, and successful steps stay quiet. Ported from omacom/omarchy-mac#389 (the logging part). Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a migration defer itself without stopping the update A migration that exits 75 changed nothing and cannot finish yet: omarchy-migrate leaves it pending with its login notification, runs the migrations after it and exits 0, so omarchy update carries on. Any other failure still stops the queue. The platform migration passes a deferred refusal of its entrypoint through, and defers itself where the dispatcher can't resolve its entrypoint (the platform can't be told, or the entrypoint isn't trusted): nothing has run then, so the update goes on to its boot verification, which fails there, as documented. * Keep Apple Silicon's systemd initramfs on the HOOKS baseline 00-omarchy-hooks.conf set the busybox line on every machine. On a Mac, omarchy-mac-boot's drop-ins then add the Asahi firmware hooks, but its encryption drop-in reads busybox encrypt as a Mac unlocked through cryptdevice= and leaves the line alone. The image came out as "... block encrypt asahi omarchy-vendorfw filesystems ...", with no systemd, sd-encrypt or omarchy-mac-encrypt, so an encrypted Mac could not unlock its disk. Reproduced against an M1 Pro's installed drop-ins. The baseline now asks omarchy-hw-platform. Apple Silicon starts from the systemd line its boot package builds on. A Mac whose own mkinitcpio.conf carries the asahi hook, or busybox encrypt, keeps that line, as a legacy install needs. x86, Snapdragon and generic aarch64 keep today's busybox line, as does a machine with no detector. The detector is the runtime's, or else the copy omarchy-settings ships for its platform guard, so a settings package newer than the runtime still places a Mac. A detector that cannot place the machine stops the build. The HOOKS test now composes the baseline on each platform and on legacy Mac lines. A new test composes it with omarchy-mac-boot 20260926-1.45's drop-ins, kept as fixtures, and checks the exact HOOKS the M1 Pro builds. The NVIDIA test pins its platform. * Hide optional installs that have no package on this machine Not every optional install exists on every architecture: some vendors ship no aarch64 build, and some repositories don't carry a package at all. Each Install row now carries a `when:` guard naming every package its install command adds, checked with omarchy-pkg-available, so a row with nothing to install disappears instead of failing halfway through a pacman transaction. The rows that build from the AUR (Chrome, Edge, Brave, Brave Origin, Zen, NordVPN) are guarded on the CPU architectures their vendors publish for. omarchy-pkg-available asks the sync databases: one `pacman -Slq` per batch, and `pacman -Sp` for a name that list doesn't hold, so a provided name (libappindicator-gtk3) and a version constraint resolve the way `pacman -S` will. The menu's guard prelude sources the same function, so the whole batch still costs one fork. omarchy-reinstall-pkgs reports and skips a default that no configured repository offers instead of failing the transaction. On x86_64 every guarded row resolves against the stable and edge repositories, so the menu is unchanged there. Tests derive each row's packages from its install command on x86_64 and aarch64 and fail when the guard names different ones. Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> * Run 1Password with software GL where there is no render GPU Electron draws a blank window when the machine has no DRM render node: a VM, a headless or passthrough GPU, or a GPU whose driver isn't up yet (Apple M3 and later on Linux today). omarchy-hw-render-gpu tells whether a renderD node exists, and omarchy-cmd-electron-gl-args prints --ozone-platform=wayland --disable-gpu only when it doesn't. omarchy-cmd-electron-gl-wrap installs a marked PATH wrapper in /usr/local/bin that execs the real binary with those flags, so it follows the machine rather than the moment it was installed. It refuses to replace a launcher it didn't write, or to wrap a binary over itself, and stages the write so a failure leaves the old launcher in place. omarchy-cmd-desktop-exec-repair points the user's desktop entry at the wrapper by replacing only the executable token, keeping arguments, other actions and customizations, with a backup. The 1Password installer uses them when the machine has no render node. With one, which is every x86 machine with a working GPU, nothing changes. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Record with wf-recorder when gpu-screen-recorder can't start gpu-screen-recorder knows NVIDIA, AMD, Intel and Broadcom GPUs and aborts during GL init on anything else, such as Apple Silicon's, before a recording starts. Screen recording now falls back to wf-recorder, when it is installed, if gpu-screen-recorder is missing or exits before the file appears. The picker, file naming, notifications and post-processing are shared. wf-recorder records one audio device, so desktop plus microphone go through a temporary null sink that stop unloads, and its track is pinned to 48 kHz. Portal capture and --resolution stay gpu-screen-recorder only. The finalize pass keeps 48 kHz too: loudnorm declares 192 kHz output, so without -ar the AAC encoder wrote 96 kHz from a 48 kHz recording, which many editors decode as silence, and which on Apple Silicon makes the speaker safety daemon lock the amps. #11092 makes the same one-line change; whichever lands second drops it. Stop and status matched `pgrep -f ^gpu-screen-recorder`, which misses wf-recorder and matches any command line that starts with that path. The script now records the recorder's pid, and the new omarchy-capture-screenrecording-process checks or signals it by its executable, through a pidfd opened before the check so a reused pid can't be signalled. When that pid is no longer a recorder (a recording started before this change, or one that died), every recorder the user runs counts, as it does for the bar indicator and the Stop row, which use the same helper. Where gpu-screen-recorder works, which is every x86 GPU it supports today, it records as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com> * Hold logind's lid switch while a display it doesn't count is in use systemd-logind treats a closed lid as docked only when an external display is on a connector type it lists (HDMI, DisplayPort, DVI, VGA...). Displays on "USB" connectors, which Apple Silicon uses for USB-C displays, aren't on that list, and a missing comma fuses DVI-A and Composite into one entry. With such a display in use, closing the lid suspended a laptop that was still being used on it. omarchy-system-lid-inhibit holds a handle-lid-switch inhibitor while one of those connectors is connected and enabled in Hyprland, on a laptop where HandleLidSwitchDocked is ignore, which is exactly the case logind would have ignored had it counted the display. It releases the inhibitor when the display goes. A compositor or logind that doesn't answer changes nothing, since releasing then would suspend a closed lid. The inhibitor runs as a transient user unit bound to the monitor watcher's unit, so a watcher that dies can't leave the lid inhibited until logout. The monitor watcher runs it alongside the clamshell sync. On a machine whose external displays are all on connectors logind counts, it does nothing. * Keep the focused workspace in front when a monitor goes away When a monitor is unplugged, or the lid turns off the laptop panel, Hyprland first warps focus to whichever monitor it lists first and only then moves the removed monitor's workspaces over. The workspace you were on lands hidden behind that monitor's own (Hyprland 0.56.2, CMonitor::onDisconnect). monitor.focused fires before Hyprland records the new focus, so the monitor still reported as active is the one being left: its workspace is remembered until the event loop next turns, and a monitor.removed in that window focuses it again once the removal has finished. An open special workspace already comes along, and any focus or workspace change made before the restore runs wins. * Drive the keyboard backlight from the ambient light sensor Laptops with an ambient light sensor and a keyboard backlight LED now light the keys in the dark and dim them as the room brightens, inverse to display auto-brightness. omarchy-brightness-keyboard-auto polls the sensor every five seconds (preferring an IIO device that names itself ALS, and converting a raw channel with its offset and scale) and maps lux onto the LED, with a deadband so it doesn't chase noise. It pauses while the screen is locked or the lid is closed. A level set with the keyboard-brightness keys wins until the light changes enough that it no longer fits. omarchy-brightness-keyboard records each level it sets in the session's runtime directory (a private state directory without one), so the loop can tell keys turned off on purpose from keys left off by the lock screen or a restored zero, which it lights again. The user unit runs it in the graphical session. Its ExecCondition asks --available, so it is a no-op on machines without both a sensor and a keyboard LED. First run enables it, and a migration enables it for existing installs, writing the enable symlink itself when there is no live user manager. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com> * Defer an image build's hardware setup to the machine's first boot An image is built away from the machine it will run on, so its hardware leaves would probe the builder. While the image manifest the detector already reads (/var/lib/omarchy/image/target) exists, omarchy-apply-hardware queues every leaf of install/hardware/all.sh instead of running it and arms omarchy-provision-hardware.service. On the first boot, before owner setup and the login screen, omarchy-provision-hardware retires the manifest and runs the queue on the real hardware: a failed step stays queued for the next boot (exit 75), and the initramfs is rebuilt once after the last step when a step changed its inputs or asked for it. Bluetooth starts its service on that boot, whose target is already up. Root ignores any environment override of these paths. Without a manifest, as on every ISO install, hardware setup runs exactly as before. Nothing here fetches package databases: an image carries its sync databases and every package its steps install. * Give an image-built root a UTF-8 locale when LANG is still C A root built from a distribution tarball instead of the ISO (Arch Linux ARM's ships LANG=C) never went through the ISO's locale step, so it ran non-UTF-8. The config phase now selects en_US.UTF-8, generating it if needed, but only when LANG is unset, C or POSIX: any named locale is somebody's choice and stays. The ISO writes the locale before this runs, so an ISO install is unchanged. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a platform's packages run their own boot, system and user setup Three dispatch operations give a platform one place for the setup it needs beyond Omarchy's, instead of inline platform branches in the setup leaves. The last hardware leaf runs setup-boot, then setup-system, as root, so an image build queues them with the others and its first boot runs them, with image-first-boot (offline-safe, asking for the one boot rebuild instead of building). setup-boot belongs to the boot package and is required on Apple Silicon, where hardware setup has no other way to run the Mac's boot setup; a failed setup-boot stops the leaf before setup-system. setup-user runs as the user being set up, refuses root, and gets only the user's home and session variables: from the last user leaf at finalization (skipped inside an image build, where the detector would read the build host) and again from first run once the session is up. On Apple Silicon setup-boot resolves in omarchy-mac-boot's directory and the other two, both optional, in omarchy-mac's, /usr/lib/omarchy/mac; x86, generic aarch64 and Qualcomm register none, so all three are no-ops there. A failed finalization now keeps first run pending, like any failed first-run step, instead of letting the other steps mark it complete. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Find batteries, lids and panels that aren't named like a PC's Laptop detection assumed PC names: a battery called BAT*, a lid in /proc/acpi, a touchpad or trackpad in the device name, a GPU backlight. Apple Silicon has none of them (macsmc-battery, the SMC lid switch, apple-mtp-multi-touch, apple-panel-bl), and some Framework and Chromebook firmware calls its battery CMB0, so those machines lost the battery panel, laptop-only services, lid handling, the touchpad toggle and display brightness. - Battery presence goes by type instead of name, and skips a battery scoped Device (a wireless mouse or keyboard), so a desktop with one isn't a laptop. - Battery status takes the first UPower battery that powers the machine, reads thresholds and cycles from it (BAT* stays the fallback), shows the magnitude of a signed power_now, and rounds the percentage half-up for display like the bar, while the charge-hold check keeps the unrounded value. - A laptop is also one with any input device that reports a lid switch (SW_LID), whatever its name: Apple's SMC input device has the same name on a Mac mini, where it reports none. Whether the lid is closed comes from ACPI where there is an ACPI lid, as before; otherwise logind is asked, with a short timeout, only when a device reports a lid switch. - A device named touchpad or trackpad still wins; only when there is none does the touchpad match take a mouse udev marks as a touchpad (Apple's multi-touch trackpad), never a touchscreen. - The platform package can describe its displays in /usr/share/omarchy-platform/displays.conf: backlights that never drive the panel (a Touch Bar's), the panel's own where there is no GPU backlight, and a display driver without DDC channels, where a probe walks unrelated I2C buses for about 3 s: there only a connector with a ddc node is probed, and a monitor without one reports no brightness instead of dimming the built-in panel. Omarchy ships no such file and names no device in it; without it the backlight choice and DDC probing are unchanged. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com> Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> * Keep the user's XDG config and state directories in platform user setup setup-user runs with an allowlisted environment, which dropped XDG_CONFIG_HOME and XDG_STATE_HOME. A platform's user setup reads the user's configuration and keeps its setup markers there, so for a user who sets either it looked in, and wrote to, the default directories instead. Both now pass through with the rest of the session; XDG_DATA_HOME and everything else still don't. * Let a platform package add Hyprland defaults of its own A platform's runtime package may need Hyprland defaults of its own (a Mac's capture keys and three-finger workspace swipe, say), and Omarchy's config should carry no branch for them. Three directories under the platform root, /usr/share/omarchy-platform, now take them. The one installed platform package owns that root, Omarchy reads it by literal path and ships nothing there, so without a platform package nothing changes. Each file is loaded by its path, so it can't shadow a module, and one that fails is logged and shown as a notification while the rest of the config loads. - hypr/defaults/*.lua loads before Omarchy's defaults. A chord bound there replaces Omarchy's default for it, and a decorator it adds runs before every later bind, so it can bind what has to run first. It sees the command a bind runs, including the command a shell shortcut such as { menu = "root" } stands for when it binds the shell's global shortcut. - hypr/settings/*.lua loads after Omarchy's defaults and before the theme, for settings that replace Omarchy's. - hypr/gestures/*.lua loads after the user's files, only for what must see them: the gesture registry, which now records on every platform, lets a default gesture step aside for the user's own. The user's files load after the first two and can unbind or rebind any of it. * Let a platform package name keys in the keybindings menu The keybindings menu shows a key by its keysym, which on some keyboards is not what the key says: on a MacBook, XF86MonBrightnessUp and Down are the F2 and F1 keys, so the menu listed rows like "SHIFT + XF86MonBrightnessUp". A platform package can now list names for such keys in /usr/share/omarchy- platform/key-names, one " " per line, and the menu shows those instead. The file is part of the menu cache's key, so installing or changing it refreshes the menu. Without it the menu is as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com> * Keep the top bar out of a panel's camera cutout A laptop panel with a camera notch at its top (Apple Silicon MacBooks, for one) covered the middle of the top bar, and a bar shorter than the notch left a sliver of every window beside the camera. Omarchy knows no panel's cutout itself: the platform's own package describes them in /usr/share/omarchy-platform/display-cutouts.json, by connector, physical mode and the rows the cutout covers. On a described panel a top bar is never shorter than the cutout, its center section sits beside the right one, and hiding it parks all of it; [bar] notch-height sets that floor by hand, on that panel only. Without a description, which is every machine without such a package, the bar is as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Keep PC and Intel Mac quirks off Apple Silicon A few hardware steps and commands written for PCs and Intel Macs do the wrong thing on Apple Silicon: - The Intel Mac Wi-Fi quirk turns off the Broadcom firmware supplicant for every Mac whose DMI vendor is Apple and whose chip is on its list. Apple Silicon reports Apple Inc. through U-Boot's DMI tables, and the M1 Pro and Max's BCM4387 is on the list, but there the firmware supplicant is the one that works: turning it off makes firmware commands time out and breaks scanning. The install step and its migration now skip Apple Silicon. - fnmode=2 for Apple-like keyboards is left to the Mac's own platform package on Apple Silicon, where the built-in keyboard's top row depends on it. - The SPI keyboard check reads the DMI product name without failing the hardware pass where there is none. - The Windows VM, an x86_64 guest under KVM, runs on x86_64 only, and direct boot (U-Boot finds the bootloader and makes its boot entries itself) refuses Apple Silicon; the menu hides both there. Hibernation setup skips it; its kernel has no hibernation today, and resume there is not wired. Everywhere else each step runs as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a platform's runtime package adjust the apps it needs to Some apps need a platform's own touch once they are installed. On Apple Silicon, Chromium-family browsers must decode video in software (the hardware decoder renders green frames in them), and Steam only runs through a FEX launcher, which then depends on Steam and must go before Steam can be removed. Until now the Mac runtime did this with inline branches in the installers. Two optional dispatch operations take their place. post-install runs as the user once an installer has installed the app and finished its own setup of it: every browser install (after its flags file, before it says the browser is installed) and Steam's (after the package, before the 32-bit drivers and the first launch). pre-remove runs before Steam's removal drops its packages. Where a platform registers them they refuse root (a platform with nothing registered is a no-op, so sudo installs still work there) and get the same home and session variables as setup-user, and a failure fails the install or stops the removal. On Apple Silicon they resolve in omarchy-mac's directory; every other platform registers none, so installs and removal run as before there. Steam's 32-bit driver step also stops failing the install on a machine with no Intel, AMD or NVIDIA GPU (Apple Silicon, but also Snapdragon or a VM): it has nothing to add there and now says so. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Ask the platform's boot package before an update takes over its files When an Omarchy package's upgrade stops only on files no package owns, the update moves them aside and retries. On a Mac some of those files belong to its boot chain or were written by its image (initramfs and pacman configuration, say), and moving them aside can leave it unable to boot or update. The Mac runtime refused those paths with an inline list. update-takeover ... asks the platform's boot package instead, before anything moves: read-only, exit 0 lets the takeover go ahead, anything else keeps every file and the update fails as it did before the retry existed. The update resolves it as the user and runs it with sudo only where it resolves, inside the authorization the move needs anyway. On Apple Silicon it is required: a Mac without its boot package, or with one too old to answer, keeps its files. Every other platform registers none, so the takeover goes ahead as before, asking no one. * Install the Mac's package set on Apple Silicon omarchy-pkg-defaults already composes install/omarchy-.packages after the base and aarch64 lists, but Apple Silicon had no list, so a Mac got the generic aarch64 set: no omarchy-mac or omarchy-mac-boot, whose entrypoints the dispatcher requires there. install/omarchy-apple-silicon.packages names the Mac's runtime and boot packages, plus the defaults an owner may remove: the startup-volume tool, hardware video decode, Widevine, and wf-recorder, since gpu-screen-recorder can't capture on Apple Silicon and screen recording falls back to it. What a Mac can't work without (its speaker stack, PipeWire's PulseAudio server and realtime scheduling, the GPU's Vulkan driver) comes as omarchy-mac's dependencies, so a new Mac driver needs no Omarchy change. Other platforms compose exactly what they did. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Give existing aarch64 machines the zram swap Omarchy configures omarchy-settings configures swap on zram and tunes memory reclaim for it. x86_64 gets zram-generator from its installer and new aarch64 installs from their default packages, but an aarch64 machine installed before omarchy-aarch64.packages named it has no generator, so the configuration asks for a device nothing creates. Migration 1790328426 installs zram-generator where the platform's default packages name it and it is missing, and starts the zram swap wherever it is down, unless no device is configured (then the generator makes no swap unit) or the administrator masked it. A swap that won't start, or whose unit can't be read, only warns: the generator's unit starts with every boot, so a reboot retries it, and failing the migration would stop every update. A failed install waits (75) for the next migrate, as it does where the platform can't be told, changing nothing. It keeps the name it has in omarchy-mac, so Macs that already ran it skip it. It also retires an /etc copy of the drop-in identical to the packaged one, which earlier Mac setups made and which would hide the packaged file's updates; an edited copy stays. On x86_64 the default packages don't name the generator and there is no copy, so it does nothing. * Keep an ARM machine's repositories when it changes channel omarchy-refresh-pacman and install finalization copied x86_64 templates everywhere, so on an aarch64 machine a channel change or a finished install wrote Arch Linux's x86 [core]/[extra]/[multilib] and dropped Arch Linux ARM and the platform's own repositories. Channel changes now tell the platform apart. x86_64 is unchanged. On aarch64 the machine's configuration is kept, with its repositories, mirrors and signature policies, and only the [omarchy] server moves to the new channel; a configuration whose Omarchy server can't be identified is refused unchanged, and one with no Omarchy repository gets it at the end (not on Apple Silicon, whose own repository must not come first). Only pacman.conf is backed up and rewritten, straight from this process through sudo, never through a file another process could change first. Before that, a channel must be qualified for the platform: a signed upgrade and reboot tested there, not only a published repository. x86_64 keeps stable, rc and edge; Snapdragon and other aarch64 machines take edge, as dragon does; Apple Silicon takes none until the Mac packages qualify on edge. The refresh then checks, as the user, that the channel's omarchy.db publishes the runtime pair, and on Apple Silicon omarchy-mac and omarchy-mac-boot. omarchy-channel-set refuses an unqualified channel before anything else, the dev confirmation included. Install finalization writes a whole template, as before on x86_64. Other aarch64 platforms get Arch Linux ARM (dragon's pacman-aarch64.conf and mirrorlist-aarch64, byte for byte) with its keyring, plus Omarchy on a qualified channel. Apple Silicon gets omarchy-mac's template on a qualified channel and keeps its image's configuration otherwise. In an image build, finalization leaves the keyring to the machine's first boot, so machines flashed from one image don't share a pacman master key. On an unqualified channel, a machine that gets no Omarchy repository is told why. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com> * Keep Arch Linux ARM's signing keys current on ARM machines omarchy-update-keyring reinstalls archlinux-keyring before every system upgrade, since keys change without a version bump. An aarch64 machine's Arch Linux ARM repositories are signed with keys from archlinuxarm-keyring, which nothing refreshed, so a rotated ALARM key failed the upgrade that followed. On an aarch64 platform where archlinuxarm-keyring is installed, it is reinstalled in the same transaction. On x86_64 the keyring step runs exactly as before, even where that package happens to be installed. * Mark the runtime's platform files as deciding at runtime The omarchy-settings recipe leaves the mkinitcpio, Limine, memory and USB drop-ins out of its aarch64 package, because they used to assume x86_64. That leaves an aarch64 install without the HOOKS baseline its initramfs needs, and without the zram and reclaim settings every other Omarchy machine gets. Each of those files now decides at runtime whether it applies: the HOOKS baseline asks the platform, thunderbolt is added only where the kernel has the module, the Limine files are read only where Limine is installed, the oomd drop-ins are inert without systemd-oomd, aarch64 installs the zram generator the zram drop-in needs, and the USB autosuspend option is ignored by a kernel that builds usbcore in. default/settings-runtime-profile says so, file by file; a recipe that finds it ships the same files on every architecture (omacom/omarchy-pkgs#638), and older sources keep today's aarch64 package. A test checks that every file it names exists and that each runtime decision is in place. * Set the wireless regulatory domain from the owner's timezone at first-boot setup A deferred install (an Apple Silicon image, an OEM ISO install) runs its hardware steps before the owner picks a timezone, on the UTC placeholder, which names no country, so install/hardware/set-wireless-regdom.sh leaves the regulatory domain unset and the radios stay on the world domain. Owner setup now runs that step again once the owner's timezone is set, and applies it to the radios through wireless-regdb's helper, since no reboot follows setup. An up-front install, which picks the timezone before its hardware steps, is unchanged. * Ask again for a keyboard layout first-boot setup could not apply First-boot setup kept going when the chosen layout did not take: loadkeys errors were ignored, a keymap localectl did not list kept the default, and a failed or partial write of /etc/vconsole.conf was only logged. The owner then typed the disk password in one layout while the boot files asked for it in another, and could only unlock the disk by typing US-style. The keyboard step now fails closed. On a console loadkeys must succeed, the keymap must be in localectl's list exactly (a list localectl cannot produce counts as a failure too, logged apart), and after systemd-firstboot or the localectl fallback, /etc/vconsole.conf is read back in a clean shell and must name the chosen keymap with an XKB layout. Any failure is logged, the owner is told the layout could not be set, and the layout is asked for again, so the password form is never reached with a layout that did not take. * Refresh a platform's own signing keyrings before an update omarchy-update-keyring reinstalls archlinux-keyring (and Arch Linux ARM's on aarch64) before the system upgrade, so a rotated key never fails that upgrade's signature checks. A platform's own repository has a keyring of its own that nothing refreshed: on Apple Silicon, asahi-alarm-keyring for [asahi-alarm]. A platform package now lists those keyrings in /usr/share/omarchy-platform/keyrings, one package name per line, and omarchy-update-keyring reinstalls each one that is installed in the same transaction. It adds no repository the machine didn't already trust. A line that isn't one keyring package name is skipped with a warning, so a mistake in the list can't stop every update. Without the file, as on x86 today, the step runs as before. * Drive an untyped default input from the audio panel Quickshell types only exact media classes, so a virtual source (an Audio/Source/Virtual: EasyEffects' source, or the microphone mapping a platform package adds) has no PwNode.audio. With one as the default input the audio panel showed the input at 0% and its slider changed nothing, the peak meter stayed flat, the microphone widget read it as muted, and the panel could not list it unless it was already the default. Such an input now goes through wpctl for its volume and mute (UntypedInput in shell/Commons, one reader and one pactl subscribe for the whole shell), the panel meters it with the new omarchy-audio-source-level, and the input list takes an untyped node PulseAudio lists as a source. A platform whose audio runs through its own processing can also name nodes that are neither devices nor apps in /usr/share/omarchy-platform/audio.json, which Omarchy ships none of: the panel and the microphone widget leave them out, and a "replaced" node only while its replacement exists. Without the file nothing is hidden. On every machine: the input list leaves out a source whose ports are all unavailable (an empty headset jack) unless it is the default, as the output list does for sinks; choosing an input no longer moves a filter's own capture (a source output with node.link-group), which would feed the filter from the new input or from itself; the shell's own level meters no longer light the microphone widget; and omarchy-audio-sink-availability reads pactl in the C locale for sinks too, since "not available" is translated. * Give first-boot setup an XKB layout for keymaps systemd cannot map systemd-firstboot writes an XKB layout only for keymaps listed in kbd-model-map. Nine the setup form offers are not (Colemak, Azerbaijani, Bulgarian, Czech, Swiss German, Kyrgyz, Norwegian, Polish, Ukrainian), so it wrote KEYMAP= alone, the readback refused it, and owner setup could never get past the keyboard step for them. Setup now adds the XKB settings itself when firstboot left none, matching the console map so the shared password types the same on the desktop: kbd's cz is QWERTY, bg-cp1251 phonetic and azerty French AZERTY, and non-Latin layouts keep a US layout to switch to, as kbd-model-map's own rows do. A test checks every layout the form offers ends with an XKB layout. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Leave keys a firmware key turned off dark The ambient keyboard backlight treated any 0 the keyboard-brightness keys had not recorded as an accident and relit it within five seconds. Firmware Fn keys (Fn+Space on ThinkPads and Framework laptops, Fn+F10 on Dells) write the LED directly, so on those laptops turning the keys off never stuck. The lock screen's off now records a blank, and only that blank or a 1% leftover is taken back; any other off is a choice that pauses auto like the keys do. A wake clears the blank only once the restore succeeded, the command runs one at a time so a blank racing a wake can't leave the LED and record disagreeing, and the loop drops a blank once it has taken the keys back. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Drop the optional-install availability guards Each menu open checked every Install row against the sync databases, which costs most of a second of pacman work on every machine to hide rows only an ARM machine is missing. The fix for those is publishing the ARM packages, not hiding the rows, so this removes omarchy-pkg-available, the Install rows' availability and architecture guards, and reinstall's skipping of defaults no repository offers. The Windows VM row keeps its x86_64 guard, since no package fixes a guest that needs an x86_64 host, and reinstall keeps its aarch64 channel handling. This reverts commit aea001cbde4d0852b311ef42b5e8c380c719f833. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Put the current root back when the factory root cannot take its place Factory reset moved the running root aside, marked the reset committed, then renamed the staged factory root to @. If that second rename failed, cleanup saw a committed reset: it skipped the boot package's rollback and kept the throwaway LUKS slot, and since the swap had not happened it deleted the staged root as well, leaving nothing at @. The reset now commits only once the factory root is at @. A failed activation renames the old root back first, then cleanup rolls the platform's boot state back and revokes the throwaway slot. If that rename fails too, both roots are kept, cleanup still rolls back and revokes, and the error names the rename that recovers the machine. The generic Limine path has already rebuilt its boot files from the factory root by then, as it had before this change. * Leave other recorders alone when the saved recorder pid is stale A saved pid that no longer named a recorder made status and stop select every gpu-screen-recorder and wf-recorder the user ran, so stopping could interrupt, and after five seconds kill, a recording started somewhere else. A stale pid now means no recording of ours is running: the toggle starts a new one, and --stop-recording finds nothing to stop. Stop acts on the recorder the status check selected rather than selecting again. Without a saved pid, as for a recording started before the pid was saved, every recorder the user runs still counts. * Name the audio a wf-recorder recording could not capture With desktop and microphone audio both requested, wf-recorder records a null sink fed by two loopbacks, and a loopback that failed to load was ignored: the recording went ahead without that source, or silent when both failed. A missing null sink or default device dropped audio just as quietly. A source that cannot be captured is now left out with a notification naming it, the loopbacks that did load are unloaded, and the recording carries the source that remains, or no audio track at all. * Show the Edge, Spotify and Dropbox installers on x86_64 only These vendors currently publish Linux builds for x86_64 alone (the AUR's microsoft-edge-stable-bin and spotify are arch=('x86_64'), and Dropbox lists no Linux ARM client), so no native aarch64 build is available and choosing one on an ARM machine only fails. Like the Windows VM row they keep a uname check, which the menu answers once per open without asking pacman. The other Install rows are unchanged. * Revoke the generic reset's throwaway slot when the reset fails The generic Limine path added its throwaway LUKS slot without recording it, so a reset that failed before activation never revoked it. Once the factory root's UKI was on the ESP, a failed activation that put the old root back left a machine that booted the factory kernel and unlocked unattended with a key readable on the ESP, while the screen said the current root was back. The generic slot is now recorded like the platform's and revoked by cleanup whenever the reset fails before the factory root is active. A failed activation on that path says the boot files were already rebuilt from the factory system and to run limine-update before rebooting. * Clear a stale recorder pid when a recording starts The recorder's pid is saved only once its file exists. Until then a pid left by a recording that ended without a stop still counted as stale, so pressing the toggle again while the new recorder started found nothing of ours and could start a second recording. Starting a recording now clears that pid, and during startup the toggle falls back to the user's recorders as before. * Word the dropped-audio notice for every reason a source is left out The notice said the dropped source "could not be captured", which was wrong when the null sink itself failed to load: the microphone was never tried, and desktop audio recorded alone as it always has. It now says the source could not be added to the recording, true for a missing device, a failed loopback and a failed null sink alike. * Show the Heroic and Minecraft installers on x86_64 only Heroic publishes Linux builds for x86_64 alone (its arm64 builds are for macOS and Windows), and Mojang's Linux launcher is a single x86_64 tarball. They join Edge, Spotify and Dropbox under the same uname check, so every row whose vendor ships no native Linux aarch64 build is treated alike. * Ask the recorder script whether its stop has a recording to end The bar indicator and the menu's Stop Screenrecording row asked the process helper whether any recorder the user runs was alive, while the toggle's stop acts only on the recording it saved. With a stale saved pid alongside an unrelated recorder, both offered a stop that did nothing. omarchy-capture-screenrecording --status answers with the selection the stop uses, and both now ask it. Status needs no recordings directory and never notifies, so the recordings-directory check moved below argument parsing and skips it. * Copy every platform's pacman templates into place, as x86_64 does On aarch64 a channel change edited the machine's pacman.conf in place: an awk pass found the [omarchy] server and rewrote its channel, appended a section where there was none, checked the channel's omarchy.db first, and gated it all on a per-platform table of qualified channels. Install finalization appended Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What a machine ended up with was never in the repository. Each platform now has a pacman.conf and mirrorlist per channel, copied whole: x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so far, so every ARM channel points at edge for now and channel changes work there; moving a channel is a template edit. A channel without both files is refused before anything changes, including a dev checkout's own templates. Reinstall goes back to resetting to stable, and the update confirmation loses the missing-repository notice it no longer needs. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Revert "Copy every platform's pacman templates into place, as x86_64 does" This reverts commit 9a07e138cc6c3a19b58d2f3d7eb536dd3042ccc5. * Copy every platform's pacman templates into place, as x86_64 does On aarch64 a channel change edited the machine's pacman.conf in place: an awk pass found the [omarchy] server and rewrote its channel, appended a section where there was none, checked the channel's omarchy.db first, and gated it all on a per-platform table of qualified channels. Install finalization appended Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What a machine ended up with was never in the repository. Each platform now has a pacman.conf and mirrorlist per channel, copied whole: x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so far, so every ARM channel points at edge for now and channel changes work there; moving a channel is a template edit. A channel without both files is refused before anything changes, including a dev checkout's own templates. Reinstall goes back to resetting to stable, and the update confirmation loses the missing-repository notice it no longer needs. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Drop the migrations no installed machine needs Fresh installs mark every migration done, and every Apple Silicon machine is a fresh install, so the Mac handoff to omarchy-mac-boot (and the migrate operation it called), zram for aarch64 installs that predate the package list, the Brave Widevine link and the Broadcom migration's Apple Silicon guard never run anywhere. The exit-75 deferral in omarchy-migrate existed only for two of them. Keyboard backlight auto stays on new installs through first run rather than switching on for existing ones, and migration 1786605598 goes back to quattro: the mkinitcpio drop-ins arrive by package. Co-Authored-By: Claude Opus 5.5 * Drop the generic changes Mac support doesn't need The pacman platform guard (and the detector copy omarchy-settings shipped for it, which the HOOKS baseline fell back to), the lint against uname gates, first run staying pending after a failed finalization, run_logged echoing failures to stderr, half-up battery rounding, hiding empty-jack inputs and leaving a filter's capture alone when choosing an input all rode along with the Mac work without serving it. Each goes back to quattro's behaviour and can come back on its own merits. Co-Authored-By: Claude Opus 5.5 * Drop the platform Hyprland slots A platform package could load its own Hyprland binds, settings and gestures: binds that replaced Omarchy's chords and decorated every later bind, settings ahead of the theme, and gestures after the user's files, which needed every hl.gesture call wrapped so a default could step aside. On a Mac those carried macOS habits (a three-finger workspace swipe, tap-to-click off, menus opening on the built-in screen, other screenshot and keyboard-backlight chords), not anything the hardware needs, and Omarchy should behave the same on every machine. The one bind a Mac does need, its lid switch under the SMC's name, joins Omarchy's own lid binds. Co-Authored-By: Claude Opus 5.5 * Keep aarch64 machines off the release channels until a release supports them Every aarch64 channel's template pointed at the edge repository, but a switch to stable or rc installs the release-named omarchy and omarchy-settings. On edge/aarch64 those are the 4.0.4 release line, which has no omarchy-hw-platform, lifecycle dispatch or omarchy-pkg-defaults, so `omarchy-channel-set stable` on a Mac replaced the quattro runtime it runs with one that cannot run it, and succeeded. aarch64 platforms now have edge templates alone, so a switch to stable or rc is refused before anything changes; adding those templates once a release supports aarch64 opens them. Refresh with no channel named, as reinstall runs it, and install finalization for a channel the platform has no templates for take the platform's default channel: stable on x86_64, edge on aarch64. * Leave a filter's own capture alone when choosing an input again d0b94d98c returned omarchy-audio-input-set-default to moving every source output, as not serving Mac support. It does: asahi-audio's microphone DSP on Apple Silicon records the raw array through a filter-chain capture stream (audio_effect.jNNN-mic, node.link-group "filter-chain-..."). On an M1 Pro, choosing the Mac's microphone in the audio panel moved that stream onto the DSP's own output, and the microphone recorded digital silence (-91 dB, from -55 dB of room noise) until PipeWire restarted. With the skip it keeps recording (-54 dB). This restores the skip of source outputs carrying node.link-group, and its test, as they were before that commit. * Say aarch64 offers edge alone where the templates are described The aarch64 edge templates, the template helper and the update-process docs still said every platform has a template per channel and every ARM channel installs from edge, which the previous commit made untrue. * Point the Mac packages at omacom/omarchy-mac-pkgs omarchy-mac and omarchy-mac-boot now live in omacom/omarchy-mac-pkgs, with each package at the top level. The dispatcher, its documentation and the Apple Silicon package list name that repository, and the drop-in test's fixtures, byte for byte the same as omarchy-mac-boot/files/etc/mkinitcpio.conf.d at 4399105, cite it there. * Stop naming a platform migration among the dispatcher's callers The migrate operation went with the Mac migrations, and no caller hands a machine to a platform migration any more, so the header lists only the flows that still dispatch. The optional-operations comment is rewrapped. * Keep the Intel Mac Broadcom migration off Apple Silicon again A fresh install marks every migration done only for the user it creates, so an account added later runs them all on its first update. On an Apple Silicon Mac this one matches: an M1 Pro reports sys_vendor "Apple Inc." and a BCM4387 (14e4:4433), so it would append feature_disable=0x82000, which stops that chip scanning once brcmfmac reloads. Restore the guard and its test case. * Say when Steam's post-install hook runs now that the drivers go first Steam's installer now adds the 32-bit drivers before Steam itself, so the hook runs after both and before the first launch. * Release fallback audio after the recorder exits When wf-recorder ends outside the normal Stop path, status rejects the saved PID before unloading the combined-audio loopbacks. Release those owned modules when the saved recorder is gone, so the next status or stop call ends microphone capture without signaling another recorder. Leave a mix without a saved PID alone: its recorder can still be starting. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Codex Medium * Serialize recording state changes and stale audio cleanup Status can finish checking an exited recorder after another command has replaced its audio modules, and stop finalization can outlive the recorder while a new recording starts. Share a lock across start, stop and cleanup so each command finishes updating its resources before another changes them. Status uses a read-only check when the lock is busy, keeping the bar responsive during the picker. Close the lock descriptor in persistent children so recording and preview cleanup cannot retain it. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> * Keep automatic and manual keyboard brightness updates together A brightness key can update the LED and its chosen-level record after the automatic loop checks for a manual change but before it applies a new target. The automatic write then overwrites the user's choice and deletes the record. Take the manual command's lock for the whole tick, skipping a busy tick and releasing the descriptor on every return. Create the private fallback directory before opening the lock outside a session runtime directory. The normal lock screen waits five seconds before blanking, so an ordinary tick should already see the session locked. The regression exercises a brightness key arriving within a tick instead of relying on an implausibly delayed lock-state check. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Read the split hook baseline in the NVIDIA migration The NVIDIA firmware migration decides whether to rebuild by sourcing the hardware and filter drop-ins. With HOOKS now defined in the earlier baseline, that partial read treats every hybrid machine as if kms had been removed. Source the new baseline first when it exists, keeping the legacy two-file layout working and skipping rebuilds when the effective hooks still include kms. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Use a hardware helper for x86-only menu entries Keep the x86_64 availability checks for Windows and vendor-specific installers in a hardware predicate, so menu conditions use the same command style as other hardware checks. Co-Authored-By: GPT-6 in Codex * Keep restored keyboard levels from pausing automatic brightness Idle wake, quick unlock and hibernate can restore a saved level older than the ambient loop current level without the lock blanking first. Treat that restoration as a one-tick event, preserving existing manual pauses and the normal blank-and-restore path. Consume unmatched and unchanged restore records too, so a later firmware key cannot look like the earlier restore. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Preserve restored keyboard levels across a later lock blank The ambient loop skips a locked session, so a restored level may still be unconsumed when the next blank saves it. Carry that origin through blanking and restore failures so fingerprint unlock cannot make the old saved level look like a manual choice. A brightness key replaces the marker with its chosen level and retains the ordinary blank behavior. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Preserve firmware brightness choices before a lock blank Firmware keys change the LED without updating the record. Carry a pending restoration through the next blank only if the observed LED still matches that restoration, or is still zero from its previous blank. A firmware change otherwise follows the ordinary blank-and-restore path so automatic lighting pauses on the chosen level. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Change only the disk password in omarchy-drive-password Changing the system disk's password also set the login and root passwords to match. That changes account-password policy on every platform, x86 included, and overwrites passwords someone chose to keep different, so it moves to its own change with an explicit confirm. The disk change itself stays as it was: the new key must open the disk and the old one must not before the change counts, the journal finishes an interrupted change, and where the platform's boot checks prove the key slots, the owner's new slot is recorded before the run finishes. The journal's confirmed phase is now "record" and no longer needs a login user. * Cancel a pending recording start instead of queueing behind it Starting a recording holds the recorder lock through the picker and until the recorder creates its file. A second toggle or a stop then waited on that lock, with no end while a portal pick or a stuck recorder never finished, and once the start gave up the queued toggle began another recording. While a start is pending, a toggle or stop that cannot take the lock within a second now leaves a cancel marker and returns. The start checks it after the picker, after the webcam overlay and while waiting on its recorder, then stops that recorder and releases its audio. A recorder other than the portal's that has not created its file after 15 seconds is stopped and the next backend tried. * Move automatic keyboard lighting to its own change Ambient-light keyboard backlight is a new feature on every laptop with the sensor, not something Apple hardware needs to run Omarchy, so it leaves this change for a separate review. The keyboard-brightness command returns to upstream's version, and first run no longer enables the user service. * Move workspace refocus on monitor removal to its own change Keeping the viewed workspace in front after a monitor is unplugged changes desktop behaviour on every machine, not just Apple hardware, so it leaves this change for a separate review. * Move prebuilt-image hardware deferral to its own change Building an image away from its machine needs a manifest the detector reads, a queue of hardware steps for the first boot, a first-boot service and a per-machine pacman keyring. An install on the machine itself needs none of it, so it leaves this change for a separate review with an installer and package boundary of its own. Hardware and user setup now run where they are installed, and the platform setup operations take no image argument. * Move the Qualcomm platform to its own change Snapdragon support is separable from Apple compatibility, so the detector no longer names a qualcomm platform: a "qcom," device tree is generic aarch64, which Snapdragon already behaves as everywhere else here, and a tree naming both Apple and Qualcomm is still refused. linux-firmware-qcom's platform list leaves with it. * Keep a cancel that arrives while a recording start finishes A stop could land after the start's last cancel check, while it saved its recording as started, and be dropped with the marker. The start now removes its starting marker before a final check, a request reads that marker only after leaving its own, and one that finds the start gone waits for the lock and acts unless the start already used its request. A recorder ended by a cancel or a readiness timeout gets two seconds after TERM before KILL, so a recorder ignoring TERM cannot hold the start and its lock. * Defer an image build's hardware setup to the machine's first boot Brings back prebuilt-image support split out of #14431 so it gets its own review. An image built away from its machine names its target in a root-owned manifest the detector reads; hardware setup is queued for the machine's first boot, run there by omarchy-provision-hardware, which also makes the machine's own pacman keyring; and the platform setup operations learn they run on an image's first boot. Apple Silicon images built by the omarchy-mac installer need this; an install on the machine itself does not. * Rebuild the initramfs when deferred setup changes module options mkinitcpio's modconf hook copies /etc/modprobe.d into the initramfs, but the deferred first-boot setup only compared mkinitcpio's configuration and the kernel command line when deciding whether to rebuild. A step that only wrote module options, as nvidia.sh does, left the old ones in the boot image. * Put the Omarchy repository first on aarch64, as on x86_64 quattro now searches [omarchy] before the Arch repositories (34016cfb2), and its migration 1791403252 moves the section first in /etc/pacman.conf on every machine. The aarch64 edge template still listed it last, so refreshing pacman on a generic aarch64 machine would undo that migration. Apple Silicon's template already lists it first. * Refuse disk password changes that recovery could not settle An interrupted change to the system disk is finished from the one key slot the password opened. If the old password also sat in another slot, a LUKS2 change made in place and stopped before the old key was retired let the rerun accept the new password as the old one and finish while the other copy still opened the disk. A system disk whose current password opens more than one slot is now refused before anything changes, naming the extra slot to remove and, where the platform records key slots, how to record the ones kept. The journal now notes that check (checked=1). A change journaled without it, by an earlier version, on a disk that has other key slots may still hold a copy of the old password, and no password entered now can show which slot is whose, so it is left for the owner to settle by hand, in every phase, with the commands to do it. On a disk with one slot it settles as before. A new password that already opens a key slot is refused on every drive too: the new key's slot is how the change is followed, and an existing copy made the run take the wrong slot as the owner's. A settled interrupted change now says the disk opens with the password just entered, which is true whether it finished the change or rolled it back. * Ignore the start marker of a recording start that was killed A start killed before it removed its marker left it behind, and a later toggle that had to wait for the lock took that marker for a start still in progress and exited without doing anything. The marker now holds the starting process's pid and counts only while that process runs. * Say why generic aarch64 searches Omarchy's repository first Putting [omarchy] ahead of Arch Linux ARM follows quattro's x86_64 order and its migration 1791403252, which already reorders existing aarch64 machines. It means a package Omarchy also publishes now comes from Omarchy, and a refresh can move it there, downgrading it if Omarchy's build is older. The update-process notes and the template now say so. * Keep a working key in the manual settlement steps When an earlier version's interrupted change has to be settled by hand, the steps now start by finding and keeping the slot of the password to keep, test the other slots with passphrases only, never remove the kept slot, and record a Mac's slots before the journal goes. Before, removing every slot the old password opened could remove the only key when the change had not landed. * Keep Superwhisper's x86_64-only default off aarch64 quattro now ships Superwhisper as the default dictation backend and puts superwhisper-bin in the base list, but it has no aarch64 build. On an aarch64 machine omarchy-reinstall-pkgs and omarchy-upgrade-to-quattro asked for a package no repository carries, Setup > Defaults offered an installer that cannot work, new users were preset to that backend, and the dictation keys, F9 and Right Alt (AltGr on many layouts) included, were bound to it. install/omarchy-x86_64-only.packages names the base packages only x86_64 builds. omarchy-pkg-defaults leaves them out of every aarch64 platform's set, and omarchy-upgrade-to-quattro out of the base list it installs there. The Superwhisper default row shows on x86_64 only, like the other x86_64-only rows. User setup on aarch64 removes the shipped Superwhisper preset, so a new user starts with no backend and picks one; any other selection stays. The dictation keys are bound only once the selected backend's own program is installed, which changes nothing where it is. * Install the stub dictation backend where binding tests expect its keys The dictation keys are now bound only once the selected backend's program is installed. The binding-conflict check selected Voxtype without providing it, so it no longer saw the dictation keys and failed its F9 check; the default-config check now also covers a selected backend that is not installed. * Make the stale start marker test's dead pid reliably dead The test killed a backgrounded sleep to get a pid that no longer runs. When the signal landed before sleep started, wait never returned and the suite timed out, about one run in three. A process that exits on its own gives the same dead pid without the race. * Drop the virtual input level meter the panel ran through a helper Volume and mute for an untyped input still go through wpctl; typed inputs keep Quickshell's peak monitor. Quickshell's monitor takes typed nodes only, so the panel hides the level bar for a virtual input rather than showing an empty bar that reads as a silent mic. * Find recording processes with pgrep instead of a Python /proc scan pgrep matches the kernel's 15-character process name, so gpu-screen-recorder is matched as gpu-screen-reco. That prefix also lets any gpu-screen-reco* executable, or a process that renames itself, pass for a recorder, so each match is confirmed against its /proc exe link, ignoring the " (deleted)" suffix an upgrade leaves. --signal re-checks that link just before signalling each recorder, which narrows the pid-reuse window the old pidfd-based scan closed, and exits 1 when no signal was delivered. --pid rejects values above the Linux pid limit instead of letting bash arithmetic wrap them. * Leave 1Password's software GL wrapper to its own change This reverts f200105c2; the render-GPU check, Electron GL wrapper and desktop entry repair return in a separate branch. * Keep Gliff's x86_64-only package off aarch64 quattro added gliff to the base list and a migration that installs it, but its recipe builds for x86_64 alone. It joins Superwhisper in install/omarchy-x86_64-only.packages, so aarch64 package sets leave it out, and the migration installs it on x86_64 only. * Stub accessory enrollment in the first-boot re-key test Owner setup now enrolls the owner's USB devices and Thunderbolt accessories after the staged LUKS key is gone, and the re-key test's provisioning run reached that step with nothing to answer it. It now stubs both and checks that enrollment runs once, after the staged unlock is retired. * Keep USB authorization from boot off Apple Silicon Blocking USB from the start of kernel boot writes Limine's entry-tool drop-in and checks the Limine menu and UKI directly, but a Mac's boot chain belongs to its boot package. omarchy-setup-security-usb-authorization --boot is refused there before anything changes, and Setup and Remove > Security hide USB at Boot. The boot helper itself refuses enable on Apple Silicon and treats disable as done when nothing is configured, so turning USB device authorization off still works on a Mac; a boot-time setting configured some other way is left with steps to remove it by hand rather than reported removed. USB device authorization after login is unchanged. * Skip fixture-only checks when the shell suite runs as root Root's locale step always writes the real /etc/locale.conf and locale.gen, and root's platform check always reads this machine, so their fixtures do not apply there. The locale test now stops after its static checks as root instead of changing the runner's locale, and the Apple Silicon refusal in the USB authorization test runs unprivileged only. * Fetch aarch64 packages from Omarchy's HTTPS Arch Linux ARM mirror * Name platforms x86, aarch64 and aarch64-apple omarchy-hw-platform now prints the CPU architecture, then the hardware family when it needs handling of its own: x86, aarch64 (every ARM machine without a family) and aarch64-apple (Apple Silicon). A later family such as aarch64-qualcomm or aarch64-n1x follows the same form, so aarch64* always means ARM. The Apple predicate is now omarchy-hw-aarch64-apple; omarchy-hw-apple-silicon stays as a hidden alias while omarchy-mac's packages and units call it. The Apple package list and pacman templates move to install/omarchy-aarch64-apple.packages and default/pacman/aarch64-apple/. Image manifests and omarchy-pkg-defaults still accept apple-silicon, generic-aarch64 and generic from builders written before the names settled, and the mkinitcpio baseline accepts a detector that still says apple-silicon while omarchy-settings and omarchy update apart. * Add omarchy-hw-aarch64 and gate architecture checks on the helpers omarchy-hw-aarch64 is the counterpart of omarchy-hw-x86: true on every ARM machine, Apple Silicon included. The Windows VM asks omarchy-hw-x86 instead of reading uname itself, and the keyring refresh asks omarchy-hw-aarch64, so an x86 update no longer runs the platform detector for it. * Document platform names, helpers and the consistency rule for agents AGENTS.md gains a Platforms section: the x86 / aarch64 / aarch64- naming standard, which helper answers architecture and which answers hardware family, where platform-specific packages, templates, setup and boot code live, and that every platform should get the same experience, with missing packages fixed upstream before any workaround and alternative packages kept as a last resort. --------- Co-authored-by: Marcelo Alcantara Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Scott Jones Co-authored-by: Marcelo Alcantara Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com> Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com> Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com> Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com> Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com> Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com> Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com> Co-authored-by: Ryan Hughes Co-authored-by: Claude Opus 5.5 Co-authored-by: Codex XHigh Co-authored-by: David Heinemeier Hansson Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com> Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- AGENTS.md | 24 + agents/skills/install-scripts.md | 2 + bin/omarchy-apply-hardware | 16 +- bin/omarchy-audio-input-set-default | 10 +- bin/omarchy-audio-sink-availability | 21 +- bin/omarchy-battery-present | 14 +- bin/omarchy-battery-status | 33 +- bin/omarchy-brightness-display | 29 +- bin/omarchy-capture-screenrecording | 355 ++++++- bin/omarchy-capture-screenrecording-process | 72 ++ bin/omarchy-channel-set | 34 +- bin/omarchy-drive-password | 438 ++++++++- bin/omarchy-hibernation-setup | 6 + bin/omarchy-hw-aarch64 | 5 + bin/omarchy-hw-aarch64-apple | 17 + bin/omarchy-hw-apple-silicon | 15 + bin/omarchy-hw-display | 46 +- bin/omarchy-hw-laptop | 28 +- bin/omarchy-hw-laptop-closed | 44 +- bin/omarchy-hw-platform | 202 ++++ bin/omarchy-hw-touchpad | 27 +- bin/omarchy-hw-x86 | 5 + bin/omarchy-hyprland-monitor-watch | 1 + bin/omarchy-install-browser | 22 + bin/omarchy-install-dictation-voxtype | 5 +- bin/omarchy-install-gaming-steam | 1 + bin/omarchy-lifecycle-dispatch | 200 ++++ bin/omarchy-menu-keybindings | 27 +- bin/omarchy-pkg-defaults | 53 + bin/omarchy-provision-first-run | 4 + bin/omarchy-provision-hardware | 162 ++++ bin/omarchy-provision-owner | 345 +++++-- bin/omarchy-refresh-pacman | 20 +- bin/omarchy-reinstall-pkgs | 14 +- bin/omarchy-remove-gaming-steam | 1 + bin/omarchy-setup-direct-boot | 8 + bin/omarchy-setup-security-usb-authorization | 7 + bin/omarchy-system-factory-reset | 171 +++- bin/omarchy-system-lid-close | 5 +- bin/omarchy-system-lid-inhibit | 100 ++ bin/omarchy-update | 16 +- bin/omarchy-update-boot | 39 + bin/omarchy-update-keyring | 26 +- ...omarchy-update-system-pkgs-when-conflicted | 11 + bin/omarchy-upgrade-to-quattro | 5 + bin/omarchy-usb-authorization-boot | 20 +- bin/omarchy-windows-vm | 6 + default/hypr/bindings/dictation.lua | 10 +- default/hypr/bindings/utilities.lua | 3 + default/omarchy/omarchy-menu.jsonc | 22 +- default/pacman/aarch64-apple/mirrorlist-edge | 3 + default/pacman/aarch64-apple/pacman-edge.conf | 39 + default/pacman/aarch64/mirrorlist-edge | 3 + default/pacman/aarch64/pacman-edge.conf | 36 + default/settings-runtime-profile | 27 + docs/file-layout.md | 69 +- docs/lifecycle-dispatch.md | 164 ++++ docs/omarchy-shell.md | 10 + docs/update-process.md | 34 +- etc/mkinitcpio.conf.d/00-omarchy-hooks.conf | 37 + etc/mkinitcpio.conf.d/omarchy_hooks.conf | 3 +- etc/mkinitcpio.conf.d/thunderbolt_module.conf | 8 +- install/config/all.sh | 1 + install/config/locale.sh | 50 + install/hardware/all.sh | 2 + .../hardware/apple/fix-brcmfmac-supplicant.sh | 8 + install/hardware/apple/fix-spi-keyboard.sh | 6 +- install/hardware/bluetooth.sh | 10 + install/hardware/fix-fkeys.sh | 10 +- install/hardware/platform-setup.sh | 13 + install/helpers/image-target.sh | 164 ++++ install/helpers/pacman.sh | 32 + install/omarchy-aarch64-apple.packages | 26 + install/omarchy-aarch64.packages | 4 + install/omarchy-x86_64-only.packages | 4 + install/post-install/pacman.sh | 38 +- install/provisioning/luks-rekey.sh | 290 ++++++ .../omarchy-provision-hardware.service | 21 + install/user/all.sh | 3 + install/user/dictation-default.sh | 8 + install/user/platform-setup.sh | 10 + manual/48-security.md | 2 +- migrations/1786391100.sh | 7 + migrations/1786605598.sh | 7 +- migrations/1791542175.sh | 5 +- shell/Commons/AudioNodes.qml | 41 + shell/Commons/AudioNodesModel.js | 96 ++ shell/Commons/Style.qml | 9 +- shell/Commons/UntypedInput.qml | 152 +++ shell/Commons/qmldir | 2 + shell/plugins/bar/Bar.qml | 116 ++- shell/plugins/bar/BarModel.js | 113 +++ .../bar/indicators/ScreenRecording.qml | 2 +- shell/plugins/bar/widgets/Microphone.qml | 31 +- .../notifications/NotificationLogic.js | 14 + shell/plugins/notifications/Service.qml | 10 +- shell/plugins/panels/audio/Model.js | 13 +- shell/plugins/panels/audio/Panel.qml | 85 +- shell/services/PluginBarStateApi.qml | 11 + shell/shell.qml | 14 + test/shell.d/apple-silicon-quirks-test.sh | 93 ++ test/shell.d/audio-input-set-default-test.sh | 76 ++ .../shell.d/audio-source-availability-test.sh | 65 ++ test/shell.d/audio-test.sh | 68 ++ test/shell.d/bar-notch-test.sh | 235 +++++ test/shell.d/bar-test.sh | 4 +- test/shell.d/base-test.sh | 52 + test/shell.d/battery-present-test.sh | 38 + test/shell.d/battery-status-test.sh | 90 +- test/shell.d/brave-widevine-test.sh | 89 ++ test/shell.d/brcmfmac-supplicant-test.sh | 29 +- test/shell.d/brightness-display-test.sh | 54 +- test/shell.d/channel-sudo-boundary-test.sh | 79 ++ test/shell.d/default-apps-test.sh | 3 + test/shell.d/desktop-entry-launch-test.sh | 2 +- test/shell.d/dictation-test.sh | 41 +- test/shell.d/drive-password-test.sh | 856 ++++++++++++++++- test/shell.d/factory-reset-dispatch-test.sh | 339 +++++++ .../90-omarchy-mac.conf | 18 + .../91-omarchy-mac-encrypt.conf | 90 ++ .../92-omarchy-mac-hid.conf | 31 + .../93-omarchy-mac-plymouth.conf | 18 + .../94-omarchy-mac-vconsole.conf | 46 + test/shell.d/fixtures/sudo-boundary-test.sh | 15 +- test/shell.d/hw-display-test.sh | 84 +- test/shell.d/hw-laptop-test.sh | 141 +++ test/shell.d/hw-platform-test.sh | 203 ++++ test/shell.d/hw-touchpad-test.sh | 97 ++ .../hyprland-binding-conflicts-test.sh | 4 +- test/shell.d/hyprland-default-config-test.sh | 6 +- test/shell.d/image-deferred-hardware-test.sh | 665 +++++++++++++ test/shell.d/image-target-platform-test.sh | 442 +++++++++ test/shell.d/keybindings-menu-test.sh | 17 +- test/shell.d/lid-inhibit-test.sh | 398 ++++++++ test/shell.d/lifecycle-dispatch-test.sh | 346 +++++++ test/shell.d/locale-setup-test.sh | 121 +++ test/shell.d/luks-parent-detection-test.sh | 71 ++ test/shell.d/luks-rekey-journal-test.sh | 904 ++++++++++++++++++ .../menu-keybindings-key-names-test.sh | 155 +++ test/shell.d/menu-test.sh | 24 +- .../shell.d/mkinitcpio-apple-drop-ins-test.sh | 76 ++ test/shell.d/mkinitcpio-hooks-test.sh | 264 +++++ test/shell.d/monitor-modeless-test.sh | 2 + test/shell.d/monitor-recovery-test.sh | 7 +- test/shell.d/notifications-test.sh | 17 + test/shell.d/nvidia-kms-hook-test.sh | 35 +- test/shell.d/pacman-templates-test.sh | 173 ++++ test/shell.d/platform-app-hooks-test.sh | 136 +++ test/shell.d/platform-packages-test.sh | 160 ++++ test/shell.d/platform-setup-test.sh | 146 +++ test/shell.d/provision-owner-keyboard-test.sh | 313 ++++++ test/shell.d/provision-owner-regdom-test.sh | 128 +++ .../screenrecording-concurrency-test.sh | 228 +++++ test/shell.d/screenrecording-process-test.sh | 197 ++++ .../screenrecording-sample-rate-test.sh | 30 + .../screenrecording-stale-audio-test.sh | 57 ++ test/shell.d/screenrecording-test.sh | 133 ++- test/shell.d/screenrecording-wf-audio-test.sh | 110 +++ test/shell.d/settings-runtime-profile-test.sh | 27 + test/shell.d/spi-keyboard-test.sh | 33 + test/shell.d/update-boot-verify-test.sh | 127 +++ test/shell.d/update-disk-space-test.sh | 1 + test/shell.d/update-file-conflict-test.sh | 83 +- test/shell.d/update-keyring-test.sh | 66 +- test/shell.d/update-lock-test.sh | 1 + test/shell.d/update-sequence-test.sh | 2 + test/shell.d/usb-authorization-test.sh | 35 + test/shell.d/voxtype-install-test.sh | 74 ++ test/shell.d/windows-vm-compose-test.sh | 9 + .../shell.d/windows-vm-mount-boundary-test.sh | 7 + 170 files changed, 13030 insertions(+), 366 deletions(-) create mode 100755 bin/omarchy-capture-screenrecording-process create mode 100755 bin/omarchy-hw-aarch64 create mode 100755 bin/omarchy-hw-aarch64-apple create mode 100755 bin/omarchy-hw-apple-silicon create mode 100755 bin/omarchy-hw-platform create mode 100755 bin/omarchy-hw-x86 create mode 100755 bin/omarchy-lifecycle-dispatch create mode 100755 bin/omarchy-pkg-defaults create mode 100755 bin/omarchy-provision-hardware create mode 100755 bin/omarchy-system-lid-inhibit create mode 100755 bin/omarchy-update-boot create mode 100644 default/pacman/aarch64-apple/mirrorlist-edge create mode 100644 default/pacman/aarch64-apple/pacman-edge.conf create mode 100644 default/pacman/aarch64/mirrorlist-edge create mode 100644 default/pacman/aarch64/pacman-edge.conf create mode 100644 default/settings-runtime-profile create mode 100644 docs/lifecycle-dispatch.md create mode 100644 etc/mkinitcpio.conf.d/00-omarchy-hooks.conf create mode 100644 install/config/locale.sh create mode 100644 install/hardware/platform-setup.sh create mode 100644 install/helpers/image-target.sh create mode 100644 install/helpers/pacman.sh create mode 100644 install/omarchy-aarch64-apple.packages create mode 100644 install/omarchy-aarch64.packages create mode 100644 install/omarchy-x86_64-only.packages create mode 100644 install/provisioning/luks-rekey.sh create mode 100644 install/provisioning/omarchy-provision-hardware.service create mode 100644 install/user/dictation-default.sh create mode 100644 install/user/platform-setup.sh create mode 100644 shell/Commons/AudioNodes.qml create mode 100644 shell/Commons/AudioNodesModel.js create mode 100644 shell/Commons/UntypedInput.qml create mode 100644 test/shell.d/apple-silicon-quirks-test.sh create mode 100644 test/shell.d/audio-input-set-default-test.sh create mode 100644 test/shell.d/audio-source-availability-test.sh create mode 100644 test/shell.d/bar-notch-test.sh create mode 100755 test/shell.d/battery-present-test.sh create mode 100644 test/shell.d/brave-widevine-test.sh create mode 100644 test/shell.d/factory-reset-dispatch-test.sh create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/90-omarchy-mac.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/91-omarchy-mac-encrypt.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/92-omarchy-mac-hid.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf create mode 100755 test/shell.d/hw-laptop-test.sh create mode 100644 test/shell.d/hw-platform-test.sh create mode 100755 test/shell.d/hw-touchpad-test.sh create mode 100644 test/shell.d/image-deferred-hardware-test.sh create mode 100644 test/shell.d/image-target-platform-test.sh create mode 100755 test/shell.d/lid-inhibit-test.sh create mode 100755 test/shell.d/lifecycle-dispatch-test.sh create mode 100644 test/shell.d/locale-setup-test.sh create mode 100644 test/shell.d/luks-parent-detection-test.sh create mode 100755 test/shell.d/luks-rekey-journal-test.sh create mode 100644 test/shell.d/menu-keybindings-key-names-test.sh create mode 100755 test/shell.d/mkinitcpio-apple-drop-ins-test.sh create mode 100755 test/shell.d/mkinitcpio-hooks-test.sh create mode 100755 test/shell.d/pacman-templates-test.sh create mode 100644 test/shell.d/platform-app-hooks-test.sh create mode 100644 test/shell.d/platform-packages-test.sh create mode 100644 test/shell.d/platform-setup-test.sh create mode 100644 test/shell.d/provision-owner-keyboard-test.sh create mode 100644 test/shell.d/provision-owner-regdom-test.sh create mode 100755 test/shell.d/screenrecording-concurrency-test.sh create mode 100755 test/shell.d/screenrecording-process-test.sh create mode 100755 test/shell.d/screenrecording-sample-rate-test.sh create mode 100755 test/shell.d/screenrecording-stale-audio-test.sh create mode 100644 test/shell.d/screenrecording-wf-audio-test.sh create mode 100755 test/shell.d/settings-runtime-profile-test.sh create mode 100644 test/shell.d/spi-keyboard-test.sh create mode 100644 test/shell.d/update-boot-verify-test.sh create mode 100644 test/shell.d/voxtype-install-test.sh diff --git a/AGENTS.md b/AGENTS.md index 6de19cb9..1bee7182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,30 @@ guidance does not drift from the router. - `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code. - Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually. +# Platforms + +Omarchy runs on x86 and aarch64 (ARM). A platform name is the CPU architecture, then a hardware family only when that family needs handling of its own: + +- `x86` - every x86_64 machine +- `aarch64` - every ARM machine without a family of its own (Snapdragon laptops, Raspberry Pis and ARM VMs today) +- `aarch64-apple` - Apple Silicon Macs + +A new family is named `aarch64-` (e.g. `aarch64-qualcomm`, `aarch64-n1x`) and is added only once it needs code of its own, so `aarch64*` always means ARM. `omarchy-hw-platform` prints the most specific name. Use these exact names wherever a platform is named: package lists, pacman template directories, image manifests, dispatch registration, tests and docs. Never introduce `generic`, `arm64`, `apple-silicon` or other spellings; the old `apple-silicon`, `generic-aarch64` and `generic` are read only for compatibility with older image builders. + +Ask a helper; don't read `uname -m` or the device tree in feature code: + +- `omarchy-hw-x86` / `omarchy-hw-aarch64` - the CPU architecture. Use them for binary and ABI availability: a package with no aarch64 build, an x86 guest VM, multilib. +- `omarchy-hw-aarch64-apple` (and later `omarchy-hw-aarch64-`) - built on `omarchy-hw-platform`, image-build aware. Use them for hardware behaviour. `omarchy-hw-apple-silicon` is a hidden alias kept for the omarchy-mac packages; don't use it in new code. + +Where platform code lives: + +- Packages: `install/omarchy-base.packages` for everyone, `install/omarchy-aarch64.packages` for every ARM machine, `install/omarchy-.packages` for one family, and base packages with no aarch64 build in `install/omarchy-x86_64-only.packages`. `omarchy-pkg-defaults` composes them. +- Pacman templates: `default/pacman/` for x86, `default/pacman//` for each ARM platform. +- Hardware setup: `install/hardware//`, gated by a predicate. `install/hardware/apple/` holds Intel Mac quirks; Apple Silicon's own setup ships in the omarchy-mac packages. +- Boot chains Omarchy can't drive generically: a platform package behind `omarchy-lifecycle-dispatch` ([`docs/lifecycle-dispatch.md`](docs/lifecycle-dispatch.md)). Platforms that boot Limine with UKIs like x86 keep the generic path. + +The experience should be the same on every platform. When a package or feature is missing or broken on one, the default is to get it fixed upstream (an aarch64 build, a bug report, a patch to the maintainer) and leave the gap visible meanwhile, not to paper over it. When asked for a platform workaround, say so and propose the upstream fix first. Hiding a menu entry or skipping a package is fine only for things that can't exist there (an x86 guest VM, an x86-only proprietary binary). Alternative packages and platform-only replacements are a last resort, used only when upstream can't or won't fix it, with the reason written next to the code. + # Privileged Commands - Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md index aa993e5e..b117b193 100644 --- a/agents/skills/install-scripts.md +++ b/agents/skills/install-scripts.md @@ -12,6 +12,8 @@ commands and reusable setup leaves: - avoid `exit` in sourced setup scripts unless intentionally aborting setup. - use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths. - keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`. +- in an image build every `run_logged` leaf in `install/hardware/all.sh` is queued and runs on the machine's first boot instead (`bin/omarchy-provision-hardware`, see `docs/file-layout.md`), possibly offline, using packages already in the image, and before any user exists; keep those leaves idempotent and put nothing but `run_logged` lines in `all.sh`. +- gate platform-specific setup on a predicate built on `omarchy-hw-platform` (`omarchy-hw-aarch64-apple`), not on `uname -m`; architecture checks (`omarchy-hw-x86`, `omarchy-hw-aarch64`) are only for ABI, binary availability or a repository's `$arch`. Platform names and where platform code lives are in the Platforms section of `AGENTS.md`. - keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user. - The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist. - prefer helper commands for package and command checks where available. diff --git a/bin/omarchy-apply-hardware b/bin/omarchy-apply-hardware index 89eb8ab1..9f873d5a 100755 --- a/bin/omarchy-apply-hardware +++ b/bin/omarchy-apply-hardware @@ -19,6 +19,9 @@ for diagnostics or after hardware/package updates. --defer-provisioning runs without an install user (deferred-provisioning installs create the user at first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups. + +In an image build (/var/lib/omarchy/image/target exists) nothing runs here: every step is queued +for omarchy-provision-hardware on the machine's first boot. USAGE } @@ -66,10 +69,21 @@ else fi export OMARCHY_INSTALL_USER="$install_user" +# Only omarchy-provision-hardware runs steps as an image's first boot. +unset OMARCHY_IMAGE_DEFERRED_HARDWARE export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}" export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" export PATH="$OMARCHY_PATH/bin:$PATH" source "$OMARCHY_INSTALL/helpers/logging.sh" -source "$OMARCHY_INSTALL/hardware/all.sh" +source "$OMARCHY_INSTALL/helpers/image-target.sh" + +omarchy_image_init +omarchy_image_lock +if omarchy_image_manifest_present; then + omarchy_image_read_manifest + omarchy_image_defer_hardware +else + source "$OMARCHY_INSTALL/hardware/all.sh" +fi diff --git a/bin/omarchy-audio-input-set-default b/bin/omarchy-audio-input-set-default index 280042d2..97a8c2c7 100755 --- a/bin/omarchy-audio-input-set-default +++ b/bin/omarchy-audio-input-set-default @@ -15,6 +15,14 @@ fi wpctl set-default "$node_id" 2>/dev/null || true pactl set-default-source "$source_name" 2>/dev/null || true -pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do +# A filter's own capture (the input half of a filter chain, loopback or echo +# canceller, which PipeWire joins to its output by node.link-group) is the +# processing, not an app: moving it would feed the filter from the new input, or +# from its own output. pactl translates the listing, so it is read in the C +# locale. +LC_ALL=C pactl list source-outputs 2>/dev/null | awk ' + /^Source Output #/ { if (id != "" && !filter) print id; id = substr($3, 2); filter = 0 } + /^[[:space:]]*node\.link-group = / { filter = 1 } + END { if (id != "" && !filter) print id }' | while read -r output; do [[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true done diff --git a/bin/omarchy-audio-sink-availability b/bin/omarchy-audio-sink-availability index 531b5bff..7e794b41 100755 --- a/bin/omarchy-audio-sink-availability +++ b/bin/omarchy-audio-sink-availability @@ -1,14 +1,27 @@ #!/bin/bash -# omarchy:summary=Print PulseAudio sink availability for the shell +# omarchy:summary=Print PulseAudio sink or source availability for the shell # omarchy:group=audio +# omarchy:args=[sources] + +# With "sources", print source availability instead: a jack input such as a +# headset microphone reports its port unavailable while nothing is plugged in. +# pactl translates the listing ("not available" too), so it is read in the C +# locale. +kind=sinks +header="Sink #" +if [[ ${1:-} == "sources" ]]; then + kind=sources + header="Source #" +fi # A speaker tuning is a virtual sink in front of the real speakers. Both exist # in the graph, but selecting the physical one would only bypass the tuning, so # report it unavailable and keep it out of the output list. -fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" +fronted="" +[[ $kind == sinks ]] && fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" -pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' +LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$header" ' function emit_sink() { if (name == "") return if (fronted != "" && name == fronted) { @@ -18,7 +31,7 @@ pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' print name "\t" ((port_count == 0 || available) ? 1 : 0) } - /^Sink #/ { + index($0, header) == 1 { emit_sink() name = "" in_ports = 0 diff --git a/bin/omarchy-battery-present b/bin/omarchy-battery-present index fb221e8c..90883a67 100755 --- a/bin/omarchy-battery-present +++ b/bin/omarchy-battery-present @@ -2,10 +2,16 @@ # omarchy:summary=Returns true if a battery is present on the system. -for bat in /sys/class/power_supply/BAT*; do - [[ -r $bat/present ]] && - [[ $(cat $bat/present) == "1" ]] && - [[ $(cat $bat/type) == "Battery" ]] && +# Not every battery is called BAT*: Apple Silicon reports macsmc-battery and +# some Framework and Chromebook firmware CMB0, so decide by type, not by name. +power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}" + +for bat in "$power_supply_path"/*; do + [[ -r $bat/present && $(<"$bat/present") == "1" ]] || continue + [[ -r $bat/type && $(<"$bat/type") == "Battery" ]] || continue + # A wireless mouse or keyboard registers a Battery too, scoped Device. The + # one that powers the machine is scoped System, or unscoped on older drivers. + [[ -r $bat/scope && $(<"$bat/scope") == "Device" ]] && continue exit 0 done diff --git a/bin/omarchy-battery-status b/bin/omarchy-battery-status index cc0a6024..13de4454 100755 --- a/bin/omarchy-battery-status +++ b/bin/omarchy-battery-status @@ -18,11 +18,23 @@ case "${1:-}" in ;; esac -battery=$(upower -e 2>/dev/null | grep BAT | head -n 1) +# UPower names the device after the sysfs entry, and only some kernels call +# that BAT*: Apple Silicon reports battery_macsmc_battery. Match on the class, +# then take the first one that powers the machine: a wireless mouse or keyboard +# is a battery_ device too, and UPower marks it "power supply: no". +battery="" +battery_info="" +while read -r candidate; do + [[ -n $candidate ]] || continue + candidate_info=$(upower -i "$candidate" 2>/dev/null) || continue + if grep -qE '^\s*power supply:\s+yes' <<<"$candidate_info"; then + battery=$candidate + battery_info=$candidate_info + break + fi +done < <(upower -e 2>/dev/null | grep '/battery_') [[ -z $battery ]] && exit 0 -battery_info=$(upower -i "$battery") - percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info") capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info") time_remaining=$(awk '/time to (empty|full)/ { @@ -56,7 +68,10 @@ elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then 'BEGIN { print microamps * microvolts / 1000000000000 }') fi +# Some kernels sign power_now by direction (negative while discharging on +# Apple Silicon); the state field already says which way it flows. power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN { + if (rate < 0) rate = -rate rounded = sprintf("%.1f", rate) sub(/\.0$/, "", rounded) print rounded @@ -65,8 +80,12 @@ state=$(awk '/state/ { print $2; exit }' <<<"$battery_info") threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") -[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) -[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) +# Read the thresholds from the battery UPower resolved rather than a BAT* glob, +# which misses every battery with another name. +[[ -n $threshold_end || ! -r $battery_path/charge_control_end_threshold ]] || threshold_end=$(<"$battery_path/charge_control_end_threshold") +[[ -n $threshold_start || ! -r $battery_path/charge_control_start_threshold ]] || threshold_start=$(<"$battery_path/charge_control_start_threshold") +[[ -n $threshold_end ]] || threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) +[[ -n $threshold_start ]] || threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) ac_online=false for supply in "$power_supply_path"/*; do @@ -107,7 +126,9 @@ if [[ $shell_output == "true" ]]; then printf 'size\t%s\n' "${capacity}Wh" printf 'time\t%s\n' "$time_remaining" - cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) + cycles="" + [[ ! -r $battery_path/cycle_count ]] || cycles=$(<"$battery_path/cycle_count") + [[ -n $cycles ]] || cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) [[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles" diff --git a/bin/omarchy-brightness-display b/bin/omarchy-brightness-display index a6a1e830..5a3e534e 100755 --- a/bin/omarchy-brightness-display +++ b/bin/omarchy-brightness-display @@ -40,7 +40,30 @@ use_apple_display() { } use_ddc_display() { - [[ -n $monitor ]] && ! monitor_is_internal + [[ -n $monitor ]] && ! monitor_is_internal && monitor_may_have_ddc +} + +# A platform whose display driver registers no DDC channel says so in its +# displays.conf (see docs/file-layout.md): there a probe finds only unrelated +# I2C buses, slowly, so only a connector with a ddc node is probed. Everywhere +# else every external monitor is. +monitor_may_have_ddc() { + local displays_conf=/usr/share/omarchy-platform/displays.conf directive argument + [[ -r $displays_conf ]] || return 0 + + while IFS=$' \t\r' read -r directive argument || [[ -n $directive ]]; do + if [[ $directive == "ddc-require-connector-ddc" && -z $argument ]]; then + compgen -G "/sys/class/drm/card*-$monitor/ddc" >/dev/null + return + fi + done <"$displays_conf" + return 0 +} + +# The kernel backlight is the built-in panel's, so an external monitor without +# DDC fails rather than dimming the panel. +use_backlight_display() { + [[ -z $monitor ]] || monitor_is_internal } if (( $# == 0 )); then @@ -50,6 +73,8 @@ if (( $# == 0 )); then elif use_ddc_display; then omarchy-brightness-display-ddc "$monitor" exit + elif ! use_backlight_display; then + exit 1 fi device="$(omarchy-hw-display)" || exit 1 @@ -85,6 +110,8 @@ if use_apple_display; then elif use_ddc_display; then brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1 (( no_osd )) || omarchy-osd -i brightness -p "$brightness" +elif ! use_backlight_display; then + exit 1 else # Current device highlighted device="$(omarchy-hw-display)" || exit 1 diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 0c4c5494..e5269ad1 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -2,7 +2,7 @@ # omarchy:summary=Start or stop screen recording # omarchy:group=capture -# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] +# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] [--status] # omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio # omarchy:aliases=omarchy screenrecord # @@ -14,19 +14,20 @@ # DMA-BUF modifier import on some configurations, leaving recording unable to # start. # -# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and -# the picker target it was launched with) to -# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when -# reporting capture failures. +# Env: OMARCHY_SCREENRECORD_DEBUG=true appends the recorder's stderr (and the +# picker target it was launched with) to $XDG_RUNTIME_DIR/omarchy-screenrecord.log +# so users can attach a log when reporting capture failures. +# +# Backend: gpu-screen-recorder. Where it is not installed, or it exits before +# the recording starts (it knows only NVIDIA, AMD, Intel and Broadcom GPUs, and +# aborts on others such as Apple Silicon's), wf-recorder records instead when +# it is installed. wf-recorder encodes on the CPU and captures one audio +# device, so desktop and microphone audio together go through a temporary null +# sink. Portal capture and --resolution are gpu-screen-recorder only. [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" -if [[ ! -d $OUTPUT_DIR ]]; then - omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" - exit 1 -fi - DESKTOP_AUDIO="false" MICROPHONE_AUDIO="false" WEBCAM="false" @@ -35,6 +36,7 @@ WEBCAM_SIZE="medium" RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" +STATUS="false" # Both of these live in the per-user runtime directory, which is 0700. What # RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes # beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in @@ -46,6 +48,17 @@ STOP_RECORDING="false" RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename" REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region" +# The recorder's pid, so stop and status act on that process alone. +REC_PID_FILE="$RUNTIME_DIR/omarchy-screenrecord-pid" +# The PulseAudio module ids of a combined-audio null sink, for cleanup. +PA_MODULES_FILE="$RUNTIME_DIR/omarchy-screenrecord-pa-modules" +# Present while a start holds the lock; another toggle or stop then leaves +# CANCEL_FILE for that start instead of queueing behind its picker or recorder. +STARTING_FILE="$RUNTIME_DIR/omarchy-screenrecord-starting" +CANCEL_FILE="$RUNTIME_DIR/omarchy-screenrecord-cancel" +# How long a recorder may take to create its file, except through the portal, +# whose picker waits on the user. +READY_TIMEOUT_TICKS=75 LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do @@ -58,6 +71,7 @@ for arg in "$@"; do --resolution=*) RESOLUTION="${arg#*=}" ;; --fullscreen) FULLSCREEN="true" ;; --stop-recording) STOP_RECORDING="true" ;; + --status) STATUS="true" ;; esac done @@ -69,6 +83,13 @@ small | medium | large) ;; ;; esac +# --status only answers whether our recording runs, for the bar and the menu, so +# it needs no recordings directory and never notifies. +if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then + omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" + exit 1 +fi + start_webcam_overlay() { cleanup_webcam @@ -99,7 +120,7 @@ start_webcam_overlay() { '--vf=lavfi=[crop=ih*8/9:ih]' \ --title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \ --no-border --no-audio --no-osc --osd-level=0 \ - --really-quiet &>/dev/null & + --really-quiet 9>&- &>/dev/null & # The move has to settle before gpu-screen-recorder starts, or the camera is # recorded sliding into its corner. Waiting for the map is what the blind @@ -153,12 +174,132 @@ select_capture_target() { echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}" } -start_screenrecording() { - mkdir -p "$RUNTIME_DIR" || return 1 - # XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. - if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then - chmod 700 "$RUNTIME_DIR" || return 1 +# gpu-screen-recorder first, then wf-recorder, of those installed. The base +# packages carry gpu-screen-recorder, but an architecture may build none. +recorder_backends() { + local backend found=false + + for backend in gpu-screen-recorder wf-recorder; do + if omarchy-cmd-present "$backend"; then + echo "$backend" + found=true + fi + done + + # Neither installed: fail the way a missing gpu-screen-recorder always has. + $found || echo gpu-screen-recorder +} + +# wf-recorder records a single audio device. Map the desktop/mic flags onto one: +# desktop -> default sink monitor, mic -> default source. When both are requested, +# route them through a throwaway null sink so a single track carries both; the +# module ids are saved to PA_MODULES_FILE so stop can unload them. A requested +# source that can't be recorded is left out with a notification naming it, and +# the recording carries whichever source remains. +wf_audio_device() { + local sink="" source="" m + [[ $DESKTOP_AUDIO == "true" ]] && sink=$(pactl get-default-sink 2>/dev/null) + [[ $MICROPHONE_AUDIO == "true" ]] && source=$(pactl get-default-source 2>/dev/null) + + if [[ -n $sink && -n $source ]]; then + if m=$(pactl load-module module-null-sink sink_name=omarchy_screenrec sink_properties=device.description=omarchy_screenrec 2>/dev/null); then + echo "$m" >"$PA_MODULES_FILE" + if m=$(pactl load-module module-loopback source="${sink}.monitor" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then + echo "$m" >>"$PA_MODULES_FILE" + else + sink="" + fi + if m=$(pactl load-module module-loopback source="$source" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then + echo "$m" >>"$PA_MODULES_FILE" + else + source="" + fi + if [[ -n $sink && -n $source ]]; then + echo "omarchy_screenrec.monitor" + return + fi + wf_audio_cleanup + else + # No device to mix into: desktop audio records alone, as it always has. + source="" + fi fi + + [[ $DESKTOP_AUDIO == "true" && -z $sink ]] && wf_audio_unavailable desktop + [[ $MICROPHONE_AUDIO == "true" && -z $source ]] && wf_audio_unavailable microphone + if [[ -n $sink ]]; then + echo "${sink}.monitor" + elif [[ -n $source ]]; then + echo "$source" + fi +} + +wf_audio_unavailable() { + omarchy-notification-send -u critical -t 5000 "Recording without $1 audio" "The $1 audio could not be added to this recording." >/dev/null 2>&1 +} + +wf_audio_cleanup() { + [[ -f $PA_MODULES_FILE ]] || return + # Unload in reverse so the loopbacks drop before the null sink they feed. + tac "$PA_MODULES_FILE" 2>/dev/null | while read -r m; do + [[ -n $m ]] && pactl unload-module "$m" 2>/dev/null + done + rm -f "$PA_MODULES_FILE" +} + +# Launches wf-recorder for the given picker target, backgrounded, with REC_PID set. +launch_wf_recorder() { + local target=$1 filename=$2 + local geom_args=() + + case $target in + monitor:*) + geom_args=(-o "${target#monitor:}") + ;; + region:*) + # region:WxH+X+Y (logical coords) -> wf-recorder -g "X,Y WxH" (same space). + [[ ${target#region:} =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]] || return 1 + geom_args=(-g "${BASH_REMATCH[3]},${BASH_REMATCH[4]} ${BASH_REMATCH[1]}x${BASH_REMATCH[2]}") + ;; + *) + return 1 + ;; + esac + + local audio_args=() dev + dev=$(wf_audio_device) + [[ -n $dev ]] && audio_args=(--audio="$dev") + + # -r 60 caps the framerate: capturing a 120 Hz panel at its native rate makes + # needlessly large files. -R 48000 keeps the AAC track at 48 kHz rather than + # following a sink that advertises 96 kHz into a file many players, and some + # speaker DSPs, play as silence. + wf-recorder "${geom_args[@]}" -r 60 -R 48000 "${audio_args[@]}" -f "$filename" 9>&- 2>>"$LOG_FILE" & + REC_PID=$! +} + +start_cancelled() { + [[ -e $CANCEL_FILE ]] +} + +# Stops a recorder this start launched: TERM, two seconds to exit, then KILL. +end_launched_recorder() { + local pid=$1 waited=0 state + kill "$pid" 2>/dev/null + while (( waited++ < 20 )); do + state=$(<"/proc/$pid/stat") 2>/dev/null || break + state=${state##*) } + [[ ${state:0:1} != "Z" ]] || break + sleep 0.1 + done + kill -KILL "$pid" 2>/dev/null + wait "$pid" 2>/dev/null +} + +start_screenrecording() { + # A pid left by a recording that ended without a stop would otherwise hide + # this one from the toggle until it has started and saved its own. + rm -f "$REC_PID_FILE" local capture_args=() local target @@ -174,6 +315,7 @@ start_screenrecording() { capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}") else target=$(select_capture_target) || return 1 + start_cancelled && return 1 case $target in monitor:*) @@ -187,50 +329,101 @@ start_screenrecording() { fi [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" + start_cancelled && return 1 local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" - local audio_devices="" - local audio_args=() + local backend pid="" - [[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output" + for backend in $(recorder_backends); do + [[ $backend == "wf-recorder" && $target == "portal" ]] && continue + echo "===== $(date '+%F %T') args: $* backend: $backend target: $target =====" >>"$LOG_FILE" - if [[ $MICROPHONE_AUDIO == "true" ]]; then - # Merge audio tracks into one - separate tracks only play one at a time in most players - [[ -n $audio_devices ]] && audio_devices+="|" - audio_devices+="default_input" - fi + if [[ $backend == "wf-recorder" ]]; then + launch_wf_recorder "$target" "$filename" || continue + pid=$REC_PID + else + local audio_devices="" + local audio_args=() - [[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac) + [[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output" - echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE" - gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" & - local pid=$! + if [[ $MICROPHONE_AUDIO == "true" ]]; then + # Merge audio tracks into one - separate tracks only play one at a time in most players + [[ -n $audio_devices ]] && audio_devices+="|" + audio_devices+="default_input" + fi - while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do - sleep 0.2 + [[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac) + + gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 9>&- 2>>"$LOG_FILE" & + pid=$! + fi + + local ticks=0 + while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do + start_cancelled && break + if [[ $target != "portal" ]] && (( ++ticks > READY_TIMEOUT_TICKS )); then + end_launched_recorder "$pid" + wf_audio_cleanup + rm -f "$filename" + continue 2 + fi + sleep 0.2 + done + + if start_cancelled; then + end_launched_recorder "$pid" + wf_audio_cleanup + rm -f "$filename" + return 1 + fi + + if kill -0 $pid 2>/dev/null; then + echo "$pid" >"$REC_PID_FILE" + echo "$filename" >"$RECORDING_FILE" + toggle_screenrecording_indicator + return 0 + fi + + wf_audio_cleanup + rm -f "$filename" done - if kill -0 $pid 2>/dev/null; then - echo "$filename" >"$RECORDING_FILE" - toggle_screenrecording_indicator - fi + return 1 } +# Selects the recorder this script started, by the pid it saved. A saved pid +# that is no longer a recorder is a recording that ended without a stop, so +# nothing of ours is recording and no other recorder is touched. Without a +# saved pid (a recording started before the pid was saved), every recorder the +# user runs counts, as for the bar indicator and the menu. +select_recorder() { + RECORDER=() + [[ -f $REC_PID_FILE ]] || return 0 + + local pid + pid=$(<"$REC_PID_FILE") + [[ $pid =~ ^[0-9]+$ ]] && omarchy-capture-screenrecording-process --pid "$pid" || return 1 + RECORDER=(--pid "$pid") +} + +# Stops the recorder screenrecording_active selected. stop_screenrecording() { - pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly + omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal INT # SIGINT required to save video properly # Wait a maximum of 5 seconds to finish before hard killing local count=0 - while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do + while omarchy-capture-screenrecording-process "${RECORDER[@]}" && ((count < 50)); do sleep 0.1 count=$((count + 1)) done toggle_screenrecording_indicator cleanup_webcam + wf_audio_cleanup - if pgrep -f "^gpu-screen-recorder" >/dev/null; then - pkill -9 -f "^gpu-screen-recorder" + if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then + omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." else finalize_recording @@ -251,10 +444,10 @@ stop_screenrecording() { ( sleep 2 rm -f "$preview" - ) & + ) 9>&- & fi - rm -f "$RECORDING_FILE" + rm -f "$RECORDING_FILE" "$REC_PID_FILE" } toggle_screenrecording_indicator() { @@ -262,7 +455,14 @@ toggle_screenrecording_indicator() { } screenrecording_active() { - pgrep -f "^gpu-screen-recorder" >/dev/null + if select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"; then + return 0 + else + if [[ -f $REC_PID_FILE ]]; then + wf_audio_cleanup + fi + return 1 + fi } finalize_recording() { @@ -283,7 +483,10 @@ finalize_recording() { # Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping # transient around 130-200ms that a gentle fade-in can't attenuate enough), then a # 50ms fade avoids a click at the boundary before loudnorm normalizes the rest. - args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11") + # + # loudnorm declares 192 kHz output, so without -ar the AAC encoder writes + # 96 kHz from a 48 kHz recording. + args+=(-ar 48000 -af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11") fi local processed="${latest%.mp4}-processed.mp4" @@ -294,10 +497,66 @@ finalize_recording() { fi } -if screenrecording_active; then - stop_screenrecording -elif [[ $STOP_RECORDING == "true" ]]; then - exit 1 -else - start_screenrecording || cleanup_webcam +mkdir -p "$RUNTIME_DIR" || exit 1 +# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. +if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then + chmod 700 "$RUNTIME_DIR" || exit 1 fi + +# Serialize changes to the recorder and its files; background children close 9. +# A busy status query stays read-only so a picker cannot stall the bar. +{ + if [[ $STATUS == "true" ]]; then + if flock -n 9; then + screenrecording_active + else + select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}" + fi + else + # A start still in its picker or waiting on its recorder is cancelled, not + # queued behind: that toggle would otherwise begin another recording. + # The start reads CANCEL_FILE only after it removes STARTING_FILE, so a + # request left while STARTING_FILE stands is always seen. One that finds it + # gone waits its turn, and a request the start used up by then is done. + if ! flock -w 1 9; then + touch "$CANCEL_FILE" + # A start that was killed leaves its marker: it counts only while that + # start still runs. + starter="" + [[ -r $STARTING_FILE ]] && starter=$(<"$STARTING_FILE") + if [[ $starter =~ ^[0-9]+$ ]] && kill -0 "$starter" 2>/dev/null; then + exit 0 + fi + flock 9 || exit 1 + [[ -e $CANCEL_FILE ]] || exit 0 + rm -f "$CANCEL_FILE" + fi + if screenrecording_active; then + stop_screenrecording + elif [[ $STOP_RECORDING == "true" ]]; then + exit 1 + else + rm -f "$CANCEL_FILE" + echo $$ >"$STARTING_FILE" + if start_screenrecording; then + rm -f "$STARTING_FILE" + # Cancelled while the recording was being saved as started. + if start_cancelled; then + end_launched_recorder "$(<"$REC_PID_FILE")" + wf_audio_cleanup + cleanup_webcam + rm -f "$(<"$RECORDING_FILE")" "$REC_PID_FILE" "$RECORDING_FILE" + toggle_screenrecording_indicator + rm -f "$CANCEL_FILE" + fi + # A request after that check is left for its requester, who stops this + # recording once it has the lock. + else + rm -f "$STARTING_FILE" + wf_audio_cleanup + cleanup_webcam + rm -f "$CANCEL_FILE" + fi + fi + fi +} 9>>"$RUNTIME_DIR/omarchy-screenrecord.lock" diff --git a/bin/omarchy-capture-screenrecording-process b/bin/omarchy-capture-screenrecording-process new file mode 100755 index 00000000..bec34707 --- /dev/null +++ b/bin/omarchy-capture-screenrecording-process @@ -0,0 +1,72 @@ +#!/bin/bash + +# omarchy:summary=Check or signal the current user's recording processes +# omarchy:args=[--pid ] [--signal INT|KILL] +# omarchy:hidden=true + +usage() { + echo "Usage: omarchy-capture-screenrecording-process [--pid ]... [--signal INT|KILL]" >&2 + exit 2 +} + +proc_root=${OMARCHY_PROC_ROOT:-/proc} +wanted=" " +signal="" + +while (($#)); do + case $1 in + --pid) + # Compare textually first: bash arithmetic wraps values past 64 bits. + [[ ${2-} =~ ^0*([0-9]+)$ ]] || usage + pid=${BASH_REMATCH[1]} + ((${#pid} <= 10 && 10#$pid <= 4194304)) || usage + wanted+="$((10#$pid)) " + shift 2 + ;; + --signal) + [[ ${2-} == "INT" || ${2-} == "KILL" ]] || usage + signal=$2 + shift 2 + ;; + *) + usage + ;; + esac +done + +is_recorder() { + local exe + exe=$(readlink "$proc_root/$1/exe" 2>/dev/null) || return 1 + # Linux appends this when an upgrade unlinks a still-running executable. + exe=${exe% (deleted)} + exe=${exe##*/} + [[ $exe == "gpu-screen-recorder" || $exe == "wf-recorder" ]] +} + +# Linux truncates a process name to 15 characters: gpu-screen-recorder runs as +# gpu-screen-reco, which any executable sharing that prefix also matches, and a +# process can rename itself, so the executable path confirms each candidate. +matched=() +while read -r pid; do + if [[ $wanted == " " || $wanted == *" $pid "* ]] && is_recorder "$pid"; then + matched+=("$pid") + fi +done < <(pgrep -u "$UID" -x 'gpu-screen-reco(rder)?|wf-recorder') + +if ((${#matched[@]} == 0)); then + exit 1 +fi + +if [[ -n $signal ]]; then + delivered=0 + for pid in "${matched[@]}"; do + # The pid can be reused between discovery and kill; re-checking exe here narrows that window. + if is_recorder "$pid" && kill "-$signal" "$pid" 2>/dev/null; then + delivered=1 + fi + done + # A recorder that exits before its signal has stopped anyway; fail only if none was signalled. + ((delivered)) || exit 1 +fi + +exit 0 diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 039d3292..feee2518 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -60,6 +60,23 @@ link_dev_checkout() { fi done + # The checkout's own channel refresh runs next, from its own templates. + if [[ ! -f $checkout/${templates#"$OMARCHY_PATH"/}/pacman-$pacman_channel.conf || + ! -f $checkout/${templates#"$OMARCHY_PATH"/}/mirrorlist-$pacman_channel ]]; then + fail "Update the checkout before switching to dev; it has no $pacman_channel templates for $platform." + fi + + # On aarch64 one from before it had platform templates would replace this + # machine's repositories with x86's. + if [[ $platform != "x86" ]]; then + for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do + if [[ ! -f $checkout/$required || ! -r $checkout/$required || + ( $required == "bin/omarchy-hw-platform" && ! -x $checkout/$required ) ]]; then + fail "Update the checkout before switching to dev; on $platform it needs $required for this machine's repositories." + fi + done + fi + omarchy-dev-link "$checkout" --no-reboot } @@ -102,9 +119,6 @@ case "$channel" in packages=(omarchy-dev omarchy-settings-dev) ;; dev) - confirm_dev || { echo "Cancelled."; exit 0; } - dev_checkout="$HOME/omarchy" - validate_dev_checkout "$dev_checkout" pacman_channel=edge packages=(omarchy-dev omarchy-settings-dev) ;; @@ -115,6 +129,20 @@ case "$channel" in ;; esac +# A channel this machine's platform has no templates for is refused before +# anything changes (install/helpers/pacman.sh). +source "$OMARCHY_PATH/install/helpers/pacman.sh" +platform=$(omarchy-hw-platform) || fail "Cannot tell this machine's platform; nothing changed." +templates=$(omarchy_pacman_templates "$platform") || exit 1 +[[ -f $templates/pacman-$pacman_channel.conf && -f $templates/mirrorlist-$pacman_channel ]] || + fail "Omarchy has no $pacman_channel channel for $platform; nothing changed." + +if [[ $channel == "dev" ]]; then + confirm_dev || { echo "Cancelled."; exit 0; } + dev_checkout="$HOME/omarchy" + validate_dev_checkout "$dev_checkout" +fi + # A failure past this point leaves the channel switch half-applied, so say how # to pick it back up rather than dying silently under set -e. trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR diff --git a/bin/omarchy-drive-password b/bin/omarchy-drive-password index 2fbbb187..e1d9dc74 100755 --- a/bin/omarchy-drive-password +++ b/bin/omarchy-drive-password @@ -1,32 +1,432 @@ #!/bin/bash -# omarchy:summary=Set a new encryption password for a drive selected. +# omarchy:summary=Set a new password for an encrypted drive # omarchy:requires-sudo=true -encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device) +# Changes a drive's LUKS key and nothing else. For the disk holding /, the new +# key must open the disk and the old one must not before the change counts. The +# journal records only the disk's UUID, key slots and phase, so a change that +# stops part way is finished by the next run. A platform whose boot checks prove +# the disk's key slots records the owner's new one before the run finishes. + +# Passwords live in variables; never trace them. +set +x + +REKEY_STATE=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/drive-password.state +source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh" + +# The LUKS partition beneath /, or nothing when / is not encrypted. Fails when +# it cannot tell, including a crypt layer whose LUKS partition lsblk cannot name. +root_luks_device() { + local root_source ancestry device + root_source=$(findmnt -no SOURCE /) && [[ -n $root_source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${root_source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + readlink -f "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +# Every LUKS device, from udev's probe as root_luks_device reads it. An +# unprivileged blkid only reads /run/blkid, empty until root runs blkid. lsblk +# repeats a device under each parent (RAID members), so each is listed once. +luks_drives() { + lsblk -nrpo NAME,FSTYPE | awk '$2 == "crypto_LUKS" && !seen[$1]++ { print $1 }' +} + +# Every cryptsetup call holds the disk lock, so one left running by a run that +# was killed finishes before the next run looks at the drive. + +# Print the key slot the password opens. Returns 2 when it opens none, and +# another failure when the drive cannot be checked. cryptsetup open tries +# enrolled tokens (TPM2, FIDO2, keyring) before the key and reports a token's +# slot whatever key it was given, so allow only a token type no token has. +drive_key_slot() { + local drive=$1 password=$2 out status=0 + out=$(printf '%s' "$password" | sudo flock "$disk_lock" env LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file - "$drive" 2>&1) || status=$? + (( status == 0 )) || return "$status" + grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1 +} + +# The enabled key slots, comma-separated. A luks2-keyring token under "Tokens:" +# looks like a keyslot, so read only the keyslot section. +drive_slots() { + local dump + dump=$(sudo flock "$disk_lock" cryptsetup luksDump "$1") || return 1 + awk ' + /^[^ \t]/ { keyslots = ($0 == "Keyslots:") } + keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 } + /^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' <<<"$dump" | paste -sd, - +} + +# The key slots other than FOUND that the password also opens, comma-separated, +# or nothing. Fails when a slot cannot be checked. +other_key_slots() { + local drive=$1 password=$2 found=$3 slots slot status + local -a others=() + slots=$(drive_slots "$drive") || return 1 + for slot in ${slots//,/ }; do + [[ $slot != "$found" ]] || continue + status=0 + printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup open --test-passphrase --token-type passphrase-only --key-slot "$slot" --key-file - "$drive" >/dev/null 2>&1 || status=$? + if (( status == 0 )); then + others+=("$slot") + elif (( status != 2 )); then + return 1 + fi + done + local IFS=, + printf '%s' "${others[*]}" +} + +kill_slot() { + local drive=$1 password=$2 slot=$3 + printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup luksKillSlot -q --key-file - "$drive" "$slot" +} + +# luksChangeKey writes the new key before it retires the old one (LUKS2 swaps +# them in one header update), so a failure that leaves the new key unable to +# open the drive changed nothing: that returns 2. One that stops in between +# leaves both keys, so the old key is retired here until it opens nothing. +# Sets new_slot. +change_luks_key() { + local drive=$1 current=$2 new=$3 old_slot=$4 remains attempt status + local -a pbkdf=(--iter-time 2000) + + echo "Changing full-disk encryption password for $drive" + if sudo flock "$disk_lock" cryptsetup isLuks --type luks2 "$drive"; then + pbkdf+=(--pbkdf argon2id) + fi + # Both keys travel over stdin, the current one on the first line. + printf '%s\n%s' "$current" "$new" | + sudo flock "$disk_lock" bash -c 'IFS= read -r current; drive=$1; shift; exec cryptsetup luksChangeKey "$@" --key-file <(printf "%s" "$current") "$drive" <(cat)' bash "$drive" "${pbkdf[@]}" + + new_slot=$(drive_key_slot "$drive" "$new") || return + for (( attempt = 0; attempt < 8; attempt++ )); do + status=0 + remains=$(drive_key_slot "$drive" "$current") || status=$? + (( status == 2 )) && return 0 + (( status == 0 )) && [[ $remains != "$new_slot" ]] || return 1 + kill_slot "$drive" "$new" "$remains" || return 1 + done + return 1 +} + +# The entrypoint that records the system disk's key slots for the platform's +# boot checks, or nothing where none does. A machine without its platform's +# boot package at all (dispatch exit 3) predates it, and nothing checks its +# slots. Fails with the dispatcher's message otherwise, such as a boot package +# too old to ship luks-slots. +slot_recorder() { + local out status=0 + out=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1) || status=$? + if (( status == 0 )); then + printf '%s' "$out" + elif (( status != 3 )); then + printf '%s\n' "$out" >&2 + return 1 + fi +} + +# Refuse a current password that opens any slot but the owner's where the +# platform records the slots: on a disk an earlier setup gave a second key (a +# recovery key), changing that one would leave the owner's password as it was. +# luks-slots --owner prints the recorded slot (0), or exits 4 when no owner slot +# is recorded, as on a Mac set up before its boot package recorded slots; any +# other status is a failure the entrypoint explains, and stops the change. +# Where nothing is recorded, a password that opens the disk's only key slot is +# the owner's, and the change records its new slot (unrecorded_owner). +owner_slot_matches() { + local slot=$1 recorder owner errors reason status=0 + recorder=$(slot_recorder) || return 1 + [[ -n $recorder ]] || return 0 + errors=$(mktemp) || return 1 + owner=$(sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots --owner 2>"$errors") || status=$? + reason=$(<"$errors") + rm -f "$errors" + if (( status == 4 )); then + only_key_slot "$slot" || return 1 + unrecorded_owner=1 + return 0 + elif (( status != 0 )); then + echo "Could not read which key slot holds the system disk password: ${reason:-luks-slots --owner exited $status}" + return 1 + elif [[ ! $owner =~ ^[0-9]+$ ]]; then + echo "Could not read which key slot holds the system disk password: the platform's boot package named '$owner', which is not a key slot." + return 1 + fi + if [[ $owner != "$slot" ]]; then + echo "That password opens another key slot on the system disk, such as a recovery key from an earlier setup. Enter the system disk password." + return 1 + fi +} + +# With no owner slot recorded, the password must open the system disk's one key +# slot: with more keys there is no telling which is the owner's, and recording +# only the owner's would fail the platform's boot check, which wants every kept +# slot recorded. Says how to settle it. +only_key_slot() { + local slot=$1 slots others + slots=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + [[ $slots != "$slot" ]] || return 0 + others=$(tr ',' '\n' <<<"$slots" | grep -vx -- "$slot" | paste -sd, -) + echo "No key slot is recorded as the system disk password's, and $root_drive has keys in slots $slots, so which one is yours can't be told. The password you entered opens slot $slot." + if [[ $others =~ ^[0-9]+$ ]]; then + echo "If that password is yours and slot $others holds a recovery key you keep, record both: sudo omarchy-lifecycle-dispatch luks-slots owner=$slot recovery=$others" + echo "Otherwise remove the key you don't use: sudo cryptsetup luksKillSlot $root_drive " + else + echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive " + fi + echo "Then run omarchy-drive-password again." + return 1 +} + +# Record the slot the owner's password opens now; a no-op where nothing does. +record_owner_slot() { + local recorder + recorder=$(slot_recorder) || return 1 + [[ -z $recorder ]] || sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1" +} + +# Phase luks: the key change may or may not have landed. Phase record: the new +# key alone opens the recorded slot, which the boot checks still need. +change_system_password() { + local current=$1 new=$2 old_slot=$3 slots status=0 + + slots=$(drive_slots "$root_drive") + if [[ ,$slots, != *",$old_slot,"* ]]; then + echo "Could not read the key slots of $root_drive." + return 1 + fi + # A disk taken as the owner's for holding one key must still hold only it. + if (( unrecorded_owner )) && [[ $slots != "$old_slot" ]]; then + echo "The key slots of $root_drive changed while you entered the new password. Run omarchy-drive-password again." + return 1 + fi + mkdir -p "${REKEY_STATE%/*}" && + rekey_state_put uuid "$root_uuid" old_slot "$old_slot" slots "$slots" checked 1 phase luks || return 1 + + change_luks_key "$root_drive" "$current" "$new" "$old_slot" || status=$? + if (( status == 2 )); then + rm -f "$REKEY_STATE" + echo "The disk password did not change." + return 1 + elif (( status != 0 )); then + echo "Could not confirm the disk password change. Run omarchy-drive-password again to finish." + return 1 + fi + rekey_state_put slot "$new_slot" phase record || return 1 + + if ! record_owner_slot "$new_slot"; then + echo "Could not record the new key slot for the boot checks. Run omarchy-drive-password again to finish." + return 1 + fi + rm -f "$REKEY_STATE" +} + +# A journal left on this system's disk by a change that stopped part way. +change_pending() { + local phase uuid + phase=$(rekey_state_get phase) || return 1 + uuid=$(rekey_state_get uuid) + if [[ $phase == "luks" || $phase == "record" || $phase == "accounts" ]] && [[ -n $uuid && $uuid == "$root_uuid" ]]; then + return 0 + fi + rm -f "$REKEY_STATE" + return 1 +} + +# Settle the disk on the password it opens with now, then record its slot. +# Before the change was confirmed that is the old key (removing the slot the +# change added, if any) or the new key in the one slot the change added +# (retiring the old slot); after, only the confirmed slot. Any other key on the +# disk, such as a recovery passphrase, is refused, and so is a disk that gained +# more than one slot since. +finish_interrupted_change() { + local phase old_slot slots slot header password opened now added extra count=0 status=0 + + phase=$(rekey_state_get phase) + # Earlier versions named the confirmed phase "accounts". + [[ $phase != "accounts" ]] || phase=record + old_slot=$(rekey_state_get old_slot) + slots=$(rekey_state_get slots) + slot=$(rekey_state_get slot) + + if [[ $phase == "luks" && ! ( $old_slot =~ ^[0-9]+$ && ,$slots, == *",$old_slot,"* ) ]] || + [[ $phase == "record" && ! $slot =~ ^[0-9]+$ ]]; then + echo "$REKEY_STATE is unreadable. Remove it once the disk opens with only the password you want." + return 1 + fi + + # Earlier versions journaled the change without checking that the old + # password opened one slot alone (checked=1), so with other slots on the disk + # a copy of it may have outlived the change. No password entered now can show + # which slot was whose, so the owner settles that by hand. + if [[ -z $(rekey_state_get checked) ]]; then + now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + if [[ $now == *,* ]]; then + echo "The last password change for $root_drive was started by an earlier version of omarchy-drive-password, and the disk has key slots $now, so it may still hold a copy of the old password." + echo "Settle it by hand, in this order:" + echo "1. Find the slot the password you want to keep opens, and keep it: sudo cryptsetup open --test-passphrase --token-type passphrase-only --verbose $root_drive names it. If only the old password opens the disk, that is the one you keep." + echo "2. If the password you keep is not the old one, check each other slot with the old password (sudo cryptsetup open --test-passphrase --token-type passphrase-only --key-slot $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive ). Never remove the slot from step 1." + if [[ -n $(slot_recorder 2>/dev/null) ]]; then + echo "3. Record the slots you kept for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner= recovery=, with an empty recovery= when no recovery key is left." + fi + echo "Then remove $REKEY_STATE, and run omarchy-drive-password again if the disk should still take a new password." + return 1 + fi + fi + + echo "The last password change for $root_drive stopped part way." + if [[ $phase == "record" ]]; then + header="New encryption password" + else + header="Encryption password that opens $root_drive now (the new one if the change went through)" + fi + password=$(gum input --password --header "$header") || return 1 + + opened=$(drive_key_slot "$root_drive" "$password") || status=$? + if (( status != 0 && status != 2 )); then + echo "Could not check the password against $root_drive." + return 1 + fi + + if [[ $phase == "luks" && -n $opened ]]; then + now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + for extra in ${now//,/ }; do + if [[ ,$slots, != *",$extra,"* ]]; then + added=$extra + count=$(( count + 1 )) + fi + done + if (( count > 1 )); then + echo "The key slots of $root_drive changed since the interrupted change. Finish it by hand." + return 1 + fi + + if [[ $opened == "$old_slot" ]]; then + [[ -z $added ]] || kill_slot "$root_drive" "$password" "$added" || return 1 + elif [[ -n $added && $opened == "$added" ]]; then + if [[ ,$now, == *",$old_slot,"* ]]; then + kill_slot "$root_drive" "$password" "$old_slot" || return 1 + fi + else + opened="" + fi + [[ -z $opened ]] || rekey_state_put slot "$opened" phase record || return 1 + elif [[ $phase == "record" && $opened != "$slot" ]]; then + opened="" + fi + + if [[ -z $opened ]]; then + echo "That password does not finish the change on $root_drive. Run omarchy-drive-password again to finish." + return 1 + fi + + if ! record_owner_slot "$opened"; then + echo "Could not record the key slot for the boot checks. Run omarchy-drive-password again to finish." + return 1 + fi + rm -f "$REKEY_STATE" + echo "$root_drive now opens with the password you entered, and the change is settled." +} + +change_selected_drive() { + local encrypted_drives drive current old_slot others new_password confirmation status=0 system_drive=false + + encrypted_drives=$(luks_drives) + if [[ -z $encrypted_drives ]]; then + echo "No encrypted drives available." + return 1 + fi -if [[ -n $encrypted_drives ]]; then if (( $(wc -l <<<"$encrypted_drives") == 1 )); then - drive_to_change="$encrypted_drives" + drive="$encrypted_drives" else - drive_to_change="$(omarchy-drive-select "$encrypted_drives")" + drive="$(omarchy-drive-select "$encrypted_drives")" fi - if [[ -n $drive_to_change ]]; then - new_password=$(gum input --password --header "New encryption password") || exit 1 - [[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; } - - confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1 - [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; } - - echo "Changing full-disk encryption password for $drive_to_change" - # The new key travels over stdin and reaches cryptsetup as a keyfile via - # <(cat), leaving the tty free for the current-passphrase prompt. - printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) ), then run omarchy-drive-password again." + if [[ -n $(slot_recorder 2>/dev/null) ]]; then + echo "Then record the slots you keep for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner= recovery=, with an empty recovery= when no recovery key is left." + fi + echo "The system disk password did not change." + return 1 + fi + fi + + new_password=$(gum input --password --header "New encryption password") || return 1 + [[ -n $new_password ]] || { echo "Password cannot be empty."; return 1; } + + confirmation=$(gum input --password --header "Confirm new encryption password") || return 1 + [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; return 1; } + [[ $new_password != "$current" ]] || { echo "The new password is the current one."; return 1; } + # Its slot is how the change is followed, so it must not open one already. + status=0 + drive_key_slot "$drive" "$new_password" >/dev/null || status=$? + if (( status == 0 )); then + echo "That password already opens a key slot on $drive. Choose another." + return 1 + elif (( status != 2 )); then + echo "Could not check the password against $drive." + return 1 + fi + status=0 + + if [[ $system_drive == "true" ]]; then + change_system_password "$current" "$new_password" "$old_slot" + else + change_luks_key "$drive" "$current" "$new_password" "$old_slot" || status=$? + if (( status == 2 )); then + echo "The password of $drive did not change." + return 1 + elif (( status != 0 )); then + echo "Could not confirm the password change on $drive." + return 1 + fi + fi +} + +disk_lock=${REKEY_STATE%/*}/drive-password.disk.lock +mkdir -p "${REKEY_STATE%/*}" && touch "$disk_lock" && exec {lock_fd}>"${REKEY_STATE%/*}/drive-password.lock" && flock -n "$lock_fd" || + { echo "Another drive password change is running."; exit 1; } + +unrecorded_owner=0 +root_drive=$(root_luks_device) || { echo "Could not tell which drive holds the system."; exit 1; } +root_uuid="" +if [[ -n $root_drive ]]; then + root_uuid=$(lsblk -dno UUID "$root_drive") && [[ -n $root_uuid ]] || { echo "Could not read the UUID of $root_drive."; exit 1; } +fi + +if change_pending; then + finish_interrupted_change else - echo "No encrypted drives available." - exit 1 + change_selected_drive fi diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index 1aeb0d75..1c2dde0a 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -13,6 +13,12 @@ for arg in "$@"; do esac done +# Resume is Limine-oriented and hibernate is unreliable on Asahi. +if omarchy-hw-aarch64-apple; then + echo "Skipping hibernation setup (not supported on Apple Silicon)" + exit 0 +fi + if [[ ! -f /sys/power/image_size ]]; then echo -e "Hibernation is not supported on your system" >&2 exit 0 diff --git a/bin/omarchy-hw-aarch64 b/bin/omarchy-hw-aarch64 new file mode 100755 index 00000000..7c228ac5 --- /dev/null +++ b/bin/omarchy-hw-aarch64 @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included). + +[[ $(uname -m) == "aarch64" ]] diff --git a/bin/omarchy-hw-aarch64-apple b/bin/omarchy-hw-aarch64-apple new file mode 100755 index 00000000..181b4ad6 --- /dev/null +++ b/bin/omarchy-hw-aarch64-apple @@ -0,0 +1,17 @@ +#!/bin/bash -p + +# omarchy:summary=Detect whether the computer is an Apple Silicon Mac. + +# Callers run this by absolute path, from systemd and from the shell, so use the +# detector shipped beside it rather than whichever one PATH finds. -p keeps a +# root caller's exported functions out, so a launch without it is refused, and +# nothing here searches PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +dir=${BASH_SOURCE[0]%/*} +[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=. +platform=$("$dir/omarchy-hw-platform") || exit 1 +[[ $platform == "aarch64-apple" ]] diff --git a/bin/omarchy-hw-apple-silicon b/bin/omarchy-hw-apple-silicon new file mode 100755 index 00000000..aad94b58 --- /dev/null +++ b/bin/omarchy-hw-apple-silicon @@ -0,0 +1,15 @@ +#!/bin/bash -p + +# omarchy:summary=Detect whether the computer is an Apple Silicon Mac (the old name of omarchy-hw-aarch64-apple). +# omarchy:hidden=true + +# Kept while omarchy-mac's packages and units still call it by this name. Like +# the predicate it runs, it refuses a launch without -p and searches no PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +dir=${BASH_SOURCE[0]%/*} +[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=. +exec "$dir/omarchy-hw-aarch64-apple" diff --git a/bin/omarchy-hw-display b/bin/omarchy-hw-display index d3c6d3e6..aa17c341 100755 --- a/bin/omarchy-hw-display +++ b/bin/omarchy-hw-display @@ -5,14 +5,48 @@ backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}" +# The platform package may name backlights to skip and to prefer (see +# docs/file-layout.md). +displays_conf=/usr/share/omarchy-platform/displays.conf +skips=() +prefers=() +if [[ -r $displays_conf ]]; then + # \r in IFS: a file saved with CRLF line ends reads the same. + while IFS=$' \t\r' read -r directive argument extra || [[ -n $directive ]]; do + [[ -n $argument && -z $extra && $argument != */* && $argument != . && $argument != .. ]] || continue + case $directive in + backlight-skip) skips+=("$argument") ;; + backlight-prefer) prefers+=("$argument") ;; + esac + done <"$displays_conf" +fi + +usable() { + local skip + # The Touch Bar on T2 Macs registers a backlight that never drives the display panel. + [[ -n $1 && $1 != appletb_backlight ]] || return 1 + for skip in "${skips[@]}"; do + # shellcheck disable=SC2053 # a skip is a glob + [[ $1 == $skip ]] && return 1 + done + return 0 +} + # Start with the first possible output, then refine to the most likely given an order heuristic. -# Glob the candidates in the loop list: [[ ]] does not do pathname expansion. -# The Touch Bar on T2 Macs registers a backlight that never drives the display panel. -device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)" +device="" +while IFS= read -r name; do + if usable "$name"; then + device="$name" + break + fi +done < <(ls -1 "$backlight_path" 2>/dev/null) + # gmux comes first: apple-gmux only registers when the kernel has already picked it, and on -# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. -for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do - if [[ -e $candidate ]]; then +# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. The +# platform's preferred backlights follow, then the GPU and ACPI ones. +# Glob the candidates in the loop list: [[ ]] does not do pathname expansion. +for candidate in "$backlight_path"/gmux_backlight "${prefers[@]/#/$backlight_path/}" "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do + if [[ -e $candidate ]] && usable "${candidate##*/}"; then device="${candidate##*/}" break fi diff --git a/bin/omarchy-hw-laptop b/bin/omarchy-hw-laptop index 0ffbeeb3..47664d12 100755 --- a/bin/omarchy-hw-laptop +++ b/bin/omarchy-hw-laptop @@ -2,15 +2,39 @@ # omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis). +acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}" +input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" +dmi_chassis="${OMARCHY_DMI_CHASSIS_TYPE_PATH:-/sys/class/dmi/id/chassis_type}" + +shopt -s nullglob +acpi_states=("$acpi_lid"/*/state) +input_switches=("$input_class"/*/capabilities/sw) +shopt -u nullglob + # A lid switch is the definitive signal for clamshell-capable hardware. -for state in /proc/acpi/button/lid/*/state; do +for state in "${acpi_states[@]}"; do [[ -e $state ]] && exit 0 done +# Without an ACPI lid (Apple Silicon, for one), any input device that reports a +# lid switch (SW_LID, bit 0 of its switch capabilities, the last word of the +# bitmap) counts. The switch's name varies: Apple's SMC input device carries +# the same name on a Mac mini, where it reports no lid. +for switches in "${input_switches[@]}"; do + [[ -r $switches ]] || continue + read -r -a words <"$switches" || true + (( ${#words[@]} )) || continue + word=${words[-1]} + [[ $word =~ ^[0-9a-fA-F]+$ ]] || continue + (( 16#$word & 1 )) && exit 0 +done + # Fall back to the DMI chassis type for laptops that don't expose an ACPI lid # button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet # 31=Convertible 32=Detachable. -case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in +chassis_type="" +read -r chassis_type <"$dmi_chassis" 2>/dev/null || true +case $chassis_type in 8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;; esac diff --git a/bin/omarchy-hw-laptop-closed b/bin/omarchy-hw-laptop-closed index 8e03f965..52eb4778 100755 --- a/bin/omarchy-hw-laptop-closed +++ b/bin/omarchy-hw-laptop-closed @@ -3,9 +3,45 @@ # omarchy:summary=Returns true when the laptop lid is closed # omarchy:hidden=true -for state in /proc/acpi/button/lid/*/state; do - [[ -r $state ]] || continue - [[ $(< "$state") == *"closed"* ]] && exit 0 -done +acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}" +input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" + +shopt -s nullglob +acpi_states=("$acpi_lid"/*/state) +input_switches=("$input_class"/*/capabilities/sw) +shopt -u nullglob + +# An input device that reports a lid switch: SW_LID, bit 0 of the last word +# of its switch bitmap. +has_lid_switch() { + local switches word + local -a words + + for switches in "${input_switches[@]}"; do + [[ -r $switches ]] || continue + read -r -a words <"$switches" || true + (( ${#words[@]} )) || continue + word=${words[-1]} + [[ $word =~ ^[0-9a-fA-F]+$ ]] || continue + (( 16#$word & 1 )) && return 0 + done + return 1 +} + +if (( ${#acpi_states[@]} )); then + # An ACPI lid reports its own state. + for state in "${acpi_states[@]}"; do + [[ -r $state ]] || continue + [[ $(<"$state") == *"closed"* ]] && exit 0 + done +elif has_lid_switch; then + # Elsewhere (Apple Silicon's SMC, for one) only logind reads the switch. Ask + # it only then: it answers "open" when it tracks no lid. This runs in sudo's + # and polkit's PAM stack where fingerprint login is set up, so a stalled + # logind must not hold up every sudo. + lid_state=$(busctl --timeout=2 get-property org.freedesktop.login1 \ + /org/freedesktop/login1 org.freedesktop.login1.Manager LidClosed 2>/dev/null) + [[ $lid_state == "b true" ]] && exit 0 +fi exit 1 diff --git a/bin/omarchy-hw-platform b/bin/omarchy-hw-platform new file mode 100755 index 00000000..737fb136 --- /dev/null +++ b/bin/omarchy-hw-platform @@ -0,0 +1,202 @@ +#!/bin/bash -p + +# omarchy:summary=Print the hardware platform: x86, aarch64 or aarch64-apple. + +# Platform names are the CPU architecture, then the hardware family when it +# needs handling of its own: x86, aarch64 (every ARM machine without one) and +# aarch64-apple (Apple Silicon). A later family is aarch64-, so +# "aarch64*" always means ARM (AGENTS.md, Platforms). +# +# ARM laptops have no DMI, so the board identity is the root node of the device +# tree the firmware hands the kernel: "apple," from Asahi's m1n1 on every +# Apple Silicon Mac, "qcom," on Snapdragon laptops, which are plain aarch64 +# here and must never pass for a Mac. Each compatible string is a NUL-separated +# list, and only a token's vendor prefix counts. +# +# An image is built away from the machine it will run on, so while this root is +# being built rather than booted, the image-target manifest the builder wrote +# names the platform and the build host's device tree is never read. +# +# Fixture roots let unprivileged tests fake the device tree, PID 1 and the image +# root. Naming any of them makes the whole view a fixture: nothing live is read, +# not even the device tree when only the image root is named. A privileged +# caller must not let its environment pick the platform at all. +# Privileged mode (-p) keeps exported functions and BASH_ENV out of this shell, +# so a launch without it is refused. Root then starts over in an empty +# environment and reads only the live paths with a fixed PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +if (( EUID == 0 )) && [[ ${1:-} != --clean-environment ]]; then + exec /usr/bin/env -i PATH=/usr/bin /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi + +if (( EUID == 0 )); then + export PATH=/usr/bin + fixtures="" +else + fixtures=${OMARCHY_PROC_ROOT:-}${OMARCHY_SYS_ROOT:-}${OMARCHY_IMAGE_ROOT:-} +fi + +if [[ -z $fixtures ]]; then + proc=/proc + sources=(/proc/device-tree/compatible /sys/firmware/devicetree/base/compatible) + image_root="" + image_dir=/var/lib/omarchy/image + image_owner=0 +else + proc=${OMARCHY_PROC_ROOT:-} + sources=() + [[ -z ${OMARCHY_PROC_ROOT:-} ]] || sources+=("$OMARCHY_PROC_ROOT/device-tree/compatible") + [[ -z ${OMARCHY_SYS_ROOT:-} ]] || sources+=("$OMARCHY_SYS_ROOT/firmware/devicetree/base/compatible") + image_root=${OMARCHY_IMAGE_ROOT:-} + image_dir=${image_root:+$image_root/var/lib/omarchy/image} + image_owner=$EUID +fi +manifest=$image_dir/target + +contradiction() { + echo "Error: contradictory platform identity: $1" >&2 + exit 1 +} + +if ! machine=$(uname -m) || [[ -z $machine ]]; then + echo "Error: cannot read the CPU architecture" >&2 + exit 1 +fi + +# A booted system is one systemd runs as PID 1 from this root. An image build +# is told apart by what it shows: no /run/systemd/system (a root with a /run of +# its own), PID 1's root being another (a chroot on the build host, even with +# the host's /run bound in, as arch-chroot does), or PID 1 being something other +# than systemd (a PID namespace whose PID 1 is the build). What cannot be seen, +# such as PID 1's root to a normal user or /proc/1 behind hidepid, never makes a +# build: a booted system always answers from its hardware, so a build runs as +# root. A caller in a private PID namespace looks like a build too, so nothing +# that asks the detector runs with PrivatePIDs=. +booted() { + local comm + + [[ -d $image_root/run/systemd/system ]] || return 1 + if [[ -n $proc && -e $proc/1/root ]]; then + [[ $proc/1/root -ef $image_root/ ]] || return 1 + fi + if [[ -n $proc && -r $proc/1/comm ]]; then + read -r comm <"$proc/1/comm" && [[ $comm == "systemd" ]] || return 1 + fi +} + +# Owned by root (the test's own user for a fixture), not a symlink, and not +# writable by anyone else. +trusted() { + local owner mode + + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + [[ $owner =~ ^[0-9]+$ && $mode =~ ^[0-7]+$ ]] && (( owner == image_owner && (8#$mode & 8#022) == 0 )) +} + +# The manifest: format=1, platform= one of the platform names, comments and +# unknown keys ignored, and any other line refused. Image builders written before +# the names settled say apple-silicon, generic-aarch64 or generic, which still +# read as aarch64-apple, aarch64 and x86. The detector reads it itself, so it +# stands alone wherever a package ships a copy of it. +image_platform() { + local line key value format="" platform="" + + if [[ ! -d $image_dir || ! -f $manifest ]] || ! trusted "$image_dir" || ! trusted "$manifest"; then + echo "Error: the image manifest $manifest is not a root-owned regular file" >&2 + return 1 + fi + + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + if [[ $line != *=* ]]; then + echo "Error: the image manifest $manifest is malformed: $line" >&2 + return 1 + fi + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + platform) platform=$value ;; + esac + done <"$manifest" + + if [[ $format != "1" ]]; then + echo "Error: the image manifest $manifest is not format=1" >&2 + return 1 + fi + case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; + esac + if [[ ! $platform =~ ^(x86|aarch64|aarch64-apple)$ ]]; then + echo "Error: the image manifest $manifest names no known platform: ${platform:-none}" >&2 + return 1 + fi + + echo "$platform" +} + +if [[ -n $image_dir ]] && [[ -e $manifest || -L $manifest ]] && ! booted; then + platform=$(image_platform) || exit 1 + if [[ $platform == "x86" ]]; then + [[ $machine != "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine" + else + [[ $machine == "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine" + fi + echo "$platform" + exit 0 +fi + +vendor="" +vendor_source="" +for source in "${sources[@]}"; do + [[ -r $source ]] || continue + + apple=0 + qcom=0 + mapfile -d '' -t tokens <"$source" + for token in "${tokens[@]}"; do + [[ $token == "apple,"* ]] && apple=1 + [[ $token == "qcom,"* ]] && qcom=1 + done + + if (( apple && qcom )); then + contradiction "$source names both Apple and Qualcomm hardware" + elif (( apple )); then + found=apple + elif (( qcom )); then + found=qcom + else + found=none + fi + + if [[ -n $vendor && $vendor != "$found" ]]; then + contradiction "$vendor_source says $vendor but $source says $found" + fi + vendor=$found + vendor_source=$source +done + +case $vendor in + apple | qcom) + [[ $machine == "aarch64" ]] || contradiction "$vendor_source names $vendor hardware but the CPU is $machine" + if [[ $vendor == "apple" ]]; then + echo aarch64-apple + else + echo aarch64 + fi + ;; + *) + if [[ $machine == "aarch64" ]]; then + echo aarch64 + else + echo x86 + fi + ;; +esac diff --git a/bin/omarchy-hw-touchpad b/bin/omarchy-hw-touchpad index b5e4b777..28ffc4d8 100755 --- a/bin/omarchy-hw-touchpad +++ b/bin/omarchy-hw-touchpad @@ -2,5 +2,30 @@ # omarchy:summary=Print the detected Hyprland touchpad or trackpad device name -device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty') +mice=$(hyprctl devices -j | jq -r '.mice[].name') +device=$(grep -iE -m1 'touchpad|trackpad' <<<"$mice") + +# Some touchpads carry neither word (Apple Silicon's is "apple-mtp-multi-touch"), +# so ask udev which mouse is one. The name alone can't say: some touchscreens' +# mouse interfaces are called multi-touch too. +if [[ -z $device ]]; then + input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" + for event in "$input_class"/event*; do + [[ -r $event/device/name ]] || continue + # Hyprland's name for a device: the kernel's, lowercased, with spaces, + # commas and newlines as dashes, and -1, -2 and so on after a name another + # device already took. + name=$(<"$event/device/name") + name=${name,,} + name=${name//[ ,$'\n']/-} + udevadm info --query=property "$event" 2>/dev/null | grep -Fxq 'ID_INPUT_TOUCHPAD=1' || continue + while IFS= read -r mouse; do + if [[ $mouse == "$name" || ( $mouse == "$name"-* && ${mouse#"$name"-} =~ ^[0-9]+$ ) ]]; then + device=$mouse + break 2 + fi + done <<<"$mice" + done +fi + [[ -n $device ]] && echo "$device" diff --git a/bin/omarchy-hw-x86 b/bin/omarchy-hw-x86 new file mode 100755 index 00000000..95074173 --- /dev/null +++ b/bin/omarchy-hw-x86 @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether the CPU is x86_64. + +[[ $(uname -m) == "x86_64" ]] diff --git a/bin/omarchy-hyprland-monitor-watch b/bin/omarchy-hyprland-monitor-watch index 33e8f2c5..b7199e17 100755 --- a/bin/omarchy-hyprland-monitor-watch +++ b/bin/omarchy-hyprland-monitor-watch @@ -10,6 +10,7 @@ sync_clamshell() { ( flock -n 9 || exit 0 omarchy-hyprland-monitor-clamshell + omarchy-system-lid-inhibit ) 9>"$LOCK" } diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index 4593bc72..4e208422 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -8,6 +8,8 @@ set -e source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +opt_path="${OMARCHY_OPT_PATH:-/opt}" + setup_chromium_policy_directory() { browser_policy_setup_dir "$1" } @@ -17,6 +19,17 @@ announce_browser_installed() { echo "$1 browser installed. Make it the default via Setup > Defaults > Browser." } +# The widevine package registers its CDM for Chromium and Firefox only; Brave +# looks for one in its own install directory. +link_widevine_cdm() { + local cdm="$opt_path/WidevineCdm/chromium" + local install_dir="$opt_path/$1" + + if [[ -d $cdm && -d $install_dir && ! -e $install_dir/WidevineCdm ]]; then + as_root ln -sfn "$cdm" "$install_dir/WidevineCdm" + fi +} + copy_chromium_flags() { mkdir -p ~/.config cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1" @@ -37,6 +50,7 @@ chromium) setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install chromium announce_browser_installed "Chromium" ;; chrome) @@ -46,6 +60,7 @@ chrome) setup_chromium_policy_directory /etc/opt/chrome/policies/managed copy_chromium_flags ~/.config/chrome-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install chrome announce_browser_installed "Chrome" ;; edge) @@ -55,6 +70,7 @@ edge) setup_chromium_policy_directory /etc/opt/edge/policies/managed copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install edge announce_browser_installed "Edge" ;; brave) @@ -63,7 +79,9 @@ brave) setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-flags.conf + link_widevine_cdm brave-bin omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install brave announce_browser_installed "Brave" ;; brave-origin) @@ -72,7 +90,9 @@ brave-origin) setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-origin-flags.conf + link_widevine_cdm brave-origin-bin omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install brave-origin announce_browser_installed "Brave Origin" ;; firefox) @@ -81,6 +101,7 @@ firefox) browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution setup_firefox_wayland + omarchy-lifecycle-dispatch post-install firefox announce_browser_installed "Firefox" ;; zen) @@ -89,6 +110,7 @@ zen) browser_policy_setup_firefox_distribution /opt/zen-browser/distribution setup_firefox_wayland + omarchy-lifecycle-dispatch post-install zen announce_browser_installed "Zen" ;; *) diff --git a/bin/omarchy-install-dictation-voxtype b/bin/omarchy-install-dictation-voxtype index 79011e1d..7202ca4d 100755 --- a/bin/omarchy-install-dictation-voxtype +++ b/bin/omarchy-install-dictation-voxtype @@ -5,6 +5,8 @@ set -e +voxtype_lib_path="${OMARCHY_VOXTYPE_LIB_PATH:-/usr/lib/voxtype}" + if omarchy-cmd-missing voxtype; then if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then omarchy-pkg-add wtype voxtype-bin @@ -17,7 +19,8 @@ config_home="${XDG_CONFIG_HOME:-$HOME/.config}" mkdir -p "$config_home/voxtype" if [[ ! -e $config_home/voxtype/config.toml ]]; then cp "$OMARCHY_PATH/default/voxtype/config.toml" "$config_home/voxtype/" - if omarchy-hw-vulkan; then + # A Vulkan runtime is not enough: aarch64 voxtype-bin ships no Vulkan backend. + if omarchy-hw-vulkan && [[ -x $voxtype_lib_path/voxtype-vulkan ]]; then voxtype setup gpu --enable || true fi fi diff --git a/bin/omarchy-install-gaming-steam b/bin/omarchy-install-gaming-steam index 8b977f2c..6837878b 100755 --- a/bin/omarchy-install-gaming-steam +++ b/bin/omarchy-install-gaming-steam @@ -15,6 +15,7 @@ echo "Installing Steam..." # GPU. omarchy-install-gaming-gpu-lib32 omarchy-pkg-add steam +omarchy-lifecycle-dispatch post-install steam echo "" echo "Steam will start automatically now. This might take a while..." diff --git a/bin/omarchy-lifecycle-dispatch b/bin/omarchy-lifecycle-dispatch new file mode 100755 index 00000000..53fa6c41 --- /dev/null +++ b/bin/omarchy-lifecycle-dispatch @@ -0,0 +1,200 @@ +#!/bin/bash -p + +# omarchy:summary=Run a fixed lifecycle operation through the platform's own packages +# omarchy:args=[--resolve] [arguments...] +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# Provisioning, factory reset, update, boot, system and user setup and app +# installs call a fixed set of operations here. A platform that needs its own +# handling registers directories of root-owned entrypoints below; on every other +# platform each operation is a no-op and the caller keeps its generic path. The +# contract is docs/lifecycle-dispatch.md. +# +# --resolve prints the entrypoint an operation would run, or nothing when it is +# a no-op here, so a caller can choose between a platform implementation and +# its own generic path. +# +# Root runs this from provisioning, factory reset and update, and it decides +# what runs as root. -p keeps a root caller's environment from running code in +# this shell before the first line (bash then ignores BASH_ENV and exported +# functions), so a launch without it is refused. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +set -euo pipefail + +operations=( + provision-prepare provision-commit provision-verify + reset-prepare reset-verify reset-commit reset-rollback + update-verify update-takeover + luks-slots + setup-boot setup-system setup-user + post-install pre-remove +) + +# Run as the user, never as root, with that user's session. +user_operations=(setup-user post-install pre-remove) + +usage() { + echo "Usage: omarchy-lifecycle-dispatch [--resolve] [arguments...]" >&2 + echo "Operations: ${operations[*]}" >&2 + exit 2 +} + +listed() { + local item=$1 candidate + shift + for candidate in "$@"; do + [[ $candidate == "$item" ]] && return 0 + done + return 1 +} + +resolve=0 +if [[ ${1:-} == "--resolve" ]]; then + resolve=1 + shift +fi +operation=${1:-} +[[ -n $operation ]] || usage +shift +listed "$operation" "${operations[@]}" || usage + +# A privileged caller must not let its environment choose what runs as root: +# root resolves only the fixed paths below, and the detector beside this file +# ignores its fixture roots. Fixture roots exist for unprivileged tests. +fixed_path=/usr/local/sbin:/usr/local/bin:/usr/bin +detect=("$(PATH=$fixed_path dirname -- "${BASH_SOURCE[0]}")/omarchy-hw-platform") +if (( EUID == 0 )); then + export PATH=$fixed_path + detect=(/usr/bin/env -i PATH="$fixed_path" "${detect[@]}") + root="" +else + root=${OMARCHY_LIFECYCLE_ROOT:-} + root=${root%/} + if [[ -n $root && $root != /* ]]; then + echo "Error: OMARCHY_LIFECYCLE_ROOT must be an absolute path" >&2 + exit 1 + fi +fi + +if ! platform=$("${detect[@]}"); then + echo "Error: cannot determine the hardware platform for $operation" >&2 + exit 1 +fi + +# x86 and plain aarch64 machines (Snapdragon laptops among them) have no +# registered package: every operation is a no-op there and callers keep their +# generic path. +# Apple Silicon's boot operations belong to omarchy-mac-boot, and its system and +# user setup and app-install hooks to omarchy-mac (both omacom/omarchy-mac-pkgs). +# Provisioning and factory reset have no other path on a Mac, only update-verify +# checks what an update left in its boot chain, its boot checks prove the kept +# LUKS slots that luks-slots records, and hardware setup has no other way to run +# the Mac's boot setup (setup-boot), so those operations are required. So is +# update-takeover: the Mac boot chain and its image own files no package does, +# and a Mac whose boot package can't vouch for a takeover keeps them. Setup and +# the app-install hooks are optional: a Mac without omarchy-mac keeps Omarchy's +# generic setup and installs. Moving an operation to required also changes +# apple_optional in test/shell.d/lifecycle-dispatch-test.sh and the Apple column +# and registration table in docs/lifecycle-dispatch.md. +case $platform:$operation in + aarch64-apple:setup-system | aarch64-apple:setup-user | aarch64-apple:post-install | aarch64-apple:pre-remove) + implementation=/usr/lib/omarchy/mac + package=omarchy-mac + required=() + ;; + aarch64-apple:*) + implementation=/usr/lib/omarchy/mac-boot + package=omarchy-mac-boot + required=( + provision-prepare provision-commit provision-verify + reset-prepare reset-verify reset-commit reset-rollback + update-verify update-takeover + luks-slots + setup-boot + ) + ;; + *) + exit 0 + ;; +esac + +# Checked once the platform registers the operation, so an installer run with +# sudo still works where the hook is a no-op. +if listed "$operation" "${user_operations[@]}" && (( EUID == 0 )); then + echo "Error: $operation runs as the user, never as root" >&2 + exit 1 +fi + +# Every path from the entrypoint up to / (or the fixture root) must be a real +# file or directory owned by root, or by the unprivileged caller inside a +# fixture root, and writable by nobody else. +trusted() { + local path=$1 top=${root:-/} owner mode + + [[ -f $path && -x $path ]] || return 1 + while true; do + [[ ! -L $path ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1 + if (( owner != 0 )); then + [[ -n $root ]] && (( EUID != 0 && owner == EUID )) || return 1 + fi + (( (8#$mode & 8#022) == 0 )) || return 1 + [[ $path != "$top" ]] || return 0 + [[ $path != "/" ]] || return 1 + path=$(dirname -- "$path") + done +} + +entrypoint=$root$implementation/$operation + +# The package's own pacman record tells a machine without it from one whose +# copy predates the operation. +installed_version() { + local record + for record in "$root/var/lib/pacman/local/$package"-[0-9]*/; do + [[ -d $record ]] || continue + record=${record%/} + printf '%s\n' "${record##*/"$package"-}" + return 0 + done + return 1 +} + +# 3 is a machine without the platform's boot package at all, which a caller may +# tell apart from a failure; 1 is everything else. +if [[ ! -e $entrypoint && ! -L $entrypoint ]]; then + if listed "$operation" "${required[@]}"; then + if version=$(installed_version); then + echo "Error: $operation on $platform needs $implementation/$operation, which $package $version does not provide; update $package" >&2 + exit 1 + fi + echo "Error: $operation on $platform needs $package, which provides $implementation/$operation; it is not installed" >&2 + exit 3 + fi + exit 0 +fi + +if ! trusted "$entrypoint"; then + echo "Error: refusing $implementation/$operation: it must be a root-owned executable file in root-owned directories, writable by no one else" >&2 + exit 1 +fi + +if (( resolve )); then + printf '%s\n' "$entrypoint" + exit 0 +fi + +# A user operation also gets the user's home and session, when there is one, +# and nothing else. +environment=(PATH="$fixed_path") +if listed "$operation" "${user_operations[@]}"; then + for name in HOME USER XDG_CONFIG_HOME XDG_STATE_HOME XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS WAYLAND_DISPLAY OMARCHY_PATH; do + [[ -z ${!name:-} ]] || environment+=("$name=${!name}") + done +fi +exec /usr/bin/env -i "${environment[@]}" "$entrypoint" "$@" diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 153a4d29..ca5bdef0 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -9,12 +9,25 @@ declare -A LUA_BIND_KEY_MAP declare -A LUA_BIND_DISPATCHER_MAP declare -A LUA_BIND_ARG_MAP +# A platform package can name keys the way its keyboards print them: one +# " " per line (on a MacBook, XF86MonBrightnessUp is F2), in the +# fixed platform root, which no environment variable moves. +key_names_file=/usr/share/omarchy-platform/key-names + # Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols # via the compiled keymap, with a small fallback for common keys so the menu # remains readable if xkbcli cannot resolve a symbol. parse_keycodes() { - awk ' + awk -v key_names="$key_names_file" ' BEGIN { + while ((getline line < key_names) > 0) { + # Blanks around a line and a CRLF line end read the same, as in + # displays.conf. + gsub(/^[ \t\r]+|[ \t\r]+$/, "", line) + if (split(line, name_parts, /[ \t]+/) == 2 && name_parts[1] ~ /^[A-Za-z0-9_]+$/ && name_parts[2] ~ /^[A-Za-z0-9_]+$/) key_name[name_parts[1]] = name_parts[2] + } + close(key_names) + split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ") for (i in fallbacks) { separator = index(fallbacks[i], "=") @@ -59,6 +72,13 @@ parse_keycodes() { symbol = mouse_symbol[code] if (symbol == "") symbol = "mouse:" code sub("mouse:" code, symbol) + } else if ((first = index($0, ",")) > 0) { + # Only the key (the second field) is renamed: the description, the + # dispatcher and its argument are left as they are. + rest = substr($0, first + 1) + second = index(rest, ",") + key = second > 0 ? substr(rest, 1, second - 1) : rest + if (key in key_name) $0 = substr($0, 1, first) key_name[key] (second > 0 ? substr(rest, second) : "") } print @@ -81,7 +101,9 @@ build_lua_bind_cache() { LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher" LUA_BIND_ARG_MAP["$cache_key"]="$arg" done < <( - lua <<'LUA' + # -E keeps LUA_INIT and LUA_PATH from the environment out of the scan, which + # loads the user's config. + lua -E - <<'LUA' local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 } local function split_keys(keys) @@ -535,6 +557,7 @@ keybindings_cache_key() { { printf 'v14\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' + cat "$key_names_file" 2>/dev/null hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' } diff --git a/bin/omarchy-pkg-defaults b/bin/omarchy-pkg-defaults new file mode 100755 index 00000000..a96d08ce --- /dev/null +++ b/bin/omarchy-pkg-defaults @@ -0,0 +1,53 @@ +#!/bin/bash + +# omarchy:summary=Print the default package set for this computer's platform, or for the named one: the base list plus its architecture's and platform's additions. +# omarchy:args=[x86|aarch64|aarch64-apple] +# omarchy:hidden=true + +set -eo pipefail + +platform=${1:-} +if [[ -z $platform ]]; then + platform=$(omarchy-hw-platform) +fi + +# Image builders written before the platform names settled pass the old ones. +case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; +esac + +case $platform in + x86) lists=(base) ;; + aarch64) lists=(base aarch64) ;; + aarch64-apple) lists=(base aarch64 "$platform") ;; + *) + echo "Error: unknown platform '$platform'" >&2 + exit 1 + ;; +esac + +# The base list always exists. An architecture or platform with nothing to add +# has no list of its own. +files=() +for list in "${lists[@]}"; do + file="$OMARCHY_PATH/install/omarchy-$list.packages" + if [[ $list == "base" || -f $file ]]; then + files+=("$file") + fi +done + +names() { + sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$@" +} + +# One name per line, first occurrence kept, in list order. An aarch64 platform +# leaves out the base packages that only x86_64 builds. +if [[ $platform == "x86" ]]; then + names "${files[@]}" | awk '!seen[$0]++' +else + names "${files[@]}" | awk -v skips=<(names "$OMARCHY_PATH/install/omarchy-x86_64-only.packages") ' + BEGIN { while ((getline name < skips) > 0) skip[name] = 1 } + !skip[$0] && !seen[$0]++' +fi diff --git a/bin/omarchy-provision-first-run b/bin/omarchy-provision-first-run index eec307ec..8804f6b0 100755 --- a/bin/omarchy-provision-first-run +++ b/bin/omarchy-provision-first-run @@ -75,6 +75,10 @@ run_first_run_step "install agent setup post-update hook" \ run_first_run_step "enable user systemd units" \ bash "$OMARCHY_PATH/install/user/first-run/enable-user-units.sh" +# Finalization ran it too, maybe without a session; the platform finishes what +# needs one here. +run_first_run_step "run the platform's user setup" \ + omarchy-lifecycle-dispatch setup-user run_first_run_step "set GNOME theme" \ bash "$OMARCHY_PATH/install/user/first-run/gnome-theme.sh" run_first_run_step "set GTK primary paste" \ diff --git a/bin/omarchy-provision-hardware b/bin/omarchy-provision-hardware new file mode 100755 index 00000000..fee98763 --- /dev/null +++ b/bin/omarchy-provision-hardware @@ -0,0 +1,162 @@ +#!/bin/bash + +# omarchy:summary=Finish the hardware setup an image build deferred to this machine +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# Started by omarchy-provision-hardware.service on every boot while an image's +# deferred steps remain (see install/helpers/image-target.sh), and safe to call +# from a platform's own first boot as well: runs are serialised. The first run +# retires the image's build manifest, so from then on this machine is itself. +# Steps run in order and each one that succeeds leaves the queue, so a failure +# stops the run and the next run resumes at that step. Once the queue is empty +# the initramfs is rebuilt if any step changed its configuration or asked for +# the rebuild, and the service is disabled. With nothing deferred this does +# nothing. A failed step, or a failure to make the pacman keyring the build +# asked for, exits 75 (it stays queued for the next run); anything else that +# stops the run, such as an untrusted manifest or queue, exits 1. + +set -euo pipefail + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +if (( EUID == 0 )); then + export OMARCHY_PATH=/usr/share/omarchy + export PATH=/usr/share/omarchy/bin:/usr/local/sbin:/usr/local/bin:/usr/bin +elif [[ -z ${OMARCHY_IMAGE_ROOT:-} || -z ${OMARCHY_PATH:-} ]]; then + fail "omarchy-provision-hardware must run as root" +fi +export OMARCHY_INSTALL=$OMARCHY_PATH/install +export OMARCHY_INSTALL_USER="" + +source "$OMARCHY_INSTALL/helpers/logging.sh" +source "$OMARCHY_INSTALL/helpers/image-target.sh" +omarchy_image_init +export OMARCHY_INSTALL_LOG_FILE=$omarchy_image_root/var/log/omarchy-install.log + +queue_present() { + [[ -e $omarchy_image_queue || -L $omarchy_image_queue ]] +} + +# Anything left for the first boot: the manifest, a queue, or the keyring an +# image build asked for, which is owed even when no step was queued. +work_present() { + omarchy_image_manifest_present || queue_present || [[ -e $omarchy_image_keyring_request ]] +} + +work_present || exit 0 +omarchy_image_lock || exit 1 +# Another run may have finished while this one waited for the lock. +work_present || exit 0 + +if omarchy_image_manifest_present; then + omarchy_image_read_manifest || exit 1 + mv -f "$omarchy_image_manifest" "$omarchy_image_manifest.booted" || fail "could not retire $omarchy_image_manifest" + live_platform=$(omarchy-hw-platform) || live_platform=unknown + echo "First boot of an image built for $omarchy_image_platform, on $live_platform hardware" +fi + +remaining=() +if queue_present; then + [[ -f $omarchy_image_queue ]] && omarchy_image_trusted "$omarchy_image_queue" || + fail "$omarchy_image_queue is not a root-owned regular file" + mapfile -t queued <"$omarchy_image_queue" + for step in "${queued[@]}"; do + [[ -z $step ]] || remaining+=("$step") + done +fi + +# What the boot image is built from: mkinitcpio's configuration, the module +# options its modconf hook copies in (leaves such as nvidia.sh write them), and +# the kernel command line Limine's entry tool adds (leaves such as intel/fred.sh +# add to it). +initramfs_inputs() { + local file + + for file in "$omarchy_image_root/etc/mkinitcpio.conf" "$omarchy_image_root/etc/mkinitcpio.conf.d/"* \ + "$omarchy_image_root/etc/modprobe.d/"* "$omarchy_image_root/usr/lib/modprobe.d/"* \ + "$omarchy_image_root/etc/default/limine" "$omarchy_image_root/etc/limine-entry-tool.d/"*; do + [[ -e $file ]] || continue + printf '%s\0' "$file" + cat -- "$file" + done | cksum +} + +# Recorded before the first step and kept until a rebuild lands, so a step that +# changed the initramfs and then failed or lost power still gets one. +if [[ ! -f $omarchy_image_initramfs_baseline ]]; then + baseline=$(mktemp "$omarchy_image_dir/.initramfs-inputs.XXXXXX") && + initramfs_inputs >"$baseline" && chmod 0644 "$baseline" && mv -f "$baseline" "$omarchy_image_initramfs_baseline" || + fail "could not record the initramfs inputs" +fi + +mkdir -p "$(dirname "$OMARCHY_INSTALL_LOG_FILE")" + +# A step that would build the boot image itself (a platform's boot setup) asks +# for this run's rebuild instead, so the image is built once, after the last +# step. The request stays on disk until that rebuild lands. +export OMARCHY_IMAGE_BOOT_REBUILD=$omarchy_image_boot_rebuild +# Tells a step it runs on the booted machine, after boot already brought up the +# targets its units hang off (see install/hardware/bluetooth.sh). +export OMARCHY_IMAGE_DEFERRED_HARDWARE=1 + +# An image build asks for this machine's own pacman keyring (see +# install/post-install/pacman.sh), made before any step installs a package. Any +# keyring the build or an interrupted run left is replaced; the request stays +# until a keyring is complete. +if [[ -e $omarchy_image_keyring_request ]]; then + echo "Creating this machine's pacman keyring" + keyring=$omarchy_image_root/etc/pacman.d/gnupg + if ! { rm -rf "$keyring" && pacman-key --gpgdir "$keyring" --init && + pacman-key --gpgdir "$keyring" --populate; } >"$OMARCHY_INSTALL_LOG_FILE" 2>&1; then + echo "Error: could not create the pacman keyring (see $OMARCHY_INSTALL_LOG_FILE); the next run retries it" >&2 + exit 75 + fi + rm -f "$omarchy_image_keyring_request" +fi + +while (( ${#remaining[@]} )); do + step=${remaining[0]} + [[ $step == install/hardware/*.sh && $step != *..* ]] || fail "Deferred step is not a hardware setup step: $step" + script=$OMARCHY_PATH/$step + + if [[ -L $script ]]; then + fail "Deferred step is a symlink: $step" + elif [[ ! -e $script ]]; then + echo "Skipping deferred hardware step $step: this Omarchy no longer ships it" + else + echo "Running deferred hardware step $step" + if ! run_logged "$script"; then + echo "Error: Deferred hardware step failed: $step (see $OMARCHY_INSTALL_LOG_FILE); the next run resumes here" >&2 + exit 75 + fi + fi + + remaining=("${remaining[@]:1}") + omarchy_image_write_queue "${remaining[@]}" || fail "could not update $omarchy_image_queue" +done + +if [[ -e $omarchy_image_boot_rebuild || $(initramfs_inputs) != "$(<"$omarchy_image_initramfs_baseline")" ]]; then + echo "Rebuilding the initramfs for the deferred hardware setup" + if omarchy-cmd-present limine-mkinitcpio; then + limine-mkinitcpio >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 + else + mkinitcpio -P >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 + fi || fail "Initramfs rebuild failed (see $OMARCHY_INSTALL_LOG_FILE); the next run retries it" + # The new image must be on disk before the queue that owes it is cleared. + sync || fail "could not flush the rebuilt initramfs to disk" +fi +rm -f "$omarchy_image_initramfs_baseline" "$omarchy_image_boot_rebuild" + +# Disabled, not deleted: a platform first boot that runs this while the +# service's start job waits would otherwise fail that job at the next +# daemon-reload. The emptied queue keeps the unit from running. +rm -f "$omarchy_image_systemd_dir/multi-user.target.wants/$omarchy_image_unit" +rm -f "$omarchy_image_queue" +if (( EUID == 0 )); then + systemctl daemon-reload 2>/dev/null || true +fi +echo "Deferred hardware setup is complete" diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index e7823fd8..0a5e6b88 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -9,7 +9,9 @@ # behind. Asks for the user (the configurator's Step 2), creates it with the # groups system setup recorded, finalizes it offline from the stashed Node # tarball, re-keys LUKS from the throwaway install passphrase to the user's -# password, and hands off to SDDM. +# password, and hands off to SDDM. A platform whose boot package owns its boot +# chain prepares it, drops the install key's unlock from it and records the +# kept key slot through omarchy-lifecycle-dispatch. set -euo pipefail @@ -33,6 +35,9 @@ fi export PATH="$OMARCHY_PATH/bin:$PATH" +REKEY_STATE=$PROVISIONING_DIR/luks-rekey.state +source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh" + # The shared setup form — the same questions, prompts, and validation the ISO # configurator's user step asks. The ISO vendors this very file out of the # runtime package it bundles, so an install and the first boot that finishes it @@ -579,14 +584,27 @@ greeter_screen() { # The keyboard step the ISO configurator runs — deferred to first boot for OEM # installs, so the machine's owner picks their own layout. Applied immediately # (live VT + persisted) so the password typed next, and the LUKS re-key below, -# use the chosen layout. +# use the chosen layout. A layout that did not take is asked for again: the +# password form must never be typed in a layout the disk unlock won't use. +# Three failures in a row mean the machine can't set a layout at all (a +# missing or broken localectl, say), so the attempt fails and setup's own +# retry-or-console screen takes over instead of asking forever. keyboard_form() { - local status + local status failures=0 while true; do step "Let's setup your keyboard..." omarchy_prompt_keyboard && status=0 || status=$? - ((status == 0)) && break + if ((status == 0)); then + apply_keyboard "$keyboard" && break + if ((++failures >= 3)); then + log_step "no keyboard layout could be set after $failures attempts" + notice "Setup could not set a keyboard layout on this machine." 4 + return 1 + fi + notice "Could not set the ${keyboard_label:-$keyboard} keyboard layout. Choose it again." 4 + continue + fi # Esc means "back", and nothing precedes the first screen, so re-ask. ((status == OMARCHY_FORM_BACK)) && continue @@ -597,8 +615,6 @@ keyboard_form() { # re-key below. Confirm it so a stray Ctrl+C doesn't bounce the machine. confirm_reboot && exec systemctl reboot done - - apply_keyboard "$keyboard" } confirm_reboot() { @@ -609,21 +625,74 @@ confirm_reboot() { gum confirm --affirmative "Yes, reboot" --negative "No, keep setting up" "Reboot this machine?" } -# Load the layout on the live VT and persist it for the installed system. -# systemd-firstboot writes both the console KEYMAP and the XKB layout Hyprland -# reads, matching what the ISO's configure_keyboard does at install time. A -# keymap localectl doesn't know keeps the default rather than failing (defensive; -# the picker no longer offers any such layout). -apply_keyboard() { - local keymap="$1" - [[ $(tty 2>/dev/null) == /dev/tty* ]] && loadkeys "$keymap" 2>/dev/null || true +# XKB settings for the form's keymaps that kbd-model-map lacks, as its rows for +# the nearest keymap give them, so the desktop types what the console does: +# kbd's cz is QWERTY, bg-cp1251 phonetic and azerty French AZERTY. Non-Latin +# layouts keep a US layout to switch to, so the shared password stays typeable. +keyboard_xkb_settings() { + local layout variant="" options=terminate:ctrl_alt_bksp + local switch=terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll - if localectl --no-pager list-keymaps 2>/dev/null | grep -qix "$keymap"; then - systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 || \ - localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1 || \ - log_step "could not persist keymap $keymap" - else - log_step "keymap $keymap unknown to localectl; keeping the default" + case $1 in + colemak) layout=us variant=colemak ;; + azerty) layout=fr ;; + bg-cp1251) layout=bg,us variant=phonetic, options=$switch ;; + cz) layout=cz,us variant=qwerty, options=$switch ;; + de_CH-latin1) layout=ch ;; + kyrgyz) layout=kg,us options=$switch ;; + no-latin1) layout=no ;; + pl) layout=pl ;; + ua) layout=ua,us options=$switch ;; + *) return 1 ;; + esac + + printf 'XKBLAYOUT=%s\nXKBMODEL=pc105\n' "$layout" + [[ -z $variant ]] || printf 'XKBVARIANT=%s\n' "$variant" + printf 'XKBOPTIONS=%s\n' "$options" +} + +# Load the layout on the live VT and persist it for the installed system, or +# fail. systemd-firstboot writes both the console KEYMAP and the XKB layout +# Hyprland reads, matching what the ISO's configure_keyboard does at install +# time. The boot files ask for the disk password in the layout vconsole.conf +# names, so a keymap localectl doesn't know, a failed loadkeys, or a file that +# does not read back as the chosen keymap with an XKB layout is a failure. +apply_keyboard() { + local keymap=$1 keymaps saved + + if [[ $(tty 2>/dev/null) == /dev/tty* ]] && ! loadkeys "$keymap" >>"$LOG_FILE" 2>&1; then + log_step "loadkeys could not load keymap $keymap" + return 1 + fi + + if ! keymaps=$(localectl --no-pager list-keymaps 2>>"$LOG_FILE"); then + log_step "localectl could not list keymaps to check $keymap" + return 1 + fi + if ! grep -qxF -- "$keymap" <<<"$keymaps"; then + log_step "keymap $keymap unknown to localectl" + return 1 + fi + + if ! systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 && + ! localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1; then + log_step "could not persist keymap $keymap" + return 1 + fi + + # systemd-firstboot writes an XKB layout only for keymaps in kbd-model-map, + # and several the form offers are not, so Hyprland would come up in US. + if ! grep -q '^XKBLAYOUT=.' /etc/vconsole.conf 2>/dev/null; then + keyboard_xkb_settings "$keymap" >>/etc/vconsole.conf 2>>"$LOG_FILE" || + log_step "no XKB layout known for keymap $keymap" + fi + + # A clean shell, so nothing this process exported stands in for the file. + saved=$(env -i /bin/bash --noprofile --norc -c \ + '. /etc/vconsole.conf && [[ -n ${XKBLAYOUT:-} ]] && printf %s "${KEYMAP:-}"' 2>>"$LOG_FILE") || saved="" + if [[ $saved != "$keymap" ]]; then + log_step "/etc/vconsole.conf does not read back as keymap $keymap with an XKB layout" + return 1 fi } @@ -714,7 +783,7 @@ luks_device() { local src part src=$(findmnt -no SOURCE / | sed 's/\[.*//') [[ -n $src ]] || return 1 - part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + part=$(lsblk -nsrpo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') [[ -n $part ]] && { echo "$part"; return 0; } return 1 } @@ -822,13 +891,24 @@ configure_hostname() { # step, overwriting the placeholder (UTC) the deferred install seeded. configure_timezone() { [[ -n ${timezone:-} ]] || return 0 - if timedatectl set-timezone "$timezone" 2>&1; then - return 0 + if ! timedatectl set-timezone "$timezone" 2>&1; then + # Fallback only if the zone file exists, so a bad zone can't point + # /etc/localtime at nothing and break every localtime read afterward. + [[ -e /usr/share/zoneinfo/$timezone ]] && + ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime 2>&1 || true + fi + configure_wireless_regdom +} + +# The install's hardware step found only the UTC placeholder, which names no +# country, so the wireless regulatory domain comes from the owner's timezone +# here. No reboot follows setup, so the radios get it now the way udev applies +# it at every boot. +configure_wireless_regdom() { + bash "$OMARCHY_PATH/install/hardware/set-wireless-regdom.sh" 2>&1 || return 0 + if omarchy-cmd-present set-wireless-regdom; then + set-wireless-regdom 2>&1 || true fi - # Fallback only if the zone file exists, so a bad zone can't point - # /etc/localtime at nothing and break every localtime read afterward. - [[ -e /usr/share/zoneinfo/$timezone ]] && - ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime 2>&1 || true } finalize_user() { @@ -851,16 +931,99 @@ finalize_user() { "$OMARCHY_PATH/bin/omarchy-provision-user" --force --first-install } +# During the provisioning window the staged key rides in the UKI's initramfs, +# named on the kernel cmdline, so boot unlocks unattended. +PROVISIONING_UNLOCK_FILES=( + /etc/omarchy/provisioning.key + /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf +) + +limine_auto_unlock_present() { + local file + for file in "${PROVISIONING_UNLOCK_FILES[@]}"; do + [[ -e $file ]] && return 0 + done + return 1 +} + +# Rebuild the UKI without the embedded keyfile. A failed rebuild restores the +# auto-unlock, so the retry still boots unattended. +limine_auto_unlock_drop() { + rm -f "${PROVISIONING_UNLOCK_FILES[@]}" + reset_limine_config + if limine-update >>"$LOG_FILE" 2>&1; then + return 0 + fi + + # Once the staged key is destroyed there is no auto-unlock to restore: the + # boot files already ask for the owner's password. The rebuild is still + # retried for the boot entries. + if [[ -f $PROVISIONING_DIR/luks-key ]]; then + log_step "limine-update failed during re-key; restoring auto-unlock for retry" + install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + else + log_step "limine-update failed during re-key; the staged key is gone, so no auto-unlock is restored" + fi + limine-update >>"$LOG_FILE" 2>&1 || true + return 1 +} + +# A platform whose boot package implements provision-commit and provision-verify +# stages the unlock in its own boot chain and owns it; everywhere else it rides +# in the Limine UKI above. Deciding fails closed: the unlock then counts as +# present and cannot be dropped. +UNLOCK_OWNER="" + +unlock_owner() { + [[ -z ${UNLOCK_OWNER:-} ]] || return 0 + + local commit verify + commit=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-commit) || return 1 + verify=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-verify) || return 1 + if [[ -n $commit && -n $verify ]]; then + UNLOCK_OWNER=platform + elif [[ -z $commit && -z $verify ]]; then + UNLOCK_OWNER=limine + else + log_step "the platform implements only one of provision-commit and provision-verify" + return 1 + fi +} + +luks_auto_unlock_present() { + unlock_owner || return 0 + if [[ $UNLOCK_OWNER == "platform" ]]; then + ! "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-verify >>"$LOG_FILE" 2>&1 + else + limine_auto_unlock_present + fi +} + +luks_auto_unlock_drop() { + unlock_owner || return 1 + if [[ $UNLOCK_OWNER == "platform" ]]; then + "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-commit >>"$LOG_FILE" 2>&1 + else + limine_auto_unlock_drop + fi +} + +# The platform records the slot the re-key keeps, and no recovery slot; a no-op +# where nothing does. +luks_record_slots() { + "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1" "recovery=" >>"$LOG_FILE" 2>&1 +} + # Move the LUKS volume from the throwaway install passphrase to the user's -# password: add the user's key, kill every other slot (throwaway + any seller -# keys a reset left behind), then rebuild the UKI without the embedded -# auto-unlock keyfile. -# -# Failing here must be LOUD (abort the attempt, offer retry): silently keeping -# the staged auto-unlock keyfile would leave the disk effectively unencrypted -# forever. +# password and retire every other slot (throwaway + any seller keys a reset +# left behind). luks_rekey journals each step, so an interrupted attempt +# resumes where it stopped. rekey_luks() { - [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 + luks_rekey_pending || return 0 local device if ! device=$(luks_device) || [[ ! -e $device ]]; then @@ -869,61 +1032,32 @@ rekey_luks() { return 1 fi - if ! cryptsetup open --test-passphrase --key-file "$PROVISIONING_DIR/luks-key" "$device" 2>>"$LOG_FILE"; then - log_step "staged LUKS key does not unlock $device" - say --foreground 1 "The staged LUKS key no longer unlocks $device." - return 1 - fi + luks_rekey "$device" +} - # Add the user's key (a retry with a different password just adds another - # slot; all but the current one are killed once the rebuild succeeds). - cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password") - - # Rebuild the no-auto-unlock UKI FIRST, keeping the throwaway key and slot as - # a fallback. Only once that succeeds do we kill the other slots and destroy - # the staged key — so a limine-update failure leaves a recoverable, - # still-auto-unlocking state to retry, never a disk locked to a password the - # user may have just changed. - rm -f /etc/omarchy/provisioning.key \ - /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ - /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf - reset_limine_config - if ! limine-update >>"$LOG_FILE" 2>&1; then - log_step "limine-update failed during re-key; restoring auto-unlock for retry" - install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key - echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ - >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf - echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf - limine-update >>"$LOG_FILE" 2>&1 || true - return 1 +# The re-key's fingerprint of the console keyboard layout the boot files ask for +# the disk password in: a hash of vconsole.conf's keymap and XKB settings, in +# file order since a later duplicate wins, so the journal never names the layout. +luks_boot_layout() { + if [[ ! -f /etc/vconsole.conf ]]; then + echo none + return fi + awk '/^[ \t]*(KEYMAP|KEYMAP_TOGGLE|XKB[A-Z_]*)=/ { sub(/^[ \t]+/, ""); print }' /etc/vconsole.conf | + sha256sum | cut -d' ' -f1 +} - local new_slot slot other_slots - new_slot=$(cryptsetup open --test-passphrase --verbose --key-file <(printf '%s' "$password") "$device" 2>&1 | - grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1) - # Retiring the throwaway/seller slots must be all-or-nothing: if we can't - # identify the user's slot or a kill fails, keep the staged key and retry — - # never shred it while a slot the seller knows still unlocks the disk. - if [[ -z $new_slot ]]; then - log_step "could not identify the user's LUKS slot after re-key; keeping the staged key for retry" - say --foreground 1 "Could not confirm the LUKS re-key; will retry." - return 1 +# A re-key interrupted after the staged key was retired can only finish with +# the password the disk already holds. +rekey_accepts_password() { + local - + set +x + local device + [[ -f $REKEY_STATE ]] || return 0 + if ! device=$(luks_device) || [[ ! -e $device ]]; then + return 0 fi - if ! other_slots=$(cryptsetup luksDump "$device" | awk '/^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }'); then - log_step "luksDump failed while retiring slots; keeping the staged key for retry" - say --foreground 1 "Could not enumerate LUKS slots; will retry." - return 1 - fi - for slot in $other_slots; do - [[ $slot == "$new_slot" ]] && continue - if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then - log_step "failed to kill LUKS slot $slot; keeping the staged key for retry" - say --foreground 1 "Could not remove the throwaway LUKS key; will retry." - return 1 - fi - done - - shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" + luks_rekey_accepts_password "$device" } # Start the ESP's limine.conf over from the shipped template and drop foreign @@ -990,7 +1124,7 @@ reset_limine_config() { cleanup_oem_state() { # Keep groups + packages: omarchy-system-factory-reset stages the bundled Node # tarball from these live copies when the factory snapshot predates it. - rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" + rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" "$REKEY_STATE" rm -f /etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service systemctl daemon-reload 2>/dev/null || true @@ -1017,7 +1151,7 @@ run_provisioning() { touch "$FINALIZE_WARNING_FLAG" fi - if [[ -f $PROVISIONING_DIR/luks-key ]]; then + if luks_rekey_pending; then log_step "re-keying LUKS to the user's password" echo rekey >"$STATE_FILE" rekey_luks @@ -1034,6 +1168,11 @@ run_provisioning() { limine-update fi + if luks_staged_unlock_remains; then + log_step "refusing to finish setup while the staged LUKS key or its auto-unlock remains" + return 1 + fi + if omarchy-pkg-present usbguard; then log_step "enrolling the owner's USB devices" source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" @@ -1048,11 +1187,32 @@ run_provisioning() { log_step "first-boot setup complete" } +# Where the platform's boot package owns the boot chain, it must be ready to +# finish setup, and able to record the kept key slot, before the owner is asked +# anything. +platform_ready() { + local error + if error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-prepare 2>&1 >>"$LOG_FILE") && + error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1 >/dev/null); then + return 0 + fi + error=${error:-"The boot package could not prepare this machine for setup."} + + log_step "the boot package is not ready for setup: $error" + clear_logo + echo + say --foreground 1 "$error" + sleep 5 + return 1 +} + run_setup() { local status + platform_ready || return 1 + while true; do - keyboard_form + keyboard_form || return 1 user_form && status=0 || status=$? if ((status != 0)); then # Esc unwinds to the keyboard step. Ctrl+C is the only other way out of a @@ -1060,7 +1220,12 @@ run_setup() { ((status == OMARCHY_FORM_SIGNAL)) && confirm_reboot && exec systemctl reboot continue fi - confirm_form && break + confirm_form || continue + if ! rekey_accepts_password; then + notice "Setup already set the disk password. Enter that same password." 4 + continue + fi + break done touch "$LOG_FILE" diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 7406e6ad..5c1eb0d5 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -24,17 +24,29 @@ omarchy_security_install_sudo_cleanup_traps omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo -channel="${1:-stable}" -if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then +channel="${1:-}" +if [[ -n $channel && $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then echo "Invalid channel: $channel" >&2 exit 2 fi +source "$OMARCHY_PATH/install/helpers/pacman.sh" +if ! platform=$(omarchy-hw-platform); then + echo "Cannot tell this machine's platform. No repository configuration changed." >&2 + exit 1 +fi +templates=$(omarchy_pacman_templates "$platform") || exit 1 +[[ -n $channel ]] || channel=$(omarchy_pacman_default_channel "$platform") || exit 1 +if [[ ! -f $templates/pacman-$channel.conf || ! -f $templates/mirrorlist-$channel ]]; then + echo "Omarchy has no $channel channel for $platform. No repository configuration changed." >&2 + exit 1 +fi + sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak echo "Setting channel to $channel" -sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf -sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist +sudo cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf +sudo cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist # Allow user customization of /etc/pacman.conf before the upgrade runs, so # custom repositories and IgnorePkg entries shape the downgrade-capable diff --git a/bin/omarchy-reinstall-pkgs b/bin/omarchy-reinstall-pkgs index 97aff9c6..156ab9d4 100755 --- a/bin/omarchy-reinstall-pkgs +++ b/bin/omarchy-reinstall-pkgs @@ -1,18 +1,20 @@ #!/bin/bash -# omarchy:summary=Reinstall all default Omarchy packages from the stable channel +# omarchy:summary=Reinstall all default Omarchy packages from the stable channel, or on aarch64 the edge channel # omarchy:requires-sudo=true set -e -# Reinstall all default Omarchy packages from the stable channel and downgrade any packages that are too new. +# Reinstall all default Omarchy packages from the platform's default channel +# (stable, or edge on aarch64) and downgrade any packages that are too new. -# Set the package repository to the stable mirrors +# Set the package repository to the platform's default channel omarchy-refresh-pacman -# Downgrade any packages to the stable setup +# Downgrade any packages to that channel's setup omarchy-update-pacman -Suu --noconfirm -# Ensure all packages are installed -mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') +# Ensure this platform's default packages are installed +default_packages=$(omarchy-pkg-defaults) +mapfile -t packages <<<"$default_packages" omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}" diff --git a/bin/omarchy-remove-gaming-steam b/bin/omarchy-remove-gaming-steam index 1b20ec22..16a2570a 100755 --- a/bin/omarchy-remove-gaming-steam +++ b/bin/omarchy-remove-gaming-steam @@ -5,6 +5,7 @@ set -e +omarchy-lifecycle-dispatch pre-remove steam omarchy-pkg-drop steam rm -rf \ diff --git a/bin/omarchy-setup-direct-boot b/bin/omarchy-setup-direct-boot index 56c7fd50..ee9f30f5 100755 --- a/bin/omarchy-setup-direct-boot +++ b/bin/omarchy-setup-direct-boot @@ -3,6 +3,14 @@ # omarchy:summary=Add or remove an EFI boot entry for the Omarchy UKI, allowing the system to boot directly # omarchy:requires-sudo=true +# Apple Silicon boots m1n1, then U-Boot, which finds the bootloader on its own +# and creates its boot entries itself, so there is no direct-boot entry to add +# or remove. +if omarchy-hw-aarch64-apple; then + echo "Error: Direct boot is not supported on Apple Silicon: U-Boot finds the bootloader itself" >&2 + exit 1 +fi + if [[ ! -d /sys/firmware/efi ]]; then echo "Error: System is not booted in UEFI mode" >&2 exit 1 diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index ee68641d..ab3e2584 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -40,6 +40,13 @@ while (( $# > 0 )); do shift done +# Boot-time blocking goes into Limine's own configuration, which a Mac's boot +# package owns, so it is not offered there. +if (( from_boot )) && omarchy-hw-aarch64-apple; then + echo "USB authorization from boot is not available on Apple Silicon yet; USB device authorization after login still works." >&2 + exit 2 +fi + state_dir="$HOME/.local/state/omarchy/usb-authorization" unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service" unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 22c789fd..c46c5cb1 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -17,7 +17,9 @@ # The heavy lifting happens on the next boot via omarchy-system-factory-reset-finish, staged # here. On encrypted installs the LUKS volume is re-keyed to a throwaway # passphrase with an auto-unlock keyfile for the provisioning window; first-boot setup -# re-keys it to the new owner's password and removes the keyfile. +# re-keys it to the new owner's password and removes the keyfile. A platform +# whose boot chain the Limine UKI path can't drive rebuilds, verifies and +# unlocks it through omarchy-lifecycle-dispatch. # # Honest limitations: # - On unencrypted disks this is deletion, not forensic erasure. fstrim helps @@ -70,6 +72,51 @@ root_is_btrfs_at() { [[ $(findmnt -no FSTYPE /) == btrfs ]] && findmnt -no OPTIONS / | grep -q "subvol=/@\(,\|$\)" } +DISPATCH="$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" + +# A platform whose boot package implements the reset operations rebuilds, +# verifies and unlocks the factory root's boot chain itself; everywhere else +# the Limine UKI path below does. Deciding fails closed. +RESET_BOOT="" + +reset_boot_owner() { + [[ -z $RESET_BOOT ]] || return 0 + + local operation path found=0 missing=0 + for operation in reset-prepare reset-verify reset-commit reset-rollback; do + path=$("$DISPATCH" --resolve "$operation") || return 1 + if [[ -n $path ]]; then + found=$((found + 1)) + else + missing=$((missing + 1)) + fi + done + if (( missing == 0 )); then + RESET_BOOT=platform + elif (( found == 0 )); then + RESET_BOOT=generic + else + echo "Error: the boot package implements only some of the factory reset operations" >&2 + return 1 + fi +} + +# Runs a reset operation with its output in the log. A failure keeps the +# operation's last line for the error shown. +RESET_BOOT_ERROR="" + +reset_boot_op() { + local output status=0 + output=$("$DISPATCH" "$@" 2>&1) || status=$? + [[ -z $output ]] || printf '%s\n' "$output" >>"$LOG_FILE" + if (( status == 0 )); then + return 0 + fi + RESET_BOOT_ERROR=$(tail -n 1 <<<"$output") + RESET_BOOT_ERROR=${RESET_BOOT_ERROR:-"$1 failed"} + return 1 +} + # Resolve the crypto_LUKS partition backing the root, or return non-zero if # the root is not on LUKS. Prefers the cmdline cryptdevice= spec (archinstall # writes PARTUUID=, the pre-mounted path UUID=), and falls back to walking the @@ -89,7 +136,7 @@ luks_device() { local src part src=$(findmnt -no SOURCE / | sed 's/\[.*//') [[ -n $src ]] || return 1 - part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + part=$(lsblk -nsrpo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') [[ -n $part ]] && { echo "$part"; return 0; } return 1 } @@ -107,6 +154,11 @@ generate_passphrase() { } cleanup() { + # Before the switch the old root still boots: the boot package gives it back + # its boot state before the reset's throwaway slot is revoked. + if [[ ${reset_committed:-0} == 0 && ${RESET_BOOT_PREPARED:-0} == 1 ]]; then + reset_boot_op reset-rollback || echo "Error: $RESET_BOOT_ERROR (see $LOG_FILE)" >&2 + fi if mountpoint -q "$TOP_MNT" 2>/dev/null; then if [[ -d $TOP_MNT/$NEXT_NAME && ${swap_done:-0} == 0 ]]; then btrfs subvolume delete --recursive "$TOP_MNT/$NEXT_NAME" >/dev/null 2>&1 || true @@ -114,6 +166,9 @@ cleanup() { umount -R "$TOP_MNT" 2>/dev/null || true fi rmdir "$TOP_MNT" 2>/dev/null || true + if [[ ${reset_committed:-0} == 0 ]]; then + revoke_reset_luks + fi } trap cleanup EXIT @@ -135,8 +190,54 @@ confirm_reset() { [[ $typed == "reset" ]] || fail "Reset not confirmed." } +# The slot the reset's throwaway key opens, or nothing. Restricting cryptsetup +# to a token type no token has keeps an enrolled token from answering for it. +reset_luks_slot() { + local - + set +x + LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only \ + --key-file <(printf '%s' "$RESET_THROWAWAY") "$RESET_LUKS_DEVICE" 2>&1 | + grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1 || true +} + +# Add the throwaway slot where the platform's boot package owns the unlock. +# Called once reset-verify passed, so a failed rebuild adds no credential. +# reset-commit writes the boot-time key only once the factory root is active: +# a power loss before the switch leaves the old root asking for its password, +# never unlocked unattended. +add_reset_luks_slot() { + local - + set +x + [[ -n ${RESET_LUKS_DEVICE:-} && -n ${RESET_LUKS_AUTH:-} && -n ${RESET_THROWAWAY:-} ]] || fail "the reset is missing its throwaway passphrase" + RESET_LUKS_ADDED=1 + { cryptsetup luksAddKey --key-file <(printf '%s' "$RESET_LUKS_AUTH") "$RESET_LUKS_DEVICE" <(printf '%s' "$RESET_THROWAWAY") ; } 2>>"$LOG_FILE" || + fail "could not add the throwaway LUKS keyslot" + RESET_LUKS_SLOT=$(reset_luks_slot) + [[ -n $RESET_LUKS_SLOT ]] || fail "could not identify the throwaway LUKS keyslot" +} + +# Only the slot this attempt added goes, found by its key if the add was not +# confirmed. The platform's boot-time key is written only after activation, so +# a failed reset has none to revoke. +revoke_reset_luks() { + local - + set +x + [[ ${RESET_LUKS_ADDED:-0} == 1 && -n ${RESET_LUKS_AUTH:-} ]] || return 0 + [[ -n ${RESET_LUKS_SLOT:-} ]] || RESET_LUKS_SLOT=$(reset_luks_slot) + if [[ -n $RESET_LUKS_SLOT ]]; then + { cryptsetup luksKillSlot -q --key-file <(printf '%s' "$RESET_LUKS_AUTH") "$RESET_LUKS_DEVICE" "$RESET_LUKS_SLOT" ; } 2>>"$LOG_FILE" || true + else + echo "Could not find the reset's throwaway LUKS slot on $RESET_LUKS_DEVICE to revoke; check its keyslots" >>"$LOG_FILE" + fi + RESET_LUKS_ADDED=0 + RESET_LUKS_SLOT="" +} + # Re-key to a throwaway passphrase whose keyfile auto-unlocks boot during the -# provisioning window. $1 is the root of the staged factory system. +# provisioning window. $1 is the root of the staged factory system. Where the +# platform's boot package owns the unlock, only the staged key is written here: +# reset-prepare stages the unlock with the rest of the boot files, and the +# throwaway slot follows their verification. stage_luks_rekey() { local next="$1" device passphrase @@ -148,19 +249,29 @@ stage_luks_rekey() { local current while true; do current=$(gum input --password --placeholder "Current disk encryption passphrase" --prompt "Passphrase> ") || exit 1 - if cryptsetup open --test-passphrase --key-file <(printf '%s' "$current") "$device" 2>/dev/null; then + if cryptsetup open --test-passphrase --token-type passphrase-only --key-file <(printf '%s' "$current") "$device" 2>/dev/null; then break fi gum style --foreground 1 "That passphrase does not unlock $device. Try again." done passphrase=$(generate_passphrase) - cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + reset_boot_owner || fail "could not tell how this machine's boot chain is reset" + RESET_LUKS_AUTH=$current + RESET_LUKS_DEVICE=$device + RESET_THROWAWAY=$passphrase + if [[ $RESET_BOOT == "generic" ]]; then + # Revoked like the platform's slot if the reset fails before activation. + RESET_LUKS_ADDED=1 + cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + fi install -d -m 755 "$next$PROVISIONING_DIR" printf '%s' "$passphrase" >"$next$PROVISIONING_DIR/luks-key" chmod 600 "$next$PROVISIONING_DIR/luks-key" + [[ $RESET_BOOT == "generic" ]] || return 0 + install -d -m 755 "$next/etc/omarchy" printf '%s' "$passphrase" >"$next/etc/omarchy/provisioning.key" chmod 600 "$next/etc/omarchy/provisioning.key" @@ -243,6 +354,12 @@ verify_limine_hashes() { rebuild_next_boot() { local next="$1" + reset_boot_owner || fail "could not tell how this machine's boot chain is reset" + if [[ $RESET_BOOT == "platform" ]]; then + rebuild_next_boot_platform "$next" + return + fi + local esp_source esp_mount esp_mount=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $2; exit }' "$next/etc/fstab") esp_source=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $1; exit }' "$next/etc/fstab") @@ -305,6 +422,23 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# The boot package stages the factory root's boot files and unlock, then proves +# they boot it; reset-rollback undoes both if the reset fails before the switch. +# The throwaway slot is added only once they are proven. +rebuild_next_boot_platform() { + local next="$1" + local -a luks=() + [[ -z ${RESET_LUKS_DEVICE:-} ]] || luks=("$RESET_LUKS_DEVICE") + + RESET_BOOT_PREPARED=1 + log "Rebuilding boot files from the factory system (this can take a minute)" + reset_boot_op reset-prepare "$next" "${luks[@]}" || fail "$RESET_BOOT_ERROR (see $LOG_FILE)" + log "Verifying that the factory system boots" + reset_boot_op reset-verify "$next" || fail "$RESET_BOOT_ERROR (see $LOG_FILE)" + + [[ -z ${RESET_LUKS_DEVICE:-} ]] || add_reset_luks_slot +} + # Both the staged system and the retained baseline must lose the old hashes. scrub_factory_accounts() { local root="$1" user users @@ -417,9 +551,30 @@ stage_full_reset() { log "Activating the factory system" local old="@omarchy-old-$(date +%s)" mv "$top/@" "$top/$old" - mv "$next" "$top/@" + # Until the factory root is at @ the reset is not committed: cleanup has the + # platform's boot package roll its boot state back to the old root and + # revokes the throwaway slot, so the old root goes back to @ first. + if ! mv "$next" "$top/@"; then + if mv "$top/$old" "$top/@"; then + [[ $RESET_BOOT == "platform" ]] || + fail "could not activate the factory system; the current root is back at @, but its boot files were already rebuilt from the factory system: run 'sudo limine-update' before rebooting (see $LOG_FILE)" + fail "could not activate the factory system; the current root is back at @ (see $LOG_FILE)" + fi + # Neither root is at @. Keep the staged one as well for recovery. + swap_done=1 + fail "could not activate the factory system or put the current root back; from a live USB, mount the Btrfs top level, rename $old to @, and rebuild its boot files if it does not boot" + fi + # From here the old root is gone, so the reset's unlock must survive. + reset_committed=1 swap_done=1 sync + + # The boot package that prepared the reset gets the key on standard input, + # never in arguments. A failure costs the first boot one prompt for the + # current disk password. + if [[ ${RESET_BOOT_PREPARED:-0} == 1 ]] && ! reset_boot_op reset-commit < <(printf '%s' "${RESET_THROWAWAY:-}"); then + log "$RESET_BOOT_ERROR" + fi } # A reset is a clone of @factory, so a machine without one has nothing to @@ -457,6 +612,10 @@ main() { require_factory_snapshot + # Decided before anything is asked: a platform whose boot package is missing + # stops here, with the dispatcher's error naming the package. + reset_boot_owner || fail "this machine's boot package cannot factory-reset it" + confirm_reset # The running system's limine-snapper-sync must not rewrite the ESP's diff --git a/bin/omarchy-system-lid-close b/bin/omarchy-system-lid-close index eb3dc097..45283143 100755 --- a/bin/omarchy-system-lid-close +++ b/bin/omarchy-system-lid-close @@ -11,8 +11,9 @@ # omarchy-system-sleep-lock then usually finds the session already secure. # # A docked lid close does not suspend (HandleLidSwitchDocked defaults to -# ignore), so it must not lock either: that is clamshell mode, still in use on -# the external display. +# ignore, and omarchy-system-lid-inhibit covers displays logind does not count), +# so it must not lock either: that is clamshell mode, still in use on the +# external display. if omarchy-hw-laptop-closed && ! omarchy-hw-external-monitors; then omarchy-system-lock >/dev/null 2>&1 || true fi diff --git a/bin/omarchy-system-lid-inhibit b/bin/omarchy-system-lid-inhibit new file mode 100755 index 00000000..2dbd496f --- /dev/null +++ b/bin/omarchy-system-lid-inhibit @@ -0,0 +1,100 @@ +#!/bin/bash + +# omarchy:summary=Keep logind from suspending on lid close while a display it misses is in use +# omarchy:group=system +# omarchy:hidden=true + +# systemd-logind treats a closed lid as docked only when an external display is +# on a connector type it lists (HDMI, DisplayPort, DVI, VGA...), and USB-C +# displays on Apple Silicon arrive on "USB" connectors instead. Left alone, +# logind suspends a laptop still in use on one. Holding its handle-lid-switch +# inhibitor while such a display is in use leaves the lid to Omarchy's +# clamshell handling, as HandleLidSwitchDocked=ignore would. Once released, a +# lid that is still shut suspends at once: logind rechecks a closed lid after +# every event. + +UNIT="omarchy-lid-inhibit.service" +drm_path="${OMARCHY_DRM_PATH:-/sys/class/drm}" +cgroup_path="${OMARCHY_CGROUP_PATH:-/proc/self/cgroup}" + +# Connected connectors of the external types logind does not count, by the name +# Hyprland gives them (card2-USB-2 is USB-2). DVI-A and Composite are on its +# list, but a missing comma there fuses them into one entry. +uncounted_outputs() { + local status connector + + for status in "$drm_path"/card*-*/status; do + [[ -r $status ]] || continue + connector=${status%/status} + connector=${connector##*/} + + case $connector in + card*-USB-* | card*-DVI-A-* | card*-Composite-*) ;; + *) continue ;; + esac + + [[ $(<"$status") == "connected" ]] && printf '%s\n' "${connector#card*-}" + done +} + +held() { + systemctl --user is-active --quiet "$UNIT" +} + +in_use=0 +outputs=$(uncounted_outputs) + +# In use means enabled in Hyprland: a display switched off in the monitor config +# should not keep a closed laptop awake, while one blanked by DPMS still should, +# and the connector's own "enabled" flag cannot tell the two apart on every +# driver. A compositor that does not answer, as while it reconfigures outputs +# around a lid close, changes nothing: releasing then would suspend a closed lid. +if [[ -n $outputs ]]; then + monitors=$(hyprctl monitors all -j 2>/dev/null) || exit 0 + [[ -n $monitors ]] || exit 0 + jq -e --arg outputs "$outputs" \ + '($outputs | split("\n")) as $names | any(.[]; .disabled == false and (.name | IN($names[])))' \ + <<<"$monitors" >/dev/null 2>&1 + case $? in + 0) in_use=1 ;; + 1) ;; + *) exit 0 ;; + esac +fi + +# Only where logind would ignore a docked lid is there anything to stand in for. +# An unanswered query changes nothing, for the same reason as above. +wanted=0 +if (( in_use )) && omarchy-hw-laptop; then + docked_action=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager HandleLidSwitchDocked 2>/dev/null) || exit 0 + [[ -n $docked_action ]] || exit 0 + [[ $docked_action == 's "ignore"' ]] && wanted=1 +fi + +# Bound to the user unit of the watcher that keeps it in step, so a watcher that +# is gone cannot leave the lid inhibited until logout. systemd-run unquotes +# property values, so the backslashes in an escaped unit name are doubled. +watcher_binding() { + local cgroup="" unit + + read -r cgroup 2>/dev/null <"$cgroup_path" + unit=${cgroup##*/} + if [[ $cgroup == */user@*.service/* && ( $unit == *.scope || $unit == *.service ) ]]; then + unit=${unit//\\/\\\\} + printf '%s\n' "--property=BindsTo=$unit" "--property=After=$unit" + fi +} + +if (( wanted )); then + if ! held; then + mapfile -t bind < <(watcher_binding) + systemd-run --user --quiet --collect --unit="$UNIT" \ + --description="Omarchy clamshell lid inhibitor" \ + --property=PartOf=graphical-session.target "${bind[@]}" \ + systemd-inhibit --no-ask-password --what=handle-lid-switch --mode=block \ + --who=Omarchy --why="External display in use" sleep infinity + fi +elif held; then + systemctl --user stop "$UNIT" +fi diff --git a/bin/omarchy-update b/bin/omarchy-update index edb6410f..9f32abdd 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -148,6 +148,15 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$PATH" OMARCHY_SUDO_NO_UPDATE=1 omarchy-update-aur-pkgs omarchy_security_revoke_sudo_timestamp + # The last sudo-capable step: the boot files every package step left, AUR + # packages' initramfs rebuilds included, must be shown to boot the updated + # system. It follows third-party build code, so it starts cold and, like AUR, + # authorizes one command through the no-update wrapper without a reusable + # timestamp. Until it passes, the update neither finishes nor offers a reboot. + boot_verified=1 + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$PATH" OMARCHY_SUDO_NO_UPDATE=1 omarchy-update-boot || boot_verified=0 + omarchy_security_revoke_sudo_timestamp + # The sleep inhibitor covers AUR builds, hooks and mise as well; releasing it # needs no privilege because the held command already dropped to this user. # Release it before offering a reboot: a confirmed reboot can terminate this @@ -156,5 +165,10 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake stop update_stay_awake_stopped=1 - "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only + if (( boot_verified )); then + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only + else + echo -e "\e[31m\nThe update is not finished: its boot files did not pass verification (see above). Do not reboot until omarchy update completes.\e[0m" >&2 + exit 1 + fi fi diff --git a/bin/omarchy-update-boot b/bin/omarchy-update-boot new file mode 100755 index 00000000..2c3487c7 --- /dev/null +++ b/bin/omarchy-update-boot @@ -0,0 +1,39 @@ +#!/bin/bash + +# omarchy:summary=Verify the platform's boot chain after the update's package changes +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Proves that the boot files the package steps left boot the updated system; +# omarchy update neither finishes nor offers a reboot until this passes. It is +# the update-verify lifecycle dispatch operation: a no-op where the platform +# registers no boot package, x86 included, and the platform's own entrypoint +# says what failed. + +set -e + +if (( $# )); then + echo "Usage: omarchy-update-boot" >&2 + exit 2 +fi +operation=update-verify + +# Resolving as the user keeps sudo, and its password prompt, out of updates +# with nothing to run. Root resolves again from its own fixed paths before +# anything runs. +errors=$(mktemp) +trap 'rm -f "$errors"' EXIT +status=0 +resolved=$(omarchy-lifecycle-dispatch --resolve "$operation" 2>"$errors") || status=$? + +# 3: the platform's boot package is not installed at all. Such a machine +# predates it and boots a chain the package does not manage, so verification +# warns instead of failing every update until the package is installed. +if (( status == 3 )); then + echo -e "\e[33m\n$(sed 's/^Error: //' "$errors")\nThe boot files were not verified. This machine predates its platform's boot package; updates verify them once it is installed.\e[0m" >&2 +elif (( status != 0 )); then + cat "$errors" >&2 + exit "$status" +elif [[ -n $resolved ]]; then + sudo omarchy-lifecycle-dispatch "$operation" +fi diff --git a/bin/omarchy-update-keyring b/bin/omarchy-update-keyring index 3a1c1c17..55d49e9b 100755 --- a/bin/omarchy-update-keyring +++ b/bin/omarchy-update-keyring @@ -22,8 +22,32 @@ fi # Ensure we have the latest archlinux-keyring, maintainer keys might have changed # Always reinstall, as the keyring can be updated without a package version bump. +# An aarch64 machine's Arch Linux ARM repositories are signed with keys of +# their own, so its keyring is kept current the same way. +keyrings=(archlinux-keyring) +if omarchy-hw-aarch64 && omarchy-pkg-present archlinuxarm-keyring; then + keyrings+=(archlinuxarm-keyring) +fi +# A platform package names the signing keyrings of the repositories it adds, +# one package per line. Only installed ones are refreshed, so this adds no +# repository the machine didn't already trust; a refreshed keyring may carry +# that repository's new keys. A line that isn't one keyring package name is +# skipped with a warning, so a mistake in the list can't stop every update. +platform_keyrings=/usr/share/omarchy-platform/keyrings +if [[ -f $platform_keyrings ]]; then + while read -r keyring extra || [[ -n $keyring ]]; do + [[ -n $keyring && $keyring != \#* ]] || continue + if [[ -n $extra || ! $keyring =~ ^[a-z0-9][a-z0-9@._+-]*-keyring$ ]]; then + echo "Warning: skipping ${keyring}${extra:+ $extra} in $platform_keyrings: not a keyring package name" >&2 + continue + fi + if omarchy-pkg-present "$keyring" && [[ " ${keyrings[*]} " != *" $keyring "* ]]; then + keyrings+=("$keyring") + fi + done <"$platform_keyrings" +fi echo -e "\e[32m\nUpdate Arch signing keys\e[0m" -sudo pacman -Sy --noconfirm archlinux-keyring >/dev/null 2> >(grep -vE '^warning: archlinux-keyring-[^ ]+ is up to date -- reinstalling$' >&2) +sudo pacman -Sy --noconfirm "${keyrings[@]}" >/dev/null 2> >(grep -vE '^warning: [a-z0-9@._+-]+-keyring-[^ ]+ is up to date -- reinstalling$' >&2) # Say "correct" only once the key verifiably is: before the failure checks # above, a failed recv or reinstall still ended here with exit 0. diff --git a/bin/omarchy-update-system-pkgs-when-conflicted b/bin/omarchy-update-system-pkgs-when-conflicted index 58a3a0c0..37d39556 100755 --- a/bin/omarchy-update-system-pkgs-when-conflicted +++ b/bin/omarchy-update-system-pkgs-when-conflicted @@ -98,6 +98,17 @@ mapfile -t leftovers < <( # the moved files inactive for nothing. ((${#leftovers[@]} == $(grep -c ' exists in filesystem' "$errors"))) || exit 1 +# A platform whose boot package owns the boot chain may also have files no +# package owns that it can't do without (written by its image or installer). +# Its boot package vouches for the takeover or refuses it, and the files stay. +# Where the platform registers none, nothing resolves and nothing asks for root. +takeover=$(omarchy-lifecycle-dispatch --resolve update-takeover) || exit 1 +if [[ -n $takeover ]] && ! sudo omarchy-lifecycle-dispatch update-takeover "${leftovers[@]}"; then + echo -e "\e[33m\nThis platform keeps these files, so the upgrade can't take them over:\e[0m" >&2 + printf ' %s\n' "${leftovers[@]}" >&2 + exit 1 +fi + echo -e "\e[33m\nTaking over files pacman doesn't own yet:\e[0m" for path in "${leftovers[@]}"; do sudo mkdir -p "$replaced${path%/*}" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 9f4a8ae7..7b1caa44 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -807,6 +807,11 @@ install_omarchy_quattro_packages() { if [[ -f /usr/share/omarchy/install/omarchy-base.packages ]]; then log "Installing Omarchy quattro default package set" mapfile -t base_packages < <(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' /usr/share/omarchy/install/omarchy-base.packages) + # Some base packages have no aarch64 build. + if [[ $(uname -m) != "x86_64" && -f /usr/share/omarchy/install/omarchy-x86_64-only.packages ]]; then + mapfile -t base_packages < <(printf '%s\n' "${base_packages[@]}" | + grep -vxF -f <(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' /usr/share/omarchy/install/omarchy-x86_64-only.packages)) + fi if (( ${#base_packages[@]} )); then as_root pacman -S --needed --noconfirm --ask 4 --overwrite='*' "${base_packages[@]}" mark_packages_explicit "${base_packages[@]}" diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 2fdd4cbe..deaa9952 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -694,6 +694,22 @@ usb_authorization_sync_and_verify() ( usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" ) +# A Mac's boot chain belongs to its boot package, so boot-time denial is not +# managed here. Disabling what is not configured succeeds, so turning USB +# authorization off still works; anything configured by another route is left +# to the owner, since this cannot rebuild a Mac's boot files. +usb_authorization_apple_boot() { + if [[ ${1:-} == "enable" ]]; then + echo "USB authorization from boot is not available on Apple Silicon yet." >&2 + return 2 + fi + if [[ -e $drop_in ]] || grep -Fqx "$snapshot_begin" "$limine_defaults" 2>/dev/null; then + echo "Boot-time USB authorization is configured in $drop_in or $limine_defaults, which this command cannot remove on Apple Silicon." >&2 + echo "Remove it by hand: delete $drop_in and the lines from '$snapshot_begin' to '$snapshot_end' in $limine_defaults, then rebuild the boot files." >&2 + return 1 + fi +} + usb_authorization_main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary @@ -791,7 +807,9 @@ main() { fi case "${1:-}" in enable|disable) - if usb_authorization_secure_boot_enabled; then + if /usr/bin/omarchy-hw-aarch64-apple; then + usb_authorization_apple_boot "$1" + elif usb_authorization_secure_boot_enabled; then usb_authorization_boot_transaction "$1" else usb_authorization_main "$1" diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 96a8d575..07f60d7f 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -4,6 +4,12 @@ # omarchy:args= [options] # omarchy:requires-sudo=true +# The VM is an x86_64 Windows guest under KVM, which only an x86_64 host runs. +if ! omarchy-hw-x86; then + echo "Windows VM needs an x86_64 machine (it runs an x86_64 guest under KVM)." >&2 + exit 1 +fi + # The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so # it needs the root-owned Docker daemon. By default the user is NOT in the docker # group (that group is root-equivalent), so Docker access is gated behind a diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua index aa3e2c69..55b97c2d 100644 --- a/default/hypr/bindings/dictation.lua +++ b/default/hypr/bindings/dictation.lua @@ -1,4 +1,12 @@ -if o.shell_succeeds("omarchy-default-dictation") then +-- Bound only once the chosen backend's own program is installed (Omarchy ships +-- every adapter): an aarch64 machine has no Superwhisper build, and +-- push-to-talk would hold Right Alt (AltGr) for nothing. +local pipe = io.popen("omarchy-default-dictation 2>/dev/null", "r") +local backend = pipe and pipe:read("*l") +if pipe then + pipe:close() +end +if backend and backend:match("^[%w%-]+$") and not o.cmd_missing(backend) then o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") o.bind_hold("F9", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop") -- A modifier's mask changes between its press and release. Match the keysym diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 6ae4ef50..5594bda8 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -34,6 +34,9 @@ o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monit o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle") o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true }) o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) +-- Apple Silicon reports its lid through the SMC, under its own switch name. +o.bind("switch:on:Apple SMC power/lid events", nil, "omarchy-system-lid-close", { locked = true }) +o.bind("switch:off:Apple SMC power/lid events", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot") o.bind("ALT + PRINT", "Screenrecording", "omarchy-capture-screenrecording --stop-recording || omarchy-menu toggle trigger.capture.screenrecord") diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 4e6d03ca..fcda7c86 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -57,7 +57,7 @@ "trigger.reminder": {"icon":"󰢌","label":"Reminder","aliases":["reminder"]}, "trigger.capture": {"icon":"","label":"Capture","aliases":["capture","screenshot","screenrecord","screen-record","screenrecording"]}, "trigger.capture.screenshot": {"icon":"","label":"Screenshot","action":"omarchy-capture-screenshot"}, - "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","when":"pgrep -f '^gpu-screen-recorder'","action":"omarchy-capture-screenrecording --stop-recording"}, + "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","when":"omarchy-capture-screenrecording --status","action":"omarchy-capture-screenrecording --stop-recording"}, "trigger.capture.screenrecord": {"icon":"","label":"Screenrecord"}, "trigger.capture.text": {"icon":"󰴑","label":"Text","action":"omarchy-capture-text"}, "trigger.capture.qr": {"icon":"󰐲","label":"QR Code","action":"omarchy-capture-qr"}, @@ -160,7 +160,7 @@ "setup.default.browser.firefox": {"icon":"","label":"Firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, "setup.default.dictation": {"icon":"","label":"Dictation","title":"Default Dictation"}, - "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, + "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","when":"omarchy-hw-x86","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, "setup.default.dictation.voxtype": {"icon":"","label":"Voxtype","checked":"[[ \"$(omarchy-default-dictation)\" == \"voxtype\" ]]","action":"omarchy-default-dictation voxtype"}, "setup.default.terminal": {"icon":"","label":"Terminal","title":"Default Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, @@ -189,7 +189,7 @@ "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, - "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, + "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, "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.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, @@ -197,7 +197,7 @@ "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"}, - "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, + "setup.direct-boot": {"icon":"","label":"Direct Boot","when":"! omarchy-hw-aarch64-apple","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, "setup.reset": {"icon":"󰑓","label":"Reset Computer","when":"[[ $(findmnt -no FSTYPE /) == btrfs ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-system-factory-reset"}, // Install @@ -221,18 +221,18 @@ "install.webapp": {"icon":"","label":"Web App","action":"omarchy-launch-floating-terminal-with-presentation omarchy-webapp-install"}, "install.terminal": {"icon":"","label":"Terminal"}, "install.tui": {"icon":"","label":"TUI","action":"omarchy-launch-floating-terminal-with-presentation omarchy-tui-install"}, - "install.windows": {"icon":"󰍲","label":"Windows","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, + "install.windows": {"icon":"󰍲","label":"Windows","when":"omarchy-hw-x86","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, "install.aur": {"icon":"󰣇","label":"AUR","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-aur-install"}, "install.preinstalls": {"icon":"󰏓","label":"Preinstalls","disabled":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"}, "install.browser.chrome": {"icon":"","label":"Chrome","disabled":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"}, - "install.browser.edge": {"icon":"󰇩","label":"Edge","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, + "install.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, "install.browser.brave": {"icon":"","label":"Brave","disabled":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave'"}, "install.browser.brave-origin": {"icon":"","label":"Brave Origin","disabled":"omarchy-pkg-present brave-origin-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave-origin'"}, "install.browser.firefox": {"icon":"","label":"Firefox","disabled":"omarchy-pkg-present firefox","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser firefox'"}, "install.browser.zen": {"icon":"󰖟","label":"Zen","disabled":"omarchy-pkg-present zen-browser-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser zen'"}, "install.service.1password": {"icon":"󰢁","label":"1Password","disabled":"omarchy-pkg-present 1password","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password"}, - "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.dropbox": {"icon":"","label":"Dropbox","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, + "install.service.spotify": {"icon":"󰓇","label":"Spotify","when":"omarchy-hw-x86","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"}, @@ -263,13 +263,13 @@ "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-t3-code"}, "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, - "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, + "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, "install.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","disabled":"flatpak info com.nvidia.geforcenow","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-geforce-now"}, "install.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","disabled":"[[ -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-cloud"}, "install.gaming.xbox-controllers": {"icon":"󰂯","label":"Xbox Controllers","disabled":"omarchy-pkg-present xpadneo-dkms","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-controllers"}, "install.gaming.battlenet": {"icon":"","label":"Battle.net","disabled":"[[ -d $HOME/Games/battlenet ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-battlenet"}, "install.gaming.lutris": {"icon":"","label":"Lutris","disabled":"omarchy-pkg-present lutris","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-lutris"}, - "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","disabled":"omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, + "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, "install.gaming.retro-launcher": {"icon":"󰯉","label":"RetroArch Game Launcher","action":"omarchy-games-retro-install"}, "install.development.rails": {"icon":"󰫏","label":"Ruby on Rails","disabled":"[[ -d $HOME/.local/share/mise/installs/ruby ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ruby'"}, "install.development.docker-dbs": {"icon":"","label":"Docker DB","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-docker-dbs"}, @@ -313,7 +313,7 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, - "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, + "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple && grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, diff --git a/default/pacman/aarch64-apple/mirrorlist-edge b/default/pacman/aarch64-apple/mirrorlist-edge new file mode 100644 index 00000000..74727dba --- /dev/null +++ b/default/pacman/aarch64-apple/mirrorlist-edge @@ -0,0 +1,3 @@ +# Omarchy's mirror of Arch Linux ARM, over HTTPS since the repository databases +# are unsigned. It keeps Arch Linux ARM's $arch/$repo layout. +Server = https://arm-mirror.omarchy.org/$arch/$repo diff --git a/default/pacman/aarch64-apple/pacman-edge.conf b/default/pacman/aarch64-apple/pacman-edge.conf new file mode 100644 index 00000000..2355cd68 --- /dev/null +++ b/default/pacman/aarch64-apple/pacman-edge.conf @@ -0,0 +1,39 @@ +# See the pacman.conf(5) manpage for option and repository directives +# aarch64 has an edge channel alone: Omarchy publishes aarch64 packages on edge +# so far, and stable and rc templates come once a release supports aarch64. + +[options] +Color +ILoveCandy +VerbosePkgLists +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +ParallelDownloads = 5 +DownloadUser = alpm + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# pacman searches repositories in the order defined here. Omarchy comes first on +# Apple Silicon, so its builds for the Mac (the Aurora kernel, m1n1, U-Boot and +# the Hyprland stack) win over the same names in Asahi ALARM and Arch Linux ARM. +[omarchy] +Server = https://pkgs.omarchy.org/edge/$arch + +[asahi-alarm] +Server = https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist + +[aur] +Include = /etc/pacman.d/mirrorlist diff --git a/default/pacman/aarch64/mirrorlist-edge b/default/pacman/aarch64/mirrorlist-edge new file mode 100644 index 00000000..74727dba --- /dev/null +++ b/default/pacman/aarch64/mirrorlist-edge @@ -0,0 +1,3 @@ +# Omarchy's mirror of Arch Linux ARM, over HTTPS since the repository databases +# are unsigned. It keeps Arch Linux ARM's $arch/$repo layout. +Server = https://arm-mirror.omarchy.org/$arch/$repo diff --git a/default/pacman/aarch64/pacman-edge.conf b/default/pacman/aarch64/pacman-edge.conf new file mode 100644 index 00000000..0401bf5e --- /dev/null +++ b/default/pacman/aarch64/pacman-edge.conf @@ -0,0 +1,36 @@ +# See the pacman.conf(5) manpage for option and repository directives +# Arch Linux ARM provides the aarch64 repositories used below. +# aarch64 has an edge channel alone: Omarchy publishes aarch64 packages on edge +# so far, and stable and rc templates come once a release supports aarch64. + +[options] +Color +ILoveCandy +VerbosePkgLists +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +ParallelDownloads = 5 +DownloadUser = alpm + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# pacman searches repositories in the order defined here. Omarchy comes first, +# as on x86_64, so a package it also publishes is installed from Omarchy. +[omarchy] +Server = https://pkgs.omarchy.org/edge/$arch + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist + +[aur] +Include = /etc/pacman.d/mirrorlist diff --git a/default/settings-runtime-profile b/default/settings-runtime-profile new file mode 100644 index 00000000..c175ff2a --- /dev/null +++ b/default/settings-runtime-profile @@ -0,0 +1,27 @@ +# omarchy-settings ships the same files on every architecture when its source +# has this file: the recipe stops leaving drop-ins out of the aarch64 package. +# Each platform-specific file below, by its path in this source, takes its +# decision at runtime instead. +# +# etc/mkinitcpio.conf.d/00-omarchy-hooks.conf +# Sets each platform's HOOKS baseline (omarchy-hw-platform), and leaves alone +# a Mac whose own mkinitcpio.conf carries asahi or busybox encrypt. +# etc/mkinitcpio.conf.d/omarchy_hooks.conf +# Only filters kms for NVIDIA and handles the vconsole hooks, from the +# machine's own devices and configuration. +# etc/mkinitcpio.conf.d/thunderbolt_module.conf +# Adds thunderbolt to MODULES only where modinfo finds it as a loadable module. +# etc/limine-entry-tool.d/, default/limine/ +# Read only where Limine and its entry tool are installed. +# etc/systemd/oomd.conf.d/, default/systemd/user/app.slice.d/10-oomd.conf +# Inert unless systemd-oomd runs (install/config/enable-services.sh, +# migration 1785424256). +# default/systemd/zram-generator.conf.d/90-omarchy.conf, +# etc/sysctl.d/99-omarchy-sysctl.conf, etc/tmpfiles.d/omarchy-zswap.conf +# Swap on zram everywhere: the installer provides zram-generator on x86_64, +# install/omarchy-aarch64.packages on aarch64. +# config/omarchy/defaults/dictation +# Presets Superwhisper, which has no aarch64 build; user setup removes the +# preset there (install/user/dictation-default.sh). +# etc/modprobe.d/omarchy-usb-autosuspend.conf +# Applies where usbcore is a module; a kernel that builds it in ignores it. diff --git a/docs/file-layout.md b/docs/file-layout.md index 4abb6745..9831e349 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -54,6 +54,12 @@ behind, which arms `omarchy-provision-owner.service` (shipped from `setup-form.sh`). On first boot `bin/omarchy-provision-owner` creates the user on tty1 and runs the finalize step itself. +On an encrypted install `omarchy-provision-owner` then re-keys LUKS from the staged install key to the owner's password through `install/provisioning/luks-rekey.sh`, which journals each step (phase and slot numbers, never keys) in `/var/lib/omarchy/provisioning/luks-rekey.state` so an interrupted first boot resumes. Setup finishes only once the staged key opens nothing, and removes the journal with `pending`, so a retry after the staged key is retired must use the password the disk holds. On a platform whose boot package owns the boot chain, the check before the owner form, the boot-time unlock and the record of the kept key slot go through `omarchy-lifecycle-dispatch`. See [lifecycle-dispatch.md](lifecycle-dispatch.md). + +`omarchy-drive-password` reuses those journal helpers when the owner changes the password of the disk holding `/`: it changes the LUKS key and confirms the new key opens the disk and the old one no longer does. The login and root passwords are left alone. `~/.local/state/omarchy/drive-password.state` records the disk's UUID, the key slots and the phase (never a password) until the change is confirmed and its slot recorded, and the next run finishes an interrupted change with whichever of the old or new password the disk opens with. + +Prebuilt images are set up away from the machine they will run on, so their hardware setup waits for that machine. The builder writes a root-owned manifest, `/var/lib/omarchy/image/target` (`format=1`, `platform=`), before `omarchy-apply-system`. While it exists, `omarchy-apply-hardware` runs no hardware leaf: it queues each one in `/var/lib/omarchy/image/deferred-steps` and arms `omarchy-provision-hardware.service` (shipped from `install/provisioning/`). On the machine's first boot, before owner setup and the login screen, `bin/omarchy-provision-hardware` renames the manifest to `target.booted` and runs the queue in order, dropping each step that succeeds. Before the first step it makes the machine's own pacman keyring when the build asked for one (`/var/lib/omarchy/image/pacman-keyring`, which install finalization writes on aarch64 platforms other than Apple Silicon instead of running `pacman-key --init`, so no two machines share a master key). A failed step stays queued for the next boot. It refreshes no package databases: the image carries its sync databases and every package its queued steps install, so the queue runs offline (the image build checks that it carries them; a step whose package is missing stays queued). Once the queue is empty it rebuilds the initramfs if a step changed its inputs (the mkinitcpio configuration, module options in `/etc/modprobe.d/` and `/usr/lib/modprobe.d/`, or the kernel command line in `/etc/default/limine` and `/etc/limine-entry-tool.d/`) or asked for the rebuild (`/var/lib/omarchy/image/boot-rebuild`), and disables the service; the unit file stays, so a start job already queued is skipped on the emptied queue rather than failed. A platform's own first boot may run `omarchy-provision-hardware` itself: runs are serialised, and it exits 75 when a step or the keyring failed and stays queued, and 1 when it refused to run (an untrusted manifest or queue) or the final rebuild failed, which the next run retries. Steps know they run on an image's first boot from `OMARCHY_IMAGE_DEFERRED_HARDWARE=1` (Bluetooth starts its service there, since boot already brought up its target). `install/helpers/image-target.sh` holds the contract. Root always uses these fixed paths; no environment variable turns a live system into an image build. + Current generated theme state lives under `~/.local/state/omarchy/current/`. Keep `~/.config/omarchy/` for files a user may intentionally version in a dotfile manager, such as user themes, hooks, @@ -94,7 +100,7 @@ default/applications/*.desktop applications/icons/* ──► omarchy-settings /usr/share/icons/hicolor/{48,256,scalable}/apps/ etc/** ──► omarchy-settings /etc/** (drop-ins we own outright) - ├─ mkinitcpio.conf.d/{omarchy_hooks,thunderbolt_module}.conf + ├─ mkinitcpio.conf.d/{00-omarchy-hooks,omarchy_hooks,thunderbolt_module}.conf ├─ limine-entry-tool.d/{omarchy-defaults,omarchy-uki}.conf ├─ NetworkManager/, sudoers.d/, sysctl.d/, tmpfiles.d/, │ profile.d/omarchy.sh, … (a summary — `ls etc/` for the full ~17-entry tree) @@ -140,6 +146,48 @@ logo.{txt,svg}, icon.{txt,png} ──► omarchy-settings /usr/share/omarchy The hardware-conditional `force-igpu` and `keyboard-backlight` sources also live under `default/systemd/system-sleep/`, but their setup commands publish root-owned copies only on machines that need them; they are not installed by `omarchy-settings`. +### Platform root + +A platform's runtime package (omarchy-mac on Apple Silicon, say) adds its desktop files under a fixed root that only the one installed platform package owns. Omarchy ships nothing there, and a missing root or file means no platform additions. No environment variable moves it; tests reach fixtures through test-only seams, never through a desktop session's environment. + +``` +/usr/share/omarchy-platform/ + displays.conf backlight and DDC hints for the display commands (below) + key-names " " lines the keybindings menu shows in place of keysyms + display-cutouts.json camera cutouts the top bar keeps out of (see omarchy-shell.md) + keyrings signing keyring packages of the repositories the platform adds, one per line + audio.json audio processing nodes the audio panel and microphone widget leave out (below) +``` + +See [lifecycle-dispatch.md](lifecycle-dispatch.md#platform-files). + +`omarchy-update-keyring` reinstalls each installed package `keyrings` names (`#` comments and blank lines skipped, and a line that isn't a `*-keyring` package name skipped with a warning) with Arch's, before the system upgrade, so a key rotation in the platform's own repository never fails that upgrade's signature checks. On Apple Silicon, omarchy-mac names `asahi-alarm-keyring` there. + +#### Display hints (`displays.conf`) + +`omarchy-hw-display` picks the built-in panel's backlight by name (`gmux_backlight`, then `amdgpu_bl*`, `intel_backlight`, `acpi_video*`, else the first one listed, never a T2 Touch Bar's `appletb_backlight`), and `omarchy-brightness-display` probes every external monitor over DDC. Where a platform's names or display driver differ, its platform package says so in `/usr/share/omarchy-platform/displays.conf`; Omarchy ships none, and without it both behave as above. One directive per line, whitespace-separated; blank lines, whole-line `#` comments, unknown directives, and lines with a wrong word count or a name that is `.`, `..` or has a `/` are ignored: + +- `backlight-skip `: a `/sys/class/backlight` name (a shell glob) that never drives the built-in panel, such as a Touch Bar's. It is never picked, not even when a later directive or the built-in order names it. +- `backlight-prefer `: the built-in panel's backlight, a literal name, tried after `gmux_backlight` and before the GPU and ACPI ones, in file order. +- `ddc-require-connector-ddc`: the display driver registers no DDC channel, so a probe would only walk unrelated I2C buses. An external monitor (other than an Apple Studio or XDR Display, which `omarchy-brightness-display-apple` drives with asdcontrol) is probed only when its DRM connector has a `ddc` node (`/sys/class/drm/card*-/ddc`); otherwise it gets no DDC or backlight control, and the built-in panel is never dimmed in its place. + +No environment variable moves the file: these commands also run under `sudo` and from the brightness keys. Their tests (`test/shell.d/hw-display-test.sh`, `test/shell.d/brightness-display-test.sh`) run a copy rewritten to read a fixture in its place. + +#### Audio hints (`audio.json`) + +The audio panel lists every output, input and playback stream PipeWire has, and the microphone widget counts every recording as the microphone in use (the shell's own level meters never count). A platform whose audio runs through its own processing (DSP filter graphs in front of raw devices, say) has nodes that are neither devices nor apps, and its platform package names them in `/usr/share/omarchy-platform/audio.json`; Omarchy ships none, and without it nothing is left out. + +```json +{ + "hidden": ["", "..."], + "replaced": [{ "node": "", "by": "" }] +} +``` + +A pattern is a JavaScript regular expression matched against a whole `node.name`. A `hidden` node is never listed as an output, an input or an app stream, unless it is the current default output or input (so the panel always shows what's in use), and never lights the microphone widget; a `replaced` node is left out only while a node matching `by` exists (a mono processed microphone behind a stereo copy of it, say). Missing or malformed JSON means no hints, and an invalid pattern or entry is skipped. The shell reads the file at that fixed path, which no environment variable moves (`shell/Commons/AudioNodes.qml`), and `test/shell.d/audio-test.sh` covers the parsing. + +A virtual source (an `Audio/Source/Virtual`, such as EasyEffects' or a platform's microphone mapping) needs no hint: Quickshell leaves it untyped, so the panel and widget set its volume and mute through `wpctl` (`shell/Commons/UntypedInput.qml`, one `pactl subscribe` for the whole shell), shows no level meter for it (Quickshell's peak monitor takes typed nodes only), and the panel lists it because PulseAudio does (`omarchy-audio-sink-availability sources`). + ### Why `etc-overrides/` exists Some files under `/etc/` (`.bashrc` in `/etc/skel`, `nsswitch.conf`, @@ -279,6 +327,9 @@ and/or a working user systemd instance: Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` in the unit files keeps services inert when they don't apply. +- `omarchy-lifecycle-dispatch setup-user` — the platform's own user setup, + now with the session up (a no-op where the platform registers none; see + [lifecycle-dispatch.md](lifecycle-dispatch.md)). - `install/user/first-run/gnome-theme.sh`, `install/user/first-run/gtk-primary-paste.sh` — GNOME/GTK settings that need the dconf daemon. @@ -319,10 +370,26 @@ finalization. It sources: Logging goes to `/var/log/omarchy-install.log` via `install/helpers/logging.sh`. +Platform-specific setup asks `omarchy-hw-platform`, which prints `x86`, `aarch64` or `aarch64-apple` (the naming standard is in AGENTS.md under Platforms). It reads the vendor prefix of each token in the device tree's root `compatible` (`apple,` or `qcom,`, from `/proc/device-tree` or `/sys/firmware/devicetree/base`) and the CPU architecture, and fails when they contradict each other. A Snapdragon laptop's `qcom,` tree is `aarch64`. A manifest written by an older image builder may still say `apple-silicon`, `generic-aarch64` or `generic`, which read as `aarch64-apple`, `aarch64` and `x86`. + +An image built away from the machine it will run on names its target in a root-owned manifest, `/var/lib/omarchy/image/target` (`format=1`, `platform=`, unknown keys ignored). While the root is being built rather than booted, the detector answers from the manifest and never reads the build host's device tree. The root counts as built when it shows it: no `/run/systemd/system`, PID 1's root is another one (a chroot), or PID 1 is not systemd (a PID namespace). A booted system always answers from its hardware, even with a manifest left behind, so no unit that asks the detector may use `PrivatePIDs=`. As root the detector restarts in an empty environment and ignores the fixture variables its tests use. + The package lists the ISO pacstraps live at `install/omarchy-base.packages` and `install/omarchy-other.packages`; the ISO builder also reads them when constructing its offline mirror. +A platform's default package set is the base list, then its architecture's additions, then its own: `install/omarchy-aarch64.packages` on every aarch64 platform, then `install/omarchy-.packages` when that platform has one (`install/omarchy-aarch64-apple.packages` on Apple Silicon). x86 installs the base list alone. `omarchy-pkg-defaults [platform]` prints the composed set, for the running machine by default (via `omarchy-hw-platform`, so an image build gets its target's set), and `omarchy-reinstall-pkgs` installs it. + +## Platform display hints (`/usr/share/omarchy-platform/displays.conf`) + +`omarchy-hw-display` picks the built-in panel's backlight by name (`gmux_backlight`, then `amdgpu_bl*`, `intel_backlight`, `acpi_video*`, else the first one listed, never a T2 Touch Bar's `appletb_backlight`), and `omarchy-brightness-display` probes every external monitor over DDC. Where a platform's names or display driver differ, its platform package says so in `/usr/share/omarchy-platform/displays.conf`; Omarchy ships none, and without it both behave as above. One directive per line, whitespace-separated; blank lines, whole-line `#` comments, unknown directives, and lines with a wrong word count or a name that is `.`, `..` or has a `/` are ignored: + +- `backlight-skip `: a `/sys/class/backlight` name (a shell glob) that never drives the built-in panel, such as a Touch Bar's. It is never picked, not even when a later directive or the built-in order names it. +- `backlight-prefer `: the built-in panel's backlight, a literal name, tried after `gmux_backlight` and before the GPU and ACPI ones, in file order. +- `ddc-require-connector-ddc`: the display driver registers no DDC channel, so a probe would only walk unrelated I2C buses. An external monitor (other than an Apple Studio or XDR Display, which `omarchy-brightness-display-apple` drives with asdcontrol) is probed only when its DRM connector has a `ddc` node (`/sys/class/drm/card*-/ddc`); otherwise it gets no DDC or backlight control, and the built-in panel is never dimmed in its place. + +No environment variable moves the file: these commands also run under `sudo` and from the brightness keys. Their tests (`test/shell.d/hw-display-test.sh`, `test/shell.d/brightness-display-test.sh`) run a copy rewritten to read a fixture in its place. + ## Explicit resync (`omarchy-reinstall-configs`) When an existing user wants to reset to shipped defaults: diff --git a/docs/lifecycle-dispatch.md b/docs/lifecycle-dispatch.md new file mode 100644 index 00000000..4e458644 --- /dev/null +++ b/docs/lifecycle-dispatch.md @@ -0,0 +1,164 @@ +# Lifecycle dispatch + +Omarchy owns the boot lifecycle flows: the owner wizard, account creation, LUKS discovery, retry journals, snapshots, the migration runner and the update flow. Some platforms boot through a chain those flows can't drive generically. Apple Silicon Macs boot m1n1 → U-Boot → Limine, keep the install key on an ext4 boot partition and name it on the kernel command line. For those platforms, the flows call a small fixed set of operations through `bin/omarchy-lifecycle-dispatch`, and a platform boot package implements them as root-owned entrypoints. System and user setup, and the installers of apps a platform needs to adjust, call a few more, which a platform's runtime package implements, so its own setup runs from Omarchy's without an inline platform branch. Every other platform keeps the generic path, and each dispatch call is a no-op there. + +## The command + +``` +omarchy-lifecycle-dispatch [arguments...] +omarchy-lifecycle-dispatch --resolve +``` + +The first form runs the operation. `--resolve` prints the entrypoint the operation would run, or nothing when the operation is a no-op on this machine. A caller uses it when a platform implementation replaces a generic step (see [Callers](#callers)). + +| Situation | Run | `--resolve` | +| --- | --- | --- | +| The platform registers no package (`x86` and `aarch64` today) | no-op, exit 0 | prints nothing, exit 0 | +| The entrypoint exists and passes the trust rules | execs it; its exit status is the result | prints its path | +| A required operation has no entrypoint, and the package is not installed | exit 3 (an entrypoint's own status could also be 3; with `--resolve` it is only this): `Error: on needs , which provides ; it is not installed` | same error | +| A required operation has no entrypoint, but the package is installed (its pacman record says so) | exit 1: `Error: on needs , which does not provide; update ` | same error | +| An optional operation has no entrypoint (every setup and app-install operation is optional, so none of them ever fails for a missing package) | no-op, exit 0 | prints nothing, exit 0 | +| A user operation (`setup-user`, `post-install`, `pre-remove`) is run as root on a platform that registers it (Apple Silicon), its entrypoint installed or not | exit 1: `Error: runs as the user, never as root` | same error | +| The entrypoint fails the trust rules | exit 1: `Error: refusing : ...` (optional operations too) | same error | +| `omarchy-hw-platform` can't settle the platform | exit 1 | exit 1 | +| No operation, or one outside the fixed set | exit 2 with usage | exit 2 | + +## Operations + +The set is fixed in the dispatcher; adding one is a change to Omarchy. Every operation runs as root except `setup-user`, `post-install` and `pre-remove`, which run as the user. The `provision-*` operations take no arguments and work on fixed paths: `/var/lib/omarchy/provisioning` holds the staged install key (`luks-key`) and the re-key journal (`luks-rekey.state`). `luks-slots` takes the slot numbers it records. Factory reset names the factory root it is about to activate, since that is not `/` yet, and hands `reset-commit` the throwaway key on standard input, never in arguments. + +| Operation | Called | Contract | Apple | Caller | +| --- | --- | --- | --- | --- | +| `provision-prepare` | Owner provisioning, at the start of every setup attempt, before the owner is asked anything | Succeeds when the platform can finish setup on this machine. On failure its stderr is shown on tty1 and logged, and the attempt fails into the usual retry screen. It must leave nothing a retry can't repeat. | required | `omarchy-provision-owner` | +| `provision-commit` | Owner provisioning, during the LUKS re-key: after the owner's key is added, before any other slot is retired | Removes every boot-time copy of the staged key and its unlock configuration from the platform's boot chain, and rebuilds the boot files so the next boot asks for the password. Idempotent. If it fails, it leaves or restores a boot chain that still unlocks unattended with the staged key, so the retry boots. | required | `omarchy-provision-owner` | +| `provision-verify` | Owner provisioning, whenever it asks whether the staged unlock remains: before the re-key, during it, and before setup drops `pending` | Read-only. Exits 0 only when the boot chain holds no staged key or unlock configuration. Any other status counts as "remains", so setup never finishes. | required | `omarchy-provision-owner` | +| `reset-prepare []` | Factory reset, once the factory root is cloned and scrubbed, before switching to it. The device is there only when the root is encrypted. | Stages the platform's boot state for the factory root: the unlock's command line, the rebuilt boot files, and encryption state reopened for the next owner. It keeps what `reset-rollback` restores, and writes no key. | required | `omarchy-system-factory-reset` | +| `reset-verify ` | Factory reset, right after `reset-prepare` and before the throwaway slot is added and the switch committed | Read-only. Proves the rebuilt boot files boot the factory root on this boot chain. A failure rolls the reset back. | required | `omarchy-system-factory-reset` | +| `reset-commit` (key on standard input) | Factory reset, once the factory root is the active root | Puts the throwaway key where the staged unlock reads it, so the first boot unlocks unattended until owner setup re-keys, and drops what `reset-rollback` would restore. A failure is logged, not fatal: that boot asks for the current disk password once. | required | `omarchy-system-factory-reset` | +| `reset-rollback` | Factory reset, when anything fails after `reset-prepare` started and before the factory root is active, `reset-prepare`'s own failure included | Restores the previous boot state. Nothing to restore is a success. | required | `omarchy-system-factory-reset` | +| `update-verify` | Update, after the last package step: the transaction, migrations, orphan removal and AUR packages | Read-only. Verifies the boot chain boots the updated system, whose new kernel may still wait for its reboot. A failure leaves the update unfinished: it exits non-zero and offers no reboot. | required | `omarchy-update-boot` (`omarchy update`) | +| `update-takeover ...` | Update, when an Omarchy package's upgrade stopped only on files no package owns, before they are moved aside for the retry | Read-only. Gets those paths. Exit 0 lets the takeover go ahead; any other status keeps every file where it is, and the update fails as it would have without the retry. A platform whose boot chain or image writes files it can't do without refuses those. | required | `omarchy-update-system-pkgs-when-conflicted` (`omarchy update`) | +| `luks-slots` | Owner provisioning, once the re-key keeps only the owner's slot, before it destroys the staged key; the disk password change, with `--owner` before anything changes on the system disk, then once the owner's new key is confirmed | `luks-slots owner= [recovery=]` records the root volume's kept slots wherever the platform's boot checks look for them. Owner setup passes an empty `recovery=`, which records none. Omarchy creates no recovery key; `recovery=` exists only for a slot an earlier Mac setup recorded, which stays when the argument is left out. `luks-slots --owner` changes nothing: it exits 0 printing the recorded owner slot when that slot is in the LUKS header; 4 with `No owner key slot is recorded` on stderr when there is no owner record at all (a Mac set up before its boot package recorded slots); and 1 with the reason on stderr for anything else: a record that isn't a slot number or names a slot the header lacks, an unreadable header, or a boot package too old to know `--owner`. Idempotent. It fails when a slot is not in the LUKS header, and the caller then retries. | required | `omarchy-provision-owner`, `omarchy-drive-password` | +| `setup-boot [image-first-boot]` | Hardware setup's last leaf (`install/hardware/platform-setup.sh`), before `setup-system`, on every install and rerun of `omarchy-apply-hardware`, and on an image's first boot with `image-first-boot` | The platform boot package's own boot setup (on a Mac, the GRUB console settings and the Limine activation). Idempotent. With `image-first-boot` it asks for the boot-file rebuild by creating `/var/lib/omarchy/image/boot-rebuild` where it would build one. A failure fails the leaf, and `setup-system` does not run. | required | `install/hardware/platform-setup.sh` | +| `setup-system [image-first-boot]` | Hardware setup, as its last leaf (`install/hardware/platform-setup.sh`), after `setup-boot`, on every install and every rerun of `omarchy-apply-hardware`. In an image build it is queued with the other leaves and runs on the machine's first boot, with `image-first-boot`. | The platform's machine-wide setup: services, configuration and drivers its runtime package owns. Idempotent. Needs no network with `image-first-boot` (that boot may be offline), and there it asks for a boot-file rebuild by creating `/var/lib/omarchy/image/boot-rebuild` rather than building one, since the deferred setup rebuilds once after its last step. A failure fails the leaf, and on an image's first boot the step stays queued for the next boot. | optional | `install/hardware/platform-setup.sh` | +| `setup-user` | Runs as the user being set up: at the end of user finalization (`install/user/platform-setup.sh`, from `install/user/all.sh`), and in first run once the session is up. Finalization inside an image build skips it; first run on the machine runs it. | The platform's per-user setup. Idempotent. Finalization may have no session (the ISO chroot, owner setup), so work that needs one (a user unit started now, a D-Bus call) is left for the first-run call, and its absence is not a failure. A failure fails finalization or the first-run step, so first run stays pending and runs it again at the next login. | optional | `install/user/platform-setup.sh`, `omarchy-provision-first-run` | +| `post-install ` | Runs as the user, from an installer once `` is installed and Omarchy's own setup of it is done: `chromium`, `chrome`, `edge`, `brave`, `brave-origin`, `firefox`, `zen` (`omarchy-install-browser`) and `steam` (`omarchy-install-gaming-steam`) | The platform's adjustments for that app, such as its browser flags or a launcher the app needs on this hardware (which may install a package through `sudo`). Idempotent. A failure fails the install. | optional | `omarchy-install-browser`, `omarchy-install-gaming-steam` | +| `pre-remove ` | Runs as the user, from a remover before ``'s packages go: `steam` (`omarchy-remove-gaming-steam`) | Undoes what `post-install ` added, packages that depend on the app first. Idempotent; nothing to undo is a success. A failure stops the removal before anything is removed. | optional | `omarchy-remove-gaming-steam` | + +Snapshot restore is not an operation: a Limine machine restores through `limine-snapper-restore`, and a platform boot package that checks a restore does so through limine-snapper-sync's own hooks. + +## Platform registration + +Registration is code in `bin/omarchy-lifecycle-dispatch`, not configuration. No file, environment variable or `PATH` entry decides what runs as root. + +| Platform | Implementation directory | Package | Required operations | +| --- | --- | --- | --- | +| `aarch64-apple` | `/usr/lib/omarchy/mac-boot` | `omarchy-mac-boot` | all its operations | +| `aarch64-apple`: `setup-system`, `setup-user`, `post-install`, `pre-remove` | `/usr/lib/omarchy/mac` | `omarchy-mac` | none | +| `x86`, `aarch64` | none | none | none: every operation is a no-op, and callers keep their generic path | + +The entrypoint for an operation is `/`. A registered platform's required operations must be shipped. Its optional operations may be left out, and then they are no-ops. + +On a Mac, owner provisioning and factory reset have no other path, so their operations are required: a Mac without `omarchy-mac-boot`'s entrypoints stops before the owner form or before the reset is confirmed, with the dispatcher's error naming the package, where the generic Limine path would leave the boot-partition key behind or rebuild a UKI the Mac does not boot. `luks-slots` is required because the Mac's boot checks prove the disk's key slots. `update-verify` is required because nothing else checks what an update left in a Mac's boot chain. `update-takeover` is required because a Mac's boot chain and image write files no package owns, so a Mac whose boot package can't vouch for a takeover keeps its files and the update stops at the conflict. `setup-boot` is required because the Mac's boot setup has no other path in hardware setup: a Mac whose `omarchy-mac-boot` lacks it stops the hardware leaf, asking for the update, where an optional operation would skip the Limine activation. System and user setup and the app-install hooks belong to the Mac's runtime package, `omarchy-mac`, not its boot package, and are optional: a Mac without `omarchy-mac` gets Omarchy's generic setup and installs. + +## Trust rules + +- The dispatcher runs as `bash -p`, so a root caller's `BASH_ENV` and exported functions run nothing in it, and it refuses an ordinary Bash launch. As root it uses a fixed `PATH`, runs the detector installed beside it with nothing in its environment but that `PATH` (the detector reads only the live device tree as root), and resolves only the fixed implementation directory. +- An entrypoint runs only if it is a regular executable file. Neither the file nor any directory up to `/` may be a symlink, and all of them must be owned by root and not writable by group or others. An entrypoint that fails these rules is refused, even for an optional operation. +- The entrypoint runs with an empty environment apart from `PATH=/usr/local/sbin:/usr/local/bin:/usr/bin`. It gets the caller's arguments, standard streams and working directory. Entrypoints use fixed paths, never environment variables. Anything that can also run them directly re-checks the platform itself. +- `setup-user`, `post-install` and `pre-remove` refuse root where the platform registers them, so they never run as anyone but their caller. Where it registers none they stay no-ops for root too, so an installer run with `sudo` still finishes there. Their entrypoints follow the same trust rules, and besides `PATH` they get only these, when the caller has them set: `HOME`, `USER`, `XDG_CONFIG_HOME` and `XDG_STATE_HOME` (where the user's configuration and setup markers live), `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, `WAYLAND_DISPLAY` and `OMARCHY_PATH`. +- For unprivileged tests, `OMARCHY_LIFECYCLE_ROOT` (absolute) prefixes the implementation directory, and the detector's fixture roots apply. Inside that root, files the caller owns count as root's. Root ignores both. + +## Callers + +A dispatch point takes one of two shapes: + +- **A platform step with no generic counterpart:** `omarchy-lifecycle-dispatch || fail`. It is a no-op on platforms without a boot package. +- **A platform implementation that replaces a generic step:** `--resolve `. A path means dispatch the operation, an empty result means run the generic step, and a failure fails closed. + +Failing closed holds on every platform: where `omarchy-hw-platform` can't settle the platform (contradicting device-tree evidence, for one), first-boot setup and factory reset stop instead of guessing a boot path, and an update fails its verification without the reboot prompt. There is no check before an update's packages change: on a machine whose platform can't be told, the update installs its packages first, and it only then fails verification. A platform that needs an earlier refusal adds that operation with its first entrypoint. + +### Owner provisioning (`bin/omarchy-provision-owner`) + +- `platform_ready` runs `provision-prepare` and resolves `luks-slots` at the start of each setup attempt, before the keyboard and account forms. If either fails, the owner sees its error, the log records it, and the attempt ends in the retry or root-shell screen. +- The shared re-key (`install/provisioning/luks-rekey.sh`) asks the caller for two callbacks: `luks_auto_unlock_present` and `luks_auto_unlock_drop`. `unlock_owner` resolves `provision-commit` and `provision-verify` once per process. If both resolve, the platform owns the unlock: drop is `provision-commit`, and present is `provision-verify` failing. If neither resolves, the Limine UKI callbacks run unchanged (x86, Snapdragon, other aarch64). If only one resolves, or resolution fails, the unlock counts as present and can't be dropped, so setup never finishes. +- After a factory reset left Limine entries for another machine identity, `run_provisioning` starts the menu over from the template and runs `limine-update`, on every platform. +- The shared re-key calls the caller's `luks_record_slots` once it has verified that only the owner's slot remains and before it destroys the staged key; `omarchy-provision-owner` runs `luks-slots owner= recovery=` there, a no-op where nothing records them. +- Everything else stays as it is: the wizard, account and login, the journal, slot retirement, the proof that the staged key opens nothing, and cleanup. + +### Factory reset (`bin/omarchy-system-factory-reset`) + +- `reset_boot_owner` resolves the four reset operations before the reset is confirmed. If all resolve, the platform owns the factory root's boot chain; if none do, the generic path runs unchanged (x86, Snapdragon, other aarch64: throwaway slot, keyfile in the Limine UKI, `limine-update`, `verify_limine_hashes`). If only some resolve, or resolution fails (a Mac without `omarchy-mac-boot`'s entrypoints), the reset stops before anything changes. +- Where the platform owns it, the order is: authorise with the current passphrase and stage the throwaway in the factory root's `/var/lib/omarchy/provisioning/luks-key`; `reset-prepare`; `reset-verify`; add the throwaway slot; switch the subvolumes; `reset-commit` with the throwaway on standard input. The slot comes after verification, so a failed rebuild adds no credential, and the platform writes its boot-time key only after the switch, so a power loss before it leaves the previous root asking for its password, never unlocked unattended. +- Any failure before the switch runs `reset-rollback` (once `reset-prepare` started), then revokes the slot this attempt added (found by the throwaway key when the add was not confirmed) and deletes the clone: the previous root stays the one that boots, with its boot files and encryption state. Operation output goes to the reset log; a failure shows the operation's last line. +- Everything else stays as it is: the @factory clone, identity and account scrub, provisioning markers and units, LUKS discovery, the passphrase check, the throwaway slot, and the subvolume switch. + +### Update (`bin/omarchy-update`) + +- `omarchy-update-boot` runs `update-verify` after AUR packages, which the update runs last, so it is the last sudo-capable step; like AUR it follows third-party build code, so it authenticates through the no-update wrapper without a reusable timestamp. When it fails, the update still releases Stay Awake, then says the update is not finished and exits 1 without the reboot prompt. +- It resolves the operation as the user first and runs it with `sudo` only when it resolves to an entrypoint, so an update with nothing to run never asks for root. A failed resolution fails the step with the dispatcher's message, except one: `update-verify` on a machine without its platform's boot package at all (exit 3) warns that the boot files were not verified and lets the update finish. Such a machine predates the package and boots a chain it does not manage; once the package is installed, a failed verification blocks. A package too old to ship `update-verify` blocks, since the fix is one package update away. +- The update path rebuilds no boot file itself: package hooks do, and `update-verify` catches what they missed. +- When an Omarchy package's upgrade stops only on files no package owns, `omarchy-update-system-pkgs-when-conflicted` moves them aside and retries. Before it moves anything it resolves `update-takeover` as the user and, when it resolves, runs `sudo omarchy-lifecycle-dispatch update-takeover ...` (inside the update's authorization, which the move needs anyway). A refusal, or a failed resolution (a Mac without its boot package, or with one too old to ship the operation), keeps every file and fails the update as it did before the retry existed. Where nothing resolves, the takeover goes ahead with no extra step. + +### Hardware setup (`install/hardware/platform-setup.sh`) + +- The last leaf of `install/hardware/all.sh` runs `setup-boot`, then `setup-system`, so both build on every generic leaf. As a `run_logged` leaf it is queued in an image build like any other, and `omarchy-provision-hardware` runs it on the first boot with `OMARCHY_IMAGE_DEFERRED_HARDWARE=1`, which the leaf passes on as `image-first-boot` to both. The environment variable itself never reaches the entrypoints. + +### User setup (`install/user/platform-setup.sh`, `bin/omarchy-provision-first-run`) + +- The last leaf of `install/user/all.sh` runs `setup-user` at the end of finalization. While `/var/lib/omarchy/image/target` exists the root is still being built, where a normal user's detector would read the build host, so the leaf says so and skips it. +- First run runs `setup-user` again as its own step, after the user units are enabled, now that the session is up. Its failure, like finalization's, leaves first run pending for the next login. + +### App installs (`bin/omarchy-install-browser`, `bin/omarchy-install-gaming-steam`, `bin/omarchy-remove-gaming-steam`) + +- A browser install runs `post-install ` once the browser's package, policy directory, flags file and theme are in place, and before it says the browser is installed. Steam's install runs `post-install steam` after the 32-bit drivers and the package, and before the first launch, and Steam's removal runs `pre-remove steam` before its packages go, so a package the hook added that depends on Steam doesn't stop the removal. +- On a platform that registers none, each call is a no-op and the installer runs as before. + +### Disk password change (`bin/omarchy-drive-password`) + +- After the system disk's key changed and the new key is confirmed, `record_owner_slot` resolves `luks-slots` as the user and, when it resolves, runs `sudo omarchy-lifecycle-dispatch luks-slots owner=`, so no other platform sees an extra `sudo`. Until that succeeds the journal stays, and the next run finishes the change and records the slot. The new key can land in another slot (cryptsetup 2.8's `luksChangeKey` moves a LUKS1 key to the first free slot, and keeps a LUKS2 one in place), and the boot check would then find a slot the platform did not record. +- Before anything changes, where `luks-slots` resolves, `owner_slot_matches` runs `sudo omarchy-lifecycle-dispatch luks-slots --owner` and refuses a current password whose slot is not the owner's, so another key on the system disk, such as a recovery key an earlier Mac setup added, stays as it is and can't take the owner's place. Exit 4 (no owner slot recorded) is taken as a machine nobody recorded: the change goes on only when the current password opens the disk's one key slot, checked again before the key changes, and records the new slot as the owner's once it is confirmed; with other keys on the disk it stops, naming the slots and saying how to record them (`luks-slots owner= recovery=`) or remove the extra key. Any other failure stops the change with the boot package's reason. + +## Key slots + +Omarchy has no disk recovery key: the disk, the login user and root share one password, as on every install. The re-key keeps the owner's slot alone. + +- **The owner's password:** the owner's slot is the re-key's own step, so a retry may still choose a new password while the staged key opens the disk, even after the boot step: the re-key then retires the old password's slot and records the final slot with `luks-slots`. +- **Temporary key:** setup finishes only after the re-key proved the staged key opens nothing, the boot package's `provision-verify` found no boot-time copy, and `luks-slots` recorded the kept slot. + +## Platform files + +A platform's runtime package describes its hardware in files under the platform root, `/usr/share/omarchy-platform/`, without an operation and without a branch in Omarchy's code: display hints, camera cutouts, audio nodes, keyrings and key names (see [file-layout.md](file-layout.md#platform-root)). Only the one installed platform package owns the root, and Omarchy ships nothing in it. The root is fixed: no environment variable moves it, and a development checkout in `OMARCHY_PATH` doesn't replace it. A missing root or file means no platform additions. It changes no binds, settings or gestures: Omarchy behaves the same on every machine. + +- **Key names: `key-names`**, one ` ` per line, which the keybindings menu shows in place of the keysym (a MacBook's `XF86MonBrightnessUp` is its F2 key). Its tests run copies of the menu that read a fixture root (`platform_root_copy` in `base-test.sh`). + +## Apple Silicon + +`omarchy-mac-boot`, from omacom/omarchy-mac-pkgs, implements the Apple boot operations as small entrypoints around its boot modules and installs them in `/usr/lib/omarchy/mac-boot`. `omarchy-mac`, from the same repository, implements `setup-system`, `setup-user`, `post-install` and `pre-remove` in `/usr/lib/omarchy/mac`. Their documentation describes each one. Nothing Apple-specific lives in Omarchy beyond the registration above. + +## Snapdragon + +Snapdragon laptops are `aarch64` here: they boot Limine with unified kernel images, like x86, every operation is a no-op there, and provisioning uses the Limine UKI callbacks, so Dragon behaves exactly as before. + +## Tests + +- `test/shell.d/lifecycle-dispatch-test.sh` covers the dispatcher on every platform fixture: + - the setup and app-install operations: no-ops off Apple, `omarchy-mac`'s directory on Apple (never `omarchy-mac-boot`'s), no-ops without `omarchy-mac`, the user operations refused as root and given only their allowlisted environment + - no-ops on x86 and plain aarch64, even with Mac entrypoints on disk + - Apple with and without the boot package, and with one too old to ship an operation + - arguments, exit status and the cleared environment + - untrusted entrypoints + - usage errors, and an undetermined platform + - an ordinary Bash launch, and root ignoring fixture roots, `BASH_ENV` and exported functions +- `test/shell.d/luks-rekey-journal-test.sh` runs owner provisioning through the real dispatcher: + - the crash-and-resume matrix on x86 (Limine UKI path unchanged, no Mac entrypoint runs) and on Apple with a fake boot package (the owner's slot recorded), on a slot-table fake and on file-backed LUKS2 and LUKS1 volumes + - setup stopping before the owner form when the boot package is not ready, missing, or too old to ship the provisioning entrypoints + - the stale-entry refresh rebuilding through `limine-update`, behind the boot package's `provision-verify` on Apple + - the worker failing closed without the provisioning entrypoints, or when a boot package implements only one of `provision-commit` and `provision-verify` +- `test/shell.d/platform-setup-test.sh` runs the two setup leaves (the system one running `setup-boot`, then `setup-system`, and stopping when either fails or the boot package lacks `setup-boot`) and first run through the real dispatcher: each is the last of its setup, a no-op on x86, `image-first-boot` only on an image's first boot, the user leaf waiting for first run in an image build, and a failing entrypoint failing the leaf and keeping first run pending. `test/shell.d/image-deferred-hardware-test.sh` checks that an image build queues every hardware leaf, the system one included. +- `test/shell.d/first-run-test.sh` checks that a failed finalization keeps first run pending. +- `test/shell.d/platform-app-hooks-test.sh` runs every browser install and Steam's install and removal through the real dispatcher: on Apple each calls its hook once, after the package and flags (before removal for `pre-remove`), and fails with it; on x86 none runs, even with Mac entrypoints on disk. +- `test/shell.d/update-file-conflict-test.sh` checks the takeover: on x86 it goes ahead asking no one; on Apple it goes ahead only when the boot package's `update-takeover` vouches for exactly the files that move, and a refusal, a missing boot package or one too old keep every file. +- `test/shell.d/factory-reset-dispatch-test.sh` runs the reset through the real dispatcher: on x86 with Mac entrypoints on disk that must not run (generic path unchanged), and on Apple into a fake boot package, covering a finished reset, a failed verification and a failed switch that roll back, an unconfirmed throwaway slot found by its key and revoked, a failed commit after the switch, and a missing or partial package. +- `test/shell.d/update-boot-verify-test.sh` runs `omarchy update` through the real `omarchy-update-boot` and dispatcher inside the sudo boundary fixture: no-ops and no root on x86 and plain aarch64; on Apple, verify after AUR through the no-update wrapper, a failed verification that offers no reboot, and a Mac without the package or with one too old. +- `test/shell.d/drive-password-test.sh` checks that an Apple password change records the owner's new slot through `luks-slots`, including after an interruption, and that x86 never calls it; that `--owner` failing (1, or a slot that isn't a number) stops the change with the reason; and that with no owner slot recorded (4) a one-key disk changes and records its slot, also after an interruption, while a disk with another key, or one that gains a key during the prompts, is refused unchanged. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index c834d879..8a6f2415 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -396,6 +396,16 @@ size-vertical = 28 # left/right bar width at base-size 12 Set `scale-with-font = false` to keep those bar sizes as fixed pixels. +A panel with a camera cutout (a notch) at its top keeps a top bar out of it: the bar is never shorter than the cutout, and its center section sits beside the right one. Omarchy knows no panel's cutout itself; the platform's own package describes them in `/usr/share/omarchy-platform/display-cutouts.json`, a fixed path no environment variable moves (see [file-layout.md](file-layout.md#platform-root)): + +```json +{ "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +``` + +A panel matches a screen whose connector name starts with `connector` and whose mode, as `hyprctl monitors` reports it, is `width` x `height` physical pixels, at any scale; `top` is the physical rows the cutout covers. On such a panel, `[bar] notch-height` (logical pixels, not scaled with the font) sets the floor by hand. The bar reads the file when the shell starts and whenever it changes; a file installed where its directory didn't exist yet (the platform root itself, on a first install) is read at the next shell start. + +Until Hyprland reports a screen's mode, a top bar there waits to appear if a description names the screen's connector, unless the logical size times Qt's whole-number scale already matches a panel. It then appears at its floor rather than growing once windows are laid out, and never guesses a floor for a panel nothing describes. After two seconds without a mode it appears without a floor. Notifications clear the bar as thick as it is on their screen. A plugin's `shell.bar` gives the same through `barSizeFor(screenName)` and the `barSizes` map (screen name to thickness); its `barSize` stays the configured size, which the widgets are drawn at. + ## Custom bar modules If a full plugin is overkill, declare a one-off module inline in diff --git a/docs/update-process.md b/docs/update-process.md index 3c7804c8..de02c184 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -143,9 +143,11 @@ omarchy-update ├─ run the post-update hook, then update mise tools ├─ stop the keepalive and invalidate sudo, then update AUR packages with │ no-update authentication, and invalidate again + ├─ omarchy-update-boot, with no-update authentication, and invalidate again + │ └─ the platform's boot package proves the boot files boot the updated system ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - └─ offer the unprivileged reboot prompt + └─ offer the unprivileged reboot prompt, only when the boot files were verified ``` Important behavior: @@ -166,6 +168,7 @@ Important behavior: `omarchy-migrate` after pacman finishes. - A failure should leave enough output in `/tmp/omarchy-update.log` and the terminal transcript to debug. +- The boot check is the [lifecycle dispatch](lifecycle-dispatch.md) operation `update-verify`, a no-op on platforms whose boot chain needs no handling of its own, x86 included: nothing runs and nothing asks for root. It is the last sudo-capable step, after AUR packages, so it also covers the initramfs rebuilds they trigger; since it follows third-party build code, it authenticates through the no-update wrapper like AUR does, which on a platform that implements it without passwordless sudo is one more prompt. When it fails, the update finishes its remaining steps, then exits non-zero without the reboot prompt: the update is not finished. A machine without its platform's boot package at all predates it: the update warns that its boot files were not verified and finishes. There is no check before the packages change: where `omarchy-hw-platform` can't tell the platform, the update installs its packages, and the update then fails verification, without the reboot prompt. ## Path 2: direct `sudo pacman -Syu` attempt @@ -274,6 +277,32 @@ Channel switching runs the `pre-refresh-pacman` hook once, during its refresh step: cold, behind the no-update wrapper, after the package config is re-synced and before the refresh transaction. It does not run if the switch fails earlier. +Every platform has its own pacman.conf and mirrorlist for each channel it +offers (x86_64 stable, rc and edge; aarch64 edge alone, below), and a channel +change or install finalization copies them into place whole, the same way on +every platform (`install/helpers/pacman.sh`); a channel change backs up +the old pair first. +x86_64's are `default/pacman/pacman-.conf` and +`mirrorlist-`; Snapdragon and other aarch64 machines use +`default/pacman/aarch64/`, Omarchy's repository ahead of Arch Linux ARM's, as +x86_64 puts it ahead of Arch's (migration 1791403252 reorders existing +machines the same way, and a refresh can then move a package Omarchy also +publishes to Omarchy's build, downgrading it if that build is older); Apple +Silicon uses `default/pacman/aarch64-apple/`, which puts Omarchy and Asahi ALARM +ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so +far, and the `omarchy` and `omarchy-settings` packages there for stable and rc +are the release line, which has no aarch64 support, so ARM platforms have edge +templates only: switching an ARM machine to stable or rc would replace its +runtime with one that cannot run it. Adding a stable or rc template once a +release supports aarch64 is what opens that channel. A channel change refuses a +channel without both files for the platform before anything changes. On aarch64, +`omarchy-channel-set` refuses to link a dev checkout without +`bin/omarchy-hw-platform` and `install/helpers/pacman.sh`, whose own refresh +would write the x86_64 templates. `omarchy-reinstall-pkgs` resets to the +platform's default channel (`omarchy_pacman_default_channel`: stable, or edge on +aarch64) and installs its default packages; install finalization does the same +when the install's channel has no templates for the platform. + There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a linked checkout, and `omarchy-version-channel` sniffs the mirrorlist and @@ -293,7 +322,7 @@ scripts. | `omarchy-update-status` | Hidden helper that refreshes or clears the shell update indicator after rechecking available updates. | **Keep internal/hidden.** Keeps shell status synchronization out of the main pipeline. | | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | -| `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | +| `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring (and Arch Linux ARM keyring on aarch64, where installed) are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | | `omarchy-update-system-pkgs` | Runs `omarchy-update-pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | @@ -308,6 +337,7 @@ scripts. | `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 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-boot` | Hidden helper that runs the platform's `update-verify` lifecycle operation through `omarchy-lifecycle-dispatch`, with `sudo` only when the platform implements it. | **Keep internal/hidden.** Keeps platform boot checks out of the pipeline and stubbable in tests. | | `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. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf b/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf new file mode 100644 index 00000000..dda5da9a --- /dev/null +++ b/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf @@ -0,0 +1,37 @@ +# The platform's HOOKS baseline. mkinitcpio sources mkinitcpio.conf and then +# every drop-in here in name order, so the baseline sorts first and the drop-ins +# after it add their hooks to it instead of being overwritten by it. +# +# Apple Silicon boots a systemd initramfs, and its platform package adds the +# firmware and encryption hooks. Every other machine keeps the busybox line, as +# does one without the runtime's detector. A detector that cannot place the +# machine stops the build rather than let it produce an image for the wrong +# platform. +_omarchy_platform="" +if _omarchy_detector=$(command -v omarchy-hw-platform); then + _omarchy_platform=$("$_omarchy_detector") || return 1 +fi +# omarchy-settings can update before the omarchy runtime whose detector still +# says apple-silicon. +[[ $_omarchy_platform != "apple-silicon" ]] || _omarchy_platform=aarch64-apple + +# A Mac whose mkinitcpio.conf carries the asahi hook, or the busybox encrypt +# hook that unlocks it through cryptdevice= (sd-encrypt cannot parse that), boots +# the initramfs its platform set up without the Apple boot package, as a legacy +# install does. Its line stays as it is. The asahi hook also marks such a root +# off a Mac, as in a chroot or a VM. +if [[ " ${HOOKS[*]:-} " == *" asahi "* ]] || + [[ $_omarchy_platform == "aarch64-apple" && " ${HOOKS[*]:-} " == *" encrypt "* ]]; then + _omarchy_platform=platform-owned +fi + +case $_omarchy_platform in + platform-owned) ;; + aarch64-apple) + HOOKS=(base systemd plymouth autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck) + ;; + *) + HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs) + ;; +esac +unset _omarchy_platform _omarchy_detector diff --git a/etc/mkinitcpio.conf.d/omarchy_hooks.conf b/etc/mkinitcpio.conf.d/omarchy_hooks.conf index 68408b00..0dfcb8e9 100644 --- a/etc/mkinitcpio.conf.d/omarchy_hooks.conf +++ b/etc/mkinitcpio.conf.d/omarchy_hooks.conf @@ -1,4 +1,5 @@ -HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs) +# Adjusts the baseline HOOKS from 00-omarchy-hooks.conf. It stays apart from +# the baseline because it reads what the hardware drop-ins sorting before it set. # The proprietary NVIDIA driver does early KMS itself: nvidia.conf (written by # install/hardware/nvidia.sh, sourced before this file) early-loads nvidia_drm diff --git a/etc/mkinitcpio.conf.d/thunderbolt_module.conf b/etc/mkinitcpio.conf.d/thunderbolt_module.conf index 9518f8b1..2d308269 100644 --- a/etc/mkinitcpio.conf.d/thunderbolt_module.conf +++ b/etc/mkinitcpio.conf.d/thunderbolt_module.conf @@ -1 +1,7 @@ -MODULES+=(thunderbolt) +# Early-load thunderbolt where the kernel builds it as a module. The optional +# form, thunderbolt?, is not enough: a systemd initramfs copies MODULES as +# written into modules-load.d, where "thunderbolt?" names no module, and a +# kernel without the module would still list it there. +if [[ $(modinfo -k "${KERNELVERSION:-$(uname -r)}" -F filename thunderbolt 2>/dev/null) == /* ]]; then + MODULES+=(thunderbolt) +fi diff --git a/install/config/all.sh b/install/config/all.sh index 09b23293..bc327e6e 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -6,6 +6,7 @@ run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" +run_logged "$OMARCHY_INSTALL/config/locale.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" diff --git a/install/config/locale.sh b/install/config/locale.sh new file mode 100644 index 00000000..e70713fb --- /dev/null +++ b/install/config/locale.sh @@ -0,0 +1,50 @@ +# An image built from a distribution's root tarball rather than the ISO (Arch +# Linux ARM's ships LANG=C) never went through the ISO's locale step, so it +# runs non-UTF-8: byte-wise sorting, ASCII-only \u escapes, and any tool that +# reads the locale for its encoding. +# Root always writes the real files; the overrides are for unprivileged tests. +locale_conf=/etc/locale.conf +locale_gen=/etc/locale.gen +if (( EUID != 0 )); then + locale_conf=${OMARCHY_LOCALE_CONF:-$locale_conf} + locale_gen=${OMARCHY_LOCALE_GEN:-$locale_gen} +fi + +# Repair only the stock state -- an unset LANG, or the bare C/POSIX the image +# ships. Any named locale is somebody's choice, C.UTF-8 included, so leave it. +# A machine with no locale.conf at all reads as unset, not as a failure: +# under pipefail the missing file would otherwise abort the installer. +current=$(sed -n 's/^LANG=//p' "$locale_conf" 2>/dev/null | tail -1 | tr -d '"') || current="" + +case ${current:-C} in + C | POSIX) ;; + *) + echo "Leaving the locale as $current" + return 0 2>/dev/null || exit 0 + ;; +esac + +echo "Setting up locale (en_US.UTF-8)..." + +if ! locale -a 2>/dev/null | grep -qi "en_US.utf-\?8"; then + if grep -q '^#en_US.UTF-8' "$locale_gen" 2>/dev/null; then + sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' "$locale_gen" + elif ! grep -q '^en_US.UTF-8' "$locale_gen" 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >>"$locale_gen" + fi + + locale-gen >/dev/null 2>&1 +fi + +# Only LANG changes; LC_* lines somebody set stay. +if grep -q '^LANG=' "$locale_conf" 2>/dev/null; then + sed -i 's/^LANG=.*/LANG=en_US.UTF-8/' "$locale_conf" +else + echo "LANG=en_US.UTF-8" >>"$locale_conf" +fi + +# The session that ran this keeps its inherited LANG; everything after it here +# should see the new one. +export LANG=en_US.UTF-8 + +echo "Locale set to en_US.UTF-8" diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 92f3fdea..4f24cdcb 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -45,3 +45,5 @@ run_logged "$OMARCHY_INSTALL/hardware/fix-yt6801-ethernet-adapter.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-tuxedo-backlight.sh" run_logged "$OMARCHY_INSTALL/hardware/speaker-tuning.sh" run_logged "$OMARCHY_INSTALL/hardware/pacman.sh" + +run_logged "$OMARCHY_INSTALL/hardware/platform-setup.sh" diff --git a/install/hardware/apple/fix-brcmfmac-supplicant.sh b/install/hardware/apple/fix-brcmfmac-supplicant.sh index 33238c9c..b679790f 100644 --- a/install/hardware/apple/fix-brcmfmac-supplicant.sh +++ b/install/hardware/apple/fix-brcmfmac-supplicant.sh @@ -18,6 +18,14 @@ # machines including the T2-less iMac19,1 and iMac19,2, and BCM4377/4378/4387 # from the T2 era on. The BCM4360 in 2013-2015 Macs is deliberately absent: it # runs the out-of-tree wl driver, which never reads a brcmfmac option. +# +# Apple Silicon is excluded even though its BCM4387 is in that list: there the +# firmware supplicant is what works, and disabling it makes firmware commands +# time out and breaks scanning entirely. +if omarchy-hw-aarch64-apple; then + return 0 +fi + sys_vendor="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || true)" if lspci -nn | grep "106b:180[12]" >/dev/null || diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index 330081b7..59d4b12f 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -1,5 +1,7 @@ -# Detect MacBook models that need SPI keyboard modules -product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" +# Detect MacBook models that need SPI keyboard modules. +# Not every machine has a DMI product name, and under set -e an unguarded read +# of a missing one aborts the hardware pass. +product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" diff --git a/install/hardware/bluetooth.sh b/install/hardware/bluetooth.sh index 40345cf9..8ba4f897 100644 --- a/install/hardware/bluetooth.sh +++ b/install/hardware/bluetooth.sh @@ -1,5 +1,15 @@ systemctl enable bluetooth.service +# An image's first boot runs this after the adapter has brought up (or queued) +# bluetooth.target, and a target never starts a unit enabled after its start +# job was made, so Bluetooth would stay off until the next boot. Only there +# (omarchy-provision-hardware sets the marker): an install or a rerun of +# hardware setup leaves the service as it finds it. Without Bluetooth support +# the unit's own condition skips it. +if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then + systemctl start --no-block bluetooth.service || true +fi + # AutoEnable stays at its stock default on purpose. It was set to false here to # persist the power state, which it never did: BlueZ has no such behaviour, so # all it bought was Bluetooth coming up off on every boot. omarchy-bluetooth-power diff --git a/install/hardware/fix-fkeys.sh b/install/hardware/fix-fkeys.sh index ae7fee52..f1edee26 100644 --- a/install/hardware/fix-fkeys.sh +++ b/install/hardware/fix-fkeys.sh @@ -1,5 +1,7 @@ -# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys -if [[ ! -f /etc/modprobe.d/hid_apple.conf ]]; then - sudo mkdir -p /etc/modprobe.d - echo "options hid_apple fnmode=2" | sudo tee /etc/modprobe.d/hid_apple.conf >/dev/null +# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys. +# Apple Silicon is left alone: the omarchy-mac package owns its keyboard mode. +conf="${OMARCHY_HID_APPLE_CONF:-/etc/modprobe.d/hid_apple.conf}" +if [[ ! -f $conf ]] && ! omarchy-hw-aarch64-apple; then + sudo mkdir -p "$(dirname "$conf")" + echo "options hid_apple fnmode=2" | sudo tee "$conf" >/dev/null fi diff --git a/install/hardware/platform-setup.sh b/install/hardware/platform-setup.sh new file mode 100644 index 00000000..946df9ea --- /dev/null +++ b/install/hardware/platform-setup.sh @@ -0,0 +1,13 @@ +# The platform packages' own setup, last so it builds on every other leaf +# (omarchy-lifecycle-dispatch setup-boot and setup-system, see +# docs/lifecycle-dispatch.md): the boot package's first, then the runtime +# package's. A no-op where the platform registers none. An image's first boot +# says so: there the network may be down, and a boot-file rebuild is asked for +# rather than built, since the deferred setup rebuilds once after its last step. +if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then + omarchy-lifecycle-dispatch setup-boot image-first-boot + omarchy-lifecycle-dispatch setup-system image-first-boot +else + omarchy-lifecycle-dispatch setup-boot + omarchy-lifecycle-dispatch setup-system +fi diff --git a/install/helpers/image-target.sh b/install/helpers/image-target.sh new file mode 100644 index 00000000..5dc5882d --- /dev/null +++ b/install/helpers/image-target.sh @@ -0,0 +1,164 @@ +# Image builds and the hardware setup they defer to the machine's first boot. +# Sourced by omarchy-apply-hardware and omarchy-provision-hardware; no shebang. +# +# An image is built away from the machine it will run on, so hardware setup +# during the build would describe the builder, not the target. The builder +# declares the build in a root-owned manifest before it runs +# omarchy-apply-system in the image root: +# +# /var/lib/omarchy/image/target +# format=1 +# platform=x86|aarch64|aarch64-apple +# +# While it exists, omarchy-apply-hardware queues each hardware leaf in +# /var/lib/omarchy/image/deferred-steps instead of running it, and arms +# omarchy-provision-hardware.service. On the machine's first boot that service, +# or the platform's own first boot, retires the manifest (to target.booted) and +# runs the queue on the real hardware. Unknown manifest keys are ignored so the +# format can grow. +# +# An image carries no pacman master key of its own, or every machine flashed +# from it would share one: where install finalization would make the keyring +# (install/post-install/pacman.sh), a build requests it in +# /var/lib/omarchy/image/pacman-keyring instead, and the first boot makes it +# before any deferred step runs. +# +# The manifest is also the only source of the image's platform: until that boot, +# omarchy-hw-platform reports its platform instead of the build host's, so the +# initramfs, services and packages the build sets up are the target's. It reads +# the manifest by the same rules as omarchy_image_read_manifest below. +# +# Root always uses the fixed paths. Only a non-root test may move them under +# OMARCHY_IMAGE_ROOT, so no environment variable can make a live system defer, +# skip or replay its hardware setup. + +omarchy_image_init() { + if (( EUID == 0 )); then + omarchy_image_root="" + elif [[ -n ${OMARCHY_IMAGE_ROOT:-} ]]; then + omarchy_image_root=$OMARCHY_IMAGE_ROOT + else + echo "Error: image setup state is root-owned; run as root" >&2 + return 1 + fi + + omarchy_image_dir=$omarchy_image_root/var/lib/omarchy/image + omarchy_image_manifest=$omarchy_image_dir/target + omarchy_image_queue=$omarchy_image_dir/deferred-steps + omarchy_image_initramfs_baseline=$omarchy_image_dir/initramfs-inputs + omarchy_image_boot_rebuild=$omarchy_image_dir/boot-rebuild + omarchy_image_keyring_request=$omarchy_image_dir/pacman-keyring + omarchy_image_unit=omarchy-provision-hardware.service + omarchy_image_systemd_dir=$omarchy_image_root/etc/systemd/system +} + +# Owned by whoever runs this (root on a live system), not a symlink, and not +# writable by anyone else. +omarchy_image_trusted() { + local path=$1 owner mode + + [[ -e $path && ! -L $path ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +omarchy_image_manifest_present() { + [[ -e $omarchy_image_manifest || -L $omarchy_image_manifest ]] +} + +# Sets omarchy_image_platform, or fails without guessing whether this is a build. +omarchy_image_read_manifest() { + local line key value format="" platform="" + + if [[ ! -d $omarchy_image_dir || ! -f $omarchy_image_manifest ]] || ! omarchy_image_trusted "$omarchy_image_dir" || + ! omarchy_image_trusted "$omarchy_image_manifest"; then + echo "Error: $omarchy_image_manifest is not a root-owned regular file" >&2 + return 1 + fi + + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + if [[ $line != *=* ]]; then + echo "Error: $omarchy_image_manifest is malformed: $line" >&2 + return 1 + fi + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + platform) platform=$value ;; + esac + done <"$omarchy_image_manifest" + + if [[ $format != "1" ]]; then + echo "Error: $omarchy_image_manifest is not format=1" >&2 + return 1 + fi + # Builders written before the platform names settled say apple-silicon, + # generic-aarch64 or generic (as omarchy-hw-platform reads them too). + case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; + esac + case $platform in + x86 | aarch64 | aarch64-apple) ;; + *) + echo "Error: $omarchy_image_manifest names no known platform: ${platform:-none}" >&2 + return 1 + ;; + esac + + omarchy_image_platform=$platform +} + +# Holds the image state still for the rest of the caller, so hardware setup and +# a first-boot run never overlap or decide from a manifest the other retires. +# A machine that was never an image has no state and nothing to lock. +omarchy_image_lock() { + [[ -e $omarchy_image_dir || -L $omarchy_image_dir ]] || return 0 + if [[ ! -d $omarchy_image_dir ]] || ! omarchy_image_trusted "$omarchy_image_dir"; then + echo "Error: $omarchy_image_dir is not a root-owned directory" >&2 + return 1 + fi + exec {omarchy_image_lock_fd}>>"$omarchy_image_dir/lock" || return 1 + flock "$omarchy_image_lock_fd" +} + +omarchy_image_write_queue() { + local tmp + + tmp=$(mktemp "$omarchy_image_dir/.deferred-steps.XXXXXX") || return 1 + if (( $# )); then + printf '%s\n' "$@" >"$tmp" || { rm -f "$tmp"; return 1; } + fi + chmod 0644 "$tmp" && mv -f "$tmp" "$omarchy_image_queue" +} + +# Queue every leaf install/hardware/all.sh would run, in its order, running +# none of them, then arm the first-boot service. Rerunning rewrites the same queue. +omarchy_image_defer_hardware() { + local listing + local -a steps=() + + listing=$( + run_logged() { + [[ $1 == "$OMARCHY_INSTALL"/hardware/*.sh && $1 != *..* ]] || { + echo "Error: cannot defer a step outside the hardware setup: $1" >&2 + exit 1 + } + printf 'install/%s\n' "${1#"$OMARCHY_INSTALL"/}" + } + source "$OMARCHY_INSTALL/hardware/all.sh" + ) || return 1 + + [[ -z $listing ]] || mapfile -t steps <<<"$listing" + omarchy_image_write_queue "${steps[@]}" || return 1 + + install -d -m 0755 "$omarchy_image_systemd_dir/multi-user.target.wants" && + install -m 0644 "$OMARCHY_INSTALL/provisioning/$omarchy_image_unit" "$omarchy_image_systemd_dir/$omarchy_image_unit" && + ln -sfn "/etc/systemd/system/$omarchy_image_unit" "$omarchy_image_systemd_dir/multi-user.target.wants/$omarchy_image_unit" || + return 1 + + echo "Image build for $omarchy_image_platform: deferred ${#steps[@]} hardware steps to first boot" +} diff --git a/install/helpers/pacman.sh b/install/helpers/pacman.sh new file mode 100644 index 00000000..1825e15a --- /dev/null +++ b/install/helpers/pacman.sh @@ -0,0 +1,32 @@ +# Pacman repository templates. Every platform has a pacman.conf and mirrorlist +# for each channel it offers, copied into place whole, as x86's always were: +# x86's in default/pacman, each ARM platform's in a directory named for it. +# Sourcing this file only defines functions; it never changes the system. + +# The directory holding 's pacman-.conf and +# mirrorlist-. A channel without both files isn't offered there. +omarchy_pacman_templates() { + case ${1:-} in + x86) echo "$OMARCHY_PATH/default/pacman" ;; + aarch64 | aarch64-apple) echo "$OMARCHY_PATH/default/pacman/$1" ;; + *) + echo "Error: Unknown platform '${1:-}'." >&2 + return 1 + ;; + esac +} + +# The channel a machine takes when none is named: stable, except on aarch64. +# Omarchy publishes aarch64 packages on edge alone so far, and the stable and rc +# packages there are the release line, which has no aarch64 support, so ARM +# platforms have edge templates only until a release does. +omarchy_pacman_default_channel() { + case ${1:-} in + x86) echo stable ;; + aarch64 | aarch64-apple) echo edge ;; + *) + echo "Error: Unknown platform '${1:-}'." >&2 + return 1 + ;; + esac +} diff --git a/install/omarchy-aarch64-apple.packages b/install/omarchy-aarch64-apple.packages new file mode 100644 index 00000000..13a7ad66 --- /dev/null +++ b/install/omarchy-aarch64-apple.packages @@ -0,0 +1,26 @@ +# Apple Silicon additions, composed after omarchy-base.packages and +# omarchy-aarch64.packages (omarchy-pkg-defaults aarch64-apple); never on Intel +# Macs or other aarch64 machines. The kernel and boot chain come with the +# Mac's image, as the kernel and bootloader do from the ISO on x86_64. What a +# Mac cannot work without (its speaker stack, audio server and GPU driver) +# comes as omarchy-mac's dependencies, so a new Mac driver needs no change +# here; the rest are defaults an owner may remove. + +# The Mac's runtime and boot packages (omacom/omarchy-mac-pkgs). +omarchy-mac +omarchy-mac-boot + +# Picks the startup volume (macOS or Omarchy) from Linux. +asahi-bless + +# Hardware video decode. +avd-fw +libva-v4l2_request-avd + +# Widevine for DRM streaming. +widevine + +# Screen recording: gpu-screen-recorder finds no display device to capture on +# Apple Silicon, whose display and GPU are separate DRM devices, so recording +# falls back to wf-recorder (omarchy-capture-screenrecording). +wf-recorder diff --git a/install/omarchy-aarch64.packages b/install/omarchy-aarch64.packages new file mode 100644 index 00000000..fa7dcab5 --- /dev/null +++ b/install/omarchy-aarch64.packages @@ -0,0 +1,4 @@ +# Additions for every aarch64 platform, composed after omarchy-base.packages +# (omarchy-pkg-defaults). omarchy-settings configures swap on zram, and the +# x86_64 installer provides zram-generator for it; aarch64 gets it here. +zram-generator diff --git a/install/omarchy-x86_64-only.packages b/install/omarchy-x86_64-only.packages new file mode 100644 index 00000000..231c4c9b --- /dev/null +++ b/install/omarchy-x86_64-only.packages @@ -0,0 +1,4 @@ +# Base packages with no aarch64 build in any repository Omarchy configures. +# omarchy-pkg-defaults leaves them out of every aarch64 platform's set. +gliff +superwhisper-bin diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index da84975b..4964fe97 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -1,7 +1,39 @@ # Configure pacman after package installation completes. Offline target package -# installs use the live ISO's offline pacman.conf until this final restore. -cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf -cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist +# installs use the live ISO's offline pacman.conf until this final restore, +# which copies the platform's channel templates (install/helpers/pacman.sh). A +# Mac's image brings its keyrings, as the ISO does on x86; every other aarch64 +# platform installs Arch Linux ARM's before its repositories replace the +# offline ones, and trust every installed keyring before the first signed sync. +source "$OMARCHY_PATH/install/helpers/pacman.sh" +source "$OMARCHY_PATH/install/helpers/image-target.sh" +platform=$(omarchy-hw-platform) +templates=$(omarchy_pacman_templates "$platform") +alarm_keyring=0 +if [[ $platform == aarch64* && $platform != "aarch64-apple" ]]; then + alarm_keyring=1 +fi + +if (( alarm_keyring )); then + omarchy-pkg-add archlinuxarm-keyring +fi + +# An install built for a channel this platform has no templates for (stable or +# rc on aarch64) gets the platform's default channel instead. +channel=${OMARCHY_MIRROR:-} +[[ -n $channel && -f $templates/pacman-$channel.conf ]] || channel=$(omarchy_pacman_default_channel "$platform") +cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf +cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist + +# An image build leaves the keyring to each machine's first boot, so no two +# share a master key (install/helpers/image-target.sh). +if (( alarm_keyring )); then + if omarchy_image_init && omarchy_image_manifest_present; then + install -m 0644 /dev/null "$omarchy_image_keyring_request" + else + pacman-key --init + pacman-key --populate + fi +fi # Wait for CUPS to own the file, the way omarchy-settings does, so pacman does # not turn the override into a .pacnew during ISO package installation. diff --git a/install/provisioning/luks-rekey.sh b/install/provisioning/luks-rekey.sh new file mode 100644 index 00000000..ab7194fc --- /dev/null +++ b/install/provisioning/luks-rekey.sh @@ -0,0 +1,290 @@ +# Resumable re-key of the root LUKS volume from the staged install key to the +# owner's password. Sourcing performs no setup. +# +# Caller contract: +# - Set PROVISIONING_DIR (holds the staged luks-key), REKEY_STATE, LOG_FILE and +# password. Secrets reach cryptsetup through key files and process +# substitution, never argv, and the entry points turn tracing off. +# - Define luks_boot_layout, printing a fingerprint of the keyboard layout the +# boot files ask for the disk password in (the same value while it is +# unchanged, and never the layout's name). +# - Define log_step and say, plus the platform's boot-time unlock: +# luks_auto_unlock_present succeeds while any boot-time copy of the staged +# key or its unlock configuration remains; luks_auto_unlock_drop removes +# them and rebuilds the boot files, restoring the unlock before it fails. +# luks_record_slots records the kept slot wherever the platform's +# boot checks look for it, and succeeds where nothing does. +# +# The journal (REKEY_STATE) records only the phase, slot numbers and the layout +# fingerprint, never key material. Phases advance staged → owner → boot → done, each written durably +# after its step, so an attempt interrupted anywhere resumes from the last one; +# the retire and record steps before done are idempotent. A retry may still +# choose a new password while the staged key opens the disk, even after the +# boot step, so the kept slot is recorded only once it is final. The +# caller removes the journal with the rest of its provisioning state, so until +# then a retry can only use the password the disk holds. + +# cryptsetup open tries enrolled tokens (TPM2, FIDO2, keyring) before the key +# and reports a token's slot whatever key it was given. Restricting it to a +# token type no token has leaves only the key to decide. +luks_key_slot() { + local out + out=$(LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file "$1" "$2" 2>&1) || return 0 + grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1 || true +} + +luks_slot_for() { + local - + set +x + luks_key_slot <(printf '%s' "$1") "$2" +} + +# LUKS2 lists keyslots under "Keyslots:", and a luks2-keyring token under +# "Tokens:" looks the same, so read only the keyslot section. +luks_dump_slots() { + cryptsetup luksDump "$1" | awk ' + /^[^ \t]/ { keyslots = ($0 == "Keyslots:") } + keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 } + /^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' +} + +rekey_state_get() { + [[ -f $REKEY_STATE ]] || return 1 + awk -F= -v k="$1" '$1 == k { print $2; exit }' "$REKEY_STATE" +} + +# Set key/value pairs, keeping every other recorded key. +rekey_state_put() { + local line key tmp + local -A updates=() + local -a lines=() + + while (( $# >= 2 )); do + updates[$1]=$2 + shift 2 + done + + if [[ -f $REKEY_STATE ]]; then + while IFS= read -r line || [[ -n $line ]]; do + if [[ -n $line && -z ${updates[${line%%=*}]+set} ]]; then + lines+=("$line") + fi + done <"$REKEY_STATE" + fi + for key in "${!updates[@]}"; do + lines+=("$key=${updates[$key]}") + done + + tmp=$(mktemp "$REKEY_STATE.XXXXXX") || return 1 + if printf '%s\n' "${lines[@]}" >"$tmp" && chmod 600 "$tmp" && sync "$tmp" && mv -f "$tmp" "$REKEY_STATE"; then + sync "$(dirname "$REKEY_STATE")" + return + fi + rm -f "$tmp" + return 1 +} + +staged_key_slot() { + [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 + luks_key_slot "$PROVISIONING_DIR/luks-key" "$1" +} + +# The staged key or its boot-time auto-unlock is still on this machine. +luks_staged_unlock_remains() { + [[ -e $PROVISIONING_DIR/luks-key ]] || luks_auto_unlock_present +} + +# Re-key work is due while the staged unlock remains or the journal is open. +luks_rekey_pending() { + [[ -e $REKEY_STATE ]] || luks_staged_unlock_remains +} + +# Whether an interrupted re-key can finish with $password. Until the staged key +# is retired it can add any password; afterwards only one the disk holds. +luks_rekey_accepts_password() { + local - + set +x + local device=$1 + + [[ -f $REKEY_STATE ]] || return 0 + [[ -n $(luks_slot_for "$password" "$device") || -n $(staged_key_slot "$device") ]] +} + +# Record the slot $password opens, adding it with the staged key when none does. +# A retry after a crash between the add and the journal write finds the slot +# instead of adding a duplicate, and a retry with a new password adds its own +# while the staged key still works; the retire step removes the stale one. +luks_rekey_owner() { + local device=$1 phase=$2 owner + + owner=$(luks_slot_for "$password" "$device") + if [[ -z $owner ]]; then + if [[ -z $(staged_key_slot "$device") ]]; then + log_step "the password opens no LUKS slot and the staged key no longer unlocks $device" + say --foreground 1 "Use the disk password chosen earlier in setup." + return 1 + fi + if ! cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password"); then + log_step "could not add the owner's key to $device" + say --foreground 1 "Could not add your password to the disk; will retry." + return 1 + fi + owner=$(luks_slot_for "$password" "$device") + if [[ -z $owner ]]; then + log_step "could not identify the owner's LUKS slot after adding it" + say --foreground 1 "Could not confirm the LUKS re-key; will retry." + return 1 + fi + fi + + if [[ $owner == "$(rekey_state_get staged_slot || true)" ]]; then + log_step "the owner's password is the staged install key; refusing to keep it" + say --foreground 1 "Choose a disk password different from the temporary install key." + return 1 + fi + + if [[ $phase == "staged" ]]; then + rekey_state_put owner_slot "$owner" phase owner + else + rekey_state_put owner_slot "$owner" + fi +} + +luks_rekey_retire() { + local device=$1 owner slot slots + + owner=$(rekey_state_get owner_slot || true) + if [[ -z $owner ]]; then + log_step "no owner slot is recorded; refusing to retire LUKS slots" + return 1 + fi + + if ! slots=$(luks_dump_slots "$device"); then + log_step "luksDump failed while retiring slots; keeping the staged key for retry" + say --foreground 1 "Could not enumerate LUKS slots; will retry." + return 1 + fi + for slot in $slots; do + [[ $slot == "$owner" ]] && continue + if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then + log_step "failed to kill LUKS slot $slot; keeping the staged key for retry" + say --foreground 1 "Could not remove the throwaway LUKS key; will retry." + return 1 + fi + done +} + +# The staged key must open nothing before it is destroyed: only the owner slot +# remains and no boot-time copy or unlock configuration is left behind. +luks_rekey_verify() { + local device=$1 owner slots + + owner=$(rekey_state_get owner_slot || true) + if ! slots=$(luks_dump_slots "$device") || [[ -z $owner || $slots != "$owner" ]]; then + log_step "LUKS slots other than the owner's remain on $device" + return 1 + fi + if [[ -n $(staged_key_slot "$device") ]]; then + log_step "the staged LUKS key still unlocks $device" + return 1 + fi + if luks_auto_unlock_present; then + log_step "the boot-time auto-unlock is still configured" + return 1 + fi +} + +# The boot files carry the console keyboard layout the disk password is typed +# in at boot, so rebuild them again when the owner chose another layout since +# they were built, or when an older journal recorded none. The phase stays. +luks_rekey_boot_layout() { + local layout=$1 + + [[ $(rekey_state_get boot_layout || true) != "$layout" ]] || return 0 + log_step "the boot files were not built with the current keyboard layout; rebuilding them" + if ! luks_auto_unlock_drop; then + say --foreground 1 "Could not rebuild the boot files for your keyboard layout; will retry." + return 1 + fi + sync + rekey_state_put boot_layout "$layout" +} + +# Order: record the staged slot, add the owner's key, rebuild boot without the +# auto-unlock (keeping the staged slot as the fallback while that can fail), +# retire every other slot, then verify, record the kept slot for the platform, +# destroy the staged key and record done. +# Failing is loud: silently keeping the staged key would leave the disk +# effectively unencrypted. +luks_rekey() { + local - + set +x + local device=$1 phase staged layout + + if ! layout=$(luks_boot_layout) || [[ -z $layout ]]; then + log_step "could not fingerprint the keyboard layout for the boot files" + say --foreground 1 "Could not read the keyboard layout for the boot files; will retry." + return 1 + fi + + phase=$(rekey_state_get phase || true) + case $phase in + "") + staged=$(staged_key_slot "$device") + if [[ -z $staged ]]; then + log_step "the staged LUKS key is missing or does not unlock $device" + say --foreground 1 "The staged LUKS key no longer unlocks $device." + return 1 + fi + rekey_state_put staged_slot "$staged" phase staged || return 1 + phase=staged + ;; + staged | owner | boot | done) ;; + *) + log_step "unknown LUKS re-key phase '$phase' in $REKEY_STATE" + say --foreground 1 "The LUKS re-key journal is unreadable." + return 1 + ;; + esac + + if [[ $phase != "done" ]]; then + luks_rekey_owner "$device" "$phase" || return 1 + [[ $phase == "staged" ]] && phase=owner + + if [[ $phase == "owner" ]] || luks_auto_unlock_present; then + if ! luks_auto_unlock_drop; then + say --foreground 1 "Could not rebuild the boot files without the install key; will retry." + return 1 + fi + sync + rekey_state_put phase boot boot_layout "$layout" || return 1 + else + luks_rekey_boot_layout "$layout" || return 1 + fi + + luks_rekey_retire "$device" || return 1 + else + if [[ $(luks_slot_for "$password" "$device") != "$(rekey_state_get owner_slot || true)" ]]; then + log_step "the password does not open the owner slot the finished re-key recorded on $device" + say --foreground 1 "Use the disk password chosen earlier in setup." + return 1 + fi + luks_rekey_boot_layout "$layout" || return 1 + fi + + if ! luks_rekey_verify "$device"; then + say --foreground 1 "Could not confirm the temporary install key was removed; will retry." + return 1 + fi + if ! luks_record_slots "$(rekey_state_get owner_slot)"; then + log_step "the platform could not record the kept LUKS slot" + say --foreground 1 "Could not record the disk's key slot for the boot checks; will retry." + return 1 + fi + + if [[ -e $PROVISIONING_DIR/luks-key ]]; then + shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" || return 1 + fi + rm -f "$REKEY_STATE".?????? + [[ $phase == "done" ]] || rekey_state_put phase done +} diff --git a/install/provisioning/omarchy-provision-hardware.service b/install/provisioning/omarchy-provision-hardware.service new file mode 100644 index 00000000..a6638c8d --- /dev/null +++ b/install/provisioning/omarchy-provision-hardware.service @@ -0,0 +1,21 @@ +# Runs the hardware setup an image build deferred, on the machine it was built +# for, before owner setup and the login screen. Armed with its queue by +# omarchy-apply-hardware during the build (install/helpers/image-target.sh); +# retried on every boot until the queue is empty and any keyring an image build +# asked for exists, then it disables itself (the unit file stays; with neither +# file left it no longer runs). +[Unit] +Description=Omarchy first-boot hardware setup deferred from the image build +ConditionPathExists=|/var/lib/omarchy/image/deferred-steps +ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring +Before=omarchy-provision-owner.service display-manager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-provision-hardware +TimeoutStartSec=infinity +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/install/user/all.sh b/install/user/all.sh index d81c3e75..a9d15021 100644 --- a/install/user/all.sh +++ b/install/user/all.sh @@ -13,3 +13,6 @@ run_logged "$OMARCHY_INSTALL/user/hardware/vm-no-animations.sh" run_logged "$OMARCHY_INSTALL/user/default-keyring.sh" run_logged "$OMARCHY_INSTALL/user/mise.sh" +run_logged "$OMARCHY_INSTALL/user/dictation-default.sh" + +run_logged "$OMARCHY_INSTALL/user/platform-setup.sh" diff --git a/install/user/dictation-default.sh b/install/user/dictation-default.sh new file mode 100644 index 00000000..3a8f15c0 --- /dev/null +++ b/install/user/dictation-default.sh @@ -0,0 +1,8 @@ +# The settings package presets Superwhisper as the dictation backend, and it has +# no aarch64 build. A new user there starts with no backend selected and picks +# one in Setup > Defaults > Dictation. Any other selection is left alone. +dictation_default="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +if ! omarchy-hw-x86 && omarchy-cmd-missing superwhisper && + [[ -f $dictation_default && $(<"$dictation_default") == "superwhisper" ]]; then + rm -f "$dictation_default" +fi diff --git a/install/user/platform-setup.sh b/install/user/platform-setup.sh new file mode 100644 index 00000000..a83f3b01 --- /dev/null +++ b/install/user/platform-setup.sh @@ -0,0 +1,10 @@ +# The platform package's own setup for this user, last so it builds on every +# other leaf (omarchy-lifecycle-dispatch setup-user, see +# docs/lifecycle-dispatch.md). A no-op where the platform registers none. In an +# image build the detector would read the build host, so it waits for first run +# on the machine, which runs it again. +if [[ -e ${OMARCHY_IMAGE_ROOT:-}/var/lib/omarchy/image/target ]]; then + echo "Image build: the platform's user setup waits for first run on the machine" +else + omarchy-lifecycle-dispatch setup-user +fi diff --git a/manual/48-security.md b/manual/48-security.md index f600e7a0..36e76830 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -33,7 +33,7 @@ With Secure Boot enabled, changing USB boot protection requires access to your s ## Changing your passwords -You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. +You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Setup makes them the same. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. If a change is interrupted, run _Drive Encryption_ again: it asks for the password that unlocks the drive now and finishes the change. ## Passing on a machine you've already used diff --git a/migrations/1786391100.sh b/migrations/1786391100.sh index b67ecf82..6d66d58a 100644 --- a/migrations/1786391100.sh +++ b/migrations/1786391100.sh @@ -1,5 +1,12 @@ echo "Run the WPA handshake in software on Macs with Broadcom Wi-Fi" +# This quirk targets Intel Macs. Apple Silicon uses brcmfmac too and reports +# "Apple Inc." with a listed part (BCM4387 is 14e4:4433), but disabling its +# firmware supplicant makes firmware commands time out and breaks scanning +# entirely. A fresh install marks this done only for its first user, so an +# account added later still runs it. +omarchy-hw-aarch64-apple && exit 0 + # The install-time quirk only reaches machines set up after it shipped, and it # never covered Macs without a T2 at all, so an existing install on one still # cannot join a WPA2/WPA3 transition-mode network. See diff --git a/migrations/1786605598.sh b/migrations/1786605598.sh index ce9e9c0a..5ed1d817 100644 --- a/migrations/1786605598.sh +++ b/migrations/1786605598.sh @@ -11,6 +11,7 @@ echo "Rebuild the initramfs so NVIDIA-only systems shed nouveau's unused GSP fir # update as a .pacnew) keeps kms and correctly skips the rebuild. hooks_conf="${OMARCHY_MKINITCPIO_HOOKS_CONF:-/etc/mkinitcpio.conf.d/omarchy_hooks.conf}" +baseline_conf="${hooks_conf%/*}/00-omarchy-hooks.conf" nvidia_conf="${OMARCHY_MKINITCPIO_NVIDIA_CONF:-/etc/mkinitcpio.conf.d/nvidia.conf}" rebuild_marker="${OMARCHY_KMS_REBUILD_MARKER:-/var/lib/omarchy/migrations/1786605598}" @@ -22,9 +23,9 @@ omarchy-cmd-present limine-mkinitcpio || exit 0 # marker still retries an interrupted rebuild. [[ ! -e $rebuild_marker ]] || exit 0 -# Source the drop-ins in mkinitcpio's order (nvidia.conf sorts first) and read -# the HOOKS they produce. Skip conservatively if evaluation fails. -hooks=$(bash -c 'source "$1" && source "$2" && echo " ${HOOKS[*]} "' -- "$nvidia_conf" "$hooks_conf") || exit 0 +# Include the baseline when installed, then the hardware and filter drop-ins. +# Skip conservatively if evaluation fails. +hooks=$(bash -c 'if [[ -f $1 ]]; then source "$1" || exit 1; fi; source "$2" && source "$3" && echo " ${HOOKS[*]} "' -- "$baseline_conf" "$nvidia_conf" "$hooks_conf") || exit 0 [[ $hooks != *" kms "* ]] || exit 0 diff --git a/migrations/1791542175.sh b/migrations/1791542175.sh index 40bc4ba4..9984bda6 100644 --- a/migrations/1791542175.sh +++ b/migrations/1791542175.sh @@ -1,3 +1,6 @@ echo "Install Gliff, the Hyprland remote desktop over SSH" -omarchy-pkg-add gliff +# Gliff has no aarch64 build (install/omarchy-x86_64-only.packages). +if omarchy-hw-x86; then + omarchy-pkg-add gliff +fi diff --git a/shell/Commons/AudioNodes.qml b/shell/Commons/AudioNodes.qml new file mode 100644 index 00000000..448cfe55 --- /dev/null +++ b/shell/Commons/AudioNodes.qml @@ -0,0 +1,41 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "AudioNodesModel.js" as Model + +// Which PipeWire nodes the audio panel and the microphone widget leave out: +// the shell's own level meters, and the nodes a platform's audio processing +// adds, which its platform package names in the fixed platform root (see +// Model.parsePlatformAudio). No environment variable moves that file, and most +// machines have none, so nothing of theirs is left out. +Singleton { + id: root + + readonly property var hints: Model.parsePlatformAudio(hintsFile.missing ? "" : hintsFile.text()) + + // Whether the platform's hints hide a node, given the names of every node + // now in the graph (for a "replaced" node). + function platformHides(name, nodeNames) { + return Model.platformHidesNode(name, hints, nodeNames) + } + + function isShellLevelMeter(name) { + return Model.isShellLevelMeter(name) + } + + FileView { + id: hintsFile + + // A file that went away hints nothing, whatever text() still holds. + property bool missing: false + + path: "/usr/share/omarchy-platform/audio.json" + blockLoading: true + watchChanges: true + printErrors: false + onLoaded: missing = false + onLoadFailed: missing = true + onFileChanged: reload() + } +} diff --git a/shell/Commons/AudioNodesModel.js b/shell/Commons/AudioNodesModel.js new file mode 100644 index 00000000..c139e48e --- /dev/null +++ b/shell/Commons/AudioNodesModel.js @@ -0,0 +1,96 @@ +// Audio node rules the bar and the audio panel share. Plain functions, so the +// tests load this file in Node. + +// The level meter the shell runs itself: Quickshell's peak monitor stream, +// named after the shell or after its stream depending on the PipeWire version. +// It is not an app using the input. +function isShellLevelMeter(name) { + var value = String(name || "") + return value === "quickshell" || value === "quickshell-peak-monitor" +} + +function wholeNamePattern(pattern) { + if (typeof pattern !== "string" || pattern === "") return null + try { + return new RegExp("^(?:" + pattern + ")$") + } catch (e) { + return null + } +} + +// A platform's audio hints, which its own package ships in the platform root +// (/usr/share/omarchy-platform/audio.json, read by AudioNodes.qml; Omarchy +// ships none): nodes of its audio processing that are neither devices nor apps. +// +// { "hidden": ["", ...], +// "replaced": [{ "node": "", "by": "" }, ...] } +// +// A pattern is a regular expression matched against a whole node.name. A +// hidden node is never listed as an output, an input or an app stream, and +// never counts as a recording; a replaced node is hidden while a node matching +// "by" exists. Missing or malformed text means no hints, and an invalid pattern +// or entry is skipped. +function parsePlatformAudio(text) { + var hints = { hidden: [], replaced: [] } + var data = null + try { + data = JSON.parse(String(text || "")) + } catch (e) { + return hints + } + if (!data || typeof data !== "object" || Array.isArray(data)) return hints + + var hidden = Array.isArray(data.hidden) ? data.hidden : [] + for (var i = 0; i < hidden.length; i++) { + var pattern = wholeNamePattern(hidden[i]) + if (pattern) hints.hidden.push(pattern) + } + + var replaced = Array.isArray(data.replaced) ? data.replaced : [] + for (var j = 0; j < replaced.length; j++) { + var entry = replaced[j] + if (!entry || typeof entry !== "object") continue + var node = wholeNamePattern(entry.node) + var by = wholeNamePattern(entry.by) + if (node && by) hints.replaced.push({ node: node, by: by }) + } + return hints +} + +// Whether the platform's hints hide a node, given the names of every node now +// in the graph (for "replaced"). +function platformHidesNode(name, hints, nodeNames) { + var value = String(name || "") + if (value === "" || !hints) return false + var hidden = hints.hidden || [] + for (var i = 0; i < hidden.length; i++) + if (hidden[i].test(value)) return true + + var replaced = hints.replaced || [] + var names = Array.isArray(nodeNames) ? nodeNames : [] + for (var j = 0; j < replaced.length; j++) { + if (!replaced[j].node.test(value)) continue + for (var k = 0; k < names.length; k++) { + var other = String(names[k] || "") + if (other !== value && replaced[j].by.test(other)) return true + } + } + return false +} + +// `wpctl get-volume ` prints "Volume: 0.50", with " [MUTED]" when muted, on +// the same cubic scale as PwNodeAudio.volume. +function parseWpctlVolume(text) { + var match = /^Volume:\s+([0-9]+(?:\.[0-9]+)?)(\s+\[MUTED\])?$/.exec(String(text || "").trim()) + if (!match) return null + return { volume: parseFloat(match[1]), muted: !!match[2] } +} + +if (typeof module !== "undefined") { + module.exports = { + isShellLevelMeter: isShellLevelMeter, + parsePlatformAudio: parsePlatformAudio, + platformHidesNode: platformHidesNode, + parseWpctlVolume: parseWpctlVolume + } +} diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 87849c18..81792792 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -343,6 +343,13 @@ QtObject { readonly property QtObject bar: QtObject { readonly property int sizeHorizontal: root.barToken("size-horizontal", 26) readonly property int sizeVertical: root.barToken("size-vertical", 28) + // Calibrated notch cutout height in logical px, for panels whose real + // cutout differs from the derived default (see BarModel.notchHeight). + // 0 means derive it. Physical alignment, so it never scales with font. + readonly property int notchHeight: { + var v = Number(root.barOverrides["notch-height"]) + return (isFinite(v) && v > 0) ? Math.round(v) : 0 + } readonly property int iconSlot: root.barToken("icon-slot", 27) readonly property int iconCanvas: root.barToken("icon-canvas", 16) readonly property int iconFont: root.barToken("icon-font", 13) @@ -417,7 +424,7 @@ QtObject { } else if (section === "bar") { if (key === "scale-with-font") { nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont) - } else if (key === "size-horizontal" || key === "size-vertical") { + } else if (key === "size-horizontal" || key === "size-vertical" || key === "notch-height") { var b = parseInt(raw, 10) if (isFinite(b)) barOut[key] = b } diff --git a/shell/Commons/UntypedInput.qml b/shell/Commons/UntypedInput.qml new file mode 100644 index 00000000..f7dfd936 --- /dev/null +++ b/shell/Commons/UntypedInput.qml @@ -0,0 +1,152 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import "AudioNodesModel.js" as Model + +// Volume and mute of the default input when Quickshell gives it no +// PwNode.audio: Quickshell types only exact media classes, so a virtual source +// (an Audio/Source/Virtual, such as EasyEffects' or a platform's microphone +// mapping) has no volume or mute there. They go through wpctl instead, once for +// the whole shell. Reads and writes name the node by id, a reply for a node no +// longer tracked is dropped, and writes run one at a time with only the latest +// volume kept, so a slider drag cannot land on another input later. +Singleton { + id: root + + readonly property var source: Pipewire.defaultAudioSource + // The default input is untyped, so this drives it. + readonly property bool active: !!source && !source.audio + // Every handler reads nodeId itself: a property bound to it could still hold + // its old value when onNodeIdChanged runs. + readonly property int nodeId: active ? source.id : -1 + + property bool known: false + property real volume: 0 + property bool muted: false + + property int generation: 0 + property int readGeneration: -1 + property bool readAgain: false + property var pendingVolume: null + property var pendingMute: null + + // pactl subscribe is started again when it ends, unless it keeps ending at + // once (no pipewire-pulse), which would only respawn it forever. + property real subscribedAt: 0 + property int quickExits: 0 + + onNodeIdChanged: reset() + Component.onCompleted: reset() + + function reset() { + generation++ + known = false + pendingVolume = null + pendingMute = null + quickExits = 0 + events.running = nodeId >= 0 + refresh() + } + + function refresh() { + if (nodeId < 0) return + if (reader.running || writer.running) { + readAgain = true + return + } + readGeneration = generation + reader.command = ["wpctl", "get-volume", String(nodeId)] + reader.running = true + } + + function setVolume(value) { + if (nodeId < 0) return + volume = Math.max(0, Math.min(1, value)) + pendingVolume = [String(nodeId), volume.toFixed(2)] + flush() + } + + function setMuted(value) { + if (nodeId < 0 || !known) return + muted = value + pendingMute = [String(nodeId), value ? "1" : "0"] + flush() + } + + function flush() { + if (reader.running || writer.running) return true + var args = null + if (pendingMute !== null) { + args = ["wpctl", "set-mute"].concat(pendingMute) + pendingMute = null + } else if (pendingVolume !== null) { + args = ["wpctl", "set-volume"].concat(pendingVolume) + pendingVolume = null + } + if (!args) return false + writer.command = args + writer.running = true + return true + } + + function settle() { + if (flush()) return + if (readAgain) { + readAgain = false + refresh() + } + } + + Process { + id: reader + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var level = Model.parseWpctlVolume(text) + if (!level || root.nodeId < 0 || root.readGeneration !== root.generation) return + if (root.pendingVolume !== null || root.pendingMute !== null || writer.running) return + root.volume = level.volume + root.muted = level.muted + root.known = true + } + } + onExited: root.settle() + } + + Process { + id: writer + onExited: { + root.readAgain = true + root.settle() + } + } + + // The volume keys, the mute key and other apps change the node too. pactl + // translates its events, so they are read in the C locale. + Process { + id: events + command: ["pactl", "subscribe"] + environment: ({ LC_ALL: "C" }) + onRunningChanged: if (running) root.subscribedAt = Date.now() + onExited: { + root.quickExits = Date.now() - root.subscribedAt < 1000 ? root.quickExits + 1 : 0 + if (root.nodeId >= 0 && root.quickExits < 5) resubscribe.start() + } + stdout: SplitParser { + onRead: function(line) { + if (String(line).indexOf(" on source #") !== -1) root.refresh() + } + } + } + + Timer { + id: resubscribe + interval: 2000 + onTriggered: if (root.nodeId >= 0 && !events.running) { + events.running = true + root.refresh() + } + } +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 1c9101c1..660f5901 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -1,7 +1,9 @@ module qs.Commons +singleton AudioNodes 1.0 AudioNodes.qml singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml singleton IpcRegistry 1.0 IpcRegistry.qml singleton Style 1.0 Style.qml +singleton UntypedInput 1.0 UntypedInput.qml singleton Util 1.0 Util.qml ShellIpc 1.0 ShellIpc.qml diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 84680cb5..392a7c44 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -569,6 +569,61 @@ Item { readonly property bool vertical: position === "left" || position === "right" readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal + // Display cutouts (a camera notch) the platform's own package describes in + // the fixed platform root, which no environment variable moves. Most machines + // have none. It is read as the shell starts (blockLoading), so a bar surface + // knows before it maps whether its screen may have one. See + // BarModel.parseCutouts. + readonly property var displayCutouts: displayCutoutsFile.missing ? [] : BarModel.parseCutouts(displayCutoutsFile.text()) + FileView { + id: displayCutoutsFile + + // A file that went away describes nothing, whatever text() still holds. + property bool missing: false + + path: "/usr/share/omarchy-platform/display-cutouts.json" + blockLoading: true + watchChanges: true + printErrors: false + onLoaded: missing = false + onLoadFailed: missing = true + onFileChanged: reload() + } + + // The physical mode Hyprland reports for a screen, as hyprctl monitors does. + // Qt's whole-number devicePixelRatio can't rebuild it at a fractional scale. + function panelModeFor(screen) { + var monitor = screen ? Hyprland.monitorFor(screen) : null + return monitor ? ({ + width: monitor.width, + height: monitor.height, + transform: monitor.lastIpcObject ? monitor.lastIpcObject.transform : 0 + }) : null + } + + // How thick the bar is on a screen. A top bar shorter than a panel's camera + // cutout leaves a sliver of every window peeking out beside the camera, so + // the cutout is this panel's minimum sensible top-bar height. An + // intentionally taller bar still wins. + function thicknessFor(screen) { + if (root.vertical || !screen) return root.barSize + return Math.max(root.barSize, BarModel.notchFloor(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, root.panelModeFor(screen), Style.bar.notchHeight)) + } + + // Each screen's bar thickness, by screen name: barSize, or more where a notch + // floor raises a top bar. Toasts and plugins clear the bar by it; barSize + // stays the configured size the widgets are drawn at. + readonly property var screenBarSizes: { + var sizes = ({}) + var screens = Quickshell.screens + for (var i = 0; i < screens.length; i++) sizes[screens[i].name] = root.thicknessFor(screens[i]) + return sizes + } + + function barSizeFor(screenName) { + return BarModel.barSizeFor(root.screenBarSizes, screenName, root.barSize) + } + function normalizePosition(value) { return BarModel.normalizePosition(value) } @@ -1253,7 +1308,12 @@ Item { // reveal has to rebuild them — new surface, re-shaped glyphs, re-uploaded // textures — which measures ~150ms against ~20ms to tear down. Parking // keeps the surface alive, so showing is only a margin change. - visible: !remapGuard.remapping + // + // A top bar that can't tell its notch floor yet waits for Hyprland's mode + // before it maps (see BarModel.cutoutPending), so it maps at its floor + // instead of growing once the windows are laid out. The wait ends once, for + // good: when the mode settles it, or after two seconds without one. + visible: !remapGuard.remapping && (cutoutWaitOver || !cutoutPending) exclusionMode: root.barHidden ? ExclusionMode.Ignore : ExclusionMode.Auto ScreenMoveRemap { @@ -1261,11 +1321,13 @@ Item { window: barWindow } + // Parked by its full thickness, which a notch floor may make more than + // barSize, so no strip of it stays on screen. margins { - top: root.barHidden && root.position === "top" ? -root.barSize : 0 - bottom: root.barHidden && root.position === "bottom" ? -root.barSize : 0 - left: root.barHidden && root.position === "left" ? -root.barSize : 0 - right: root.barHidden && root.position === "right" ? -root.barSize : 0 + top: root.barHidden && root.position === "top" ? -barWindow.thickness : 0 + bottom: root.barHidden && root.position === "bottom" ? -barWindow.thickness : 0 + left: root.barHidden && root.position === "left" ? -barWindow.thickness : 0 + right: root.barHidden && root.position === "right" ? -barWindow.thickness : 0 } anchors { @@ -1275,8 +1337,26 @@ Item { right: root.position === "right" || !root.vertical } - implicitWidth: root.vertical ? root.barSize : 0 - implicitHeight: root.vertical ? 0 : root.barSize + readonly property var panelMode: root.panelModeFor(screen) + + readonly property bool centerBesideRight: BarModel.centerBesideRight(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode) + + readonly property bool cutoutPending: BarModel.cutoutPending(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode) + property bool cutoutWaitOver: false + onCutoutPendingChanged: if (!cutoutPending) cutoutWaitOver = true + Component.onCompleted: if (!cutoutPending) cutoutWaitOver = true + + Timer { + interval: 2000 + running: barWindow.cutoutPending && !barWindow.cutoutWaitOver + onTriggered: barWindow.cutoutWaitOver = true + } + + // The same thickness the bar publishes for its screen (screenBarSizes). + readonly property int thickness: root.thicknessFor(screen) + + implicitWidth: root.vertical ? thickness : 0 + implicitHeight: root.vertical ? 0 : thickness color: root.transparent ? "transparent" : root.background surfaceFormat.opaque: false WlrLayershell.namespace: "omarchy-bar" @@ -1367,7 +1447,10 @@ Item { Item { anchors.fill: parent - CenterModules { anchors.fill: parent } + CenterModules { + anchors.fill: parent + entries: barWindow.centerBesideRight ? [] : root.layoutEntries("center") + } LeftModules { anchors.left: parent.left @@ -1376,10 +1459,22 @@ Item { } RightModules { + id: rightModules anchors.right: parent.right anchors.rightMargin: Style.space(8) anchors.verticalCenter: parent.verticalCenter } + + // Keeps the center region, so settings pushes, drag and drop, and panel + // routing still address it. The gap lets a drop at the seam land in + // the section the pointer is nearer to. + ModuleList { + entries: barWindow.centerBesideRight ? root.layoutEntries("center") : [] + region: "center" + anchors.right: rightModules.left + anchors.rightMargin: Style.space(4) + anchors.verticalCenter: parent.verticalCenter + } } } @@ -1529,8 +1624,7 @@ Item { } } - function findCenterAnchorEntry() { - var entries = root.layoutEntries("center") + function findCenterAnchorEntry(entries) { var idx = root.entryIndex(entries, root.centerAnchor) return idx === -1 ? null : entries[idx] } @@ -1550,7 +1644,7 @@ Item { property var entries: root.layoutEntries("center") readonly property bool hasAnchor: root.entryIndex(entries, root.centerAnchor) !== -1 - readonly property var anchorEntry: root.findCenterAnchorEntry() + readonly property var anchorEntry: root.findCenterAnchorEntry(entries) Loader { anchors.fill: parent diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index 36900e5b..b9ce90e8 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -208,9 +208,122 @@ function nearestDropTarget(candidates, point, vertical) { return best } +// Display cutouts (a camera notch at the top of a laptop panel) are described +// by the platform's own package, in /usr/share/omarchy-platform/display-cutouts.json: +// +// { "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +// +// A panel matches a screen whose connector name starts with `connector` and +// whose mode is width x height physical pixels; `top` is how many physical rows +// at its top the cutout covers. Anything malformed is dropped. +function parseCutouts(text) { + var parsed + try { + parsed = JSON.parse(String(text || "")) + } catch (error) { + return [] + } + var panels = parsed && Array.isArray(parsed.panels) ? parsed.panels : [] + var cutouts = [] + for (var i = 0; i < panels.length; i++) { + var panel = panels[i] || {} + var width = Number(panel.width) + var height = Number(panel.height) + var top = Number(panel.top) + if (typeof panel.connector !== "string" || panel.connector === "") continue + if (!(width > 0) || !(height > 0) || !(top > 0) || top >= height) continue + cutouts.push({ connector: panel.connector, width: width, height: height, top: top }) + } + return cutouts +} + +// The cutout at the top of this screen, in logical pixels, or 0. `mode` is the +// output's physical mode as Hyprland reports it ({ width, height, transform }). +// Qt's devicePixelRatio is a whole number even at a fractional scale, so the +// logical size times it rebuilds the mode only at whole scales; that stands in +// just until Hyprland's answer arrives. A mode Hyprland reports that matches no +// panel has no cutout. +function cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + var name = String(screenName || "") + var logical = Number(logicalWidth) + var logicalTall = Number(logicalHeight) + if (!(logical > 0) || !(logicalTall > 0)) return 0 + var width = Number(mode && mode.width) + var height = Number(mode && mode.height) + if (width > 0 && height > 0) { + // Turned a quarter or upside down, the cutout is on another edge. + var transform = Number(mode.transform) || 0 + if (transform !== 0 && transform !== 4) return 0 + if ((width > height) !== (logical > logicalTall)) return 0 + } else { + var scale = Number(devicePixelRatio) > 0 ? Number(devicePixelRatio) : 1 + width = Math.round(logical * scale) + height = Math.round(logicalTall * scale) + } + var list = Array.isArray(cutouts) ? cutouts : [] + for (var i = 0; i < list.length; i++) { + var panel = list[i] + if (name.indexOf(panel.connector) !== 0) continue + // Logical sizes are rounded, so a rebuilt mode can be a couple of pixels off. + if (Math.abs(width - panel.width) <= 4 && Math.abs(height - panel.height) <= 4) + return Math.ceil(panel.top * logical / width) + } + return 0 +} + +// The height a top bar on this screen must reach to cover its cutout, or a +// calibrated [bar] notch-height in its place. A screen without a cutout, or a +// bar on another edge, has no floor, so a calibration never reaches an +// external monitor. +function notchFloor(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode, calibrated) { + if (position !== "top") return 0 + var top = cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) + if (!(top > 0)) return 0 + return Number(calibrated) > 0 ? Math.round(Number(calibrated)) : top +} + +// A cutout covers the middle of a top bar, so that bar draws its center +// section beside the right one. +function centerBesideRight(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + return position === "top" && cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0 +} + +// Whether a top bar can't tell its floor yet: Hyprland hasn't reported the +// screen's mode, the logical size times Qt's ratio matched no panel (as at a +// fractional scale), and a described panel's connector matches the screen, so +// the mode may still bring a cutout. Such a bar waits for the mode before it +// maps rather than guess a floor. A screen no entry's connector matches never +// waits. +function cutoutPending(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + if (position !== "top") return false + if (Number(mode && mode.width) > 0 && Number(mode && mode.height) > 0) return false + var name = String(screenName || "") + var list = Array.isArray(cutouts) ? cutouts : [] + var described = false + for (var i = 0; i < list.length; i++) { + if (name.indexOf(list[i].connector) === 0) described = true + } + return described && !(cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0) +} + +// The bar's thickness on the screen with this name, from the sizes a bar +// publishes by screen name, or `fallback` (its configured size) for a screen +// it has none for. +function barSizeFor(sizes, screenName, fallback) { + var name = String(screenName || "") + var size = sizes && Object.prototype.hasOwnProperty.call(sizes, name) ? Number(sizes[name]) : 0 + return size > 0 ? size : fallback +} + if (typeof module !== "undefined") { module.exports = { + barSizeFor: barSizeFor, + centerBesideRight: centerBesideRight, + cutoutPending: cutoutPending, + cutoutTop: cutoutTop, isDrawnSlot: isDrawnSlot, + notchFloor: notchFloor, + parseCutouts: parseCutouts, pickDrawnSlot: pickDrawnSlot, pickPanelSlot: pickPanelSlot, nearestDropTarget: nearestDropTarget, diff --git a/shell/plugins/bar/indicators/ScreenRecording.qml b/shell/plugins/bar/indicators/ScreenRecording.qml index 271e1c39..59b4cab2 100644 --- a/shell/plugins/bar/indicators/ScreenRecording.qml +++ b/shell/plugins/bar/indicators/ScreenRecording.qml @@ -15,7 +15,7 @@ BarIndicator { function refresh() { if (!root.bar || statusProc.running) return - statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"] + statusProc.command = ["omarchy-capture-screenrecording", "--status"] statusProc.running = true } diff --git a/shell/plugins/bar/widgets/Microphone.qml b/shell/plugins/bar/widgets/Microphone.qml index 4f1bf211..10b0e1f9 100644 --- a/shell/plugins/bar/widgets/Microphone.qml +++ b/shell/plugins/bar/widgets/Microphone.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Services.Pipewire import qs.Ui +import qs.Commons BarWidget { id: root @@ -9,15 +10,28 @@ BarWidget { readonly property var source: Pipewire.defaultAudioSource - readonly property bool muted: source && source.audio ? source.audio.muted : true - readonly property real volume: source && source.audio ? source.audio.volume : 0 + // A virtual source is untyped in Quickshell, so it has no PwNode.audio; its + // volume and mute go through wpctl (UntypedInput). + readonly property bool viaWpctl: UntypedInput.active + readonly property bool muted: viaWpctl ? (!UntypedInput.known || UntypedInput.muted) : (source && source.audio ? source.audio.muted : true) + readonly property real volume: viaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0) readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + readonly property var nodeNames: { + var names = [] + for (var i = 0; i < nodes.length; i++) + if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name)) + return names + } readonly property var activeStreams: { var list = [] for (var i = 0; i < nodes.length; i++) { var node = nodes[i] - if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node) + if (!node || !node.isStream || node.isSink !== false || node.audio?.muted) continue + // The shell's own level meters, and a platform's audio processing that + // records all the time, are not an app using the microphone. + if (AudioNodes.isShellLevelMeter(node.name) || AudioNodes.platformHides(node.name, nodeNames)) continue + list.push(node) } return list } @@ -29,7 +43,10 @@ BarWidget { implicitHeight: button.implicitHeight function toggleMute() { - if (source && source.audio) source.audio.muted = !source.audio.muted + if (viaWpctl) { + if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted) + } + else if (source && source.audio) source.audio.muted = !source.audio.muted } PwObjectTracker { objects: root.source ? [root.source] : [] } @@ -46,8 +63,12 @@ BarWidget { else root.toggleMute() } onWheelMoved: function(delta) { - if (!root.source || !root.source.audio) return var step = 0.05 + if (root.viaWpctl) { + if (UntypedInput.known) UntypedInput.setVolume(root.volume + (delta > 0 ? step : -step)) + return + } + if (!root.source || !root.source.audio) return root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step))) } } diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b152dc06..6ba2b6ab 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -412,6 +412,19 @@ function popupExpired(entry, duration, now) { return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime } +// How far a toast on a screen keeps from a top or right bar: the bar's +// thickness on that screen (a notch floor can make a top bar thicker than its +// configured size on one screen), plus the gap. A bar without per-screen +// sizes counts its configured size; a hidden or missing one, the default size. +function barClearance(bar, screenName, defaultBarSize, gapsOut) { + var size = Number(defaultBarSize) + if (bar && !bar.barHidden) { + size = Number(typeof bar.barSizeFor === "function" ? bar.barSizeFor(screenName) : bar.barSize) + size = isFinite(size) ? Math.max(0, size) : 0 + } + return size + Number(gapsOut) +} + function popupPlacement(barPosition, barClearance, gapsOut) { var position = String(barPosition || "top") var clearance = Number(barClearance) @@ -492,6 +505,7 @@ if (typeof module !== "undefined") { serializePopup: serializePopup, parsePopupFiles: parsePopupFiles, popupExpired: popupExpired, + barClearance: barClearance, popupPlacement: popupPlacement } } diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index cba13cbe..a239b19f 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -45,11 +45,15 @@ Item { // pull notification popups away from the expected top-right location. // Falls back to the bar's default size (26 horizontal / 28 vertical) when // shell.bar isn't reachable so the popup never lands on top of the bar. + // Each screen clears the bar as thick as it is there: a notch floor can make + // a top bar taller than its configured size on one screen only. readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top" readonly property bool barVertical: barPosition === "left" || barPosition === "right" readonly property int defaultBarSize: barVertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal - readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize - readonly property int barClearance: liveBarSize + Style.gapsOut + + function barClearanceFor(screenName) { + return NotificationLogic.barClearance(shell ? shell.bar : null, screenName, defaultBarSize, Style.gapsOut) + } // Live Notification objects by originalId, kept OUT of the ListModels: a // QObject stored in a model role becomes a dangling C++ pointer when the @@ -1009,7 +1013,7 @@ Item { color: "transparent" readonly property var popupPlacement: NotificationLogic.popupPlacement( - service.barPosition, service.barClearance, Style.gapsOut) + service.barPosition, service.barClearanceFor(modelData ? modelData.name : ""), Style.gapsOut) // Full-screen, fixed-size surface (like the OSD overlay). Adding or // removing a toast changes only the content inside; the Wayland surface diff --git a/shell/plugins/panels/audio/Model.js b/shell/plugins/panels/audio/Model.js index 03b2f8aa..32a006c2 100644 --- a/shell/plugins/panels/audio/Model.js +++ b/shell/plugins/panels/audio/Model.js @@ -256,6 +256,16 @@ function streamRepresentsPlayer(node, player, players, streams) { return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel) } +// Quickshell types only exact media classes, so a virtual source (an +// Audio/Source/Virtual, such as EasyEffects' or a platform's microphone +// mapping) is a node with no PwNode.audio. PulseAudio lists it as a source, so +// the source availability listing names it; a non-stream node it names is one. +function isUntypedSource(node, sourceAvailability) { + if (!node || node.audio || node.isStream || node.isSink) return false + var name = String(node.name || "") + return name !== "" && !!sourceAvailability && Object.prototype.hasOwnProperty.call(sourceAvailability, name) +} + if (typeof module !== "undefined") { module.exports = { isPlaybackStream: isPlaybackStream, @@ -281,6 +291,7 @@ if (typeof module !== "undefined") { matchingMprisStreamLabel: matchingMprisStreamLabel, unmatchedMprisStreamLabel: unmatchedMprisStreamLabel, streamLabel: streamLabel, - streamRepresentsPlayer: streamRepresentsPlayer + streamRepresentsPlayer: streamRepresentsPlayer, + isUntypedSource: isUntypedSource } } diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 7f336a31..46cb39a8 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -21,11 +21,25 @@ Panel { readonly property var mediaService: bar?.shell?.firstPartyServiceFor("omarchy.media") readonly property var activeMediaPlayer: mediaService ? mediaService.activePlayer : null + // Nodes of a platform's audio processing, which are neither devices nor apps, + // stay out of every list (AudioNodes.platformHides). Most machines have none. + readonly property var nodeNames: { + var names = [] + for (var i = 0; i < nodes.length; i++) + if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name)) + return names + } + + function platformHides(node) { + return !!node && AudioNodes.platformHides(node.name, nodeNames) + } + readonly property var candidateSinks: { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && n.isSink && !n.isStream) list.push(n) + if (!n || !n.isSink || n.isStream || platformHides(n)) continue + list.push(n) } return list } @@ -34,11 +48,11 @@ Panel { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && !n.isSink && !n.isStream && isAudioSource(n)) { - var name = n.name || "" - if (name === "quickshell") continue - list.push(n) - } + if (!n || n.isSink || n.isStream) continue + if (!isAudioSource(n) && !Model.isUntypedSource(n, sourceAvailability)) continue + var name = n.name || "" + if (name === "quickshell" || platformHides(n)) continue + list.push(n) } return list } @@ -51,6 +65,7 @@ Panel { // A tuning's output is a playback stream too, but it is the processing // itself rather than an application, so it does not belong in the list. if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue + if (platformHides(n)) continue list.push(n) } return list @@ -58,6 +73,7 @@ Panel { property var sinkAvailability: ({}) property bool sinkAvailabilityLoaded: false + property var sourceAvailability: ({}) // Identify true playback streams without reading node.properties here: // PwNode.properties is invalid until the node is bound, and reading it while @@ -148,8 +164,15 @@ Panel { readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0 readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false - readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 - readonly property bool inputMuted: source && source.audio ? source.audio.muted : false + // A virtual source is untyped in Quickshell (see Model.isUntypedSource), so + // its volume and mute go through wpctl (UntypedInput), and the panel shows no + // level meter for it: Quickshell's peak monitor takes typed nodes only. + readonly property bool inputViaWpctl: UntypedInput.active + readonly property bool inputLevelKnown: !inputViaWpctl || UntypedInput.known + readonly property real inputVolume: inputViaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0) + readonly property bool inputMuted: inputViaWpctl ? UntypedInput.muted : (source && source.audio ? source.audio.muted : false) + readonly property var inputPeakNode: inputViaWpctl ? null : source + readonly property bool inputLevelShown: !!inputPeakNode onRawAudioSinksChanged: if (rawAudioSinks.length > 0) cachedAudioSinks = rawAudioSinks onRawAudioSourcesChanged: if (rawAudioSources.length > 0) cachedAudioSources = rawAudioSources @@ -175,7 +198,7 @@ Panel { // would otherwise report "input unmuted" forever, leaving the hero switch // able to mute but never to unmute. readonly property bool hasOutput: !!(volumeSink && volumeSink.audio) - readonly property bool hasInput: !!(source && source.audio) + readonly property bool hasInput: !!(source && source.audio) || (inputViaWpctl && UntypedInput.known) readonly property bool anyAudible: (hasOutput && !outputMuted) || (hasInput && !inputMuted) readonly property string toggleHint: anyAudible ? "Mute" : "Unmute" @@ -427,7 +450,7 @@ Panel { } function inputIcon() { - if (!source || !source.audio) return "󰍭" + if (!hasInput) return "󰍭" return inputMuted ? "󰍭" : "󰍬" } @@ -454,6 +477,10 @@ Panel { } function setInputVolume(v) { + if (inputViaWpctl) { + UntypedInput.setVolume(v) + return + } if (!source || !source.audio) return source.audio.volume = Math.max(0, Math.min(1, v)) } @@ -463,7 +490,10 @@ Panel { } function toggleInputMute() { - if (source && source.audio) source.audio.muted = !source.audio.muted + if (inputViaWpctl) { + if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted) + } + else if (source && source.audio) source.audio.muted = !source.audio.muted } // The hero switch is the whole panel's on/off, so it carries both channels @@ -472,7 +502,8 @@ Panel { function toggleAllMuted() { var mute = anyAudible if (hasOutput) volumeSink.audio.muted = mute - if (hasInput) source.audio.muted = mute + if (hasInput && inputViaWpctl) UntypedInput.setMuted(mute) + else if (hasInput) source.audio.muted = mute } function setDefaultSink(node) { @@ -489,7 +520,8 @@ Panel { function setDefaultSource(node) { if (!node) return - Pipewire.preferredDefaultAudioSource = node + // Quickshell refuses an untyped node; the command below still selects it. + if (node.audio) Pipewire.preferredDefaultAudioSource = node if (node.id !== undefined && node.name) { Quickshell.execDetached([ "omarchy-audio-input-set-default", @@ -597,8 +629,8 @@ Panel { PwNodePeakMonitor { id: inputPeakMonitor - node: root.source - enabled: root.opened && !!root.source + node: root.inputPeakNode + enabled: root.opened && !!root.inputPeakNode } Process { @@ -610,6 +642,15 @@ Panel { } } + Process { + id: sourceAvailabilityProc + command: ["omarchy-audio-sink-availability", "sources"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.sourceAvailability = Model.parseSinkAvailability(text) + } + } + Process { id: volumeSinkProc command: ["omarchy-audio-output-sink"] @@ -624,7 +665,10 @@ Panel { running: root.opened repeat: true triggeredOnStart: true - onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true + onTriggered: { + if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true + if (!sourceAvailabilityProc.running) sourceAvailabilityProc.running = true + } } // Runs whether or not the panel is open: the bar shows and scrolls the output @@ -908,7 +952,7 @@ Panel { Text { id: microphonePercent textFormat: Text.PlainText - text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" + text: root.inputLevelKnown ? Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" : "–" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption @@ -932,9 +976,13 @@ Panel { Column { id: inputControls - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) + // Alone, the slider centres in the row the way the output slider does. + anchors.topMargin: root.inputLevelShown ? 0 : Style.spacing.controlGap / 2 spacing: Style.space(5) PanelSlider { @@ -953,6 +1001,7 @@ Panel { } Rectangle { + visible: root.inputLevelShown width: parent.width height: Math.max(Style.space(5), Style.spacing.xs) color: Util.alpha(root.bar.foreground, 0.18) diff --git a/shell/services/PluginBarStateApi.qml b/shell/services/PluginBarStateApi.qml index db802b77..82b22718 100644 --- a/shell/services/PluginBarStateApi.qml +++ b/shell/services/PluginBarStateApi.qml @@ -6,7 +6,18 @@ QtObject { required property string ownerPluginId property bool barHidden: false + // The configured size. A notch floor can make a top bar thicker on one + // screen; barSizes holds each screen's thickness by screen name. property int barSize: 0 + property var barSizes: ({}) property string fontFamily: "" property string position: "top" + + // The bar's thickness on the screen with this name, to clear the bar by; + // barSize for a screen the bar reports none for. + function barSizeFor(screenName) { + var name = String(screenName || "") + var size = barSizes && Object.prototype.hasOwnProperty.call(barSizes, name) ? Number(barSizes[name]) : 0 + return size > 0 ? size : barSize + } } diff --git a/shell/shell.qml b/shell/shell.qml index b89831ed..458ad1de 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -439,12 +439,26 @@ ShellRoot { return api } + // A copy of the active bar's per-screen thickness (screen name -> number), + // so a plugin holds no reference into the bar. A bar that reports none + // gives an empty map. + function detachedBarSizes(sizes) { + var copy = ({}) + if (!Util.isPlainObject(sizes)) return copy + for (var name in sizes) { + var size = Number(sizes[name]) + if (size > 0) copy[name] = size + } + return copy + } + function pluginBarStateFor(cacheKey, pluginId) { if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey] var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId }) if (!api) return null api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false }) api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 }) + api.barSizes = Qt.binding(function() { return shell.bar ? shell.detachedBarSizes(shell.bar.screenBarSizes) : ({}) }) api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" }) api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" }) var next = ({}) diff --git a/test/shell.d/apple-silicon-quirks-test.sh b/test/shell.d/apple-silicon-quirks-test.sh new file mode 100644 index 00000000..7ec697f6 --- /dev/null +++ b/test/shell.d/apple-silicon-quirks-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# PC and Intel Mac quirks that must leave Apple Silicon alone, run on both sides +# of the detector. + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +conf="$test_tmp/modprobe.d/hid_apple.conf" +mkdir -p "$stub_bin" + +cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH' +#!/bin/bash +[[ ${APPLE_SILICON:-0} == "1" ]] +SH + +cat >"$stub_bin/uname" <<'SH' +#!/bin/bash +if [[ $1 == "-m" ]]; then + echo "${UNAME_M:-x86_64}" +else + exec /usr/bin/uname "$@" +fi +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" +"$@" +SH + +chmod +x "$stub_bin"/* + +run_fkeys() { + APPLE_SILICON="$1" OMARCHY_HID_APPLE_CONF="$conf" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + bash -eE -o pipefail -c 'source "$1"' _ "$ROOT/install/hardware/fix-fkeys.sh" +} + +run_fkeys 0 +[[ $(<"$conf") == "options hid_apple fnmode=2" ]] || fail "a PC keeps F-keys first on Apple-like keyboards" "$(cat "$conf")" +pass "a PC keeps F-keys first on Apple-like keyboards" + +printf 'options hid_apple fnmode=0\n' >"$conf" +run_fkeys 0 +[[ $(<"$conf") == "options hid_apple fnmode=0" ]] || fail "an existing hid_apple.conf is left alone" +pass "an existing hid_apple.conf is left alone" + +rm -rf "$test_tmp/modprobe.d" +: >"$calls" +run_fkeys 1 +[[ ! -e $conf && ! -s $calls ]] || fail "Apple Silicon keeps its own keyboard mode" "$(cat "$calls")" +pass "Apple Silicon keeps its own keyboard mode" + +# The Windows guest is x86_64, so every other CPU refuses it before it touches +# anything, Apple Silicon or not. +for machine in aarch64 armv7l; do + output=$(UNAME_M=$machine APPLE_SILICON=0 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" install 2>&1) && + fail "Windows VM install fails on $machine" + [[ $output == *"needs an x86_64 machine"* ]] || fail "Windows VM says why it refuses on $machine" "$output" +done +pass "Windows VM refuses every CPU but x86_64" + +# The firmware boot entry and hibernation are refused or skipped on Apple +# Silicon before they touch anything. +output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-setup-direct-boot" 2>&1) && + fail "direct boot fails on Apple Silicon" +[[ $output == *"not supported on Apple Silicon"* ]] || fail "direct boot says why it refuses" "$output" +output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-hibernation-setup" --force 2>&1) || + fail "hibernation setup skips Apple Silicon without failing" "$output" +[[ $output == "Skipping hibernation setup (not supported on Apple Silicon)" ]] || + fail "hibernation setup stops before its own checks on Apple Silicon" "$output" +pass "direct boot and hibernation step aside on Apple Silicon" + +# On x86_64 the Windows VM goes on to its own commands, as before. +output=$(UNAME_M=x86_64 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" help 2>&1) || true +[[ $output != *"x86_64 machine"* && $output == *"install"* ]] || fail "Windows VM runs as before on x86_64" "$output" +pass "Windows VM runs as before on x86_64" + +run_node_test <<'JS' +const fs = require('fs') +const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') +const items = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')) +const when = id => (items.find(entry => entry.id === id) || {}).when +assertEqual(when('install.windows'), 'omarchy-hw-x86', 'install.windows is offered on x86_64 only') +assertEqual(when('setup.direct-boot'), '! omarchy-hw-aarch64-apple', 'setup.direct-boot is hidden on Apple Silicon only') +JS +pass "the menu offers Windows on x86_64 only and hides Direct Boot on Apple Silicon only" diff --git a/test/shell.d/audio-input-set-default-test.sh b/test/shell.d/audio-input-set-default-test.sh new file mode 100644 index 00000000..5d9d5ab5 --- /dev/null +++ b/test/shell.d/audio-input-set-default-test.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" + +cat >"$work/bin/wpctl" <<'SH' +#!/bin/bash +printf 'wpctl %s\n' "$*" >>"$CALLS" +SH +cat >"$work/bin/pactl" <<'SH' +#!/bin/bash +printf 'pactl %s\n' "$*" >>"$CALLS" +if [[ $* == "list source-outputs" ]]; then + [[ ${LC_ALL:-} == C ]] || echo "pactl read outside the C locale" >>"$CALLS" + cat "$OUTPUTS" +fi +exit 0 +SH +chmod +x "$work/bin/wpctl" "$work/bin/pactl" + +moved() { + grep '^pactl move-source-output ' "$CALLS" | awk '{ print $3 }' | tr '\n' ' ' +} + +export CALLS="$work/calls" OUTPUTS="$work/outputs" + +# A filter chain's capture half (joined to its source by node.link-group) +# records its device all the time. Choosing an input moves apps, never it. +cat >"$OUTPUTS" <<'OUT' +Source Output #67 + Driver: PipeWire + Properties: + node.name = "filter_capture.mic" + node.group = "filter-chain-100-18" + node.link-group = "filter-chain-100-18" + media.name = "Microphone filter" +Source Output #90 + Driver: PipeWire + Properties: + application.name = "Firefox" + node.name = "Firefox" +Source Output #91 + Properties: + node.name = "native-recorder" + media.name = "node.link-group = in a title" +OUT +: >"$CALLS" +PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 113 virtual_mic +grep -qx 'wpctl set-default 113' "$CALLS" || fail 'input set-default selects the node' +grep -qx 'pactl set-default-source virtual_mic' "$CALLS" || fail 'input set-default names the default source' +! grep -q 'outside the C locale' "$CALLS" || fail 'input set-default reads the recordings in the C locale' +[[ $(moved) == "90 91 " ]] || fail 'input set-default moves apps but not a filter'"'"'s own capture' "$(moved)" +pass 'input set-default moves apps but not a filter'"'"'s own capture' + +# Without filters every recording moves, as before, whatever its properties. +cat >"$OUTPUTS" <<'OUT' +Source Output #12 + Properties: + application.name = "OBS" +Source Output #13 + Properties: + node.name = "native-recorder" +Source Output #14 + Properties: + node.name = "filter_capture.mic-copy" + node.group = "pipewire.dummy" +OUT +: >"$CALLS" +PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 43 alsa_input.pci-0000_00_1f.3.analog-stereo +[[ $(moved) == "12 13 14 " ]] || fail 'input set-default still moves every other recording' "$(moved)" +pass 'input set-default still moves every other recording' diff --git a/test/shell.d/audio-source-availability-test.sh b/test/shell.d/audio-source-availability-test.sh new file mode 100644 index 00000000..04a3e173 --- /dev/null +++ b/test/shell.d/audio-source-availability-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" + +cat >"$work/bin/pactl" <<'SH' +#!/bin/bash +printf '%s %s\n' "${LC_ALL:-unset}" "$*" >>"$CALLS" +case $* in + "list sources") cat "$SOURCES" ;; + "list sinks") cat "$SINKS" ;; +esac +SH +cat >"$work/bin/omarchy-audio-tuning" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$work/bin/pactl" "$work/bin/omarchy-audio-tuning" +export CALLS="$work/calls" SOURCES="$work/sources" SINKS="$work/sinks" + +# A headset jack input with nothing plugged in, a virtual source (no ports) +# and a filter's source (one port of unknown availability). +cat >"$SOURCES" <<'OUT' +Source #1074 + Name: alsa_input.pci-0000_00_1f.3.analog-stereo + Ports: + [In] analog-input-headset-mic: Headset Microphone (type: Headset, priority: 300, availability group: Headset Mic, not available) + Active Port: [In] analog-input-headset-mic + Formats: + pcm +Source #126 + Name: easyeffects_source + Formats: + pcm +Source #1102 + Name: effect_output.mic-filter + Ports: + [In] Mic: Microphone (type: Mic, priority: 100, availability unknown) + Active Port: [In] Mic +OUT +cat >"$SINKS" <<'OUT' +Sink #57 + Name: alsa_output.pci-0000_00_1f.3.analog-stereo + Ports: + [Out] analog-output-headphones: Headphones (type: Headphones, priority: 300, availability group: Headphone, not available) + Active Port: [Out] analog-output-headphones +OUT + +: >"$CALLS" +expected=$'alsa_input.pci-0000_00_1f.3.analog-stereo\t0\neasyeffects_source\t1\neffect_output.mic-filter\t1' +actual=$(PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability" sources) +[[ $actual == "$expected" ]] || fail 'source availability reports an empty jack input unavailable' "$actual" +grep -qx 'C list sources' "$CALLS" || fail 'source availability reads pactl in the C locale' "$(cat "$CALLS")" +pass 'source availability reports an empty jack input unavailable' + +: >"$CALLS" +actual=$(LC_ALL=de_DE.UTF-8 PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability") +[[ $actual == $'alsa_output.pci-0000_00_1f.3.analog-stereo\t0' ]] || fail 'sink availability reports an empty headphone jack unavailable' "$actual" +grep -qx 'C list sinks' "$CALLS" || fail 'sink availability reads pactl in the C locale whatever the caller'"'"'s' "$(cat "$CALLS")" +pass 'sink availability reports an empty headphone jack unavailable, in the C locale' diff --git a/test/shell.d/audio-test.sh b/test/shell.d/audio-test.sh index c0c962cb..a0e20321 100644 --- a/test/shell.d/audio-test.sh +++ b/test/shell.d/audio-test.sh @@ -57,4 +57,72 @@ assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'au assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams') assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS') assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player') + +// A virtual source is untyped in Quickshell (no audio); PulseAudio's source +// listing is what names it a source. +const availability = { virtual_mic: true, 'alsa_input.usb': false } +assert(audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, availability), 'audio finds an untyped source PulseAudio lists') +assert(audio.isUntypedSource({ name: 'unplugged_virtual', isSink: false, isStream: false }, { unplugged_virtual: false }), 'an untyped source PulseAudio lists as unavailable is still a source') +assert(!audio.isUntypedSource({ name: 'v4l2_input.camera', isSink: false, isStream: false }, availability), 'audio skips an untyped node PulseAudio does not list') +assert(!audio.isUntypedSource({ name: 'virtual_mic', audio: {}, isSink: false, isStream: false }, availability), 'a typed source is not untyped') +assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: true }, availability), 'a stream is never an untyped source') +assert(!audio.isUntypedSource({ name: '', isSink: false, isStream: false }, { '': true }), 'a nameless node is never an untyped source') +assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, {}), 'nothing is untyped before the listing') + +const nodes = requireFromRoot('shell/Commons/AudioNodesModel.js') + +for (const name of ['quickshell', 'quickshell-peak-monitor']) + assert(nodes.isShellLevelMeter(name), 'audio knows the shell meter ' + name) +for (const name of ['Firefox', 'quickshell-other', '', undefined]) + assert(!nodes.isShellLevelMeter(name), 'audio counts ' + name + ' as a recording') + +// Platform hints: none by default, whole-name patterns, and "replaced" only +// while its replacement exists. +const none = nodes.parsePlatformAudio('') +assertDeepEqual([none.hidden.length, none.replaced.length], [0, 0], 'audio has no platform hints without the file') +for (const text of ['not json', '[]', 'null', '{"hidden": "x"}', '{"replaced": [1, {"node": "a"}, {"node": "(", "by": "b"}]}']) { + const parsed = nodes.parsePlatformAudio(text) + assertEqual(parsed.hidden.length + parsed.replaced.length, 0, 'audio ignores platform hints ' + text) +} +const hints = nodes.parsePlatformAudio(JSON.stringify({ + hidden: ['dsp_capture\\.[a-z]+', 'raw_speakers', '(', 7], + replaced: [{ node: 'dsp_mic\\.[0-9]+', by: 'stereo_mic' }] +})) +assertEqual(hints.hidden.length, 2, 'audio skips invalid hidden patterns') +const names = ['dsp_capture.mic', 'dsp_mic.1', 'stereo_mic', 'raw_speakers'] +assert(nodes.platformHidesNode('dsp_capture.mic', hints, names), 'platform hints hide a matching node') +assert(nodes.platformHidesNode('raw_speakers', hints, []), 'platform hints hide a literal name') +assert(!nodes.platformHidesNode('raw_speakers.monitor', hints, []), 'platform patterns match whole names only') +assert(!nodes.platformHidesNode('xdsp_capture.mic', hints, []), 'platform patterns are anchored at the start') +assert(nodes.platformHidesNode('dsp_mic.1', hints, names), 'platform hints hide a replaced node while its replacement exists') +assert(!nodes.platformHidesNode('dsp_mic.1', hints, ['dsp_mic.1']), 'platform hints keep a replaced node without its replacement') +assert(!nodes.platformHidesNode('stereo_mic', hints, names), 'platform hints keep the replacement') +for (const name of ['Firefox', 'alsa_input.pci-0000_00_1f.3.analog-stereo', '', undefined]) + assert(!nodes.platformHidesNode(name, hints, names), 'platform hints keep ' + name) +assert(!nodes.platformHidesNode('dsp_capture.mic', none, names), 'no platform hints hide nothing') + +// An untyped source's level is read from wpctl. +assertDeepEqual(nodes.parseWpctlVolume('Volume: 0.50\n'), { volume: 0.5, muted: false }, 'audio reads a wpctl volume') +assertDeepEqual(nodes.parseWpctlVolume('Volume: 1.00 [MUTED]'), { volume: 1, muted: true }, 'audio reads a muted wpctl volume') +for (const text of ['', 'Translate ID error: 404', 'Volume: loud', 'Volume: 0.50 [muted]']) + assertEqual(nodes.parseWpctlVolume(text), null, 'audio rejects wpctl output ' + JSON.stringify(text)) + +// Source wiring (no Quickshell here): the hints come from the fixed platform +// root, and the audio code names no platform or hardware of its own. +const fs = require('fs') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') +const audioNodes = read('shell/Commons/AudioNodes.qml') +assert(/path: "\/usr\/share\/omarchy-platform\/audio\.json"/.test(audioNodes) && !/Quickshell\.env|OMARCHY_/.test(audioNodes), + 'audio reads the platform hints from the fixed platform root') +assert(/onLoadFailed: missing = true/.test(audioNodes), 'a removed hints file hides nothing') +const sources = ['shell/plugins/panels/audio/Panel.qml', 'shell/plugins/panels/audio/Model.js', 'shell/Commons/AudioNodes.qml', + 'shell/Commons/AudioNodesModel.js', 'shell/Commons/UntypedInput.qml', 'shell/plugins/bar/widgets/Microphone.qml', + 'bin/omarchy-audio-input-set-default', 'bin/omarchy-audio-sink-availability'] +for (const file of sources) + assert(!/apple|asahi|macbook|j[0-9]{3}|omarchy-hw-|platform-sound/i.test(read(file)), file + ' names no platform') + +const panel = read('shell/plugins/panels/audio/Panel.qml') +assert(/inputPeakNode: inputViaWpctl \? null : source/.test(panel) && /inputLevelShown: !!inputPeakNode/.test(panel) && + /visible: root\.inputLevelShown[^}]*inputPeakMonitor\.peak/.test(panel), + 'the input level bar is hidden when the input is driven through wpctl') JS diff --git a/test/shell.d/bar-notch-test.sh b/test/shell.d/bar-notch-test.sh new file mode 100644 index 00000000..fab5b1b0 --- /dev/null +++ b/test/shell.d/bar-notch-test.sh @@ -0,0 +1,235 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const bar = requireFromRoot('shell/plugins/bar/BarModel.js') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') +const styleSource = fs.readFileSync(root + '/shell/Commons/Style.qml', 'utf8') +const shellJson = fs.readFileSync(root + '/config/omarchy/shell.json', 'utf8') + +// A platform package's cutout description, as it would ship it. +const cutouts = bar.parseCutouts(JSON.stringify({ panels: [ + { connector: 'eDP', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 2560, height: 1664, top: 56 } +] })) +assertEqual(cutouts.length, 2, 'a well-formed description yields its panels') + +// Malformed descriptions and entries are dropped, never guessed at. +for (const text of ['', 'not json', '[]', '{}', '{"panels": {}}']) + assertDeepEqual(bar.parseCutouts(text), [], `an unusable description (${JSON.stringify(text)}) yields no cutouts`) +assertDeepEqual(bar.parseCutouts(JSON.stringify({ panels: [ + { width: 3024, height: 1964, top: 64 }, + { connector: '', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 0, height: 1964, top: 64 }, + { connector: 'eDP', width: 3024, height: 1964, top: 0 }, + { connector: 'eDP', width: 3024, height: 1964, top: 1964 }, + null +] })), [], 'entries without a connector, a size or a sensible cutout are dropped') + +// A cutout is described in physical rows; the bar works in logical pixels at +// any scale. Hyprland reports each output's physical mode, as hyprctl monitors +// does; Qt's devicePixelRatio is a whole number even at a fractional scale. +const mode = (width, height, transform = 0) => ({ width, height, transform }) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 32, 'a 64-row cutout is 32 px at scale 2') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 1, mode(3024, 1964)), 64, 'scale 1 uses the rows as they are') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1280, 832, 2, mode(2560, 1664)), 28, 'each described panel gets its own cutout') + +// A 16" MacBook Pro panel at scale 1.33: Qt says 2, Hyprland says 3456x2234. +const pro16 = [{ connector: 'eDP', width: 3456, height: 2234, top: 64 }] +assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), 48, 'a fractional scale matches its panel by the mode Hyprland reports') +assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234), 0), 48, 'a top bar at a fractional scale floors at the cutout') +assertEqual(bar.centerBesideRight(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), true, 'a top bar at a fractional scale moves the center section') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1890, 1228, 2, mode(3024, 1964)), 40, 'scale 1.6 matches too') + +// Until Hyprland answers, the logical size times Qt's ratio stands in for the +// mode, which holds at whole scales only. +for (const pending of [null, undefined, {}, mode(0, 0)]) + assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, pending), 32, `a whole scale matches before Hyprland reports the mode (${JSON.stringify(pending)})`) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 0, null), 64, 'a missing scale reads as 1') +assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, null), 0, 'a fractional scale finds nothing on Qt\'s ratio alone') + +// A mode Hyprland reports is the answer: one no panel describes has no cutout, +// whatever the logical size times the ratio would say. +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1890)), 0, 'the same panel with its cutout rows hidden has none') +assertEqual(bar.cutoutTop(cutouts, 'DP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'another connector with the same mode has none') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1728, 1117, 2, mode(3456, 2234)), 0, 'a panel nobody described has none') +assertEqual(bar.cutoutTop([], 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'with no description no panel has a cutout') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 0, 0, 2, mode(3024, 1964)), 0, 'degenerate screen sizes have none') + +// Turned a quarter or upside down, the cutout is on another edge; mirrored, +// it is still at the top. +for (const transform of [1, 2, 3, 5, 6, 7]) + assertEqual(bar.cutoutTop(cutouts, 'eDP-1', transform % 2 ? 982 : 1512, transform % 2 ? 1512 : 982, 2, mode(3024, 1964, transform)), 0, `a panel with transform ${transform} has no cutout at its top`) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964, 4)), 32, 'a mirrored panel keeps its cutout at the top') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 982, 1512, 2, mode(3024, 1964)), 0, 'a portrait screen on a landscape mode is turned, whatever its transform says') + +// Only a top bar on a described panel has a floor; a calibrated [bar] +// notch-height replaces the described value there and nowhere else. +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 32, 'a top bar floors at the cutout') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.notchFloor(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 0, `a ${position} bar has no floor`) +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 36, 'a calibrated notch-height overrides the described floor') +assertEqual(bar.notchFloor(cutouts, 'top', 'DP-1', 2560, 1440, 1, mode(2560, 1440), 36), 0, 'a calibrated notch-height never floors an external monitor') +assertEqual(bar.notchFloor([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 0, 'a calibrated notch-height never floors a panel without a cutout') +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 'x'), 32, 'an unreadable calibration falls back to the described floor') + +// Source wiring only (no Quickshell here): the bar reads the description from +// the fixed platform root, which no environment variable moves, not from a +// detector. +assert( + /path: "\/usr\/share\/omarchy-platform\/display-cutouts\.json"/.test(barSource) && + /readonly property var displayCutouts: displayCutoutsFile\.missing \? \[\] : BarModel\.parseCutouts\(displayCutoutsFile\.text\(\)\)/.test(barSource), + 'bar reads the platform package\'s cutout description from the platform root' +) +assert( + /id: displayCutoutsFile[\s\S]{0,400}blockLoading: true[\s\S]{0,200}onLoadFailed: missing = true/.test(barSource), + 'bar reads the description as the shell starts, before its surfaces map, and a removed file describes nothing' +) +assert(!/_PACKAGED_PATH|packagedPath/.test(barSource), 'no environment variable moves the cutout description') +assert(!/omarchy-hw-/.test(barSource), 'bar asks no hardware detector') +assert( + /BarModel\.notchFloor\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, root\.panelModeFor\(screen\), Style\.bar\.notchHeight\)/.test(barSource), + 'bar derives the floor from its own screen geometry, its mode and the calibration' +) +assert( + /function panelModeFor\(screen\) \{\s*var monitor = screen \? Hyprland\.monitorFor\(screen\) : null\s*return monitor \? \(\{\s*width: monitor\.width,\s*height: monitor\.height,\s*transform: monitor\.lastIpcObject \? monitor\.lastIpcObject\.transform : 0\s*\}\) : null/.test(barSource) && + /readonly property var panelMode: root\.panelModeFor\(screen\)/.test(barSource), + 'bar takes its screen\'s physical mode from Hyprland' +) +assert( + /function thicknessFor\(screen\) \{\s*if \(root\.vertical \|\| !screen\) return root\.barSize\s*return Math\.max\(root\.barSize, BarModel\.notchFloor\(/.test(barSource) && + /readonly property int thickness: root\.thicknessFor\(screen\)/.test(barSource) && + /implicitHeight: root\.vertical \? 0 : thickness/.test(barSource), + 'bar height is floored at the cutout, never shrunk to it' +) +// A hidden bar parks by that same thickness (bar-test.sh), so a floored top +// bar leaves no strip of itself on screen. + +// The calibration must not scale with the font: it describes physical pixels +// beside the camera. +assert( + /notchHeight:[\s\S]{0,240}barOverrides\["notch-height"\]/.test(styleSource) && + !/barToken\("notch-height"/.test(styleSource), + 'notch-height is read raw, not through the font-scaled bar tokens' +) + +// A top bar on a described panel draws its center section beside the right +// one; everything else keeps the configured layout. +for (const scale of [1, 2]) + assertEqual(bar.centerBesideRight(cutouts, 'top', 'eDP-1', 3024 / scale, 1964 / scale, scale, mode(3024, 1964)), true, `a described panel at scale ${scale} moves the center section`) +assertEqual(bar.centerBesideRight(cutouts, 'top', 'USB-2', 3440, 1440, 1, mode(3440, 1440)), false, 'an external monitor keeps the center section') +assertEqual(bar.centerBesideRight([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, 'a machine without a description keeps the center section') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.centerBesideRight(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, `a ${position} bar keeps the center section`) + +// The move is per screen and draw-time only: the user's layout is read as +// configured, and the center entries keep their order and their own region. +assert( + /centerBesideRight: BarModel\.centerBesideRight\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource), + 'each bar surface decides the move from its own screen' +) +assert( + /CenterModules \{\s*anchors\.fill: parent\s*entries: barWindow\.centerBesideRight \? \[\] : root\.layoutEntries\("center"\)\s*\}/.test(barSource), + 'a moved center section draws nothing in the middle of the bar' +) +assert( + /ModuleList \{\s*entries: barWindow\.centerBesideRight \? root\.layoutEntries\("center"\) : \[\]\s*region: "center"\s*anchors\.right: rightModules\.left\s*anchors\.rightMargin: Style\.space\(\d+\)/.test(barSource), + 'the center entries sit just left of the right section in their own region' +) + +// With no gap the last center slot and the first right slot would share an +// edge, and a drop there would always land in whichever registered first. +const seam = [ + { slot: 'right-first', x: 104, width: 20 }, + { slot: 'center-last', x: 80, width: 20 } +] +assertDeepEqual(bar.nearestDropTarget(seam, { x: 101 }, false), { slot: 'center-last', after: true }, 'a drop at the seam nearer the center section lands after its last entry') +assertDeepEqual(bar.nearestDropTarget(seam, { x: 103 }, false), { slot: 'right-first', after: false }, 'a drop at the seam nearer the right section lands before its first entry') +assert( + /anchorEntry: root\.findCenterAnchorEntry\(entries\)/.test(barSource), + 'an emptied center section does not keep a hidden copy of its anchor' +) + +// Until Hyprland reports the mode, a top bar waits to map where the mode may +// still bring a cutout: a described connector whose whole-scale rebuild +// matched nothing, as at a fractional scale. It never guesses a floor there. +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, null), true, 'a fractional scale on a described connector waits for the mode') +for (const pending of [undefined, {}, mode(0, 0)]) + assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, pending), true, `no mode yet (${JSON.stringify(pending)}) still waits`) +assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, null, 0), 0, 'a waiting bar has no guessed floor') +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), false, 'the mode ends the wait') +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 1728, 1117, 2, mode(3456, 2160)), false, 'a mode no panel describes ends the wait too') +assertEqual(bar.cutoutPending(cutouts, 'top', 'eDP-1', 1512, 982, 2, null), false, 'a whole scale that matches needs no wait') +assertEqual(bar.cutoutPending(cutouts, 'top', 'DP-1', 2560, 1440, 1, null), false, 'a connector no entry names never waits') +assertEqual(bar.cutoutPending([], 'top', 'eDP-1', 2592, 1676, 2, null), false, 'without a description nothing waits') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.cutoutPending(pro16, position, 'eDP-1', 2592, 1676, 2, null), false, `a ${position} bar never waits`) +assert( + /readonly property bool cutoutPending: BarModel\.cutoutPending\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource) && + /visible: !remapGuard\.remapping && \(cutoutWaitOver \|\| !cutoutPending\)/.test(barSource), + 'a bar surface maps only once its floor is known, and still remaps on a screen move' +) +assert( + /property bool cutoutWaitOver: false\s*onCutoutPendingChanged: if \(!cutoutPending\) cutoutWaitOver = true\s*Component\.onCompleted: if \(!cutoutPending\) cutoutWaitOver = true/.test(barSource) && + /Timer \{\s*interval: 2000\s*running: barWindow\.cutoutPending && !barWindow\.cutoutWaitOver\s*onTriggered: barWindow\.cutoutWaitOver = true\s*\}/.test(barSource), + 'the wait ends once, for good: when the mode settles it or after two seconds' +) + +// Toasts and plugins clear the bar as thick as it is on their screen. The bar +// publishes each screen's thickness by name from the same calculation its +// surfaces use; barSize stays the configured size the widgets are drawn at. +const sizes = { 'eDP-1': 40, 'DP-1': 26 } +assertEqual(bar.barSizeFor(sizes, 'eDP-1', 26), 40, 'a notch screen reports its floored thickness') +assertEqual(bar.barSizeFor(sizes, 'DP-1', 26), 26, 'another screen reports its own') +assertEqual(bar.barSizeFor(sizes, 'HDMI-A-1', 26), 26, 'a screen without a surface falls back to the configured size') +assertEqual(bar.barSizeFor({}, 'eDP-1', 26), 26, 'no published sizes fall back to the configured size') +assertEqual(bar.barSizeFor(sizes, 'constructor', 26), 26, 'inherited object keys are no screen') +assert( + /readonly property var screenBarSizes: \{\s*var sizes = \(\{\}\)\s*var screens = Quickshell\.screens\s*for \(var i = 0; i < screens\.length; i\+\+\) sizes\[screens\[i\]\.name\] = root\.thicknessFor\(screens\[i\]\)\s*return sizes\s*\}/.test(barSource) && + /function barSizeFor\(screenName\) \{\s*return BarModel\.barSizeFor\(root\.screenBarSizes, screenName, root\.barSize\)\s*\}/.test(barSource), + 'bar publishes every screen\'s thickness from the same calculation its surfaces use' +) +assert( + /readonly property int barSize: vertical \? Style\.bar\.sizeVertical : Style\.bar\.sizeHorizontal/.test(barSource), + 'barSize stays the configured size' +) + +// The plugin facade copies the sizes, holding no reference into the bar, and +// keeps barSize the configured size. +const shellSource = fs.readFileSync(root + '/shell/shell.qml', 'utf8') +const stateApiSource = fs.readFileSync(root + '/shell/services/PluginBarStateApi.qml', 'utf8') +const extract = (source, name) => { + const match = source.match(new RegExp(`function ${name}\\((\\w+)\\) \\{([\\s\\S]*?)\\n \\}\\n`)) + assert(match, `${name} is defined`) + return match +} +const detached = extract(shellSource, 'detachedBarSizes') +const Util = { isPlainObject: value => value !== null && typeof value === 'object' && !Array.isArray(value) } +const detachedBarSizes = new Function('Util', detached[1], detached[2]) +const live = { 'eDP-1': 40, 'DP-1': 26, broken: 'x', none: 0 } +const copy = detachedBarSizes.call(null, Util, live) +assertDeepEqual(copy, { 'eDP-1': 40, 'DP-1': 26 }, 'the plugin facade copies each screen\'s thickness') +assert(copy !== live, 'the copy is detached from the bar\'s map') +assertDeepEqual(detachedBarSizes.call(null, Util, undefined), {}, 'a bar without per-screen sizes gives an empty map') +assert( + /api\.barSize = Qt\.binding\(function\(\) \{ return shell\.bar \? Math\.max\(0, shell\.bar\.barSize \|\| 0\) : 0 \}\)/.test(shellSource) && + /api\.barSizes = Qt\.binding\(function\(\) \{ return shell\.bar \? shell\.detachedBarSizes\(shell\.bar\.screenBarSizes\) : \(\{\}\) \}\)/.test(shellSource), + 'plugins get the configured barSize and every screen\'s thickness' +) +const apiFor = extract(stateApiSource, 'barSizeFor') +const stateBarSizeFor = new Function('barSizes', 'barSize', apiFor[1], apiFor[2]) +assertEqual(stateBarSizeFor(copy, 26, 'eDP-1'), 40, 'a plugin reads a notch screen\'s thickness') +assertEqual(stateBarSizeFor(copy, 26, 'HDMI-A-1'), 26, 'a plugin falls back to barSize for a screen with none') +assertEqual(stateBarSizeFor({}, 26, 'eDP-1'), 26, 'a plugin under a bar without per-screen sizes gets barSize') + +const parsed = JSON.parse(shellJson) +assertEqual(parsed.bar.centerAnchor, 'omarchy.clock', 'shipped bar layout still anchors on the clock') +assert( + JSON.stringify(parsed.bar.layout.center).indexOf('omarchy.indicators') >= 0, + 'shipped bar layout still keeps the default center widgets' +) +JS diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index bc2b945f..79b7f9a4 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -81,8 +81,8 @@ assert( ) for (const edge of ['top', 'bottom', 'left', 'right']) { assert( - new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -root\\.barSize : 0`).test(barSource), - `a hidden bar parks past the ${edge} edge` + new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -barWindow\\.thickness : 0`).test(barSource), + `a hidden bar parks past the ${edge} edge by its whole thickness` ) } diff --git a/test/shell.d/base-test.sh b/test/shell.d/base-test.sh index 476b8f83..7deda36d 100644 --- a/test/shell.d/base-test.sh +++ b/test/shell.d/base-test.sh @@ -89,6 +89,58 @@ require_compositor() { exit 0 } +# Fake the hardware identity omarchy-hw-platform reads: a device tree under +# $1/proc and a uname in $1/bin reporting the platform's CPU. Run the code under +# test with OMARCHY_PROC_ROOT="$1/proc" and PATH="$1/bin:$ROOT/bin:...", so the +# real detector and its wrappers answer from the fixture. +fake_platform() { + local dir="$1" platform="$2" machine=aarch64 + local -a compatible=() + + case $platform in + aarch64-apple) compatible=(apple,j416c apple,t6021 apple,arm-platform) ;; + aarch64) compatible=(raspberrypi,5-model-b brcm,bcm2712) ;; + x86) machine=x86_64 ;; + *) fail "fake_platform knows the platform $platform" ;; + esac + + rm -rf "$dir/proc" + mkdir -p "$dir/proc" "$dir/bin" + if (( ${#compatible[@]} > 0 )); then + mkdir -p "$dir/proc/device-tree" + printf '%s\0' "${compatible[@]}" >"$dir/proc/device-tree/compatible" + fi + + cat >"$dir/bin/uname" <"$copy" && + chmod +x "$copy" || fail "copy $script for a fixture platform root" + grep -qF -- "$fixture" "$copy" && ! grep -qF /usr/share/omarchy-platform "$copy" || + fail "$(basename -- "$script") reads the platform root from the fixture" +} + run_node_test() { require_command node diff --git a/test/shell.d/battery-present-test.sh b/test/shell.d/battery-present-test.sh new file mode 100755 index 00000000..2ec66f96 --- /dev/null +++ b/test/shell.d/battery-present-test.sh @@ -0,0 +1,38 @@ +#!/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/macsmc-ac" "$test_tmp/CMB0" +printf 'Mains\n' >"$test_tmp/macsmc-ac/type" +printf 'Battery\n' >"$test_tmp/CMB0/type" +printf '1\n' >"$test_tmp/CMB0/present" + +OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts non-BAT native paths" +pass "battery presence accepts non-BAT native paths" + +printf '0\n' >"$test_tmp/CMB0/present" +if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then + fail "battery presence rejects an absent battery" +fi +pass "battery presence rejects an absent battery" + +# A wireless mouse registers a Battery of its own, scoped Device; it must not +# make a desktop look like a laptop. +printf '1\n' >"$test_tmp/CMB0/present" +printf 'System\n' >"$test_tmp/CMB0/scope" +mkdir -p "$test_tmp/hidpp_battery_0" +printf 'Battery\n' >"$test_tmp/hidpp_battery_0/type" +printf '1\n' >"$test_tmp/hidpp_battery_0/present" +printf 'Device\n' >"$test_tmp/hidpp_battery_0/scope" +OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts a System-scoped battery beside a peripheral" +pass "battery presence accepts a System-scoped battery beside a peripheral" + +rm -r "$test_tmp/CMB0" +if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then + fail "battery presence ignores a peripheral's Device-scoped battery" +fi +pass "battery presence ignores a peripheral's Device-scoped battery" diff --git a/test/shell.d/battery-status-test.sh b/test/shell.d/battery-status-test.sh index 88a7fa12..8478cdb8 100644 --- a/test/shell.d/battery-status-test.sh +++ b/test/shell.d/battery-status-test.sh @@ -8,20 +8,40 @@ tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT mkdir -p "$tmp_dir/bin" -mkdir -p "$tmp_dir/power/BAT0" -printf '900000\n' >"$tmp_dir/power/BAT0/current_now" -printf '12000000\n' >"$tmp_dir/power/BAT0/voltage_now" +# Apple Silicon: a battery not called BAT*, power_now signed by direction, and +# the thresholds and cycle count only under the native path. +mkdir -p "$tmp_dir/power/macsmc-battery" +printf '%s\n' '-10800000' >"$tmp_dir/power/macsmc-battery/power_now" +printf '75\n' >"$tmp_dir/power/macsmc-battery/charge_control_start_threshold" +printf '80\n' >"$tmp_dir/power/macsmc-battery/charge_control_end_threshold" +printf '212\n' >"$tmp_dir/power/macsmc-battery/cycle_count" cat >"$tmp_dir/bin/upower" <<'STUB' #!/bin/bash +# A wireless mouse enumerates first, as a battery_ device that is not a power +# supply; the machine's own battery follows it. if [[ $1 == "-e" ]]; then - echo "/org/freedesktop/UPower/devices/battery_BAT0" + echo "/org/freedesktop/UPower/devices/line_power_macsmc_ac" + echo "/org/freedesktop/UPower/devices/battery_hidpp_battery_0" + echo "/org/freedesktop/UPower/devices/battery_${OMARCHY_TEST_NATIVE_PATH//-/_}" + exit 0 +fi + +if [[ $1 == "-i" && $2 == */battery_hidpp_battery_0 ]]; then + cat <<'INFO' + native-path: hidpp_battery_0 + model: Wireless Mouse + power supply: no + state: discharging + percentage: 5% +INFO exit 0 fi if [[ $1 == "-i" ]]; then cat <<'INFO' - native-path: BAT0 + native-path: macsmc-battery + power supply: yes state: discharging energy: 28.3 Wh energy-full: 56.7 Wh @@ -36,13 +56,69 @@ exit 1 STUB chmod +x "$tmp_dir/bin/upower" -shell_output=$(OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +shell_output=$(OMARCHY_TEST_NATIVE_PATH=macsmc-battery OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) -grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports percentage" +grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports the machine's battery, not the mouse's" grep -Fx $'state\tdischarging' <<<"$shell_output" >/dev/null || fail "battery status reports state" grep -Fx $'rate\t10.8W' <<<"$shell_output" >/dev/null || fail "battery status reports live sysfs power rate" grep -Fx $'size\t56Wh' <<<"$shell_output" >/dev/null || fail "battery status reports full capacity" grep -Fx $'time\t2h 30m' <<<"$shell_output" >/dev/null || fail "battery status reports remaining time" +grep -Fx $'cycles\t212' <<<"$shell_output" >/dev/null || fail "battery status reports native-path cycle count" +grep -Fx $'threshold\t75-80%' <<<"$shell_output" >/dev/null || fail "battery status reports native-path charge thresholds" + +# The BAT* name the script used to key on is one of many; CMB0 is another. +mkdir -p "$tmp_dir/power/CMB0" +printf '7300000\n' >"$tmp_dir/power/CMB0/power_now" +sed -i 's/native-path: macsmc-battery/native-path: CMB0/' "$tmp_dir/bin/upower" +generic_output=$(OMARCHY_TEST_NATIVE_PATH=CMB0 OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +grep -Fx $'rate\t7.3W' <<<"$generic_output" >/dev/null || fail "battery status accepts arbitrary UPower battery paths" +pass "battery status supports Apple Silicon and arbitrary native battery paths" + +# An 80% hold threshold doesn't trip while the battery is still below it. +hold_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir" "$hold_dir"' EXIT + +mkdir -p "$hold_dir/bin" "$hold_dir/power/BAT0" "$hold_dir/power/ac" +printf 'Mains\n' >"$hold_dir/power/ac/type" +printf '1\n' >"$hold_dir/power/ac/online" +printf '80\n' >"$hold_dir/power/BAT0/charge_control_end_threshold" +cat >"$hold_dir/bin/upower" <<'STUB' +#!/bin/bash + +if [[ $1 == "-e" ]]; then + echo "/org/freedesktop/UPower/devices/battery_BAT0" + exit 0 +fi + +if [[ $1 == "-i" ]]; then + cat <<'INFO' + native-path: BAT0 + power supply: yes + state: charging + energy-full: 69.6 Wh + energy-rate: 0.1 W + time to full: 0.2 hours + percentage: 79.5% + charge-end-threshold: 80% +INFO + exit 0 +fi + +exit 1 +STUB +chmod +x "$hold_dir/bin/upower" + +hold_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) + +grep -Fx $'state\tcharging' <<<"$hold_output" >/dev/null || fail "a hold below its threshold is still charging" +pass "battery status doesn't trip a hold early" + +# Once the battery reaches the threshold, idle charging is holding. +sed -i 's/percentage: 79.5%/percentage: 80.0%/' "$hold_dir/bin/upower" +held_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +grep -Fx $'percentage\t80%' <<<"$held_output" >/dev/null || fail "threshold percentage still displays as 80%" +grep -Fx $'state\tholding' <<<"$held_output" >/dev/null || fail "idle charging at the threshold is holding" +pass "battery status reports holding once the raw percentage reaches the threshold" if matches=$(rg -n 'omarchy-battery-(capacity|remaining|remaining-time)' "$ROOT/bin" "$ROOT/test" "$ROOT/shell" "$ROOT/docs"); then fail "battery status owns capacity and remaining calculations" "$matches" diff --git a/test/shell.d/brave-widevine-test.sh b/test/shell.d/brave-widevine-test.sh new file mode 100644 index 00000000..ca9750c1 --- /dev/null +++ b/test/shell.d/brave-widevine-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stub_bin="$test_tmp/bin" +omarchy_path="$test_tmp/omarchy" +opt_path="$test_tmp/opt" +cdm="$opt_path/WidevineCdm/chromium" +calls="$test_tmp/calls.log" +mkdir -p "$stub_bin" "$test_tmp/home" "$omarchy_path/install/helpers" "$omarchy_path/config" + +# The real policy helper writes under /etc without sudo when run as root, so +# stand it in; this test covers only the CDM link. Its as_root goes through the +# sudo stub, which logs every link, whatever the test's EUID. +printf 'browser_policy_setup_dir() { :; }\nas_root() { sudo "$@"; }\n' >"$omarchy_path/install/helpers/browser-policy.sh" +cp "$ROOT/config/chromium-flags.conf" "$omarchy_path/config/" + +for command in omarchy-pkg-add omarchy-pkg-aur-add omarchy-install-chromium-copy-url omarchy-install-chromium-ytdlp omarchy-theme-set-browser omarchy-lifecycle-dispatch; do + printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command" +done +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo:%s\n' "$*" >>"$TEST_LOG" +"$@" +SH +chmod +x "$stub_bin"/* + +reset_opt() { + rm -rf "$opt_path" + mkdir -p "$opt_path" + local dir + for dir in "$@"; do + mkdir -p "$opt_path/$dir" + done +} + +run() { + : >"$calls" + HOME="$test_tmp/home" OMARCHY_PATH="$omarchy_path" OMARCHY_OPT_PATH="$opt_path" \ + TEST_LOG="$calls" PATH="$stub_bin:$PATH" "$@" >/dev/null +} + +install_browser() { + run bash "$ROOT/bin/omarchy-install-browser" "$1" +} + +links_made() { + grep -c '^sudo:ln ' "$calls" || true +} + +assert_cdm_linked() { + [[ -L $opt_path/$1/WidevineCdm && $(readlink "$opt_path/$1/WidevineCdm") == "$cdm" ]] || + fail "$2" "$(ls -la "$opt_path/$1" 2>&1)" +} + +for browser in brave brave-origin; do + install_dir=$browser-bin + + reset_opt WidevineCdm/chromium "$install_dir" + install_browser "$browser" + assert_cdm_linked "$install_dir" "$browser install links the system Widevine CDM into $install_dir" + install_browser "$browser" + (( $(links_made) == 0 )) || fail "$browser reinstall keeps the existing CDM link" "$(<"$calls")" + assert_cdm_linked "$install_dir" "$browser reinstall keeps the existing CDM link" + pass "$browser install links the system Widevine CDM into $install_dir once" + + reset_opt "$install_dir" + install_browser "$browser" + [[ ! -e $opt_path/$install_dir/WidevineCdm && ! -L $opt_path/$install_dir/WidevineCdm ]] || + fail "$browser install links nothing without the widevine package" + pass "$browser install links nothing without the widevine package" + + reset_opt WidevineCdm/chromium "$install_dir" own-cdm + ln -s "$opt_path/own-cdm" "$opt_path/$install_dir/WidevineCdm" + install_browser "$browser" + [[ $(readlink "$opt_path/$install_dir/WidevineCdm") == "$opt_path/own-cdm" ]] || + fail "$browser install keeps a CDM the browser already has" + pass "$browser install keeps a CDM the browser already has" +done + +reset_opt WidevineCdm/chromium +install_browser chromium +(( $(links_made) == 0 )) || fail "Chromium install leaves Widevine to the widevine package" "$(<"$calls")" +pass "Chromium install leaves Widevine to the widevine package" diff --git a/test/shell.d/brcmfmac-supplicant-test.sh b/test/shell.d/brcmfmac-supplicant-test.sh index 7e476d73..b02af983 100755 --- a/test/shell.d/brcmfmac-supplicant-test.sh +++ b/test/shell.d/brcmfmac-supplicant-test.sh @@ -9,6 +9,11 @@ fix_t2="$ROOT/install/hardware/apple/fix-t2.sh" all="$ROOT/install/hardware/all.sh" migration="$ROOT/migrations/1786391100.sh" +# Apple Silicon's BCM4387 is in the ID list, but there the firmware supplicant +# is the one that works, so the leaf and the migration step aside: a fresh +# install marks the migration done only for its first user. +grep -Fq 'omarchy-hw-aarch64-apple && exit 0' "$migration" || fail "Broadcom migration excludes Apple Silicon" + grep -q 'apple/fix-brcmfmac-supplicant.sh' "$all" || fail "the brcmfmac quirk runs during hardware setup" @@ -61,13 +66,19 @@ printf '\t%s' "$@" >>"$TEST_LOG" printf '\n' >>"$TEST_LOG" SH +cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH' +#!/bin/bash + +(( ${APPLE_SILICON:-0} == 1 )) +SH + chmod +x "$stub_bin"/* # The leaf reads the vendor from an absolute path, so point it at a fixture by # running with a fake root on PATH-independent state. pipefail is on, so a # grep -q gate would go silent here the way #6608 did. run_leaf() { - local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" + local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}" rm -rf "$test_tmp/etc" mkdir -p "$test_tmp/etc" printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor" @@ -78,7 +89,7 @@ run_leaf() { -e "s|/etc/modprobe.d|$test_tmp/etc/modprobe.d|g" \ "$leaf" >"$script" - WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" \ + WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" \ bash -eE -o pipefail -c 'source "$1"' bash "$script" /dev/null +[[ ! -f $conf ]] || fail "Apple Silicon is excluded from the Intel Mac quirk" +pass "Apple Silicon is excluded from the Intel Mac quirk" + # Older Macs report the vendor differently. run_leaf "Apple Computer, Inc." 43ba 0 >/dev/null [[ -f $conf ]] || fail "the older Apple vendor string is recognized" @@ -120,11 +135,11 @@ pass "a Mac with no wireless device is left alone" # Installs that predate the quirk never ran the leaf, so the migration has to # reach them. It runs as the user under pipefail, the context #6608 was about. run_migration() { - local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" + local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}" printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor" : >"$calls" - WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ OMARCHY_BRCMFMAC_DMI_VENDOR="$test_tmp/dmi/sys_vendor" \ OMARCHY_BRCMFMAC_CONF="$conf" \ bash -euo pipefail "$migration" >/dev/null @@ -148,6 +163,12 @@ run_migration "Apple Inc." 4488 1 [[ ! -s $calls ]] || fail "a repaired install is left untouched" "$(cat "$calls")" pass "the migration is idempotent" +rm -rf "$test_tmp/etc" +run_migration "Apple Inc." 4433 0 1 +[[ ! -e $conf ]] || fail "the migration leaves Apple Silicon Wi-Fi alone" +[[ ! -s $calls ]] || fail "the migration escalates nothing on Apple Silicon" "$(cat "$calls")" +pass "the migration leaves Apple Silicon Wi-Fi alone" + # The machine this was written for, with no T2 to fall back on. rm -rf "$test_tmp/etc" run_migration "Apple Inc." 43ba 0 diff --git a/test/shell.d/brightness-display-test.sh b/test/shell.d/brightness-display-test.sh index 3e41c511..4399f437 100755 --- a/test/shell.d/brightness-display-test.sh +++ b/test/shell.d/brightness-display-test.sh @@ -53,9 +53,19 @@ SH chmod +x "$mock_bin"/* +# A copy of the command reads its platform file from a fixture root (none +# unless a case writes one, whatever the machine running the suite has +# installed) and its DRM connectors from a fixture directory. +platform_root="$test_tmp/platform" +mkdir -p "$platform_root" "$test_tmp/copy" +brightness_display="$test_tmp/copy/omarchy-brightness-display" +platform_root_copy "$ROOT/bin/omarchy-brightness-display" "$brightness_display" "$platform_root" +sed -i "s|/sys/class/drm|$test_tmp/drm|g" "$brightness_display" +grep -qF "$test_tmp/drm/card" "$brightness_display" || fail "omarchy-brightness-display reads DRM connectors from the fixture" + run_brightness() { - CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" PATH="$mock_bin:$ROOT/bin:$PATH" \ - "$ROOT/bin/omarchy-brightness-display" "$@" + CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" \ + PATH="$mock_bin:$ROOT/bin:$PATH" "$brightness_display" "$@" } brightness=$(run_brightness --monitor DP-1) @@ -144,3 +154,43 @@ if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hyprland-monitor-focused-apple"; th fail "focused non-Apple display is not detected as Apple" fi pass "named Apple display is detected independently of focus" + +# A platform whose display driver has no DDC channel says so: an external +# monitor whose connector has no ddc node is not probed and never dims the +# built-in panel in its place. +mkdir -p "$test_tmp/drm/card2-USB-1" "$test_tmp/drm/card2-eDP-1" +# The last line needs no newline. +printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf" +: >"$call_log" +if run_brightness --monitor USB-1 >/dev/null 2>&1; then + fail "an external monitor without a ddc node has no brightness backend" +fi +if run_brightness --no-osd --monitor USB-1 +5% >/dev/null 2>&1; then + fail "setting an external monitor without a ddc node fails" +fi +printf 'ddc-require-connector-ddc\r\n' >"$platform_root/displays.conf" +if run_brightness --monitor USB-1 >/dev/null 2>&1; then + fail "a displays.conf saved with CRLF line ends reads the same" +fi +printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf" +[[ ! -s $call_log ]] || fail "an external monitor without a ddc node probes nothing" "$(<"$call_log")" +brightness=$(run_brightness --monitor eDP-1) +(( brightness == 40 )) || fail "the built-in panel still uses the kernel backlight" "actual: $brightness" +pass "a platform without DDC channels skips DDC for a connector without one" + +rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* +: >"$test_tmp/drm/card2-USB-1/ddc" +brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1) +(( brightness == 50 )) || fail "an external monitor with a ddc node uses DDC" "actual: $brightness" +pass "a platform without DDC channels still uses DDC where a connector has one" + +# Without the directive, every external monitor is probed, ddc node or not. +rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* "$test_tmp/drm/card2-USB-1/ddc" +for conf in "" "ddc-require-connector-ddc extra" "ddc-future"; do + printf '%s\n' "$conf" >"$platform_root/displays.conf" + rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* + brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1) + (( brightness == 50 )) || fail "an external monitor is probed without the directive" "conf: $conf, actual: $brightness" +done +rm -f "$platform_root/displays.conf" +pass "every external monitor is probed without the directive" diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh index f98934c2..8ca9ab7e 100755 --- a/test/shell.d/channel-sudo-boundary-test.sh +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -69,6 +69,51 @@ for channel in stable rc edge dev; do pass "$channel starts cold, authorizes the switch per command, runs the refresh hook cold, hands off to one update authorization and exits cold" done +# Every aarch64 platform switches to edge and dev, copying its own templates. +for platform in aarch64 aarch64-apple; do + case $platform in + aarch64-apple) templates=default/pacman/aarch64-apple ;; + *) templates=default/pacman/aarch64 ;; + esac + for channel in edge dev; do + # dev refreshes from the checkout it links, on edge. + pacman_channel=$channel root=$SUDO_TEST_ROOT + [[ $channel != "dev" ]] || pacman_channel=edge root=$SUDO_TEST_HOME/omarchy + reset_boundary + SUDO_TEST_PLATFORM=$platform run_channel "$channel" || fail "$platform $channel failed" "$(<"$boundary_tmp/output")" + assert_scoped_channel "$platform $channel" + grep -Fqx "step:cp -f $root/$templates/pacman-$pacman_channel.conf /etc/pacman.conf" "$SUDO_TEST_LOG" || + fail "$platform $channel copies its own template" "$(<"$SUDO_TEST_LOG")" + done +done +pass "aarch64 platforms switch to edge and dev through their own templates" + +# stable and rc would install the release line, which has no aarch64 support: +# an aarch64 machine refuses them before anything changes. +for platform in aarch64 aarch64-apple; do + for channel in stable rc; do + reset_boundary + if SUDO_TEST_PLATFORM=$platform run_channel "$channel"; then fail "$platform accepted $channel"; fi + if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$platform $channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi + grep -q "Omarchy has no $channel channel for $platform" "$boundary_tmp/output" || fail "$platform $channel: the refusal says why" "$(<"$boundary_tmp/output")" + assert_boundary_cold "$platform $channel" + done +done +pass "aarch64 platforms refuse stable and rc before any change" + +# A channel the platform has no template for stops before anything, the dev +# confirmation included. +mv "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf" "$boundary_tmp/saved-template" +for channel in edge dev; do + reset_boundary + if SUDO_TEST_PLATFORM=aarch64-apple run_channel "$channel"; then fail "a channel without a template was accepted ($channel)"; fi + if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi + grep -q "Omarchy has no edge channel for aarch64-apple" "$boundary_tmp/output" || fail "the refusal says why" "$(<"$boundary_tmp/output")" + assert_boundary_cold "missing template $channel" +done +mv "$boundary_tmp/saved-template" "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf" +pass "a channel without a template for the platform is refused before any change" + reset_boundary wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo" mv "$wrapper" "$boundary_tmp/saved-wrapper" @@ -81,6 +126,40 @@ assert_boundary_cold "stale checkout" mv "$boundary_tmp/saved-wrapper" "$wrapper" pass "a stale dev checkout is rejected before linking or privileged work" +# On aarch64 the checkout's own refresh keeps the machine's repositories only +# once it tells platforms apart: a checkout without that is refused the same way. +checkout="$SUDO_TEST_HOME/omarchy" +for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do + for platform in aarch64; do + reset_boundary + mv "$checkout/$required" "$boundary_tmp/saved-required" + if SUDO_TEST_PLATFORM=$platform run_channel dev; then fail "$platform: a dev checkout without $required was accepted"; fi + mv "$boundary_tmp/saved-required" "$checkout/$required" + if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "$platform: a dev checkout without $required changed the system before rejection" "$(<"$SUDO_TEST_LOG")" + fi + grep -q "Update the checkout before switching to dev; on $platform it needs $required" "$boundary_tmp/output" || + fail "$platform: the rejection names $required" "$(<"$boundary_tmp/output")" + assert_boundary_cold "$platform checkout without $required" + done +done +pass "on aarch64 a dev checkout that can't keep the machine's repositories is rejected before linking" + +# The checkout's own refresh copies its own templates, so one without them for +# this platform is refused before linking. +template=$checkout/default/pacman/aarch64/mirrorlist-edge +mv "$template" "$boundary_tmp/saved-template" +reset_boundary +if SUDO_TEST_PLATFORM=aarch64 run_channel dev; then fail "a dev checkout without its templates was accepted"; fi +mv "$boundary_tmp/saved-template" "$template" +if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "a dev checkout without its templates changed the system before rejection" "$(<"$SUDO_TEST_LOG")" +fi +grep -q "Update the checkout before switching to dev; it has no edge templates for aarch64" "$boundary_tmp/output" || + fail "the rejection names the missing templates" "$(<"$boundary_tmp/output")" +assert_boundary_cold "checkout without templates" +pass "a dev checkout without its templates for the platform is rejected before linking" + reset_boundary OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")" assert_scoped_channel "dev to stable" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index d4aaa26a..b450c299 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -125,6 +125,9 @@ for setup_command in \ ln -s omarchy-test-setup-call "$mock_bin/$setup_command" done +# The real browser installer below calls the platform's post-install hook; on a +# Mac with the platform package installed that would run, so answer it here. +printf '#!/bin/bash\nexit 0\n' >"$mock_bin/omarchy-lifecycle-dispatch" chmod +x "$mock_bin"/* export HOME="$test_home" diff --git a/test/shell.d/desktop-entry-launch-test.sh b/test/shell.d/desktop-entry-launch-test.sh index c6014b10..16fe5a05 100644 --- a/test/shell.d/desktop-entry-launch-test.sh +++ b/test/shell.d/desktop-entry-launch-test.sh @@ -22,7 +22,7 @@ cat >"$mock_bin/omarchy-font-set" <<'SH' printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG" SH -for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do +for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32 omarchy-lifecycle-dispatch; do cat >"$mock_bin/$command" <<'SH' #!/bin/bash exit 0 diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh index f8793310..d70f1b90 100644 --- a/test/shell.d/dictation-test.sh +++ b/test/shell.d/dictation-test.sh @@ -212,11 +212,15 @@ pass "selection updates are atomic for concurrent readers" lua <<'LUA' local root = os.getenv("ROOT") -local configured, bindings = false, {} +local selected, installed, bindings = nil, true, {} +io.popen = function(command) + assert(command == "omarchy-default-dictation 2>/dev/null") + return { read = function() return selected end, close = function() end } +end o = { - shell_succeeds = function(command) - assert(command == "omarchy-default-dictation") - return configured + cmd_missing = function(command) + assert(command == selected) + return not installed end, bind = function(...) table.insert(bindings, {...}) end, bind_hold = function(keys, _, press, _, release, options) @@ -226,9 +230,9 @@ o = { } dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 0, "unconfigured dictation leaves application keys available") -configured = true +selected = "some-backend" dofile(root .. "/default/hypr/bindings/dictation.lua") -assert(#bindings == 5, "any configured backend receives the same bindings") +assert(#bindings == 5, "any installed backend receives the same bindings") local function binds(keys, command) for _, binding in ipairs(bindings) do if binding[1] == keys and binding[2] == command then return true end @@ -238,8 +242,11 @@ for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"), keys .. " starts dictation on press and stops it on release") end +bindings, installed = {}, false +dofile(root .. "/default/hypr/bindings/dictation.lua") +assert(#bindings == 0, "a selected backend that is not installed leaves application keys available") LUA -pass "dictation shortcuts require a selection without limiting backend names" +pass "dictation shortcuts require an installed selection without limiting backend names" # Fresh users receive the backend choice through the settings package's skel. cp "$ROOT/config/omarchy/defaults/dictation" "$config" @@ -247,6 +254,26 @@ cp "$ROOT/config/omarchy/defaults/dictation" "$config" printf '%s\n' voxtype > "$config" pass "fresh users get Superwhisper as their default backend" +# On aarch64, where Superwhisper has no build, user setup drops that preset; an +# owner's own selection, and every x86_64 preset, stay. +leaf_bin="$test_tmp/leaf-bin" +mkdir -p "$leaf_bin" +printf '#!/bin/bash\n[[ ${TEST_ARCH:-aarch64} == x86_64 ]]\n' >"$leaf_bin/omarchy-hw-x86" +printf '#!/bin/bash\n[[ -z ${TEST_HAS_SUPERWHISPER:-} ]]\n' >"$leaf_bin/omarchy-cmd-missing" +chmod +x "$leaf_bin"/* +run_leaf() { env "$@" PATH="$leaf_bin:$PATH" bash -c 'source "$1"' bash "$ROOT/install/user/dictation-default.sh"; } +cp "$ROOT/config/omarchy/defaults/dictation" "$config" +run_leaf +[[ ! -e $config ]] || fail "aarch64 user setup drops the Superwhisper preset" +printf '%s\n' voxtype >"$config" +run_leaf +[[ $(<"$config") == "voxtype" ]] || fail "aarch64 user setup keeps a selection someone made" +cp "$ROOT/config/omarchy/defaults/dictation" "$config" +run_leaf TEST_ARCH=x86_64 +[[ $(<"$config") == "superwhisper" ]] || fail "x86_64 keeps the Superwhisper preset" +printf '%s\n' voxtype >"$config" +pass "aarch64 new users start with no dictation backend instead of an unbuildable one" + lua <<'LUA' local root = os.getenv("ROOT") package.path = root .. "/?.lua;" .. package.path diff --git a/test/shell.d/drive-password-test.sh b/test/shell.d/drive-password-test.sh index 7cd32b17..7479a598 100644 --- a/test/shell.d/drive-password-test.sh +++ b/test/shell.d/drive-password-test.sh @@ -4,46 +4,828 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -tmp_dir=$(mktemp -d) -trap 'rm -r "$tmp_dir"' EXIT +# omarchy-drive-password against fake drives. findmnt and lsblk describe a +# system drive under / and a data drive, and blkid has the empty cache a user +# sees until root runs blkid in that boot; cryptsetup is either a slot-table fake +# or the real binary on file-backed volumes; a chpasswd fake shows the login and +# root passwords never change. Each cryptsetup call is a crash point, so a run +# can be killed after every step and rerun, like a power loss. The runs repeat on an Apple fixture, +# where a fake boot package records the owner's slot through the real +# omarchy-lifecycle-dispatch; on the x86 fixture that is a no-op. -cat >"$tmp_dir/blkid" <<'EOF' -#!/bin/bash -echo /dev/test-luks -EOF - -cat >"$tmp_dir/gum" <<'EOF' -#!/bin/bash -head -n 1 "$TEST_INPUTS" -sed -i '1d' "$TEST_INPUTS" -EOF - -cat >"$tmp_dir/sudo" <<'EOF' -#!/bin/bash -printf '%s\n' "$@" >"$TEST_ARGS" -cat >"$TEST_STDIN" -EOF - -chmod +x "$tmp_dir/blkid" "$tmp_dir/gum" "$tmp_dir/sudo" -export PATH="$tmp_dir:$ROOT/bin:$PATH" -export TEST_ARGS="$tmp_dir/args" TEST_INPUTS="$tmp_dir/inputs" TEST_STDIN="$tmp_dir/stdin" - -printf '\n' >"$TEST_INPUTS" -if "$ROOT/bin/omarchy-drive-password" >/dev/null; then - fail "drive password rejects an empty passphrase" +# Root's dispatcher ignores the fixtures and sees this machine. +if (( EUID == 0 )) && [[ $("$ROOT/bin/omarchy-hw-platform") == "aarch64-apple" ]]; then + skip "running as root on Apple Silicon, where dispatch ignores fixtures; skipping" + exit 0 fi -[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for an empty passphrase" -printf 'secret123\n*\n' >"$TEST_INPUTS" -if "$ROOT/bin/omarchy-drive-password" >/dev/null; then - fail "drive password rejects a mismatched confirmation" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +old_password=old-password +new_password='new pass:word' +recovery_key=recovery-passphrase +data_password=data-password +journal=$tmp/state/omarchy/drive-password.state + +mkdir -p "$tmp/bin" "$tmp/real" "$tmp/dev" + +cat >"$tmp/crash.sh" <<'SH' +crash_point() { + local count + count=$(( $(cat "$TEST_TMP/steps") + 1 )) + echo "$count" >"$TEST_TMP/steps" + printf '%s %s\n' "$count" "$1" >>"$TEST_TMP/trace" + if (( count == ${CRASH_AT:-0} )); then + kill -9 "$TEST_PID" + # An orphan outlives the script for a moment, like a cryptsetup child. + if [[ -n ${CRASH_ORPHAN:-} ]]; then sleep 1; else kill -9 $$; fi + fi +} +SH + +cat >"$tmp/bin/blkid" <<'SH' +#!/bin/bash +exit 2 +SH + +cat >"$tmp/bin/findmnt" <<'SH' +#!/bin/bash +[[ $* == "-no SOURCE /" && -z ${TEST_FINDMNT_FAIL:-} ]] || exit 1 +echo '/dev/mapper/root[/@]' +SH + +cat >"$tmp/bin/lsblk" <<'SH' +#!/bin/bash +if [[ $1 == "-dno" && $2 == "UUID" ]]; then + cat "$3.uuid" + exit fi -[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for a mismatched confirmation" +if [[ $* == "-nrpo NAME,FSTYPE" ]]; then + echo "/dev/fake-disk " + while IFS= read -r drive; do + printf '%s crypto_LUKS\n/dev/mapper/%s btrfs\n' "$drive" "${drive##*/}" + done <"$TEST_TMP/drives" + exit +fi +[[ $* == "-nsrpo NAME,TYPE,FSTYPE /dev/mapper/root" ]] || exit 98 +cat "$TEST_TMP/root-ancestry" +SH -printf 'new password\nnew password\n' >"$TEST_INPUTS" -"$ROOT/bin/omarchy-drive-password" >/dev/null +cat >"$tmp/bin/omarchy-drive-select" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TMP/offered" +cat "$TEST_TMP/select" +SH -[[ $(<"$TEST_STDIN") == "new password" ]] || fail "drive password passes the validated passphrase without a newline" -grep -F 'cryptsetup luksChangeKey' "$TEST_ARGS" >/dev/null || fail "drive password changes the LUKS key" -grep -Fx /dev/test-luks "$TEST_ARGS" >/dev/null || fail "drive password targets the selected drive" -pass "drive password rejects empty and mismatched passphrases and passes validated input to cryptsetup" +cat >"$tmp/bin/gum" <<'SH' +#!/bin/bash +[[ $1 == "input" ]] || exit 97 +printf '%s\n' "$*" >>"$TEST_TMP/prompts" +[[ -s $TEST_TMP/inputs ]] || exit 130 +# Another key enrolled while the owner types the new password. +if [[ -n ${TEST_ENROLL_AT_PROMPT:-} && $* == *"$TEST_ENROLL_AT_PROMPT"* ]]; then + printf '3\tlater-key\n' >>"$TEST_ENROLL_DEVICE.slots" +fi +head -n 1 "$TEST_TMP/inputs" +sed -i '1d' "$TEST_TMP/inputs" +SH + +# Runs as the caller, closing every descriptor above stderr as sudo does. +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_TMP/sudo-calls" +for fd in /proc/$$/fd/*; do + fd=${fd##*/} + (( fd > 2 && fd != 255 )) && eval "exec $fd>&-" 2>/dev/null +done +exec "$@" +SH + +cat >"$tmp/bin/chpasswd" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" +IFS= read -r line +name=${line%%:*} +{ + grep -v "^$name " "$TEST_TMP/accounts" || true + printf '%s\t%s\n' "$name" "${line#*:}" +} >"$TEST_TMP/accounts.next" +mv -f "$TEST_TMP/accounts.next" "$TEST_TMP/accounts" +SH + +# A LUKS1-style volume as a table of "slotpassphrase" lines next to the +# device: luksChangeKey adds the new key to a free slot, then removes the old, +# and luksKillSlot needs a key that opens another slot, as cryptsetup does. +cat >"$tmp/bin/cryptsetup" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" + +slot_of() { + local slot key + while IFS=$'\t' read -r slot key; do + [[ $key == "$1" && $slot != "${2:-}" ]] && { echo "$slot"; return; } + done <"$slots" +} + +op=$1 +shift +key_file="" token_type="" key_slot="" positional=() +while (( $# )); do + case $1 in + --key-file) key_file=$2; shift 2 ;; + --token-type) token_type=$2; shift 2 ;; + --key-slot) key_slot=$2; shift 2 ;; + --pbkdf | --iter-time) shift 2 ;; + -*) shift ;; + *) positional+=("$1"); shift ;; + esac +done +slots=${positional[0]}.slots +[[ -f $slots ]] || { echo "Device ${positional[0]} does not exist or access denied."; exit 4; } +if [[ $key_file == "-" ]]; then key=$(cat); elif [[ -n $key_file ]]; then key=$(cat "$key_file"); fi + +[[ $op != "isLuks" ]] || exit 1 +if [[ $op == "luksDump" ]]; then + echo "Keyslots:" + cut -f1 "$slots" | sed 's/^/ /; s/$/: luks2/' + [[ -z ${TEST_TOKEN_SLOT:-} ]] || printf 'Tokens:\n 0: luks2-keyring\n\tKeyslot: %s\n' "$TEST_TOKEN_SLOT" + printf 'Digests:\n 0: pbkdf2\n' + exit 0 +fi + +crash_point "cryptsetup $op" +[[ $op != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1 +# An enrolled token unlocks its slot whatever key is given, unless the allowed +# token types exclude it, as with cryptsetup. +if [[ $op == "open" && -z $token_type && -n ${TEST_TOKEN_SLOT:-} ]]; then + echo "Key slot $TEST_TOKEN_SLOT unlocked." + exit 0 +fi +target="" +[[ $op == "luksKillSlot" ]] && target=${positional[1]} +if [[ $op == "open" && -n $key_slot ]]; then + grep -qxF "$key_slot"$'\t'"$key" "$slots" || { echo "No key available with this passphrase."; exit 2; } + echo "Key slot $key_slot unlocked." + exit 0 +fi +slot=$(slot_of "$key" "$target") +[[ -n $slot ]] || { echo "No key available with this passphrase."; exit 2; } + +case $op in + open) echo "Key slot $slot unlocked." ;; + luksChangeKey) + [[ -z ${TEST_CHANGE_FAIL:-} ]] || exit 1 + for (( next = 0; next < 32; next++ )); do + cut -f1 "$slots" | grep -qx "$next" || break + done + printf '%s\t%s\n' "$next" "$(cat "${positional[1]}")" >>"$slots" + crash_point "new key added" + [[ -z ${TEST_CHANGE_PARTIAL:-} ]] || exit 137 + awk -F'\t' -v s="$slot" '$1 != s' "$slots" >"$slots.next" + mv -f "$slots.next" "$slots" + crash_point "old key removed" + ;; + luksKillSlot) + [[ -z ${TEST_KILL_FAIL:-} ]] || exit 1 + awk -F'\t' -v s="$target" '$1 != s' "$slots" >"$slots.next" + mv -f "$slots.next" "$slots" + crash_point "slot $target killed" + ;; + *) exit 1 ;; +esac +SH + +# The real cryptsetup, keeping the command's KDF but at a fast cost, counting +# the same crash points. +cat >"$tmp/real/cryptsetup" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" +args=() +while (( $# )); do + case $1 in + --iter-time) shift 2 ;; + *) args+=("$1"); shift ;; + esac +done +if [[ ${args[0]} == "luksChangeKey" ]]; then + if [[ " ${args[*]} " == *" argon2id "* ]]; then + args+=(--pbkdf-force-iterations 4 --pbkdf-memory 32768) + else + args+=(--pbkdf-force-iterations 1000) + fi +fi +[[ ${args[0]} == "luksDump" || ${args[0]} == "isLuks" ]] || crash_point "cryptsetup ${args[0]}" +[[ ${args[0]} != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1 +status=0 +"$REAL_CRYPTSETUP" "${args[@]}" || status=$? +if (( status == 0 )) && [[ ${args[0]} == "luksChangeKey" || ${args[0]} == "luksKillSlot" ]]; then + crash_point "${args[0]} done" +fi +exit "$status" +SH + +chmod +x "$tmp"/bin/* "$tmp/real/cryptsetup" + +# The boot package's luks-slots, run by dispatch with an empty environment: it +# records its arguments and is a crash point like the rest. +fake_platform "$tmp/x86" x86 +fake_platform "$tmp/apple" aarch64-apple +mkdir -p "$tmp/lifecycle/usr/lib/omarchy/mac-boot" +cat >"$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" <&2; exit 1; } + [[ ! -e $tmp/owner-query-garbage ]] || { echo "slot zero"; exit 0; } + [[ ! -e $tmp/owner-slot ]] || { cat "$tmp/owner-slot"; exit 0; } + [[ ! -e $tmp/owner-unrecorded ]] || { echo "No owner key slot is recorded." >&2; exit 4; } + echo 0 + exit 0 +fi +[[ ! -e $tmp/record-fail ]] || exit 1 +printf '%s\n' "\$*" >>"$tmp/slot-record" +[[ \$1 != owner=* ]] || echo "\${1#owner=}" >"$tmp/owner-slot" +count=\$(( \$(cat "$tmp/steps") + 1 )) +echo "\$count" >"$tmp/steps" +printf '%s slots recorded\n' "\$count" >>"$tmp/trace" +if (( count == \$(cat "$tmp/crash-at") )); then + kill -9 "\$PPID" + kill -9 \$\$ +fi +SH +chmod 755 "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" +chmod -R go-w "$tmp/lifecycle" + +export TEST_TMP=$tmp OMARCHY_PATH=$ROOT XDG_STATE_HOME=$tmp/state SUDO_USER=owner OMARCHY_LIFECYCLE_ROOT=$tmp/lifecycle +base_path=$PATH +platform=x86 + +# The command's PATH for this backend on this platform fixture. +use() { + backend=$1 platform=$2 + export OMARCHY_PROC_ROOT=$tmp/$platform/proc + if [[ $backend == "fake" ]]; then + export PATH="$tmp/$platform/bin:$tmp/bin:$ROOT/bin:$base_path" + else + export PATH="$tmp/$platform/bin:$tmp/real:$tmp/bin:$ROOT/bin:$base_path" + fi +} +data=$tmp/dev/data + +# The slot the key opens, or nothing. +opens() { + local device=$1 key=$2 slot k + if [[ $backend == "fake" ]]; then + while IFS=$'\t' read -r slot k; do + [[ $k == "$key" ]] && { echo "$slot"; return; } + done <"$device.slots" + else + LC_ALL=C "$REAL_CRYPTSETUP" open --test-passphrase --verbose --key-file <(printf '%s' "$key") "$device" 2>&1 | + grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' || true + fi +} + +account() { + awk -F'\t' -v n="$1" '$1 == n { print $2 }' "$tmp/accounts" +} + +volume() { + local device=$1 key=$2 extra=${3:-} + echo "uuid-$(basename "$device")" >"$device.uuid" + if [[ $backend == "fake" ]]; then + : >"$device" + printf '0\t%s\n' "$key" >"$device.slots" + [[ -z $extra ]] || printf '1\t%s\n' "$extra" >>"$device.slots" + else + rm -f "$device" + truncate -s 32M "$device" + "$REAL_CRYPTSETUP" luksFormat -q --type "$backend" --pbkdf pbkdf2 --pbkdf-force-iterations 1000 "$device" <(printf '%s' "$key") + if [[ -n $extra ]]; then + "$REAL_CRYPTSETUP" luksAddKey --pbkdf pbkdf2 --pbkdf-force-iterations 1000 --key-file <(printf '%s' "$key") "$device" <(printf '%s' "$extra") + fi + fi +} + +# / on the system drive, which also holds a recovery key; a data drive beside it. +fixture() { + rm -rf "$tmp/state" "$tmp/output" "$tmp/trace" "$tmp/offered" "$tmp"/dev/* "$tmp/slot-record" "$tmp/record-fail" "$tmp/owner-slot" "$tmp/owner-query-fail" "$tmp/owner-query-garbage" "$tmp/owner-unrecorded" + unset TEST_ENROLL_AT_PROMPT TEST_ENROLL_DEVICE TEST_OPEN_FAIL TEST_CHANGE_FAIL TEST_CHANGE_PARTIAL TEST_KILL_FAIL TEST_FINDMNT_FAIL TEST_TOKEN_SLOT CRASH_ORPHAN + system=$tmp/dev/system + recovery=${1-$recovery_key} + volume "$system" "$old_password" "$recovery" + volume "$data" "$data_password" + printf 'owner\t%s\nroot\t%s\n' "$old_password" "$old_password" >"$tmp/accounts" + printf '%s\n%s\n' "$system" "$data" >"$tmp/drives" + printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" + echo "$system" >"$tmp/select" + : >"$tmp/prompts" + : >"$tmp/sudo-calls" +} + +# One run of the command: an optional crash step, then answers for its prompts. +attempt() { + local crash_at=$1 + shift + if (( $# )); then printf '%s\n' "$@" >"$tmp/inputs"; else : >"$tmp/inputs"; fi + echo 0 >"$tmp/steps" + echo "$crash_at" >"$tmp/crash-at" + : >"$tmp/trace" + { + CRASH_AT=$crash_at bash -c 'TEST_PID=$$ exec bash ${TEST_TRACE:+-x} "$0"' "$ROOT/bin/omarchy-drive-password" + } >>"$tmp/output" 2>&1 +} + +consistent() { + local context=$1 password=$2 other=$old_password + [[ $password == "$old_password" ]] && other=$new_password + untouched_accounts "$context" + [[ -n $(opens "$system" "$password") ]] || fail "$backend: $context: the disk opens with that password" "$(cat "$tmp/output")" + [[ -z $(opens "$system" "$other") ]] || fail "$backend: $context: the other password no longer opens the disk" "$(cat "$tmp/trace" "$tmp/output")" + [[ -z $recovery || -n $(opens "$system" "$recovery") ]] || fail "$backend: $context: the recovery key still opens the disk" + [[ ! -e $journal ]] || fail "$backend: $context: the journal is gone" "$(cat "$journal")" + recorded "$context" "$password" +} + +# On Apple the boot package holds the slot the owner's password opens: recorded +# whenever the key moved. x86 records nothing and runs no extra sudo. +recorded() { + local context=$1 password=$2 slot + if [[ $platform == "apple" ]]; then + slot=$(opens "$system" "$password") + if [[ -s $tmp/slot-record ]]; then + [[ $(tail -n 1 "$tmp/slot-record") == "owner=$slot" ]] || + fail "$backend: apple: $context: the owner's slot is recorded" "$(cat "$tmp/slot-record")" + else + [[ $slot == "0" ]] || fail "$backend: apple: $context: a key that moved is recorded" "$(cat "$tmp/trace")" + fi + else + [[ ! -e $tmp/slot-record ]] && ! grep -q 'lifecycle-dispatch' "$tmp/sudo-calls" || + fail "$backend: x86: $context: no slot is recorded and no dispatch runs under sudo" + fi +} + +# The disk password never changes the login or root password. +untouched_accounts() { + [[ $(account owner) == "$old_password" && $(account root) == "$old_password" ]] && ! grep -q 'chpasswd' "$tmp/sudo-calls" || + fail "$backend: $1: the login and root passwords stay as they were" "$(cat "$tmp/accounts" "$tmp/sudo-calls")" +} + +said() { + grep -Fq "$1" "$tmp/output" || fail "$backend: the command says: $1" "$(cat "$tmp/output")" +} + +no_secrets() { + local file + for file in "$journal" "$tmp/sudo-calls" "$tmp/prompts"; do + [[ -e $file ]] || continue + ! grep -Fq -e "$old_password" -e "$new_password" -e "$recovery_key" -e "$data_password" "$file" || + fail "$backend: $1: $(basename "$file") holds no password" "$(cat "$file")" + done +} + +# Between runs the disk opens with the old or the new password, and the +# accounts never change. +recoverable() { + local context=$1 + [[ -n $(opens "$system" "$old_password") || -n $(opens "$system" "$new_password") ]] || + fail "$backend: $context: the disk opens with the old or the new password" "$(cat "$tmp/trace")" + untouched_accounts "$context" + [[ ! -e $journal || $(stat -c %a "$journal") == "600" ]] || fail "$backend: $context: the journal is private" + no_secrets "$context" +} + +backends=(fake) +if REAL_CRYPTSETUP=$(command -v cryptsetup); then + export REAL_CRYPTSETUP + backends+=(luks2 luks1) +else + skip "cryptsetup is not installed; skipping the file-backed volume runs" +fi + +matrix=() +for backend in "${backends[@]}"; do + matrix+=("$backend x86" "$backend apple") +done + +for run_spec in "${matrix[@]}"; do + use $run_spec + + fixture + attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: changing the system disk password succeeds" "$(cat "$tmp/output")" + consistent "uninterrupted" "$new_password" + [[ $(opens "$data" "$data_password") == "0" ]] || fail "$backend: the data drive is untouched" + ! grep -Fq 'login password' "$tmp/output" || fail "$backend: the command does not mention the login password" "$(cat "$tmp/output")" + no_secrets "uninterrupted" + total_steps=$(cat "$tmp/steps") + (( total_steps >= 4 )) || fail "$backend: every change is a crash point" "$(cat "$tmp/trace")" + pass "$backend: the system disk key changes and verifies, and the login and root passwords stay as they were" + + fixture + if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "$backend: a wrong current password fails the command"; fi + said "That password does not open $system." + ! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "$backend: a wrong current password changes nothing" + [[ -n $(opens "$system" "$old_password") && $(account owner) == "$old_password" ]] || fail "$backend: a wrong current password leaves the disk and accounts" + pass "$backend: a wrong current password is refused before anything changes" + + fixture "" + attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: a single-slot system disk changes" "$(cat "$tmp/output")" + consistent "single slot" "$new_password" + pass "$backend: a disk with one key slot keeps it through the change" + + fixture + echo "$data" >"$tmp/select" + attempt 0 "$data_password" "$new_password" "$new_password" || fail "$backend: changing a data drive password succeeds" "$(cat "$tmp/output")" + [[ -n $(opens "$data" "$new_password") && -z $(opens "$data" "$data_password") ]] || fail "$backend: the data drive takes the new password" + untouched_accounts "data drive" + [[ -n $(opens "$system" "$old_password") ]] || fail "$backend: a data drive change leaves the system disk alone" + [[ ! -e $journal ]] || fail "$backend: a data drive change keeps no journal" + pass "$backend: changing a non-root encrypted drive's password leaves the system disk alone" + + # Killed after each step, the owner runs the command again and answers with a + # password that opens the disk now: the new one, or the old one while it still + # works. Each rerun is itself killed after each of its steps until one finishes. + for (( step = 1; step <= total_steps; step++ )); do + for answer in "$new_password" "$old_password"; do + fixture + if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "$backend: the run is killed at step $step"; fi + point=$(sed -n "${step}p" "$tmp/trace") + recoverable "killed after '$point'" + if [[ ! -e $journal ]]; then + if [[ -n $(opens "$system" "$new_password") ]]; then + consistent "killed after '$point'" "$new_password" + else + consistent "killed after '$point'" "$old_password" + fi + continue + fi + [[ -n $(opens "$system" "$answer") ]] || continue + if grep -qx 'phase=record' "$journal" && [[ $answer == "$old_password" ]]; then + if attempt 0 "$answer"; then fail "$backend: after '$point' a confirmed change refuses the old password"; fi + [[ -e $journal ]] || fail "$backend: after '$point' a refused rerun keeps the journal" + answer=$new_password + fi + for (( again = 1; ; again++ )); do + status=0 + attempt "$again" "$answer" || status=$? + (( status == 0 )) && break + (( status == 137 )) || fail "$backend: the rerun after '$point' finishes" "$(cat "$tmp/output")" + recoverable "rerun after '$point' killed at step $again" + done + consistent "rerun with $answer after '$point'" "$answer" + no_secrets "rerun after '$point'" + done + done + pass "$backend $platform: killed after each of $total_steps steps, and each rerun after each of its own, the disk ends on one password and the accounts never change" +done + +use fake x86 + +fixture +printf '%s\n%s\n%s\n' "$system" "$data" "$data" >"$tmp/drives" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a fresh boot finds the system disk without a blkid cache" "$(cat "$tmp/output")" +[[ $(cat "$tmp/offered") == "$system"$'\n'"$data" ]] || fail "each LUKS drive lsblk names is offered once" "$(cat "$tmp/offered")" +consistent "fresh boot" "$new_password" +pass "before root has run blkid, every LUKS drive is offered once and the system disk changes" + +fixture +printf '5\t%s\n' tpm-sealed-key >>"$system.slots" +export TEST_TOKEN_SLOT=5 +if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "an enrolled token does not stand in for the current password"; fi +said "That password does not open $system." +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a disk with an enrolled token changes its password" "$(cat "$tmp/output")" +consistent "beside an enrolled token" "$new_password" +[[ $(opens "$system" tpm-sealed-key) == "5" ]] || fail "the token's slot is left alone" +pass "an enrolled token never answers for a password, and its slot is left alone" + +fixture +export TEST_CHANGE_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed luksChangeKey fails the command"; fi +[[ -n $(opens "$system" "$old_password") && ! -e $journal ]] || fail "a failed luksChangeKey leaves the disk and no journal" +said "The disk password did not change." +pass "a failed LUKS change leaves the disk on its old password" + +fixture +export TEST_CHANGE_PARTIAL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a luksChangeKey that dies after adding the new key is finished" "$(cat "$tmp/output")" +consistent "luksChangeKey died after adding the new key" "$new_password" +pass "a luksChangeKey that dies between keys has its old key retired" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed retirement fails the command"; fi +[[ -e $journal ]] || fail "a failed retirement keeps the journal" +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +if attempt 0 "$recovery_key"; then fail "an unconfirmed change refuses the recovery key"; fi +said "That password does not finish the change" +[[ -e $journal && -n $(opens "$system" "$recovery_key") ]] || fail "refusing the recovery key changes nothing" +attempt 0 "$new_password" || fail "the rerun retires the old key and finishes" "$(cat "$tmp/output")" +consistent "rerun after a failed retirement" "$new_password" +pass "before the change is confirmed a rerun refuses the recovery key and retires the old key" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || true +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +attempt 0 "$old_password" || fail "the rerun with the old password rolls back" "$(cat "$tmp/output")" +consistent "rollback with the old password" "$old_password" +pass "a rerun with the old password removes the half-added new key" + +# On Apple the slot record follows the confirmed change: a crash there leaves +# the confirmed phase. +use fake apple +fixture +attempt 0 "$old_password" "$new_password" "$new_password" +record_step=$(grep -n 'slots recorded' "$tmp/trace" | cut -d: -f1) +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +grep -qx 'phase=record' "$journal" || fail "a crash at the slot record leaves the confirmed phase" "$(cat "$journal" "$tmp/trace")" +if attempt 0 "$recovery_key"; then fail "a confirmed change refuses the recovery key"; fi +said "That password does not finish the change" +[[ -e $journal ]] || fail "refusing the recovery key keeps the journal" +if attempt 0 "not-the-password"; then fail "a rerun refuses a password that opens nothing"; fi +for suffix in "" .slots .uuid; do + mv "$system$suffix" "$tmp/dev/renumbered$suffix" +done +system=$tmp/dev/renumbered +printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +attempt 0 "$new_password" || fail "the confirmed change finishes on a renumbered disk" "$(cat "$tmp/output")" +consistent "rerun after a confirmed change" "$new_password" +pass "apple: once the new key is confirmed only its slot finishes the change, on the disk found by UUID" + +fixture +export TEST_OPEN_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a disk that cannot be checked fails the command"; fi +said "Could not check the password against $system." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a disk that cannot be checked is not changed" +pass "the current password is checked before anything changes" + +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +export TEST_FINDMNT_FAIL=1 +if attempt 0 "$new_password"; then fail "a run that cannot find / fails"; fi +said "Could not tell which drive holds the system." +[[ -e $journal ]] || fail "a run that cannot find / keeps the journal" +unset TEST_FINDMNT_FAIL +attempt 0 "$new_password" || fail "the rerun finishes once / is found" "$(cat "$tmp/output")" +consistent "rerun after a failed root lookup" "$new_password" +pass "apple: a failed root lookup changes nothing and keeps the journal" + +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +sed -i 's/^phase=record$/phase=accounts/' "$journal" +grep -qx 'phase=accounts' "$journal" || fail "the journal is rewritten as an earlier version wrote it" "$(cat "$journal")" +if attempt 0 "$old_password"; then fail "an earlier version's confirmed change refuses the old password"; fi +attempt 0 "$new_password" || fail "an earlier version's confirmed change finishes" "$(cat "$tmp/output")" +consistent "journal from an earlier version" "$new_password" +pass "apple: a confirmed change journaled by an earlier version records its slot and leaves the accounts alone" +use fake x86 + +fixture +attempt 0 "$old_password" "$new_password" "$new_password" +change_step=$(grep -n 'cryptsetup luksChangeKey' "$tmp/trace" | cut -d: -f1) +fixture +export CRASH_ORPHAN=1 +attempt "$change_step" "$old_password" "$new_password" "$new_password" || true +unset CRASH_ORPHAN +if attempt 0 "$old_password"; then fail "a rerun waits for a luksChangeKey the killed run left behind"; fi +attempt 0 "$new_password" || fail "the rerun finishes after the orphaned change" "$(cat "$tmp/output")" +consistent "orphaned luksChangeKey" "$new_password" +pass "a luksChangeKey that outlives its run finishes before the rerun reads the disk" + +fixture +printf '/dev/mapper/root crypt btrfs\n%s part \n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a crypt layer without a named LUKS partition fails"; fi +said "Could not tell which drive holds the system." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" || fail "an unresolved crypt layer changes nothing" +pass "a crypt layer whose LUKS partition cannot be named fails closed" + +fixture +mkdir -p "${journal%/*}" +printf 'uuid=uuid-system\nold_slot=0\nslots=\nphase=luks\n' >"$journal" +if attempt 0 "$recovery_key"; then fail "a journal without slots refuses to finish"; fi +said "is unreadable" +[[ -e $journal && -n $(opens "$system" "$old_password") ]] || fail "an unreadable journal changes nothing" +pass "a journal without its slot list is refused rather than trusted" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || true +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +printf '3\tlater-key\n' >>"$system.slots" +if attempt 0 "$new_password"; then fail "a disk that gained two slots refuses to finish"; fi +said "changed since the interrupted change" +[[ -e $journal && -n $(opens "$system" "later-key") && -n $(opens "$system" "$old_password") ]] || fail "a disk that gained two slots is left alone" +pass "a rerun never removes a slot enrolled after the interrupted change" + +fixture +mkdir -p "${journal%/*}" +exec {held}>"${journal%/*}/drive-password.lock" +flock -n "$held" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a second run waits for the first"; fi +exec {held}>&- +grep -Fq 'Another drive password change is running.' "$tmp/output" && ! grep -q 'luksChangeKey' "$tmp/sudo-calls" || + fail "a second run changes nothing while another holds the lock" +pass "two runs never interleave" + +fixture +export TEST_TRACE=1 +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a traced run succeeds" "$(cat "$tmp/output")" +unset TEST_TRACE +! grep -Fq -e "$old_password" -e "$new_password" "$tmp/output" || fail "tracing never shows a password" "$(cat "$tmp/output")" +pass "bash -x does not trace passwords" + +fixture +printf '/dev/mapper/root lvm btrfs\n/dev/mapper/cryptlvm crypt LVM2_member\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "an LVM-on-LUKS system disk changes" "$(cat "$tmp/output")" +consistent "LVM on LUKS" "$new_password" +pass "the system disk is found beneath an LVM volume group" + +fixture +printf '/dev/mapper/root part ext4\n/dev/fake-disk disk \n' >"$tmp/root-ancestry" +echo "$data" >"$tmp/drives" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "an unencrypted root still changes a data drive" "$(cat "$tmp/output")" +[[ -n $(opens "$data" "$new_password") ]] || fail "an unencrypted root still changes the data drive" +untouched_accounts "unencrypted root" +pass "with / unencrypted, every encrypted drive changes alone" + +fixture +mkdir -p "${journal%/*}" +printf 'uuid=uuid-elsewhere\nphase=record\nslot=0\n' >"$journal" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a journal for another disk does not block a change" "$(cat "$tmp/output")" +consistent "stale journal" "$new_password" +pass "a journal for a disk that is not / is dropped" + +fixture +if attempt 0 "$old_password" ""; then fail "an empty password is refused"; fi +said "Password cannot be empty." +if attempt 0 "$old_password" "secret123" "*"; then fail "a mismatched confirmation is refused"; fi +said "Passwords do not match." +if attempt 0 "$old_password" "$old_password" "$old_password"; then fail "the current password is refused as the new one"; fi +said "The new password is the current one." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || + fail "refused passwords change nothing" +pass "drive password rejects empty, mismatched and unchanged passphrases before changing anything" + +# A change interrupted part way is finished from the one slot the password +# opened, so a system disk whose password is in two slots is refused before +# anything changes: the other copy would outlive the change. +use fake x86 +fixture "$old_password" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a password in two slots is refused"; fi +said "That password also opens key slot 1 on $system" +said "The system disk password did not change." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a password in two slots changes nothing" +[[ $(opens "$system" "$old_password") == "0" ]] && grep -qxF $'1\t'"$old_password" "$system.slots" || fail "both copies are left as they were" +pass "a system disk whose password opens two key slots is refused before anything changes" + +# A change journaled by an earlier version did not check the old password for +# other slots. With other slots on the disk a copy of it may have outlived the +# change, and no password entered now can show whose slot is whose, so it is +# left for the owner to settle by hand, in every phase. With one slot it settles. +for legacy_phase in luks record accounts; do + fixture "" + printf '0\t%s\n2\t%s\n' "$new_password" "$old_password" >"$system.slots" + mkdir -p "${journal%/*}" + printf 'uuid=uuid-system\nold_slot=0\nslots=0,2\nslot=0\nphase=%s\n' "$legacy_phase" >"$journal" + if attempt 0 "$new_password" "$old_password"; then fail "an earlier version's $legacy_phase change on a disk with other slots is not settled"; fi + said "may still hold a copy of the old password" + said "Settle it by hand" + ! grep -q 'luksKillSlot\|luksChangeKey' "$tmp/sudo-calls" && [[ -e $journal && -n $(opens "$system" "$old_password") ]] || + fail "an earlier version's $legacy_phase change is left as it was" "$(cat "$tmp/sudo-calls")" +done +fixture "" +printf '0\t%s\n' "$new_password" >"$system.slots" +mkdir -p "${journal%/*}" +printf 'uuid=uuid-system\nold_slot=0\nslots=0\nphase=luks\n' >"$journal" +attempt 0 "$new_password" || fail "an earlier version's change on a one-slot disk settles" "$(cat "$tmp/output")" +[[ ! -e $journal && $(opens "$system" "$new_password") == "0" ]] || fail "the one-slot change is settled" +pass "an earlier version's interrupted change is left to the owner when other slots remain, and settles on a one-slot disk" + +# The new key's slot is how the change is followed, so a new password that +# already opens a slot (another key on the disk) is refused. +fixture +if attempt 0 "$old_password" "$recovery_key" "$recovery_key"; then fail "a new password that opens a slot already is refused"; fi +said "That password already opens a key slot on $system. Choose another." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "an enrolled new password changes nothing" +echo "$data" >"$tmp/select" +volume "$data" "$data_password" "$new_password" +if attempt 0 "$data_password" "$new_password" "$new_password"; then fail "a data drive refuses a new password it already has"; fi +said "That password already opens a key slot on $data. Choose another." +pass "a new password that already opens a key slot is refused, on the system disk and data drives" + +# Only the owner's slot changes the system disk. A second key an earlier setup +# added (a recovery key) is refused before anything changes, and so is a change +# the boot package cannot vouch for. +use fake apple +fixture +if attempt 0 "$recovery_key" "$new_password" "$new_password"; then fail "apple: a key in another slot is refused"; fi +said "That password opens another key slot on the system disk" +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: refusing another slot's key changes nothing" +[[ -n $(opens "$system" "$recovery_key") ]] || fail "apple: the other key still opens the disk" +consistent "another slot's key refused" "$old_password" +touch "$tmp/owner-query-fail" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a boot package that cannot name the owner's slot stops the change"; fi +said "Could not read which key slot holds the system disk password: The LUKS header has no key in the recorded owner slot 0." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: an unanswered owner query changes nothing" +consistent "owner query failed" "$old_password" +rm "$tmp/owner-query-fail" +touch "$tmp/owner-query-garbage" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an owner slot that is not a number stops the change"; fi +said "named 'slot zero', which is not a key slot" +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: a garbled owner slot changes nothing" +rm "$tmp/owner-query-garbage" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: the owner's password changes the disk" "$(cat "$tmp/output")" +consistent "owner's password after refusals" "$new_password" +pass "apple: only the owner's slot changes the system disk; another slot's key and an unanswered query change nothing" + +# A Mac set up before its boot package recorded slots has no owner slot (4). +# Its disk's only key is the owner's: the change goes through and records the +# new slot. With another key beside it the owner's can't be told, so the change +# is refused, saying how to settle it: record both, or remove the extra key. +use fake apple +fixture "" +touch "$tmp/owner-unrecorded" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: an unrecorded single-key disk changes its password" "$(cat "$tmp/output")" +[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the owner's new slot is recorded" "$(cat "$tmp/slot-record")" +consistent "no owner slot recorded, one key" "$new_password" +step=$(grep -n 'cryptsetup' "$tmp/trace" | tail -1 | cut -d: -f1) +fixture "" +touch "$tmp/owner-unrecorded" +if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "apple: the unrecorded change is killed before the slot record"; fi +[[ -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a change killed before the slot record keeps its journal" "$(cat "$tmp/trace")" +attempt 0 "$new_password" || fail "apple: the rerun finishes an unrecorded change" "$(cat "$tmp/output")" +[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the rerun records the owner's new slot" "$(cat "$tmp/slot-record")" +consistent "no owner slot recorded, interrupted" "$new_password" +pass "apple: with no owner slot recorded, the disk's only key changes and its new slot is recorded, after an interruption too" + +fixture +touch "$tmp/owner-unrecorded" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an unrecorded disk with two keys refuses the change"; fi +said "has keys in slots 0,1, so which one is yours can't be told" +said "record both: sudo omarchy-lifecycle-dispatch luks-slots owner=0 recovery=1" +said "sudo cryptsetup luksKillSlot $system " +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: an unrecorded two-key disk changes nothing" +"$ROOT/bin/omarchy-lifecycle-dispatch" luks-slots owner=0 recovery=1 +: >"$tmp/slot-record" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: once both slots are recorded the change goes through" "$(cat "$tmp/output")" +consistent "slots recorded as the refusal said" "$new_password" +pass "apple: with no owner slot recorded, a disk with another key refuses the change and says how to record or remove it" + +fixture "" +touch "$tmp/owner-unrecorded" +export TEST_ENROLL_AT_PROMPT="Confirm new encryption password" TEST_ENROLL_DEVICE=$system +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a key enrolled during the prompts stops an unrecorded change"; fi +said "changed while you entered the new password" +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a key enrolled during the prompts changes nothing" +pass "apple: an unrecorded disk that gains a key while the owner types is left alone" + +# A slot the boot package could not record keeps the journal: the rerun records it. +use fake apple +fixture +touch "$tmp/record-fail" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed record fails the command"; fi +said "Could not record the new key slot for the boot checks." +[[ -e $journal && ! -e $tmp/slot-record ]] || fail "a failed record keeps the journal" +rm "$tmp/record-fail" +attempt 0 "$new_password" || fail "the rerun records the slot" "$(cat "$tmp/output")" +[[ -s $tmp/slot-record ]] || fail "the rerun records the moved key's slot" +consistent "rerun after a failed record" "$new_password" +pass "apple: the change finishes only once the boot package recorded the owner's new slot" + +# An installed boot package older than luks-slots could not record the slot, so +# the system disk does not change; a data drive still does. +fixture +mv "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" "$tmp/luks-slots.off" +mkdir -p "$tmp/lifecycle/var/lib/pacman/local/omarchy-mac-boot-20260925-2" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "an Apple boot package without luks-slots fails the command"; fi +said "which omarchy-mac-boot 20260925-2 does not provide; update omarchy-mac-boot" +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a boot package without luks-slots changes nothing" +consistent "boot package without luks-slots" "$old_password" +echo "$data" >"$tmp/select" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "a data drive changes without luks-slots" "$(cat "$tmp/output")" +pass "apple: a boot package without luks-slots stops a system disk change before it starts, naming the package" + +# A Mac without its boot package at all predates it: nothing checks its slots, +# so the change goes through and records nothing. +fixture +rm -r "$tmp/lifecycle/var/lib/pacman" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a Mac without the boot package changes its disk password" "$(cat "$tmp/output")" +# Checked as on x86: no slot recorded and no dispatch under sudo. +platform=x86 +consistent "no boot package" "$new_password" +platform=apple +! grep -q 'Error:' "$tmp/output" || fail "without the boot package nothing is reported" "$(cat "$tmp/output")" +mv "$tmp/luks-slots.off" "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" +pass "apple: a Mac without its boot package changes its disk password and records no slot" + +fixture +echo "$data" >"$tmp/select" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "apple: a data drive changes" "$(cat "$tmp/output")" +[[ ! -e $tmp/slot-record && -n $(opens "$data" "$new_password") ]] || fail "apple: a data drive records no slot" +pass "apple: only the system disk's slot is recorded" diff --git a/test/shell.d/factory-reset-dispatch-test.sh b/test/shell.d/factory-reset-dispatch-test.sh new file mode 100644 index 00000000..bf35d4a8 --- /dev/null +++ b/test/shell.d/factory-reset-dispatch-test.sh @@ -0,0 +1,339 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Factory reset's boot step through the real omarchy-lifecycle-dispatch. On an +# x86 fixture the generic Limine UKI path runs and the boot-package entrypoints +# on disk never do. On an Apple fixture a fake boot package owns the factory +# root's boot chain: it prepares and verifies it before the throwaway slot is +# added, gets the key on standard input after the switch, and rolls back when +# the reset fails before the switch. Subvolumes are directories, and +# cryptsetup is a slot-table fake. +require_platform_fixtures "factory reset through lifecycle dispatch" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +fake_platform "$tmp/x86" x86 +fake_platform "$tmp/apple" aarch64-apple + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/functions" + +# The fake boot package: each entrypoint records its call, reset-commit its +# standard input, and reset-verify fails on request. +lifecycle=$tmp/lifecycle +mac_boot=$lifecycle/usr/lib/omarchy/mac-boot +mkdir -p "$mac_boot" +for operation in reset-prepare reset-verify reset-commit reset-rollback; do + cat >"$mac_boot/$operation" <>"$tmp/calls" +[[ $operation != reset-commit ]] || cat >"$tmp/committed-key" +[[ ! -e $tmp/$operation-fail ]] || { echo "$operation: the fixture refuses" >&2; exit 1; } +SH +done +chmod 755 "$mac_boot"/* +chmod -R go-w "$lifecycle" + +cat >"$tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$TMP/functions" +TOP_MNT=$TMP/top +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE=$TMP/reset.log +DISPATCH=${TEST_DISPATCH:-$ROOT/bin/omarchy-lifecycle-dispatch} +RESET_BOOT="" +RESET_BOOT_ERROR="" +swap_done=0 + +gum() { + if [[ $1 == "input" ]]; then + printf '%s\n' "$CURRENT" + elif [[ $1 == "style" ]]; then + printf '%s\n' "${!#}" >>"$TMP/screen" + fi +} +encrypted_install() { [[ -n $DEVICE ]]; } +luks_device() { echo "$DEVICE"; } +scrub_factory_accounts() { :; } +sanitize_factory_baseline() { :; } +install_provisioning_units() { :; } +systemd-id128() { printf '%032d\n' 7; } +sync() { :; } +mountpoint() { return 0; } +mount() { echo "mount $*" >>"$TMP/calls"; } +umount() { :; } +chroot() { echo "chroot ${*:2}" >>"$TMP/calls"; } +reset_limine_config() { echo "limine menu reset" >>"$TMP/calls"; } +verify_limine_hashes() { :; } + +btrfs() { + case "$1 $2" in + "subvolume snapshot") cp -a "$3" "$4" ;; + "subvolume delete") rm -rf "${!#}" ;; + "subvolume show") return 1 ;; + esac +} + +mv() { + if [[ -e $TMP/switch-fail && $1 == "$TOP_MNT/@" ]]; then + echo "mv: the fixture refuses the switch" >&2 + return 1 + fi + if [[ -e $TMP/activate-fail && $1 == "$TOP_MNT/$NEXT_NAME" ]]; then + echo "mv: the fixture refuses the activation" >&2 + return 1 + fi + if [[ -e $TMP/restore-fail && $1 == "$TOP_MNT"/@omarchy-old-* ]]; then + echo "mv: the fixture refuses the restore" >&2 + return 1 + fi + command mv "$@" +} + +# " " per line. +cryptsetup() { + local op=$1 key_file="" slot material next + local -a args=() + shift + while (( $# )); do + case $1 in + --key-file) key_file=$2; shift 2 ;; + --token-type) shift 2 ;; + -*) shift ;; + *) args+=("$1"); shift ;; + esac + done + echo "cryptsetup $op" >>"$TMP/calls" + case $op in + open) + material=$(cat "$key_file") + if [[ -e $TMP/lookup-fail-once && $material != "$CURRENT" ]]; then + rm "$TMP/lookup-fail-once" + return 2 + fi + slot=$(awk -v m="$material" '$2 == m { print $1; exit }' "$TMP/slots") + [[ -n $slot ]] || return 2 + echo "Key slot $slot unlocked." + ;; + luksAddKey) + material=$(cat "$key_file") + awk -v m="$material" '$2 == m { found = 1 } END { exit !found }' "$TMP/slots" || return 2 + for (( next = 0; next < 32; next++ )); do + awk '{ print $1 }' "$TMP/slots" | grep -qx "$next" || break + done + printf '%s %s\n' "$next" "$(cat "${args[1]}")" >>"$TMP/slots" + ;; + luksDump) + echo "Keyslots:" + awk '{ printf " %s: luks2\n", $1 }' "$TMP/slots" + ;; + luksKillSlot) + slot=${args[1]} + awk -v s="$slot" '$1 != s' "$TMP/slots" >"$TMP/slots.next" + command mv -f "$TMP/slots.next" "$TMP/slots" + ;; + *) return 1 ;; + esac +} + +case $MODE in + owner) reset_boot_owner && echo "$RESET_BOOT" ;; + reset) + trap cleanup EXIT + stage_full_reset + ;; +esac +SH + +current_password=owner-password +device=$tmp/luks-device + +# A top-level Btrfs volume: the running root and a factory baseline whose ESP +# the generic path mounts from its fstab. +fixture() { + rm -rf "$tmp/top" "$tmp/calls" "$tmp/screen" "$tmp/reset.log" "$tmp/committed-key" "$tmp"/*-fail + mkdir -p "$tmp/top/@" "$tmp/top/@factory/etc" "$tmp/top/@factory/usr/bin" \ + "$tmp/top/@factory/usr/share/omarchy/install/provisioning" "$tmp/top/@factory/boot" + touch "$tmp/top/@/old-system" "$tmp/top/@factory/factory-system" "$tmp/esp" \ + "$tmp/top/@factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" + printf '#!/bin/bash\n' >"$tmp/top/@factory/usr/bin/omarchy-provision-owner" + chmod +x "$tmp/top/@factory/usr/bin/omarchy-provision-owner" + printf '%s /boot vfat defaults 0 2\n' "$tmp/esp" >"$tmp/top/@factory/etc/fstab" + : >"$device" + printf '0 %s\n' "$current_password" >"$tmp/slots" + : >"$tmp/reset.log" +} + +run() { + local mode=$1 platform=$2 + TMP=$tmp ROOT=$ROOT MODE=$mode CURRENT=$current_password DEVICE=${DEVICE-$device} TEST_DISPATCH=${TEST_DISPATCH:-} \ + OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT=$lifecycle PATH="$tmp/$platform/bin:$PATH" \ + bash "$tmp/reset" >"$tmp/out" 2>&1 +} + +activated() { + [[ -e $tmp/top/@/factory-system && -f $tmp/top/@/var/lib/omarchy/provisioning/pending ]] && + compgen -G "$tmp/top/@omarchy-old-*/old-system" >/dev/null +} + +untouched() { + [[ -e $tmp/top/@/old-system && ! -e $tmp/top/@omarchy-reset-next ]] && [[ $(cat "$tmp/slots") == "0 $current_password" ]] +} + +# x86: the generic Limine UKI path, with boot-package entrypoints on disk that +# would record themselves if they ran. +fixture +run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: the reset boot step is generic" "$(cat "$tmp/out")" +run reset x86 || fail "x86: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")" +activated || fail "x86: the factory root is activated" +grep -qx 'chroot /usr/bin/limine-update' "$tmp/calls" || fail "x86: limine-update rebuilds the factory root's UKI" "$(cat "$tmp/calls")" +[[ -f $tmp/top/@/etc/omarchy/provisioning.key && -f $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf && + -f $tmp/top/@/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf ]] || fail "x86: the UKI carries the reset's auto-unlock" +[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key")" ]] || + fail "x86: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")" +! grep -q '^reset-' "$tmp/calls" || fail "x86: no boot-package entrypoint runs" "$(cat "$tmp/calls")" +pass "x86: dispatch is a no-op and the generic Limine UKI path resets the machine" + +# A failed activation on the generic path puts the previous root back and +# revokes the throwaway slot. The ESP already holds the factory root's UKI, so +# the screen says to rebuild the boot files rather than claiming nothing changed. +fixture +touch "$tmp/activate-fail" +if run reset x86; then fail "x86: a failed activation fails the reset"; fi +untouched || fail "x86: a failed activation puts the previous root back and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")" +! grep -q '^reset-' "$tmp/calls" || fail "x86: a failed activation runs no boot-package entrypoint" "$(cat "$tmp/calls")" +grep -q "run 'sudo limine-update' before rebooting" "$tmp/screen" || + fail "x86: a failed activation says the boot files need rebuilding" "$(cat "$tmp/screen")" +pass "x86: a failed activation puts the previous root back, revokes the throwaway slot and says to rebuild the boot files" + +# Apple: the boot package prepares and verifies the factory root before the +# throwaway slot exists, and gets the key only once the factory root is active. +fixture +run owner apple && [[ $(cat "$tmp/out") == "platform" ]] || fail "apple: the boot package owns the reset boot step" "$(cat "$tmp/out")" +run reset apple || fail "apple: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")" +activated || fail "apple: the factory root is activated" +expected="reset-prepare $tmp/top/@omarchy-reset-next $device +reset-verify $tmp/top/@omarchy-reset-next +cryptsetup luksAddKey +reset-commit" +[[ $(grep -v '^cryptsetup \(open\|luksDump\)' "$tmp/calls") == "$expected" ]] || + fail "apple: prepare, verify, the throwaway slot, then commit" "$(cat "$tmp/calls")" +staged=$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key") +[[ -n $staged && $(cat "$tmp/committed-key") == "$staged" ]] || fail "apple: reset-commit gets the staged key on standard input" +[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$staged" ]] || fail "apple: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")" +[[ ! -e $tmp/top/@/etc/omarchy/provisioning.key && ! -e $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf ]] || + fail "apple: no Limine UKI unlock is written" +! grep -q 'limine' "$tmp/calls" || fail "apple: the Limine UKI path never runs" "$(cat "$tmp/calls")" +! grep -Fq "$staged" "$tmp/reset.log" "$tmp/calls" "$tmp/out" || fail "apple: the throwaway key is never logged" +pass "apple: the boot package prepares and verifies the factory root before the throwaway slot, and gets the key after the switch" + +# A failed verification adds no credential and rolls the boot state back; the +# previous root keeps booting. +fixture +touch "$tmp/reset-verify-fail" +if run reset apple; then fail "apple: a failed verification fails the reset"; fi +[[ $(grep '^reset-' "$tmp/calls" | cut -d' ' -f1) == $'reset-prepare\nreset-verify\nreset-rollback' ]] || + fail "apple: a failed verification rolls back" "$(cat "$tmp/calls")" +! grep -q 'luksAddKey' "$tmp/calls" || fail "apple: a failed verification adds no slot" +untouched || fail "apple: a failed verification leaves the previous root and its slots" "$(cat "$tmp/slots")" +grep -q 'reset-verify: the fixture refuses' "$tmp/screen" || fail "apple: the verification's reason is shown" "$(cat "$tmp/screen")" +pass "apple: a failed verification rolls back, adds no slot and keeps the previous root" + +# A failure after the slot was added and before the switch revokes it too. +fixture +touch "$tmp/switch-fail" +if run reset apple; then fail "apple: a failed switch fails the reset"; fi +grep -q 'luksAddKey' "$tmp/calls" && grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" || + fail "apple: a failed switch rolls back without committing" "$(cat "$tmp/calls")" +untouched || fail "apple: a failed switch revokes the throwaway slot and keeps the previous root" "$(cat "$tmp/slots")" +pass "apple: a failure before the switch revokes the slot it added and rolls the boot state back" + +# The old root already moved aside when the factory root cannot take @: the old +# root goes back, and the reset is undone as if it failed before the switch. +fixture +touch "$tmp/activate-fail" +if run reset apple; then fail "apple: a failed activation fails the reset"; fi +grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" || + fail "apple: a failed activation rolls back without committing" "$(cat "$tmp/calls")" +untouched || fail "apple: a failed activation puts the previous root back at @ and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")" +! compgen -G "$tmp/top/@omarchy-old-*" >/dev/null || fail "apple: a failed activation leaves no renamed root behind" "$(ls "$tmp/top")" +grep -q 'the current root is back at @' "$tmp/screen" || fail "apple: a failed activation says the current root is back" "$(cat "$tmp/screen")" +pass "apple: a failed activation puts the previous root back, rolls back and revokes the slot" + +# When the old root cannot go back either, neither root is at @: both survive +# for recovery, and the screen names the rename that restores the machine. +fixture +touch "$tmp/activate-fail" "$tmp/restore-fail" +if run reset apple; then fail "apple: a failed activation and restore fails the reset"; fi +old=$(compgen -G "$tmp/top/@omarchy-old-*") || fail "apple: the previous root survives under its moved-aside name" "$(ls "$tmp/top")" +[[ -e $old/old-system && -e $tmp/top/@omarchy-reset-next/factory-system && ! -e $tmp/top/@ ]] || + fail "apple: a failed restore keeps both roots" "$(ls "$tmp/top")" +grep -qx 'reset-rollback' "$tmp/calls" && [[ $(cat "$tmp/slots") == "0 $current_password" ]] || + fail "apple: a failed restore still rolls the boot state back and revokes the slot" "$(cat "$tmp/calls" "$tmp/slots")" +grep -q "rename ${old##*/} to @" "$tmp/screen" || fail "apple: a failed restore names the recovery rename" "$(cat "$tmp/screen")" +pass "apple: a failed activation and restore keeps both roots and names the recovery rename" + +# A slot added but not confirmed is found by its key and revoked all the same. +fixture +touch "$tmp/lookup-fail-once" +if run reset apple; then fail "apple: an unconfirmed throwaway slot fails the reset"; fi +grep -qx 'reset-rollback' "$tmp/calls" && grep -q 'luksKillSlot' "$tmp/calls" || fail "apple: an unconfirmed slot is rolled back and killed" "$(cat "$tmp/calls")" +untouched || fail "apple: an unconfirmed throwaway slot is revoked" "$(cat "$tmp/slots")" +pass "apple: a throwaway slot the reset could not confirm is found by its key and revoked" + +# Once the factory root is active nothing rolls back: a failed commit is logged +# and costs the first boot one password prompt. +fixture +touch "$tmp/reset-commit-fail" +run reset apple || fail "apple: a failed commit still completes the reset" "$(cat "$tmp/out")" +activated && ! grep -qx 'reset-rollback' "$tmp/calls" || fail "apple: a failed commit keeps the factory root active" "$(cat "$tmp/calls")" +grep -q 'reset-commit: the fixture refuses' "$tmp/reset.log" || fail "apple: a failed commit is logged" "$(cat "$tmp/reset.log")" +[[ $(wc -l <"$tmp/slots") == 2 ]] || fail "apple: a failed commit keeps the throwaway slot" +pass "apple: a failed commit after the switch is logged and the reset stands" + +# An unencrypted Mac still has its boot files rebuilt and verified. +fixture +DEVICE="" run reset apple || fail "apple: an unencrypted reset stages" "$(cat "$tmp/out")" +[[ $(grep '^reset-' "$tmp/calls") == "reset-prepare $tmp/top/@omarchy-reset-next"$'\n'"reset-verify $tmp/top/@omarchy-reset-next"$'\n'"reset-commit" ]] || + fail "apple: an unencrypted reset names no LUKS device" "$(cat "$tmp/calls")" +! grep -q '^cryptsetup' "$tmp/calls" || fail "apple: an unencrypted reset touches no key slot" +pass "apple: an unencrypted reset rebuilds and verifies the boot files without a key" + +# Without the boot package, or with one that lacks a reset operation, the +# reset stops before anything is asked or changed, naming the package. +fixture +mv "$mac_boot" "$tmp/mac-boot.off" +if run owner apple; then fail "apple without the boot package: the reset refuses"; fi +grep -q 'reset-prepare on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple without the boot package: the package is named" "$(cat "$tmp/out")" +mkdir -p "$mac_boot" +cp -p "$tmp/mac-boot.off/reset-prepare" "$tmp/mac-boot.off/reset-verify" "$mac_boot/" +chmod -R go-w "$lifecycle" +run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: a partial boot package on disk is ignored" "$(cat "$tmp/out")" +if run owner apple; then fail "apple with part of the boot package: the reset refuses"; fi +grep -q 'reset-commit on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple with part of the boot package: the missing operation is named" "$(cat "$tmp/out")" +rm -rf "$mac_boot" +mv "$tmp/mac-boot.off" "$mac_boot" +pass "apple: without the boot package's reset operations, or with only some of them, the reset refuses naming the package" + +# A registration that left the reset operations optional and got only some of +# them would mix two boot paths: the reset refuses that too. +mkdir -p "$tmp/half" +cat >"$tmp/half/omarchy-lifecycle-dispatch" </dev/null) || continue + [[ $_omarchy_mac_hid_path == /* ]] || continue + MODULES+=("$_omarchy_mac_hid_module") +done +unset _omarchy_mac_hid_module _omarchy_mac_hid_path + +# MTP trackpad firmware (apple/tpmtfw-.bin) is not a MODULES entry. +# omarchy-vendorfw-initrd.service unpacks ESP vendorfw/firmware.cpio onto +# /lib/firmware/vendor (symlink to /vendorfw) before cryptsetup-pre.target. +# Do not FILES+= under /lib/firmware/vendor, and do not pre-create +# /vendorfw/apple in the image: that skipped ESP extraction. diff --git a/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf new file mode 100644 index 00000000..75cbbb50 --- /dev/null +++ b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf @@ -0,0 +1,18 @@ +# Plymouth draws the disk password prompt and the boot splash, as on x86 +# Omarchy. It goes right after systemd so its initrd units order correctly, +# only when the hook is installed, and never twice. +if [[ -f /usr/lib/initcpio/install/plymouth && " ${HOOKS[*]} " != *" plymouth "* ]]; then + _omarchy_mac_plymouth_hooks=() + _omarchy_mac_plymouth_added=false + for _omarchy_mac_plymouth_hook in "${HOOKS[@]}"; do + _omarchy_mac_plymouth_hooks+=("$_omarchy_mac_plymouth_hook") + if [[ $_omarchy_mac_plymouth_hook == systemd && $_omarchy_mac_plymouth_added == false ]]; then + _omarchy_mac_plymouth_hooks+=(plymouth) + _omarchy_mac_plymouth_added=true + fi + done + if [[ $_omarchy_mac_plymouth_added == true ]]; then + HOOKS=("${_omarchy_mac_plymouth_hooks[@]}") + fi + unset _omarchy_mac_plymouth_hooks _omarchy_mac_plymouth_added _omarchy_mac_plymouth_hook +fi diff --git a/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf new file mode 100644 index 00000000..9547df77 --- /dev/null +++ b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf @@ -0,0 +1,46 @@ +# The disk passphrase prompt types with the owner's keyboard layout, as on +# x86 Omarchy: sd-vconsole loads KEYMAP on the console (systemd-ask-password) +# and /etc/vconsole.conf gives Plymouth its XKBLAYOUT. The aarch64 +# omarchy-settings drops upstream's omarchy_hooks.conf, so this drop-in +# carries its guard: a layout that does not type Latin letters stays out of +# the initramfs, because a Latin passphrase would be untypeable in it +# (upstream #6229). The prompt then uses the kernel's US map, which is what +# such a passphrase was typed with. +# +# A systemd HOOKS line gets sd-vconsole exactly once, after keyboard (or +# after systemd without one); keymap and consolefont are its busybox +# counterparts and never belong on such a line. A busybox line (a Mac +# unlocked by cryptdevice=, which 91 leaves alone) keeps its hooks; it only +# gets the file for Plymouth, as upstream does. +# No vconsole.conf is the kernel's US map: sd-vconsole stays, nothing to bundle. +_omarchy_mac_vconsole_latin=true +if [[ -f /etc/vconsole.conf ]]; then + _omarchy_mac_vconsole_layout=$(unset XKBLAYOUT; . /etc/vconsole.conf 2>/dev/null; printf '%s' "${XKBLAYOUT:-}") + case ${_omarchy_mac_vconsole_layout%%,*} in + 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) + _omarchy_mac_vconsole_latin=false ;; + esac +fi + +if [[ " ${HOOKS[*]} " == *" systemd "* ]]; then + _omarchy_mac_vconsole_hooks=() + _omarchy_mac_vconsole_anchor=systemd + [[ " ${HOOKS[*]} " != *" keyboard "* ]] || _omarchy_mac_vconsole_anchor=keyboard + for _omarchy_mac_vconsole_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_vconsole_hook in + sd-vconsole | keymap | consolefont) continue ;; + esac + _omarchy_mac_vconsole_hooks+=("$_omarchy_mac_vconsole_hook") + if [[ $_omarchy_mac_vconsole_hook == "$_omarchy_mac_vconsole_anchor" && $_omarchy_mac_vconsole_latin == true ]]; then + _omarchy_mac_vconsole_hooks+=(sd-vconsole) + _omarchy_mac_vconsole_anchor= + fi + done + HOOKS=("${_omarchy_mac_vconsole_hooks[@]}") +fi + +if [[ $_omarchy_mac_vconsole_latin == true && -f /etc/vconsole.conf ]]; then + FILES+=(/etc/vconsole.conf) +fi +unset _omarchy_mac_vconsole_latin _omarchy_mac_vconsole_layout _omarchy_mac_vconsole_hooks \ + _omarchy_mac_vconsole_anchor _omarchy_mac_vconsole_hook diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 1fb123d0..83824530 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -32,6 +32,17 @@ PY copy_boundary_file bin/omarchy-security-functions copy_boundary_file bin/omarchy-update-pacman copy_boundary_file default/omarchy/sudo-no-update/sudo +copy_boundary_file install/helpers/pacman.sh +# Channel changes copy the platform's templates; the copy itself is a stand-in. +cp -r "$ROOT/default/pacman" "$SUDO_TEST_ROOT/default/" + +# Channel changes ask for the platform. x86 unless a test says otherwise, so no +# test reads the host's hardware. +cat >"$SUDO_TEST_ROOT/bin/omarchy-hw-platform" <<'STUB' +#!/bin/bash +echo "${SUDO_TEST_PLATFORM:-x86}" +STUB +chmod +x "$SUDO_TEST_ROOT/bin/omarchy-hw-platform" cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB' #!/bin/bash @@ -132,7 +143,7 @@ case "$step" in esac STUB chmod +x "$SUDO_TEST_ROOT/bin/test-step" -for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do +for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-boot omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do ln -s test-step "$SUDO_TEST_ROOT/bin/$step" done ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" @@ -140,7 +151,7 @@ ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" reset_boundary() { : >"$SUDO_TEST_LOG" /usr/bin/rm -f "$SUDO_TEST_CACHE" - unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP + unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP SUDO_TEST_PLATFORM } assert_boundary_cold() { [[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization" diff --git a/test/shell.d/hw-display-test.sh b/test/shell.d/hw-display-test.sh index e4028831..5e9bb139 100755 --- a/test/shell.d/hw-display-test.sh +++ b/test/shell.d/hw-display-test.sh @@ -17,8 +17,18 @@ write_backlights() { done } +# A copy of the command reads its platform file from a fixture root: none +# unless a case writes one, whatever the machine running the suite has installed. +platform_root="$tmp_dir/platform" +mkdir -p "$platform_root" "$tmp_dir/bin" +platform_root_copy "$ROOT/bin/omarchy-hw-display" "$tmp_dir/bin/omarchy-hw-display" "$platform_root" + +write_displays_conf() { + printf '%s\n' "$@" >"$platform_root/displays.conf" +} + hw_display() { - OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$ROOT/bin/omarchy-hw-display" + OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$tmp_dir/bin/omarchy-hw-display" } write_backlights intel_backlight @@ -48,9 +58,9 @@ pass "a T2 Mac uses gmux instead of the Touch Bar" write_backlights appletb_backlight if hw_display >/dev/null 2>&1; then - fail "the Touch Bar is never used as the display backlight" + fail "the T2 Touch Bar is never used as the display backlight" fi -pass "the Touch Bar is never used as the display backlight" +pass "the T2 Touch Bar is never used as the display backlight" write_backlights acpi_video0 amdgpu_bl0 appletb_backlight gmux_backlight intel_backlight device=$(hw_display) @@ -62,3 +72,71 @@ if hw_display >/dev/null 2>&1; then fail "no backlight device reports failure" fi pass "no backlight device reports failure" + +# An empty platform file changes nothing. +: >"$platform_root/displays.conf" +write_backlights acpi_video0 amdgpu_bl1 +device=$(hw_display) +[[ $device == "amdgpu_bl1" ]] || fail "an empty platform file keeps the candidate order" "actual: $device" +write_backlights nvidia_wmi_ec_backlight +device=$(hw_display) +[[ $device == "nvidia_wmi_ec_backlight" ]] || fail "an empty platform file keeps the fallback" "actual: $device" +pass "an empty platform file changes nothing" + +write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl' +write_backlights 1000.dsi.0 accessory-pipe +if hw_display >/dev/null 2>&1; then + fail "a skipped backlight is never the fallback" +fi +write_backlights 1000.dsi.0 accessory-pipe zz-other +device=$(hw_display) +[[ $device == "zz-other" ]] || fail "the fallback passes over skipped backlights" "actual: $device" +pass "a platform's skipped backlights are never the fallback" + +write_backlights 1000.dsi.0 amdgpu_bl0 panel-bl +device=$(hw_display) +[[ $device == "panel-bl" ]] || fail "a preferred backlight beats the GPU backlights" "actual: $device" +printf 'backlight-prefer panel-bl\r\n' >"$platform_root/displays.conf" +device=$(hw_display) +[[ $device == "panel-bl" ]] || fail "a file saved with CRLF line ends reads the same" "actual: $device" +write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl' +write_backlights gmux_backlight panel-bl +device=$(hw_display) +[[ $device == "gmux_backlight" ]] || fail "gmux still beats a preferred backlight" "actual: $device" +pass "a platform's preferred backlight follows gmux and beats the GPU backlights" + +write_displays_conf 'backlight-prefer second-bl' 'backlight-prefer first-bl' +write_backlights first-bl second-bl +device=$(hw_display) +[[ $device == "second-bl" ]] || fail "preferences are tried in file order" "actual: $device" +pass "preferences are tried in file order" + +write_displays_conf 'backlight-skip intel_backlight' 'backlight-prefer panel-bl' 'backlight-skip panel-bl' +write_backlights acpi_video0 intel_backlight panel-bl +device=$(hw_display) +[[ $device == "acpi_video0" ]] || fail "a skip beats preferred and built-in candidates" "actual: $device" +write_displays_conf 'backlight-skip gmux_backlight' +write_backlights gmux_backlight intel_backlight +device=$(hw_display) +[[ $device == "intel_backlight" ]] || fail "a skip beats gmux" "actual: $device" +pass "a skip beats preferred and built-in candidates" + +write_displays_conf 'backlight-prefer panel-*' 'backlight-prefer .' 'backlight-prefer ..' +write_backlights amdgpu_bl0 panel-bl +device=$(hw_display) +[[ $device == "amdgpu_bl0" ]] || fail "a preference is a literal name" "actual: $device" +pass "a preference is a literal name" + +# Only whole directives count: an unknown one, a path, an extra word or a +# trailing comment leaves the line out; the last line needs no newline. +printf '%s\n' 'backlight-future panel-bl' 'backlight-prefer ../panel-bl' 'backlight-prefer panel-bl extra' \ + 'backlight-skip acpi_video0 # comment' >"$platform_root/displays.conf" +printf 'backlight-prefer last-bl' >>"$platform_root/displays.conf" +write_backlights acpi_video0 last-bl panel-bl +device=$(hw_display) +[[ $device == "last-bl" ]] || fail "malformed directives are ignored and the last line is read" "actual: $device" +write_displays_conf 'backlight-skip acpi_video0 # comment' +write_backlights acpi_video0 +device=$(hw_display) +[[ $device == "acpi_video0" ]] || fail "a directive with a trailing comment is ignored" "actual: $device" +pass "malformed directives are ignored and the last line is read" diff --git a/test/shell.d/hw-laptop-test.sh b/test/shell.d/hw-laptop-test.sh new file mode 100755 index 00000000..9b4781d9 --- /dev/null +++ b/test/shell.d/hw-laptop-test.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +acpi_lid="$test_tmp/acpi/button/lid" +input_class="$test_tmp/input" +dmi_chassis="$test_tmp/chassis_type" +mkdir -p "$stub_bin" "$acpi_lid/LID0" "$input_class" + +busctl_log="$test_tmp/busctl.log" +cat >"$stub_bin/busctl" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$BUSCTL_LOG" +[[ ${OMARCHY_TEST_LID_STATE:-} != "fail" ]] || exit 1 +printf '%s\n' "${OMARCHY_TEST_LID_STATE:-}" +SH +chmod +x "$stub_bin/busctl" + +run_laptop() { + OMARCHY_DMI_CHASSIS_TYPE_PATH="$dmi_chassis" \ + OMARCHY_ACPI_LID_PATH="$acpi_lid" \ + OMARCHY_INPUT_CLASS_PATH="$input_class" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-hw-laptop" +} + +run_lid_closed() { + OMARCHY_ACPI_LID_PATH="$acpi_lid" \ + OMARCHY_INPUT_CLASS_PATH="$input_class" \ + BUSCTL_LOG="$busctl_log" \ + PATH="$stub_bin:$PATH" \ + OMARCHY_TEST_LID_STATE="$1" \ + "$ROOT/bin/omarchy-hw-laptop-closed" +} + +# Input devices as sysfs shows them: a name and a switch capability bitmap +# whose last word holds SW_LID in bit 0. +input_device() { + mkdir -p "$input_class/$1/capabilities" + printf '%s\n' "$2" >"$input_class/$1/name" + printf '%s\n' "$3" >"$input_class/$1/capabilities/sw" +} + +# No ACPI lid, no lid switch, desktop chassis: a Mac mini, a Studio or a tower. +: >"$acpi_lid/LID0/state" +rm -f "$acpi_lid/LID0/state" +printf '3\n' >"$dmi_chassis" +input_device input0 "Power Button" 0 +input_device input1 "Apple SMC power/lid events" 0 +input_device input2 "MacBook Pro J416 Headphone Jack" 14 +if run_laptop; then + fail "a desktop without a lid is not classified as a laptop" +fi +pass "a desktop without a lid is not classified as a laptop, even with the SMC's power device" + +# Apple Silicon MacBook: no ACPI lid, but the SMC reports a lid switch. +input_device input1 "Apple SMC power/lid events" 1 +run_laptop || fail "an Apple SMC lid switch is classified as a laptop" +pass "an Apple SMC lid switch is classified as a laptop" + +input_device input1 "Lid Switch" "0 1" +run_laptop || fail "a lid switch in the low word of a longer bitmap is classified as a laptop" +pass "a lid switch in the low word of a longer bitmap is classified as a laptop" + +input_device input1 "Lid Switch" "1 0" +if run_laptop; then + fail "a switch bit in a higher word is not a lid" +fi +pass "a switch bit in a higher word is not a lid" + +# x86 laptop via DMI when ACPI and evdev lids are absent. +input_device input1 "Power Button" 0 +printf '9\n' >"$dmi_chassis" +run_laptop || fail "DMI laptop chassis is recognized when ACPI is absent" +pass "DMI laptop chassis is recognized when ACPI is absent" + +printf '3\n' >"$dmi_chassis" +if run_laptop; then + fail "a desktop DMI chassis is not classified as a laptop" +fi +pass "a desktop DMI chassis is not classified as a laptop" + +printf 'closed\n' >"$acpi_lid/LID0/state" +run_laptop || fail "an ACPI lid is classified as a laptop" +pass "an ACPI lid is classified as a laptop" + +# An ACPI lid answers for itself, as it always has, without asking logind over +# D-Bus: this runs in sudo's PAM stack where fingerprint login is set up. +: >"$busctl_log" +run_lid_closed 'b false' || + fail "an ACPI lid reports itself closed" +pass "an ACPI lid reports itself closed" + +printf 'open\n' >"$acpi_lid/LID0/state" +if run_lid_closed 'b true'; then + fail "an ACPI lid reports itself open" +fi +pass "an ACPI lid reports itself open" +[[ ! -s $busctl_log ]] || fail "an ACPI lid leaves logind unasked" "$(cat "$busctl_log")" +pass "an ACPI lid leaves logind unasked" + +# Without an ACPI lid, logind answers only for a lid switch it can track: it +# says "open" of a machine it tracks no lid on. +rm -f "$acpi_lid/LID0/state" +input_device input1 "Power Button" 0 +if run_lid_closed 'b true'; then + fail "a machine without a lid is never closed" +fi +[[ ! -s $busctl_log ]] || fail "a machine without a lid leaves logind unasked" "$(cat "$busctl_log")" +pass "a machine without a lid is never closed and leaves logind unasked" + +input_device input1 "Apple SMC power/lid events" 1 +run_lid_closed 'b true' || + fail "logind recognizes a closed lid switch" +pass "logind recognizes a closed lid switch" +[[ $(head -1 "$busctl_log") == "--timeout=2 "*" LidClosed" ]] || + fail "logind gets a short timeout, not busctl's 25 s" "$(cat "$busctl_log")" +pass "logind gets a short timeout, not busctl's 25 s" + +if run_lid_closed 'b false'; then + fail "logind recognizes an open lid switch" +fi +pass "logind recognizes an open lid switch" + +input_device input1 "Lid Switch" "0 1" +run_lid_closed 'b true' || + fail "a lid switch in the low word of a longer bitmap is asked about" +pass "a lid switch in the low word of a longer bitmap is asked about" + +for answer in '' 'not-a-property' 'fail'; do + if run_lid_closed "$answer"; then + fail "a logind that does not answer leaves the lid open" "$answer" + fi +done +pass "a logind that does not answer leaves the lid open" diff --git a/test/shell.d/hw-platform-test.sh b/test/shell.d/hw-platform-test.sh new file mode 100644 index 00000000..9b476213 --- /dev/null +++ b/test/shell.d/hw-platform-test.sh @@ -0,0 +1,203 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +detector="$ROOT/bin/omarchy-hw-platform" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +for platform in aarch64-apple aarch64 x86; do + fake_platform "$test_tmp/$platform" "$platform" +done + +# A privileged caller reads the live device tree and the system uname, never +# the fixture roots or PATH its environment names. Run it as root when the suite +# is root and as namespaced root otherwise. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + live=$("${root_runner[@]}" "$detector") || fail "root detects the live platform" + [[ $live =~ ^(aarch64-apple|aarch64|x86)$ ]] || fail "root detects the live platform" "live: $live" + for platform in aarch64-apple aarch64 x86; do + fixture="$test_tmp/$platform" + if [[ -f $fixture/proc/device-tree/compatible ]]; then + mkdir -p "$fixture/sys/firmware/devicetree/base" + cp "$fixture/proc/device-tree/compatible" "$fixture/sys/firmware/devicetree/base/compatible" + fi + overridden=$(OMARCHY_PROC_ROOT="$fixture/proc" OMARCHY_SYS_ROOT="$fixture/sys" PATH="$fixture/bin:$ROOT/bin:$PATH" \ + "${root_runner[@]}" "$detector") || fail "root detects the live platform with a $platform fixture in its environment" + [[ $overridden == "$live" ]] || + fail "root ignores a $platform fixture in its environment" "live: $live +with fixture: $overridden" + done + pass "root ignores fixture roots and PATH when detecting the platform" +else + skip "no unprivileged user namespace; skipping the root override probe" +fi + +# -p in the shebang is what keeps exported functions and BASH_ENV out, so an +# ordinary Bash launch with a decoy -p argument is refused before it reads anything. +for command in omarchy-hw-platform omarchy-hw-aarch64-apple omarchy-hw-apple-silicon; do + if /usr/bin/bash "$ROOT/bin/$command" -p >/dev/null 2>"$test_tmp/error"; then + fail "$command refuses an ordinary Bash launch" + fi + grep -Fq "Refusing an unsafe Bash startup" "$test_tmp/error" || fail "$command explains the refusal" "$(cat "$test_tmp/error")" +done +pass "the detector and the Apple predicate refuse an ordinary Bash launch with a decoy -p" + +require_platform_fixtures "the platform fixtures" + +# The three platforms every caller is written against. +for platform in aarch64-apple aarch64 x86; do + fixture="$test_tmp/$platform" + actual=$(OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$detector") || + fail "the $platform fixture is detected" + [[ $actual == "$platform" ]] || fail "the $platform fixture is detected" "actual: $actual" + + apple_status=0 + OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-hw-aarch64-apple" || apple_status=$? + if [[ $platform == "aarch64-apple" ]]; then + (( apple_status == 0 )) || fail "the Apple predicate accepts the Apple fixture" + else + (( apple_status != 0 )) || fail "the Apple predicate rejects the $platform fixture" + fi + pass "the $platform fixture is detected and the Apple predicate agrees" +done + +# Systemd and the shell run the predicate by absolute path with whatever PATH +# they have; it must use the detector shipped beside it. +fixture="$test_tmp/aarch64-apple" +OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-hw-aarch64-apple" || + fail "the Apple predicate finds its detector without Omarchy on PATH" +pass "the Apple predicate finds its detector without Omarchy on PATH" + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" +cat >"$stub_bin/uname" <<'SH' +#!/bin/bash +[[ ${1:-} == -m ]] && { printf '%s\n' "${TEST_ARCH:-aarch64}"; exit 0; } +exec /usr/bin/uname "$@" +SH +chmod +x "$stub_bin/uname" + +# $1 names a case directory holding proc/ and sys/ roots; $2 is the CPU. +detect() { + local case_dir="$test_tmp/cases/$1" + TEST_ARCH="${2:-aarch64}" OMARCHY_PROC_ROOT="$case_dir/proc" OMARCHY_SYS_ROOT="$case_dir/sys" \ + PATH="$stub_bin:$ROOT/bin:$PATH" "$detector" 2>"$test_tmp/error" +} + +# $1 case, $2 proc|sys, then the compatible tokens. +write_tree() { + local case_dir="$test_tmp/cases/$1" tree="$2" file + shift 2 + mkdir -p "$case_dir/proc" "$case_dir/sys" + if [[ $tree == "proc" ]]; then + file="$case_dir/proc/device-tree/compatible" + else + file="$case_dir/sys/firmware/devicetree/base/compatible" + fi + mkdir -p "$(dirname "$file")" + printf '%s\0' "$@" >"$file" +} + +expect() { + local name="$1" machine="$2" expected="$3" description="$4" actual + actual=$(detect "$name" "$machine") || fail "$description" "$(cat "$test_tmp/error")" + [[ $actual == "$expected" ]] || fail "$description" "expected: $expected +actual: $actual" +} + +expect_contradiction() { + local name="$1" machine="$2" description="$3" + if detect "$name" "$machine" >/dev/null; then + fail "$description" + fi + grep -Fq "contradictory platform identity" "$test_tmp/error" || fail "$description explains itself" "$(cat "$test_tmp/error")" +} + +# Real boards: the M1 Pro and M2 Max test Macs, an M1 Mac mini, and the +# Snapdragon X laptops Dragon supports. +write_tree m1-pro proc apple,j314s apple,t6000 apple,arm-platform +write_tree m2-max proc apple,j416c apple,t6021 apple,arm-platform +write_tree m1-mini proc apple,j274 apple,t8103 apple,arm-platform +write_tree yoga-slim7x proc lenovo,yoga-slim7x qcom,x1e80100 +write_tree xps13-9345 proc dell,xps13-9345 qcom,x1e80100 +write_tree t14s proc lenovo,thinkpad-t14s qcom,x1e78100 qcom,x1e80100 +for board in m1-pro m2-max m1-mini; do + expect "$board" aarch64 aarch64-apple "the $board device tree is Apple Silicon" +done +for board in yoga-slim7x xps13-9345 t14s; do + expect "$board" aarch64 aarch64 "the $board device tree is plain aarch64, never Apple Silicon" +done +pass "real Apple and Snapdragon device trees are recognised" + +write_tree qemu-virt proc linux,dummy-virt +write_tree raspberry-pi proc raspberrypi,5-model-b brcm,bcm2712 +mkdir -p "$test_tmp/cases/acpi/proc" "$test_tmp/cases/acpi/sys" +expect qemu-virt aarch64 aarch64 "a QEMU virt board is plain aarch64" +expect raspberry-pi aarch64 aarch64 "a Raspberry Pi is plain aarch64" +expect acpi aarch64 aarch64 "an aarch64 machine without a device tree is plain aarch64" +expect acpi x86_64 x86 "an x86 machine without a device tree is x86" +pass "unknown aarch64 and x86 machines need no family" + +# Only a token's vendor prefix identifies the board; the old detector matched +# "apple," anywhere in the file. +write_tree substring proc pineapple,board acmeqcom,soc vendor,apple,x vendor,qcom,y +expect substring aarch64 aarch64 "vendor names inside other tokens do not identify the board" +pass "only a token's vendor prefix identifies the board" + +# /proc/device-tree is a link into sysfs; read sysfs when it is missing. +write_tree sysfs-qualcomm sys lenovo,yoga-slim7x qcom,x1e80100 +write_tree sysfs-apple sys apple,j314s apple,t6000 apple,arm-platform +expect sysfs-qualcomm aarch64 aarch64 "sysfs reads a Snapdragon tree without /proc/device-tree" +expect sysfs-apple aarch64 aarch64-apple "sysfs identifies Apple Silicon without /proc/device-tree" +write_tree agree proc apple,j314s apple,t6000 apple,arm-platform +write_tree agree sys apple,j314s apple,t6000 apple,arm-platform +expect agree aarch64 aarch64-apple "matching proc and sysfs trees agree" +pass "sysfs is the fallback for the device tree" + +write_tree both-vendors proc apple,j314s qcom,x1e80100 +expect_contradiction both-vendors aarch64 "a device tree naming Apple and Qualcomm fails" +write_tree sources-disagree proc apple,j314s apple,t6000 apple,arm-platform +write_tree sources-disagree sys lenovo,yoga-slim7x qcom,x1e80100 +expect_contradiction sources-disagree aarch64 "proc and sysfs naming different vendors fails" +write_tree vendor-vs-none proc apple,j314s apple,t6000 apple,arm-platform +write_tree vendor-vs-none sys linux,dummy-virt +expect_contradiction vendor-vs-none aarch64 "proc naming Apple while sysfs names nobody fails" +expect_contradiction m1-pro x86_64 "an Apple device tree on an x86 CPU fails" +expect_contradiction yoga-slim7x x86_64 "a Qualcomm device tree on an x86 CPU fails" +if TEST_ARCH=x86_64 OMARCHY_PROC_ROOT="$test_tmp/cases/m1-pro/proc" PATH="$stub_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-hw-aarch64-apple" 2>/dev/null; then + fail "the Apple predicate fails closed on contradictory identity" +fi +pass "contradictory identity fails with an explanation" + +failing_uname="$test_tmp/failing-uname" +mkdir -p "$failing_uname" +printf '#!/bin/bash\nexit 1\n' >"$failing_uname/uname" +chmod +x "$failing_uname/uname" +if OMARCHY_PROC_ROOT="$test_tmp/cases/acpi/proc" PATH="$failing_uname:$PATH" "$detector" 2>"$test_tmp/error"; then + fail "an unreadable CPU architecture fails instead of guessing generic" +fi +grep -Fq "cannot read the CPU architecture" "$test_tmp/error" || fail "an unreadable CPU architecture explains itself" "$(cat "$test_tmp/error")" +pass "an unreadable CPU architecture fails instead of guessing generic" + +# The architecture predicates answer from the CPU alone: aarch64 covers every +# ARM family, Apple Silicon included, and x86 none of them. +for platform in x86 aarch64 aarch64-apple; do + fake_platform "$test_tmp/arch-$platform" "$platform" + arch_path="$test_tmp/arch-$platform/bin:$ROOT/bin:$PATH" + if [[ $platform == "x86" ]]; then + PATH=$arch_path omarchy-hw-x86 || fail "x86: omarchy-hw-x86 accepts it" + ! PATH=$arch_path omarchy-hw-aarch64 || fail "x86: omarchy-hw-aarch64 rejects it" + else + PATH=$arch_path omarchy-hw-aarch64 || fail "$platform: omarchy-hw-aarch64 accepts it" + ! PATH=$arch_path omarchy-hw-x86 || fail "$platform: omarchy-hw-x86 rejects it" + fi +done +pass "omarchy-hw-x86 and omarchy-hw-aarch64 split every platform by its CPU" diff --git a/test/shell.d/hw-touchpad-test.sh b/test/shell.d/hw-touchpad-test.sh new file mode 100755 index 00000000..27a86ed9 --- /dev/null +++ b/test/shell.d/hw-touchpad-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +touchpad="$ROOT/bin/omarchy-hw-touchpad" + +require_command jq + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +input_class="$test_tmp/input" +mkdir -p "$stub_bin" + +# Hyprland's mice, in its order, from a space-separated MICE. +cat >"$stub_bin/hyprctl" <<'SH' +#!/bin/bash + +jq -n --arg names "${MICE:-}" \ + '{mice: [$names | split(" ")[] | select(. != "") | {name: .}], keyboards: []}' +SH + +# udev's properties for an event device, kept beside it in the fixture. +cat >"$stub_bin/udevadm" <<'SH' +#!/bin/bash + +cat "${!#}/udev" 2>/dev/null +SH + +chmod +x "$stub_bin"/* + +# An event device as sysfs shows it: its input device's name, and what udev +# made of it. +event_device() { + mkdir -p "$input_class/$1/device" + printf '%s\n' "$2" >"$input_class/$1/device/name" + printf '%s\n' "$3" >"$input_class/$1/udev" +} + +detected() { + MICE="$1" OMARCHY_INPUT_CLASS_PATH="$input_class" PATH="$stub_bin:$PATH" bash "$touchpad" +} + +# Apple Silicon's trackpad is named after its MTP HID interface, so neither +# word is in its name; udev still calls it a touchpad. +event_device event1 "Apple MTP keyboard" "ID_INPUT_KEYBOARD=1" +event_device event2 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "apple-mtp-multi-touch") == "apple-mtp-multi-touch" ]] || + fail "the Apple Silicon trackpad is detected" "$(detected "apple-mtp-multi-touch")" +pass "the Apple Silicon trackpad is detected" + +# omarchy-toggle-input-device passes the name straight to hl.device, so a name +# that does not match leaves the menu entry hidden and the toggle dead. +for name in "elan-touchpad" "apple-magic-trackpad-2"; do + [[ $(detected "$name") == "$name" ]] || fail "the touchpads that already worked still do" "$(detected "$name")" +done +pass "the touchpads that already worked still do" + +# Some touchscreens' mouse interfaces are called multi-touch as well. A named +# touchpad wins over one listed first, and without one it takes udev calling +# the device a touchpad, not its name. +rm -rf "$input_class" +event_device event5 "ELAN9008:00 04F3:2C82 Multi-Touch" "ID_INPUT_TOUCHSCREEN=1" +event_device event7 "SYNA2BA6:00 06CB:CEF5 Touchpad" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad") == "syna2ba6:00-06cb:cef5-touchpad" ]] || + fail "a touchscreen listed first does not take the touchpad's place" "$(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad")" +[[ -z $(detected "elan9008:00-04f3:2c82-multi-touch") ]] || + fail "a multi-touch touchscreen alone is no touchpad" "$(detected "elan9008:00-04f3:2c82-multi-touch")" +pass "a multi-touch touchscreen is never taken for the touchpad" + +# Hyprland turns commas into dashes as well as spaces. +event_device event9 "Acme, Inc. Precision Pad" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "acme--inc.-precision-pad") == "acme--inc.-precision-pad" ]] || + fail "a touchpad named with a comma is found by Hyprland's name for it" "$(detected "acme--inc.-precision-pad")" +pass "a touchpad named with a comma is found by Hyprland's name for it" + +# Hyprland adds -1, -2 and so on to a name another device already took, as when +# the MTP interface exposes the trackpad twice; that name is the one to toggle, +# and an unrelated name that merely starts the same is not. +event_device event11 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "apple-mtp-multi-touch-1") == "apple-mtp-multi-touch-1" ]] || + fail "a touchpad under Hyprland's numbered name is found" "$(detected "apple-mtp-multi-touch-1")" +[[ -z $(detected "apple-mtp-multi-touch-extra") ]] || + fail "a name that only starts with the touchpad's is not taken for it" "$(detected "apple-mtp-multi-touch-extra")" +pass "a touchpad under Hyprland's numbered name for a duplicate is found" + +# udev's touchpad has to be one of Hyprland's mice for its name to toggle. +[[ -z $(detected "logitech-mx-master-3") ]] || + fail "a udev touchpad Hyprland does not list is not reported" "$(detected "logitech-mx-master-3")" +pass "an ordinary mouse is not mistaken for a touchpad" + +[[ -z $(detected "") ]] || + fail "no touchpad answers with an empty detection" "$(detected "")" +pass "no touchpad answers with an empty detection" diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index 90af5845..f18b7538 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -151,7 +151,9 @@ cat > "$stub_bin/omarchy-default-dictation" <<'SH' #!/bin/bash echo voxtype SH -chmod +x "$stub_bin/omarchy-default-dictation" +# The dictation keys are bound only once the selected backend is installed. +printf '#!/bin/bash\nexit 0\n' > "$stub_bin/voxtype" +chmod +x "$stub_bin/omarchy-default-dictation" "$stub_bin/voxtype" bindings=$(PATH="$stub_bin:$PATH" list_bindings "$home") [[ -n $bindings ]] || fail "default bindings load for the conflict check" diff --git a/test/shell.d/hyprland-default-config-test.sh b/test/shell.d/hyprland-default-config-test.sh index 0e12178a..3fe76dbd 100644 --- a/test/shell.d/hyprland-default-config-test.sh +++ b/test/shell.d/hyprland-default-config-test.sh @@ -158,6 +158,10 @@ echo future-backend SH chmod +x "$missing_bin/omarchy-default-dictation" dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +if grep -Fq 'dictation' <<<"$dictation_output"; then fail "a selected backend that is not installed leaves application shortcuts available"; fi +printf '#!/bin/bash\nexit 0\n' > "$missing_bin/future-backend" +chmod +x "$missing_bin/future-backend" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") for binding in \ $'SUPER + CTRL + X\tToggle dictation' \ $'F9\tStart dictation (push-to-talk)' \ @@ -166,7 +170,7 @@ for binding in \ $'ALT + Alt_R\tStop dictation (push-to-talk)'; do grep -Fq "$binding" <<<"$dictation_output" || fail "dictation bindings load for any selected backend" "$binding" done -pass "dictation bindings require a selection without hardcoding backends" +pass "dictation bindings require an installed selection without hardcoding backends" # Lazydocker is optional on new installs, while existing installs keep the hotkey. lazydocker_bin="$tmpdir/lazydocker-bin" diff --git a/test/shell.d/image-deferred-hardware-test.sh b/test/shell.d/image-deferred-hardware-test.sh new file mode 100644 index 00000000..741ef4d3 --- /dev/null +++ b/test/shell.d/image-deferred-hardware-test.sh @@ -0,0 +1,665 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Root never reads the fixture root, so as root the build and first boot below +# would act on this machine. A skip is a pass. +if (( EUID == 0 )); then + skip "running as root, where image setup ignores fixture roots; skipping" + exit 0 +fi + +umask 022 +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +unit_name=omarchy-provision-hardware.service +unit_source="$ROOT/install/provisioning/$unit_name" + +# omarchy-apply-hardware refuses anyone but root, and root never reads the +# fixture root. Run a copy whose only change is that check, so a normal user +# can build an image into a fixture root. +apply_hardware="$test_tmp/omarchy-apply-hardware" +sed 's/^if (( EUID != 0 )); then$/if false; then/' "$ROOT/bin/omarchy-apply-hardware" >"$apply_hardware" +chmod +x "$apply_hardware" +grep -q '^if false; then$' "$apply_hardware" || fail "the test copy of omarchy-apply-hardware drops only its root check" + +# Commands a hardware leaf would reach for. Any call means a leaf ran. +stub_bin="$test_tmp/stub-bin" +mkdir -p "$stub_bin" +for command in sudo pacman pacman-key systemctl lspci omarchy-pkg-add modinfo; do + cat >"$stub_bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$LEAF_CALLS" +exit 1 +SH +done +stub_rebuild() { + cat >"$stub_bin/$1" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$REBUILDS" +SH + chmod +x "$stub_bin/$1" +} +stub_rebuild mkinitcpio +# Only the stubs count as present, so a machine with Limine never runs its own +# limine-mkinitcpio here. +cat >"$stub_bin/omarchy-cmd-present" <"$fixture/install/hardware/all.sh" <<'SH' +run_logged "$OMARCHY_INSTALL/hardware/a.sh" +run_logged "$OMARCHY_INSTALL/hardware/apple/b.sh" +run_logged "$OMARCHY_INSTALL/hardware/c.sh" +SH +cat >"$fixture/install/hardware/a.sh" <<'SH' +printf 'a user=%s path=%s\n' "${OMARCHY_INSTALL_USER-unset}" "$OMARCHY_PATH" >>"$RUNS" +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d" +echo 'MODULES+=(a)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/a.conf" +SH +cat >"$fixture/install/hardware/apple/b.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d" +echo 'MODULES+=(b)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/b.conf" +[[ ! -e $FAIL_B ]] || return 1 +echo b >>"$RUNS" +SH +cat >"$fixture/install/hardware/c.sh" <<'SH' +echo c >>"$RUNS" +[[ ! -e $REQUEST_REBUILD ]] || touch "$OMARCHY_IMAGE_BOOT_REBUILD" +SH +export RUNS="$test_tmp/runs" FAIL_B="$test_tmp/fail-b" REQUEST_REBUILD="$test_tmp/request-rebuild" + +new_root() { + local root="$test_tmp/root-$1" + rm -rf "$root" + mkdir -p "$root/var/log" + printf '%s\n' "$root" +} + +write_manifest() { + local root=$1 body=${2:-$'format=1\nplatform=aarch64-apple\n'} + mkdir -p "$root/var/lib/omarchy/image" + chmod 0755 "$root/var/lib/omarchy/image" + printf '%s' "$body" >"$root/var/lib/omarchy/image/target" + chmod 0644 "$root/var/lib/omarchy/image/target" +} + +build() { + local root=$1 omarchy=${2:-$fixture} + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$omarchy" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$base_path" "$apply_hardware" --defer-provisioning +} + +first_boot() { + local root=$1 + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware" +} + +reset_logs() { + rm -f "$RUNS" "$LEAF_CALLS" "$REBUILDS" "$FAIL_B" +} + +queue_of() { + cat "$1/var/lib/omarchy/image/deferred-steps" +} + +# --- The build --------------------------------------------------------------- + +# The real hardware setup: every leaf is queued in order and none of them runs. +reset_logs +root=$(new_root real) +write_manifest "$root" +output=$(build "$root" "$ROOT") || fail "an image build defers the real hardware setup" "$output" +expected=$(sed -n 's|^run_logged "\$OMARCHY_INSTALL/\(hardware/[^"]*\)"$|install/\1|p' "$ROOT/install/hardware/all.sh") +[[ -n $expected && $(queue_of "$root") == "$expected" ]] || + fail "an image build queues every hardware leaf in order" "expected: +$expected +queued: +$(queue_of "$root")" +[[ ! -e $LEAF_CALLS ]] || fail "an image build runs no hardware leaf" "$(cat "$LEAF_CALLS")" +[[ $output == *"Image build for aarch64-apple: deferred $(wc -l <<<"$expected") hardware steps to first boot"* ]] || + fail "an image build reports what it deferred" "$output" +pass "an image build queues every hardware leaf in order and runs none of them" + +[[ $(<"$unit_source") == "$(<"$root/etc/systemd/system/$unit_name")" ]] || + fail "an image build installs the first-boot hardware service" +[[ $(readlink "$root/etc/systemd/system/multi-user.target.wants/$unit_name") == "/etc/systemd/system/$unit_name" ]] || + fail "an image build enables the first-boot hardware service" +[[ -f $root/var/lib/omarchy/image/target && ! -e $root/var/lib/omarchy/image/target.booted ]] || + fail "an image build leaves its manifest for the first boot" +[[ $(stat -c '%a' "$root/var/lib/omarchy/image/deferred-steps") == "644" ]] || + fail "the queue is readable and only its owner writes it" +pass "an image build arms the first-boot hardware service" + +before=$(queue_of "$root") +build "$root" "$ROOT" >/dev/null || fail "an image build can run hardware setup again" +[[ $(queue_of "$root") == "$before" ]] || fail "running hardware setup again in the build queues the same steps" +pass "running hardware setup again in the build queues the same steps" + +# Without a manifest nothing is deferred: the leaves run as they always did. +reset_logs +root=$(new_root live) +build "$root" >/dev/null || fail "hardware setup without a manifest runs" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] || fail "hardware setup without a manifest runs every leaf" "$(cat "$RUNS")" +[[ ! -e $root/var/lib/omarchy/image && ! -e $root/etc/systemd/system/$unit_name ]] || + fail "hardware setup without a manifest queues and arms nothing" +pass "hardware setup without a manifest runs every leaf and defers nothing" + +# Hardware setup that has to wait for a first boot in progress decides only +# once it has the lock: by then the manifest is retired and the leaves run here. +reset_logs +root=$(new_root race) +write_manifest "$root" +( + exec {lock}>>"$root/var/lib/omarchy/image/lock" + flock "$lock" + touch "$test_tmp/locked" + until [[ -e $test_tmp/release || ! -d $test_tmp ]]; do sleep 0.1; done + mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted" +) & +first_boot_pid=$! +until [[ -e $test_tmp/locked ]]; do sleep 0.1; done +build "$root" >/dev/null & +setup_pid=$! +sleep 1 +kill -0 "$setup_pid" 2>/dev/null || fail "hardware setup waits while a first boot holds the lock" +touch "$test_tmp/release" +wait "$first_boot_pid" +wait "$setup_pid" || fail "hardware setup runs after waiting for a first boot" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "hardware setup that waited for a first boot runs its leaves instead of queueing them" +pass "hardware setup waits for a first boot in progress and then runs on the machine" + +# A manifest that is not unambiguously root's own stops hardware setup outright. +check_refused() { + local description=$1 root=$2 + reset_logs + if build "$root" >/dev/null 2>&1; then + fail "hardware setup refuses $description" + fi + [[ ! -e $RUNS && ! -e $root/var/lib/omarchy/image/deferred-steps && ! -e $root/etc/systemd/system/$unit_name ]] || + fail "hardware setup refuses $description without running, queueing or arming anything" + pass "hardware setup refuses $description" +} + +root=$(new_root format) +write_manifest "$root" $'format=2\nplatform=aarch64-apple\n' +check_refused "a manifest of an unknown format" "$root" + +root=$(new_root platform) +write_manifest "$root" $'format=1\nplatform=apple\n' +check_refused "a manifest naming an unknown platform" "$root" + +root=$(new_root malformed) +write_manifest "$root" $'format=1\napple-silicon\n' +check_refused "a malformed manifest" "$root" + +root=$(new_root writable) +write_manifest "$root" +chmod 0666 "$root/var/lib/omarchy/image/target" +check_refused "a manifest others can write" "$root" + +root=$(new_root writable-dir) +write_manifest "$root" +chmod 0777 "$root/var/lib/omarchy/image" +check_refused "a manifest in a directory others can write" "$root" + +root=$(new_root symlink) +write_manifest "$root" +mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/real-target" +ln -s real-target "$root/var/lib/omarchy/image/target" +check_refused "a symlinked manifest" "$root" + +# --- The first boot ---------------------------------------------------------- + +reset_logs +root=$(new_root fresh) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +[[ $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the fixture image queues its three leaves" "$(queue_of "$root")" + +output=$(first_boot "$root") || fail "the first boot finishes the deferred hardware setup" "$output" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] || + fail "the first boot runs each deferred leaf once, in order, with no install user" "$(cat "$RUNS")" +[[ $output == *"First boot of an image built for aarch64-apple, on aarch64-apple hardware"* ]] || + fail "the first boot reports the image target and the live platform" "$output" +[[ ! -e $root/var/lib/omarchy/image/target && -f $root/var/lib/omarchy/image/target.booted ]] || + fail "the first boot retires the build manifest" +[[ ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot empties the queue" +[[ ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -f $root/etc/systemd/system/$unit_name ]] || + fail "the first boot disables its service and keeps the unit, so a start job already queued is skipped rather than failed" +[[ $(cat "$REBUILDS") == "mkinitcpio -P" ]] || + fail "the first boot rebuilds the initramfs once after a leaf changed it" "$(cat "$REBUILDS" 2>/dev/null)" +[[ ! -e $root/var/lib/omarchy/image/initramfs-inputs ]] || fail "the first boot clears the rebuild it owed" +grep -q 'Completed: .*/install/hardware/c.sh' "$root/var/log/omarchy-install.log" || + fail "the first boot logs each step to the install log" +pass "the first boot runs the deferred hardware setup with no command from the owner" + +rm -f "$RUNS" "$REBUILDS" +output=$(first_boot "$root") || fail "running the first-boot hardware setup again succeeds" +[[ -z $output && ! -e $RUNS && ! -e $REBUILDS ]] || + fail "running the first-boot hardware setup again does nothing" "$output" +pass "running the first-boot hardware setup again does nothing" + +# The live platform is asked once the manifest is retired, so it is the +# hardware's even where the detector cannot tell a booted root (this fixture +# runs no systemd): an Apple Silicon image booted in a VM reports plain aarch64. +fake_platform "$test_tmp/vm" aarch64 +reset_logs +root=$(new_root vm) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds for a VM boot" +output=$(OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/vm/proc" \ + PATH="$test_tmp/vm/bin:$stub_bin:$ROOT/bin:/usr/local/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-provision-hardware") || + fail "the first boot on other hardware finishes" "$output" +[[ $output == *"First boot of an image built for aarch64-apple, on aarch64 hardware"* ]] || + fail "the first boot reports the hardware it runs on, not the image target" "$output" +pass "the first boot reports the hardware it runs on, not the image target" + +# A failed step stays queued with everything after it; the next boot resumes there. +reset_logs +root=$(new_root resume) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +# A repository without a sync database, which the image never has: the failed +# step is not retried after a fetch. +mkdir -p "$root/etc" +printf '[options]\nArchitecture = auto\n\n[core]\nServer = https://mirror.invalid/$arch/$repo\n' >"$root/etc/pacman.conf" +touch "$FAIL_B" +status=0 +output=$(first_boot "$root" 2>&1) || status=$? +(( status == 75 )) || fail "a failed step exits 75, so a platform first boot can tell it from a refusal" "status $status: $output" +[[ $output == *"Deferred hardware step failed: install/hardware/apple/b.sh"* ]] || + fail "the first boot names the failed step" "$output" +[[ $(cat "$RUNS") == "a user= path=$fixture" ]] || fail "the first boot stops at the failed step" "$(cat "$RUNS")" +[[ $(queue_of "$root") == $'install/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the failed step and those after it stay queued" "$(queue_of "$root")" +[[ -f $root/var/lib/omarchy/image/target.booted && ! -e $root/var/lib/omarchy/image/target ]] || + fail "the machine stops being an image build once its first boot starts" +[[ -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -e $root/var/lib/omarchy/image/initramfs-inputs && ! -e $REBUILDS ]] || + fail "the service stays armed and the initramfs rebuild stays owed until the queue is empty" +[[ ! -e $LEAF_CALLS ]] || fail "a failed step fetches no package databases" "$(cat "$LEAF_CALLS")" +pass "a failed step stays queued with the steps after it" + +rm -f "$FAIL_B" "$RUNS" +stub_rebuild limine-mkinitcpio +output=$(first_boot "$root") || fail "the next boot finishes the deferred hardware setup" "$output" +[[ $(cat "$RUNS") == $'b\nc' ]] || fail "the next boot resumes at the failed step" "$(cat "$RUNS")" +[[ $(cat "$REBUILDS") == "limine-mkinitcpio " ]] || + fail "the next boot rebuilds the initramfs the earlier boot owed, through Limine when present" "$(cat "$REBUILDS" 2>/dev/null)" +[[ ! -e $root/var/lib/omarchy/image/deferred-steps && ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name ]] || + fail "the next boot disarms the service" +pass "the next boot resumes at the failed step and finishes" + +# The step that changed the initramfs inputs is the one that failed: its rerun +# changes nothing more, and the rebuild is still owed. +reset_logs +rm -f "$stub_bin/limine-mkinitcpio" +root=$(new_root rebuild) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/apple/b.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +touch "$FAIL_B" +first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step" +rm -f "$FAIL_B" +first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup" +[[ $(cat "$RUNS") == $'b\nc' && $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that changed the initramfs and then failed still gets it rebuilt" "$(cat "$REBUILDS" 2>/dev/null)" +pass "a step that changed the initramfs and then failed still gets it rebuilt" + +# A step that only adds to the kernel command line Limine's entry tool builds +# (as intel/fred.sh does) gets the rebuild too. +reset_logs +root=$(new_root cmdline) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +cat >"$fixture/install/hardware/cmdline.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d" +echo 'KERNEL_CMDLINE[default]+=" example=on"' >"$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d/example.conf" +SH +printf '%s\n' install/hardware/cmdline.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that changes the kernel command line finishes" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that only changed Limine's kernel command line gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)" +rm -f "$fixture/install/hardware/cmdline.sh" +pass "a step that only changed Limine's kernel command line gets the rebuild" + +# Module options reach the initramfs through mkinitcpio's modconf hook, so a +# step that only writes /etc/modprobe.d (as nvidia.sh does) gets the rebuild too. +reset_logs +root=$(new_root modprobe) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +cat >"$fixture/install/hardware/modprobe.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/modprobe.d" +echo 'options example flag=1' >"$OMARCHY_IMAGE_ROOT/etc/modprobe.d/example.conf" +SH +printf '%s\n' install/hardware/modprobe.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that changes module options finishes" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that only changed module options gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)" +rm -f "$fixture/install/hardware/modprobe.sh" +pass "a step that only changed module options gets the rebuild" + +# --- The pacman keyring ------------------------------------------------------- + +# Install finalization makes the pacman keyring on plain aarch64. +# An image build asks for it instead, so every machine flashed from the image +# makes its own master key on its first boot, before any step installs a package. +keyring_bin="$test_tmp/keyring-bin" +mkdir -p "$keyring_bin" "$fixture/install/post-install" +# Finalization's repositories are pacman-templates-test.sh's; here they land in +# the root under test, never the host's /etc. +sed 's|/etc/pacman|$OMARCHY_IMAGE_ROOT/etc/pacman|g' "$ROOT/install/post-install/pacman.sh" >"$fixture/install/post-install/pacman.sh" +cp "$ROOT/install/helpers/pacman.sh" "$fixture/install/helpers/pacman.sh" +mkdir -p "$fixture/default" +cp -r "$ROOT/default/pacman" "$fixture/default/" +: >"$fixture/install/hardware/pacman.sh" +for command in pacman-key omarchy-pkg-add; do + cat >"$keyring_bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$KEYRING" +[[ ${0##*/} != "pacman-key" || ! -e $KEYRING_FAIL ]] +SH +done +printf '#!/bin/bash\necho "$PLATFORM"\n' >"$keyring_bin/omarchy-hw-platform" +chmod +x "$keyring_bin"/* +export KEYRING="$test_tmp/keyring" KEYRING_FAIL="$test_tmp/keyring-fail" +request=var/lib/omarchy/image/pacman-keyring + +finalize() { + mkdir -p "$1/etc/pacman.d" + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_INSTALL="$fixture/install" PLATFORM=$2 \ + PATH="$keyring_bin:$base_path" bash -e -c 'source "$1"' bash "$fixture/install/post-install/pacman.sh" +} + +keyring_boot() { + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$keyring_bin:$base_path" "$ROOT/bin/omarchy-provision-hardware" +} + +for platform in aarch64; do + rm -f "$KEYRING" + root=$(new_root "install-$platform") + finalize "$root" "$platform" || fail "$platform: install finalization succeeds" + [[ $(cat "$KEYRING") == $'omarchy-pkg-add archlinuxarm-keyring\npacman-key --init\npacman-key --populate' ]] || + fail "$platform: an install makes its pacman keyring in finalization, as before" "$(cat "$KEYRING")" + [[ ! -e $root/$request ]] || fail "$platform: an install requests no first-boot keyring" + + rm -f "$KEYRING" + root=$(new_root "image-$platform") + write_manifest "$root" $'format=1\nplatform='"$platform"$'\n' + build "$root" >/dev/null || fail "$platform: the fixture image builds" + finalize "$root" "$platform" || fail "$platform: image finalization succeeds" + [[ $(cat "$KEYRING") == "omarchy-pkg-add archlinuxarm-keyring" ]] || + fail "$platform: an image build makes no pacman keyring" "$(cat "$KEYRING")" + [[ -f $root/$request ]] || fail "$platform: an image build asks its first boot for the keyring" +done +for platform in aarch64-apple x86; do + rm -f "$KEYRING" + root=$(new_root "image-$platform") + write_manifest "$root" $'format=1\nplatform='"$platform"$'\n' + build "$root" >/dev/null || fail "$platform: the fixture image builds" + finalize "$root" "$platform" || fail "$platform: image finalization succeeds" + finalize "$(new_root "install-$platform")" "$platform" || fail "$platform: install finalization succeeds" + [[ ! -e $KEYRING && ! -e $root/$request ]] || fail "$platform: finalization leaves the keyring alone" "$(cat "$KEYRING" 2>/dev/null)" +done +pass "an image build on plain aarch64 leaves the pacman keyring to the first boot" + +reset_logs +rm -f "$KEYRING" +root=$(new_root image-aarch64-apple) +write_manifest "$root" +build "$root" >/dev/null || fail "the Mac fixture image builds" +keyring_boot "$root" >/dev/null || fail "a Mac image's first boot finishes" +[[ ! -e $KEYRING ]] || fail "a first boot nobody asked for a keyring leaves the keyring alone" "$(cat "$KEYRING")" +pass "a first boot nobody asked for a keyring leaves the keyring alone" + +reset_logs +root=$test_tmp/root-image-aarch64 +mkdir -p "$root/etc/pacman.d/gnupg" +touch "$root/etc/pacman.d/gnupg/build-master-key" "$KEYRING_FAIL" +status=0 +output=$(KEYRING=$RUNS keyring_boot "$root" 2>&1) || status=$? +(( status == 75 )) || fail "a keyring that can't be made exits 75" "status $status: $output" +[[ $output == *"could not create the pacman keyring"* ]] || fail "the first boot says the keyring failed" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init" ]] || + fail "no step runs before the keyring exists" "$(cat "$RUNS")" +[[ -f $root/$request && $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the keyring request and the whole queue stay for the next boot" +pass "a keyring that can't be made stays requested and holds back every step" + +rm -f "$KEYRING_FAIL" "$RUNS" +mkdir -p "$root/etc/pacman.d/gnupg" +touch "$root/etc/pacman.d/gnupg/build-master-key" +output=$(KEYRING=$RUNS keyring_boot "$root") || fail "the next boot makes the keyring and finishes" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate"$'\na user= path='"$fixture"$'\nb\nc' ]] || + fail "the first boot makes the keyring, then runs the steps" "$(cat "$RUNS")" +[[ ! -e $root/etc/pacman.d/gnupg/build-master-key ]] || fail "the first boot replaces a keyring the build left" +[[ ! -e $root/$request && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot clears the keyring request" +pass "the first boot replaces any build keyring with the machine's own before the steps run" + +# A keyring request with nothing queued (a build whose hardware setup queued no +# step) still gets its keyring. +reset_logs +rm -f "$RUNS" +root=$test_tmp/root-keyring-only +mkdir -p "$root/var/lib/omarchy/image" +install -m 0644 /dev/null "$root/$request" +output=$(KEYRING=$RUNS keyring_boot "$root") || fail "a first boot with only a keyring request finishes" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate" && ! -e $root/$request ]] || + fail "a keyring request is honoured with no queued step" "$(cat "$RUNS" 2>/dev/null)" +grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring' "$ROOT/install/provisioning/omarchy-provision-hardware.service" || + fail "the first-boot unit also starts for a keyring request alone" +pass "a keyring request with no queued step still gets the machine's own keyring" + +# A leaf a later Omarchy no longer ships is dropped; a queue entry outside the +# hardware setup stops the run before anything runs. +reset_logs +root=$(new_root gone) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/removed.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +output=$(first_boot "$root") || fail "a step no longer shipped does not block the rest" "$output" +[[ $output == *"Skipping deferred hardware step install/hardware/removed.sh"* && $(cat "$RUNS") == "c" ]] || + fail "a step no longer shipped is skipped and the rest run" "$output" +pass "a deferred step Omarchy no longer ships is skipped" + +# A step that would build the boot image itself asks for the rebuild after the +# last step instead: one rebuild, even when no +# initramfs input changed, and the request outlives a run that stopped early. +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that asks for nothing finishes" +[[ ! -e $REBUILDS ]] || fail "no rebuild without a changed input or a request" "$(cat "$REBUILDS")" +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +touch "$REQUEST_REBUILD" +first_boot "$root" >/dev/null || fail "a step that asks for the rebuild finishes" +rm -f "$REQUEST_REBUILD" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] || + fail "the request alone, with no changed input, gets one rebuild and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)" +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh install/hardware/apple/b.sh >"$root/var/lib/omarchy/image/deferred-steps" +rm -f "$root/etc/mkinitcpio.conf.d/b.conf" +touch "$REQUEST_REBUILD" "$FAIL_B" +first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step" +[[ -f $root/var/lib/omarchy/image/boot-rebuild && ! -e $REBUILDS ]] || + fail "a requested rebuild waits for the queue to empty, and stays requested" +rm -f "$FAIL_B" "$REQUEST_REBUILD" +first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] || + fail "the requested rebuild runs once after the last step and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)" +pass "a step can ask for the one rebuild after the last step" + +for entry in /etc/passwd install/hardware/../../bin/x.sh install/login/sddm.sh; do + reset_logs + root=$(new_root unsafe) + write_manifest "$root" + build "$root" >/dev/null || fail "the fixture image builds" + printf '%s\n' "$entry" install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" + status=0 + first_boot "$root" >/dev/null 2>&1 || status=$? + (( status == 1 )) || fail "the first boot refuses the queue entry $entry with status 1, not a step failure's 75" "status $status" + [[ ! -e $RUNS && $(head -n 1 "$root/var/lib/omarchy/image/deferred-steps") == "$entry" ]] || + fail "the first boot refuses the queue entry $entry before running anything" +done +pass "the first boot runs only hardware setup leaves from its queue" + +reset_logs +root=$(new_root nothing) +output=$(first_boot "$root") || fail "the first-boot hardware setup succeeds with nothing deferred" +[[ -z $output && ! -e $root/var/lib/omarchy ]] || fail "the first-boot hardware setup does nothing with nothing deferred" "$output" +pass "with nothing deferred the first-boot hardware setup does nothing" + +if env -u OMARCHY_IMAGE_ROOT OMARCHY_PATH="$fixture" PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1; then + fail "the first-boot hardware setup refuses a normal user" +fi +pass "the first-boot hardware setup refuses a normal user" + +# The service fires on the queue this helper writes and hands the machine to +# owner setup and the login screen only after it. +grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/deferred-steps' "$unit_source" && + grep -qx 'ExecStart=/usr/bin/omarchy-provision-hardware' "$unit_source" && + grep -qx 'Before=omarchy-provision-owner.service display-manager.service' "$unit_source" && + grep -qx 'WantedBy=multi-user.target' "$unit_source" || + fail "the first-boot hardware service runs on the queue before owner setup and the login screen" +pass "the first-boot hardware service runs on the queue before owner setup and the login screen" + +# --- Bluetooth on the first boot --------------------------------------------- + +# The adapter brings up bluetooth.target before the first boot runs the +# Bluetooth leaf, and a target never starts a unit enabled after its start job +# was made, so that boot has to start bluetooth.service itself. Nothing else +# does: an install or a rerun of hardware setup leaves the service as it finds it. +bt_fixture="$test_tmp/omarchy-bluetooth" +mkdir -p "$bt_fixture/install/helpers" "$bt_fixture/install/provisioning" "$bt_fixture/install/hardware" +cp "$ROOT/install/helpers/logging.sh" "$ROOT/install/helpers/image-target.sh" "$bt_fixture/install/helpers/" +cp "$unit_source" "$bt_fixture/install/provisioning/" +cp "$ROOT/install/hardware/bluetooth.sh" "$bt_fixture/install/hardware/" +cp "$fixture/install/hardware/c.sh" "$bt_fixture/install/hardware/" +printf 'run_logged "$OMARCHY_INSTALL/hardware/%s"\n' bluetooth.sh c.sh >"$bt_fixture/install/hardware/all.sh" + +bt_bin="$test_tmp/bt-bin" +mkdir -p "$bt_bin" +cat >"$bt_bin/systemctl" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$SYSTEMCTL_CALLS" +case $* in + "start --no-block bluetooth.service") [[ ! -e $BT_START_REFUSED ]] ;; + *) exit 0 ;; +esac +SH +chmod +x "$bt_bin/systemctl" +export SYSTEMCTL_CALLS="$test_tmp/systemctl-calls" BT_START_REFUSED="$test_tmp/bt-start-refused" +bt_path="$bt_bin:$base_path" + +bt_image() { + local root + root=$(new_root "bt-$1") + write_manifest "$root" + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null || fail "the Bluetooth fixture image builds" + printf '%s\n' "$root" +} + +bt_first_boot() { + rm -f "$SYSTEMCTL_CALLS" "$RUNS" + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$bt_fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$bt_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1 +} + +started=$'enable bluetooth.service\nstart --no-block bluetooth.service' + +reset_logs +rm -f "$BT_START_REFUSED" "$SYSTEMCTL_CALLS" +root=$(bt_image started) +[[ ! -e $SYSTEMCTL_CALLS ]] || fail "an image build touches no Bluetooth unit" "$(cat "$SYSTEMCTL_CALLS")" +bt_first_boot "$root" || fail "the first boot finishes the Bluetooth leaf" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] || + fail "the first boot enables Bluetooth and starts it without waiting for the job" "$(<"$SYSTEMCTL_CALLS")" +[[ $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "the first boot goes on past the Bluetooth leaf" +pass "the first boot enables Bluetooth and starts it in the same session" + +touch "$BT_START_REFUSED" +root=$(bt_image refused) +bt_first_boot "$root" || fail "a refused Bluetooth start does not fail the first boot" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" && $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "a refused Bluetooth start leaves the step done and the queue going" "$(<"$SYSTEMCTL_CALLS")" +pass "a refused Bluetooth start does not fail the first boot" +rm -f "$BT_START_REFUSED" + +# A later boot that retries the queue is still the machine's own first setup. +root=$(bt_image retry) +mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted" +bt_first_boot "$root" || fail "a retried queue finishes" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] || + fail "a retried queue starts Bluetooth like the first run" "$(<"$SYSTEMCTL_CALLS")" +pass "a retried queue starts Bluetooth like the first run" + +# Hardware setup outside the first boot, even with the marker inherited. +rm -f "$SYSTEMCTL_CALLS" "$RUNS" +root=$(new_root bt-live) +OMARCHY_IMAGE_DEFERRED_HARDWARE=1 OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" \ + OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null || + fail "hardware setup without a manifest runs the Bluetooth leaf" +[[ $(<"$SYSTEMCTL_CALLS") == "enable bluetooth.service" && $(cat "$RUNS") == c ]] || + fail "hardware setup outside the first boot only enables Bluetooth" "$(<"$SYSTEMCTL_CALLS")" +pass "hardware setup outside the first boot only enables Bluetooth, as an install always has" + +# --- Root ignores the fixture root ------------------------------------------ + +# As namespaced root the fixture root in the environment is ignored: the +# fixture image is not built into, and hardware setup runs as on any live system. +if [[ -e /var/lib/omarchy/image ]]; then + pass "this machine keeps image state of its own; skipping the root override probe" +elif unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + + reset_logs + root=$(new_root root-build) + write_manifest "$root" + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$base_path" "${root_runner[@]}" "$ROOT/bin/omarchy-apply-hardware" --defer-provisioning >/dev/null || + fail "root runs hardware setup with a fixture root in its environment" + [[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "root ignores a fixture manifest named by its environment" + + # Both commands take every path from the helper, which as root answers with + # the live ones whatever the environment says. + resolved=$(OMARCHY_IMAGE_ROOT="$root" "${root_runner[@]}" bash -c \ + 'source "$1"; omarchy_image_init && printf "%s\n" "$omarchy_image_manifest" "$omarchy_image_queue" "$omarchy_image_systemd_dir"' \ + bash "$ROOT/install/helpers/image-target.sh") || fail "root resolves the image paths" + [[ $resolved == $'/var/lib/omarchy/image/target\n/var/lib/omarchy/image/deferred-steps\n/etc/systemd/system' ]] || + fail "root resolves the live image paths whatever its environment names" "$resolved" + pass "root ignores fixture roots in its environment" +else + pass "no unprivileged user namespace; skipping the root override probe" +fi diff --git a/test/shell.d/image-target-platform-test.sh b/test/shell.d/image-target-platform-test.sh new file mode 100644 index 00000000..3b8b4092 --- /dev/null +++ b/test/shell.d/image-target-platform-test.sh @@ -0,0 +1,442 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# omarchy-hw-platform answers with the image-target manifest while a root is +# being built, and with the hardware once it boots. Each world below is what one +# detector run sees: image/ is the root, proc/ its /proc (the host's device tree +# and PID 1), bin/ a uname for the CPU it runs on. + +detector="$ROOT/bin/omarchy-hw-platform" +umask 022 +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +apple_manifest=$'format=1\nplatform=aarch64-apple\n' + +# $1 world, $2 CPU, then the host's device-tree tokens (none: no device tree). +world() { + local dir="$test_tmp/worlds/$1" cpu=$2 + shift 2 + rm -rf "$dir" + mkdir -p "$dir/image" "$dir/proc" "$dir/bin" "$dir/host" + if (( $# )); then + mkdir -p "$dir/proc/device-tree" + printf '%s\0' "$@" >"$dir/proc/device-tree/compatible" + fi + cat >"$dir/bin/uname" <