Compare commits

..
Author SHA1 Message Date
雷电芽衣 e6cb58c4c3 Keep lazydocker: Super+Shift+D launches it
Claude-Session: https://claude.ai/code/session_019krxFwnSbEM2n2QNQ1rG1K
2026-09-03 06:27:42 -04:00
雷电芽衣 5742a2841f Fit the appliance under GitHub's 2 GiB release limit
The qcow2 is written with zstd and 2M clusters after trimming freed blocks,
and the appliance leaves out clang, llvm, the Docker plugins, lazydocker,
base-devel, aether, and yay, plus usr/share/doc through NoExtract: 2.78 GB
became 2.10 GB. sudo is installed explicitly now that base-devel no longer
pulls it in.

Claude-Session: https://claude.ai/code/session_019krxFwnSbEM2n2QNQ1rG1K
2026-09-03 06:09:55 -04:00
雷电芽衣 70cbcb558c Build the Omarchy appliance for UTM SE on iPad and iPhone
omarchy-utm-make runs builder/build-utm.sh in a privileged linux/arm64
container: a 32G sparse GPT disk with the ISO's Btrfs subvolume layout,
Arch Linux ARM plus the aarch64 Omarchy packages, Limine on the UEFI
fallback path, omarchy-apply-system --defer-provisioning in the chroot,
first-boot provisioning staged with the arm64 Node tarball, one
limine-update, then a sealed and checked disk converted to compressed
qcow2. The host wraps it as a .utm bundle (no serial device: iOS has no
Ptty mode), zips it, and writes a sha256. omarchy-utm-boot boots the
result under qemu's interpreter, the closest stand-in for UTM SE.

fstab is written from the partition UUIDs directly: genfstab named the
hand-made loop device nodes, which sent the first boot into emergency
mode.

Claude-Session: https://claude.ai/code/session_019krxFwnSbEM2n2QNQ1rG1K
2026-09-03 04:50:14 -04:00
40 changed files with 859 additions and 2107 deletions

No files matched your search

