Compare commits

..
Author SHA1 Message Date
ZacharyZhang-NY 16faf06fcf Switch the region under Setup > Region, and offer China Apps
omarchy region cn|global switches an installed machine: cn upgrades,
trusts ArchLinuxCN through the rendered China config, records the
marker and links the developer mirrors, then refreshes pacman;
global undoes only what Omarchy made. The developer mirrors reach
new sessions only, so a terminal switch offers to log out.

Install > China Apps, shown only in China, installs WeChat, QQ,
Feishu, DingTalk, Tencent Meeting and WPS Office from the AUR and
sets up Tencent Docs, Yuque and Shimo as web apps.

manual/52-china.md describes the China region.
2026-10-12 00:29:40 -04:00
ZacharyZhang-NY 466c2ca14a Point package managers at China mirrors on cn installs
npm, PyPI and uv, Go, rustup and crates.io, RubyGems and Bundler,
and Maven reach their registries slowly or not at all from China.
On a cn install, link the profile's environment, cargo and gem
drop-ins into place. A regular file the admin wrote is never
replaced; the install fails instead.
2026-10-12 00:29:13 -04:00
Kros DaiandClaude Opus 5.5 ec4579a461 🐛 fix(test): skip exact zone match without tzdata.zi
- 🐛 minimal containers lack the link table, so the exact china zone check failed on a correct profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 16:44:15 -04:00
Kros DaiandClaude Opus 5.5 d9a781e3c9 📝 docs(region): scope the refresh render boundary
- 📝 state that same-user code is outside the refresh boundary, so reviewers see the trade-off was deliberate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 16:08:36 -04:00
Kros DaiandClaude Opus 5.5 a6c3480ff8 📝 docs(region): state marker mode and archlinuxcn risk
- 📝 refresh reads the marker unprivileged, and rolling archlinuxcn packages can outpace stable snapshots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:31:04 -04:00
Kros DaiandClaude Opus 5.5 545190596d ♻️ refactor(pacman): branch on render with if/else
- ♻️ follow the repo style instead of exiting early to skip the install block

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:31:04 -04:00
Kros DaiandClaude Opus 5.5 b381093e32 ✅ test(region): derive china zones from tzdata
- ✅ dropping a backward-compatible zone name from the profile now fails the test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:31:04 -04:00
Kros DaiandClaude Opus 5.5 9d74c813f1 🐛 fix(channel): require every region fragment for dev
- 🐛 leaving dev refreshes stable or rc with the checkout's tools, so a profile missing those fragments would drop regional repositories
- ✅ refuse a profile missing any single fragment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:31:04 -04:00
Kros DaiandClaude Opus 5.5 fce85abf48 📝 docs(region): explain mirror order and fallback
- 📝 pacman's three-error skip and first-server databases decide what the china replicas must serve

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:21:51 -04:00
Kros DaiandClaude Opus 5.5 3c68a1bb04 ✨ feat(region): put the china channel mirrors first
- ✨ omarchy now runs per-channel china replicas, so they should lead instead of trailing as a fallback
- ♻️ prepend per-channel fragments so each channel's own mirror stays the fallback
- 🔥 drop the ustc arch mirror, which was never a stable or rc snapshot replica

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:21:51 -04:00
Kros DaiandClaude Opus 5.5 b89def6c7f 📝 docs(region): describe timezone-derived regions
- 📝 the --region build flag is gone, so the installer contract and validation plan change with it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:03:46 -04:00
Kros DaiandClaude Opus 5.5 0de49b231a ✨ feat(region): map timezones to regions
- ✨ one iso serves every region, so the installer derives it from the timezone the user already picks
- ✅ reject unknown zones and zones claimed by two regions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QuATgjU4mnqpqSLAKMQ14g
2026-10-08 15:03:41 -04:00
Kros DaiandClaude Opus 5.5 3e00a48c00 ✅ test(pacman): sandbox the region for every boundary test
- ✅ The channel and update boundary tests copied the real render and dev-region guard, so on a China install they read the host's `/etc/omarchy/region` and failed before reaching their checks
- ♻️ The shared fixture now points both lookups at a sandbox target that starts global, and the regional test only writes `cn` there

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 22:14:56 -04:00
Kros DaiandClaude Opus 5.5 a1bdc46119 ✅ test(pacman): isolate the regional refresh boundary test
- ✅ the render read the host's region marker, so a global machine never rendered China and a China machine failed on missing fragments
- ✅ check the finished China files root installs, not only the copy order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 22:00:59 -04:00
Kros DaiandClaude Opus 5.5 7a60c8dea4 🐛 fix(channel): require a usable region profile for dev
- 🐛 an empty profile directory or a helper that cannot run passed the guard, linked dev, and then broke the refresh
- ✅ the success case now uses a complete profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 22:00:59 -04:00
Kros DaiandClaude Opus 5.5 6be88d14a5 🐛 fix(pacman): remove the render dir on every refresh exit
- 🐛 a failed render, a refused sudo prompt, or a signal left the scratch directory behind; repeated attempts piled them up
- 🔒 the cleanup runs before the sudo revocation, which stays the last step of the exit path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdXqTwm63rRMwotZ1xqLC8
2026-10-06 21:58:48 -04:00
Kros DaiandClaude Opus 5.5 50a26be6a7 📝 docs(region): scope the cn profile to repositories
- 📝 frame USTC and ArchLinuxCN as a bridge until an official China mirror exists
- 📝 record that language, timezone, and input defaults follow as separate changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3Jna1ogUUviYLJzhsG6Vr
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 d5ef1c1d15 🔥 refactor(region): drop input defaults from the cn profile
- 🔥 keep this change to the regional build architecture; Chinese input gets its own proposal instead of fixing one engine here
- ✅ assert the shipped profile adds only its repository keyring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3Jna1ogUUviYLJzhsG6Vr
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 d024c82a9d 📝 docs(plans): move the china proposal to a discussion
- 📝 upstream plans are implementation plans; the open questions belong in the discussion that links these changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3Jna1ogUUviYLJzhsG6Vr
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 d7f17a0424 ♻️ refactor(pacman): render regional config without root
- 🔒 upstream refresh now gives root only command-scoped copies, so regional files are rendered as the caller and installed with the same four `sudo cp` calls
- ✅ exercise refresh only inside the sudo-boundary sandbox; the old host-level test reached real sudo once refresh pinned its PATH

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 87a2931209 📝 docs(region): drop the unreachable menu input gate
- 📝 the menu bypasses the input method, so requiring Pinyin there made the release procedure impossible until upstream fixes it
- 📝 record the required `skel` profile and the dev-switch region check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 647979bde7 🐛 fix(pacman): keep existing config file modes
- 🐛 refresh used to copy over the existing files, so a customized mode must survive the shared regional applicator too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:43:25 -04:00
Kros DaiandClaude Opus 5.5 123535ea14 🐛 fix(channel): keep regional repos when switching to dev
- 🛡️ refuse a dev checkout that lacks this machine's region profile before linking it, since its own refresh would silently replace the regional pacman files
- ✅ isolate channel tests from the host region marker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:43:25 -04:00
Kros Dai 645cfc2a36 📝 docs(region): explain china bootstrap choices
- 📝 clarify optional defaults, trust requirements, and opt-out behavior
- ⚠️ document snapshot compatibility and release validation limits

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 14:43:25 -04:00
Kros Dai e989960895 ✅ test(region): guard runtime defaults
- ✅ protect global behavior and regional mirror ordering
- ⌨️ guard personal settings and reserved input shortcuts

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 14:43:25 -04:00
Kros Dai 48f7c411c0 ✨ feat(pacman): preserve regional defaults
- ✨ share regional repository generation across install and refresh
- 🛡️ reject invalid profiles before replacing configuration

Amp-Thread-ID: T-01a0df38-0ea7-759b-bcff-29da223cc567
2026-10-06 14:43:25 -04:00
Kros DaiandAmp 7727d40c92 ✨ feat(region): seed offline chinese input
- ✨ make first-login pinyin available without downloads
- ⌨️ preserve compose and terminal prefix shortcuts

Amp-Thread-ID: https://ampcode.com/threads/T-01a0df38-0ea7-759b-bcff-29da223cc567
Co-authored-by: Amp <amp@ampcode.com>
2026-10-06 14:43:25 -04:00
77 changed files with 1064 additions and 1169 deletions

No files matched your search