-118
View File
@@ -1,118 +0,0 @@
name: Install test comment
# Posts the install test's numbers on the pull request as one comment, kept
# up to date on every run, next to quattro's latest numbers. GitHub runs
# workflow_run workflows from the default branch, with write access even for
# pull requests from forks, so this job never checks out or runs pull request
# code: it only reads the finished run's artifact, as data.
on:
workflow_run:
workflows: ["Install test"]
types: [completed]
permissions:
actions: read
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
steps:
# A rerun keeps earlier attempts' artifacts under the same run, so take
# exactly the attempt that finished (and the baseline's latest attempt).
- name: Fetch this run's numbers and quattro's latest
env:
ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
run: |
gh run download "$RUN_ID" -R "$REPO" -n "install-test-$RUN_ID-$ATTEMPT" -D pr || true
base=$(gh run list -R "$REPO" --workflow install-test.yml --branch quattro --event push \
--status success -L 1 --json databaseId -q '.[0].databaseId')
if [ -n "$base" ]; then
base_attempt=$(gh api "repos/$REPO/actions/runs/$base" -q .run_attempt)
gh run download "$base" -R "$REPO" -n "install-test-$base-$base_attempt" -D base || true
fi
- name: Comment on the pull request
run: |
set -euo pipefail
marker='<!-- omarchy-install-test -->'
# The number comes from the pull request's own run, so trust it only
# when that pull request's head is the commit this run tested.
pr=$({ find pr -name pr-number -exec cat {} + 2>/dev/null || true; } | head -1 | tr -dc 0-9)
[ -n "$pr" ] || { echo "No pull request number in the artifact"; exit 0; }
[ "$(gh api "repos/$REPO/pulls/$pr" -q .head.sha)" = "$HEAD_SHA" ] ||
{ echo "PR #$pr has moved past $HEAD_SHA; not commenting"; exit 0; }
# Every lookup prints nothing rather than failing, so a missing baseline (none
# yet, or expired) shows as "–" instead of ending the script under set -e.
timing() { { find "$1" -name omarchy-install-timing.json 2>/dev/null || true; } | head -1; }
install_s() { [ -n "$1" ] || return 0; jq -r '.finished_at - .started_at' "$1" 2>/dev/null || true; }
# "Startup finished in … = 1min 2.3s" or "= 12.104s" -> seconds
boot_s() {
local f
f=$({ find "$1" -path "$2" 2>/dev/null || true; } | head -1)
[ -n "$f" ] || return 0
head -1 "$f" | sed -n 's/.*= *//p' |
awk '{t=0; for (i=1;i<=NF;i++) { if ($i ~ /min$/) t+=60*$i; else if ($i ~ /ms$/) t+=$i/1000; else t+=$i } printf "%.3f", t}'
}
cell() { [ -n "$1" ] && awk -v x="$1" 'BEGIN{printf "%.1f s", x}' || echo "–"; }
change() {
[ -n "$1" ] && [ -n "$2" ] || { echo "–"; return; }
awk -v a="$1" -v b="$2" 'BEGIN{d=a-b; printf "%s%.1f s", (d>=0?"+":"−"), (d<0?-d:d)}'
}
pt=$(timing pr); bt=$(timing base)
rows=(
"Install|$(install_s "$pt")|$(install_s "$bt")"
"First boot|$(boot_s pr '*/first-boot-systemd-analyze.txt')|$(boot_s base '*/first-boot-systemd-analyze.txt')"
"Everyday boot|$(boot_s pr '*-boot/systemd-analyze.txt')|$(boot_s base '*-boot/systemd-analyze.txt')"
)
status="passed"; [ "$CONCLUSION" = success ] || status="**${CONCLUSION}**"
{
echo "$marker"
echo "### Install test $status"
echo
echo "| | This PR | quattro | Change |"
echo "| --- | ---: | ---: | ---: |"
for row in "${rows[@]}"; do
IFS='|' read -r name this base_v <<<"$row"
echo "| $name | $(cell "$this") | $(cell "$base_v") | $(change "$this" "$base_v") |"
done
if [ -n "$pt" ]; then
echo
echo "<details><summary>Install phases</summary>"
echo
echo "| Phase | This PR | quattro |"
echo "| --- | ---: | ---: |"
# Phase names come from the pull request's installer: keep them to plain text.
jq -r --slurpfile b <( [ -n "$bt" ] && cat "$bt" || echo '{"phases":[]}' ) '
($b[0].phases | map({key: .name, value: .elapsed}) | from_entries) as $base
| .phases[]
| "| \(.name | gsub("[^A-Za-z0-9 +&/().-]"; "") | .[0:60]) | \(.elapsed * 10 | round / 10) s | \(($base[.name] // null) | if . == null then "–" else "\(. * 10 | round / 10) s" end) |"
' "$pt"
echo
echo "</details>"
fi
echo
echo "[Run]($RUN_URL) · ${HEAD_SHA:0:8}"
} > comment.md
existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \
-q ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -1)
if [ -n "$existing" ]; then
gh api -X PATCH "repos/$REPO/issues/comments/$existing" -F body=@comment.md >/dev/null
else
gh api "repos/$REPO/issues/$pr/comments" -F body=@comment.md >/dev/null
fi
cat comment.md
-117
View File
@@ -1,117 +0,0 @@
name: Install test
# Builds the ISO a pull request produces, installs it unattended in a KVM
# guest, boots the installed system again, and reports how long the install
# and both boots took. Every later change to the installer is measured
# against these numbers in its own PR.
on:
pull_request:
# Each merge records quattro's numbers, which PR comments compare against.
push:
branches: [quattro]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: install-test-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install:
runs-on: ubuntu-24.04
timeout-minutes: 75
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# The hosted runner has far more disk than the build needs (145 GB,
# ~86 GB free); fail early and loudly if that ever changes.
- name: Check disk space
run: |
df -h /
avail_gb=$(df -BG --output=avail / | tail -1 | tr -dc 0-9)
[ "$avail_gb" -ge 40 ] || { echo "::error::only ${avail_gb} GB free, the ISO build and install test need ~40 GB"; exit 1; }
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build the ISO
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make
# The harness runs in an Arch container: Arch's current QEMU and OVMF
# (QEMU 11) instead of Ubuntu 24.04's QEMU 8.2, at the paths the
# harness expects, the same as on an Omarchy workstation.
- name: Install unattended, then boot the installed system
run: |
sudo chmod 0666 /dev/kvm
docker run --rm --device /dev/kvm -v "$PWD:/w" -w /w \
-e OMARCHY_INTEGRATION_DISK_CACHE=none \
archlinux:latest bash -euo pipefail -c '
pacman -Syu --noconfirm --needed qemu-system-x86 qemu-hw-display-virtio-gpu qemu-hw-display-virtio-vga qemu-img edk2-ovmf socat \
imagemagick tesseract tesseract-data-eng mtools dosfstools jq openssh openssl python curl >/dev/null
# test/integration installs its tools through Omarchy'"'"'s helper; they are installed above.
printf "#!/bin/sh\nexit 0\n" >/usr/local/bin/omarchy-pkg-add
chmod 0755 /usr/local/bin/omarchy-pkg-add
qemu-system-x86_64 --version | head -1
./test/integration release/*.iso boot --no-preview --memory 8192 --timeout 1800
'
# The harness ran as root in its container: test-runs and what QEMU
# wrote there (the serial logs) belong to root until this step.
- name: Hand the results to the runner's user
if: always()
run: |
mkdir -p test-runs
sudo chown -R "$(id -u):$(id -g)" test-runs
- name: Report times
if: always()
run: |
base=$(ls -d test-runs/*-integration 2>/dev/null | head -1)
[ -n "$base" ] || { echo "No test run to report" >> "$GITHUB_STEP_SUMMARY"; exit 0; }
{
echo "## Install test"
echo
if [ -f "$base/omarchy-install-timing.json" ]; then
jq -r '"**Install:** \((.finished_at - .started_at) * 10 | round / 10) s across \(.phases | length) phases"' "$base/omarchy-install-timing.json"
echo
echo "| Phase | Seconds |"
echo "| --- | ---: |"
jq -r '.phases[] | "| \(.name) | \(.elapsed * 10 | round / 10) |"' "$base/omarchy-install-timing.json"
echo
fi
if [ -f "$base/first-boot-systemd-analyze.txt" ]; then
echo "**First boot:** $(head -1 "$base/first-boot-systemd-analyze.txt")"
echo
fi
boot_dir=$(ls -d "$base"/runs/*-boot 2>/dev/null | tail -1)
if [ -n "$boot_dir" ] && [ -f "$boot_dir/systemd-analyze.txt" ]; then
echo "**Everyday boot:** $(head -1 "$boot_dir/systemd-analyze.txt")"
echo
echo "System state: $(jq -r .system_state "$boot_dir/boot-timing.json" 2>/dev/null)"
fi
} >> "$GITHUB_STEP_SUMMARY"
# The comment workflow runs from quattro and reads only this artifact;
# it needs the PR number, which a workflow_run event omits for forks.
- name: Record the pull request
if: always() && github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > test-runs/pr-number
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: install-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test-runs/pr-number
test-runs/*/omarchy-install-timing.json
test-runs/*/omarchy-install.log
test-runs/*/first-boot-systemd-analyze.txt
test-runs/*/runs/**/*.png
test-runs/*/runs/**/*.txt
test-runs/*/runs/**/*.json
test-runs/*/runs/**/*.log
if-no-files-found: ignore
retention-days: 14
-55
View File
@@ -1,55 +0,0 @@
name: Unit tests
# The fast, VM-free tests under test/unit/: cidata autoinstall loading and the
# orchestrator's phases. Seconds per run, no ISO build; the install test
# covers the rest.
on:
pull_request:
push:
branches: [quattro]
workflow_dispatch:
permissions:
contents: read
jobs:
unit:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
# The tests run where the code they cover runs: on Arch, the same as
# the live ISO and an Omarchy workstation. The container boots systemd
# because the keyboard tests ask the real localectl for its keymaps, and
# the tests run as an ordinary user because some of them check what
# happens when a file cannot be written.
#
# A privileged container shares the host's consoles and devices, so the
# units that would claim them are masked before systemd starts: without
# that, its getty, udev and vconsole setup take over the host's.
- name: Run ./test/all in an Arch container
run: |
docker run -d --name unit --privileged --cgroupns=host \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw --tmpfs /run --tmpfs /tmp:exec \
-v "$PWD:/src:ro" archlinux:latest bash -c '
systemctl mask getty.target console-getty.service \
systemd-udev-trigger.service systemd-udevd.service \
systemd-vconsole-setup.service
exec /usr/lib/systemd/systemd
'
docker exec unit bash -euo pipefail -c '
for i in $(seq 1 60); do
state=$(systemctl is-system-running 2>/dev/null || true)
case $state in running | degraded) break ;; esac
sleep 1
done
echo "systemd: $state"
pacman -Syu --noconfirm --needed python parted >/dev/null
useradd -m tester
cp -r /src /home/tester/omarchy-iso
chown -R tester /home/tester/omarchy-iso
cd /home/tester/omarchy-iso
setpriv --reuid=tester --regid=tester --init-groups ./test/all
'
+8 -4
View File
@@ -26,8 +26,6 @@ For local development, build the ISO from sibling checkouts:
Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo.
The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces.
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
## Autoinstall
@@ -64,8 +62,6 @@ When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh
When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs.
The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network.
### Building the drive
```bash
@@ -147,3 +143,11 @@ Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone confi
## Full release of the ISO
Run `./bin/omarchy-iso-release VERSION` to create, test, sign, and upload the ISO in one flow. Add `--rc` to release an RC build instead.
## Building the UTM SE appliance
`./bin/omarchy-utm-make` builds Omarchy for the App Store version of UTM on iPad and iPhone: an aarch64 disk with Arch Linux ARM underneath, wrapped as a `.utm` bundle and zipped into `release/Omarchy-<version>-aarch64-utm-se.utm.zip` beside its `.sha256`. Everything Omarchy ships from its own repository comes from the aarch64 builds at [ZacharyZhang-NY/omarchy-pkgs-aarch64](https://github.com/ZacharyZhang-NY/omarchy-pkgs-aarch64), which `builder/pacman-utm-se.conf` names; the runtime is the `omarchy-dev` and `omarchy-settings-dev` pair built there from the `utm-se` branch of omarchy, as local-source ISOs do.
The build runs in a privileged `linux/arm64` container: it partitions a 32 GiB sparse disk, lays out the same Btrfs subvolumes the ISO does, installs `builder/utm-se.packages` and then the Omarchy base set minus `builder/utm-se-drop.packages`, puts Limine on the UEFI fallback path, runs `omarchy-apply-system --defer-provisioning`, stages first-boot provisioning with the arm64 Node tarball, builds the boot entries, and seals the disk with an empty machine-id. The host needs Docker with a kernel that offers btrfs and loop devices (with colima: `colima ssh -- sudo modprobe btrfs`), `qemu` for the UEFI variable store template, `zip`, and `shasum`.
The bundle boots with 2 cores, 2048 MiB, a `virtio-gpu-pci` display at 1280x800, VirtFS sharing, and the shared clipboard. The first boot asks for the keyboard, user, and password, the way a deferred-provisioning ISO install does.
+30 -130
View File
@@ -13,11 +13,7 @@
#
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
#
# --keyboard LABEL English (US/UK), Japanese, Japanese (US keyboard), Korean,
# or Chinese (Simplified, Pinyin)/(Traditional, Zhuyin).
# OMARCHY_PATH must be the source tree used to build the ISO.
# --encrypt Drive the encrypted install flow (default: unencrypted)
# --language LANG Pick en (default) or zh; zh keeps the preselected Pinyin keyboard
# --provision Drive the deferred-provisioning install flow: no user during install, the
# first boot runs omarchy-provision-owner and creates it there
# --reuse-base Skip the install phase if a base image already exists
@@ -43,7 +39,6 @@ MEMORY=8192
INSTALL_TIMEOUT=2400
BOOT_TIMEOUT=600
ENCRYPT=false
INSTALL_LANGUAGE=en
PROVISION=false
REUSE_BASE=false
INSTALL_ONLY=false
@@ -52,16 +47,13 @@ NO_PREVIEW=false
SYNC_DIR=""
SYNC_ALL=false
KEYBOARD_LABEL="English (US)"
GUEST_USER="omarchy"
GUEST_PASSWORD="omarchy"
GUEST_HOSTNAME="omarchy-test"
while (($#)); do
case "$1" in
--keyboard) KEYBOARD_LABEL="$2"; shift ;;
--encrypt) ENCRYPT=true ;;
--language) INSTALL_LANGUAGE="$2"; shift ;;
--provision) PROVISION=true ;;
--reuse-base) REUSE_BASE=true ;;
--install-only) INSTALL_ONLY=true ;;
@@ -92,42 +84,13 @@ if [[ ! -f ${ISO:-} ]]; then
exit 1
fi
if [[ $INSTALL_LANGUAGE != "en" && $INSTALL_LANGUAGE != "zh" ]]; then
echo "--language must be en or zh, not $INSTALL_LANGUAGE" >&2
exit 1
fi
if [[ $INSTALL_LANGUAGE == "zh" && $KEYBOARD_LABEL != "English (US)" ]]; then
echo "--language zh takes the preselected Pinyin keyboard; drop --keyboard" >&2
exit 1
fi
case "$KEYBOARD_LABEL" in
"English (US)" | "English (UK)" | "Japanese" | "Japanese (US keyboard)" | "Korean" | "Chinese (Simplified, Pinyin)" | "Chinese (Traditional, Zhuyin)") ;;
*) echo "The VM harness cannot type credentials with keyboard: $KEYBOARD_LABEL" >&2; exit 1 ;;
esac
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng tesseract-data-chi_sim
# After the language screen a Chinese install reads in Chinese.
OCR_LANGS=eng
[[ $INSTALL_LANGUAGE == "zh" ]] && OCR_LANGS=eng+chi_sim
# lang_text <english> <chinese> picks the text a screen shows in this run.
lang_text() {
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
printf '%s' "$2"
else
printf '%s' "$1"
fi
}
omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng
OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd"
OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd"
BASE_NAME="$(basename "$ISO" .iso)"
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
BASE_NAME+="-$(printf '%s' "$KEYBOARD_LABEL" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')"
fi
$ENCRYPT && BASE_NAME+="-encrypted"
[[ $INSTALL_LANGUAGE == "zh" ]] && BASE_NAME+="-zh"
$PROVISION && BASE_NAME+="-provision"
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
@@ -277,13 +240,7 @@ ocr_screen() {
screendump "$shot"
[[ -s $shot ]] || return 0
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
# The logo throws tesseract off CJK, so Chinese runs also read below it, without spaces.
if [[ $INSTALL_LANGUAGE == "zh" ]]; then
magick "$shot" -gravity north -chop 0x35% -colorspace gray -negate -resize 150% "$prepped.text.png" 2>/dev/null || return 0
{ tesseract "$prepped" - --psm 6 -l "$OCR_LANGS"; tesseract "$prepped.text.png" - --psm 6 -l "$OCR_LANGS"; } 2>/dev/null | sed 'p; s/ //g' || true
else
tesseract "$prepped" - --psm 6 -l "$OCR_LANGS" 2>/dev/null || true
fi
tesseract "$prepped" - --psm 6 2>/dev/null || true
}
# Wait until the console shows the given text. Saves the offending screen as
@@ -653,65 +610,31 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
# ----------------------------------------------------- driving the installer
select_keyboard() {
local label="$1" index direction="down"
# gum choose uses arrows, not a searchable text field. Read the same ordered
# choices the ISO vendors from the runtime rather than hard-code row numbers.
index=$(OMARCHY_TEST_KEYBOARD="$label" python - <<'PYTHON'
import os, re
from pathlib import Path
source = Path(os.environ.get("OMARCHY_PATH", "")) / "install/provisioning/setup-form.sh"
if not source.is_file():
raise SystemExit("Set OMARCHY_PATH to the source tree used to build this ISO")
text = source.read_text()
rows = re.search(r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", text, re.S).group(1).splitlines()
labels = [row.split("|")[0] for row in rows]
label = os.environ["OMARCHY_TEST_KEYBOARD"]
if label not in labels:
raise SystemExit(f"Keyboard choice is absent from the ISO source: {label}")
print(labels.index(label) - labels.index("English (US)"))
PYTHON
) || return 1
if (( index < 0 )); then
direction="up"
index=$((-index))
fi
for (( i = 0; i < index; i++ )); do
press "$direction"
sleep 0.1
done
}
drive_configurator() {
log "Driving the installer wizard"
# A greeter (animated logo + tagline) opens the installer; Return begins.
wait_for_screen "Linux by DHH" 300
wait_for_screen "Opinionated" 300
capture_console "success-installer-00-greeter"
press ret
# Language comes first: English on top, Chinese below.
wait_for_screen "Select language" 300
capture_console "success-installer-01-language"
if [[ $INSTALL_LANGUAGE == "zh" ]]; then press end; else press home; fi
press ret
# The keyboard follows, for Chinese in cage + foot; Ctrl+C there defers provisioning.
wait_for_screen "$(lang_text "keyboard layout" "键盘布局")" 300
# Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning
# mode; a normal install just selects the layout.
wait_for_screen "keyboard layout" 300
capture_console "success-installer-01-keyboard-layout"
if $PROVISION; then
press ctrl-c # arm "prepare for another owner"
wait_for_screen "$(lang_text "another owner" "另一位")" 30
wait_for_screen "another owner" 30
capture_console "success-installer-02-prepare-confirm"
press ret # "Yes, prepare for another owner" is the affirmative default
# Jumps straight to disk selection, then the overwrite confirm.
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
wait_for_screen "install disk" 60
capture_console "success-installer-03-prepare-install-disk"
press ret
wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60
wait_for_screen "recovery possible" 60
capture_console "success-installer-04-prepare-disk-warning-encrypted"
if ! $ENCRYPT; then
press ctrl-c
@@ -728,70 +651,58 @@ drive_configurator() {
return 0
fi
if [[ $KEYBOARD_LABEL != "English (US)" ]]; then
select_keyboard "$KEYBOARD_LABEL"
capture_console "success-installer-01-selected-keyboard"
fi
press ret
press ret # English (US) is preselected
# Normal flow: user step comes before disk selection.
wait_for_screen "$(lang_text "Username" "用户名")" 120
wait_for_screen "Username" 120
type_text "$GUEST_USER"
capture_console "success-installer-02-username"
press ret
wait_for_screen "$(lang_text "Password" "密码")" 60
wait_for_screen "Password" 60
type_text "$GUEST_PASSWORD"
capture_console "success-installer-03-password"
press ret
wait_for_screen "$(lang_text "Must match" "确认")" 60
wait_for_screen "Must match" 60
type_text "$GUEST_PASSWORD"
capture_console "success-installer-04-password-confirmation"
press ret
wait_for_screen "$(lang_text "Full name" "姓名")" 60
wait_for_screen "Full name" 60
type_text "Omarchy Test"
capture_console "success-installer-05-full-name"
press ret
wait_for_screen "$(lang_text "Email address" "邮箱")" 60
type_text "test"
if [[ $KEYBOARD_LABEL == "Japanese" ]]; then
press bracket_left
elif [[ $KEYBOARD_LABEL == "English (UK)" ]]; then
press shift-apostrophe
else
press shift-2
fi
type_text "omarchy.org"
wait_for_screen "Email address" 60
type_text "test@omarchy.org"
capture_console "success-installer-06-email"
press ret
wait_for_screen "$(lang_text "Hostname" "主机名")" 60
wait_for_screen "Hostname" 60
type_text "$GUEST_HOSTNAME"
capture_console "success-installer-07-hostname"
press ret
wait_for_screen "$(lang_text "Timezone" "时区")" 180 # tzupdate geo-guess can take a moment
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
capture_console "success-installer-08-timezone"
press ret
wait_for_screen "$(lang_text "look right" "正确")" 60
wait_for_screen "look right" 60
capture_console "success-installer-09-review"
press ret
wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60
wait_for_screen "install disk" 60
capture_console "success-installer-10-install-disk"
press ret # the single virtio disk
wait_for_screen "$(lang_text "installation mode\|recovery possible" "安装方式\|无法恢复")" 60
if ocr_screen | grep -qi "$(lang_text "installation mode" "安装方式")"; then
wait_for_screen "installation mode\|recovery possible" 60
if ocr_screen | grep -qi "installation mode"; then
capture_console "success-installer-11-install-mode"
press ret # "Full disk install" is preselected
fi
wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60
wait_for_screen "recovery possible" 60
capture_console "success-installer-12-disk-warning-encrypted"
if ! $ENCRYPT; then
press ctrl-c # toggles the confirm to the unencrypted flow
@@ -820,17 +731,13 @@ drive_provision_owner() {
# layout — UK leaves every letter identical to US, so the typed password (sent
# as raw keycodes) still matches the literal one the harness pipes to sudo.
# A greeter (animated logo + tagline) opens first boot; Return begins.
wait_for_screen "Linux by DHH" 600
wait_for_screen "Opinionated" 600
capture_console "success-provision-00-greeter"
press ret
wait_for_screen "keyboard layout" 120
if [[ $KEYBOARD_LABEL == "English (US)" ]]; then
press down # preserve the harness's default UK provisioning coverage
else
select_keyboard "$KEYBOARD_LABEL"
fi
capture_console "success-provision-01-keyboard-layout"
press down
capture_console "success-provision-01-keyboard-layout-uk"
press ret
wait_for_screen "Username" 120
@@ -856,13 +763,7 @@ drive_provision_owner() {
# Shift+2 as on US); send that keycode so the typed email is correct under
# the new layout — itself further proof the deferred keymap took effect.
type_text "test"
if [[ $KEYBOARD_LABEL == "English (US)" || $KEYBOARD_LABEL == "English (UK)" ]]; then
press shift-apostrophe
elif [[ $KEYBOARD_LABEL == "Japanese" ]]; then
press bracket_left
else
press shift-2
fi
press shift-apostrophe
type_text "omarchy.org"
capture_console "success-provision-04-email"
press ret
@@ -895,8 +796,7 @@ wait_for_install() {
while true; do
text=$(ocr_screen)
# OCR cannot read the Chinese reboot button, so Chinese runs match the title above it.
if grep -qi "$(lang_text "Reboot Now" "安装完成")" <<<"$text"; then
if grep -qi "Reboot Now" <<<"$text"; then
log "Install finished. Confirming the reboot prompt."
capture_console "success-installer-15-reboot"
press ret
@@ -906,13 +806,13 @@ wait_for_install() {
# Deferred-provisioning installs skip the celebration and reboot on their own
# (no Reboot Now prompt); the first-boot greeter tagline appearing means the
# install finished and rebooted into setup.
if $PROVISION && grep -qi "Linux by DHH" <<<"$text"; then
if $PROVISION && grep -qi "Opinionated" <<<"$text"; then
log "Install finished and auto-rebooted into first-boot setup."
capture_console "success-installer-15-autoreboot"
return 0
fi
if grep -qi "$(lang_text "installation stopped" "安装已停止")" <<<"$text"; then
if grep -qi "installation stopped" <<<"$text"; then
capture_console "failure-installer-installation-stopped"
echo "Install failed — screenshot saved to $RUN_DIR" >&2
return 1
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
set -e
usage() {
echo "Usage: $0 <omarchy-utm-se.qcow2|Omarchy.utm> [--hvf]" >&2
echo "Boots the appliance disk under qemu-system-aarch64 with the devices the UTM bundle declares." >&2
echo "TCG emulation is the default, the closest stand-in for UTM SE; --hvf uses Apple's hypervisor instead." >&2
exit 1
}
(( $# >= 1 )) || usage
disk="$1"
shift
accel=tcg
cpu=cortex-a72
if (( $# > 0 )); then
[[ $1 == "--hvf" ]] || usage
accel=hvf
cpu=host
fi
if [[ -d $disk ]]; then
disk=$(ls "$disk"/Data/*.qcow2 | head -n1)
fi
[[ -f $disk ]] || { echo "Error: $disk is not a disk image" >&2; exit 1; }
firmware=""
for candidate in "$(brew --prefix 2>/dev/null)/share/qemu" /usr/share/qemu /usr/share/edk2/aarch64; do
if [[ -f $candidate/edk2-aarch64-code.fd ]]; then
firmware="$candidate"
break
fi
done
[[ -n $firmware ]] || { echo "Error: no edk2-aarch64-code.fd found; install qemu" >&2; exit 1; }
run_dir="${TMPDIR:-/tmp}/omarchy-utm-boot"
mkdir -p "$run_dir"
vars="$run_dir/efi_vars.fd"
[[ -f $vars ]] || cp "$firmware/edk2-arm-vars.fd" "$vars"
echo "QMP socket: $run_dir/qmp.sock serial log: $run_dir/serial.log ssh: localhost:2222"
exec qemu-system-aarch64 \
-machine virt -cpu "$cpu" -accel "$accel" -smp 2 -m 2048 \
-drive if=pflash,format=raw,readonly=on,file="$firmware/edk2-aarch64-code.fd" \
-drive if=pflash,format=raw,file="$vars" \
-drive file="$disk",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0 \
-device virtio-gpu-pci -device virtio-keyboard-pci -device virtio-mouse-pci \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \
-device virtio-rng-pci \
-serial file:"$run_dir/serial.log" \
-qmp unix:"$run_dir/qmp.sock",server,nowait \
-display default
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
set -e
BUILD_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RELEASE_DIR="$BUILD_ROOT/release"
CACHE_DIR="$HOME/.cache/omarchy/utm-se"
BUILDER_IMAGE="menci/archlinuxarm:base"
if (( $# > 0 )); then
echo "Usage: $0" >&2
echo "Builds the Omarchy appliance for UTM SE into $RELEASE_DIR: a zipped .utm bundle and its sha256." >&2
exit 1
fi
DOCKER=(docker)
if ! docker version &>/dev/null; then
DOCKER=(sudo docker)
fi
# The build partitions a loop device and mounts btrfs inside the container, so the engine's kernel must offer both.
if ! "${DOCKER[@]}" run --rm --privileged --platform linux/arm64 "$BUILDER_IMAGE" \
sh -c 'grep -q btrfs /proc/filesystems && losetup -f >/dev/null'; then
echo "Error: the Docker engine's kernel needs btrfs and loop devices (colima: colima ssh -- sudo modprobe btrfs)" >&2
exit 1
fi
efi_vars=""
for candidate in "$(brew --prefix 2>/dev/null)/share/qemu/edk2-arm-vars.fd" /usr/share/qemu/edk2-arm-vars.fd /usr/share/edk2/aarch64/QEMU_VARS.fd; do
if [[ -f $candidate ]]; then
efi_vars="$candidate"
break
fi
done
if [[ -z $efi_vars ]]; then
echo "Error: no UEFI variable store template found; install qemu" >&2
exit 1
fi
mkdir -p "$RELEASE_DIR" "$CACHE_DIR/pkg"
rm -f "$RELEASE_DIR/omarchy-utm-se.qcow2" "$RELEASE_DIR/omarchy-utm-se.version"
"${DOCKER[@]}" run --rm --privileged --platform linux/arm64 \
-v "$BUILD_ROOT/builder:/builder:ro" \
-v "$RELEASE_DIR:/out" \
-v "$CACHE_DIR/pkg:/var/cache/pacman/pkg" \
"$BUILDER_IMAGE" bash /builder/build-utm.sh
version=$(cat "$RELEASE_DIR/omarchy-utm-se.version")
name="Omarchy-$version-aarch64-utm-se"
bundle="$RELEASE_DIR/$name/Omarchy.utm"
vm_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
disk_uuid=$(uuidgen | tr '[:lower:]' '[:upper:]')
mac=$(printf '02:%02X:%02X:%02X:%02X:%02X' $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256)) $((RANDOM % 256)))
rm -rf "$RELEASE_DIR/$name"
mkdir -p "$bundle/Data"
mv "$RELEASE_DIR/omarchy-utm-se.qcow2" "$bundle/Data/$disk_uuid.qcow2"
cp "$efi_vars" "$bundle/Data/efi_vars.fd"
"$BUILD_ROOT/builder/utm/render" "$vm_uuid" "$disk_uuid" "$mac" "$version" >"$bundle/config.plist"
rm -f "$RELEASE_DIR/$name.utm.zip"
(cd "$RELEASE_DIR/$name" && zip -qr "../$name.utm.zip" Omarchy.utm)
(cd "$RELEASE_DIR" && shasum -a 256 "$name.utm.zip" >"$name.utm.zip.sha256")
rm -rf "$RELEASE_DIR/$name" "$RELEASE_DIR/omarchy-utm-se.version"
echo "Built $RELEASE_DIR/$name.utm.zip"
+7 -61
View File
@@ -41,14 +41,11 @@ pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
pacman-key --populate omarchy
# Put OPR first in the container too so makepkg resolves the same overrides
# as the online installer configuration.
# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent
# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy-
# only build deps like limine-snapper-sync and limine-mkinitcpio-hook.
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
opr_section=$(awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf)
awk -v opr="$opr_section" '/^\[core\]/ { print opr; print "" } { print }' \
/etc/pacman.conf >/tmp/pacman-opr-first.conf
install -m 644 /tmp/pacman-opr-first.conf /etc/pacman.conf
rm -f /tmp/pacman-opr-first.conf
awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf
fi
# Build locations
@@ -121,8 +118,7 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
# The selected omarchy-settings package is needed here so its post_install hook
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the
# live initramfs.
# cage and foot give Chinese installs a console that draws Chinese.
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted cage foot)
arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted)
printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64"
# The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so
@@ -143,7 +139,6 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# already in the mirror and we filter them out below. Without it, pacman -Syw
# pulls the published omarchy* from the network mirror like any other package.
if [[ -d /omarchy-source ]]; then
regions_source=/omarchy-source/default/regions
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
setup_form=/omarchy-source/install/provisioning/setup-form.sh
else
@@ -166,17 +161,6 @@ else
# build here (set -e) with a bare "Not found in archive" instead of the
# actionable error below.
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true
# Region profiles live under default/, which the settings package ships,
# not the runtime. Its own cache dir keeps the runtime lookup above from
# ever matching it.
settings_cache_dir="$bootstrap_cache_dir/settings"
mkdir -p "$settings_cache_dir"
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1)
if [[ -z $regions_source ]]; then
echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2
exit 1
fi
setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh
fi
@@ -203,24 +187,6 @@ if [[ ! -f $setup_form ]]; then
fi
cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh"
# Every region's packages ride in the offline mirror, and its community
# repositories join a writable copy of the online config to download them.
online_config=/tmp/omarchy-pacman-online.conf
cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config"
bash /builder/prepare-regions.sh "$regions_source" \
"$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config"
# Profiles ship in the settings package, but the finalizer that applies them
# ships in the runtime. A runtime without it would ignore the region marker,
# leaving regional targets with the keyring but none of the repositories.
if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then
runtime_listing=$(bsdtar -tf "$omarchy_pkg")
if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then
echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2
exit 1
fi
fi
# Collect every package we want available in the offline mirror.
declare -a all_packages
mapfile -t all_packages < <(
@@ -228,32 +194,12 @@ mapfile -t all_packages < <(
cat "$build_cache_dir/packages.x86_64"
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
grep -hv '^#\|^$' /builder/archinstall.packages
# Downloaded but not installed: the installer adds a region's packages
# only to targets in that region.
grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true
# Always include the selected Omarchy packages so the target install can
# find the runtime and companion packages in the offline mirror.
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
# Arch dropped the prebuilt broadcom-wl on 2026-09-02 and rebuilt broadcom-wl-dkms
# with replaces=(broadcom-wl). A replaces entry only helps upgrades of an already
# installed package; as an explicit pacman target the old name now fails with
# "target not found". Published Omarchy runtime packages that predate the rename
# still list it in omarchy-other.packages, so map it here until every channel
# ships a runtime that names broadcom-wl-dkms itself.
# arch-mact2 dropped apple-bcm-firmware on 2026-09-16 in favour of
# apple-bcm-firmware-fetcher, which does the same job (pull the T2 Wi-Fi and
# Bluetooth firmware off the macOS volume) but only conflicts with the old name
# rather than replacing it, so pacman cannot follow the rename on its own.
mapfile -t all_packages < <(
printf '%s\n' "${all_packages[@]}" |
sed -e 's/^broadcom-wl$/broadcom-wl-dkms/' \
-e 's/^apple-bcm-firmware$/apple-bcm-firmware-fetcher/' |
sort -u
)
# With --local-source we already built these omarchy* packages directly into
# the mirror; strip them from the pacman -Syw list so it doesn't try to fetch
# the published versions on top.
@@ -269,7 +215,7 @@ fi
mkdir -p /tmp/offlinedb
download_offline_packages() {
pacman --config "$online_config" --noconfirm -Syw \
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
}
@@ -287,7 +233,7 @@ fi
# newest version of every cached package name) removes packages that have left
# the lists or dependency closure, such as an old Electron major version.
if ! resolved_package_files="$(
pacman --config "$online_config" --noconfirm \
pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \
--dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}"
)"; then
echo "ERROR: could not resolve the package files required by the offline mirror" >&2
+205
View File
@@ -0,0 +1,205 @@
#!/bin/bash
# Builds the aarch64 Omarchy disk for UTM SE inside a privileged Arch Linux ARM container.
set -euo pipefail
BUILDER=/builder
OUT=/out
WORK=/work
TARGET=$WORK/target
IMAGE=$WORK/omarchy.img
DISK_SIZE=32G
NODE_DIST_URL="https://nodejs.org/dist/latest"
PROVISIONING_DIR=var/lib/omarchy/provisioning
loop=""
cleanup() {
umount -R "$TARGET" 2>/dev/null || true
[[ -n $loop ]] && losetup -d "$loop" 2>/dev/null || true
}
trap cleanup EXIT
packages() {
grep -hv '^#\|^$' "$1"
}
# Docker's /dev is static, so the partition nodes the kernel scanned have to be made by hand.
partition() {
local node="${loop}p$1"
[[ -b $node ]] || mknod "$node" b $(tr ':' ' ' <"/sys/block/${loop#/dev/}/${loop#/dev/}p$1/dev")
echo "$node"
}
# The ISO defers the boot-image hooks the same way: /dev/null masks on the side pacman reads them from.
deferred_hooks=(60-mkinitcpio-remove.hook 60-limine-mkinitcpio-remove-pre.hook 80-limine-efi-deploy.hook 90-limine-mkinitcpio-remove-post.hook 90-mkinitcpio-install.hook)
mask_hooks() {
local dir="$1" name path
shift
mkdir -p "$dir"
for name in "$@"; do
path="$dir/$name"
if [[ -e $path || -L $path ]]; then
mv "$path" "$path.omarchy-backup"
fi
ln -s /dev/null "$path"
done
}
unmask_hooks() {
local dir="$1" name path
shift
for name in "$@"; do
path="$dir/$name"
rm "$path"
if [[ -e $path.omarchy-backup ]]; then
mv "$path.omarchy-backup" "$path"
fi
done
}
chroot_env=(
env --unset=XDG_RUNTIME_DIR
OMARCHY_PATH=/usr/share/omarchy
OMARCHY_INSTALL=/usr/share/omarchy/install
OMARCHY_MIRROR=stable
OMARCHY_RUNTIME_PACKAGE=omarchy-dev
OMARCHY_SETTINGS_PACKAGE=omarchy-settings-dev
OMARCHY_NVIM_PACKAGE=omarchy-nvim
OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log
OMARCHY_LOG_TO_STDOUT=1
)
echo "==> Preparing the build container"
pacman-key --init
pacman-key --populate archlinuxarm
install -m 0644 "$BUILDER/mirrorlist-utm-se" /etc/pacman.d/mirrorlist
pacman -Syu --noconfirm arch-install-scripts btrfs-progs dosfstools gptfdisk psmisc qemu-img
echo "==> Partitioning a $DISK_SIZE disk"
mkdir -p "$WORK" "$TARGET"
rm -f "$IMAGE"
truncate -s "$DISK_SIZE" "$IMAGE"
sgdisk --zap-all --new=1:0:+1G --typecode=1:ef00 --change-name=1:OMARCHY_ESP \
--new=2:0:0 --typecode=2:8305 --change-name=2:OMARCHY "$IMAGE" >/dev/null
loop=$(losetup --find --show --partscan "$IMAGE")
esp=$(partition 1)
root=$(partition 2)
mkfs.vfat -F32 -n OMARCHY_ESP "$esp" >/dev/null
mkfs.btrfs -f -L OMARCHY "$root" >/dev/null
mount "$root" "$TARGET"
for subvolume in @ @home @log @pkg; do
btrfs subvolume create "$TARGET/$subvolume" >/dev/null
done
umount "$TARGET"
mount -o noatime,compress=zstd,subvol=@ "$root" "$TARGET"
mkdir -p "$TARGET/home" "$TARGET/var/log" "$TARGET/var/cache/pacman/pkg" "$TARGET/boot"
mount -o noatime,compress=zstd,subvol=@home "$root" "$TARGET/home"
mount -o noatime,compress=zstd,subvol=@log "$root" "$TARGET/var/log"
mount -o noatime,compress=zstd,subvol=@pkg "$root" "$TARGET/var/cache/pacman/pkg"
mount "$esp" "$TARGET/boot"
echo "==> Installing the base system and Omarchy"
# pacstrap reads hooks from this container's /etc/pacman.d/hooks; the UKI is built once by limine-update at the end.
mask_hooks /etc/pacman.d/hooks "${deferred_hooks[@]}"
pacstrap -c -K -C "$BUILDER/pacman-utm-se.conf" "$TARGET" $(packages "$BUILDER/utm-se.packages")
base_list="$TARGET/usr/share/omarchy/install/omarchy-base.packages"
pacstrap -c -C "$BUILDER/pacman-utm-se.conf" "$TARGET" \
$(bash "$TARGET/usr/bin/omarchy-pkg-list" "$base_list" | grep -vxF -f <(packages "$BUILDER/utm-se-drop.packages"))
echo "==> Configuring the system"
# genfstab falls back to the hand-made loop device names here, so the table is written from the UUIDs directly.
root_uuid=$(blkid -s UUID -o value "$root")
esp_uuid=$(blkid -s UUID -o value "$esp")
cat >"$TARGET/etc/fstab" <<FSTAB
UUID=$root_uuid / btrfs rw,noatime,compress=zstd,subvol=/@ 0 0
UUID=$root_uuid /home btrfs rw,noatime,compress=zstd,subvol=/@home 0 0
UUID=$root_uuid /var/log btrfs rw,noatime,compress=zstd,subvol=/@log 0 0
UUID=$root_uuid /var/cache/pacman/pkg btrfs rw,noatime,compress=zstd,subvol=/@pkg 0 0
UUID=$esp_uuid /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=iso8859-1,shortname=mixed,errors=remount-ro 0 2
FSTAB
install -m 0644 "$BUILDER/pacman-utm-se.conf" "$TARGET/etc/pacman.conf"
install -m 0644 "$BUILDER/mirrorlist-utm-se" "$TARGET/etc/pacman.d/mirrorlist"
echo "en_US.UTF-8 UTF-8" >"$TARGET/etc/locale.gen"
arch-chroot "$TARGET" locale-gen >/dev/null
echo "LANG=en_US.UTF-8" >"$TARGET/etc/locale.conf"
printf 'KEYMAP=us\nXKBLAYOUT=us\n' >"$TARGET/etc/vconsole.conf"
echo omarchy >"$TARGET/etc/hostname"
ln -sf /usr/share/zoneinfo/UTC "$TARGET/etc/localtime"
install -m 0644 "$BUILDER/utm/monitors.lua" "$TARGET/etc/skel/.config/hypr/monitors.lua"
echo "==> Installing Limine on the fallback boot path"
# The virt machine's firmware puts the console on the serial port; tty0 keeps boot messages on the display too.
cmdline="root=UUID=$root_uuid rootflags=subvol=@ rw console=ttyAMA0 console=tty0"
install -D -m 0644 "$TARGET/usr/share/limine/BOOTAA64.EFI" "$TARGET/boot/EFI/BOOT/BOOTAA64.EFI"
mkdir -p "$TARGET/etc/kernel"
cat >"$TARGET/etc/pacman.d/hooks/99-omarchy-limine.hook" <<'HOOK'
[Trigger]
Operation = Upgrade
Type = Package
Target = limine
[Action]
Description = Deploying Omarchy Limine after upgrade...
When = PostTransaction
Exec = /bin/sh -c "/usr/bin/cp /usr/share/limine/BOOTAA64.EFI /boot/EFI/BOOT/BOOTAA64.EFI"
HOOK
sed "s|@@CMDLINE@@|$cmdline|" "$TARGET/usr/share/omarchy/default/limine/default.conf" >"$TARGET/etc/default/limine"
echo "$cmdline" >"$TARGET/etc/kernel/cmdline"
cp "$TARGET/usr/share/omarchy/default/limine/limine.conf" "$TARGET/boot/limine.conf"
echo "==> Running Omarchy system setup"
mask_hooks "$TARGET/etc/pacman.d/hooks" 90-mkinitcpio-install.hook
arch-chroot "$TARGET" "${chroot_env[@]}" omarchy-apply-system --defer-provisioning --first-install
unmask_hooks "$TARGET/etc/pacman.d/hooks" 90-mkinitcpio-install.hook
echo "==> Staging first-boot provisioning"
provisioning="$TARGET/$PROVISIONING_DIR"
install -d -m 0755 "$provisioning" "$provisioning/packages"
shasums=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
node_file=$(awk '/linux-arm64\.tar\.gz$/ { print $2 }' <<<"$shasums")
curl -fsSL "$NODE_DIST_URL/$node_file" -o "$provisioning/packages/$node_file"
awk -v file="$provisioning/packages/$node_file" '/linux-arm64\.tar\.gz$/ { print $1 " " file }' <<<"$shasums" | sha256sum -c - >/dev/null
touch "$provisioning/pending"
install -m 0644 "$TARGET/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \
"$TARGET/etc/systemd/system/omarchy-provision-owner.service"
mkdir -p "$TARGET/etc/systemd/system/multi-user.target.wants"
ln -sf /etc/systemd/system/omarchy-provision-owner.service \
"$TARGET/etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service"
echo "==> Building the boot entries"
arch-chroot "$TARGET" limine-update
arch-chroot "$TARGET" btrfs quota disable / >/dev/null 2>&1 || true
# limine-update swallows a kernel's failure, so the UKI and its entry are checked directly.
compgen -G "$TARGET/boot/EFI/Linux/*.efi" >/dev/null
grep -q 'EFI/Linux/' "$TARGET/boot/limine.conf"
echo "==> Sealing the image"
version=$(arch-chroot "$TARGET" pacman -Q omarchy-dev | cut -d' ' -f2)
: >"$TARGET/etc/machine-id"
rm -f "$TARGET"/etc/ssh/ssh_host_* "$TARGET/root/.bash_history"
rm -rf "$TARGET"/var/cache/pacman/pkg/*
# Freed blocks still hold old bytes; trimming zeroes them so they compress to nothing.
fstrim "$TARGET" && fstrim "$TARGET/boot"
echo "==> Checking the sealed image"
[[ ! -s $TARGET/etc/machine-id ]]
[[ -z $(ls -A "$TARGET/var/cache/pacman/pkg") ]]
[[ -f $provisioning/pending && -f $provisioning/packages/$node_file ]]
[[ -L $TARGET/etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service ]]
[[ -f $TARGET/boot/EFI/BOOT/BOOTAA64.EFI && -f $TARGET/etc/snapper/configs/root ]]
[[ -f $TARGET/etc/pacman.d/hooks/90-mkinitcpio-install.hook && ! -L $TARGET/etc/pacman.d/hooks/90-mkinitcpio-install.hook ]]
grep -q "root=UUID=$root_uuid" "$TARGET/etc/kernel/cmdline"
# Keyring setup leaves gpg-agents holding files on the target; their sockets died with the chroot's /run, so kill by holder.
fuser -km "$TARGET" >/dev/null 2>&1 || true
if ! umount -R "$TARGET"; then
fuser -vm "$TARGET" || true
exit 1
fi
losetup -d "$loop"
loop=""
echo "==> Writing the compressed disk"
qemu-img convert -c -O qcow2 -o compression_type=zstd,cluster_size=2M "$IMAGE" "$OUT/omarchy-utm-se.qcow2"
echo "$version" >"$OUT/omarchy-utm-se.version"
+4
View File
@@ -0,0 +1,4 @@
# Arch Linux ARM mirrors: the geo mirror first, then fallbacks pacman tries when one stalls.
Server = http://fl.us.mirror.archlinuxarm.org/$arch/$repo
Server = http://ca.us.mirror.archlinuxarm.org/$arch/$repo
Server = http://dk.mirror.archlinuxarm.org/$arch/$repo
+26
View File
@@ -0,0 +1,26 @@
# pacman configuration of the UTM SE appliance: Arch Linux ARM plus the aarch64 builds of Omarchy's own packages.
[options]
HoldPkg = pacman glibc
Architecture = auto
CheckSpace
ParallelDownloads = 5
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
# Developer documentation does not fit a bundle that GitHub caps at 2 GiB.
NoExtract = usr/share/doc/* usr/share/gtk-doc/*
[core]
Include = /etc/pacman.d/mirrorlist
[extra]
Include = /etc/pacman.d/mirrorlist
[alarm]
Include = /etc/pacman.d/mirrorlist
[aur]
Include = /etc/pacman.d/mirrorlist
[omarchy-aarch64]
SigLevel = Optional
Server = https://github.com/ZacharyZhang-NY/omarchy-pkgs-aarch64/releases/download/stable
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
# Ship every region profile from the bundled runtime, so one ISO serves every
# region. The installer picks a profile from the target's timezone; this only
# stages the profiles and makes their packages downloadable. Repository
# defaults stay in the runtime, not in a second set of ISO-specific templates.
set -euo pipefail
regions_source="$1"
iso_payload="$2"
online_config="$3"
# The source is a checkout's default/regions, or the published settings
# package that ships default/. One predating region profiles ships none; the
# ISO then installs global targets only, exactly as before. Anything else that
# fails here, such as an unreadable package, aborts the build.
regions="$regions_source"
if [[ -f $regions_source ]]; then
member=usr/share/omarchy/default/regions
listing=$(bsdtar -tf "$regions_source")
if grep -q "^$member/" <<<"$listing"; then
extracted=$(mktemp -d)
trap 'rm -rf "$extracted"' EXIT
bsdtar -xf "$regions_source" -C "$extracted" "$member"
regions="$extracted/$member"
fi
fi
if [[ ! -d $regions ]]; then
echo "Runtime ships no region profiles; every install will be global."
exit 0
fi
cp -a "$regions" "$iso_payload/regions"
for profile in "$regions"/*/; do
# Only the community repositories are needed to download regional packages.
# Keep the channel's build mirrors intact; target mirrors are set later by
# the runtime's own pacman finalizer.
printf '\n' >> "$online_config"
cat "$profile/pacman/pacman.conf.append" >> "$online_config"
# Each keyring package must be signed by a key Arch already trusts. Do not
# bootstrap trust from an unchecked keyserver response or disable signatures.
# https://github.com/archlinuxcn/repo#usage
mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true)
if (( ${#keyring_packages[@]} > 0 )); then
pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}"
pacman-key --populate "${keyring_packages[@]%-keyring}"
fi
done
+15
View File
@@ -0,0 +1,15 @@
# Base packages the appliance leaves out: apps and toolchains too heavy for an interpreted guest, tools with no aarch64 build, and helpers for hardware the guest has not.
aether
asdcontrol
clang
docker-buildx
docker-compose
gpu-screen-recorder
hyprland-preview-share-picker
kdenlive
libreoffice-fresh
llvm
localsend
moonlight-qt
xournalpp
yay
+18
View File
@@ -0,0 +1,18 @@
# Installed before the Omarchy base set: the aarch64 kernel, keyrings, boot stack, guest tools, and the dev packages built from the utm-se checkout.
archlinuxarm-keyring
base
efibootmgr
limine
linux-aarch64
lua51
luarocks
omarchy-dev
omarchy-keyring
omarchy-settings-dev
openssh
pipewire
qemu-guest-agent
snapper
spice-vdagent
sudo
zram-generator
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Backend</key>
<string>QEMU</string>
<key>ConfigurationVersion</key>
<integer>4</integer>
<key>Information</key>
<dict>
<key>Name</key>
<string>Omarchy</string>
<key>UUID</key>
<string>@@VM_UUID@@</string>
<key>IconCustom</key>
<false/>
<key>Icon</key>
<string>arch-linux</string>
<key>Notes</key>
<string>Omarchy @@VERSION@@ for UTM SE (aarch64). The first boot asks for your keyboard, user, and password. Option acts as SUPER.</string>
</dict>
<key>System</key>
<dict>
<key>Architecture</key>
<string>aarch64</string>
<key>Target</key>
<string>virt</string>
<key>CPU</key>
<string>default</string>
<key>CPUFlagsAdd</key>
<array/>
<key>CPUFlagsRemove</key>
<array/>
<key>CPUCount</key>
<integer>2</integer>
<key>ForceMulticore</key>
<false/>
<key>MemorySize</key>
<integer>2048</integer>
<key>JITCacheSize</key>
<integer>0</integer>
</dict>
<key>QEMU</key>
<dict>
<key>DebugLog</key>
<false/>
<key>UEFIBoot</key>
<true/>
<key>RNGDevice</key>
<true/>
<key>BalloonDevice</key>
<false/>
<key>TPMDevice</key>
<false/>
<key>Hypervisor</key>
<false/>
<key>RTCLocalTime</key>
<false/>
<key>PS2Controller</key>
<false/>
<key>AdditionalArguments</key>
<array/>
</dict>
<key>Input</key>
<dict>
<key>UsbBusSupport</key>
<string>3.0</string>
<key>UsbSharing</key>
<false/>
<key>MaximumUsbShare</key>
<integer>3</integer>
</dict>
<key>Sharing</key>
<dict>
<key>DirectoryShareMode</key>
<string>VirtFS</string>
<key>DirectoryShareReadOnly</key>
<false/>
<key>ClipboardSharing</key>
<true/>
</dict>
<key>Display</key>
<array>
<dict>
<key>Hardware</key>
<string>virtio-gpu-pci</string>
<key>DynamicResolution</key>
<false/>
<key>NativeResolution</key>
<false/>
<key>UpscalingFilter</key>
<string>Linear</string>
<key>DownscalingFilter</key>
<string>Linear</string>
</dict>
</array>
<key>Drive</key>
<array>
<dict>
<key>Identifier</key>
<string>@@DISK_UUID@@</string>
<key>ImageName</key>
<string>@@DISK_UUID@@.qcow2</string>
<key>ImageType</key>
<string>Disk</string>
<key>Interface</key>
<string>VirtIO</string>
<key>InterfaceVersion</key>
<integer>1</integer>
<key>ReadOnly</key>
<false/>
</dict>
</array>
<key>Network</key>
<array>
<dict>
<key>Mode</key>
<string>Shared</string>
<key>Hardware</key>
<string>virtio-net-pci</string>
<key>MacAddress</key>
<string>@@MAC@@</string>
<key>IsolateFromHost</key>
<false/>
<key>PortForward</key>
<array/>
</dict>
</array>
<key>Serial</key>
<array/>
<key>Sound</key>
<array/>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
-- UTM scales the guest's 1280x800 display to the device, so Hyprland and GTK draw at scale 1.
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 })
hl.env("GDK_SCALE", "1")
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Renders the UTM config.plist template: render <vm-uuid> <disk-uuid> <mac> <version>
set -e
(( $# == 4 )) || { echo "Usage: $0 <vm-uuid> <disk-uuid> <mac> <version>" >&2; exit 1; }
sed -e "s/@@VM_UUID@@/$1/" -e "s/@@DISK_UUID@@/$2/g" -e "s/@@MAC@@/$3/" -e "s/@@VERSION@@/$4/" "$(dirname "$0")/config.plist"
+3 -58
View File
@@ -10,10 +10,7 @@
# - COLUMNS/LINES so gum picks up real terminal size
set -euo pipefail
[[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
touch "$XDG_RUNTIME_DIR/cjk-console-started"
fi
[[ $(tty) == /dev/tty1 ]] || exit 0
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
if [[ -f /root/omarchy_iso_ref ]]; then
@@ -42,8 +39,7 @@ set_tokyo_night_colors() {
echo -en "\033[0m"
clear
}
# foot sets the same palette from foot.ini; these escapes are VT-only.
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] || set_tokyo_night_colors
set_tokyo_night_colors
mkdir -p /var/log
touch "$OMARCHY_INSTALL_LOG_FILE"
@@ -94,16 +90,6 @@ warm_offline_mirror &
warm_pid=$!
trap 'kill "$warm_pid" 2>/dev/null' EXIT
# The CJK console's font comes from the offline mirror's noto-fonts-cjk, not the live image.
unpack_cjk_font() {
local packages=(/var/cache/omarchy/mirror/offline/noto-fonts-cjk-*.pkg.tar.zst)
if ((${#packages[@]} != 1)) || [[ ! -f ${packages[0]} ]]; then
echo "Expected one noto-fonts-cjk package in the offline mirror, found: ${packages[*]}" >&2
return 1
fi
bsdtar -xf "${packages[0]}" -C / usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc
}
cd /root
# Autoinstall: a cidata drive carrying the configurator's own output files
# stands in for the wizard. omarchy-cidata-load copies them into /root and
@@ -112,43 +98,7 @@ if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
# Chinese moves to cage + foot, which draw Chinese; English moves back to the VT, which applies keymaps.
cjk_relaunch=10
vt_return=11
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_VT_RETURN=$vt_return
elif command -v cage >/dev/null; then
export OMARCHY_CJK_RELAUNCH=$cjk_relaunch
fi
./configurator && status=0 || status=$?
while ((status == cjk_relaunch)); do
cjk_runtime=/run/omarchy-cjk-console
mkdir -p -m 700 "$cjk_runtime"
rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt"
if ! unpack_cjk_font; then
echo "The Chinese font could not be unpacked from the offline mirror; continuing in English."
unset OMARCHY_CJK_RELAUNCH
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
continue
fi
cage_status=0
env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_LANGUAGE=zh \
LANG=C.UTF-8 WLR_RENDERER=pixman cage -- foot -c /usr/share/omarchy-iso/foot.ini "$0" || cage_status=$?
if [[ -f $cjk_runtime/return-to-vt ]]; then
OMARCHY_INSTALL_LANGUAGE=en ./configurator && status=0 || status=$?
elif [[ -f $cjk_runtime/cjk-console-started ]]; then
exit "$cage_status"
else
echo "The Chinese console could not start (cage exit $cage_status); continuing in English."
unset OMARCHY_CJK_RELAUNCH
OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$?
fi
done
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then
touch "$XDG_RUNTIME_DIR/return-to-vt"
exit 0
fi
((status == 0)) || exit "$status"
./configurator
fi
# Deferred-provisioning installs skip the celebration/reboot prompt and reboot on
@@ -161,11 +111,6 @@ if [[ -f /root/defer-provisioning ]] ||
export OMARCHY_UI_DEFER_PROVISIONING=yes
fi
# Only Chinese reaches the install from the CJK console; English returns to the VT.
if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_INSTALL_LANG=zh
fi
# The foreground dashboard is now the sole visible install UI owner. It starts
# the actual installer as a non-interactive child, logs child output, waits for
# completion, then renders the final installed-time/reboot prompt itself.
+67 -194
View File
@@ -24,72 +24,6 @@ if [[ ! -r $SETUP_FORM ]]; then
fi
source "$SETUP_FORM"
# Chinese text for OMARCHY_INSTALL_LANG=zh; disk-step failures stay English for support logs.
OMARCHY_ZH+=(
["Aborted installation"]="安装已中止"
["You can retry later by running: ./.automated_script.sh"]="稍后可运行 ./.automated_script.sh 重试"
["Let's setup your machine..."]="开始设置这台机器……"
["Press Ctrl+C to prepare this machine for another owner."]="按 Ctrl+C 为另一位使用者准备这台机器。"
["This prepares the machine for another owner."]="这会为另一位使用者准备这台机器。"
["The system installs now, but setup is delayed until first boot."]="系统现在安装,设置留到首次开机。"
["Yes, prepare for another owner"]="是,为他人准备"
["No, keep setting up"]="否,继续设置"
["Prepare this machine for another owner?"]="为另一位使用者准备这台机器?"
["Let's setup your user account..."]="开始设置用户账户……"
["Field"]="项目"
["Value"]="值"
["Username"]="用户名"
["Password"]="密码"
["Full name"]="姓名"
["Email address"]="邮箱"
["Hostname"]="主机名"
["Keyboard"]="键盘"
["Region"]="区域"
["[Skipped]"]="[已跳过]"
["Yes"]="是"
["No, change it"]="否,去修改"
["Does this look right?"]="以上信息正确吗?"
["Not enough free space on %s"]="%s 的空闲空间不足"
["%s has %s of usable free space; Omarchy needs at least 32GB."]="%s 有 %s 可用空闲空间;Omarchy 至少需要 32GB。"
["Open the partition tool to free at least 32GB on %s, then try again."]="打开分区工具,在 %s 上腾出至少 32GB,然后重试。"
["Back"]="返回"
["Open partition tool"]="打开分区工具"
["Return to installation mode?"]="返回安装方式选择?"
["Checking for BitLocker on %s"]="检查 %s 上的 BitLocker"
["BitLocker signature detected on %s."]="在 %s 上发现 BitLocker 签名。"
["Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."]="在 Windows 中关闭 BitLocker,等磁盘解密完成后重试。"
["Suspending BitLocker is not enough — the drive stays encrypted."]="只暂停 BitLocker 不够,磁盘仍是加密的。"
["Aborted: BitLocker is enabled on this disk."]="已中止:这块磁盘开启了 BitLocker。"
["Checking existing EFI partitions on %s"]="检查 %s 上已有的 EFI 分区"
["Found a Windows ESP at %s — leaving it untouched."]="在 %s 发现 Windows ESP,保持不动。"
["Omarchy will create its own dedicated EFI partition in free space."]="Omarchy 会在空闲空间中创建自己的 EFI 分区。"
["A new EFI partition will be created in free space."]="会在空闲空间中新建 EFI 分区。"
["Analyzing free space on %s"]="分析 %s 的空闲空间"
["Install Omarchy in the %s of free space."]="在 %s 空闲空间中安装 Omarchy。"
["Press Ctrl+C for unencrypted install."]="按 Ctrl+C 改为不加密安装。"
["Yes, install"]="是,开始安装"
["Yes, install without encryption"]="是,不加密安装"
["Confirm installing on %s"]="确认安装到 %s"
["Creating partitions on %s"]="在 %s 上创建分区"
["Setting up LUKS2 on %s"]="在 %s 上设置 LUKS2 加密"
["Creating Btrfs filesystem and subvolumes"]="创建 Btrfs 文件系统和子卷"
["Let's select where to install Omarchy..."]="选择 Omarchy 的安装位置……"
["Select install disk"]="选择安装磁盘"
["Partition tool for %s"]="%s 的分区工具"
["Create unallocated free space for Omarchy, then write changes and quit."]="为 Omarchy 腾出未分配空间,然后写入更改并退出。"
["Do not create an Omarchy partition here — leave the target area as Free space."]="不要在这里创建 Omarchy 分区,目标区域保持为 Free space。"
["Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."]="提示:空闲空间安装至少需要 32GB 未分配空间;没有 ESP 时再加 2GB。"
["Open cfdisk"]="打开 cfdisk"
["Open partition tool for %s?"]="打开 %s 的分区工具?"
["Full disk install"]="整盘安装"
["Free space install (alongside existing data)"]="空闲空间安装(保留现有数据)"
["Choose a different disk"]="换一块磁盘"
["Let's select how to install Omarchy..."]="选择 Omarchy 的安装方式……"
["Select installation mode on %s"]="选择 %s 上的安装方式"
["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。"
["Confirm overwriting %s"]="确认覆盖 %s"
)
# Partition creation helpers, shared with test/partition-numbering-test.sh so
# the numbering logic can be exercised without a real disk.
DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}"
@@ -242,9 +176,9 @@ greeter() {
}
abort() {
gum style "${1:-$(omarchy_text "Aborted installation")}"
gum style "${1:-Aborted installation}"
echo
gum style "$(omarchy_text "You can retry later by running: ./.automated_script.sh")"
gum style "You can retry later by running: ./.automated_script.sh"
exit 1
}
@@ -269,30 +203,6 @@ notice() {
# STEP 1: KEYBOARD LAYOUT
# The target's LANG follows the language picked on the first screen.
target_lang() {
if [[ $language == "zh" ]]; then
echo zh_CN.UTF-8
else
echo en_US.UTF-8
fi
}
# .automated_script.sh moves Chinese into the CJK console and English back to the VT, which applies keymaps.
apply_language() {
if [[ $language == "zh" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then
exit "$OMARCHY_CJK_RELAUNCH"
fi
if [[ $language == "en" && -n ${OMARCHY_VT_RETURN:-} ]]; then
exit "$OMARCHY_VT_RETURN"
fi
if [[ $language == "zh" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then
export OMARCHY_INSTALL_LANG=zh
else
unset OMARCHY_INSTALL_LANG
fi
}
keyboard_form() {
# Ctrl+C is the hidden entry into deferred provisioning, offered on every
# visit: the user step unwinds back here and nothing is on disk yet. Callers
@@ -302,24 +212,11 @@ keyboard_form() {
while true; do
clear_logo
echo
say "$(omarchy_text "Let's setup your machine...")"
say --foreground 8 "$(omarchy_text "Press Ctrl+C to prepare this machine for another owner.")"
say "Let's setup your machine..."
say --foreground 8 "Press Ctrl+C to prepare this machine for another owner."
echo
status=0
if ! $language_chosen; then
omarchy_prompt_language && status=0 || status=$?
((status == 0)) && apply_language
fi
# Esc, Ctrl+C or a later "No, change it" asks the language again.
language_chosen=false
if ((status == 0)); then
local layouts=$OMARCHY_KEYBOARD_LAYOUTS selected="English (US)"
[[ $language == "zh" ]] && selected="Chinese (Simplified, Pinyin)"
# cage keeps its US layout, so the CJK console offers only US-keymap keyboards.
[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && layouts=$(awk -F'|' '$2 == "us"' <<<"$layouts")
OMARCHY_KEYBOARD_LAYOUTS=$layouts omarchy_prompt_keyboard "$selected" && status=0 || status=$?
fi
omarchy_prompt_keyboard && status=0 || status=$?
((status == 0)) && break
# Esc means "back", and nothing precedes the first screen, so re-ask.
@@ -348,11 +245,11 @@ keyboard_form() {
confirm_prepare_for_another_owner() {
clear_logo
echo
say "$(omarchy_text "This prepares the machine for another owner.")"
say --foreground 8 "$(omarchy_text "The system installs now, but setup is delayed until first boot.")"
say "This prepares the machine for another owner."
say --foreground 8 "The system installs now, but setup is delayed until first boot."
echo
gum confirm --affirmative "$(omarchy_text "Yes, prepare for another owner")" --negative "$(omarchy_text "No, keep setting up")" \
"$(omarchy_text "Prepare this machine for another owner?")"
gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \
"Prepare this machine for another owner?"
}
# The user step. Deferred-provisioning installs skip it entirely — the machine's first owner
@@ -360,7 +257,7 @@ confirm_prepare_for_another_owner() {
# operator sets nothing user-specific.
user_form() {
step "$(omarchy_text "Let's setup your user account...")"
step "Let's setup your user account..."
# Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL
# (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they
@@ -393,29 +290,19 @@ user_step() {
continue
fi
# The timezone selects the region (mirrors and repositories), using the
# same lookup as the orchestrator. Global installs get no extra row.
region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global"
region_row=""
if [[ $region != "global" ]]; then
region_row="\n$(omarchy_text Region),$region"
fi
# Add manual padding since gum table -p doesn't respect padding
local skipped
skipped=$(omarchy_text "[Skipped]")
echo -e "$(omarchy_text Field),$(omarchy_text Value)
$(omarchy_text Username),$username
$(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*')
$(omarchy_text "Full name"),${full_name:-$skipped}
$(omarchy_text "Email address"),${email_address:-$skipped}
$(omarchy_text Hostname),$hostname
$(omarchy_text Timezone),$timezone$region_row
$(omarchy_text Keyboard),\"$keyboard_label\"" |
echo -e "Field,Value
Username,$username
Password,$(printf "%${#password}s" | tr ' ' '*')
Full name,${full_name:-[Skipped]}
Email address,${email_address:-[Skipped]}
Hostname,$hostname
Timezone,$timezone
Keyboard,$keyboard" |
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
echo
if gum confirm --affirmative "$(omarchy_text "Yes")" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Does this look right?")"; then
if gum confirm --negative "No, change it" "Does this look right?"; then
break
else
keyboard_form
@@ -528,7 +415,7 @@ detect_kernel() {
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
echo "linux-t2"
else
echo "linux-omarchy"
echo "linux"
fi
}
@@ -617,11 +504,11 @@ print_dry_run_files() {
# action so users have a way to actually fix the situation.
not_enough_space() {
local available="${1:-0}"
step "$(omarchy_text "Not enough free space on %s" "$disk")"
say --foreground 1 "$(omarchy_text "%s has %s of usable free space; Omarchy needs at least 32GB." "$disk" "$(to_gb $available)")"
say "$(omarchy_text "Open the partition tool to free at least 32GB on %s, then try again." "$disk")"
step "Not enough free space on $disk"
say --foreground 1 "$disk has $(to_gb $available) of usable free space; Omarchy needs at least 32GB."
say "Open the partition tool to free at least 32GB on $disk, then try again."
echo
if ! gum confirm --affirmative "$(omarchy_text "Back")" --negative "$(omarchy_text "Open partition tool")" "$(omarchy_text "Return to installation mode?")"; then
if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then
open_partition_tool
fi
return 1
@@ -631,14 +518,14 @@ not_enough_space() {
# encryption confirm. Sets the globals run_partition_execute needs. Returns 1
# to fall back to the install-mode picker.
run_partition_decide() {
step "$(omarchy_text "Checking for BitLocker on %s" "$disk")"
step "Checking for BitLocker on $disk"
local bl_part
bl_part=$(detect_bitlocker || true)
if [[ -n "$bl_part" ]]; then
say --foreground 1 "$(omarchy_text "BitLocker signature detected on %s." "$bl_part")"
say "$(omarchy_text "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again.")"
say "$(omarchy_text "Suspending BitLocker is not enough — the drive stays encrypted.")"
abort "$(omarchy_text "Aborted: BitLocker is enabled on this disk.")"
say --foreground 1 "BitLocker signature detected on $bl_part."
say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."
say "Suspending BitLocker is not enough — the drive stays encrypted."
abort "Aborted: BitLocker is enabled on this disk."
fi
# Omarchy always creates its own dedicated ESP in free space and never
@@ -647,17 +534,17 @@ run_partition_decide() {
# Windows ESP is far too small for our Unified Kernel Images, and a shared
# ESP forces the install unencrypted. A separate Linux ESP keeps Windows
# and Omarchy fully isolated and keeps LUKS on the table.
step "$(omarchy_text "Checking existing EFI partitions on %s" "$disk")"
step "Checking existing EFI partitions on $disk"
local detected_win_esp
detected_win_esp=$(detect_windows_esp || true)
if [[ -n "$detected_win_esp" ]]; then
say "$(omarchy_text "Found a Windows ESP at %s — leaving it untouched." "$detected_win_esp")"
say "$(omarchy_text "Omarchy will create its own dedicated EFI partition in free space.")"
say "Found a Windows ESP at $detected_win_esp — leaving it untouched."
say "Omarchy will create its own dedicated EFI partition in free space."
else
say "$(omarchy_text "A new EFI partition will be created in free space.")"
say "A new EFI partition will be created in free space."
fi
step "$(omarchy_text "Analyzing free space on %s" "$disk")"
step "Analyzing free space on $disk"
partprobe "$disk" 2>/dev/null || true
sleep 1
@@ -732,21 +619,21 @@ run_partition_decide() {
while true; do
clear_logo
echo
say "$(omarchy_text "Install Omarchy in the %s of free space." "$(to_gb $INSTALL_MAX_B)")"
say "Install Omarchy in the $(to_gb $INSTALL_MAX_B) of free space."
case $mode in
encrypted)
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
affirmative="$(omarchy_text "Yes, install")"
say --foreground 8 "Press Ctrl+C for unencrypted install."
affirmative="Yes, install"
;;
unencrypted)
affirmative="$(omarchy_text "Yes, install without encryption")"
affirmative="Yes, install without encryption"
;;
esac
echo
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" \
"$(omarchy_text "Confirm installing on %s" "$disk")"
gum confirm --affirmative "$affirmative" --negative "No, change it" \
"Confirm installing on $disk"
confirm_status=$?
case $confirm_status in
@@ -804,7 +691,7 @@ run_partition_execute() {
say "[dry] partition numbers are assigned by parted at creation, then read back"
say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice"
else
step "$(omarchy_text "Creating partitions on %s" "$disk")"
step "Creating partitions on $disk"
if $needs_mklabel; then
disk_step "initializing GPT on $disk" parted --script "$disk" mklabel gpt
partprobe "$disk" 2>/dev/null || true
@@ -842,7 +729,7 @@ run_partition_execute() {
disk_step "clearing stale signatures on $root_partition_device" wipefs -af "$root_partition_device"
if [[ "$encrypt_installation" == "true" ]]; then
step "$(omarchy_text "Setting up LUKS2 on %s" "$root_partition_device")"
step "Setting up LUKS2 on $root_partition_device"
# Kept as pipes rather than routed through disk_step: the passphrase must
# not become an argv the process table can show. Folding stderr into
# stdout still lands any error in the install log.
@@ -859,7 +746,7 @@ run_partition_execute() {
root_mapper="$root_partition_device"
fi
step "$(omarchy_text "Creating Btrfs filesystem and subvolumes")"
step "Creating Btrfs filesystem and subvolumes"
disk_step "creating the Btrfs filesystem on $root_mapper" \
mkfs.btrfs -f -L OMARCHY "$root_mapper"
wait_for_device "$root_mapper" || disk_abort_hook "Root device $root_mapper never appeared"
@@ -913,8 +800,6 @@ run_partition_execute() {
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
"custom_commands": [],
"omarchy_install": {
"input_method": "${keyboard_input_method:-none}",
"input_xkb_layout": "${keyboard_xkb_layout:-}",
"mode": "protected",
"target_mount": "/mnt",
"boot": {
@@ -947,7 +832,7 @@ run_partition_execute() {
"locale_config": {
"kb_layout": "$keyboard",
"sys_enc": "UTF-8",
"sys_lang": "$(target_lang)"
"sys_lang": "en_US.UTF-8"
},
"mirror_config": {
"custom_repositories": [],
@@ -977,7 +862,7 @@ _EOF_
}
disk_form() {
step "$(omarchy_text "Let's select where to install Omarchy...")"
step "Let's select where to install Omarchy..."
# Don't offer the install media as an option (Arch ISO mounts it here)
local boot_source
@@ -1003,19 +888,19 @@ disk_form() {
fi
done <<<"$available_disks"
selected_display=$(echo "$disk_options" | gum choose --header "$(omarchy_text "Select install disk")") || abort
selected_display=$(echo "$disk_options" | gum choose --header "Select install disk") || abort
disk=$(echo "$selected_display" | awk '{print $1}')
}
# STEP 4: INSTALL MODE
open_partition_tool() {
step "$(omarchy_text "Partition tool for %s" "$disk")"
gum style "$(omarchy_text "Create unallocated free space for Omarchy, then write changes and quit.")"
gum style --foreground 8 "$(omarchy_text "Do not create an Omarchy partition here — leave the target area as Free space.")"
gum style --foreground 8 "$(omarchy_text "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists.")"
step "Partition tool for $disk"
gum style "Create unallocated free space for Omarchy, then write changes and quit."
gum style --foreground 8 "Do not create an Omarchy partition here — leave the target area as Free space."
gum style --foreground 8 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."
echo
gum confirm --affirmative "$(omarchy_text "Open cfdisk")" --negative "$(omarchy_text "Back")" "$(omarchy_text "Open partition tool for %s?" "$disk")" || return 0
gum confirm --affirmative "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0
clear
cfdisk "$disk" || true
@@ -1048,17 +933,17 @@ requires_full_disk_install() {
}
install_mode_form() {
local choices=("$(omarchy_text "Full disk install")")
local choices=("Full disk install")
# The free-space/protected path registers an EFI boot entry and is UEFI-only.
# Full-disk installs still support BIOS through the orchestrator's BIOS branch.
if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then
choices+=("$(omarchy_text "Free space install (alongside existing data)")")
choices+=("Free space install (alongside existing data)")
fi
choices+=("$(omarchy_text "Choose a different disk")")
choices+=("Choose a different disk")
step "$(omarchy_text "Let's select how to install Omarchy...")"
install_mode=$(gum choose --header "$(omarchy_text "Select installation mode on %s" "$disk")" "${choices[@]}") || abort
step "Let's select how to install Omarchy..."
install_mode=$(gum choose --header "Select installation mode on $disk" "${choices[@]}") || abort
}
confirm_disk_overwrite() {
@@ -1068,17 +953,17 @@ confirm_disk_overwrite() {
while true; do
clear_logo
echo
say "$(omarchy_text "Everything will be overwritten. There is no recovery possible.")"
say "Everything will be overwritten. There is no recovery possible."
if [[ $mode == "encrypted" ]]; then
say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")"
affirmative="$(omarchy_text "Yes, install")"
say --foreground 8 "Press Ctrl+C for unencrypted install."
affirmative="Yes, install"
else
affirmative="$(omarchy_text "Yes, install without encryption")"
affirmative="Yes, install without encryption"
fi
echo
gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Confirm overwriting %s" "$disk")"
gum confirm --affirmative "$affirmative" --negative "No, change it" "Confirm overwriting ${disk}"
confirm_status=$?
case $confirm_status in
@@ -1117,13 +1002,13 @@ select_installation() {
# With no free-space option to offer, a full-disk install is the only
# mode, so skip the picker and go straight to the overwrite confirm.
if $full_disk_only; then
install_mode=$(omarchy_text "Full disk install")
install_mode="Full disk install"
else
install_mode_form
fi
case "$install_mode" in
"$(omarchy_text "Full disk install")")
"Full disk install")
if confirm_disk_overwrite; then
install_target="full_disk"
return 0
@@ -1132,13 +1017,13 @@ select_installation() {
# wants a different disk.
$full_disk_only && disk_form
;;
"$(omarchy_text "Free space install (alongside existing data)")")
"Free space install"*)
if run_partition_decide; then
install_target="free_space"
return 0
fi
;;
"$(omarchy_text "Choose a different disk")")
"Choose a different disk")
disk_form
;;
esac
@@ -1149,20 +1034,12 @@ select_installation() {
defer_provisioning=false
install_target="full_disk"
# A rerun arrives with the language chosen; otherwise English.
language="${OMARCHY_INSTALL_LANGUAGE:-en}"
language_chosen=false
# Let the live VT reach its settled width before the first screen so the logo
# isn't measured against the transient 80-column boot console.
wait_for_stable_terminal
if [[ -n ${OMARCHY_INSTALL_LANGUAGE:-} ]]; then
apply_language
language_chosen=true
else
greeter
fi
greeter
keyboard_form
@@ -1179,8 +1056,6 @@ if $defer_provisioning; then
# their real layout in the OOBE, which re-keys the console and, on encrypted
# installs, rebuilds the initramfs so the LUKS prompt matches.
keyboard="us"
keyboard_input_method="none"
keyboard_xkb_layout=""
username=""
password=""
password_hash=""
@@ -1263,8 +1138,6 @@ cat <<-_EOF_ >user_configuration.json
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
"custom_commands": [],
"omarchy_install": {
"input_method": "${keyboard_input_method:-none}",
"input_xkb_layout": "${keyboard_xkb_layout:-}",
"mode": "full_disk",
"defer_provisioning": $defer_provisioning,
"target_mount": "/mnt",
@@ -1348,7 +1221,7 @@ cat <<-_EOF_ >user_configuration.json
"locale_config": {
"kb_layout": "$keyboard",
"sys_enc": "UTF-8",
"sys_lang": "$(target_lang)"
"sys_lang": "en_US.UTF-8"
},
"mirror_config": {
"custom_repositories": [],
@@ -38,7 +38,9 @@ CLEAR="${CSI}2J${CSI}H"
CLEAR_LINE="${CSI}2K"
CLEAR_TO_END="${CSI}J"
GREEN="${CSI}32m"
WHITE="${CSI}37m"
RED="${CSI}31m"
DARK="${CSI}90m"
# The dashboard owns the final reboot prompt now, so keep the gum confirm
# styling that used to come from install/helpers/presentation.sh.
@@ -51,22 +53,6 @@ export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0
child_pid=""
child_pgid=""
whimsy=(
"delivering delightful defaults"
"saving setup struggles"
"picking polished packages"
"curating cohesive colors"
"preparing pleasant pixels"
"hyping happy hyprland"
"integrating intuitive interfaces"
"bootstrapping beautiful boxes"
"crafting cozy commands"
"making machines marvelous"
"polishing pretty pixels"
"tuning tasteful terminals"
"weaving wonderful workflows"
)
tips=(
"Super + Space opens the Omarchy menu for apps, settings, and more"
"Super + K shows all the key bindings"
@@ -88,81 +74,6 @@ tips=(
"Keep the system fresh with Update in the Omarchy menu"
)
# omarchy_text and its Chinese table come from the setup form the configurator uses.
SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}"
[[ -r $SETUP_FORM ]] || SETUP_FORM="${OMARCHY_PATH:-/usr/share/omarchy}/install/provisioning/setup-form.sh"
source "$SETUP_FORM"
OMARCHY_ZH+=(
["Tip: %s"]="提示:%s"
["Installed Omarchy"]="Omarchy 安装完成"
["Installed Omarchy in %s"]="Omarchy 安装完成,用时 %s"
["Reboot Now"]="立即重启"
["Last log lines:"]="日志末尾:"
["Last target log lines:"]="目标系统日志末尾:"
["Press Enter to continue…"]="按回车继续……"
["No log uploader is available in this environment."]="这个环境没有日志上传工具。"
["Install log: %s"]="安装日志:%s"
["Target log: %s"]="目标系统日志:%s"
["Omarchy installation stopped"]="Omarchy 安装已停止"
["Installer exited with status %s"]="安装程序退出,状态码 %s"
["Get help at https://omarchy.org/discord"]="求助:https://omarchy.org/discord"
["What would you like to do?"]="接下来做什么?"
["Upload log for support"]="上传日志求助"
["View full log"]="查看完整日志"
["Drop to shell"]="进入 shell"
["Reboot"]="重启"
["Power off"]="关机"
["The install medium was misread"]="安装介质读取出错"
["This package is intact on this ISO, so the download and the write were fine:"]="这个软件包在 ISO 中完好,下载和写入都没有问题:"
["Try another USB port or another stick, and test this machine's memory"]="换一个 USB 口或换一个 U 盘,并检测这台机器的内存"
["The install medium is damaged"]="安装介质已损坏"
["This package no longer matches the checksum it was built with:"]="这个软件包和构建时的校验和不再一致:"
["Re-download the ISO, verify it with sha256sum, and write the USB again"]="重新下载 ISO,用 sha256sum 校验,再重新写入 U 盘"
["If a verified ISO fails here too, the USB stick itself is the problem"]="如果校验过的 ISO 在这里也失败,问题出在 U 盘本身"
["The install medium is damaged or misread"]="安装介质已损坏或读取出错"
["pacman rejected this package, read straight off this ISO:"]="pacman 拒绝了这个直接从 ISO 读出的软件包:"
["Verify your download with sha256sum, then write the USB again"]="用 sha256sum 校验下载的文件,再重新写入 U 盘"
["If the download verifies, the stick or this machine's memory is at fault"]="如果下载校验无误,问题出在 U 盘或这台机器的内存"
)
if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]]; then
whimsy=(
"备好贴心的默认设置"
"省掉装机的折腾"
"挑选打磨过的软件包"
"调配协调的配色"
"准备悦目的像素"
"唤醒 Hyprland"
"接好顺手的界面"
"搭起漂亮的系统"
"写好趁手的命令"
"让机器焕然一新"
"擦亮每一个像素"
"调好终端的品味"
"织出流畅的工作流"
)
tips=(
"Super + Space 打开 Omarchy 菜单:应用、设置等"
"Super + K 查看全部快捷键"
"Super 是键盘上的 Windows 或 Command 键"
"用 Xournal++ 给 PDF 签名"
"用 LocalSend 和手机、电脑互传文件"
"菜单 安装 > 网页应用 把任意网站变成应用"
"Super + Return 打开终端,Super + Shift + Return 打开浏览器"
"图片用 Pinta,视频用 Kdenlive,文档用 LibreOffice"
"Super + Ctrl + Print 用 OCR 抓取屏幕文字"
"Print 截屏,Alt + Print 录屏"
"Super + Ctrl + R 设置提醒"
"菜单 风格 > 主题 切换主题"
"双击菜单栏让它透明"
"菜单 安装 > Windows 运行完整的 Windows 虚拟机"
"Super + Ctrl + V 打开剪贴板管理器"
"Super + 1 到 0 切换工作区,加 Shift 带上窗口"
"Super + Print 拾取屏幕上任意位置的颜色"
"用菜单里的 更新 让系统保持最新"
)
fi
cleanup() {
printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true
}
@@ -204,58 +115,10 @@ trap 'interrupt 143' TERM
term_size() { stty size 2>/dev/null <"$TTY_PATH" || printf '24 80\n'; }
term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; }
# Read the actual character dimensions instead of assuming an 8 x 16 font.
# Linux VTs expose the font through KDFONTOP; desktop terminals may expose
# pixels in their window size or answer the standard cell-size query.
term_cell_size() {
python3 - "$TTY_PATH" <<'PY'
import fcntl
import os
import re
import select
import struct
import sys
import termios
import time
import tty
with open(sys.argv[1], "r+b", buffering=0) as terminal:
fd = terminal.fileno()
rows, cols, width, height = struct.unpack(
"HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))
if rows and cols and width and height:
print(round(width / cols), round(height / rows))
sys.exit()
try:
font = bytearray(struct.pack("@5IP", 1, 0, 0, 0, 0, 0))
fcntl.ioctl(fd, 0x4B72, font) # KDFONTOP / KD_FONT_OP_GET, no glyph data
_, _, width, height, _ = struct.unpack_from("@5I", font)
if width and height:
print(width, height)
sys.exit()
except OSError:
pass
settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd, termios.TCSANOW)
os.write(fd, b"\x1b[16t")
response = b""
deadline = time.monotonic() + 0.15
while select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]:
response += os.read(fd, 64)
match = re.search(rb"\x1b\[6;(\d+);(\d+)t", response)
if match:
height, width = map(int, match.groups())
if width and height:
print(width, height)
sys.exit()
if time.monotonic() >= deadline:
break
finally:
termios.tcsetattr(fd, termios.TCSANOW, settings)
print(8, 16) # Conventional console font when dimensions aren't available.
PY
repeat() {
local char="$1" count="$2" out="" i
for ((i=0; i<count; i++)); do out+="$char"; done
printf '%s' "$out"
}
logo_width() {
@@ -288,16 +151,22 @@ LOGO_HEIGHT="$(logo_height)"
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH"
# Rows the progress screen occupies: logo, blank, title, blank, bar, blank, tip.
PROGRESS_HEIGHT=$((LOGO_HEIGHT + 6))
TOP_ROW=1
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_SIZE=""
LAST_PM=10
# Wordmark, blank, title, and the three rows gum confirm uses.
# The finish screen mirrors the greeter's frame — logo, blank, one line, gap,
# one button — so the last thing the installer shows matches the first. It runs
# one row taller: `gum confirm` always paints prompt/blank/button and clears all
# three, so the button cannot sit closer than two rows under a title we drew.
FINISH_HEIGHT=$((LOGO_HEIGHT + 5))
FINISH_TOP_ROW=2
# ── Install progress ─────────────────────────────────────────────────────────
#
# Position = max(floor, work), both non-decreasing, in per-mille of the drawing.
# Position = max(floor, work), both non-decreasing, in per-mille of the bar.
#
# floor lo + (span-1) * t / (t+tau), on time since this process saw the
# phase. Asymptotic, so it approaches the band end without reaching it.
@@ -320,8 +189,8 @@ fi
# A wrong denominator is worse than none: it never triggers the fallback.
(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0
PKG_BONUS_MAX_PM=100 # cap on how far progress may run ahead of the count
PKG_TAIL_PM=40 # band held back so stalled progress has somewhere to go
PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count
PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go
PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the
# first package, so slow it until it cannot outrun data
@@ -361,8 +230,8 @@ left_padding() {
visible_len() {
local text="$1"
# wc -L counts display columns, so double-width CJK centers too.
printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | wc -L
text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')"
printf '%s' "${#text}"
}
blank_line() { printf '\r%s\n' "$CLEAR_LINE"; }
@@ -394,161 +263,13 @@ render_logo() {
fi
}
# The icon uses half-block glyphs, scaled to the terminal's actual cell aspect
# ratio so its square silhouette stays square. No graphics protocol is needed.
# Coordinates follow the 15 x 15 grid of Omarchy's icon.svg. Each segment
# paints the full stroke width, starting at the bottom middle of the outer ring.
ICON_WIDTH=30
ICON_HEIGHT=15
CELL_WIDTH=8
CELL_HEIGHT=16
ICON_LEFT=1
ICON_STEPS=0
ICON_DRAWN=0
DISPLAY_PM=0
ICON_FRAME=""
declare -a ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
icon_segment() {
local scale="$1" x1="$2" y1="$3" x2="$4" y2="$5"
local x y from to
if (( y1 == y2 )); then
from=$(( (x1 * ICON_WIDTH + 14) / 15 ))
to=$(( ((x2 + 1) * ICON_WIDTH + 14) / 15 ))
if (( x2 < x1 )); then
from=$(( ((x1 + 1) * ICON_WIDTH + 14) / 15 - 1 ))
to=$(( (x2 * ICON_WIDTH + 14) / 15 - 1 ))
fi
for ((x=from; x!=to; x+=(x2 >= x1 ? 1 : -1))); do
for ((y=y1 * scale; y<(y1 + 1) * scale; y++)); do
icon_pixel "$((y * ICON_WIDTH + x))"
done
done
else
from=$(( y1 * scale ))
to=$(( (y2 + 1) * scale ))
if (( y2 < y1 )); then
from=$(( (y1 + 1) * scale - 1 ))
to=$(( y2 * scale - 1 ))
fi
for ((y=from; y!=to; y+=(y2 >= y1 ? 1 : -1))); do
for ((x=(x1 * ICON_WIDTH + 14) / 15; x<((x1 + 1) * ICON_WIDTH + 14) / 15; x++)); do
icon_pixel "$((y * ICON_WIDTH + x))"
done
done
fi
}
icon_pixel() {
local pixel="$1"
if [[ ! -v ICON_TRACE[pixel] ]]; then
ICON_ORDER+=("$pixel")
ICON_TRACE[pixel]=${#ICON_ORDER[@]}
fi
}
build_icon() {
local scale="$1" x y row
local -a mask=(
111111111111111
100000010000001
101111110001101
101000000000101
101000000000101
101000000000101
101000000000101
111000000000101
101000000000101
101000000000101
101000000000101
101000000000101
101111111111101
100000010000001
111111110111111
)
ICON_WIDTH=$(( (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) ))
ICON_HEIGHT=$(((15 * scale + 1) / 2))
ICON_ORDER=() ICON_TRACE=() ICON_FILL=()
ICON_DRAWN=0
# Start at the bottom middle, follow the outer left edge to the top cross,
# then descend into the inner shape. Fill its two bridges as we reach them.
icon_segment "$scale" 7 14 0 14
icon_segment "$scale" 0 14 0 0
icon_segment "$scale" 0 0 7 0
icon_segment "$scale" 7 0 7 2
icon_segment "$scale" 7 2 2 2
icon_segment "$scale" 2 2 2 7
icon_segment "$scale" 2 7 0 7
icon_segment "$scale" 2 7 2 12
icon_segment "$scale" 2 12 7 12
icon_segment "$scale" 7 12 7 14
icon_segment "$scale" 7 12 12 12
icon_segment "$scale" 12 12 12 2
icon_segment "$scale" 12 2 11 2
# Finish from the top cross: right along the outside, down, then left.
icon_segment "$scale" 7 0 14 0
icon_segment "$scale" 14 0 14 14
icon_segment "$scale" 14 14 9 14
ICON_STEPS=${#ICON_ORDER[@]}
for ((y=0; y<15 * scale; y++)); do
row=${mask[y / scale]}
for ((x=0; x<ICON_WIDTH; x++)); do
ICON_FILL[y * ICON_WIDTH + x]=${row:x * 15 / ICON_WIDTH:1}
done
done
}
# Append one cell with independently colored upper/lower pixels. This also
# handles a green tracing tip sharing a cell with the still-dark outline.
icon_cell() {
local pixel="$1" drawn="$2" solid="$3" upper=0 lower=0 step
if (( solid )); then
(( ICON_FILL[pixel] )) && upper=32
(( ${ICON_FILL[pixel + ICON_WIDTH]:-0} )) && lower=32
else
step=${ICON_TRACE[pixel]:-0}
(( step > 0 )) && upper=90
(( step > 0 && step <= drawn )) && upper=32
step=${ICON_TRACE[pixel + ICON_WIDTH]:-0}
(( step > 0 )) && lower=90
(( step > 0 && step <= drawn )) && lower=32
fi
if (( upper == 0 && lower == 0 )); then
ICON_FRAME+="${RESET} "
elif (( upper == lower )); then
ICON_FRAME+="${CSI}${upper};49m█"
elif (( lower == 0 )); then
ICON_FRAME+="${CSI}${upper};49m▀"
elif (( upper == 0 )); then
ICON_FRAME+="${CSI}${lower};49m▄"
else
ICON_FRAME+="${CSI}${upper};$((lower + 10))m▀"
fi
}
render_icon() {
local pm="$1" full="${2:-0}" drawn i pixel row col
drawn=$((pm * ICON_STEPS / 1000))
ICON_FRAME=""
if (( full || pm == 1000 )); then
for ((row=0; row<ICON_HEIGHT; row++)); do
ICON_FRAME+="${CSI}$((TOP_ROW + row));${ICON_LEFT}H"
for ((col=0; col<ICON_WIDTH; col++)); do
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" "$((pm == 1000))"
done
done
else
# Between layout changes, repaint only newly traced pixels. This keeps the
# animation cheap even while pacstrap saturates the disk.
for ((i=ICON_DRAWN; i<drawn; i++)); do
pixel=${ICON_ORDER[i]}
row=$((pixel / ICON_WIDTH / 2)) col=$((pixel % ICON_WIDTH))
ICON_FRAME+="${CSI}$((TOP_ROW + row));$((ICON_LEFT + col))H"
icon_cell "$((row * 2 * ICON_WIDTH + col))" "$drawn" 0
done
fi
printf '%s%s' "$ICON_FRAME" "$RESET"
ICON_DRAWN=$drawn
progress_bar() {
local pm="$1" width="${2:-34}" filled empty
(( pm < 0 )) && pm=0
(( pm > 1000 )) && pm=1000
filled=$(( pm * width / 1000 ))
empty=$(( width - filled ))
printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET"
}
# Packages libalpm has finished installing. local/ holds one directory per
@@ -569,7 +290,7 @@ count_packages() {
# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer
# and the package counters must survive between frames. Print nothing — output
# here would land on the TTY between the cursor escape and the drawing.
# here would land on the TTY between the cursor escape and the bar.
install_progress() {
local phase finished index total target
local lo hi tau span t pos work gap n pkg_signal=0
@@ -656,7 +377,7 @@ install_progress() {
PKG_DB_DIR="$target/var/lib/pacman/local"
count_packages
# Baseline on first sight: a target that already holds packages would
# otherwise pin progress at the band ceiling for the whole phase.
# otherwise pin the bar at the band ceiling for the whole phase.
(( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT
n=$(( PKG_COUNT - PKG_BASE ))
(( n < 0 )) && n=0
@@ -680,20 +401,17 @@ install_progress() {
return 0
}
# Keep the original loading messages on their own clock, so progress jumps
# cannot skip phrases and animation frame rate does not change their cadence.
current_whimsy() {
printf '%s' "${whimsy[$(( (NOW - DASH_T0) / 4 % ${#whimsy[@]} ))]}"
}
# One tip per 8 seconds, in order. Use elapsed time, since rendering can stretch
# under pacstrap I/O, when the tips may be the most visible sign of activity.
# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame
# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the
# machine where the tips, not the bar, are carrying liveness.
current_tip() {
printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}"
}
# Vertically center a block of the given height, echoing the row it starts on.
# Center both the progress icon and the final wordmark on the console.
# Both screens here center the way the configurator's greeter centers its
# welcome, so the install opens, runs, and closes on the same composition
# rather than stacking each frame from row one.
centered_top_row() {
local height="$1" rows top
rows="$(term_size)"
@@ -705,60 +423,42 @@ centered_top_row() {
}
measure_progress_layout() {
local rows cols scale=1
LAST_SIZE="$(term_size)"
read -r rows cols <<<"$LAST_SIZE"
read -r CELL_WIDTH CELL_HEIGHT < <(term_cell_size 2>/dev/null || printf '8 16\n')
(( rows >= 34 && cols >= 49 )) && scale=2
(( rows >= 50 && cols >= 64 )) && scale=3
while (( scale > 1 && (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) > cols - 4 )); do
scale=$((scale - 1))
done
build_icon "$scale"
TOP_ROW="$(centered_top_row "$((ICON_HEIGHT + 6))")"
ICON_LEFT=$(( (cols - ICON_WIDTH) / 2 + 1 ))
(( ICON_LEFT < 1 )) && ICON_LEFT=1
PROGRESS_TEXT_WIDTH=$((cols - 2))
(( PROGRESS_TEXT_WIDTH > CONTENT_WIDTH )) && PROGRESS_TEXT_WIDTH=$CONTENT_WIDTH
return 0
TOP_ROW="$(centered_top_row "$PROGRESS_HEIGHT")"
DYNAMIC_ROW=$(( TOP_ROW + LOGO_HEIGHT + 1 ))
}
render_static() {
measure_progress_layout
printf '%s' "$CLEAR"
render_icon "$DISPLAY_PM" 1
}
update_progress() {
set_now
install_progress
local pm=$PROGRESS_PM
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
# Only the child's successful exit can complete the drawing.
(( pm > 999 )) && pm=999
# POS is monotone by construction; this is a guard, not the mechanism.
(( pm < LAST_PM )) && pm=$LAST_PM
LAST_PM=$pm
printf '%s%s%d;1H' "$CLEAR" "$CSI" "$TOP_ROW"
render_logo
blank_line
}
render_dynamic() {
local step message tip
# The framebuffer can settle late in VMs. Rebuild at the new size, preserving
# progress, instead of leaving the drawing at the old screen coordinates.
local pm
# The console can still resize under us (the framebuffer settles late in VMs).
# Horizontal padding is recomputed every frame anyway, but the logo is drawn
# once, so redraw the static half at the new center rather than leave the
# centered layout stranded against a stale geometry.
[[ "$(term_size)" == "$LAST_SIZE" ]] || render_static
# Ease phase jumps over several frames, with the percentage following the
# same position as the trace. The measured progress still never goes back.
step=$(( (LAST_PM - DISPLAY_PM + 3) / 4 ))
DISPLAY_PM=$((DISPLAY_PM + step))
render_icon "$DISPLAY_PM"
set_now
install_progress
pm=$PROGRESS_PM
[[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM
(( pm > 1000 )) && pm=1000
# POS is monotone by construction; this is a guard, not the mechanism.
(( pm < LAST_PM )) && pm=$LAST_PM
LAST_PM=$pm
message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")"
tip="$(truncate_for_width "$(omarchy_text "Tip: %s" "$(current_tip)")" "$PROGRESS_TEXT_WIDTH")"
printf '%s%d;1H' "$CSI" "$((TOP_ROW + ICON_HEIGHT + 1))"
center "${GREEN}$((DISPLAY_PM / 10))%${RESET}" "$PROGRESS_TEXT_WIDTH"
center "${GREEN}${message}${RESET}" "$PROGRESS_TEXT_WIDTH"
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Installing Omarchy" "$CONTENT_WIDTH"
blank_line
center "${DIM}${tip}${RESET}" "$PROGRESS_TEXT_WIDTH"
line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) ""
progress_bar "$pm" 34
printf '\n'
blank_line
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH"
printf '%s' "$CLEAR_TO_END"
}
@@ -793,15 +493,13 @@ install_duration() {
}
render_finish() {
local duration effect_canvas_width title
local duration effect_canvas_width
duration="$(install_duration || true)"
if [[ -n $duration ]]; then
title=$(omarchy_text "Installed Omarchy in %s" "$duration")
else
title=$(omarchy_text "Installed Omarchy")
fi
# ttfx centers its text two columns right of plain centering. A canvas two
# columns narrower aligns the animation with the title and fallback logo.
duration="${duration:-Complete}"
# ttfx centers its text two columns right of plain centering, so hand it a
# canvas two columns narrower and the animated logo lands exactly on the
# static one left_padding already drew. Otherwise the logo jumps a column
# sideways when the effect starts and again when it ends.
effect_canvas_width="$(term_cols)"
(( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2))
@@ -810,21 +508,23 @@ render_finish() {
FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")"
{
# Leave the wordmark area blank: laseretch reveals it for the first time.
printf '%s%s%s%d;1H' "$HIDE_CURSOR" "$CLEAR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 1))"
center "$title" "$CONTENT_WIDTH"
printf '%s%s%s%d;1H' "$SHOW_CURSOR" "$CLEAR" "$CSI" "$FINISH_TOP_ROW"
render_logo
blank_line
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
blank_line
} >"$TTY_PATH"
if [[ -f $LOGO_PATH ]]; then
# Reuse mode restores a saved cursor before moving up over its input. Save
# this anchor so the effect occupies the reserved wordmark rows.
# this anchor so the effect redraws the same logo rows shown above.
printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH"
# Laseretch runs ~4s at 260fps, so the 8s cap is a stuck-process guard rather
# than a trim. --xterm-colors keeps the whole effect in the 256-index palette
# the way the greeter does, since the console reduces indexed codes well and
# truecolor ones badly. It settles on indexed 2, the Tokyo Night green used
# throughout the installer. The laser and sparks keep their own hot palette.
# truecolor ones badly, and it settles on indexed 2 — the green gum already
# drew the logo in above, so the send-off ends on the frame it started from
# instead of recoloring it. The laser and sparks keep their own hot palette.
timeout 8s ttfx -i "$LOGO_PATH" \
--canvas-width "$effect_canvas_width" \
--anchor-text c \
@@ -833,18 +533,13 @@ render_finish() {
--xterm-colors \
laseretch \
--final-gradient-stops 2 \
>"$TTY_PATH" 2>/dev/null || {
dashboard_log "logo effect skipped (ttfx exited $?)"
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
}
else
{ printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH"
>"$TTY_PATH" 2>/dev/null || dashboard_log "logo effect skipped (ttfx exited $?)"
printf '%s%d;1H' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
fi
printf '%s%s%d;1H' "$SHOW_CURSOR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH"
}
reboot_prompt() {
local prompt_pad label
local prompt_pad
# Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still
# applies, so setting both stops on the finish screen without a keyboard.
@@ -855,31 +550,24 @@ reboot_prompt() {
# gum allows. FINISH_HEIGHT budgets for the same three rows.
printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH"
# gum pads the button label by four columns on each side.
label=$(omarchy_text "Reboot Now")
prompt_pad=$(( ( $(term_cols) - $(visible_len "$label") - 8 ) / 2 ))
prompt_pad=$(( ( $(term_cols) - 18 ) / 2 ))
(( prompt_pad < 0 )) && prompt_pad=0
gum confirm \
--padding "0 0 0 $prompt_pad" \
--show-help=false \
--default \
--affirmative "$label" \
--affirmative "Reboot Now" \
--negative "" \
"" <"$TTY_PATH" >"$TTY_PATH" 2>&1
}
# Columns, not characters: each CJK character takes two.
truncate_for_width() {
local text="$1" width="$2"
if (( $(printf '%s' "$text" | wc -L) <= width )); then
if (( ${#text} > width )); then
printf '%s…' "${text:0:$((width - 1))}"
else
printf '%s' "$text"
return
fi
text=${text:0:$((width - 1))}
while (( $(printf '%s' "$text" | wc -L) > width - 1 )); do
text=${text%?}
done
printf '%s…' "$text"
}
render_failure_log_tail() {
@@ -905,8 +593,8 @@ render_failure_log_tail() {
fi
[[ -f $tail_log ]] || return 0
label=$(omarchy_text "Last log lines:")
[[ $tail_log == /mnt/* ]] && label=$(omarchy_text "Last target log lines:")
label="Last log lines:"
[[ $tail_log == /mnt/* ]] && label="Last target log lines:"
line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}"
printf '\n'
tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do
@@ -929,7 +617,7 @@ find_log_uploader() {
}
prompt_enter() {
printf '\n%s' "$(omarchy_text "Press Enter to continue…")" >"$TTY_PATH"
printf '\nPress Enter to continue…' >"$TTY_PATH"
IFS= read -r _ <"$TTY_PATH" || true
}
@@ -940,9 +628,9 @@ upload_failure_log() {
"$uploader" install >"$TTY_PATH" 2>&1 || true
else
{
printf '%s\n' "$(omarchy_text "No log uploader is available in this environment.")"
printf '%s\n' "$(omarchy_text "Install log: %s" "$LOG_FILE")"
[[ -f /mnt/var/log/omarchy-install.log ]] && printf '%s\n' "$(omarchy_text "Target log: %s" /mnt/var/log/omarchy-install.log)"
echo "No log uploader is available in this environment."
echo "Install log: $LOG_FILE"
[[ -f /mnt/var/log/omarchy-install.log ]] && echo "Target log: /mnt/var/log/omarchy-install.log"
} >"$TTY_PATH"
fi
prompt_enter
@@ -993,8 +681,6 @@ render_media_diagnosis() {
blank_line
continue
fi
# The support log keeps English; the screen shows the translation, and package names pass through.
[[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]] && line=${OMARCHY_ZH[$line]:-$line}
if (( first )); then
center "${RED}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH"
first=0
@@ -1010,8 +696,8 @@ render_failure() {
blank_line
render_logo
blank_line
center "${RED}$(omarchy_text "Omarchy installation stopped")${RESET}" "$CONTENT_WIDTH"
center "$(omarchy_text "Installer exited with status %s" "$status")" "$CONTENT_WIDTH"
center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH"
center "Installer exited with status $status" "$CONTENT_WIDTH"
render_media_diagnosis "$media_diagnosis"
@@ -1026,12 +712,12 @@ render_failure() {
blank_line
render_failure_log_tail "$(media_diagnosis_rows "$media_diagnosis")"
blank_line
center "${DIM}$(omarchy_text "Get help at https://omarchy.org/discord")${RESET}" "$CONTENT_WIDTH"
center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH"
blank_line
}
failure_menu() {
local choice uploader actions shown i
local choice uploader
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
# The failure screen and log tail have already rendered; exit with the
@@ -1041,20 +727,26 @@ failure_menu() {
while true; do
uploader=""
uploader=$(find_log_uploader || true)
actions=("View full log" "Drop to shell" "Reboot" "Power off")
[[ -n $uploader ]] && actions=("Upload log for support" "${actions[@]}")
shown=()
for i in "${!actions[@]}"; do
shown+=("$(omarchy_text "${actions[i]}")")
done
choice=$(gum choose \
--height "$((${#actions[@]} + 1))" \
--header "$(omarchy_text "What would you like to do?")" \
"${shown[@]}" \
<"$TTY_PATH" 2>"$TTY_PATH") || choice=""
for i in "${!shown[@]}"; do
[[ ${shown[i]} == "$choice" ]] && choice=${actions[i]}
done
if [[ -n $uploader ]]; then
choice=$(gum choose \
--height 6 \
--header "What would you like to do?" \
"Upload log for support" \
"View full log" \
"Drop to shell" \
"Reboot" \
"Power off" \
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
else
choice=$(gum choose \
--height 5 \
--header "What would you like to do?" \
"View full log" \
"Drop to shell" \
"Reboot" \
"Power off" \
<"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell"
fi
case "$choice" in
"Upload log for support")
@@ -1113,13 +805,9 @@ PHASE_T0=$NOW
PKG_LAST_CHANGE=$NOW
launch_child
frame=0
while jobs -pr | grep -qx "$child_pid"; do
# Keep package counting/state reads at 2 Hz; animate the trace at 10 Hz.
(( frame % 5 == 0 )) && update_progress
render_dynamic >"$TTY_PATH" 2>/dev/null || true
frame=$((frame + 1))
sleep 0.1
sleep 0.5
done
set +e
@@ -1132,14 +820,8 @@ child_pgid=""
dashboard_log "installer child exited with status $status"
if (( status == 0 )); then
# Finish the trace even when a short-lived child exits between state polls.
LAST_PM=1000
PHASE_NAME="Installation complete"
while (( DISPLAY_PM < 999 )); do
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.05
done
DISPLAY_PM=1000
# finished_at lands microseconds before the child exits, so the 0.5s poll
# rarely renders a full bar before the finish screen replaces it.
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.4
fi
@@ -1,29 +0,0 @@
# The Chinese installer's console: the VT's Tokyo Night palette, with Noto Sans Mono CJK from the offline mirror.
[main]
font=Noto Sans Mono CJK SC:size=13
# Noto's tall default rows leave the summary too few lines for the logo.
line-height=22px
[colors-dark]
background=1a1b26
foreground=a9b1d6
regular0=1a1b26
regular1=f7768e
regular2=9ece6a
regular3=e0af68
regular4=7aa2f7
regular5=bb9af7
regular6=7dcfff
regular7=a9b1d6
bright0=414868
bright1=f7768e
bright2=9ece6a
bright3=e0af68
bright4=7aa2f7
bright5=bb9af7
bright6=7dcfff
bright7=c0caf5
# cage draws no title bar, and a reserved one leaves a black strip.
[csd]
preferred=none
@@ -13,7 +13,7 @@ The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
# configs do their own mounting before the Installer context opens.
if disk_config.config_type != DiskLayoutType.Pre_mount:
inst.mount_ordered_layout()
sanity_check(inst)
inst.sanity_check(offline=, skip_ntp=, skip_wkd=)
inst.generate_key_files() # encrypted only
inst.set_mirrors(handler, mirror_config, on_target=False)
inst.minimal_installation(...) # base + linux pacstrap
@@ -214,14 +214,6 @@ def _method_accepts(method, name: str) -> bool:
return name in (*positional, *kwonly)
def sanity_check(installer) -> None:
"""Archinstall 4.5 removed offline; retain it for older releases."""
options = {"skip_ntp": True, "skip_wkd": True}
if _method_accepts(installer.sanity_check, "offline"):
options["offline"] = True
installer.sanity_check(**options)
def _method_accepts_users(method) -> bool:
return _method_accepts(method, "users")
@@ -11,9 +11,6 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .keyboard import validate_input_selection
from .region import GLOBAL, resolve_region
@dataclass
class InstallContext:
@@ -30,7 +27,6 @@ class InstallContext:
arch_config_path: Path
omarchy_install: dict[str, Any]
defer_provisioning: bool = False
region: str = GLOBAL
target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy")
@@ -54,20 +50,6 @@ class InstallContext:
user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
# Autoinstall configs may omit kernels, which makes archinstall default
# to stock linux. Apply the same hardware default as the configurator,
# while honoring an explicit selection (including storage-only configs).
if not user_configuration.get("kernels"):
kernel = (omarchy_install.get("storage") or {}).get("kernel") or _default_kernel()
user_configuration["kernels"] = [kernel]
validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", ""))
# Resolved before any disk is touched, so an autoinstall naming a
# region this ISO lacks fails without wiping anything.
region = resolve_region(omarchy_install, user_configuration.get("timezone"))
omarchy_install["region"] = region
# Deferred provisioning: the whole system installs but user creation is deferred to
# first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by
# an `defer-provisioning` marker file on an autoinstall drive, which also replaces the
@@ -132,7 +114,6 @@ class InstallContext:
arch_config_path=arch_config_path,
omarchy_install=omarchy_install,
defer_provisioning=defer_provisioning,
region=region,
state_dir=state_dir,
)
disk_config = user_configuration.get("disk_config", {})
@@ -194,18 +175,6 @@ def _inject_provisioning_encryption_password(arch_configuration: dict, user_cred
user_credentials["encryption_password"] = password
def _default_kernel(pci_devices: Path = Path("/sys/bus/pci/devices")) -> str:
for device in pci_devices.glob("*"):
try:
vendor = (device / "vendor").read_text().strip().lower()
device_id = (device / "device").read_text().strip().lower()
except OSError:
continue
if vendor == "0x106b" and device_id in {"0x1801", "0x1802"}:
return "linux-t2"
return "linux-omarchy"
def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]:
disk_config = user_configuration.get("disk_config", {})
mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk"
@@ -16,31 +16,21 @@ from pathlib import Path
from .command import capture
def configure_keyboard(target: Path, language: str, input_method: str | None = None, xkb_layout: str = "") -> bool:
def configure_keyboard(target: Path, language: str) -> bool:
"""Write the console keymap into a mounted target without booting it.
Returns False for layouts localectl doesn't know, matching archinstall:
warn and keep the default. Every layout the configurator offers is known;
the guard is for the kb_layout an autoinstall drive can name freely.
"""
validate_input_selection(input_method, xkb_layout)
if not language.strip():
if input_method is None:
return True
language = "us"
# Input engines are separate from real console keymaps. The shared picker
# uses US for Chinese/Korean and carries its preference in omarchy_install.
if input_method is None:
input_method = "mozc" if language == "jp106" else "none"
return True
result = capture(["localectl", "--no-pager", "list-keymaps"])
if result.returncode != 0:
detail = result.stderr.strip() or "localectl returned an error"
raise RuntimeError(f"Unable to list keyboard layouts: {detail}")
if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}:
if input_method != "none":
raise ValueError(f"Unknown keyboard language: {language}")
return False
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
@@ -61,15 +51,4 @@ def configure_keyboard(target: Path, language: str, input_method: str | None = N
if font:
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
preference = target / "etc/omarchy/input-method"
preference.parent.mkdir(parents=True, exist_ok=True)
preference.write_text(f"INPUT_METHOD={input_method}\nXKB_LAYOUT={xkb_layout}\n")
return True
def validate_input_selection(input_method: str | None, xkb_layout: str) -> None:
if input_method is not None and input_method not in {"none", "mozc", "hangul", "pinyin", "chewing"}:
raise ValueError(f"Unknown input method: {input_method}")
if xkb_layout not in {"", "kr"}:
raise ValueError(f"Unknown input keyboard layout: {xkb_layout}")
@@ -36,7 +36,6 @@ from . import archinstall_adapter as arch
from .command import capture, capture_identifier, require_text
from .context import InstallContext
from .keyboard import configure_keyboard
from .region import GLOBAL, region_keyrings, region_packages
from .ui import error, info
@@ -236,7 +235,11 @@ def arch_install_system(ctx: InstallContext) -> None:
if not pre_mounted:
installer.mount_ordered_layout()
arch.sanity_check(installer)
installer.sanity_check(
offline=True,
skip_ntp=True,
skip_wkd=True,
)
if not pre_mounted and arch.is_encrypted(config):
installer.generate_key_files()
@@ -266,16 +269,7 @@ def arch_install_system(ctx: InstallContext) -> None:
pacman_config=config.pacman_config,
)
# Headers are optional kernel dependencies. Install them before any
# DKMS package so generic installs have them too, and module builds
# use the target kernel instead of the live ISO's kernel.
installer.add_additional_packages([f"{kernel}-headers" for kernel in config.kernels])
if not configure_keyboard(
installer.target, kb_layout,
ctx.omarchy_install.get("input_method"),
ctx.omarchy_install.get("input_xkb_layout", ""),
):
if not configure_keyboard(installer.target, kb_layout):
error(f"Invalid keyboard language specified: {kb_layout}")
if config.mirror_config:
@@ -299,11 +293,6 @@ def arch_install_system(ctx: InstallContext) -> None:
info("› installing Omarchy runtime + omarchy-base.packages")
installer.add_additional_packages(_runtime_package_list(ctx))
if region_pkgs := region_packages(ctx.region):
info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}")
installer.add_additional_packages(region_pkgs)
_write_region_marker(ctx)
# Tailscale is bundled in the offline mirror but only installed
# when an autoinstall drive staged an auth key; must happen here,
# while the mirror is still bind-mounted, not in the phase that
@@ -734,17 +723,6 @@ def _unmask_mkinitcpio_pacman_hooks(
info(f"warning: failed to restore pacman hook mask for {name}: {exc}")
def _write_region_marker(ctx: InstallContext) -> None:
"""Tell the runtime's pacman finalizer which profile to apply. Global
installs write nothing, so they stay identical to a region-less install."""
if ctx.region == GLOBAL:
return
marker = ctx.target / "etc/omarchy/region"
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text(ctx.region + "\n")
marker.chmod(0o644)
def _runtime_package_list(ctx: InstallContext) -> list[str]:
"""Selected Omarchy runtime package + every package in the ISO-bundled
base package list that isn't already installed early."""
@@ -812,7 +790,7 @@ def verify_protected_mounts(ctx: InstallContext) -> None:
esp_mp.mkdir(parents=True, exist_ok=True)
subprocess.run(["mount", esp_dev, str(esp_mp)], check=True)
info(f"› protected target verified: kernel={storage.get('kernel', 'linux-omarchy')} esp={boot['esp_mount']}")
info(f"› protected target verified: kernel={storage.get('kernel', 'linux')} esp={boot['esp_mount']}")
def _is_mountpoint(path: Path) -> bool:
@@ -1152,14 +1130,6 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
def run_system_finalizer(ctx: InstallContext) -> None:
# The region's keyring packages are installed by now, but the build
# container's GPG state is not inherited. Initialize the target's trust
# database with only this region's keys before the finalizer enables its
# online repositories; global installs never trust them.
if keyrings := region_keyrings(ctx.region):
_run_target_setup_command(ctx, ["pacman-key", "--init"])
_run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings])
if ctx.defer_provisioning:
cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"]
else:
@@ -1674,7 +1644,6 @@ def _tailscale_authkey(path: Path) -> str:
def validate_boot(ctx: InstallContext) -> None:
_assert_boot_hooks_restored(ctx)
_validate_kernel_headers(ctx)
boot = _boot_intent(ctx)
storage = _storage_intent(ctx)
@@ -1697,7 +1666,7 @@ def validate_boot(ctx: InstallContext) -> None:
default_limine = ctx.target / "etc" / "default" / "limine"
config_text = _limine_combined_config_text(ctx, default_limine.read_text())
uki_prefix = _limine_setting(config_text, "CUSTOM_UKI_NAME", "omarchy") or "omarchy"
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux-omarchy"])[0]
kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux"])[0]
if arch.has_uefi():
limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", "limine_x64.efi")
@@ -1773,19 +1742,6 @@ def _assert_boot_hooks_restored(ctx: InstallContext) -> None:
raise RuntimeError(f"{path} is missing — future kernel updates would ship no UKI")
def _validate_kernel_headers(ctx: InstallContext) -> None:
kernels = sorted((ctx.target / "usr/lib/modules").glob("*/pkgbase"))
if not kernels:
raise RuntimeError("no installed kernel found in target")
for pkgbase in kernels:
release = pkgbase.parent.name
header_release = pkgbase.parent / "build/include/config/kernel.release"
if not header_release.is_file():
raise RuntimeError(f"{pkgbase.read_text().strip()} ({release}) has no kernel headers")
if header_release.read_text().strip() != release:
raise RuntimeError(f"{pkgbase.read_text().strip()} headers do not match kernel {release}")
# Every kernel package leaves its pkgbase next to its modules, which is also
# the name limine-mkinitcpio-hook builds the UKI under.
def _installed_kernels(ctx: InstallContext) -> list[str]:
@@ -1,68 +0,0 @@
"""Region selection: which of the runtime's region profiles a target gets.
The builder copies every profile from the bundled runtime's default/regions/
into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a
region by hand: the timezone the user already chose selects it, and an
autoinstall config may name one explicitly in omarchy_install.region. Run as
`python -m orchestrator.region <timezone>` to print the region the installer
would use, which is how the configurator fills its summary.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REGIONS_DIR = Path("/usr/share/omarchy-iso/regions")
GLOBAL = "global"
def _entries(path: Path) -> list[str]:
if not path.is_file():
return []
lines = (line.strip() for line in path.read_text().splitlines())
return [line for line in lines if line and not line.startswith("#")]
def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str:
regions_dir = regions_dir or REGIONS_DIR
if timezone and regions_dir.is_dir():
for profile in sorted(regions_dir.iterdir()):
if timezone in _entries(profile / "timezones"):
return profile.name
return GLOBAL
def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str:
regions_dir = regions_dir or REGIONS_DIR
region = omarchy_install.get("region")
if region is None or region == "":
return region_for_timezone(timezone, regions_dir)
# Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first
# keeps a path like ".." from passing the directory test.
if not isinstance(region, str) or (
region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir())
):
raise RuntimeError(f"Unsupported region: {region!r}")
return region
def region_packages(region: str, regions_dir: Path | None = None) -> list[str]:
if region == GLOBAL:
return []
return _entries((regions_dir or REGIONS_DIR) / region / "packages")
def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]:
"""Pacman keyrings the profile ships, named the Arch way: <name>-keyring
installs /usr/share/pacman/keyrings/<name>.gpg."""
return [
package.removesuffix("-keyring")
for package in region_packages(region, regions_dir)
if package.endswith("-keyring")
]
if __name__ == "__main__":
print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None))
+4 -4
View File
@@ -13,10 +13,6 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[core]
Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
@@ -29,6 +25,10 @@ Include = /etc/pacman.d/mirrorlist
Server = https://mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
+4 -4
View File
@@ -13,10 +13,6 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/rc/$arch
[core]
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
@@ -29,6 +25,10 @@ Include = /etc/pacman.d/mirrorlist
Server = https://rc-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/rc/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
+4 -4
View File
@@ -13,10 +13,6 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch
[core]
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
@@ -29,6 +25,10 @@ Include = /etc/pacman.d/mirrorlist
Server = https://stable-mirror.omarchy.org/$repo/os/$arch
Include = /etc/pacman.d/mirrorlist
[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch
[arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
+8 -33
View File
@@ -14,9 +14,6 @@ ISO="$OMARCHY_INTEGRATION_ISO"
SSH_PORT="${OMARCHY_INTEGRATION_SSH_PORT:-2322}"
MEMORY="${OMARCHY_INTEGRATION_MEMORY:-8192}"
INSTALL_TIMEOUT="${OMARCHY_INTEGRATION_INSTALL_TIMEOUT:-2400}"
# cache=none keeps the host page cache out of the guest's disk writes, so
# install times measure the installer rather than the host (CI sets it).
DISK_CACHE="${OMARCHY_INTEGRATION_DISK_CACHE:-}"
NO_PREVIEW="${OMARCHY_INTEGRATION_NO_PREVIEW:-false}"
BOOT_TIMEOUT=600
@@ -169,7 +166,7 @@ start_vm() {
-m "$MEMORY" \
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
-drive if=pflash,format=raw,file="$ACTIVE_OVMF" \
-drive file="$disk",format=qcow2,if=none,id=drive0${DISK_CACHE:+,cache=$DISK_CACHE} \
-drive file="$disk",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-device virtio-vga \
-display none \
@@ -285,12 +282,8 @@ ssh_sudo() {
ssh_guest "echo $GUEST_PASSWORD | sudo -S -p '' bash -c $(printf %q "$1")"
}
# The deadline is wall-clock: under QEMU user networking every attempt can
# spend its whole ConnectTimeout waiting for a banner, so counting only the
# sleeps let a "300 s" wait run for ten minutes or more, silently.
wait_for_ssh() {
local timeout="$1" failure_name="${2:-failure-ssh-timeout}"
local started=$SECONDS next_note=30 progress_name
local timeout="$1" failure_name="${2:-failure-ssh-timeout}" waited=0
while ! ssh_guest true 2>/dev/null; do
if ! vm_running; then
@@ -298,20 +291,14 @@ wait_for_ssh() {
return 1
fi
if ((SECONDS - started >= timeout)); then
if ((waited >= timeout)); then
capture_console "$failure_name"
echo "Timed out after ${timeout}s waiting for SSH" >&2
return 1
fi
if ((SECONDS - started >= next_note)); then
printf -v progress_name 'waiting-ssh-%04ds' "$((SECONDS - started))"
capture_console "$progress_name"
echo " ... waiting for SSH ($((SECONDS - started))s)"
((next_note += 30))
fi
sleep 2
sleep 5
((waited += 5))
done
}
@@ -416,7 +403,7 @@ EOF
"efi_binary": "limine_x64.efi",
"enable_fallback": true
},
"storage": { "kernel": "linux-omarchy" }
"storage": { "kernel": "linux" }
},
"disk_config": {
"config_type": "default_layout",
@@ -461,7 +448,7 @@ EOF
]
},
"hostname": "$GUEST_HOSTNAME",
"kernels": [ "linux-omarchy" ],
"kernels": [ "linux" ],
"network_config": { "type": "iso" },
"ntp": true,
"parallel_downloads": 8,
@@ -569,28 +556,16 @@ install_phase() {
return 1
fi
# A line every 30 s so a slow runner never looks hung; a screenshot of
# the installer's dashboard every 2 min, which is enough to see progress.
if ((waited % 120 == 0)); then
printf -v progress_name 'success-install-progress-%04ds' "$waited"
capture_console "$progress_name"
echo " ... installing (${waited}s)"
fi
((waited % 30 == 0)) && echo " ... installing (${waited}s)"
sleep 10
((waited += 10))
done
# Keep the installer's own clock, its log and the first boot's numbers next
# to the base image while the system is reachable, so CI can report them
# without attaching the disk.
ssh_sudo "cat /var/log/omarchy-install-timing.json" >"$BASE_DIR/omarchy-install-timing.json" 2>/dev/null || true
ssh_sudo "cat /var/log/omarchy-install.log" >"$BASE_DIR/omarchy-install.log" 2>/dev/null || true
# SSH can answer before startup finishes, and systemd-analyze refuses until
# it has; wait (bounded) so the first-boot numbers are final.
ssh_guest "timeout 120 systemctl is-system-running --wait >/dev/null; systemd-analyze" \
>"$BASE_DIR/first-boot-systemd-analyze.txt" 2>/dev/null || true
log "Installed system is up. Saving base image."
stop_vm
mv "$BASE_DISK.building" "$BASE_DISK"
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
#
# Everyday boot: boots the installed system a second time and records how
# long it takes to answer SSH and what systemd-analyze reports, then asserts
# that systemd finished starting with no failed units. CI reports these next
# to the install time, so a change that slows booting shows up in its PR.
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
base_image_ready || { echo "No base image; run this through ./test/integration" >&2; exit 1; }
log "Booting the installed system"
boot_started=$(date +%s%N)
start_vm_from_base
if ! wait_for_ssh 300 failure-boot-ssh-timeout; then
# The console screenshot is saved; the serial log shows what the firmware,
# loader and kernel printed, which a screenshot of a hung boot often lacks.
echo "--- last lines of the serial console ---" >&2
tail -n 40 "$RUN_DIR/serial.log" >&2 2>/dev/null || true
exit 1
fi
boot_ms=$((($(date +%s%N) - boot_started) / 1000000))
# Coarse: wait_for_ssh polls every 2 s. systemd-analyze below has the exact
# firmware, loader, kernel and userspace split.
log "SSH answered within ${boot_ms} ms of power-on"
# --wait returns once startup has finished, so systemd-analyze has final
# numbers and a unit that fails late in boot is still counted. It exits
# non-zero for "degraded"; keep the answer so the diagnostics below are still
# collected and the assertion reports it.
state=$(ssh_guest "timeout 120 systemctl is-system-running --wait" 2>/dev/null | tr -d '\r' || true)
ssh_guest "systemd-analyze" >"$RUN_DIR/systemd-analyze.txt" 2>/dev/null || true
ssh_guest "systemd-analyze blame --no-pager | head -n 20" >"$RUN_DIR/systemd-analyze-blame.txt" 2>/dev/null || true
# A query that fails must not read as "no failed units".
ssh_guest "systemctl --failed --no-legend --plain" >"$RUN_DIR/failed-units.txt" 2>/dev/null ||
echo "could not query failed units over SSH" >"$RUN_DIR/failed-units.txt"
printf '{"boot_to_ssh_ms": %d, "system_state": "%s"}\n' "$boot_ms" "$state" >"$RUN_DIR/boot-timing.json"
check "systemd reports the system running (got: ${state:-nothing})" test "$state" = running
check "no failed units" test ! -s "$RUN_DIR/failed-units.txt"
capture_console "success-boot"
stop_vm
finish
+2 -2
View File
@@ -165,7 +165,7 @@ drive_first_boot() {
# first-boot greeter on its own. A numeric default_entry pointing at a
# moved entry parks Limine at the menu instead — catch that explicitly,
# then boot the Omarchy entry by hand so the rest of the flow still runs.
if wait_for_screen "Linux by DHH" 120 2>/dev/null; then
if wait_for_screen "Opinionated" 120 2>/dev/null; then
printf 'ok - %s\n' "the machine boots into first-boot setup unattended"
else
if ocr_screen | grep -qi "Omarchy Bootloader"; then
@@ -175,7 +175,7 @@ drive_first_boot() {
log "Selecting the Omarchy entry manually to continue the run"
press down; sleep 1; press down; sleep 1; press down; sleep 1
press ret
wait_for_screen "Linux by DHH" 600
wait_for_screen "Opinionated" 600
else
echo "Neither the greeter nor the Limine menu appeared" >&2
return 1
+1 -7
View File
@@ -273,12 +273,6 @@ mkdir -p "$omarchy_share"
printf 'logo bottom row%*s\n' 66 ''
} >"$omarchy_share/logo.txt"
# The ISO vendors the runtime's setup form; the dashboard only needs its text lookup.
cat >"$work/setup-form.sh" <<'FORM'
declare -gA OMARCHY_ZH=()
omarchy_text() { local text=$1; shift; printf -- "$text" "$@"; }
FORM
run_dashboard() {
local install_log="$1"
@@ -290,7 +284,7 @@ run_dashboard() {
"error": "Pacstrap failed. See /var/log/archinstall.log"}]}
STATE
: >"$screen"
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' SETUP_FORM='$work/setup-form.sh' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \
"$screen" >/dev/null 2>&1
}
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
#
# Every installer text needs an OMARCHY_ZH entry, or a Chinese install shows it in English.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
SETUP_FORM="${OMARCHY_SOURCE:-$ROOT/../omarchy}/install/provisioning/setup-form.sh"
fail() {
printf 'not ok - %s\n' "$1"
[[ -z ${2:-} ]] || printf '%s\n' "$2" | sed 's/^/# /'
exit 1
}
if [[ ! -r $SETUP_FORM ]]; then
printf 'ok - # skip the setup form is not checked out next to this repo (set OMARCHY_SOURCE)\n'
exit 0
fi
source "$SETUP_FORM"
for file in configs/airootfs/root/configurator configs/airootfs/usr/local/bin/omarchy-install-dashboard; do
eval "$(sed -n '/^OMARCHY_ZH+=(/,/^)/p' "$ROOT/$file")"
done
mapfile -t texts < <(
grep -ho 'omarchy_text "[^"$]*"' "$ROOT/configs/airootfs/root/configurator" "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" |
sed 's/^omarchy_text "//; s/"$//' | sort -u
)
((${#texts[@]} > 50)) || fail "the installer routes its text through omarchy_text" "found ${#texts[@]}"
for text in "${texts[@]}"; do
[[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every installer text has a Chinese entry" "missing: $text"
done
printf 'ok - every installer and dashboard text has a Chinese entry\n'
for action in "View full log" "Drop to shell" "Reboot" "Power off" "Upload log for support"; do
[[ -n ${OMARCHY_ZH[$action]:-} ]] || fail "labels passed through variables have Chinese entries" "missing: $action"
done
printf 'ok - labels passed through variables have Chinese entries\n'
mapfile -t diagnosis < <(grep -x ' *echo "[^"$]*"' "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media" | sed 's/^ *echo "//; s/"$//')
((${#diagnosis[@]} >= 10)) || fail "the media diagnosis sentences are found" "found ${#diagnosis[@]}"
for line in "${diagnosis[@]}"; do
[[ -n ${OMARCHY_ZH[$line]:-} ]] || fail "every media diagnosis sentence has a Chinese entry" "missing: $line"
done
printf 'ok - every media diagnosis sentence has a Chinese entry\n'
[[ $(OMARCHY_INSTALL_LANG=zh omarchy_text "Confirm overwriting %s" /dev/vda) == "确认覆盖 /dev/vda" ]] ||
fail "Chinese text keeps its printf arguments"
[[ $(omarchy_text "Confirm overwriting %s" /dev/vda) == "Confirm overwriting /dev/vda" ]] ||
fail "English stays the default"
printf 'ok - omarchy_text formats Chinese and English alike\n'
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/install/provisioning"
cat > "$work/install/provisioning/setup-form.sh" <<'FORM'
OMARCHY_KEYBOARD_LAYOUTS=$'Chinese (Simplified, Pinyin)|us|pinyin
English (US)|us
Japanese|jp106|mozc'
FORM
export OMARCHY_PATH="$work"
eval "$(sed -n '/^select_keyboard() {$/,/^}$/p' "$ROOT/bin/omarchy-iso-test")"
press() { pressed+=("$1"); }
sleep() { :; }
pressed=()
select_keyboard "Japanese"
[[ ${pressed[*]} == "down" ]]
pressed=()
select_keyboard "Chinese (Simplified, Pinyin)"
[[ ${pressed[*]} == "up" ]]
pressed=()
select_keyboard "English (US)"
(( ${#pressed[@]} == 0 ))
if (set -e; select_keyboard "Missing") 2>"$work/error"; then
echo "Unknown keyboard unexpectedly succeeded" >&2
exit 1
fi
grep -q 'Keyboard choice is absent' "$work/error"
echo "ok - VM keyboard choices are relative to the default and reject absent rows"
-29
View File
@@ -1,29 +0,0 @@
"""Exercise the compatibility boundary without importing host archinstall."""
import ast
from pathlib import Path
import types
import unittest
ROOT = Path(__file__).resolve().parents[2]
source = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py"
tree = ast.parse(source.read_text())
namespace = {}
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in {"_method_accepts", "sanity_check"}]
exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), "exec"), namespace)
class SanityCheckTest(unittest.TestCase):
def test_legacy_archinstall_stays_offline(self):
calls = []
def check(offline=False, skip_ntp=False, skip_wkd=False):
calls.append((offline, skip_ntp, skip_wkd))
namespace["sanity_check"](types.SimpleNamespace(sanity_check=check))
self.assertEqual(calls, [(True, True, True)])
def test_archinstall_45_does_not_receive_removed_argument(self):
calls = []
class Installer:
def sanity_check(self, skip_ntp=False, skip_wkd=False):
calls.append((skip_ntp, skip_wkd))
namespace["sanity_check"](Installer())
self.assertEqual(calls, [(True, True)])
-197
View File
@@ -1,197 +0,0 @@
"""Fresh installs use the Omarchy kernel, except for T2 Macs."""
import json
import os
import re
import subprocess
import sys
import tempfile
import types
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter",
types.ModuleType("orchestrator.archinstall_adapter"),
)
from orchestrator import context, phases_impl # noqa: E402
class KernelSelectionTest(unittest.TestCase):
def test_hardware_detection(self):
# Run the configurator's actual probe without starting its disk wizard.
configurator = (ROOT / "configs/airootfs/root/configurator").read_text()
probe = re.search(r"^detect_kernel\(\) \{\n.*?^\}", configurator, re.M | re.S)
self.assertIsNotNone(probe)
cases = [
("00:02.0 VGA compatible controller [0300]: Intel [8086:b080]", "linux-omarchy"),
("00:01.0 Ethernet controller [0200]: Apple [106b:2001]", "linux-omarchy"),
("04:00.0 Mass storage controller [0180]: Apple [106b:1801]", "linux-t2"),
("04:00.0 Mass storage controller [0180]: Apple [106b:1802]", "linux-t2"),
("", "linux-omarchy"),
]
for devices, expected in cases:
with self.subTest(devices=devices):
result = subprocess.run(
["bash", "-c", 'lspci() { printf "%s\\n" "$TEST_PCI"; }\n'
+ probe.group() + "\ndetect_kernel"],
env={**os.environ, "TEST_PCI": devices},
check=True, capture_output=True, text=True,
)
self.assertEqual(result.stdout.strip(), expected)
def test_unattended_kernel_default_uses_pci_hardware(self):
for vendor, device, expected in [
("0x8086", "0xb080", "linux-omarchy"),
("0x106b", "0x2001", "linux-omarchy"),
("0x106b", "0x1801", "linux-t2"),
("0x106b", "0x1802", "linux-t2"),
]:
with self.subTest(device=device), tempfile.TemporaryDirectory() as tmp:
pci = Path(tmp)
slot = pci / "0000:00:00.0"
slot.mkdir()
(slot / "vendor").write_text(vendor + "\n")
(slot / "device").write_text(device + "\n")
self.assertEqual(context._default_kernel(pci), expected)
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(context._default_kernel(Path(tmp)), "linux-omarchy")
def test_context_passes_effective_kernels_to_archinstall(self):
for config, default, expected in [
({}, "linux-omarchy", ["linux-omarchy"]),
({"kernels": []}, "linux-omarchy", ["linux-omarchy"]),
({"kernels": None}, "linux-t2", ["linux-t2"]),
({}, "linux-t2", ["linux-t2"]),
({"kernels": ["linux-lts"]}, "linux-omarchy", ["linux-lts"]),
({"kernels": ["linux-omarchy", "linux-lts"]}, "linux-t2", ["linux-omarchy", "linux-lts"]),
({"omarchy_install": {"storage": {"kernel": "linux-t2"}}}, "linux-omarchy", ["linux-t2"]),
]:
with self.subTest(config=config, default=default), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config_path = root / "config.json"
creds_path = root / "creds.json"
config_path.write_text(json.dumps(config))
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
with mock.patch.dict(os.environ, {
"OMARCHY_INSTALL_CONFIG": str(config_path),
"OMARCHY_INSTALL_CREDS": str(creds_path),
"OMARCHY_INSTALL_STATE_DIR": str(root / "state"),
}, clear=True), mock.patch.object(context, "_default_kernel", return_value=default):
ctx = context.InstallContext.from_env()
self.assertEqual(ctx.user_configuration["kernels"], expected)
self.assertEqual(json.loads(ctx.arch_config_path.read_text())["kernels"], expected)
def test_headers_install_before_dkms_packages(self):
for kernels in (["linux-omarchy"], ["linux-t2"], ["linux-omarchy", "linux-lts"]):
for fail_headers in (False, True):
with self.subTest(kernels=kernels, fail_headers=fail_headers), ExitStack() as stack:
events = []
installer = mock.MagicMock()
installer.minimal_installation.side_effect = lambda **kwargs: events.append("base")
def install_packages(packages):
events.append(packages)
if fail_headers:
raise RuntimeError("header install failed")
installer.add_additional_packages.side_effect = install_packages
config = types.SimpleNamespace(
kernels=kernels, locale_config=None, mirror_config=None,
swap=None, auth_config=None, app_config=None, timezone=None,
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
)
ctx = types.SimpleNamespace(
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={},
region="global",
)
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
"_configure_limine_boot", "_write_pre_mounted_fstab"):
stack.enter_context(mock.patch.object(phases_impl, name))
unmask = stack.enter_context(mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"))
unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache"))
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
stack.enter_context(mock.patch.object(phases_impl, "_install_early_packages", side_effect=lambda inst: events.append("early")))
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
stack.enter_context(mock.patch.object(phases_impl.arch, "is_pre_mount", return_value=True, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
opened.return_value.__enter__.return_value = installer
if fail_headers:
with self.assertRaisesRegex(RuntimeError, "header install failed"):
phases_impl.arch_install_system(ctx)
else:
phases_impl.arch_install_system(ctx)
expected = ["base", [f"{kernel}-headers" for kernel in kernels]]
if not fail_headers:
expected += ["early", ["omarchy"]]
self.assertEqual(events, expected)
unmask.assert_called_once_with(ctx)
unmount.assert_called_once_with(ctx)
def test_validation_rejects_missing_or_mismatched_headers(self):
for kernel in ("linux-omarchy", "linux-t2"):
with self.subTest(kernel=kernel), tempfile.TemporaryDirectory() as tmp:
target = Path(tmp)
ctx = types.SimpleNamespace(target=target)
with self.assertRaisesRegex(RuntimeError, "no installed kernel"):
phases_impl._validate_kernel_headers(ctx)
modules = target / "usr/lib/modules/7.2-test"
modules.mkdir(parents=True)
(modules / "pkgbase").write_text(kernel + "\n")
with self.assertRaisesRegex(RuntimeError, "no kernel headers"):
phases_impl._validate_kernel_headers(ctx)
header_release = modules / "build/include/config/kernel.release"
header_release.parent.mkdir(parents=True)
header_release.write_text("7.2-stock\n")
with self.assertRaisesRegex(RuntimeError, "headers do not match"):
phases_impl._validate_kernel_headers(ctx)
header_release.write_text("7.2-test\n")
phases_impl._validate_kernel_headers(ctx)
def test_boot_validation_uses_default_or_explicit_kernel(self):
for storage, configuration, expected in [
({}, {}, "linux-omarchy"),
({}, {"kernels": ["linux-t2"]}, "linux-t2"),
({"kernel": "linux-t2"}, {"kernels": ["linux-omarchy"]}, "linux-t2"),
({}, {"kernels": ["linux-lts"]}, "linux-lts"),
]:
with self.subTest(kernel=expected, storage=storage), tempfile.TemporaryDirectory() as tmp:
target = Path(tmp)
files = {
f"usr/lib/modules/7.2-test/pkgbase": expected + "\n",
"usr/lib/modules/7.2-test/build/include/config/kernel.release": "7.2-test\n",
"boot/limine.conf": "/Omarchy\n",
"etc/kernel/cmdline": "root=UUID=test\n",
"etc/default/limine": "CUSTOM_UKI_NAME=omarchy\n",
"boot/EFI/limine/limine_x64.efi": "bootloader",
f"boot/EFI/Linux/omarchy_{expected}.efi": "UKI",
}
for name, content in files.items():
path = target / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
ctx = types.SimpleNamespace(
target=target, omarchy_install={"storage": storage},
user_configuration=configuration, encrypt=False,
is_protected=False, defer_provisioning=False,
)
with mock.patch.object(phases_impl, "_assert_boot_hooks_restored"), \
mock.patch.object(phases_impl.arch, "has_uefi", return_value=True, create=True), \
mock.patch.object(phases_impl, "_read_efibootmgr", return_value={
"entries": {"0001": "Limine\tHD(1,GPT,test)"},
}):
phases_impl.validate_boot(ctx)
(target / f"boot/EFI/Linux/omarchy_{expected}.efi").unlink()
with self.assertRaisesRegex(RuntimeError, "missing or empty"):
phases_impl.validate_boot(ctx)
if __name__ == "__main__":
unittest.main()
+1 -33
View File
@@ -1,7 +1,6 @@
#!/usr/bin/python
import re
import os
import sys
import tempfile
import unittest
@@ -18,7 +17,6 @@ from orchestrator import keyboard as KEYBOARD # noqa: E402
# wherever this checkout can see a runtime, and skip the coverage test rather
# than fail when none is around (a bare CI checkout of just this repo).
SETUP_FORM_CANDIDATES = (
Path(os.environ.get("OMARCHY_PATH", "/nonexistent")) / "install/provisioning/setup-form.sh",
Path("/omarchy-source/install/provisioning/setup-form.sh"),
ROOT.parent / "omarchy/install/provisioning/setup-form.sh",
Path("/usr/share/omarchy/install/provisioning/setup-form.sh"),
@@ -33,7 +31,7 @@ def supported_keymaps():
r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL
)
assert block, f"no layout list in {candidate}"
return [line.split("|")[1] for line in block.group(1).splitlines()]
return [line.split("|", 1)[1] for line in block.group(1).splitlines()]
return None
@@ -81,36 +79,6 @@ class KeyboardConfigurationTest(unittest.TestCase):
"KEYMAP=us\nFONT=default8x16\n",
)
def test_input_choice_survives_while_console_keymap_stays_real(self):
for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("pinyin", "us", ""), ("chewing", "us", "")):
with self.subTest(method=method), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, keymap, method, layout))
self.assertIn(f"KEYMAP={keymap}", (target / "etc/vconsole.conf").read_text())
if keymap == "jp106":
self.assertIn("XKBLAYOUT=jp", (target / "etc/vconsole.conf").read_text())
self.assertEqual((target / "etc/omarchy/input-method").read_text(), f"INPUT_METHOD={method}\nXKB_LAYOUT={layout}\n")
def test_input_choice_with_no_console_map_defaults_to_us(self):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "", "pinyin"))
self.assertIn("INPUT_METHOD=pinyin", (target / "etc/omarchy/input-method").read_text())
def test_unknown_keyboard_does_not_silently_drop_an_input_choice(self):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
with self.assertRaises(ValueError):
KEYBOARD.configure_keyboard(target, "unknown", "pinyin")
def test_invalid_input_preferences_are_rejected_without_writes(self):
for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru")):
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
with self.assertRaises(ValueError):
KEYBOARD.configure_keyboard(target, "us", method, layout)
self.assertFalse((target / "etc/omarchy/input-method").exists())
if __name__ == "__main__":
unittest.main()
-236
View File
@@ -1,236 +0,0 @@
"""The target's region comes from its timezone, using the runtime's profiles."""
import json
import os
import subprocess
import sys
import tempfile
import types
import unittest
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso"))
sys.modules.setdefault(
"orchestrator.archinstall_adapter",
types.ModuleType("orchestrator.archinstall_adapter"),
)
from orchestrator import context, phases_impl, region # noqa: E402
def write_profiles(root: Path) -> Path:
regions = root / "regions"
cn = regions / "cn"
(cn / "pacman").mkdir(parents=True)
(cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n")
(cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n")
(cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n")
for channel in ("stable", "rc", "edge"):
(cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n")
return regions
class RegionSelectionTest(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.regions = write_profiles(self.root)
patcher = mock.patch.object(region, "REGIONS_DIR", self.regions)
patcher.start()
self.addCleanup(patcher.stop)
def test_timezone_selects_region(self):
for timezone, expected in [
("Asia/Shanghai", "cn"),
("Asia/Urumqi", "cn"),
("Asia/Hong_Kong", "global"),
("America/New_York", "global"),
("UTC", "global"),
("# China", "global"),
("", "global"),
(None, "global"),
]:
with self.subTest(timezone=timezone):
self.assertEqual(region.region_for_timezone(timezone), expected)
def test_iso_without_profiles_is_global(self):
self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global")
self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global")
def test_autoinstall_region_overrides_timezone(self):
self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn")
self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global")
self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn")
for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]):
with self.subTest(region=bad), self.assertRaises(RuntimeError):
region.resolve_region({"region": bad}, "UTC")
def test_profile_packages_and_keyrings(self):
self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"])
self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"])
self.assertEqual(region.region_packages("global"), [])
self.assertEqual(region.region_keyrings("global"), [])
def from_env(self, config: dict) -> context.InstallContext:
config_path = self.root / "config.json"
creds_path = self.root / "creds.json"
config_path.write_text(json.dumps(config))
creds_path.write_text(json.dumps({"users": [{"username": "test"}]}))
with mock.patch.dict(os.environ, {
"OMARCHY_INSTALL_CONFIG": str(config_path),
"OMARCHY_INSTALL_CREDS": str(creds_path),
"OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"),
}, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"):
return context.InstallContext.from_env()
def test_context_resolves_region_before_install(self):
self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn")
self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global")
# Deferred installs carry UTC until first boot, so they are global
# unless the autoinstall config names a region.
self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global")
self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn")
with self.assertRaises(RuntimeError):
self.from_env({"omarchy_install": {"region": "zz"}})
def test_marker_is_written_only_for_regional_targets(self):
target = self.root / "target"
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global"))
self.assertFalse((target / "etc/omarchy/region").exists())
phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn"))
marker = target / "etc/omarchy/region"
self.assertEqual(marker.read_text(), "cn\n")
self.assertEqual(marker.stat().st_mode & 0o777, 0o644)
def test_region_packages_install_while_the_offline_mirror_is_mounted(self):
for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]:
with self.subTest(region=selected), ExitStack() as stack:
events = []
installer = mock.MagicMock()
installer.add_additional_packages.side_effect = lambda packages: events.append(packages)
config = types.SimpleNamespace(
kernels=["linux-omarchy"], locale_config=None, mirror_config=None,
swap=None, auth_config=None, app_config=None, timezone=None,
ntp=False, hostname="test", pacman_config="/etc/pacman.conf",
)
ctx = types.SimpleNamespace(
state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None},
target=self.root / "target", tailscale_authkey_path=None, omarchy_install={},
region=selected,
)
for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks",
"_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot",
"_write_pre_mounted_fstab", "_install_early_packages"):
stack.enter_context(mock.patch.object(phases_impl, name))
stack.enter_context(mock.patch.object(
phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount")))
stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True))
stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"]))
for name, value in (("is_pre_mount", True), ("root_user", None)):
stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, create=True))
stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True))
opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True))
opened.return_value.__enter__.return_value = installer
phases_impl.arch_install_system(ctx)
self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"])
self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global")
def test_finalizer_trusts_only_the_selected_region(self):
for selected, expected in [
("global", []),
("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]),
]:
with self.subTest(region=selected), \
mock.patch.object(phases_impl, "_run_target_setup_command") as run, \
mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \
mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"):
ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused"))
phases_impl.run_system_finalizer(ctx)
commands = [call.args[1] for call in run.call_args_list]
self.assertEqual(commands[:-1], expected)
self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system")
class PrepareRegionsTest(unittest.TestCase):
def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]:
payload = root / "payload"
payload.mkdir()
online = root / "online.conf"
online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n")
stubs = root / "bin"
stubs.mkdir()
log = root / "calls.log"
for name in ("pacman", "pacman-key"):
stub = stubs / name
stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n')
stub.chmod(0o755)
subprocess.run(
["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)],
env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"},
check=True, capture_output=True, text=True,
)
return payload, online, log.read_text() if log.exists() else ""
def test_ships_every_profile_and_bootstraps_its_keyring(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
regions = write_profiles(root)
payload, online, calls = self.run_prepare(regions, root)
self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text())
self.assertIn("[archlinuxcn]", online.read_text())
self.assertTrue(online.read_text().startswith("[core]\n"))
self.assertEqual(calls.splitlines(), [
f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring",
"pacman-key --populate archlinuxcn",
])
def settings_package(self, root: Path, with_regions: bool) -> Path:
tree = root / "pkgroot"
(tree / "usr/share/omarchy/default/pacman").mkdir(parents=True)
if with_regions:
write_profiles(root / "src")
(root / "src/regions").rename(tree / "usr/share/omarchy/default/regions")
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True)
return package
def test_published_build_reads_profiles_from_the_settings_package(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root)
self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n")
self.assertIn("[archlinuxcn]", online.read_text())
self.assertIn("pacman-key --populate archlinuxcn", calls)
def test_settings_package_without_profiles_builds_a_global_iso(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root)
self.assertFalse((payload / "regions").exists())
self.assertEqual(calls, "")
def test_unreadable_settings_package_fails_the_build(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
package = root / "omarchy-settings-1-1-any.pkg.tar.zst"
package.write_text("not an archive")
with self.assertRaises(subprocess.CalledProcessError):
self.run_prepare(package, root)
def test_runtime_without_profiles_builds_a_global_iso(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
payload, online, calls = self.run_prepare(root / "missing", root)
self.assertFalse((payload / "regions").exists())
self.assertNotIn("archlinuxcn", online.read_text())
self.assertEqual(calls, "")
if __name__ == "__main__":
unittest.main()
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
#
# Unit tests for the UTM SE appliance builder's static inputs: the config.plist
# template must be a valid plist once omarchy-utm-make substitutes its
# placeholders, and the package lists must name real packages.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
pass() {
printf 'ok - %s\n' "$1"
}
fail() {
local description="$1"
local detail="${2:-}"
[[ -n $detail ]] && printf '%s\n' "$detail" >&2
printf 'not ok - %s\n' "$description" >&2
exit 1
}
template="$ROOT/builder/utm/config.plist"
render="$ROOT/builder/utm/render"
placeholders=$(grep -o '@@[A-Z_]*@@' "$template" | sort -u | tr '\n' ' ')
[[ $placeholders == "@@DISK_UUID@@ @@MAC@@ @@VERSION@@ @@VM_UUID@@ " ]] ||
fail "the template carries exactly the placeholders the build fills" "$placeholders"
pass "the template carries exactly the placeholders the build fills"
rendered=$("$render" 11111111-2222-3333-4444-555555555555 66666666-7777-8888-9999-000000000000 02:00:00:00:00:01 4.0.2)
python3 - "$rendered" <<'PY' || fail "the rendered config.plist parses, names the disk it ships, and declares nothing iOS rejects"
import plistlib, sys
config = plistlib.loads(sys.argv[1].encode())
assert "@@" not in sys.argv[1]
assert config["ConfigurationVersion"] == 4
assert config["Information"]["UUID"] == "11111111-2222-3333-4444-555555555555"
assert config["Drive"][0]["Identifier"] == "66666666-7777-8888-9999-000000000000"
assert config["Drive"][0]["ImageName"] == "66666666-7777-8888-9999-000000000000.qcow2"
assert config["Network"][0]["MacAddress"] == "02:00:00:00:00:01"
assert "4.0.2" in config["Information"]["Notes"]
assert config["System"]["Architecture"] == "aarch64"
assert config["QEMU"]["Hypervisor"] is False
assert config["System"]["MemorySize"] == 2048
# UTM on iOS has no Ptty serial mode and decodes strictly, so the bundle ships no serial device.
assert config["Serial"] == []
PY
pass "the rendered config.plist parses, names the disk it ships, and declares nothing iOS rejects"
base=""
for candidate in "$ROOT/../omarchy" "$ROOT/../omacom/omarchy"; do
if [[ -f $candidate/install/omarchy-base.packages ]]; then
base="$candidate/install/omarchy-base.packages"
break
fi
done
if [[ -z $base ]]; then
pass "no omarchy checkout beside this one; skipping the drop list check"
exit 0
fi
missing=$(grep -v '^#\|^$' "$ROOT/builder/utm-se-drop.packages" | grep -vxF -f <(sed -e 's/[[:space:]]*#.*$//' -e '/^$/d' "$base") || true)
[[ -z $missing ]] || fail "every dropped package is one the base list ships" "$missing"
pass "every dropped package is one the base list ships"