+7
View File
@@ -96,6 +96,13 @@ Exceptions are allowed for migration and package-helper scripts where the helper
- Do not add `aliases` to new menu entries. Aliases are reserved for
established alternate names users already type, kept for compatibility.
# Region
- `/etc/omarchy/region` holds the region the installer derived from the timezone (`cn`); no file means Global. The profile lives in `default/regions/cn/`, its contract in `docs/regions.md`.
- `install/config/region.sh` links the `cn` developer mirrors into `/etc` and `/.cargo` and refuses to replace a regular file it did not create. It serves both the install (the marker) and `omarchy region cn|global` (`OMARCHY_REGION`), which also records or removes the marker; Global removes only the links it made.
- `omarchy-region` prints the region (`Global` or `China`) and switches it. Nothing in `omarchy update` or migrations may change the region.
- China-only menu rows carry `"when":"[[ \"$(omarchy-region)\" == \"China\" ]]"` on every row, not only the submenu, because search checks each row's own guard. User-facing docs: `manual/52-china.md`.
# Config Structure
- `config/` - default configs copied to `~/.config/`
+1
View File
@@ -71,6 +71,7 @@ The manual lives in [`manual/`](manual/), which is its authoritative source.
- [Omarchy on...](manual/49-omarchy-on.md)
- [Dual Boot Install](manual/50-dual-boot-install.md)
- [Unattended Installs](manual/51-unattended-installs.md)
- [China](manual/52-china.md)
## License
+8
View File
@@ -0,0 +1,8 @@
[Desktop Entry]
Version=1.0
Name=Disk Usage
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
Terminal=false
Type=Application
Icon=disk-usage
StartupNotify=true
+1
View File
@@ -73,6 +73,7 @@ GROUP_DESCRIPTIONS[plymouth]="Plymouth boot theme management"
GROUP_DESCRIPTIONS[power]="Power supply detection"
GROUP_DESCRIPTIONS[powerprofiles]="Power profile management"
GROUP_DESCRIPTIONS[refresh]="Reset config to defaults"
GROUP_DESCRIPTIONS[region]="Region-specific package sources and setup"
GROUP_DESCRIPTIONS[reinstall]="Reinstall and reset workflows"
GROUP_DESCRIPTIONS[reminder]="Desktop notification reminders"
GROUP_DESCRIPTIONS[remove]="Removal workflows"
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# omarchy:summary=Restore channel and regional pacman defaults in an installed target
# omarchy:args=<stable|rc|edge> [target-root] | --render <stable|rc|edge> <output-dir> [target-root]
# omarchy:requires-sudo=true
# omarchy:hidden=true
set -euo pipefail
usage() {
echo "Usage: omarchy-apply-pacman <stable|rc|edge> [target-root]" >&2
echo " omarchy-apply-pacman --render <stable|rc|edge> <output-dir> [target-root]" >&2
exit 1
}
# --render only builds the two files, so an unprivileged caller can hand root
# nothing but plain copies of the finished result.
render_dir=""
if [[ ${1:-} == "--render" ]]; then
(( $# == 3 || $# == 4 )) || usage
channel="$2"
render_dir="$3"
[[ -d $render_dir ]] || { echo "Render directory not found: $render_dir" >&2; exit 1; }
etc="${4:-/}/etc"
else
(( $# >= 1 && $# <= 2 )) || usage
channel="$1"
etc="${2:-/}/etc"
fi
case "$channel" in
stable|rc|edge) ;;
*) echo "Invalid pacman channel: $channel" >&2; exit 1 ;;
esac
# The ISO writes this plain-text marker before target finalization. Missing
# markers keep existing installations global; never source it as shell code.
region=global
if [[ -e $etc/omarchy/region ]]; then
region=$(<"$etc/omarchy/region")
fi
case "$region" in
global|cn) ;;
*) echo "Unsupported region: $region (expected global or cn)" >&2; exit 1 ;;
esac
# Build both files before replacing either. Explicit refreshes retain backups;
# ordinary updates do not call this command or enforce regional defaults.
if [[ -n $render_dir ]]; then
staging="$render_dir"
else
staging=$(mktemp -d)
trap 'rm -rf "$staging"' EXIT
fi
cp "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" "$staging/pacman.conf"
if [[ $region == "global" ]]; then
cp "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" "$staging/mirrorlist"
else
profile="$OMARCHY_PATH/default/regions/$region/pacman"
printf '\n' >> "$staging/pacman.conf"
cat "$profile/pacman.conf.append" >> "$staging/pacman.conf"
# pacman tries servers in order, so the regional mirror leads and the
# channel's own mirror stays as the fallback.
cat "$profile/mirrorlist-$channel.prepend" "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" > "$staging/mirrorlist"
fi
if [[ -z $render_dir ]]; then
mkdir -p "$etc/pacman.d"
for file in pacman.conf pacman.d/mirrorlist; do
if [[ -e $etc/$file ]]; then
cp -f "$etc/$file" "$etc/$file.bak"
fi
done
# Copying over an existing file keeps its owner and mode, as refresh always has.
cp -f "$staging/pacman.conf" "$etc/pacman.conf"
cp -f "$staging/mirrorlist" "$etc/pacman.d/mirrorlist"
fi
+22
View File
@@ -47,9 +47,31 @@ validate_dev_checkout() {
fi
}
# The dev channel refreshes pacman with the checkout's own tools. A checkout
# without this machine's region profile would silently drop its repositories.
validate_dev_region() {
local checkout="$1" region_file="${OMARCHY_REGION_FILE:-/etc/omarchy/region}" region=global
[[ -f $region_file ]] && region=$(<"$region_file")
[[ $region == "global" ]] && return 0
# The refresh after linking needs a runnable helper and every fragment, not
# just a directory with the region's name: dev refreshes edge, and leaving
# dev later refreshes stable or rc with this checkout's tools.
local profile="$checkout/default/regions/$region/pacman" fragment supported=true
[[ -x $checkout/bin/omarchy-apply-pacman ]] || supported=false
for fragment in pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend; do
[[ -r $profile/$fragment ]] || supported=false
done
if ! $supported; then
fail "$checkout does not support the '$region' region, so switching would drop its package repositories. Check out a branch with default/regions/$region, or remove $region_file to use global defaults."
fi
}
link_dev_checkout() {
local checkout="$1" required
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
validate_dev_region "$checkout"
# Check the destination before changing /etc/omarchy.conf or sudo's path.
# An existing checkout is not pulled automatically and may predate this policy.
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
+16 -2
View File
@@ -30,11 +30,25 @@ if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
exit 2
fi
# Render the channel templates plus this machine's regional additions as the
# caller, so root only copies finished files. The scratch directory goes on
# every exit, including a failed render, a refused sudo prompt, or a signal;
# revoking the sudo timestamp stays the last thing the exit path does.
rendered=$(mktemp -d)
cleanup_refresh() {
local status=$?
rm -rf "$rendered"
omarchy_security_exit_with_revoked_sudo "$status"
}
trap cleanup_refresh EXIT
"$OMARCHY_PATH/bin/omarchy-apply-pacman" --render "$channel" "$rendered"
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 "$rendered/pacman.conf" /etc/pacman.conf
sudo cp -f "$rendered/mirrorlist" /etc/pacman.d/mirrorlist
rm -rf "$rendered"
# Allow user customization of /etc/pacman.conf before the upgrade runs, so
# custom repositories and IgnorePkg entries shape the downgrade-capable
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# omarchy:summary=Show or switch the region this machine is set up for
# omarchy:args=[global|cn]
# omarchy:examples=omarchy region | omarchy region cn
# omarchy:requires-sudo=true
set -euo pipefail
region_file=/etc/omarchy/region
if (($# == 0)); then
if [[ ! -e $region_file ]]; then
echo "Global"
elif [[ $(<"$region_file") == "cn" ]]; then
echo "China"
else
echo "Unknown region in $region_file: $(<"$region_file")" >&2
exit 1
fi
exit 0
fi
if [[ $1 != "global" && $1 != "cn" ]]; then
echo "Usage: omarchy-region [global|cn]" >&2
exit 1
fi
channel=$(omarchy-version-channel)
if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then
echo "Cannot tell this machine's package channel ($channel); run omarchy channel set first" >&2
exit 1
fi
if [[ $1 == "cn" ]]; then
# Upgrade first: the keyring install below syncs new databases.
omarchy-update-pacman -Syu --noconfirm
sudo env OMARCHY_REGION=cn OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
# Trust ArchLinuxCN before the refresh upgrades through it.
rendered=$(mktemp -d)
trap 'rm -rf "$rendered"' EXIT
omarchy-apply-pacman --render "$channel" "$rendered"
mapfile -t profile_packages < <(grep -Ev '^[[:space:]]*(#|$)' "$OMARCHY_PATH/default/regions/cn/packages")
sudo pacman --config "$rendered/pacman.conf" -Sy --noconfirm --needed "${profile_packages[@]}"
sudo pacman-key --populate archlinuxcn
omarchy-refresh-pacman "$channel"
else
sudo env OMARCHY_REGION=global OMARCHY_PATH="$OMARCHY_PATH" bash -e "$OMARCHY_PATH/install/config/region.sh"
omarchy-refresh-pacman "$channel"
fi
# The developer mirrors reach only a new session, so offer one.
if [[ -t 0 && -t 1 ]] && gum confirm --affirmative "Log out now" --negative "Later" "Log out now to switch the developer mirrors?"; then
omarchy-system-logout
else
echo "Log back in to switch the developer mirrors."
fi
+47 -194
View File
@@ -1,7 +1,7 @@
#!/bin/bash -p
# omarchy:summary=Toggle passwordless sudo for the current user.
# omarchy:args=[MINUTES|permanent|--active|--disable]
# omarchy:args=[MINUTES]
# omarchy:requires-sudo=true
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
@@ -22,6 +22,7 @@ fi
set -euo pipefail
readonly DEFAULT_MINUTES=15
readonly MAX_MINUTES=1440
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
@@ -33,7 +34,7 @@ readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
readonly STATUS_INACTIVE=3
usage() {
echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
exit 1
}
@@ -42,10 +43,6 @@ valid_minutes() {
[[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES))
}
valid_duration() {
[[ $1 == "permanent" ]] || valid_minutes "$1"
}
valid_uid() {
[[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294))
}
@@ -99,39 +96,14 @@ with_root_lock() {
}
rule_file() {
local uid=$((10#$1))
if [[ ${2:-} == "permanent" ]]; then
printf '/etc/sudoers.d/99-omarchy-permanent-nopasswd-%s' "$uid"
else
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$uid"
fi
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
}
# The sudoers rule is the only grant record. A missing file is distinct from
# an unreadable, unsafe, or administrator-modified file.
read_grant() {
local file permanent contents
local file contents
file=$(rule_file "$1")
permanent=$(rule_file "$1" permanent)
if [[ -e $permanent || -L $permanent ]]; then
verify_root_path "$permanent" && [[ -f $permanent ]] || return 2
contents=$(/usr/bin/cat -- "$permanent") || return 2
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOPASSWD:\ ALL$ ]] || return 2
GRANT_NAME=${BASH_REMATCH[1]}
GRANT_DEADLINE=""
valid_account_name "$GRANT_NAME" || return 2
if [[ -e $file || -L $file ]]; then
# A SIGKILL between publication and removal can leave both policies.
# Only accept a matching generated timed rule. Permanent access already
# exists, so old expiry callbacks must not revoke it; disable removes
# both files, and the next duration change finishes their replacement.
verify_root_path "$file" && [[ -f $file ]] || return 2
contents=$(/usr/bin/cat -- "$file") || return 2
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
[[ ${BASH_REMATCH[1]} == "$GRANT_NAME" ]] || return 2
fi
return 0
fi
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
verify_root_path "$file" && [[ -f $file ]] || return 2
contents=$(/usr/bin/cat -- "$file") || return 2
@@ -149,9 +121,6 @@ classify_generated_rule() {
[[ -f $file && ! -L $file ]] || return 1
contents=$(/usr/bin/cat -- "$file") || return 2
suffix=${file##*/99-omarchy-nopasswd-}
if [[ $file == */99-omarchy-permanent-nopasswd-* ]]; then
suffix=${file##*/99-omarchy-permanent-nopasswd-}
fi
# The legacy command wrote the caller's unvalidated name into both the
# filename and the rule. That exact relationship is its fingerprint, so an
@@ -169,21 +138,14 @@ classify_generated_rule() {
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
}
cleanup_rule_file() {
local file=$1
cleanup_uid_locked() {
local file
file=$(rule_file "$1")
[[ -e $file || -L $file ]] || return 0
verify_root_path "$file" && classify_generated_rule "$file" || return 1
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
}
cleanup_uid_locked() {
local file duration
for duration in temporary permanent; do
file=$(rule_file "$1" "$duration")
cleanup_rule_file "$file" || return 1
done
}
# The generated prefix is reserved: boot cleanup and the package hook already
# remove everything in it, and the legacy writer could produce a rule whose
# body differs from its filename. Nothing unrecognized may stay live there, but
@@ -301,7 +263,6 @@ migrate_locked() {
expire_locked() {
local status now
if read_grant "$1"; then
[[ -n $GRANT_DEADLINE ]] || return 0
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
[[ $now < $GRANT_DEADLINE ]] && return 0
cleanup_uid_locked "$1"
@@ -320,7 +281,6 @@ status_locked() {
resolve_account "$1" || return 2
if read_grant "$1"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
[[ -n $GRANT_DEADLINE ]] || return 0
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
if [[ $now < $GRANT_DEADLINE ]]; then
return 0
@@ -340,11 +300,7 @@ finish_enable() {
if cleanup_uid_locked "$uid"; then
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
else
if [[ -n $timer ]]; then
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
else
echo "Could not revoke passwordless sudo. Administrator cleanup is required." >&2
fi
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
fi
fi
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
@@ -353,15 +309,9 @@ finish_enable() {
enable_locked() (
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
resolve_account "$uid" && valid_duration "$minutes" || return 1
verify_root_path /etc/sudoers.d || return 1
if [[ $minutes != "permanent" ]]; then
verify_boot_cleanup || {
echo "Temporary sudo cleanup is unavailable or a settings package transaction is incomplete. Check the omarchy-settings installation." >&2
return 1
}
fi
file=$(rule_file "$uid" "$minutes")
resolve_account "$uid" && valid_minutes "$minutes" || return 1
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
file=$(rule_file "$uid")
if read_grant "$uid"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
else
@@ -370,69 +320,39 @@ enable_locked() (
fi
trap finish_enable EXIT
omarchy_security_install_signal_exit_traps
now=$(/usr/bin/date +%s) || return 1
expires=$((now + 10#$minutes * 60))
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
if [[ $minutes == "permanent" ]]; then
/usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$ACCOUNT_NAME" >"$pending" || return 1
else
now=$(/usr/bin/date +%s) || return 1
expires=$((now + 10#$minutes * 60))
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
fi
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
if [[ $minutes != "permanent" ]]; then
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-$uid-$token"
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
--timer-property=AccuracySec=1s --unit="$timer" \
-- "$INSTALLED_SELF" __expire "$uid" || return 1
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
fi
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-$uid-$token"
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
--timer-property=AccuracySec=1s --unit="$timer" \
-- "$INSTALLED_SELF" __expire "$uid" || return 1
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
# The temporary filename contains a dot, so sudo ignores it. Rename within
# sudoers.d publishes the complete validated policy in one operation.
/usr/bin/mv -fT -- "$pending" "$file" || return 1
pending=""
# Keep access uninterrupted during renewal. When changing duration type,
# remove the other rule only after its replacement is published. The lock
# keeps status and expiry callbacks from observing both rules together.
if [[ $minutes == "permanent" ]]; then
cleanup_rule_file "$(rule_file "$uid")" || return 1
else
cleanup_rule_file "$(rule_file "$uid" permanent)" || return 1
fi
if [[ $minutes != "permanent" ]]; then
now=$(/usr/bin/date +%s) || return 1
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
fi
now=$(/usr/bin/date +%s) || return 1
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
)
root_dispatch() {
local action="$1" status
local action="$1"
shift
case "$action" in
__status)
(($# == 1)) && verify_sudo_caller "$1" || return 2
if with_root_lock status_locked "$1"; then
return 0
else
status=$?
# Reserve status 1 for sudo refusing noninteractive authentication.
# Every internal inspection failure, including lock failures, is 2.
(( status == STATUS_INACTIVE )) || status=2
return "$status"
fi
with_root_lock status_locked "$1"
;;
__enable)
(($# == 2)) && verify_sudo_caller "$1" && valid_duration "$2" || return 1
if with_root_lock enable_locked "$1" "$2"; then
return 0
else
status=$?
echo "Could not enable passwordless sudo. Check existing sudoers policy and cleanup support." >&2
return "$status"
fi
(($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1
with_root_lock enable_locked "$1" "$2"
;;
__disable)
(($# == 1)) && verify_sudo_caller "$1" || return 1
@@ -462,14 +382,6 @@ root_dispatch() {
esac
}
show_enabled() {
if [[ $minutes == "permanent" ]]; then
echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it."
else
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
fi
}
case "${1:-}" in
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
action=$1
@@ -479,30 +391,9 @@ case "${1:-}" in
;;
esac
# Prefer matched policy tags to executing a privileged command, avoiding
# authentication/session logs under the default listing policy. Restrictive
# listpw settings require the root-status fallback. Neither path prompts or
# trusts cached credentials as evidence of passwordless access.
if [[ ${1:-} == "--active" ]]; then
(($# == 1)) || usage
uid=$(/usr/bin/id -u)
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
/usr/bin/grep -q '!authenticate' <<<"$policy"
else
# listpw=always can require authentication to list a passwordless grant.
# With a command, -k ignores cached credentials without invalidating them.
status=0
/usr/bin/sudo -kn -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
# An internal inspection error still proves that sudo ran the helper
# without authentication. Keep the warning visible for unsafe policy.
(( status == 0 || status == 2 )) || exit 1
fi
exit
fi
(($# <= 1)) || usage
minutes=${1:-}
(($# == 0)) || [[ $minutes == "--disable" ]] || valid_duration "$minutes" || usage
minutes=${1:-$DEFAULT_MINUTES}
valid_minutes "$minutes" || usage
uid=$(/usr/bin/id -u)
valid_uid "$uid" || {
echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2
@@ -520,76 +411,38 @@ omarchy_security_install_sudo_cleanup_traps
exit 1
}
# Inspect policy without authenticating. Only inspect the root-owned grant
# when policy already permits it without a password; otherwise the eventual
# enable action is the sole authentication boundary. enable_locked validates
# any existing policy before publishing, including expired or modified rules.
status=$STATUS_INACTIVE
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
if /usr/bin/grep -q '!authenticate' <<<"$policy"; then
status=0
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" || status=$?
echo "Toggle passwordless sudo..."
if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
if (($# == 0)); then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid"
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
else
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
fi
else
# A listing password is independent of command authorization. An active
# grant can still be inspected/revoked without prompting under listpw=always.
status=0
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
if (( status == 1 )) && [[ $minutes != "--disable" ]]; then
# No noninteractive inspection is available. Offer the confirmed enable
# flow; its single authenticated action validates existing policy itself.
status=$STATUS_INACTIVE
fi
fi
if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __disable "$uid"
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
elif (( status == STATUS_INACTIVE )) && [[ $minutes == "--disable" ]]; then
echo "Passwordless sudo is already DISABLED."
elif (( status == 0 )) && [[ $minutes != "permanent" ]]; then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
show_enabled
else
if (( status != 0 && status != STATUS_INACTIVE )); then
status=$?
if (( status != STATUS_INACTIVE )); then
echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2
exit 1
fi
if [[ -z $minutes ]]; then
choice=$(/usr/bin/gum choose --header "How long should passwordless sudo stay enabled?" "15 minutes" "1 Hour" "1 Day" "Permanently") || exit 130
case "$choice" in
"15 minutes") minutes=15 ;;
"1 Hour") minutes=60 ;;
"1 Day") minutes=1440 ;;
"Permanently") minutes=permanent ;;
*) exit 130 ;;
esac
fi
if [[ $minutes == "permanent" ]]; then
duration="permanently"
else
duration="for ${minutes} minutes"
fi
echo ""
echo "⚠️ WARNING: This will allow ANY process running as your user to"
echo "execute ANY command as root WITHOUT a password $duration."
echo "execute ANY command as root WITHOUT a password for ${minutes} minutes."
echo ""
echo "This is useful for AI agents that need to run sudo commands,"
echo "but it significantly weakens the security of your system."
echo "Anyone or anything with access to your user account gets full root."
echo ""
if [[ $minutes == "permanent" ]]; then
echo "Passwordless sudo will remain enabled across reboots until you disable it."
else
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
echo "including if the machine reboots before the deadline."
fi
echo "Run this command again to disable it."
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
echo "including if the machine reboots before the deadline."
echo "Run this command again to disable it early."
echo ""
if /usr/bin/gum confirm "Enable passwordless sudo $duration? This is a significant security risk!"; then
if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo ""
show_enabled
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
else
echo "Aborted. No changes made."
fi
+1 -1
View File
@@ -127,7 +127,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then
omarchy-update-system-pkgs
omarchy-migrate
omarchy-update-orphan-pkgs -y
omarchy-update-orphan-pkgs
omarchy-update-analyze-logs
omarchy-update-status
+3 -5
View File
@@ -1,8 +1,6 @@
#!/bin/bash
# omarchy:summary=Review and optionally remove orphaned system packages after updates
# omarchy:args=[-y]
# omarchy:examples=omarchy update orphan-pkgs | omarchy update orphan-pkgs -y
# omarchy:requires-sudo=true
set -e
@@ -14,18 +12,18 @@ echo -e "\e[32m\nOrphan system packages\e[0m"
printf ' %s\n' "${orphans[@]}"
echo
if [[ ${1:-} != "-y" && ( ! -t 0 || ! -t 1 ) ]]; then
if [[ ! -t 0 || ! -t 1 ]]; then
echo "${#orphans[@]} orphaned package(s) found. Re-run omarchy-update-orphan-pkgs in a terminal to review/remove them."
echo
exit 0
fi
if [[ ${1:-} != "-y" ]] && ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then
if ! gum confirm --default=false "Remove ${#orphans[@]} orphaned package(s)?"; then
echo "Keeping orphaned packages."
echo
exit 0
fi
echo -e "\e[32m\nRemoving orphan system packages\e[0m"
omarchy-pkg-drop "${orphans[@]}"
sudo pacman -Rns "${orphans[@]}"
echo
+1 -1
View File
@@ -1,5 +1,5 @@
--ozone-platform=wayland
--ozone-platform-hint=wayland
--password-store=gnome-libsecret
--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar
--enable-features=TouchpadOverscrollHistoryNavigation
--load-extension=/usr/share/omarchy/default/chromium/extensions/copy-url,/usr/share/omarchy/default/chromium/extensions/yt-dlp,/usr/share/omarchy/default/chromium/extensions/whatsapp-slim
+4 -3
View File
@@ -6,9 +6,10 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation
-- Launcher, image selector, emojis, clipboard overlays, the OSD, reminders, the
-- Wi-Fi QR code and keyboard-driven panels should pop without compositor layer
-- animations. Overlays open as soon as their scale is ready, without a
-- compositor fade or slide. Panels keep their own QML opacity transition
-- for normal open/close, and skip it for panel handoff.
-- animations. The overlays stay mapped between opens and grow from a parked
-- 1x1 when shown, which Hyprland would otherwise animate as a slide in from
-- the corner. Panels keep their own QML opacity transition for normal
-- open/close, and skip it for panel handoff.
hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel|omarchy-osd|omarchy-reminders|omarchy-network-qr)$" }, no_anim = true, animation = "none" })
-- Dev gallery is the main shell workbench; open it maximized like
+14 -2
View File
@@ -173,6 +173,9 @@
"setup.default.editor.helix": {"icon":"","label":"Helix","checked":"[[ \"$(omarchy-default-editor)\" == \"helix\" ]]","action":"omarchy-default-editor helix"},
"setup.default.editor.vim": {"icon":"","label":"Vim","checked":"[[ \"$(omarchy-default-editor)\" == \"vim\" ]]","action":"omarchy-default-editor vim"},
"setup.default.editor.emacs": {"icon":" ","label":"Emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"},
"setup.region": {"icon":"󰇧","label":"Region"},
"setup.region.global": {"icon":"󰖟","label":"Global","checked":"[[ \"$(omarchy-region)\" == \"Global\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-region global'"},
"setup.region.china": {"icon":"󰈻","label":"China","checked":"[[ \"$(omarchy-region)\" == \"China\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-region cn'"},
"setup.plugin": {"icon":"󰐱","label":"Plugins","aliases":["plugin","plugins"]},
"setup.plugin.enable": {"icon":"󰄬","label":"Enable Plugin","action":"omarchy-menu-plugin enable"},
"setup.plugin.disable": {"icon":"󰅖","label":"Disable Plugin","action":"omarchy-menu-plugin disable"},
@@ -185,8 +188,8 @@
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
"setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
"setup.security.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.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"},
@@ -216,6 +219,16 @@
"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.aur": {"icon":"󰣇","label":"AUR","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-aur-install"},
"install.china": {"icon":"󰓜","label":"China Apps","when":"[[ \"$(omarchy-region)\" == \"China\" ]]"},
"install.china.wechat": {"icon":"󰘑","label":"WeChat","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wechat-universal-bwrap","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wechat-universal-bwrap'"},
"install.china.qq": {"icon":"󰘅","label":"QQ","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present linuxqq","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add linuxqq'"},
"install.china.feishu": {"icon":"󱗆","label":"Feishu","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present feishu-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add feishu-bin'"},
"install.china.dingtalk": {"icon":"󰭹","label":"DingTalk","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present dingtalk-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add dingtalk-bin'"},
"install.china.wemeet": {"icon":"󰕧","label":"Tencent Meeting","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wemeet-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wemeet-bin'"},
"install.china.wps": {"icon":"󰈬","label":"WPS Office","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"omarchy-pkg-present wps-office-cn","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add wps-office-cn'"},
"install.china.tencent-docs": {"icon":"󰈙","label":"Tencent Docs","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/腾讯文档.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 腾讯文档 https://docs.qq.com \"\"'"},
"install.china.yuque": {"icon":"󰠮","label":"Yuque","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/语雀.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 语雀 https://www.yuque.com/dashboard \"\"'"},
"install.china.shimo": {"icon":"󰈙","label":"Shimo","when":"[[ \"$(omarchy-region)\" == \"China\" ]]","disabled":"[[ -f \"$HOME/.local/share/applications/石墨文档.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install 石墨文档 https://shimo.im/desktop \"\"'"},
"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'"},
@@ -227,7 +240,6 @@
"install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"},
"install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"},
"install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"},
"install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"},
"install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"},
"install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"},
"install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"},
+5
View File
@@ -0,0 +1,5 @@
[source.crates-io]
replace-with = "aliyun"
[source.aliyun]
registry = "sparse+https://mirrors.aliyun.com/crates.io-index/"
+8
View File
@@ -0,0 +1,8 @@
NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/
GOPROXY=https://mirrors.aliyun.com/goproxy/,direct
RUSTUP_DIST_SERVER=https://mirrors.aliyun.com/rustup
RUSTUP_UPDATE_ROOT=https://mirrors.aliyun.com/rustup/rustup
BUNDLE_MIRROR__ALL=https://mirrors.aliyun.com/rubygems/
MAVEN_ARGS=--global-settings /usr/share/omarchy/default/regions/cn/maven-settings.xml
+3
View File
@@ -0,0 +1,3 @@
---
:sources:
- https://mirrors.aliyun.com/rubygems/
+9
View File
@@ -0,0 +1,9 @@
<settings>
<mirrors>
<mirror>
<id>aliyun</id>
<mirrorOf>central</mirrorOf>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
</mirrors>
</settings>
+2
View File
@@ -0,0 +1,2 @@
# Installed from the ISO's offline repository before enabling ArchLinuxCN.
archlinuxcn-keyring
@@ -0,0 +1 @@
Server = https://mirror.omarchy.cn/$repo/os/$arch
@@ -0,0 +1 @@
Server = https://rc-mirror.omarchy.cn/$repo/os/$arch
@@ -0,0 +1 @@
Server = https://stable-mirror.omarchy.cn/$repo/os/$arch
@@ -0,0 +1,2 @@
[archlinuxcn]
Server = https://mirrors.ustc.edu.cn/archlinuxcn/$arch
+9
View File
@@ -0,0 +1,9 @@
# Installs in these timezones get this region. The installer's timezone list
# includes tzdata's backward-compatible names, so they are listed too.
Asia/Shanghai
Asia/Urumqi
Asia/Chongqing
Asia/Chungking
Asia/Harbin
Asia/Kashgar
PRC
+6 -10
View File
@@ -1,28 +1,24 @@
# Passwordless sudo
# Temporary passwordless sudo
`omarchy-sudo-passwordless` publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
The menu bar's `PasswordlessSudo` indicator polls `--active` every five seconds and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow.
`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
## Grant lifecycle
The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface offers a duration picker after result `3`. Result `0` toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists.
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists.
Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.
Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content.
Permanent grants live under `/etc/sudoers.d/99-omarchy-permanent-nopasswd-<uid>`, outside the temporary cleanup namespace. They have no `NOTAFTER` deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward.
## Package ownership
The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files.
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing temporary policy. The marker prevents a waiting publisher from creating a new timed grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New timed grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another timed grant.
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant.
The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
## Validation
+49
View File
@@ -0,0 +1,49 @@
# Regional defaults
Regions customize bootstrap defaults independently of update channels and of the installer's language. Country identifiers are lowercase, officially assigned ISO 3166-1 alpha-2 codes. `global` is a reserved value for no country-specific additions. Only implemented profiles are accepted; currently `global` and `cn`.
## Runtime configuration
The ISO installer writes the region it derived to `/etc/omarchy/region` before target finalization. It is plain text, not executable configuration, owned by root and world-readable (`0644`), because refresh renders as the calling user. A missing file means `global`; an unsupported value is an error.
`omarchy-apply-pacman <stable|rc|edge> [target-root]` restores the selected channel's two pacman templates, appends the region's `pacman.conf.append` to `pacman.conf`, and puts the region's `mirrorlist-<channel>.prepend` ahead of the channel's mirrorlist. Both files are generated before either live file is replaced. Existing files are backed up with `.bak`. Dev uses the edge pacman channel, as before.
Both installation finalization and `omarchy-refresh-pacman` use this command. Explicit refreshes and channel changes restore regional defaults; ordinary updates do not enforce them. The existing `pre-refresh-pacman` user hook runs after regeneration and before the update, so users can maintain overrides there. Switching to the dev channel refuses a checkout that lacks this machine's region profile, because that checkout's own refresh would otherwise drop the regional repositories. Users can edit the files directly or set the region to `global` before a refresh to stop applying regional repository defaults. This does not uninstall packages or rewrite anything in existing home directories.
Refresh renders both files as the calling user and gives root only the finished copies, so the sudo boundary stays command-scoped. That boundary does not defend against other code already running as the same user: such code could change the rendered files while refresh waits for the password, as it could alter that user's own environment in other ways.
The China profile puts Omarchy's China replica of the channel's Arch mirror first (`stable-mirror.omarchy.cn`, `rc-mirror.omarchy.cn`, or `mirror.omarchy.cn` for edge and dev) and keeps the channel's `.org` mirror as the fallback. pacman tries servers in order and moves to the next one when a download fails, including a 404. After three failed downloads from one server, it skips that server for the rest of the transaction. It also takes the package databases from the first server that answers, without comparing them, so the China replicas must carry complete, current metadata. The profile also adds the ArchLinuxCN community repository from USTC's mirror, run by the University of Science and Technology of China. It does not change the `[omarchy]` package repository. ArchLinuxCN is rolling and built against current Arch, while stable and RC are snapshots, so its packages can need newer libraries than a stable machine has.
## Profile layout
Each profile lives in `default/regions/<code>/`:
- `timezones`: the timezones that select this region, one per line, with blank lines and `#` comments allowed. List tzdata's backward-compatible names too, since the installer's timezone list includes them. A timezone may belong to at most one profile.
- `packages`: additional target packages, in the same format. Every ISO carries every profile's packages in its offline repository; the installer installs them only on targets in that region. A package named `<name>-keyring` is the profile's trust anchor: the builder installs it through Arch's existing trust chain, and a regional target populates the `<name>` keyring.
- `pacman/`: `pacman.conf.append`, added after the channel's `pacman.conf`, and one `mirrorlist-<channel>.prepend` per channel (`stable`, `rc`, `edge`), placed ahead of the channel's mirrorlist so the regional mirror is tried first.
## ISO installer contract
There is one ISO for every region. The companion `omarchy-iso` repository reads the profiles from the runtime it bundles, so a region and its repository defaults can never disagree.
- **Build:** every profile's `packages` go into the offline repository, but not into the base package list. The builder adds each profile's community repository to its writable online config to download them; it does not change its Arch channel mirrors.
- **Install:** the installer maps the timezone the user picked to a region through the `timezones` files; a timezone no profile lists means `global`. It shows the result on the summary screen. Unattended installs can set the region explicitly instead. For a regional install it installs the profile's packages from the offline repository and writes `/etc/omarchy/region`; the runtime finalizer then applies the fragments.
- **Deferred installs** have no timezone until first boot, so they are `global` unless the unattended configuration sets a region.
For China, the builder installs `archlinuxcn-keyring` using the existing Arch trust chain before downloading ArchLinuxCN packages. Signature or keyring failures abort the build; no unverified keyserver trust or online signature bypass is added. On a China target the installer installs the keyring from the offline repository, then initializes and populates it before the online repositories are enabled. Global targets never populate ArchLinuxCN's keys. The ISO's existing offline repository verification policy is unchanged.
## Scope of the China profile
The China profile provides repository defaults and developer mirrors. Language is a separate installer choice, not part of a region: a Chinese speaker outside China keeps global mirrors, and a China install in English still gets the regional ones.
## Developer mirrors
The `cn` profile also points the common package managers at mirrors inside China: `environment.conf` (npm, PyPI and uv, Go, rustup, Bundler, and Maven through `MAVEN_ARGS`), `cargo.toml` for crates.io, `gemrc` for RubyGems, and `maven-settings.xml`, which Maven reads in place. During installation `install/config/region.sh` links the first three to `/etc/environment.d/90-omarchy-china.conf`, `/.cargo/config.toml` and `/etc/gemrc` when the marker says `cn`. It refuses to replace a regular file it did not create. The environment reaches sessions started after login.
`omarchy region cn|global` (_Setup > Region_) switches an installed machine. `cn` upgrades, installs and trusts the profile's keyring through a rendered China config, records the marker and links the developer mirrors through the same script, then refreshes pacman. `global` removes the marker and only the links that point into the profile, then refreshes. The region also shows _Install > China Apps_.
## Remaining release validation
Unit tests cover region/channel independence, the timezone mapping, generated pacman contents, repeat application, and refresh ordering; refresh itself is exercised only inside the sudo-boundary sandbox. They do not establish live mirror availability, real package trust, or successful offline installation. Before distribution, build from matching local checkouts on an x86_64 builder and run the disposable-VM acceptance workflow in `agents/skills/acceptance-tests.md`.
The acceptance run must install without network access in both directions: a non-China timezone must produce pacman files byte for byte identical to the channel templates, and `Asia/Shanghai` must produce the China profile, boot to a new user, and keep the regional mirrors, repository, marker, and keyring trust through `stable`, `rc`, and `edge` refreshes.
+3 -3
View File
@@ -136,7 +136,7 @@ omarchy-update
├─ omarchy-update-stay-awake start
├─ run system-package updates
├─ run migrations
├─ automatically remove orphan packages and run log analysis
├─ run orphan review and log analysis
├─ omarchy-update-status
│ └─ refresh or clear the shell update indicator
├─ restart marked services and the shell
@@ -158,7 +158,7 @@ Important behavior:
- This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow.
- Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal.
- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit.
- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Conflict handoff reports and skips instead of blocking. Orphan packages are automatically removed during every update. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately.
- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately.
- The free-space requirement uses a 10 GiB threshold and stops the update before
confirmation when it is not met. If free space cannot be determined, the
check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check.
@@ -306,7 +306,7 @@ scripts.
| `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. |
| `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. |
| `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. |
| `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal 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-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. |
| `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. |
| `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. |
| `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. |
+1
View File
@@ -1,3 +1,4 @@
run_logged "$OMARCHY_INSTALL/config/region.sh"
run_logged "$OMARCHY_INSTALL/config/theme-system.sh"
run_logged "$OMARCHY_INSTALL/config/browser-policy.sh"
run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh"
+44
View File
@@ -0,0 +1,44 @@
cn_links=(
"environment.conf /etc/environment.d/90-omarchy-china.conf"
"cargo.toml /.cargo/config.toml"
"gemrc /etc/gemrc"
)
# The installer writes the marker from the timezone; omarchy-region passes the region it switches to.
region=global
if [[ -e /etc/omarchy/region ]]; then
region=$(</etc/omarchy/region)
fi
region=${OMARCHY_REGION:-$region}
case $region in
global)
rm -f /etc/omarchy/region
for link in "${cn_links[@]}"; do
read -r _ target <<<"$link"
if [[ -L $target && $(readlink "$target") == */default/regions/cn/* ]]; then
rm "$target"
fi
done
;;
cn)
for link in "${cn_links[@]}"; do
read -r _ target <<<"$link"
if [[ -e $target && ! -L $target ]]; then
echo "Refusing to replace $target, which Omarchy did not create" >&2
exit 1
fi
done
mkdir -p /etc/omarchy
echo cn >/etc/omarchy/region
for link in "${cn_links[@]}"; do
read -r source target <<<"$link"
mkdir -p "${target%/*}"
ln -sfn "$OMARCHY_PATH/default/regions/cn/$source" "$target"
done
;;
*)
echo "Unknown region: $region" >&2
exit 1
;;
esac
-1
View File
@@ -18,7 +18,6 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh"
run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh"
run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh"
run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh"
run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-ptl-speaker-firmware.sh"
run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh"
@@ -1,3 +0,0 @@
if omarchy-hw-dell-xps13-dx13260-ptl; then
omarchy-pkg-add linux-firmware-cirrus-dx13260
fi
+1 -1
View File
@@ -21,12 +21,12 @@ cups
cups-filters
cups-pk-helper
ddcutil
disktree-bin
docker
docker-buildx
docker-compose
dosfstools
dotnet-runtime
dua-cli
evince
exfatprogs
expac
-1
View File
@@ -59,7 +59,6 @@ linux-firmware-marvell
# Dell laptop support packages
dell-xps-touchpad-haptics
dell-xps13-sidecar-amps
linux-firmware-cirrus-dx13260
# Speaker tunings (LV2 limiter every tuning ends in)
lsp-plugins-lv2
+1 -2
View File
@@ -1,7 +1,6 @@
# 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
"$OMARCHY_PATH/bin/omarchy-apply-pacman" "${OMARCHY_MIRROR:-stable}"
# 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.
+4
View File
@@ -20,6 +20,10 @@ Now you're ready to Omarchy!
The full-disk encryption won't allow you to enter the password from a Bluetooth keyboard at startup. Just like you can't use a Bluetooth keyboard to enter the BIOS on a PC. You'll need a keyboard that either uses a 2.4ghz dongle or a cable (which is much nicer for latency anyway!). I personally love the [Lofree Flow84](https://www.lofree.co/products/lofree-flow-the-smoothest-mechanical-keyboard)!
### Installing in China
The timezone you pick in the installer decides the region: `Asia/Shanghai` brings Chinese package and developer mirrors and the China apps. See [China](52-china.md).
### Installing for another owner
If you're setting up a machine for someone else — a family member, a new employee, a buyer — you shouldn't be answering the personal questions on their behalf. Hit `Ctrl + C` on the very first screen of the installer (the keyboard selection), and Omarchy will offer to prepare the machine for another owner instead. The system installs right away, but all the personal setup — keyboard layout, username, password — is deferred until the machine boots for the first time. The drive is still encrypted by default, and the password the new owner picks on that first boot becomes the encryption password too. (A machine you've already been using can be handed over without a reinstall too — see [resetting the computer](48-security.md).)
+2 -2
View File
@@ -38,7 +38,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre
### The agents panel
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan, a meter for each of its 5-hour session and weekly limits with the time until it resets (hover for the exact percentage), or the remaining prepaid balance, under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box.
The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box.
Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings.
@@ -50,7 +50,7 @@ One account per provider is active, and every new session starts as it: `cx`, `c
With more than one account, the panel shows each account's limits side by side, with the active one marked. By default Omarchy notifies you when the active account passes 95% of a limit, and clicking the notification switches to the account with the most headroom. Hover an account's line in the panel and click the Autoswitch that appears beside Use (or press `m`), or run `omarchy agent account mode auto`, to have it switch on its own instead; while it's on, Autoswitch takes Use's place, and clicking it again goes back to notifying. Add a number to change the threshold, as in `omarchy agent account mode auto 90`. If every account is over the threshold, you'll hear about it once, along with which account frees up first. While an active account is within 15 points of its threshold (80% at the default), its limits are checked every three minutes rather than every 15.
`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours can show _Sign-in required_; click it to sign that account in again right in the panel, or start a session in it, which refreshes the sign-in on its own. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account.
`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours shows its last known limits until a session runs in it again. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account.
### Crash diagnosis
+4
View File
@@ -26,6 +26,10 @@ You start it (or reattach to your existing session) with `Super + Ctrl + Return`
Omarchy has packaged this as _About_ in the Omarchy menu (`Super + Space`).
## Disk Usage
When the drive fills up and you have no idea what's eating it, launch _Disk Usage_ from the app launcher (`Super + Space`). It's [dua](https://github.com/Byron/dua-cli) in interactive mode pointed at the whole file system, so you can walk down into whatever directory is the culprit, sorted biggest first, and delete from right inside it.
## Cliamp
[Cliamp](https://www.cliamp.stream/) is a retro terminal music player inspired by Winamp 2.x, complete with built-in radio stations for lo-fi beats. Launch it with `Super + Shift + Alt + M`, or from the Omarchy menu under _Apps_. Press `?` for the full keybinding list.
-4
View File
@@ -8,10 +8,6 @@ Plug in a USB stick or an SD card and it's mounted automatically, so it just sho
Double-clicking follows sensible defaults: images open in imv, video in mpv, PDFs in Document Viewer, and plain text in Neovim.
## Disktree
When the drive fills up and you have no idea what's eating it, launch [Disktree](https://github.com/tobi/disktree) from the app launcher (`Super + Space`). It shows a graphical treemap of your home directory. Larger blocks take up more space; walk into directories with the keyboard or mouse, mark what should go, and review your selections before removing anything. Run `disktree --disk` to scan the whole disk.
## Obsidian
[Obsidian](https://obsidian.md/) is a free and highly extensible note taking application that uses simple Markdown files for storage.
-4
View File
@@ -18,10 +18,6 @@ You start 1Password with `Super + Shift + /`. If it isn't installed yet, that ho
You start Spotify using `Super + Shift + M`. Like 1Password, the hotkey kicks off the installation first if Spotify isn't installed yet (or use _Install > Service > Spotify_ from the Omarchy menu).
## Slack
[Slack](https://slack.com/) is where a lot of teams do their chatting, both inside the company and with outside collaborators. Install it with _Install > Service > Slack_ from the Omarchy menu, and it'll open as soon as it's ready.
## Dropbox
[Dropbox](https://www.dropbox.com/) is a great way to sync files between machines while keeping a backup in the cloud. To set it up, select _Install > Service > Dropbox_ from the Omarchy menu. Once it's running, hover the tray in the top right of the bar and right-click the Dropbox icon to finish the setup.
+1 -3
View File
@@ -20,9 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av
## Passwordless sudo
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ asks how long to allow access: **15 minutes**, **1 Hour**, **1 Day**, or **Permanently**. A red warning icon appears beside the other menu bar indicators while access is active. Click it or run the command again to turn access off. You can also pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30`, or use `omarchy-sudo-passwordless permanent`.
Timed access expires automatically, including immediately after resuming from a suspend that crossed the deadline. Restarting the computer ends it early. Permanent access survives reboots and stays enabled until you disable it.
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough.
Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive.
+28
View File
@@ -0,0 +1,28 @@
# China
Omarchy works in mainland China without a VPN. The timezone you pick in the installer decides the region: `Asia/Shanghai`, `Asia/Urumqi` and their older names select China; any other timezone is Global. The summary screen shows _Region: cn_ before anything touches the disk.
## What the China region sets up
| Area | China | Global |
|------|-------|--------|
| Arch packages | The channel's `.omarchy.cn` mirror first, its `.omarchy.org` mirror behind it, and the ArchLinuxCN repository with its keyring | `.omarchy.org` mirrors |
| Omarchy packages | `pkgs.omarchy.org` | `pkgs.omarchy.org` |
| Developer mirrors | npm, PyPI and uv, Go, rustup and crates.io, RubyGems and Bundler, Maven | Upstream registries |
| Software | _Install > China Apps_ | Hidden |
pacman tries the `.cn` mirror first. If it is unreachable or lacks a package, pacman falls through to `.org`, which works for anyone with their own VPN. The installer carries the China packages on the ISO, so a China install needs no network.
## Developer mirrors
China machines point the common package managers at mirrors inside China. Environment variables in `/etc/environment.d/90-omarchy-china.conf` cover npm, PyPI and uv, Go, rustup, Bundler and Maven; they reach everything started from the desktop after your next login (not SSH or TTY shells), and they take precedence over npm, pip, uv and Go config files, so unset the variable in a shell to opt out. `/.cargo/config.toml` points crates.io at the mirror unless a project's `.cargo/config.toml` says otherwise, and `/etc/gemrc` sets the RubyGems source unless your `~/.gemrc` sets another. Omarchy never replaces one of those two files if it is a regular file you created. Toolchain downloads by mise, such as Node.js or Go themselves, still come from upstream.
## China Apps
_Install > China Apps_ installs WeChat, QQ, Feishu, DingTalk, Tencent Meeting and WPS Office from the AUR, and sets up Tencent Docs, Yuque and Shimo as web apps.
## Switching later
_Setup > Region_, or `omarchy region cn` and `omarchy region global`, switches the region. China runs a full system update, trusts the ArchLinuxCN keyring, records the region, moves pacman to the `.cn` mirrors and links the developer mirrors. Global moves pacman back to `.org`, removes the developer mirrors and hides _Install > China Apps_; apps you installed from it stay. The developer mirrors change at the next login, so the switch offers to log out. `omarchy update` never changes the region.
A machine prepared for another owner installs Global, since its timezone is only set on first boot. Switch it under _Setup > Region_ once it is online.
+1 -1
View File
@@ -17,7 +17,7 @@ fi
drop_npm_grok() {
omarchy-cmd-present mise || return 0
if [[ -n $(mise ls -g "$npm_grok") ]]; then
mise unuse -y -g "$npm_grok"
mise unuse -g "$npm_grok"
fi
if [[ -n $(mise ls -i "$npm_grok") ]]; then
mise uninstall -y --all "$npm_grok"
-6
View File
@@ -1,6 +0,0 @@
echo "Install the Dell XPS 13 Panther Lake speaker firmware"
if omarchy-hw-dell-xps13-dx13260-ptl; then
source "$OMARCHY_PATH/install/hardware/dell-xps13-ptl-speaker-firmware.sh"
omarchy-state set reboot-required
fi
-9
View File
@@ -1,9 +0,0 @@
echo "Replace the Disk Usage TUI with Disktree"
omarchy-pkg-add disktree-bin
omarchy-pkg-drop dua-cli
rm -f "$HOME/.local/share/applications/Disk Usage.desktop"
if [[ -d $HOME/.local/share/applications ]]; then
update-desktop-database "$HOME/.local/share/applications"
fi
-15
View File
@@ -1,15 +0,0 @@
echo "Enable overlay scrollbars in Chromium-based browsers"
for flags_file in "$HOME"/.config/{chromium,chrome,microsoft-edge-stable,brave,brave-origin}-flags.conf; do
[[ -f $flags_file ]] || continue
grep -Eq -- '^--enable-features=([^,]+,)*OverlayScrollbar([,:]|$)' "$flags_file" && continue
if grep -q -- '^--enable-features=' "$flags_file"; then
sed -i --follow-symlinks \
-e '/^--enable-features=/ s/$/,OverlayScrollbar/' \
-e '/^--enable-features=/ s/=,/=/' "$flags_file"
else
[[ -n $(tail -c1 "$flags_file") ]] && echo >>"$flags_file"
echo '--enable-features=OverlayScrollbar' >>"$flags_file"
fi
done
+24 -57
View File
@@ -3,9 +3,12 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
// Map a fresh fullscreen surface on each open. Keep its first frames
// transparent until Qt receives the output's scale, so it opens sharp without
// retaining a parked surface that can be closed when a monitor disconnects.
// A fullscreen overlay whose surface outlives each open. A fresh surface draws
// its first frames before the compositor sends its fractional scale, so an
// overlay mapped per open flashed blurry until the scale arrived. Closed, the
// surface parks as a 1x1, input-less, content-less layer below windows: small
// enough to cost nothing, off the overlay layer so it never blocks direct
// scanout for fullscreen apps. Opening only resizes and raises it.
PanelWindow {
id: window
@@ -14,31 +17,11 @@ PanelWindow {
property int shownLayer: WlrLayer.Overlay
property int shownKeyboardFocus: WlrKeyboardFocus.Exclusive
// The surface no longer lands on the focused output by being mapped there,
// so it follows the focused monitor each time it is shown. Unset until the
// first show lets the compositor choose.
property var targetScreen: null
readonly property var targetMonitor: {
var monitors = Hyprland.monitors.values
for (var i = 0; i < monitors.length; i++) {
if (targetScreen && monitors[i].name === targetScreen.name) return monitors[i]
}
return null
}
// Wait for fullscreen geometry as well as scale so no smaller opening frame
// is stretched across the output. Fractional scale uses units of 1/120;
// compare in those units to tolerate floating point rounding.
readonly property bool contentReady: shown && backingWindowVisible && !!targetScreen
&& width === targetScreen.width && height === targetScreen.height
&& !!targetMonitor && Math.round(devicePixelRatio * 120) === Math.round(targetMonitor.scale * 120)
property bool contentRevealed: false
onContentReadyChanged: {
contentRevealed = false
// Resize and scale notifications arrive while Qt is still updating the
// window. Reveal on the next turn, once its content layout has caught up.
if (contentReady) Qt.callLater(function() {
if (window.contentReady) window.contentRevealed = true
})
}
property Region emptyRegion: Region {}
function focusedScreen() {
var monitor = Hyprland.focusedMonitor
@@ -46,44 +29,28 @@ PanelWindow {
for (var i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name === name) return Quickshell.screens[i]
}
return Quickshell.screens.length > 0 ? Quickshell.screens[0] : null
return null
}
// Live monitor scaling does not emit a Hyprland monitor event, so its cached
// scale can lag behind Qt. Refresh on opening and on scale changes while open.
onShownChanged: {
targetScreen = shown ? focusedScreen() : null
if (shown) Hyprland.refreshMonitors()
}
onDevicePixelRatioChanged: if (shown) Hyprland.refreshMonitors()
onShownChanged: if (shown) targetScreen = focusedScreen() || targetScreen
Connections {
target: Quickshell
function onScreensChanged() {
if (window.shown && Quickshell.screens.indexOf(window.targetScreen) < 0) {
window.targetScreen = null
// Let the removed output finish closing its old window before mapping
// a replacement, rather than recreating it during Qt's teardown.
Qt.callLater(function() {
if (window.shown && !window.targetScreen) window.targetScreen = window.focusedScreen()
})
}
}
}
visible: shown && !!targetScreen && !!targetMonitor
visible: true
screen: targetScreen
anchors { top: true; left: true; bottom: true; right: true }
anchors { top: true; left: true; bottom: shown; right: shown }
implicitWidth: 1
implicitHeight: 1
mask: shown ? null : emptyRegion
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: shownLayer
WlrLayershell.keyboardFocus: shownKeyboardFocus
WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom
WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None
// Opacity preserves keyboard handling while the scale arrives, including
// search keystrokes and Escape pressed immediately after opening.
// Draw nothing until the surface has actually grown. A frame drawn while it
// is still 1x1 holds only the scrim's color, which the compositor would
// stretch across the whole screen until the fullscreen frame arrives.
Binding {
target: window.contentItem
property: "opacity"
value: window.contentRevealed ? 1 : 0
property: "visible"
value: window.shown && window.width > 1 && window.height > 1
}
}
+2 -3
View File
@@ -19,9 +19,8 @@ Every subscription on one page, limits first.
per limit window: its meter and the time until it resets (the exact percentage
on hover). A model-scoped allowance on the same clock (Claude's Fable weekly
limit) is a tick on that window's meter rather than a line of its own; the
row's tooltip names it. A lapsed or missing sign-in shows a _Sign-in required_ link that signs
that account in again from the panel; other endpoint trouble shows under the
name in the urgent color. Limits kept from an earlier check after a failed one dim, and their
row's tooltip names it. Sign-in and endpoint trouble shows under the name in the urgent
color. Limits kept from an earlier check after a failed one dim, and their
tooltip says how old they are.
- **Accounts** — an agent with more than one subscription account (see
`omarchy agent account`) lists each: name and plan on one line (the email on hover), and its own limit
@@ -1,71 +0,0 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
BarIndicator {
id: root
property bool granted: false
property bool refreshPending: false
active: granted
activeText: "󰟵"
inactiveText: "󰟵"
activeTooltipText: "Disable Passwordless Sudo"
inactiveTooltipText: "Passwordless Sudo"
useActiveColor: true
activeColor: Color.urgent
function refresh() {
if (!root.bar) return
if (statusProc.running) {
root.refreshPending = true
return
}
root.refreshPending = false
statusProc.running = true
}
onBarChanged: refresh()
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Timer {
interval: 5000
repeat: true
running: !!root.bar
onTriggered: root.refresh()
}
Process {
id: statusProc
command: ["omarchy-sudo-passwordless", "--active"]
onExited: function(exitCode, exitStatus) {
root.granted = exitCode === 0 && exitStatus === 0
if (root.refreshPending) Qt.callLater(root.refresh)
}
}
Process {
id: disableProc
command: ["omarchy-sudo-passwordless", "--disable"]
onExited: function(exitCode, exitStatus) {
if ((exitCode !== 0 || exitStatus !== 0) && root.bar)
root.bar.run('omarchy-notification-send "Could not disable passwordless sudo" "Check the sudo configuration and try again."')
if (root.indicatorHost) root.indicatorHost.refresh()
else root.refresh()
}
}
onPressed: function() {
if (!root.bar || disableProc.running) return
if (root.granted) disableProc.running = true
else root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless")
}
}
@@ -26,11 +26,6 @@
"placeholderText": "Search indicators...",
"emptyText": "No indicators",
"options": [
{
"value": "PasswordlessSudo",
"label": "Passwordless sudo",
"description": "Warning while passwordless root access is enabled"
},
{
"value": "Dictation",
"label": "Dictation",
+1 -1
View File
@@ -8,7 +8,7 @@ BarWidget {
id: root
moduleName: "omarchy.indicators"
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: []
property var indicatorActiveStates: ({})
+2 -2
View File
@@ -1075,7 +1075,7 @@ Item {
// then on the card grows and shrinks downward instead of re-centering
// on every resize, which made the menu jump around. The rows height is
// frozen at the same moment, so the starting menu also caps how tall the
// card may grow from there. Closing or changing screens unfreezes both.
// card may grow from there. Closing unfreezes both.
property int cardTop: -1
property int maxRowsHeight: -1
readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2))
@@ -1086,8 +1086,8 @@ Item {
maxRowsHeight = root.visibleRowsHeight
}
}
// The surface stays mapped between opens, so closing is shown going false.
onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }
onTargetScreenChanged: { cardTop = -1; maxRowsHeight = -1 }
Rectangle {
anchors.fill: parent
+2 -1
View File
@@ -398,7 +398,8 @@ var GUARD_READERS = [
"omarchy-default-browser",
"omarchy-default-editor",
"omarchy-default-terminal",
"omarchy-dns"
"omarchy-dns",
"omarchy-region"
]
// Package and command presence account for most of what the guards ask, and
+7 -1
View File
@@ -81,11 +81,17 @@ layer_absent() {
! layer_present "$1"
}
# Fullscreen overlays map on the overlay layer and unmap when closed.
# Overlays built on OverlayWindow (the menu, image selector, emoji picker,
# clipboard and the like) stay mapped between opens, parked 1x1 on the bottom
# layer, so they keep their fractional scale. Showing means on the overlay.
layer_on_overlay() {
hyprctl -j layers | jq -e --arg ns "$1" '[.[].levels["3"][]? | select(.namespace == $ns)] | length > 0'
}
layer_off_overlay() {
! layer_on_overlay "$1"
}
# A layer can be mapped but parked off the monitor: the bar hides that way so
# revealing it does not have to rebuild the surface. Assert on geometry when
# what matters is that the user can actually see it. Layer boxes are local to
+1 -1
View File
@@ -86,7 +86,7 @@ screenshot "success-menu-05-position-submenu"
wtype -k Down -k Down -k Return
wait_until "menu bar position changes to left" 20 bar_position_is "left"
wait_until "menu bar becomes vertical" 20 bar_is_vertical
wait_until "menu closes after selecting a position" 15 layer_absent "omarchy-menu"
wait_until "menu closes after selecting a position" 15 layer_off_overlay "omarchy-menu"
screenshot "success-menu-06-bar-left"
if ((config_existed)); then
-107
View File
@@ -1,107 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
original_monitors=$(hyprctl -j monitors)
remaining_monitor=$(jq -r 'map(select(.focused))[0].name // .[0].name' <<<"$original_monitors")
remaining_height=$(jq -r --arg name "$remaining_monitor" '.[] | select(.name == $name) | (if (.transform // 0) % 2 == 1 then .width else .height end) / .scale | ceil' <<<"$original_monitors")
# Make the source tall enough that a frozen centered menu would land below
# the remaining display, so a mapped but misplaced menu cannot pass recovery.
tall_height=$((remaining_height * 2 + 1600))
virtual_monitor=""
cleanup() {
omarchy-shell shell hide omarchy.menu >/dev/null 2>&1 || true
if [[ -n $virtual_monitor ]]; then
hyprctl output remove "$virtual_monitor" >/dev/null 2>&1 || true
fi
hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null 2>&1 || true
}
trap cleanup EXIT
find_virtual_monitor() {
virtual_monitor=$(hyprctl -j monitors | jq -r --argjson previous "$original_monitors" '[.[].name | select(. as $name | $previous | map(.name) | index($name) == null)][0] // empty')
[[ -n $virtual_monitor ]]
}
monitor_is_tall() {
hyprctl -j monitors | jq -e --arg name "$virtual_monitor" --argjson height "$tall_height" 'any(.[]; .name == $name and .height == $height and .scale == 1)'
}
monitor_is_focused() {
hyprctl -j monitors | jq -e --arg name "$1" 'any(.[]; .name == $name and .focused)'
}
menu_on_monitor() {
hyprctl -j layers | jq -e --arg name "$1" '[.[$name].levels["3"][]? | select(.namespace == "omarchy-menu")] | length == 1'
}
monitor_contains() {
local monitor="$1" text="$2"
local snapshot="$ARTIFACTS/monitor-text-$$.png"
local status=0
if timeout 10 grim -o "$monitor" -s 2 "$snapshot" 2>/dev/null; then
tesseract "$snapshot" stdout --psm 11 2>/dev/null | grep -Fi -- "$text" >/dev/null || status=$?
else
status=1
fi
rm -f "$snapshot"
return "$status"
}
create_source_monitor() {
hyprctl output create headless >/dev/null
wait_until "temporary overlay monitor appears" 15 find_virtual_monitor
hyprctl eval "hl.monitor({ output = \"$virtual_monitor\", mode = \"1920x${tall_height}@60\", position = \"auto\", scale = 1 })" >/dev/null
wait_until "temporary overlay monitor has tall geometry" 15 monitor_is_tall
hyprctl dispatch "hl.dsp.focus({ monitor = \"$virtual_monitor\" })" >/dev/null
wait_until "temporary overlay monitor is focused" 15 monitor_is_focused "$virtual_monitor"
}
remove_source_monitor() {
hyprctl output remove "$virtual_monitor" >/dev/null
virtual_monitor=""
hyprctl dispatch "hl.dsp.focus({ monitor = \"$remaining_monitor\" })" >/dev/null
wait_until "remaining monitor is focused" 15 monitor_is_focused "$remaining_monitor"
}
omarchy-shell shell hide omarchy.menu >/dev/null
wait_until "menu starts closed" 15 layer_absent "omarchy-menu"
# A closed overlay must drop its old output and reopen on a connected one.
create_source_monitor
omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null
wait_until "menu opens on temporary monitor" 15 menu_on_monitor "$virtual_monitor"
wait_until "menu renders on temporary monitor" 15 monitor_contains "$virtual_monitor" "Apps"
screenshot "success-overlay-monitor-01-closed-source"
omarchy-shell shell hide omarchy.menu >/dev/null
wait_until "menu unmaps before monitor removal" 15 layer_absent "omarchy-menu"
remove_source_monitor
omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null
wait_until "closed menu reopens on remaining monitor" 15 menu_on_monitor "$remaining_monitor"
wait_until "reopened menu renders on remaining monitor" 15 monitor_contains "$remaining_monitor" "Apps"
screenshot "success-overlay-monitor-02-closed-recovered"
omarchy-shell shell hide omarchy.menu >/dev/null
wait_until "reopened menu closes" 15 layer_absent "omarchy-menu"
# Typing freezes the menu's position. Recovery onto a shorter screen must
# unfreeze that position while preserving the search and keyboard handling.
create_source_monitor
omarchy-shell shell summon omarchy.menu '{"menu":"root"}' >/dev/null
wait_until "open recovery menu maps on tall monitor" 15 menu_on_monitor "$virtual_monitor"
wait_until "open recovery menu renders" 15 monitor_contains "$virtual_monitor" "Apps"
screenshot "success-overlay-monitor-03-open-root"
wtype "keybindings"
wait_until "menu search renders on tall monitor" 15 monitor_contains "$virtual_monitor" "Keybindings"
screenshot "success-overlay-monitor-04-open-search"
remove_source_monitor
wait_until "open menu moves to remaining monitor" 15 menu_on_monitor "$remaining_monitor"
wait_until "open menu search renders on shorter monitor" 15 monitor_contains "$remaining_monitor" "Keybindings"
screenshot "success-overlay-monitor-05-open-recovered"
wtype -k Escape
wait_until "recovered menu clears search through keyboard" 15 monitor_contains "$remaining_monitor" "Apps"
wtype -k Escape
wait_until "recovered menu closes through keyboard" 15 layer_absent "omarchy-menu"
+7 -7
View File
@@ -29,7 +29,7 @@ wtype "rocket"
sleep 1
screenshot "success-emoji-picker-search"
wtype -k Return
wait_until "emoji picker selection closes" 15 layer_absent "omarchy-emojis"
wait_until "emoji picker selection closes" 15 layer_off_overlay "omarchy-emojis"
# Seed two clipboard entries, search for the older one, and copy it back out.
clipboard_token="Omarchy acceptance clipboard $(date +%s)"
@@ -44,7 +44,7 @@ wtype "$clipboard_token"
wait_until "clipboard search finds test text" 15 screen_contains "Omarchy acceptance clipboard"
screenshot "success-clipboard-search"
wtype -M shift -k Return -m shift
wait_until "clipboard selection closes" 15 layer_absent "omarchy-clipboard"
wait_until "clipboard selection closes" 15 layer_off_overlay "omarchy-clipboard"
wait_until "clipboard selection restores test text" 15 bash -c '[[ $(wl-paste --no-newline) == "$1" ]]' _ "$clipboard_token"
# Exercise the system branch without invoking any destructive action.
@@ -53,7 +53,7 @@ wait_until "system menu opens" 15 layer_on_overlay "omarchy-menu"
wait_until "system menu content is visible" 15 screen_contains "Shutdown"
screenshot "success-system-menu"
wtype -k Escape
wait_until "system menu closes" 15 layer_absent "omarchy-menu"
wait_until "system menu closes" 15 layer_off_overlay "omarchy-menu"
# Preview both visual selectors and cancel without changing user state. These
# cover thumbnail generation, the image-grid overlay, and current selection.
@@ -62,14 +62,14 @@ wait_until "background selector opens" 30 layer_on_overlay "omarchy-image-select
sleep 1
screenshot "success-background-selector"
wtype -k Escape
wait_until "background selector closes" 15 layer_absent "omarchy-image-selector"
wait_until "background selector closes" 15 layer_off_overlay "omarchy-image-selector"
launch_app "omarchy-theme-switcher"
wait_until "theme selector opens" 30 layer_on_overlay "omarchy-image-selector"
sleep 1
screenshot "success-theme-selector"
wtype -k Escape
wait_until "theme selector closes" 15 layer_absent "omarchy-image-selector"
wait_until "theme selector closes" 15 layer_off_overlay "omarchy-image-selector"
# Walk the reminder flow through each input screen, but dismiss before it
# schedules a real timer in the test user's session.
@@ -83,7 +83,7 @@ wtype -k Return
wait_until "reminder message prompt opens" 15 screen_contains "Reminder message"
screenshot "success-reminder-03-message-prompt"
wtype -k Escape
wait_until "reminder flow closes" 15 layer_absent "omarchy-reminders"
wait_until "reminder flow closes" 15 layer_off_overlay "omarchy-reminders"
# Render a real shell notification and clear it through the notification IPC.
omarchy-shell notifications dismissAll >/dev/null
@@ -111,7 +111,7 @@ screenshot "success-apps-menu-search"
wtype -k Return
wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite"
wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu"
wait_until "apps menu closes after launching" 15 layer_off_overlay "omarchy-menu"
close_windows "(?i)omawrite"
wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite"
@@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-channel-set
copy_boundary_file bin/omarchy-refresh-pacman
copy_boundary_file bin/omarchy-apply-pacman
copy_boundary_file bin/omarchy-update
export OMARCHY_UPDATE_LOGGED=1
+53
View File
@@ -113,6 +113,7 @@ run_channel() {
: >"$log_file"
OMARCHY_CHANNEL_TEST_LOG="$log_file" \
OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \
OMARCHY_REGION_FILE="${OMARCHY_TEST_REGION_FILE:-$test_tmp/no-region}" \
HOME="$test_tmp/home" \
PATH="$stub_bin:$ROOT/bin:$PATH" \
"${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@"
@@ -190,6 +191,58 @@ fi
assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "switching back to dev links ~/omarchy"
pass "switching back to dev reuses the existing ~/omarchy checkout"
printf 'cn\n' >"$test_tmp/region"
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
fail "dev refuses a checkout without this machine's region"
fi
grep -q "does not support the 'cn' region" "$test_tmp/region.err" || fail "dev explains the missing region" "$(cat "$test_tmp/region.err")"
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
fail "dev refuses before linking or refreshing pacman" "$(cat "$log_file")"
fi
pass "dev refuses a checkout without the region before touching pacman"
# A directory with the region's name, or a helper that cannot run, is not
# support: the refresh after linking would fail with dev already linked.
mkdir -p "$checkout/default/regions/cn"
touch "$checkout/bin/omarchy-apply-pacman"
chmod +x "$checkout/bin/omarchy-apply-pacman"
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
fail "dev refuses an empty region profile"
fi
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
fail "dev refuses an empty region profile before linking or refreshing pacman" "$(cat "$log_file")"
fi
mkdir -p "$checkout/default/regions/cn/pacman"
cn_fragments=(pacman.conf.append mirrorlist-stable.prepend mirrorlist-rc.prepend mirrorlist-edge.prepend)
for missing in "${cn_fragments[@]}"; do
for fragment in "${cn_fragments[@]}"; do
if [[ $fragment == "$missing" ]]; then
rm -f "$checkout/default/regions/cn/pacman/$fragment"
else
touch "$checkout/default/regions/cn/pacman/$fragment"
fi
done
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
fail "dev refuses a region profile without $missing"
fi
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
fail "dev refuses a profile without $missing before linking or refreshing pacman" "$(cat "$log_file")"
fi
done
touch "${cn_fragments[@]/#/$checkout/default/regions/cn/pacman/}"
chmod -x "$checkout/bin/omarchy-apply-pacman"
if OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev >"$test_tmp/region.out" 2>"$test_tmp/region.err"; then
fail "dev refuses a checkout whose pacman helper cannot run"
fi
if grep -Eq $'^(link|refresh)\t' "$log_file"; then
fail "dev refuses incomplete region support before linking or refreshing pacman" "$(cat "$log_file")"
fi
pass "dev refuses a region profile missing any fragment or a helper that cannot run"
chmod +x "$checkout/bin/omarchy-apply-pacman"
OMARCHY_TEST_REGION_FILE="$test_tmp/region" run_channel dev
assert_log_line $'refresh\tedge' "dev proceeds with a checkout that supports the region"
current_channel() {
OMARCHY_TEST_VERSION_CHANNEL="$1" \
OMARCHY_TEST_PACKAGES="$2" \
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
require_command node
node - "$ROOT" <<'JS'
const fs = require('fs')
const root = process.argv[2]
const model = require(`${root}/shell/plugins/menu/MenuModel.js`)
const items = model.parseMenuJsonc(fs.readFileSync(`${root}/default/omarchy/omarchy-menu.jsonc`, 'utf8'))
const china = items.filter(item => item.id === 'install.china' || item.id.startsWith('install.china.'))
const guard = '[[ "$(omarchy-region)" == "China" ]]'
const fail = message => { console.error(`not ok - ${message}`); process.exit(1) }
const aur = {
wechat: 'wechat-universal-bwrap', qq: 'linuxqq', feishu: 'feishu-bin',
dingtalk: 'dingtalk-bin', wemeet: 'wemeet-bin', wps: 'wps-office-cn',
}
const web = {
'tencent-docs': ['腾讯文档', 'https://docs.qq.com'],
yuque: ['语雀', 'https://www.yuque.com/dashboard'],
shimo: ['石墨文档', 'https://shimo.im/desktop'],
}
if (china.length !== 1 + Object.keys(aur).length + Object.keys(web).length) fail(`China Apps has exactly the expected entries: ${china.map(i => i.id)}`)
if (!china.every(item => item.when === guard)) fail('every China Apps entry, including search hits, shows only in China')
for (const [id, pkg] of Object.entries(aur)) {
const item = china.find(i => i.id === `install.china.${id}`)
if (!item || item.action !== `omarchy-launch-floating-terminal-with-presentation 'omarchy-pkg-aur-add ${pkg}'` || item.disabled !== `omarchy-pkg-present ${pkg}`)
fail(`${id} installs and checks the AUR package ${pkg}`)
}
for (const [id, [name, url]] of Object.entries(web)) {
const item = china.find(i => i.id === `install.china.${id}`)
if (!item || item.action !== `omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install ${name} ${url} ""'` || item.disabled !== `[[ -f "$HOME/.local/share/applications/${name}.desktop" ]]`)
fail(`${id} installs and checks the ${name} web app`)
}
console.log('ok - China Apps lists the AUR packages and web apps, shown only in China')
JS
aur_names="wechat-universal-bwrap linuxqq feishu-bin dingtalk-bin wemeet-bin wps-office-cn"
if [[ ${OMARCHY_TEST_NETWORK:-} == "1" ]]; then
query=$(printf '&arg[]=%s' $aur_names)
found=$(curl -fsSL "https://aur.archlinux.org/rpc/v5/info?${query#&}" | jq -r '.results[].Name' | sort | tr '\n' ' ')
[[ $found == "$(printf '%s\n' $aur_names | sort | tr '\n' ' ')" ]] || fail "every China Apps package exists in the AUR" "$found"
pass "every China Apps package exists in the AUR"
else
skip "AUR existence check needs OMARCHY_TEST_NETWORK=1"
fi
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "$test_tmp/bin" "$test_tmp/home"
cat >"$test_tmp/bin/curl" <<'SH'
#!/bin/bash
while (($#)); do [[ $1 == "-o" ]] && out=$2; shift; done
[[ -n ${out:-} ]] && printf '\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01\x08\x06\0\0\0\x1f\x15\xc4\x89' >"$out"
exit 0
SH
printf '#!/bin/bash\necho "the web app wizard opened" >&2\nexit 1\n' >"$test_tmp/bin/gum"
chmod +x "$test_tmp/bin/curl" "$test_tmp/bin/gum"
grep -o "omarchy-launch-floating-terminal-with-presentation 'omarchy-webapp-install [^']*'" "$ROOT/default/omarchy/omarchy-menu.jsonc" |
sed -e "s/^[^']*'//" -e "s/'$//" -e 's/\\"/"/g' >"$test_tmp/actions"
(($(wc -l <"$test_tmp/actions") == 3)) || fail "the menu has three China web app actions"
while read -r action; do
HOME="$test_tmp/home" PATH="$test_tmp/bin:$ROOT/bin:$PATH" bash -c "$action" >/dev/null ||
fail "the menu action installs its web app: $action"
done <"$test_tmp/actions"
for app in 腾讯文档 语雀 石墨文档; do
[[ -f $test_tmp/home/.local/share/applications/$app.desktop ]] || fail "$app gets a launcher"
done
pass "each China web app action creates its launcher without the wizard"
@@ -1,46 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1791376887.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
home="$test_tmp/home"
config_dir="$home/.config"
mkdir -p "$config_dir" "$test_tmp/targets"
printf '%s\n' '--enable-features=TouchpadOverscrollHistoryNavigation' >"$config_dir/chromium-flags.conf"
printf '%s\n' '--enable-features=' >"$config_dir/chrome-flags.conf"
printf '%s' '--ozone-platform=wayland' >"$config_dir/microsoft-edge-stable-flags.conf"
printf '%s\n' '--enable-features=BraveFeature' >"$test_tmp/targets/brave-flags.conf"
ln -s "$test_tmp/targets/brave-flags.conf" "$config_dir/brave-flags.conf"
run_migration() {
HOME="$home" bash -euo pipefail "$migration" >/dev/null
}
run_migration
run_migration
grep -Fxq -- '--enable-features=TouchpadOverscrollHistoryNavigation,OverlayScrollbar' "$config_dir/chromium-flags.conf" ||
fail "the migration preserves existing Chromium features"
overlay_count=$(grep -o 'OverlayScrollbar' "$config_dir/chromium-flags.conf" | wc -l)
(( overlay_count == 1 )) || fail "the migration does not duplicate the Chromium feature"
pass "the migration preserves existing features and can run twice"
[[ $(cat "$config_dir/chrome-flags.conf") == '--enable-features=OverlayScrollbar' ]] ||
fail "the migration fills an empty feature list"
[[ $(cat "$config_dir/microsoft-edge-stable-flags.conf") == $'--ozone-platform=wayland\n--enable-features=OverlayScrollbar' ]] ||
fail "the migration adds a feature list with a missing trailing newline"
pass "the migration handles empty and missing feature lists"
[[ -L $config_dir/brave-flags.conf ]] || fail "the migration preserves symlinked browser flags"
grep -Fxq -- '--enable-features=BraveFeature,OverlayScrollbar' "$config_dir/brave-flags.conf" ||
fail "the migration updates the target of symlinked browser flags"
pass "the migration follows symlinked browser flags"
[[ ! -e $config_dir/brave-origin-flags.conf ]] || fail "the migration leaves missing browser flags absent"
pass "the migration skips missing browser flags"
-39
View File
@@ -1,39 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "$test_tmp/bin" "$test_tmp/home/.local/share/applications"
for helper in omarchy-pkg-add omarchy-pkg-drop update-desktop-database; do
cat > "$test_tmp/bin/$helper" <<'SH'
#!/bin/bash
printf '%s %s\n' "${0##*/}" "$*" >> "$TEST_LOG"
SH
chmod +x "$test_tmp/bin/$helper"
done
launcher="$test_tmp/home/.local/share/applications/Disk Usage.desktop"
printf '[Desktop Entry]\nExec=dua i /\n' > "$launcher"
for (( attempt = 0; attempt < 2; attempt++ )); do
env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \
bash -euo pipefail "$ROOT/migrations/1791345484.sh"
[[ ! -e $launcher ]] || fail "the migration removes the old Disk Usage launcher"
done
pass "the Disktree migration removes the old launcher and can run twice"
expected="omarchy-pkg-add disktree-bin
omarchy-pkg-drop dua-cli
update-desktop-database $test_tmp/home/.local/share/applications"
[[ $(cat "$test_tmp/log") == "$expected"$'\n'"$expected" ]] || fail "the migration installs Disktree before removing dua and refreshing launchers"
pass "the migration installs Disktree before removing dua and refreshing launchers"
rm -r "$test_tmp/home/.local/share/applications"
env HOME="$test_tmp/home" OMARCHY_PATH="$ROOT" TEST_LOG="$test_tmp/log" PATH="$test_tmp/bin:$PATH" \
bash -euo pipefail "$ROOT/migrations/1791345484.sh"
[[ ! -d $test_tmp/home/.local/share/applications ]] || fail "the migration relies on the packaged Disktree launcher"
pass "the migration relies on the packaged Disktree launcher when no local launchers exist"
@@ -52,9 +52,7 @@ case "$name" in
;;
mv)
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
[[ ${TEST_REQUIRE_EXISTING_TARGET:-0} != 1 || -f ${@: -1} ]] || exit 1
/usr/bin/mv "$@"
[[ ${TEST_KILL_AFTER_PUBLISH:-0} != 1 ]] || /usr/bin/kill -KILL "$PPID"
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
;;
systemd-run)
@@ -71,15 +69,7 @@ case "$name" in
sudo)
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
if [[ ${1:-} == -k ]]; then exit 0; fi
if [[ ${1:-} == -n && ${3:-} == -l ]]; then
[[ ${TEST_POLICY_FAILURE:-0} != 1 ]] || exit 1
default_policy=' Options: authenticate'
[[ ${TEST_STATUS:-3} == 3 ]] || default_policy=' Options: !authenticate'
printf '%s\n' "${TEST_POLICY:-$default_policy}"
exit 0
fi
if [[ ${1:-} == -n ]]; then shift; fi
if [[ ${1:-} == -N || ${1:-} == -kn ]]; then shift; fi
if [[ ${1:-} == -N ]]; then shift; fi
if [[ ${1:-} == -- ]]; then shift; fi
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
@@ -88,14 +78,7 @@ case "$name" in
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
fi
;;
gum)
if [[ $1 == choose ]]; then
[[ ${TEST_CHOICE_CANCEL:-0} != 1 ]] || exit 130
printf '%s\n' "${TEST_CHOICE:-15 minutes}"
else
exit "${TEST_CONFIRM_STATUS:-1}"
fi
;;
gum) exit 1 ;;
*) exit 99 ;;
esac
STUB
@@ -137,6 +120,6 @@ assert_status() {
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
}
reset_grant() {
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-1000" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
: >"$test_tmp/commands"
}
+9 -1
View File
@@ -9,7 +9,11 @@ export SUDO_TEST_LOG="$boundary_tmp/events"
export SUDO_TEST_CACHE="$boundary_tmp/cache"
export OMARCHY_PATH="$SUDO_TEST_ROOT"
export SUDO_TEST_HOME="$boundary_tmp/home"
mkdir -p "$SUDO_TEST_HOME"
# The machine the render reads its region marker from. It starts global; a test
# writes etc/omarchy/region here to model a regional install, never the host's.
export SUDO_TEST_TARGET="$boundary_tmp/target"
export OMARCHY_REGION_FILE="$SUDO_TEST_TARGET/etc/omarchy/region"
mkdir -p "$SUDO_TEST_HOME" "$SUDO_TEST_TARGET/etc"
mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update"
: >"$SUDO_TEST_LOG"
@@ -24,6 +28,10 @@ s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME')
for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']:
s=s.replace('/usr/bin/'+command, str(target/'mock'/command))
s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"')
if name.name == 'omarchy-apply-pacman':
render_etc='etc="${4:-/}/etc"'
assert s.count(render_etc)==1, 'render target lookup moved'
s=s.replace(render_etc, 'etc="${4:-$SUDO_TEST_TARGET}/etc"')
p.write_text(s)
p.chmod((source/name).stat().st_mode & 0o777)
PY
+1 -1
View File
@@ -147,7 +147,7 @@ assert(
)
assert(
/OverlayWindow \{\s*id: panel\s*shown: root\.opened\s*shownKeyboardFocus: root\.imagesLoaded \? WlrKeyboardFocus\.Exclusive : WlrKeyboardFocus\.None/.test(imagePickerQml),
'image picker uses OverlayWindow and takes the keyboard once images load'
'image picker parks on OverlayWindow and takes the keyboard once images load'
)
assert(
/model: visibleImages/.test(imagePickerQml) &&
+10 -1
View File
@@ -284,13 +284,22 @@ assert(
'menu always exposes every supported browser, terminal, and editor under Defaults'
)
assert(!defaultById['install.ai.crush'], 'menu removes Crush from Install > AI')
const regionEntries = defaultItems.filter(item => item.parent === 'setup.region')
assert(
regionEntries.map(item => item.label).join('\0') === 'Global\0China'
&& regionEntries.every(item => item.checked.includes(`== \"${item.label}\"`) && !item.when)
&& defaultById['setup.region.global'].action.includes('omarchy-region global')
&& defaultById['setup.region.china'].action.includes('omarchy-region cn'),
'menu offers Global and China under Setup > Region'
)
// Software you already have keeps its place in Install, dimmed rather than
// dropped, so the list reads as a catalog of what Omarchy can install.
// Chromium Account is the sole Install row with anything left to hide for, so
// any other `when:` here is a row that went back to vanishing once installed.
assertDeepEqual(
defaultItems
.filter(item => item.id.startsWith('install.') && item.action && item.when)
// China Apps rows hide by region, not because the software is there.
.filter(item => item.id.startsWith('install.') && item.action && item.when && item.when !== '[[ "$(omarchy-region)" == "China" ]]')
.map(item => item.id),
['install.service.chromium-account'],
'menu never hides an Install row because the software is already there'
+1 -212
View File
@@ -32,7 +32,7 @@ for status in 1 2 3; do
else
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
fi
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" || fail "status must inspect policy without authentication"
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
done
pass "public status distinguishes inactive from errors and revokes authorization on exit"
@@ -164,214 +164,3 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/
[[ ! -e $(rule_file 1000) ]]
)
pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish"
reset_grant
(
source "$library"
valid_duration permanent
! valid_duration forever || fail "invalid duration was accepted"
enable_locked 1000 permanent
read_grant 1000
[[ $GRANT_NAME == audituser && -z $GRANT_DEADLINE ]]
[[ $(stat -c '%a' "$(rule_file 1000 permanent)") == 440 ]]
/usr/sbin/visudo -cf "$(rule_file 1000 permanent)" >/dev/null
! grep -q '^systemd-run ' "$test_tmp/commands" || fail "permanent grant started a timer"
TEST_EXPIRED=1 status_locked 1000
TEST_EXPIRED=1 expire_locked 1000
cleanup_all_locked
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline 'r! /etc/sudoers.d/99-omarchy-nopasswd-*'
[[ -f $(rule_file 1000 permanent) ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 1000
)
pass "permanent access has no timer, survives expiry and temporary cleanup, and can be disabled"
reset_grant
(
source "$library"
enable_locked 1000 15
enable_locked 1000 permanent
[[ ! -e $(rule_file 1000) && -e $(rule_file 1000 permanent) ]]
TEST_EXPIRED=1 expire_locked 1000
[[ -e $(rule_file 1000 permanent) ]]
enable_locked 1000 60
[[ -e $(rule_file 1000) && ! -e $(rule_file 1000 permanent) ]]
TEST_EXPIRED=1 expire_locked 1000
assert_status 3 status_locked 1000
TEST_PUBLISH_FAIL=1 assert_status 1 enable_locked 1000 permanent
[[ ! -e $(rule_file 1000 permanent) ]]
)
pass "switching duration replaces the prior policy and old callbacks preserve permanent access"
reset_grant
(
source "$library"
permanent=$(rule_file 1000 permanent)
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$permanent"
assert_status 2 status_locked 1000
assert_status 1 enable_locked 1000 permanent
assert_status 1 cleanup_uid_locked 1000
[[ -e $permanent ]]
rm "$permanent"
enable_locked 1000 permanent
TEST_BAD_PATH="$permanent" assert_status 2 status_locked 1000
)
pass "permanent policy rejects unsafe ownership and preserves administrator edits"
for choice in '15 minutes' '1 Hour' '1 Day' Permanently; do
case "$choice" in
'15 minutes') expected=15 ;;
'1 Hour') expected=60 ;;
'1 Day') expected=1440 ;;
Permanently) expected=permanent ;;
esac
: >"$test_tmp/commands"
TEST_CHOICE="$choice" TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
grep -q '^gum choose .*15 minutes 1 Hour 1 Day Permanently$' "$test_tmp/commands"
grep -q "^sudo -N -- .* __enable .* $expected$" "$test_tmp/commands"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
if [[ $expected == permanent ]]; then
grep -q 'remain enabled across reboots until you disable it' "$test_tmp/public.log"
! grep -q 'automatically disable' "$test_tmp/public.log" || fail "permanent grant claims automatic expiry"
fi
done
pass "each duration choice dispatches its exact grant duration with permanent-specific copy"
: >"$test_tmp/commands"
assert_status 130 env TEST_CHOICE_CANCEL=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
! grep -q '__enable\|^gum confirm ' "$test_tmp/commands" || fail "cancelled picker continued enablement"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
: >"$test_tmp/commands"
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
! grep -q '^gum ' "$test_tmp/commands" || fail "disabling access offered a picker"
grep -q '__disable ' "$test_tmp/commands"
pass "cancelling the picker grants nothing and active access still toggles off"
for confirm_status in 0 1; do
: >"$test_tmp/commands"
TEST_STATUS=0 TEST_CONFIRM_STATUS=$confirm_status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" permanent >"$test_tmp/public.log" 2>&1
grep -q '^gum confirm Enable passwordless sudo permanently?' "$test_tmp/commands"
if (( confirm_status == 0 )); then
grep -q '__enable .* permanent$' "$test_tmp/commands"
else
! grep -q '__enable ' "$test_tmp/commands" || fail "declining permanent access still enabled it"
fi
done
pass "changing active access to permanent always requires confirmation"
reset_grant
(
source "$library"
enable_locked 1000 15
TEST_REQUIRE_EXISTING_TARGET=1 enable_locked 1000 60
cleanup_uid_locked 1000
enable_locked 01000 permanent
status_locked 1000
[[ -e $(rule_file 1000 permanent) ]]
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-01000 ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 01000
)
pass "renewal replaces the existing rule atomically and zero-padded UIDs share one policy"
for policy in ' Options: !authenticate' ' Options: authenticate'; do
: >"$test_tmp/commands"
expected=1
[[ $policy != *'!authenticate'* ]] || expected=0
assert_status "$expected" env TEST_POLICY="$policy" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
[[ $(wc -l <"$test_tmp/commands") == 1 ]]
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands"
done
assert_status 1 env TEST_POLICY_FAILURE=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
pass "menu probe reads policy tags without executing a privileged action or changing the timestamp"
for status in 0 3; do
: >"$test_tmp/commands"
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "explicit disable offered enablement"
if (( status == 0 )); then
grep -q '__disable ' "$test_tmp/commands"
fi
done
pass "explicit disable never enables access even when a displayed grant has expired"
: >"$test_tmp/commands"
TEST_STATUS=3 TEST_CHOICE='15 minutes' TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "enable must authenticate exactly one sudo call"
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
! grep -q '^sudo -n -N -- .* __status ' "$test_tmp/commands" || fail "inactive flow attempted root status"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
pass "enabling from inactive policy has exactly one password-capable sudo invocation"
: >"$test_tmp/commands"
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" || fail "disable must refuse interactive authentication"
! grep -q '^sudo -N -- ' "$test_tmp/commands" || fail "disable attempted an interactive sudo command"
pass "disabling active access is fully noninteractive"
for duration in permanent 15; do
reset_grant
(
source "$library"
if [[ $duration == permanent ]]; then
enable_locked 1000 15
else
enable_locked 1000 permanent
fi
assert_status 137 env TEST_KILL_AFTER_PUBLISH=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 "$duration"
[[ -f $(rule_file 1000) && -f $(rule_file 1000 permanent) ]]
status_locked 1000
TEST_EXPIRED=1 expire_locked 1000
[[ -f $(rule_file 1000 permanent) ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 1000
)
done
pass "SIGKILL during either duration switch leaves access inspectable and revocable without expiring permanent policy"
reset_grant
(
source "$library"
enable_locked 1000 permanent
timed=$(rule_file 1000)
printf 'otheruser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
assert_status 2 read_grant 1000
assert_status 1 enable_locked 1000 15
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
TEST_BAD_PATH="$timed" assert_status 2 read_grant 1000
rm "$timed"
ln -s "$(rule_file 1000 permanent)" "$timed"
assert_status 2 read_grant 1000
rm "$timed"
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
enable_locked 1000 15
[[ -f $timed && ! -e $(rule_file 1000 permanent) ]]
)
pass "paired policy recovery rejects mismatched or unsafe rules and allows a complete duration change"
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands"
! grep -q '^gum\|^sudo -N -- ' "$test_tmp/commands" || fail "listpw=always disable prompted"
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=1 TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "listing failure must leave one enable authentication"
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
: >"$test_tmp/commands"
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "unsafe grant was offered enablement"
pass "password-required listings do not block enabling, disabling, or active detection"
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
grep -q '^sudo -kn -- .* __status ' "$test_tmp/commands" || fail "fallback probe must ignore cached credentials"
(
source "$library"
with_root_lock() { return 1; }
verify_sudo_caller() { return 0; }
assert_status 2 root_dispatch __status 1000
)
pass "status lock failures remain inspection errors and an authenticated unsafe grant keeps its warning"
+23 -120
View File
@@ -9,122 +9,31 @@ const fs = require('fs')
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
const overlay = read('shell/Ui/OverlayWindow.qml')
// Closing must unmap, not leave a surface on an output that may disappear.
// Closed, the surface parks 1x1, input-less and without keyboard below
// windows: off the overlay layer, so it never blocks direct scanout.
assert(
overlay.includes('visible: shown && !!targetScreen && !!targetMonitor') &&
overlay.includes('anchors { top: true; left: true; bottom: true; right: true }') &&
!overlay.includes('implicitWidth: 1') && !overlay.includes('WlrLayer.Bottom'),
'closed overlays unmap instead of parking a surface'
overlay.includes('visible: true') &&
overlay.includes('anchors { top: true; left: true; bottom: shown; right: shown }') &&
overlay.includes('implicitWidth: 1') &&
overlay.includes('implicitHeight: 1') &&
overlay.includes('mask: shown ? null : emptyRegion') &&
overlay.includes('WlrLayershell.layer: shown ? shownLayer : WlrLayer.Bottom') &&
overlay.includes('WlrLayershell.keyboardFocus: shown ? shownKeyboardFocus : WlrKeyboardFocus.None'),
'overlay window parks a 1x1 input-less surface below windows while hidden'
)
const readyExpression = /readonly property bool contentReady: ([^\n]*(?:\n &&[^\n]*)*)/.exec(overlay)[1]
const ready = new Function('shown', 'backingWindowVisible', 'width', 'height', 'targetScreen', 'targetMonitor', 'devicePixelRatio', `return ${readyExpression}`)
const screen = { name: 'eDP-1', width: 1200, height: 750 }
const monitor = { name: 'eDP-1', scale: 1.6 }
for (const scale of [2, 1, 1.6]) {
assertEqual(ready(true, true, 1200, 750, screen, monitor, scale), scale === 1.6,
`opening frames at ${scale}x wait for the monitor's 1.6x scale`)
}
for (const scale of [1, 1.25, 1.5, 2]) {
assert(ready(true, true, 1200, 750, screen, { scale }, scale),
`overlay reveals at the correct ${scale}x scale`)
}
assert(ready(true, true, 1200, 750, screen, { scale: 1.333333 }, 160 / 120),
'scale comparison tolerates compositor floating point rounding')
assert(!ready(true, true, 500, 500, screen, monitor, 1.6),
'initial window geometry never stretches content across the screen')
assert(!ready(true, false, 1200, 750, screen, monitor, 1.6),
'content waits for a mapped backing window')
assert(!ready(false, true, 1200, 750, screen, monitor, 1.6),
'closed content stays transparent even at the correct scale')
assert(!ready(true, true, 1200, 750, null, monitor, 1.6) &&
!ready(true, true, 1200, 750, screen, null, 1.6),
'content waits for a connected screen and its monitor information')
// Content stays hidden until the surface has grown, so no frame drawn at 1x1
// is stretched across the screen.
assert(
/target: window\.contentItem\s*property: "opacity"\s*value: window\.contentRevealed \? 1 : 0/.test(overlay),
'transparent opening frames preserve immediate keyboard handling'
/target: window\.contentItem\s*property: "visible"\s*value: window\.shown && window\.width > 1 && window\.height > 1/.test(overlay),
'overlay window draws nothing until the surface has grown'
)
const revealBody = /onContentReadyChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1]
const reveal = new Function('window', 'Qt', `with (window) { ${revealBody} }`)
const reveals = []
const revealQt = { callLater(callback) { reveals.push(callback) } }
const content = { contentReady: true, contentRevealed: false }
reveal(content, revealQt)
assert(!content.contentRevealed, 'scale and resize notifications finish before content reveals')
reveals.shift()()
assert(content.contentRevealed, 'settled content reveals on the next event-loop turn')
content.contentReady = false
reveal(content, revealQt)
assert(!content.contentRevealed, 'losing readiness immediately hides content')
content.contentReady = true
reveal(content, revealQt)
content.contentReady = false
reveal(content, revealQt)
reveals.shift()()
assert(!content.contentRevealed, 'a close or screen loss cancels a pending reveal')
const focusedScreenBody = /function focusedScreen\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1]
const focusedScreen = new Function('Hyprland', 'Quickshell', focusedScreenBody)
const external = { name: 'DP-1', width: 1920, height: 1080 }
assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen, external] }), external,
'overlay selects the focused output')
assertEqual(focusedScreen({ focusedMonitor: { name: 'DP-1' } }, { screens: [screen] }), screen,
'disconnecting the focused output falls back to a connected screen')
assertEqual(focusedScreen({ focusedMonitor: null }, { screens: [] }), null,
'no outputs leaves the overlay unmapped')
const shownHandler = /onShownChanged: \{([\s\S]*?)\n \}/.exec(overlay)[1]
let refreshed = 0
const Hyprland = { refreshMonitors() { refreshed++ } }
const changeShown = new Function('shown', 'focusedScreen', 'Hyprland', `let targetScreen = null; ${shownHandler}; return targetScreen`)
assertEqual(changeShown(false, () => external, Hyprland), null, 'closing drops the previous screen reference')
assertEqual(refreshed, 0, 'closing does not refresh monitor data')
assertEqual(changeShown(true, () => screen, Hyprland), screen, 'reopening selects a current screen')
assertEqual(refreshed, 1, 'opening refreshes monitor scales after a live change while closed')
const scaleHandler = /onDevicePixelRatioChanged: ([^\n]+)/.exec(overlay)[1]
const changeScale = new Function('shown', 'Hyprland', scaleHandler)
changeScale(false, Hyprland)
assertEqual(refreshed, 1, 'scale changes on hidden windows do not refresh monitor data')
const changedScreen = { name: 'eDP-1', width: 960, height: 600 }
assert(!ready(true, true, 960, 600, changedScreen, monitor, 2),
'a stale monitor scale keeps content transparent after a live scale change')
Hyprland.refreshMonitors = () => { refreshed++; monitor.scale = 2 }
changeScale(true, Hyprland)
assertEqual(refreshed, 2, 'a showing window refreshes monitor scales when Qt receives a live change')
assert(ready(true, true, 960, 600, changedScreen, monitor, 2),
'fresh monitor data lets content reveal at the new scale')
const screensChangedBody = /function onScreensChanged\(\) \{([\s\S]*?)\n \}/.exec(overlay)[1]
const screensChanged = new Function('window', 'Quickshell', 'Qt', screensChangedBody)
const pending = []
const Qt = { callLater(callback) { pending.push(callback) } }
const window = { shown: true, targetScreen: external, focusedScreen: () => screen }
screensChanged(window, { screens: [screen] }, Qt)
assertEqual(window.targetScreen, null, 'screen removal unmaps before Qt finishes destroying the old window')
pending.shift()()
assertEqual(window.targetScreen, screen, 'a showing overlay moves off a disconnected screen')
window.targetScreen = screen
window.focusedScreen = () => external
screensChanged(window, { screens: [screen, external] }, Qt)
assertEqual(window.targetScreen, screen, 'connecting another screen does not move a showing overlay')
window.shown = false
window.targetScreen = null
screensChanged(window, { screens: [screen, external] }, Qt)
assertEqual(window.targetScreen, null, 'screen changes leave a closed overlay unmapped')
window.shown = true
window.targetScreen = external
screensChanged(window, { screens: [screen] }, Qt)
window.shown = false
pending.shift()()
assertEqual(window.targetScreen, null, 'closing before a deferred remap prevents a stale reopen')
window.shown = true
window.targetScreen = external
screensChanged(window, { screens: [screen] }, Qt)
window.targetScreen = screen
window.focusedScreen = () => external
pending.shift()()
assertEqual(window.targetScreen, screen, 'a deferred remap preserves the screen chosen by a newer open')
assert(
/onShownChanged: if \(shown\) targetScreen = focusedScreen\(\) \|\| targetScreen/.test(overlay) &&
overlay.includes('screen: targetScreen'),
'overlay window follows the focused monitor each time it is shown'
)
const overlays = {
'shell/plugins/menu/Menu.qml': 'shown: root.opened && root.rowsLoaded',
@@ -139,11 +48,11 @@ for (const [file, shown] of Object.entries(overlays)) {
const qml = read(file)
assert(
qml.includes('OverlayWindow {') && qml.includes(shown) && !/PanelWindow \{\s*(id: panel\s*)?visible: root\.opened/.test(qml),
`${file} uses the shared overlay scale and lifecycle handling`
`${file} keeps its surface through OverlayWindow`
)
}
// Layout resets on a requested close, independent of mapping readiness.
// Window visibility never changes now, so close-time resets key off shown.
const menuQml = read('shell/plugins/menu/Menu.qml')
assert(
menuQml.includes('onShownChanged: if (!shown) { cardTop = -1; maxRowsHeight = -1 }') &&
@@ -151,15 +60,9 @@ assert(
!/onVisibleChanged: if \(!visible\) \{ cardTop/.test(menuQml),
'the menu unfreezes its layout when the overlay hides'
)
const screenHandler = /onTargetScreenChanged: \{([^\n]*)\}/.exec(menuQml)[1]
const changeScreen = new Function('panel', `with (panel) { ${screenHandler} }`)
const menuPanel = { shown: true, cardTop: 945, maxRowsHeight: 510 }
changeScreen(menuPanel)
assertEqual(menuPanel.cardTop, -1, 'moving an open menu off a disconnected output unfreezes its old top edge')
assertEqual(menuPanel.maxRowsHeight, -1, 'moving an open menu drops the old output row-height limit')
assert(menuPanel.shown, 'resetting the layout keeps the menu open')
// Fullscreen overlays open without an additional compositor animation.
// A kept surface grows from its parked 1x1 when shown, which Hyprland animates
// as a slide in from the corner unless its layer rule turns animation off.
const shellRules = read('default/hypr/apps/omarchy-shell.lua')
const noAnim = /namespace = "\^\(([^)]*)\)\$" \}, no_anim = true/.exec(shellRules)
assert(noAnim, 'the shell overlays share one no-animation layer rule')
@@ -1,36 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const read = name => fs.readFileSync(path.join(root, name), 'utf8')
const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml')
const widget = read('shell/plugins/bar/widgets/Indicators.qml')
const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json'))
assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray')
assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light')
assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color')
assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states')
assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks')
assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior')
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--active"]'), 'indicator uses the noninteractive grant probe')
assert(indicator.includes('root.granted = exitCode === 0 && exitStatus === 0'), 'failed or interrupted probes do not claim an active grant')
assert(indicator.includes('interval: 5000') && indicator.includes('onTriggered: root.refresh()'), 'indicator refreshes after activation, revocation, and expiry')
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--disable"]'), 'active indicator revokes access through a background process')
assert(indicator.includes('if (root.granted) disableProc.running = true'), 'active click bypasses the terminal launcher')
assert(indicator.includes('root.indicatorHost.refresh()'), 'disabling access refreshes all indicator instances immediately')
const press = new Function('root', 'disableProc', indicator.match(/onPressed: function\(\) \{([\s\S]*?)\n \}/)[1])
const launched = []
const button = { granted: true, bar: { run: command => launched.push(command) } }
const revoke = { running: false }
press(button, revoke)
assert(revoke.running && launched.length === 0, 'clicking active sudo starts background revocation without a terminal')
button.granted = false
press(button, revoke)
assert(launched.length === 0, 'repeat clicks during revocation cannot open the enable flow')
revoke.running = false
press(button, revoke)
assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup')
JS
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
export OMARCHY_PATH="$ROOT"
export TARGET_ROOT="$scratch/target root"
mkdir -p "$TARGET_ROOT/etc/omarchy"
apply() { "$ROOT/bin/omarchy-apply-pacman" "$1" "$TARGET_ROOT"; }
for channel in stable rc edge; do
apply "$channel"
cmp "$ROOT/default/pacman/pacman-$channel.conf" "$TARGET_ROOT/etc/pacman.conf"
cmp "$ROOT/default/pacman/mirrorlist-$channel" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
done
pass "missing region preserves all global channel templates byte for byte"
chmod 600 "$TARGET_ROOT/etc/pacman.conf"
apply stable
[[ $(stat -c %a "$TARGET_ROOT/etc/pacman.conf") == 600 ]] || fail "refresh keeps a customized pacman.conf mode"
chmod 644 "$TARGET_ROOT/etc/pacman.conf"
pass "refresh keeps existing config file modes"
printf 'cn\n' > "$TARGET_ROOT/etc/omarchy/region"
for channel in stable rc edge; do
case "$channel" in
stable) host=stable-mirror.omarchy ;;
rc) host=rc-mirror.omarchy ;;
edge) host=mirror.omarchy ;;
esac
apply "$channel"
expected=$(printf 'Server = https://%s.cn/$repo/os/$arch\nServer = https://%s.org/$repo/os/$arch' "$host" "$host")
[[ $(<"$TARGET_ROOT/etc/pacman.d/mirrorlist") == "$expected" ]] || fail "$channel mirror order and variables"
{
cat "$ROOT/default/pacman/pacman-$channel.conf"
printf '\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n'
} > "$scratch/expected.conf"
cmp "$scratch/expected.conf" "$TARGET_ROOT/etc/pacman.conf"
apply "$channel"
cmp "$TARGET_ROOT/etc/pacman.conf.bak" "$TARGET_ROOT/etc/pacman.conf"
cmp "$TARGET_ROOT/etc/pacman.d/mirrorlist.bak" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
done
pass "China puts its channel mirror first, keeps the global one as fallback, and is idempotent"
cp "$TARGET_ROOT/etc/pacman.conf" "$scratch/before.conf"
cp "$TARGET_ROOT/etc/pacman.d/mirrorlist" "$scratch/before.mirrors"
for region in '' CN chn zz '../cn' '$(touch NEVER_EXECUTE)'; do
printf '%s\n' "$region" > "$TARGET_ROOT/etc/omarchy/region"
if apply stable > "$scratch/error" 2>&1; then
fail "invalid region accepted: $region"
fi
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
cmp "$scratch/before.mirrors" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
done
pass "unsupported and malformed regions fail before replacing either config"
printf 'cn\n' > "$TARGET_ROOT/etc/omarchy/region"
mkdir -p "$scratch/incomplete/default/regions/cn/pacman"
cp -a "$ROOT/default/pacman" "$scratch/incomplete/default/"
cp "$ROOT/default/regions/cn/pacman/pacman.conf.append" "$scratch/incomplete/default/regions/cn/pacman/"
if OMARCHY_PATH="$scratch/incomplete" apply stable > "$scratch/error" 2>&1; then
fail "incomplete region profile accepted"
fi
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
cmp "$scratch/before.mirrors" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
pass "missing fragment cannot leave a half-generated config"
# Refresh renders these files unprivileged and hands root plain copies; its
# privileged path is covered in the sudo-boundary sandbox, never on the host.
cp "$TARGET_ROOT/etc/pacman.conf" "$scratch/before.conf"
mkdir -p "$scratch/rendered"
"$ROOT/bin/omarchy-apply-pacman" --render rc "$scratch/rendered" "$TARGET_ROOT"
rg -qxF 'Server = https://pkgs.omarchy.org/rc/$arch' "$scratch/rendered/pacman.conf"
rg -qxF '[archlinuxcn]' "$scratch/rendered/pacman.conf"
[[ $(<"$scratch/rendered/mirrorlist") == $'Server = https://rc-mirror.omarchy.cn/$repo/os/$arch\nServer = https://rc-mirror.omarchy.org/$repo/os/$arch' ]] ||
fail "render puts the regional channel mirror first"
cmp "$scratch/before.conf" "$TARGET_ROOT/etc/pacman.conf"
pass "render builds the regional channel files without touching the target"
if "$ROOT/bin/omarchy-apply-pacman" --render dev "$scratch/rendered" "$TARGET_ROOT" > "$scratch/error" 2>&1; then
fail "render accepts a nonexistent dev pacman template"
fi
pass "render rejects channels without pacman templates"
printf 'global\n' > "$TARGET_ROOT/etc/omarchy/region"
apply stable
cmp "$ROOT/default/pacman/pacman-stable.conf" "$TARGET_ROOT/etc/pacman.conf"
cmp "$ROOT/default/pacman/mirrorlist-stable" "$TARGET_ROOT/etc/pacman.d/mirrorlist"
pass "users can opt out of regional repository defaults"
# The shipped China profile is repository defaults only. Language, locale, and
# input methods follow the installer's language choice, not the region.
python3 - <<'PY'
import os
from pathlib import Path
region = Path(os.environ["ROOT"]) / "default/regions/cn"
packages = {line.strip() for line in (region / "packages").read_text().splitlines() if line.strip() and not line.startswith("#")}
assert packages == {"archlinuxcn-keyring"}, packages
assert not (region / "skel").exists()
PY
pass "China profile adds only its repository keyring"
# The installer derives the region from the chosen timezone, so every profile
# needs a mapping, and no timezone may select two regions.
python3 - <<'PY'
import os
from pathlib import Path
zoneinfo = Path("/usr/share/zoneinfo")
owners = {}
for profile in sorted((Path(os.environ["ROOT"]) / "default/regions").iterdir()):
lines = (profile / "timezones").read_text().splitlines()
zones = [line.strip() for line in lines if line.strip() and not line.startswith("#")]
assert zones, f"{profile.name} maps no timezones"
for zone in zones:
assert zone not in owners, f"{zone} maps to {owners[zone]} and {profile.name}"
owners[zone] = profile.name
if zoneinfo.is_dir():
assert (zoneinfo / zone).is_file(), f"{profile.name} lists unknown timezone {zone}"
# The installer offers tzdata's backward-compatible names too, so China
# covers every name that links to its two zones and nothing else.
# Without tzdata.zi (minimal containers) only the canonical zones can be
# checked, so completeness is enforced where the host has the link table.
china = {zone for zone, region in owners.items() if region == "cn"}
expected = {"Asia/Shanghai", "Asia/Urumqi"}
tzdata = zoneinfo / "tzdata.zi"
if tzdata.is_file():
for line in tzdata.read_text().splitlines():
fields = line.split()
if len(fields) == 3 and fields[0] == "L" and fields[1] in {"Asia/Shanghai", "Asia/Urumqi"}:
expected.add(fields[2])
assert china == expected, (china, expected)
else:
assert expected <= china, (china, expected)
PY
pass "region profiles map each timezone to at most one region"
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-refresh-pacman
copy_boundary_file bin/omarchy-apply-pacman
for file in default/pacman/pacman-rc.conf default/pacman/mirrorlist-rc \
default/regions/cn/pacman/pacman.conf.append default/regions/cn/pacman/mirrorlist-rc.prepend; do
copy_boundary_file "$file"
done
# A sandboxed China machine: the render reads this region marker, never the
# host's /etc/omarchy/region.
mkdir -p "$SUDO_TEST_TARGET/etc/omarchy"
printf 'cn\n' >"$SUDO_TEST_TARGET/etc/omarchy/region"
# cp still logs its step, but now really copies, so the rendered files exist.
# Root's copies into /etc land in a capture directory instead of the host.
export SUDO_TEST_CAPTURE="$boundary_tmp/capture"
rm "$SUDO_TEST_ROOT/bin/cp"
cat >"$SUDO_TEST_ROOT/bin/cp" <<'STUB'
#!/bin/bash
set -euo pipefail
printf 'step:cp %s\n' "$*" >>"$SUDO_TEST_LOG"
destination=${!#}
if [[ $destination == /etc/* ]]; then
source=${*: -2:1}
[[ $source == /etc/* ]] && exit 0
/usr/bin/mkdir -p "$SUDO_TEST_CAPTURE${destination%/*}"
exec /usr/bin/cp "$source" "$SUDO_TEST_CAPTURE$destination"
fi
exec /usr/bin/cp "$@"
STUB
chmod +x "$SUDO_TEST_ROOT/bin/cp"
# Regional rendering must stay unprivileged: root only copies finished files,
# exactly as the channel refresh did before regions existed.
reset_boundary
"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" rc >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")"
assert_boundary_cold "regional refresh"
python3 - "$SUDO_TEST_LOG" "$SUDO_TEST_ROOT" <<'PY'
import re
import sys
events = open(sys.argv[1]).read().splitlines()
root = sys.argv[2]
render = [i for i, e in enumerate(events) if e.startswith('step:cp ' + root + '/default/pacman/pacman-rc.conf ')]
assert len(render) == 1, events
rendered = events[render[0]].split()[-1].rsplit('/', 1)[0]
copies = [i for i, e in enumerate(events) if e.startswith('sudo -N cp ')]
assert [events[i] for i in copies[2:]] == [
f'sudo -N cp -f {rendered}/pacman.conf /etc/pacman.conf',
f'sudo -N cp -f {rendered}/mirrorlist /etc/pacman.d/mirrorlist',
], events
assert not any(e.startswith('sudo ') and 'omarchy-apply-pacman' in e for e in events), events
assert render[0] < copies[0], events
transaction = next(i for i, e in enumerate(events) if e.startswith('step:pacman '))
assert max(copies) < transaction, events
PY
{
cat "$ROOT/default/pacman/pacman-rc.conf"
printf '\n'
cat "$ROOT/default/regions/cn/pacman/pacman.conf.append"
} >"$boundary_tmp/expected-pacman.conf"
cat "$ROOT/default/regions/cn/pacman/mirrorlist-rc.prepend" "$ROOT/default/pacman/mirrorlist-rc" >"$boundary_tmp/expected-mirrorlist"
cmp -s "$boundary_tmp/expected-pacman.conf" "$SUDO_TEST_CAPTURE/etc/pacman.conf" ||
fail "root installs the rendered China pacman.conf" "$(diff "$boundary_tmp/expected-pacman.conf" "$SUDO_TEST_CAPTURE/etc/pacman.conf" 2>&1)"
cmp -s "$boundary_tmp/expected-mirrorlist" "$SUDO_TEST_CAPTURE/etc/pacman.d/mirrorlist" ||
fail "root installs the rendered China mirrorlist" "$(diff "$boundary_tmp/expected-mirrorlist" "$SUDO_TEST_CAPTURE/etc/pacman.d/mirrorlist" 2>&1)"
pass "refresh renders regional config unprivileged and gives root only plain copies"
reset_boundary
if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" dev >"$boundary_tmp/output" 2>&1; then
fail "refresh accepts an invalid channel"
fi
if grep -Eq '^(sudo -N cp |step:pacman )' "$SUDO_TEST_LOG"; then
fail "invalid channel reached privileged work" "$(<"$SUDO_TEST_LOG")"
fi
pass "failed refresh does not proceed to privileged copies or a package update"
# A failed render still removes its scratch directory and exits cold.
reset_boundary
mv "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman" "$boundary_tmp/omarchy-apply-pacman"
cat >"$SUDO_TEST_ROOT/bin/omarchy-apply-pacman" <<'STUB'
#!/bin/bash
: >"$3/pacman.conf"
exit 1
STUB
chmod +x "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman"
mkdir -p "$boundary_tmp/scratch"
if TMPDIR="$boundary_tmp/scratch" "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" rc >"$boundary_tmp/output" 2>&1; then
fail "refresh succeeds after a failed render"
fi
mv "$boundary_tmp/omarchy-apply-pacman" "$SUDO_TEST_ROOT/bin/omarchy-apply-pacman"
[[ -z $(ls -A "$boundary_tmp/scratch") ]] || fail "failed render leaves its scratch directory" "$(ls -AR "$boundary_tmp/scratch")"
if grep -Eq '^(sudo -N cp |step:pacman )' "$SUDO_TEST_LOG"; then
fail "failed render reached privileged work" "$(<"$SUDO_TEST_LOG")"
fi
assert_boundary_cold "failed render"
pass "failed render removes its scratch directory and exits cold"
+76
View File
@@ -0,0 +1,76 @@
#!/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
root="$test_tmp/root" sandbox="$test_tmp/omarchy" calls="$test_tmp/calls"
mkdir -p "$root/etc/pacman.d" "$sandbox/bin" "$sandbox/install/config" "$sandbox/default/regions" "$test_tmp/bin" "$test_tmp/home"
cp -r "$ROOT/default/regions/cn" "$sandbox/default/regions/"
rewrite() { sed -e "s|/etc/|$root/etc/|g" -e "s| /\\.cargo/| $root/.cargo/|g" "$1" >"$2"; chmod +x "$2"; }
rewrite "$ROOT/install/config/region.sh" "$sandbox/install/config/region.sh"
rewrite "$ROOT/bin/omarchy-region" "$sandbox/bin/omarchy-region"
for command in omarchy-refresh-pacman omarchy-system-logout pacman-key pacman omarchy-apply-pacman; do
printf '#!/bin/bash\necho "%s $*" >>"%s"\n' "$command" "$calls" >"$test_tmp/bin/$command"
done
printf '#!/bin/bash\necho "omarchy-update-pacman $*" >>"%s"\n[[ -z ${STUB_UPDATE_FAIL:-} ]]\n' "$calls" >"$test_tmp/bin/omarchy-update-pacman"
printf '#!/bin/bash\necho "${STUB_CHANNEL:-stable}"\n' >"$test_tmp/bin/omarchy-version-channel"
printf '#!/bin/bash\n[[ $1 == env ]] && shift\nexec env "$@"\n' >"$test_tmp/bin/sudo"
printf '#!/bin/bash\necho "gum $*" >>"%s"\nexit "${GUM_ANSWER:-1}"\n' "$calls" >"$test_tmp/bin/gum"
chmod +x "$test_tmp/bin"/*
mkdir -p "$test_tmp/tmp"
export PATH="$test_tmp/bin:$sandbox/bin:$PATH" OMARCHY_PATH="$sandbox" HOME="$test_tmp/home" TMPDIR="$test_tmp/tmp"
switch_calls() { sed "s|$test_tmp/tmp/[^/ ]*|RENDERED|g" "$calls"; }
omarchy-region cn >/dev/null
[[ $(omarchy-region) == "China" && $(<"$root/etc/omarchy/region") == "cn" ]] ||
fail "switching to China records cn"
[[ $(switch_calls) == $'omarchy-update-pacman -Syu --noconfirm\nomarchy-apply-pacman --render stable RENDERED\npacman --config RENDERED/pacman.conf -Sy --noconfirm --needed archlinuxcn-keyring\npacman-key --populate archlinuxcn\nomarchy-refresh-pacman stable' ]] ||
fail "China upgrades, trusts ArchLinuxCN through the rendered cn config, then refreshes" "$(switch_calls)"
[[ -z $(ls -A "$test_tmp/tmp") ]] || fail "the rendered config is removed"
pass "switching to China trusts ArchLinuxCN before the refresh upgrades through it"
: >"$calls"
STUB_CHANNEL=edge omarchy-region cn >/dev/null
grep -qx 'omarchy-refresh-pacman edge' "$calls" || fail "the refresh keeps the machine's channel" "$(<"$calls")"
pass "switching to China again keeps the channel"
: >"$calls"
omarchy-region global >/dev/null
[[ $(omarchy-region) == "Global" && ! -e $root/etc/omarchy/region ]] ||
fail "switching to Global removes the region"
[[ $(<"$calls") == 'omarchy-refresh-pacman stable' ]] ||
fail "Global refreshes pacman back to the channel defaults" "$(<"$calls")"
pass "switching to Global restores the channel's pacman defaults"
if output=$(omarchy-region mars 2>&1); then
fail "an unknown region is refused" "$output"
fi
if output=$(STUB_CHANNEL="stable / edge" omarchy-region cn 2>&1); then
fail "a mixed channel is refused" "$output"
fi
[[ $(omarchy-region) == "Global" ]] || fail "a refused switch changes nothing"
pass "an unknown region or an unclear channel is refused"
: >"$calls"
if output=$(STUB_UPDATE_FAIL=1 omarchy-region cn 2>&1); then
fail "switching to China fails when the upgrade fails" "$output"
fi
[[ $(omarchy-region) == "Global" ]] && ! grep -q '^omarchy-refresh-pacman' "$calls" ||
fail "a failed switch to China leaves the region Global and pacman untouched" "$(<"$calls")"
pass "a failed switch to China leaves the machine Global"
grep -q '^gum' "$calls" && fail "a switch without a terminal asks nothing" "$(<"$calls")"
: >"$calls"
output=$(GUM_ANSWER=1 script -qec "omarchy-region cn" /dev/null)
[[ $output == *"Log back in to switch the developer mirrors."* ]] && ! grep -q '^omarchy-system-logout' "$calls" ||
fail "declining the logout leaves a reminder and the session" "$output"
: >"$calls"
GUM_ANSWER=0 script -qec "omarchy-region global" /dev/null >/dev/null
grep -qxF 'gum confirm --affirmative Log out now --negative Later Log out now to switch the developer mirrors?' "$calls" &&
grep -qx 'omarchy-system-logout ' "$calls" || fail "a terminal switch offers to log out and logs out on yes" "$(<"$calls")"
pass "a switch in a terminal offers to log out"
+75
View File
@@ -0,0 +1,75 @@
#!/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
region_setup="$test_tmp/region.sh"
root="$test_tmp/root"
sed -e "s|/etc/|$root/etc/|g" -e "s| /\.cargo/| $root/.cargo/|g" "$ROOT/install/config/region.sh" >"$region_setup"
export OMARCHY_PATH="$ROOT"
mkdir -p "$root/etc/omarchy"
bash -e "$region_setup"
[[ -z $(find "$root" -type l) ]] || fail "no marker links nothing"
pass "no marker installs Global"
echo cn >"$root/etc/omarchy/region"
bash -e "$region_setup"
for link in "environment.conf etc/environment.d/90-omarchy-china.conf" "cargo.toml .cargo/config.toml" "gemrc etc/gemrc"; do
read -r source target <<<"$link"
[[ $(readlink "$root/$target") == "$ROOT/default/regions/cn/$source" ]] || fail "China links /$target to the shipped $source"
done
bash -e "$region_setup"
pass "the cn marker links the developer mirror drop-ins, again without error"
rm "$root/etc/gemrc"
echo ":sources: []" >"$root/etc/gemrc"
if output=$(bash -e "$region_setup" 2>&1); then
fail "China refuses to replace an admin's /etc/gemrc" "$output"
fi
[[ $output == "Refusing to replace $root/etc/gemrc, which Omarchy did not create" && $(<"$root/etc/gemrc") == ":sources: []" ]] ||
fail "China names the file it refuses to replace and leaves it" "$output"
rm "$root/etc/gemrc"
pass "region setup never replaces mirror config Omarchy did not create"
echo mars >"$root/etc/omarchy/region"
if output=$(bash -e "$region_setup" 2>&1); then
fail "an unknown region fails" "$output"
fi
[[ $output == "Unknown region: mars" ]] || fail "region setup names the unknown region" "$output"
pass "region setup rejects an unknown region"
echo cn >"$root/etc/omarchy/region"
bash -e "$region_setup"
OMARCHY_REGION=global bash -e "$region_setup"
[[ ! -e $root/etc/omarchy/region && -z $(find "$root" -type l) ]] || fail "Global removes the marker and the developer mirror links"
ln -s /usr/share/somewhere/gemrc "$root/etc/gemrc"
OMARCHY_REGION=global bash -e "$region_setup"
[[ -L $root/etc/gemrc ]] || fail "Global keeps a gemrc link Omarchy did not create"
rm "$root/etc/gemrc"
pass "switching to Global removes only what Omarchy linked"
OMARCHY_REGION=cn bash -e "$region_setup"
[[ $(<"$root/etc/omarchy/region") == "cn" && $(readlink "$root/etc/gemrc") == "$ROOT/default/regions/cn/gemrc" ]] ||
fail "switching to China records cn and links the drop-ins"
pass "switching to China records cn and links the drop-ins"
region_bin="$test_tmp/omarchy-region"
sed "s|/etc/omarchy/region|$root/etc/omarchy/region|" "$ROOT/bin/omarchy-region" >"$region_bin"
chmod +x "$region_bin"
[[ $("$region_bin") == "China" ]] || fail "the cn marker reads as China"
rm "$root/etc/omarchy/region"
[[ $("$region_bin") == "Global" ]] || fail "no marker reads as Global"
echo mars >"$root/etc/omarchy/region"
if output=$("$region_bin" 2>&1); then
fail "an unknown marker fails" "$output"
fi
[[ $output == "Unknown region in $root/etc/omarchy/region: mars" ]] || fail "the reader names the file and value" "$output"
pass "omarchy region reads Global, China, and fails on anything else"
grep -qx 'run_logged "$OMARCHY_INSTALL/config/region.sh"' "$ROOT/install/config/all.sh" || fail "the install runs region setup"
pass "the install runs region setup"
@@ -6,6 +6,7 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh"
copy_boundary_file bin/omarchy-update
copy_boundary_file bin/omarchy-refresh-pacman
copy_boundary_file bin/omarchy-apply-pacman
# Replace the step symlink, preserving the real fixture dispatcher.
rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs"
copy_boundary_file bin/omarchy-update-aur-pkgs
+3 -25
View File
@@ -23,7 +23,7 @@ SH
}
run_orphan_checker() {
HOME="$test_home" PATH="$stub_bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs" "$@"
HOME="$test_home" PATH="$stub_bin:$PATH" "$ROOT/bin/omarchy-update-orphan-pkgs"
}
write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then printf "old-lib\nunused-tool\n"; exit 0; fi; exit 1'
@@ -35,29 +35,7 @@ grep -q '^ old-lib$' "$test_tmp/noninteractive.out" || fail "orphan checker lis
grep -q 'Re-run omarchy-update-orphan-pkgs in a terminal' "$test_tmp/noninteractive.out" || fail "orphan checker does not remove packages non-interactively"
pass "orphan checker only reports orphans non-interactively"
export REMOVAL_LOG="$test_tmp/removal.log"
write_stub pacman 'case $1 in
-Qtdq|-Qq) printf "old-lib\nunused-tool\n" ;;
-Rns) printf "%s\n" "$@" >"$REMOVAL_LOG" ;;
*) exit 1 ;;
esac'
write_stub sudo '"$@"'
run_orphan_checker -y >"$test_tmp/approved.out" 2>"$test_tmp/approved.err"
diff <(printf '%s\n' -Rns --noconfirm old-lib unused-tool) "$REMOVAL_LOG" ||
fail "approved cleanup removes the orphan packages without pacman confirmation"
pass "approved cleanup removes orphans without Omarchy or pacman confirmation"
write_stub sudo 'exit 42'
if run_orphan_checker -y >"$test_tmp/failure.out" 2>"$test_tmp/failure.err"; then
fail "approved cleanup reports removal failures"
fi
pass "approved cleanup reports removal failures"
rm -f "$REMOVAL_LOG"
write_stub pacman 'if [[ $1 == "-Qtdq" ]]; then exit 0; fi; exit 1'
for mode in "" -y; do
run_orphan_checker $mode >"$test_tmp/none.out" 2>"$test_tmp/none.err"
[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist"
done
[[ ! -e $REMOVAL_LOG ]] || fail "orphan checker does not remove packages when no orphans exist"
run_orphan_checker >"$test_tmp/none.out" 2>"$test_tmp/none.err"
[[ ! -s $test_tmp/none.out ]] || fail "orphan checker stays quiet when no orphans exist"
pass "orphan checker stays quiet without orphans"
-7
View File
@@ -35,9 +35,6 @@ for step in "${steps[@]}"; do
cat >"$stub_bin/$step" <<'STUB'
#!/bin/bash
printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG"
if [[ ${0##*/} == "omarchy-update-orphan-pkgs" ]]; then
printf '%s\n' "$@" >"$STEP_LOG.orphan-args"
fi
[[ ${FAILING_STEP:-} != "${0##*/}" ]] || exit 1
STUB
chmod +x "$stub_bin/$step"
@@ -87,8 +84,6 @@ run_update -y || fail "an update where everything works reports a failure"
diff <(expected_steps) <(steps_run) >"$test_tmp/order" ||
fail "an update where everything works does not run every step in order" "$(cat "$test_tmp/order")"
pass "an update where every step works runs all of them, in order"
[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "an unattended update approves orphan removal"
pass "an unattended update approves orphan removal"
grep -q '^omarchy-update-system-pkgs unattended=1$' "$test_tmp/steps" ||
fail "-y does not mark the update unattended"
@@ -98,8 +93,6 @@ diff <(expected_steps confirmed) <(steps_run) >"$test_tmp/order" ||
grep -q '^omarchy-update-system-pkgs unattended=$' "$test_tmp/steps" ||
fail "an update a person confirmed is treated as unattended"
pass "-y is what marks an update unattended, not the update itself"
[[ $(cat "$test_tmp/steps.orphan-args") == "-y" ]] || fail "a confirmed update approves orphan removal"
pass "a confirmed update approves orphan removal"
# Migrations ship with the packages the upgrade installs and are written against
# them. Running them against what is still on disk is the failure this ordering
-127
View File
@@ -1,127 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
leaf="$ROOT/install/hardware/dell-xps13-ptl-speaker-firmware.sh"
migration="$ROOT/migrations/1791265613.sh"
grep -q 'run_logged .*hardware/dell-xps13-ptl-speaker-firmware.sh' "$ROOT/install/hardware/all.sh" ||
fail "hardware setup installs the Panther Lake XPS 13 firmware"
pass "hardware setup installs the Panther Lake XPS 13 firmware"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mkdir -p "$test_tmp/bin"
cat >"$test_tmp/bin/omarchy-hw-match" <<'SH'
#!/bin/bash
[[ $TEST_PRODUCT_NAME == *"$1"* ]]
SH
cat >"$test_tmp/bin/omarchy-hw-intel-ptl" <<'SH'
#!/bin/bash
[[ $TEST_INTEL_PTL == "1" ]]
SH
cat >"$test_tmp/bin/sudo" <<'SH'
#!/bin/bash
if [[ $1 == "pacman" ]]; then
exec "$@"
fi
printf 'Unexpected privileged command: %s\n' "$*" >&2
exit 1
SH
cat >"$test_tmp/bin/pacman" <<'SH'
#!/bin/bash
if [[ $1 == "-Q" ]]; then
if [[ ${@: -1} == "linux-firmware-cirrus-dx13260" ]]; then
[[ -e $TEST_ALIAS_FILE ]] || exit 1
printf 'linux-firmware-cirrus-dx13260 20260810-1\n'
else
[[ -s $TEST_VERSION_FILE ]] || exit 1
printf 'linux-firmware-cirrus %s\n' "$(<"$TEST_VERSION_FILE")"
fi
else
printf 'pacman %s\n' "$*" >>"$TEST_LOG"
[[ $* == "-S --noconfirm --needed -- linux-firmware-cirrus-dx13260" ]] || exit 1
[[ ${TEST_INSTALL_FAILURE:-0} == "0" ]] || exit 1
touch "$TEST_ALIAS_FILE"
fi
SH
cat >"$test_tmp/bin/omarchy-state" <<'SH'
#!/bin/bash
printf 'state %s\n' "$*" >>"$TEST_LOG"
SH
chmod +x "$test_tmp/bin/"*
export PATH="$test_tmp/bin:$ROOT/bin:$PATH"
export TEST_LOG="$test_tmp/calls" TEST_VERSION_FILE="$test_tmp/version" TEST_ALIAS_FILE="$test_tmp/alias-installed"
export TEST_PRODUCT_NAME="XPS 13 DX13260" TEST_INTEL_PTL=1
install_call="pacman -S --noconfirm --needed -- linux-firmware-cirrus-dx13260"
export OMARCHY_PATH="$ROOT"
reset_fixture() {
: >"$TEST_LOG"
printf '%s' "$1" >"$TEST_VERSION_FILE"
rm -f "$TEST_ALIAS_FILE"
}
run_leaf() {
bash -eE -c 'source "$1"' bash "$leaf"
}
run_migration() {
bash -euo pipefail "$migration" >/dev/null
}
for model in "XPS 9350" "XPS 13 DX13261"; do
reset_fixture "20260810-2"
TEST_PRODUCT_NAME="$model" run_leaf
TEST_PRODUCT_NAME="$model" run_migration
[[ ! -s $TEST_LOG ]] || fail "other models receive no firmware repair"
done
reset_fixture "20260810-2"
TEST_INTEL_PTL=0 run_leaf
TEST_INTEL_PTL=0 run_migration
[[ ! -s $TEST_LOG ]] || fail "the Wildcat Lake variant receives no firmware repair"
pass "other models and the Wildcat Lake variant receive no firmware repair"
for version in "" "20260810-2" "20260810-3" "20260810-4" "20260910-2" "20260916-1" "1:20260810-2"; do
reset_fixture "$version"
run_leaf || fail "the alias package is installed during hardware setup"
[[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "$version" && -e $TEST_ALIAS_FILE ]] ||
fail "hardware setup installs the separate aliases without replacing stock firmware"
done
pass "hardware setup installs the package identity without replacing any stock firmware"
pass "hardware setup leaves reboot bookkeeping to the migration"
reset_fixture "20260810-2"
touch "$TEST_ALIAS_FILE"
run_leaf
[[ ! -s $TEST_LOG ]] || fail "installed aliases are idempotent on stock firmware"
pass "installed aliases are idempotent on stock firmware"
run_migration
[[ $(<"$TEST_LOG") == "state set reboot-required" ]] ||
fail "the migration requests a reboot even if hardware setup already installed the aliases"
pass "the migration requests a reboot even if hardware setup already installed the aliases"
reset_fixture "20260810-2"
if TEST_INSTALL_FAILURE=1 run_migration; then
fail "a failed firmware installation fails the migration"
fi
[[ $(<"$TEST_LOG") == "$install_call" && $(<"$TEST_VERSION_FILE") == "20260810-2" ]] ||
fail "a failed installation leaves the migration retryable without claiming success"
pass "a failed firmware installation fails the migration and remains retryable"
: >"$TEST_LOG"
run_migration
[[ $(<"$TEST_LOG") == "$install_call"$'\nstate set reboot-required' ]] ||
fail "retrying the migration installs the firmware and requests a reboot"
pass "retrying the migration installs the firmware and requests a reboot"
: >"$TEST_LOG"
run_migration
[[ $(<"$TEST_LOG") == "state set reboot-required" ]] ||
fail "a second user before reboot is prompted without reinstalling"
pass "a second user before reboot is prompted without reinstalling"