Compare commits

..
Author SHA1 Message Date
Ryan Hughes 3c36557fd5 Point the rc ISO's [omarchy] repo at the rc channel
The Arch base repos already come from rc-mirror.omarchy.org, but
[omarchy] still pointed at edge — a leftover from when release
candidates published there. RCs now publish to the dedicated rc
channel on pkgs.omarchy.org, so an rc ISO built against edge would
install the previous release instead of the candidate.
2026-08-30 13:43:30 -04:00
David Heinemeier HanssonandClaude Fable 5 168c6edb0b Bring back gl acceleration for the test VM
Ported from omarchy-4 (764a65f), including that branch's follow-up
switch from GTK to SDL: GTK's GL display path crops the guest
framebuffer on HiDPI Wayland desktops (434db24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:17:47 -07:00
56 changed files with 546 additions and 20331 deletions

No files matched your search

+29 -23
View File
@@ -2,34 +2,40 @@ name: Nightly ISO Build
on: on:
schedule: schedule:
- cron: "1 0 * * *" # Daily at 00:01 UTC - cron: '1 0 * * *' # Daily at 00:01 UTC
workflow_dispatch: # Allow manual triggering workflow_dispatch: # Allow manual triggering
jobs: jobs:
build-iso: build-iso:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: archlinux/archlinux:latest
options: --privileged
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Free Disk Space - name: Build ISO in Arch Linux container
uses: jlumbroso/free-disk-space@v1.3.1 run: |
docker run --rm \
--privileged \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
archlinux:latest \
bash -c "
pacman -Syu --noconfirm && \
chmod +x build_iso.sh && \
./build_iso.sh
"
- name: Build ISO - name: Upload ISO artifact
run: NO_BOOT_OFFER=1 ./bin/omarchy-iso-make uses: actions/upload-artifact@v4
with:
- name: Delete previously uploaded ISO artifact name: omarchy-iso-${{ github.run_number }}
uses: philips-labs/action-delete-artifacts@v1.0.0 path: out/*.iso
with: retention-days: 30
workflow: nightly-build.yml
continue-on-error: true
- name: Upload newly built ISO artifact
uses: actions/upload-artifact@v4
with:
name: omarchy-iso-${{ github.run_number }}
path: ./release/*.iso
retention-days: 1
-7
View File
@@ -1,9 +1,2 @@
release/** release/**
vm-saves/** vm-saves/**
# Python bytecode (orchestrator)
__pycache__/
*.pyc
# Acceptance test harness artifacts
test-runs/**
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Anton Hvornum Copyright (c) 2025 Anton Hvornum
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+11 -95
View File
@@ -1,6 +1,6 @@
# Omarchy ISO # Omarchy ISO
The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. The Omarchy ISO streamlines [the installation of Omarchy](https://learn.omacom.io/2/the-omarchy-manual/50/getting-started). It includes the Omarchy Configurator as a front-end to archinstall and automatically launches the [Omarchy Installer](https://github.com/basecamp/omarchy) after base arch has been setup.
## Downloading the latest ISO ## Downloading the latest ISO
@@ -8,115 +8,31 @@ See the ISO link on [omarchy.org](https://omarchy.org).
## Creating the ISO ## Creating the ISO
Run `./bin/omarchy-iso-make`; output goes into `./release`. By default the ISO uses the Omarchy packages and tracks the `quattro` branch, from the stable mirror. Pass `--edge` to use `omarchy-dev` and `omarchy-settings-dev` from the edge mirror. Run `./bin/omarchy-iso-make` and the output goes into `./release`. You can build from your local `$OMARCHY_PATH` for testing with `--local-source`, from the dev branch with `--dev`, or from the quattro branch with `--quattro`. The dev and quattro builds use the edge package mirror.
For local development, build the ISO from sibling checkouts: ### Environment Variables
You can customize the repositories used during the build process by passing in variables:
- `OMARCHY_INSTALLER_REPO` - GitHub repository for the installer (default: `basecamp/omarchy`)
- `OMARCHY_INSTALLER_REF` - Git ref (branch/tag) for the installer (default: `master`)
Example usage:
```bash ```bash
./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs OMARCHY_INSTALLER_REPO="myuser/omarchy-fork" OMARCHY_INSTALLER_REF="some-feature" ./bin/omarchy-iso-make
``` ```
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.
Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror.
## Autoinstall
The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer already know how to attach one.
### Configuration files
These are the configurator's own output files, so the way to get a starting set is to run one interactive install and copy what it wrote into `/root`.
| File | Required | Purpose |
|------|----------|---------|
| `user_configuration.json` | Yes | archinstall config: disk, hostname, timezone, keyboard |
| `user_credentials.json` | Yes | Username and password hash |
| `user_full_name.txt` | No | Git full name |
| `user_email_address.txt` | No | Git email |
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
| `authorized_keys` | No | SSH public keys in sshd's own format, one per line |
| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot |
Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`.
Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption.
`authorized_keys` is the same file sshd reads — copy your own or write one key per line:
```
ssh-ed25519 AAAA... you@host
```
When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach.
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.
### Building the drive
```bash
mkdir cidata
cp user_configuration.json user_credentials.json authorized_keys cidata/
genisoimage -output cidata.iso -volid cidata -joliet -rock cidata/
```
### Proxmox example
```bash
qm create 101 --name my-omarchy \
--bios ovmf --machine q35 --cpu host --cores 4 --memory 8192 \
--ostype l26 --scsihw virtio-scsi-single \
--efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=0 \
--scsi0 local-lvm:40,discard=on,iothread=1 \
--net0 virtio,bridge=vmbr0 --vga virtio --serial0 socket \
--ide2 local:iso/omarchy.iso,media=cdrom \
--ide3 local:iso/cidata.iso,media=cdrom \
--boot order='scsi0;ide2'
qm start 101
```
Boot order is disk first: the empty disk falls through to the ISO on the first boot, and the installed system boots from disk afterwards. The machine reboots into Omarchy on its own when the install finishes.
Encrypted autoinstalls are not fully unattended — the LUKS passphrase prompt still needs someone at the first boot.
## Testing the ISO ## Testing the ISO
Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. Run `./bin/omarchy-iso-boot [release/omarchy.iso]`.
Run `./test/all` for the unit tests, which cover cidata autoinstall loading and the orchestrator's SSH access phase without needing a built ISO.
To exercise installation alongside existing Windows-style partitions, run
`./bin/omarchy-iso-test-windows-disk [release/omarchy.iso]`. It creates a
synthetic disk in `/tmp` with an existing ESP and data partition plus ample
unallocated space, then offers to start an interactive installation on it. The
fixture exercises Windows partition preservation but does not contain Windows.
## Acceptance testing the ISO
Run `./bin/omarchy-iso-test [release/omarchy.iso]` to install the ISO into a headless VM by driving the real interactive install flow — the harness reads each screen via QMP screendumps + OCR and answers with virtual keystrokes, so the configurator wizard, install dashboard, reboot prompt, and SDDM login are all exercised exactly as a user would. It then boots the installed system, sends real VM keyboard shortcuts for the primary shell and window-management actions, and runs the in-guest acceptance suite (`test/acceptance` in the omarchy repo). The suite checks session and service health, the complete core-package manifest, user defaults, representative applications, menus, panels, live weather, launchers, visual selectors, notifications, clipboard, and other interactive shell behavior.
Visual checkpoints are saved as `success-<step>.png` or `failure-<step>.png` alongside the serial and install logs in `test-runs/<iso>/runs/<timestamp>/`. Independent test files and applications continue after a failure so one broken surface does not hide the rest of the report. The harness then stops the VM and opens the ordered screenshots in `imv` for quick visual review.
The harness syncs the acceptance suite from `$OMARCHY_PATH` when it is available. The install phase produces a reusable base image, so iterating against another checkout is fast:
```bash
./bin/omarchy-iso-test release/omarchy.iso --install-only # once per ISO
./bin/omarchy-iso-test release/omarchy.iso --reuse-base \
--sync-omarchy ../omarchy # fast loop against local tests
```
Pass `--encrypt` to drive the encrypted install flow (including typing the LUKS passphrase at boot) instead of the unencrypted one. Pass `--no-preview` to collect the same visual artifacts without opening them in `imv` when the run finishes.
## Signing the ISO ## Signing the ISO
Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI. Run `./bin/omarchy-iso-sign [release/omarchy.iso]`. The signing key is retrieved from the shared Omarchy vault with the 1Password CLI.
## Uploading the ISO ## Uploading the ISO
Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires you've configured rclone (use `rclone config`).
## Full release of the ISO ## Full release of the ISO
+13 -121
View File
@@ -2,113 +2,19 @@
set -e set -e
ISO="" ISO="$1"
REUSE="" REUSE="$2"
SSH_HOST="${OMARCHY_VM_SSH_HOST:-127.0.0.1}"
SSH_PORT="${OMARCHY_VM_SSH_PORT:-2222}"
NETWORK="${OMARCHY_VM_NETWORK:-1}"
DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
OVMF_VARS="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
EXTRA_QEMU_ARGS=()
usage() { if [ $# -eq 0 ]; then
cat <<USAGE
Usage: omarchy-iso-boot [options] [release/omarchy.iso] [reuse]
Options:
--reuse Reuse the existing VM disk
--ssh-port PORT Forward host PORT to guest port 22 (default: $SSH_PORT)
--ssh-host HOST Host address for SSH forwarding (default: $SSH_HOST)
--no-network Boot without a virtual NIC
--disk PATH VM disk path (default: $DISK)
--ovmf-vars PATH OVMF vars path (default: $OVMF_VARS)
-h, --help Show this help
-- Pass the remaining arguments directly to QEMU
SSH access after starting sshd in the guest:
ssh -p $SSH_PORT root@localhost
scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/
USAGE
}
while (( $# > 0 )); do
case "$1" in
--reuse|reuse)
REUSE="reuse"
shift
;;
--ssh-port)
SSH_PORT="${2:-}"
shift 2
;;
--ssh-port=*)
SSH_PORT="${1#*=}"
shift
;;
--ssh-host)
SSH_HOST="${2:-}"
shift 2
;;
--ssh-host=*)
SSH_HOST="${1#*=}"
shift
;;
--no-network|--no-net)
NETWORK=0
shift
;;
--disk)
DISK="${2:-}"
shift 2
;;
--disk=*)
DISK="${1#*=}"
shift
;;
--ovmf-vars)
OVMF_VARS="${2:-}"
shift 2
;;
--ovmf-vars=*)
OVMF_VARS="${1#*=}"
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
EXTRA_QEMU_ARGS+=("$@")
break
;;
*)
if [[ -z $ISO ]]; then
ISO="$1"
else
echo "Unexpected argument: $1" >&2
usage >&2
exit 1
fi
shift
;;
esac
done
if [[ -z $ISO && -t 0 ]]; then
ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot") ISO=$(ls -t release/*.iso 2>/dev/null | gum choose --header "Select ISO to boot")
if gum confirm --default=false "Reuse existing disk?"; then if gum confirm --default=false "Reuse existing disk?"; then
REUSE="reuse" REUSE="reuse"
fi fi
fi fi
if [[ -z $ISO ]]; then if [ -z "$ISO" ]; then
usage echo "Usage: omarchy-iso-boot [release/omarchy.iso] [reuse]"
exit 1 echo " SSH available at: ssh -p 2222 root@localhost (after installing openssh)"
fi
if [[ -z $DISK || -z $OVMF_VARS ]]; then
echo "Disk and OVMF vars paths must not be empty" >&2
exit 1 exit 1
fi fi
@@ -130,32 +36,19 @@ if [[ -z ${WAYLAND_DISPLAY:-} && -z ${DISPLAY:-} ]]; then
exit 1 exit 1
fi fi
DISK="/tmp/omarchy-iso-boot.qcow2"
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
qemu-img create -f qcow2 "$DISK" 40G qemu-img create -f qcow2 "$DISK" 40G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$OVMF_VARS" cp /usr/share/edk2/x64/OVMF_VARS.4m.fd /tmp/OVMF_VARS.4m.fd
fi
NET_ARGS=()
if [[ $NETWORK != "0" && $NETWORK != "false" && $NETWORK != "no" ]]; then
if [[ -n $SSH_PORT && $SSH_PORT != "0" ]]; then
NET_ARGS=(-netdev "user,id=net0,hostfwd=tcp:${SSH_HOST}:${SSH_PORT}-:22" -device virtio-net-pci,netdev=net0)
echo "SSH forwarding enabled: ssh -p $SSH_PORT root@localhost"
echo "SCP example: scp -P $SSH_PORT /path/to/system-manifest root@localhost:/tmp/"
else
NET_ARGS=(-netdev user,id=net0 -device virtio-net-pci,netdev=net0)
echo "Networking enabled without SSH port forwarding."
fi
else
echo "Networking disabled."
fi fi
# Boot from ISO before we've installed, then boot from drive after # Boot from ISO before we've installed, then boot from drive after
qemu-system-x86_64 \ qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \ -cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(($(nproc) < 8 ? $(nproc) : 8))" \ -smp "$(nproc)" \
-m 8192 \ -m 8192 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \ -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
-drive if=pflash,format=raw,file="$OVMF_VARS" \ -drive if=pflash,format=raw,file=/tmp/OVMF_VARS.4m.fd \
-drive file="$DISK",format=qcow2,if=none,id=drive0 \ -drive file="$DISK",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \ -device virtio-blk-pci,drive=drive0,bootindex=1 \
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \ -drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
@@ -163,7 +56,6 @@ qemu-system-x86_64 \
-device virtio-vga-gl \ -device virtio-vga-gl \
-display sdl,gl=on \ -display sdl,gl=on \
-usb -device usb-tablet \ -usb -device usb-tablet \
-serial none \ -netdev user,id=net0,hostfwd=tcp::2222-:22 \
"${NET_ARGS[@]}" \ -device virtio-net-pci,netdev=net0 \
-boot menu=on \ -boot menu=on
"${EXTRA_QEMU_ARGS[@]}"
+19 -8
View File
@@ -3,14 +3,25 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -z "${OMARCHY_PATH:-}" ]]; then cleanup() {
for candidate in "$repo_root/../omarchy-installer" "$repo_root/../omarchy" /usr/share/omarchy; do [[ -n "${mock_omarchy_install:-}" ]] && rm -rf "$(dirname "$mock_omarchy_install")"
if [[ -f "$candidate/logo.txt" ]]; then }
export OMARCHY_PATH="$candidate" trap cleanup EXIT
break
fi if [[ -z "${OMARCHY_INSTALL:-}" ]]; then
done if [[ -r /root/omarchy/install/helpers/all.sh ]]; then
export OMARCHY_INSTALL=/root/omarchy/install
else
mock_root="$(mktemp -d)"
mock_omarchy_install="$mock_root/install"
mkdir -p "$mock_omarchy_install/helpers"
cat >"$mock_omarchy_install/helpers/all.sh" <<'EOF'
PADDING_LEFT=0
PADDING_LEFT_SPACES=""
clear_logo() { clear; }
EOF
export OMARCHY_INSTALL="$mock_omarchy_install"
fi
fi fi
# Do a dry-run of the configurator to show the flow without having to do a real installation
exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@" exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@"
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# Preview the shipped Omarchy installer dashboard without building an ISO.
set -euo pipefail
# The dashboard drives the package phase from the target's pacman database, so
# fake one here; without it the preview falls back to its time curve and the bar
# barely moves, which reads as a bug.
PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}"
write_simulation_state() {
local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4"
local target="$5" index="$6" total="$7"
local finished_at="${8:-null}" temporary="${state_file}.tmp"
jq -n \
--argjson started_at "$started_at" \
--argjson phase_started_at "$phase_started_at" \
--arg current_phase "$current_phase" \
--arg target "$target" \
--argjson current_index "$index" \
--argjson total_phases "$total" \
--argjson finished_at "$finished_at" \
'{
started_at: $started_at,
phase_started_at: $phase_started_at,
current_phase: $current_phase,
target: $target,
current_index: $current_index,
total_phases: $total_phases
} + if $finished_at == null then {} else {finished_at: $finished_at} end' \
>"$temporary"
mv -f -- "$temporary" "$state_file"
}
# Fill the fake database over the phase's duration, so the bar is driven by the
# same count it uses on a real install.
simulate_packages() {
local db="$1" duration="$2" total="$3" steps=25 i n
for ((i = 1; i <= steps; i++)); do
n=$(( total * i / steps ))
while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do
mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1"
done
sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')"
done
}
simulate_install() {
local state_file="$1"
local started_at="$EPOCHREALTIME"
local phase phase_started_at finished_at duration i index total
local target db
target="$(dirname -- "$state_file")/target"
db="$target/var/lib/pacman/local"
mkdir -p "$db"
local -a phases=(
"Starting installation" 0.5
"Preparing live environment" 0.8
"Preparing install target" 0.3
"Installing Arch + Omarchy" 5.0
"Configuring hibernation" 0.4
"Configuring system" 1.2
"Finalizing Limine boot" 1.0
"Finalizing user" 1.0
"Configuring login" 0.6
"Configuring DNS resolver" 0.3
"Validating boot setup" 0.8
)
total=$(( ${#phases[@]} / 2 ))
for ((i = 0; i < ${#phases[@]}; i += 2)); do
phase="${phases[i]}"
duration="${phases[i + 1]}"
index=$(( i / 2 ))
phase_started_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$phase_started_at" \
"$phase" "$target" "$index" "$total"
if [[ $phase == "Installing Arch + Omarchy" ]]; then
simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES"
else
sleep "$duration"
fi
done
finished_at="$EPOCHREALTIME"
write_simulation_state "$state_file" "$started_at" "$finished_at" \
"Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at"
sleep 0.3
}
if [[ ${1:-} == "--simulate-install" ]]; then
(( $# == 2 )) || exit 2
simulate_install "$2"
exit 0
fi
if (( $# > 0 )); then
echo "Usage: $0" >&2
exit 2
fi
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
dashboard="$repo_root/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
if [[ ! -x $dashboard ]]; then
echo "Installer dashboard is not executable: $dashboard" >&2
exit 1
fi
if [[ -z ${OMARCHY_PATH:-} ]]; then
for candidate in "$repo_root/../omarchy" /usr/share/omarchy; do
if [[ -f $candidate/logo.txt ]]; then
export OMARCHY_PATH="$candidate"
break
fi
done
fi
if [[ -z ${OMARCHY_PATH:-} || ! -f $OMARCHY_PATH/logo.txt ]]; then
echo "Unable to find Omarchy logo.txt; set OMARCHY_PATH to an Omarchy checkout." >&2
exit 1
fi
preview_dir="$(mktemp -d)"
cleanup() {
rm -rf "$preview_dir"
}
trap cleanup EXIT
log_file="$preview_dir/install.log"
state_file="$preview_dir/state.json"
touch "$log_file"
# Match what the simulated database will reach, so the preview shows the
# package-driven bar rather than the missing-signal fallback.
printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages"
OMARCHY_UI_AUTO_REBOOT=no \
OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \
PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \
"$dashboard" "$log_file" "$state_file" -- \
"$0" --simulate-install "$state_file"
+27 -80
View File
@@ -1,10 +1,10 @@
#!/bin/bash #!/bin/bash
# Abort if anything fails
set -e set -e
# Parse command line arguments
NO_CACHE="" NO_CACHE=""
LOCAL_OMARCHY_PATH=""
LOCAL_PKGS_PATH=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--no-cache) --no-cache)
@@ -15,28 +15,13 @@ while [[ $# -gt 0 ]]; do
NO_BOOT_OFFER=1 NO_BOOT_OFFER=1
shift shift
;; ;;
--debug)
OMARCHY_INSTALL_DEBUG=1
shift
;;
--edge)
OMARCHY_ISO_REF=edge
OMARCHY_MIRROR=edge
shift
;;
--local-source) --local-source)
LOCAL_OMARCHY_PATH="${2:-}" LOCAL_SOURCE=1
LOCAL_PKGS_PATH="${3:-}"
if [[ -z $LOCAL_OMARCHY_PATH || -z $LOCAL_PKGS_PATH ]]; then
echo "Error: --local-source requires two args: <omarchy-checkout> <pkgs-checkout>" >&2
exit 1
fi
OMARCHY_MIRROR=edge OMARCHY_MIRROR=edge
OMARCHY_ISO_REF=local shift
shift 3
;; ;;
--dev) --dev)
OMARCHY_ISO_REF=dev OMARCHY_INSTALLER_REF=dev
OMARCHY_MIRROR=edge OMARCHY_MIRROR=edge
shift shift
;; ;;
@@ -46,77 +31,40 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
--rc) --rc)
OMARCHY_ISO_REF=rc
OMARCHY_MIRROR=rc OMARCHY_MIRROR=rc
OMARCHY_INSTALLER_REF=rc
shift shift
;; ;;
*) *)
echo "Unknown option: $1" echo "Unknown option: $1"
echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source <omarchy-checkout> <pkgs-checkout>]" echo "Usage: $0 [--no-cache] [--no-boot-offer] [--local-source] [--dev] [--quattro] [--rc]"
exit 1 exit 1
;; ;;
esac esac
done done
if [[ -n $LOCAL_OMARCHY_PATH ]]; then # Clear out old packages
[[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; }
[[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; }
LOCAL_OMARCHY_PATH=$(realpath "$LOCAL_OMARCHY_PATH")
LOCAL_PKGS_PATH=$(realpath "$LOCAL_PKGS_PATH")
fi
lint_file_permissions() {
local profiledef="configs/profiledef.sh"
local -A declared=()
local missing=()
while IFS= read -r path; do
[[ -n $path ]] && declared["$path"]=1
done < <(grep -oP '^\s*\["\K[^"]+(?="\])' "$profiledef" || true)
while IFS= read -r f; do
[[ -f $f ]] || continue
local rel="${f#configs/airootfs}"
local mode
mode=$(git ls-files --stage -- "$f" | awk '{print $1}')
if [[ $mode != 100755 && $(head -c 2 "$f") != "#!" ]]; then
continue
fi
if [[ -z ${declared[$rel]+x} ]]; then
missing+=("$rel")
fi
done < <(find configs/airootfs/usr/local/bin configs/airootfs/root -type f 2>/dev/null)
if (( ${#missing[@]} > 0 )); then
echo "ERROR: the following executable files are missing from $profiledef file_permissions:" >&2
printf ' %s\n' "${missing[@]}" >&2
echo "Add entries like [\"<path>\"]=\"0:0:755\" to profiledef.sh and retry." >&2
exit 1
fi
}
lint_file_permissions
sudo rm -rf /var/cache/pacman/pkg/* sudo rm -rf /var/cache/pacman/pkg/*
# Ensure we have the submodule checked out
git submodule update --init --recursive --jobs=8 git submodule update --init --recursive --jobs=8
# Prepare build repo
BUILD_SCRIPT="builder/build-iso.sh" BUILD_SCRIPT="builder/build-iso.sh"
BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..")
BUILD_RELEASE_PATH="$BUILD_ROOT/release" BUILD_RELEASE_PATH="$BUILD_ROOT/release"
mkdir -p "$BUILD_RELEASE_PATH" mkdir -p "$BUILD_RELEASE_PATH"
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_INSTALLER_REPO="${OMARCHY_INSTALLER_REPO:-basecamp/omarchy}"
OMARCHY_INSTALLER_REF="${OMARCHY_INSTALLER_REF:-master}"
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
DOCKER_ARGS=( DOCKER_ARGS=(
--rm --rm
--privileged --privileged
-e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_INSTALLER_REPO=$OMARCHY_INSTALLER_REPO"
-e "OMARCHY_INSTALLER_REF=$OMARCHY_INSTALLER_REF"
-e "OMARCHY_MIRROR=$OMARCHY_MIRROR" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR"
-e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}"
-e "HOST_UID=$(id -u)" -e "HOST_UID=$(id -u)"
-e "HOST_GID=$(id -g)" -e "HOST_GID=$(id -g)"
-v "$BUILD_RELEASE_PATH/:/out/" -v "$BUILD_RELEASE_PATH/:/out/"
@@ -126,14 +74,13 @@ DOCKER_ARGS=(
-v "$BUILD_ROOT/configs:/configs:ro" -v "$BUILD_ROOT/configs:/configs:ro"
) )
if [[ -n $LOCAL_OMARCHY_PATH ]]; then # Use local source of Omarchy from $OMARCHY_PATH instead of git checkout
DOCKER_ARGS+=( if [[ -n "${LOCAL_SOURCE-}" ]]; then
-v "$LOCAL_OMARCHY_PATH:/omarchy-source:ro" DOCKER_ARGS+=(-v "$OMARCHY_PATH:/omarchy:ro")
-v "$LOCAL_PKGS_PATH:/omarchy-pkgs:ro"
)
fi fi
if [[ -d /var/cache/pacman/pkg ]]; then # Use local pacman cache if you already have one on host to speed up repeat runs
if [ -d "/var/cache/pacman/pkg" ]; then
DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg") DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg")
fi fi
@@ -142,20 +89,20 @@ fi
if [[ -z "$NO_CACHE" ]]; then if [[ -z "$NO_CACHE" ]]; then
OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy"
mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR"
# Mount to /var/cache/airootfs/var/cache/omarchy since that's where repo packages go in the ISO
DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy")
else
# The locally cached :latest image is a cache too — docker run never
# re-checks the registry for it, so refresh it when building cold.
DOCKER_ARGS+=(--pull=always)
fi fi
# Run the builder with assembled args
docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT
# Move as install ref
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1)
iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" iso_ref="${latest_iso%.*}-$OMARCHY_INSTALLER_REF.iso"
mv -f "$latest_iso" "$iso_ref" mv -f "$latest_iso" "$iso_ref"
if [[ -z "${NO_BOOT_OFFER:-}" ]]; then # Offer to boot the new build
if [[ -z "$NO_BOOT_OFFER" ]]; then
echo echo
gum confirm "Boot $iso_ref?" && "$BUILD_ROOT/bin/omarchy-iso-boot" "$iso_ref" gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref"
fi fi
+6 -6
View File
@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
MAKE_ARGS=() MAKE_ARGS=()
ISO_REF=quattro INSTALLER_REF=master
RELEASE_SUFFIX="" RELEASE_SUFFIX=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
;; ;;
--rc) --rc)
MAKE_ARGS+=("$1") MAKE_ARGS+=("$1")
ISO_REF=rc INSTALLER_REF=rc
RELEASE_SUFFIX="-rc" RELEASE_SUFFIX="-rc"
shift shift
;; ;;
@@ -42,21 +42,21 @@ BUILD_RELEASE_PATH="$BUILD_ROOT/release"
# Make ISOs # Make ISOs
if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then
echo -e "\e[32mMaking $ISO_REF ISO\e[0m" echo -e "\e[32mMaking $INSTALLER_REF ISO\e[0m"
omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}" omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}"
else else
echo -e "\e[31mSkipping remaking ISOs\e[0m" echo -e "\e[31mSkipping remaking ISOs\e[0m"
fi fi
# Sign and upload ISO # Sign and upload ISO
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$ISO_REF".iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1)
release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso" release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso"
cp "$latest_iso" "$release_iso" cp "$latest_iso" "$release_iso"
echo -e "\e[32mSigning $ISO_REF ISO\e[0m" echo -e "\e[32mSigning $INSTALLER_REF ISO\e[0m"
omarchy-iso-sign "$release_iso" omarchy-iso-sign "$release_iso"
iso_sha=$(sha256sum "$release_iso") iso_sha=$(sha256sum "$release_iso")
echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m" echo -e "\e[32mSHA256 for $INSTALLER_REF ISO: $iso_sha\e[0m"
omarchy-iso-upload "$release_iso" omarchy-iso-upload "$release_iso"
-914
View File
@@ -1,914 +0,0 @@
#!/bin/bash
#
# Acceptance test harness: boots an Omarchy ISO in a headless QEMU VM and
# drives the real interactive install flow the way a user would — reading
# each screen through QMP screendumps + OCR, and answering with virtual
# keystrokes. Nothing in the product is bypassed or stubbed: the configurator
# wizard, install dashboard, reboot prompt, and SDDM login are all exercised.
#
# After first boot it logs into a console TTY (typed, like everything else)
# to authorize an SSH key, snapshots the installed disk, then boots a
# throwaway overlay and runs the in-guest acceptance suite (test/acceptance
# in the omarchy repo) over SSH.
#
# Usage: omarchy-iso-test [release/omarchy.iso] [options]
#
# --encrypt Drive the encrypted install flow (default: unencrypted)
# --reuse-base Skip the install phase if a base image already exists
# --install-only Stop after producing the installed base image
# --sync-omarchy DIR Push DIR's test/acceptance into the guest before running
# (defaults to OMARCHY_PATH when the suite is present)
# --sync-all DIR Push all of DIR (bin/config/shell/test) into the guest
# --port PORT Host port forwarded to guest SSH (default 2222)
# --memory MB VM memory (default 8192)
# --timeout SECS Install timeout (default 2400)
# --keep-running Leave the acceptance VM running after the suite finishes
# --no-preview Do not open the collected screenshots in imv
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
ISO=""
SSH_PORT=2222
MEMORY=8192
INSTALL_TIMEOUT=2400
BOOT_TIMEOUT=600
ENCRYPT=false
REUSE_BASE=false
INSTALL_ONLY=false
KEEP_RUNNING=false
NO_PREVIEW=false
SYNC_DIR=""
SYNC_ALL=false
GUEST_USER="omarchy"
GUEST_PASSWORD="omarchy"
GUEST_HOSTNAME="omarchy-test"
while (($#)); do
case "$1" in
--encrypt) ENCRYPT=true ;;
--reuse-base) REUSE_BASE=true ;;
--install-only) INSTALL_ONLY=true ;;
--keep-running) KEEP_RUNNING=true ;;
--no-preview) NO_PREVIEW=true ;;
--sync-omarchy) SYNC_DIR="$2"; shift ;;
--sync-all) SYNC_DIR="$2"; SYNC_ALL=true; shift ;;
--port) SSH_PORT="$2"; shift ;;
--memory) MEMORY="$2"; shift ;;
--timeout) INSTALL_TIMEOUT="$2"; shift ;;
-*) echo "Unknown option: $1" >&2; exit 1 ;;
*) ISO="$1" ;;
esac
shift
done
if [[ -z $SYNC_DIR && -f ${OMARCHY_PATH:-}/test/acceptance ]]; then
SYNC_DIR="$OMARCHY_PATH"
fi
if [[ -z $ISO && -t 0 ]]; then
ISO=$(ls -t "$REPO_DIR"/release/*.iso 2>/dev/null | gum choose --header "Select ISO to test")
fi
if [[ ! -f ${ISO:-} ]]; then
echo "No ISO to test. Pass one explicitly or put it in release/." >&2
echo "Usage: omarchy-iso-test [release/omarchy.iso] [options]" >&2
exit 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)"
$ENCRYPT && BASE_NAME+="-encrypted"
BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME"
RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)"
BASE_DISK="$BASE_DIR/base.qcow2"
BASE_OVMF="$BASE_DIR/OVMF_VARS.4m.fd"
SSH_KEY="$BASE_DIR/id_ed25519"
HTTP_PORT=$((SSH_PORT + 1))
HTTP_PID=""
mkdir -p "$BASE_DIR" "$RUN_DIR"
QMP_SOCK="$RUN_DIR/qmp.sock"
PIDFILE="$RUN_DIR/qemu.pid"
log() {
printf '\033[1;35m==> %s\033[0m\n' "$1"
}
# ---------------------------------------------------------------- vm control
vm_running() {
[[ -f $PIDFILE ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}
qmp() {
printf '{"execute":"qmp_capabilities"}\n{"execute":%s}\n' "$1" |
timeout 5 socat -t 2 - "UNIX-CONNECT:$QMP_SOCK" 2>/dev/null || true
}
screendump() {
qmp "\"screendump\", \"arguments\": {\"filename\": \"$1\"}" >/dev/null
}
capture_console() {
local name="$1" shot="$RUN_DIR/.capture.ppm"
sleep 1
screendump "$shot"
[[ -s $shot ]] || return 0
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null || true
rm -f "$shot"
}
stop_vm() {
vm_running || return 0
if ! ssh_guest "echo $GUEST_PASSWORD | sudo -S systemctl poweroff" >/dev/null 2>&1; then
qmp '"system_powerdown"' >/dev/null
fi
local waited=0
while vm_running && ((waited < 15)); do
sleep 1
((waited += 1))
done
if vm_running; then
qmp '"quit"' >/dev/null
sleep 1
fi
if vm_running; then
kill "$(cat "$PIDFILE")" 2>/dev/null || true
sleep 1
fi
}
open_screenshots() {
local entry
local -a screenshots=()
while IFS= read -r -d '' entry; do
screenshots+=("${entry#* }")
done < <(
find "$RUN_DIR" -type f \( -name "success-*.png" -o -name "failure-*.png" \) -printf '%T@ %p\0' |
sort -zn
)
(( ${#screenshots[@]} > 0 )) || return 0
log "Visual review: ${#screenshots[@]} screenshots in $RUN_DIR"
$NO_PREVIEW && return 0
if command -v imv >/dev/null && [[ -n ${WAYLAND_DISPLAY:-}${DISPLAY:-} ]]; then
setsid -f imv "${screenshots[@]}" >/dev/null 2>&1
fi
}
cleanup() {
local status=$?
[[ -n $HTTP_PID ]] && kill "$HTTP_PID" 2>/dev/null || true
if ! $KEEP_RUNNING; then
vm_running && log "Stopping test VM"
stop_vm
fi
rm -f "$RUN_DIR/.screen.ppm" "$RUN_DIR/.screen.png"
open_screenshots
return $status
}
trap cleanup EXIT
start_vm() {
local disk="$1" serial="$2"
shift 2
qemu-system-x86_64 \
-cpu host -enable-kvm -machine q35,accel=kvm \
-smp "$(nproc)" \
-m "$MEMORY" \
-drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
-drive if=pflash,format=raw,file="$BASE_OVMF" \
-drive file="$disk",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-device virtio-vga \
-display none \
-usb -device usb-tablet \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:$SSH_PORT-:22 \
-device virtio-net-pci,netdev=net0 \
-qmp "unix:$QMP_SOCK,server,nowait" \
-serial "file:$serial" \
-pidfile "$PIDFILE" \
-daemonize \
"$@"
}
# ------------------------------------------------------------ console driver
#
# The heart of the harness: read the VM's console with screendump + OCR, and
# type into it with QMP send-key. Together they make an expect-style loop
# that walks the same screens a user sees.
ocr_screen() {
local shot="$RUN_DIR/.screen.ppm" prepped="$RUN_DIR/.screen.png"
screendump "$shot"
[[ -s $shot ]] || return 0
magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0
tesseract "$prepped" - --psm 6 2>/dev/null || true
}
# Wait until the console shows the given text. Saves the offending screen as
# an artifact on timeout.
wait_for_screen() {
local text="$1" timeout="$2" waited=0 slug
until ocr_screen | grep -qi "$text"; do
if ! vm_running; then
echo "VM exited while waiting for screen: $text" >&2
return 1
fi
if ((waited >= timeout)); then
slug=${text,,}
slug=${slug// /-}
slug=${slug//[^a-z0-9-]/}
capture_console "failure-installer-waiting-for-$slug"
echo "Timed out after ${timeout}s waiting for screen: $text" >&2
return 1
fi
sleep 3
((waited += 3))
done
sleep 1 # let the screen settle before typing
}
# Press a key or chord, dash-separated: "ret", "ctrl-c", "ctrl-alt-f3".
press() {
local part json=""
local -a parts
IFS='-' read -ra parts <<<"$1"
for part in "${parts[@]}"; do
json+="{\"type\":\"qcode\",\"data\":\"$part\"},"
done
qmp "\"send-key\", \"arguments\": {\"keys\": [${json%,}]}" >/dev/null
}
type_text() {
local text="$1" ch i
for ((i = 0; i < ${#text}; i++)); do
ch=${text:i:1}
case "$ch" in
[a-z0-9]) press "$ch" ;;
[A-Z]) press "shift-${ch,,}" ;;
" ") press spc ;;
.) press dot ;;
,) press comma ;;
-) press minus ;;
_) press shift-minus ;;
/) press slash ;;
:) press shift-semicolon ;;
@) press shift-2 ;;
\|) press shift-backslash ;;
*) echo "type_text: unsupported character: $ch" >&2; return 1 ;;
esac
sleep 0.05
done
}
# --------------------------------------------------------------------- guest
ssh_guest() {
ssh -i "$SSH_KEY" -p "$SSH_PORT" \
-o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 \
-o LogLevel=ERROR \
"$GUEST_USER@127.0.0.1" "$@"
}
wait_for_ssh() {
local timeout="$1" failure_name="${2:-failure-session-ssh-timeout}" waited=0
while ! ssh_guest true 2>/dev/null; do
if ! vm_running; then
echo "VM exited while waiting for SSH" >&2
return 1
fi
if ((waited >= timeout)); then
capture_console "$failure_name"
echo "Timed out after ${timeout}s waiting for SSH" >&2
return 1
fi
sleep 5
((waited += 5))
done
}
session_started() {
ssh_guest "ls /run/user/1000/hypr 2>/dev/null | grep -q ." 2>/dev/null
}
ssh_session() {
local command="$1"
ssh_guest "export XDG_RUNTIME_DIR=/run/user/\$(id -u); \
export DBUS_SESSION_BUS_ADDRESS=unix:path=\$XDG_RUNTIME_DIR/bus; \
export LANG=\$(systemctl --user show-environment | sed -n 's/^LANG=//p' | head -1); \
export LANG=\${LANG:-C.UTF-8}; \
export HYPRLAND_INSTANCE_SIGNATURE=\$(ls -t \$XDG_RUNTIME_DIR/hypr | head -1); \
export WAYLAND_DISPLAY=\$(find \$XDG_RUNTIME_DIR -maxdepth 1 -name 'wayland-*' ! -name '*.lock' -printf '%f\\n' | head -1); \
export OMARCHY_PATH=/usr/share/omarchy; \
export PATH=\$OMARCHY_PATH/bin:\$PATH; \
$command" </dev/null
}
wait_for_guest_state() {
local description="$1" timeout="$2"
shift 2
local deadline=$((SECONDS + timeout))
until "$@" >/dev/null 2>&1; do
if ((SECONDS >= deadline)); then
printf 'not ok - %s\n' "$description" >&2
return 1
fi
sleep 1
done
printf 'ok - %s\n' "$description"
}
guest_layer_present() {
local namespace="$1"
ssh_session "hyprctl -j layers | jq -e --arg namespace '$namespace' \
'[.. | objects | select(.namespace? == \$namespace)] | length > 0'"
}
guest_layer_absent() {
! guest_layer_present "$1"
}
cleanup_shortcut_state() {
ssh_session 'for plugin in omarchy.launcher omarchy.menu omarchy.emojis omarchy.clipboard omarchy.audio; do
omarchy-shell shell hide "$plugin" >/dev/null 2>&1 || true
done
while read -r address; do
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null 2>&1 ||
hyprctl dispatch closewindow "address:$address" >/dev/null 2>&1 || true
done < <(hyprctl -j clients | jq -r '\''.[] | select(.class == "foot") | .address'\'')
hyprctl dispatch workspace 1 >/dev/null 2>&1 || true' >/dev/null 2>&1 || true
}
verify_surface_shortcut() {
local name="$1" chord="$2" namespace="$3" plugin="$4"
press "$chord"
if ! wait_for_guest_state "$name shortcut opens" 15 guest_layer_present "$namespace"; then
capture_console "failure-shortcut-$name-open"
ssh_session "omarchy-shell shell hide '$plugin' >/dev/null 2>&1 || true" || true
return 1
fi
capture_console "success-shortcut-$name"
press esc
if ! wait_for_guest_state "$name shortcut surface closes" 15 guest_layer_absent "$namespace"; then
capture_console "failure-shortcut-$name-close"
ssh_session "omarchy-shell shell hide '$plugin' >/dev/null 2>&1 || true" || true
return 1
fi
}
verify_desktop_shortcuts() {
cleanup_shortcut_state
press meta_l-ret
if ! wait_for_guest_state "terminal shortcut opens a window" 30 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
capture_console "failure-shortcut-terminal-open"
return 1
fi
press meta_l-ret
if ! wait_for_guest_state "terminal shortcut opens a second window" 30 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 2'"; then
capture_console "failure-shortcut-terminal-second-window"
return 1
fi
capture_console "success-shortcut-desktop-01-two-tiled-terminals"
press meta_l-t
if ! wait_for_guest_state "floating shortcut floats the active window" 15 \
ssh_session "hyprctl -j activewindow | jq -e '.class == \"foot\" and .floating == true'"; then
capture_console "failure-shortcut-window-float"
return 1
fi
capture_console "success-shortcut-desktop-02-floating-terminal"
press meta_l-t
if ! wait_for_guest_state "floating shortcut returns the window to tiling" 15 \
ssh_session "hyprctl -j activewindow | jq -e '.class == \"foot\" and .floating == false'"; then
capture_console "failure-shortcut-window-retile"
return 1
fi
press meta_l-shift-2
if ! wait_for_guest_state "move shortcut follows the window to workspace 2" 15 \
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 2' && \
hyprctl -j activewindow | jq -e '.class == \"foot\" and .workspace.id == 2'"; then
capture_console "failure-shortcut-window-workspace-2"
return 1
fi
capture_console "success-shortcut-desktop-03-terminal-on-workspace-2"
press meta_l-1
if ! wait_for_guest_state "workspace shortcut returns to workspace 1" 15 \
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 1'"; then
capture_console "failure-shortcut-workspace-1"
return 1
fi
capture_console "success-shortcut-desktop-04-workspace-1"
press meta_l-w
if ! wait_for_guest_state "close shortcut closes the workspace 1 terminal" 15 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
capture_console "failure-shortcut-close-workspace-1"
return 1
fi
press meta_l-2
if ! wait_for_guest_state "workspace shortcut visits workspace 2" 15 \
ssh_session "hyprctl -j activeworkspace | jq -e '.id == 2'"; then
capture_console "failure-shortcut-visit-workspace-2"
return 1
fi
press meta_l-w
if ! wait_for_guest_state "close shortcut closes the workspace 2 terminal" 15 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 0'"; then
capture_console "failure-shortcut-close-workspace-2"
return 1
fi
}
verify_universal_clipboard_shortcuts() {
local copy_token="OmarchyUniversalCopy$(date +%s)"
local paste_token="OmarchyUniversalPaste$(date +%s)"
local terminal_token="OmarchyTerminalPaste$(date +%s)"
local terminal_result="/tmp/omarchy-universal-terminal-paste"
cleanup_shortcut_state
ssh_session "setsid -f chromium --new-window about:blank >/dev/null 2>&1"
if ! wait_for_guest_state "universal clipboard test opens Chromium" 45 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class | test(\"(?i)chromium\"))] | length == 1'"; then
capture_console "failure-shortcut-universal-clipboard-browser"
return 1
fi
press ctrl-l
type_text "$copy_token"
press ctrl-a
press meta_l-c
if ! wait_for_guest_state "universal copy uses the active GUI application" 15 \
ssh_session "[[ \$(wl-paste --no-newline) == '$copy_token' ]]"; then
capture_console "failure-shortcut-universal-copy"
return 1
fi
ssh_session "setsid -f bash -c 'printf \"%s\" \"$paste_token\" | wl-copy' >/dev/null 2>&1"
if ! wait_for_guest_state "universal paste clipboard is seeded" 15 \
ssh_session "[[ \$(wl-paste --no-newline) == '$paste_token' ]]"; then
capture_console "failure-shortcut-universal-paste-seed"
return 1
fi
press ctrl-l
press meta_l-v
sleep 1
press ctrl-a
press ctrl-c
if ! wait_for_guest_state "universal paste uses the active GUI application" 15 \
ssh_session "[[ \$(wl-paste --no-newline) == '$paste_token' ]]"; then
capture_console "failure-shortcut-universal-paste-gui"
return 1
fi
capture_console "success-shortcut-universal-copy-paste-gui"
ssh_session 'while read -r address; do
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null 2>&1 ||
hyprctl dispatch closewindow "address:$address" >/dev/null 2>&1 || true
done < <(hyprctl -j clients | jq -r '\''.[] | select(.class | test("(?i)chromium")) | .address'\'')' || true
ssh_session "rm -f '$terminal_result'; \
setsid -f bash -c 'printf \"%s\" \"$terminal_token\" | wl-copy' >/dev/null 2>&1; \
setsid -f foot --app-id=foot -e bash -c 'read -r value; printf \"%s\" \"\$value\" > \"$terminal_result\"; sleep 30' >/dev/null 2>&1"
if ! wait_for_guest_state "terminal paste clipboard is seeded" 15 \
ssh_session "[[ \$(wl-paste --no-newline) == '$terminal_token' ]]"; then
capture_console "failure-shortcut-universal-paste-terminal-seed"
return 1
fi
if ! wait_for_guest_state "universal clipboard test opens a terminal" 30 \
ssh_session "hyprctl -j clients | jq -e '[.[] | select(.class == \"foot\")] | length == 1'"; then
capture_console "failure-shortcut-universal-clipboard-terminal"
return 1
fi
press meta_l-v
sleep 1
press ret
if ! wait_for_guest_state "universal paste uses the terminal-safe shortcut" 15 \
ssh_session "[[ \$(cat '$terminal_result' 2>/dev/null) == '$terminal_token' ]]"; then
capture_console "failure-shortcut-universal-paste-terminal"
return 1
fi
capture_console "success-shortcut-universal-paste-terminal"
cleanup_shortcut_state
}
shortcut_smoke_phase() {
log "Exercising primary desktop shortcuts through the VM keyboard"
local status=0
local name chord namespace plugin
local shortcuts='launcher|meta_l-spc|omarchy-launcher|omarchy.launcher
menu|meta_l-alt-spc|omarchy-menu|omarchy.menu
emojis|meta_l-ctrl-e|omarchy-emojis|omarchy.emojis
clipboard|meta_l-ctrl-v|omarchy-clipboard|omarchy.clipboard
audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio'
cleanup_shortcut_state
while IFS='|' read -r name chord namespace plugin; do
if ! verify_surface_shortcut "$name" "$chord" "$namespace" "$plugin"; then
status=1
fi
done <<<"$shortcuts"
if ! verify_desktop_shortcuts; then
status=1
fi
if ! verify_universal_clipboard_shortcuts; then
status=1
fi
cleanup_shortcut_state
return $status
}
# ----------------------------------------------------- driving the installer
drive_configurator() {
log "Driving the installer wizard"
wait_for_screen "keyboard layout" 300
capture_console "success-installer-01-keyboard-layout"
press ret # English (US) is preselected
wait_for_screen "Username" 120
type_text "$GUEST_USER"
capture_console "success-installer-02-username"
press ret
wait_for_screen "Password" 60
type_text "$GUEST_PASSWORD"
capture_console "success-installer-03-password"
press ret
wait_for_screen "Must match" 60
type_text "$GUEST_PASSWORD"
capture_console "success-installer-04-password-confirmation"
press ret
wait_for_screen "Full name" 60
type_text "Omarchy Test"
capture_console "success-installer-05-full-name"
press ret
wait_for_screen "Email address" 60
type_text "test@omarchy.org"
capture_console "success-installer-06-email"
press ret
wait_for_screen "Hostname" 60
type_text "$GUEST_HOSTNAME"
capture_console "success-installer-07-hostname"
press ret
wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment
capture_console "success-installer-08-timezone"
press ret
wait_for_screen "look right" 60
capture_console "success-installer-09-review"
press ret
wait_for_screen "install disk" 60
capture_console "success-installer-10-install-disk"
press ret # the single virtio disk
wait_for_screen "recovery possible" 60
capture_console "success-installer-11-disk-warning-encrypted"
if ! $ENCRYPT; then
press ctrl-c # toggles the confirm to the unencrypted flow
wait_for_screen "without encryption" 30
capture_console "success-installer-12-disk-warning-unencrypted"
fi
press ret
sleep 2
capture_console "success-installer-13-install-started"
}
wait_for_install() {
log "Waiting for the install to finish (timeout ${INSTALL_TIMEOUT}s)"
local waited=0 text progress_name
while true; do
text=$(ocr_screen)
if grep -qi "Reboot Now" <<<"$text"; then
log "Install finished. Confirming the reboot prompt."
capture_console "success-installer-15-reboot"
press ret
return 0
fi
if grep -qi "installation stopped" <<<"$text"; then
capture_console "failure-installer-installation-stopped"
echo "Install failed — screenshot saved to $RUN_DIR" >&2
return 1
fi
if ! vm_running; then
echo "VM exited during install" >&2
return 1
fi
if ((waited >= INSTALL_TIMEOUT)); then
capture_console "failure-installer-install-timeout"
echo "Timed out after ${INSTALL_TIMEOUT}s waiting for install" >&2
return 1
fi
if ((waited % 60 == 0)); then
printf -v progress_name 'success-installer-14-install-progress-%04ds' "$waited"
capture_console "$progress_name"
echo " ... installing (${waited}s)"
fi
sleep 10
((waited += 10))
done
}
# Encrypted boots stop at the LUKS passphrase prompt. Plymouth's prompt isn't
# reliably OCR-able, so type the passphrase on an interval until a console
# login becomes reachable; early keystrokes are harmlessly discarded.
unlock_luks() {
log "Typing the LUKS passphrase until boot proceeds"
local waited=0
sleep 10
capture_console "success-first-boot-01-luks-prompt"
while true; do
type_text "$GUEST_PASSWORD"
if ((waited == 0)); then
capture_console "success-first-boot-02-luks-passphrase"
fi
press ret
sleep 20
((waited += 30))
press ctrl-alt-f3
sleep 3
if ocr_screen | grep -qi "login:"; then
return 0
fi
if ((waited >= 300)); then
capture_console "failure-first-boot-luks-timeout"
echo "Timed out waiting for LUKS unlock" >&2
return 1
fi
done
}
# Log into a spare console TTY as the user and authorize the harness SSH key —
# the same thing a person would do to give themselves remote access.
bootstrap_ssh() {
log "Authorizing SSH access via console login"
mkdir -p "$BASE_DIR/www"
cat >"$BASE_DIR/www/bootstrap" <<EOF
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$(cat "$SSH_KEY.pub")" >>~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
echo "$GUEST_PASSWORD" | sudo -S ufw allow from 10.0.2.2 to any port 22 proto tcp
echo "$GUEST_PASSWORD" | sudo -S systemctl enable --now sshd.service
EOF
(cd "$BASE_DIR/www" && exec python3 -m http.server "$HTTP_PORT" --bind 127.0.0.1 >/dev/null 2>&1) &
HTTP_PID=$!
local waited=0
while true; do
press ctrl-alt-f3
sleep 4
ocr_screen | grep -qi "login:" && break
((waited += 8))
if ((waited >= 300)); then
capture_console "failure-first-boot-console-timeout"
echo "Timed out waiting for a console login prompt" >&2
return 1
fi
sleep 4
done
type_text "$GUEST_USER"
capture_console "success-first-boot-03-console-username"
press ret
wait_for_screen "Password" 60
type_text "$GUEST_PASSWORD"
capture_console "success-first-boot-04-console-password"
press ret
sleep 3
type_text "curl -fsS http://10.0.2.2:$HTTP_PORT/bootstrap | bash"
capture_console "success-first-boot-05-bootstrap-command"
press ret
wait_for_ssh 180 "failure-first-boot-ssh-timeout"
capture_console "success-first-boot-06-bootstrap-complete"
kill "$HTTP_PID" 2>/dev/null || true
HTTP_PID=""
press ctrl-alt-f1
}
# Get a freshly booted system to a running Hyprland session, typing at the
# LUKS prompt and/or SDDM greeter exactly like a user would.
establish_session() {
if $ENCRYPT; then
local waited=0
sleep 10
capture_console "success-session-01-luks-prompt"
until ssh_guest true 2>/dev/null; do
type_text "$GUEST_PASSWORD"
if ((waited == 0)); then
capture_console "success-session-02-luks-passphrase"
fi
press ret
sleep 15
((waited += 16))
if ((waited >= BOOT_TIMEOUT)); then
capture_console "failure-session-luks-timeout"
echo "Timed out unlocking LUKS" >&2
return 1
fi
done
# Encrypted installs autologin once the disk is unlocked
else
wait_for_ssh "$BOOT_TIMEOUT" "failure-session-ssh-timeout"
sleep 5
fi
local waited=0
until session_started; do
if ! $ENCRYPT; then
# SDDM remembers the user and focuses the password field
if ((waited == 0)); then
capture_console "success-session-01-login"
fi
type_text "$GUEST_PASSWORD"
if ((waited == 0)); then
capture_console "success-session-02-password"
fi
press ret
fi
sleep 10
((waited += 11))
if ((waited >= 300)); then
capture_console "failure-session-start-timeout"
echo "Timed out waiting for the Hyprland session" >&2
return 1
fi
done
capture_console "success-session-03-desktop"
}
# -------------------------------------------------------------------- phases
install_phase() {
log "Installing $(basename "$ISO") into a fresh VM (headless, driven via console)"
[[ -f $SSH_KEY ]] || ssh-keygen -t ed25519 -N "" -q -C "omarchy-iso-test" -f "$SSH_KEY"
rm -f "$BASE_DISK"
qemu-img create -f qcow2 "$BASE_DISK" 40G >/dev/null
cp "$OVMF_VARS_TEMPLATE" "$BASE_OVMF"
start_vm "$BASE_DISK" "$RUN_DIR/install-serial.log" \
-drive "file=$ISO,media=cdrom,if=none,format=raw,id=cdrom0" \
-device ide-cd,drive=cdrom0,bootindex=2
drive_configurator
wait_for_install
if $ENCRYPT; then
unlock_luks
fi
bootstrap_ssh
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install-timing.json 2>/dev/null" \
>"$RUN_DIR/omarchy-install-timing.json" 2>/dev/null || true
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/pacman.log 2>/dev/null" \
>"$RUN_DIR/pacman.log" 2>/dev/null || true
log "Installed system is up. Saving base image."
stop_vm
}
sync_omarchy() {
[[ -n $SYNC_DIR ]] || return 0
local target=".local/share/omarchy"
if $SYNC_ALL; then
log "Syncing $SYNC_DIR into the guest"
tar -C "$SYNC_DIR" --exclude .git -cf - . | ssh_guest "mkdir -p $target && tar -C $target -xf -"
else
log "Syncing $SYNC_DIR/test/acceptance into the guest"
tar -C "$SYNC_DIR/test" -cf - acceptance acceptance.d | ssh_guest "mkdir -p $target/test && tar -C $target/test -xf -"
fi
}
acceptance_phase() {
log "Booting acceptance VM from base image overlay"
local guest_omarchy_path="/usr/share/omarchy"
$SYNC_ALL && guest_omarchy_path=".local/share/omarchy"
qemu-img create -f qcow2 -b "$BASE_DISK" -F qcow2 "$RUN_DIR/run.qcow2" >/dev/null
start_vm "$RUN_DIR/run.qcow2" "$RUN_DIR/serial.log"
establish_session
sync_omarchy
local status=0
shortcut_smoke_phase || status=1
log "Running acceptance suite"
ssh_guest "OMARCHY_PATH=$guest_omarchy_path OMARCHY_ACCEPTANCE_DIR=/tmp/omarchy-acceptance bash .local/share/omarchy/test/acceptance" || status=$?
log "Collecting artifacts into $RUN_DIR"
ssh_guest "tar -C /tmp -cf - omarchy-acceptance 2>/dev/null" | tar -C "$RUN_DIR" -xf - || true
ssh_guest "echo $GUEST_PASSWORD | sudo -S cat /var/log/omarchy-install.log 2>/dev/null" >"$RUN_DIR/omarchy-install.log" 2>/dev/null || true
if ((status == 0)); then
capture_console "success-acceptance-final"
else
capture_console "failure-acceptance-final"
fi
return $status
}
# ---------------------------------------------------------------------- main
if $REUSE_BASE && [[ -f $BASE_DISK ]]; then
log "Reusing existing base image: $BASE_DISK"
else
install_phase
fi
if $INSTALL_ONLY; then
log "Base image ready: $BASE_DISK"
exit 0
fi
if acceptance_phase; then
log "Acceptance suite passed. Artifacts: $RUN_DIR"
else
log "Acceptance suite FAILED. Artifacts: $RUN_DIR"
exit 1
fi
-295
View File
@@ -1,295 +0,0 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
TEST_DISK="${OMARCHY_ISO_TEST_WINDOWS_DISK:-/tmp/omarchy-iso-test-windows.qcow2}"
TEST_OVMF_VARS="${OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS:-/tmp/OVMF_VARS.omarchy-iso-test-windows.fd}"
DISK_SIZE_GIB=96
MIN_FREE_GIB=64
RECREATE=0
ISO=""
ACTIVE_NBD=""
MOUNT_DIR=""
MOUNTED=0
TEMPORARY_DISK=""
usage() {
cat <<USAGE
Usage: omarchy-iso-test-windows-disk [--recreate] [release/omarchy.iso]
Creates or validates a synthetic Windows-style test disk with:
- a 512MiB EFI partition containing EFI/Microsoft
- an existing ext4 data partition with a preservation marker
- more than 64GiB of contiguous unallocated space for Omarchy
This exercises Windows partition detection and preservation; it is not a
bootable Windows installation.
Options:
--recreate Replace the existing test fixture
-h, --help Show this help
Paths can be overridden with:
OMARCHY_ISO_TEST_WINDOWS_DISK
OMARCHY_ISO_TEST_WINDOWS_OVMF_VARS
USAGE
}
while (( $# > 0 )); do
case "$1" in
--recreate)
RECREATE=1
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -n $ISO ]]; then
echo "Only one ISO may be specified." >&2
usage >&2
exit 2
fi
ISO="$1"
shift
;;
esac
done
cleanup_mount() {
if (( MOUNTED )); then
sudo umount "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNTED=0
fi
if [[ -n $MOUNT_DIR ]]; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
fi
}
disconnect_disk() {
cleanup_mount
if [[ -n $ACTIVE_NBD ]]; then
sudo qemu-nbd --disconnect "$ACTIVE_NBD" >/dev/null 2>&1 || true
ACTIVE_NBD=""
fi
}
cleanup() {
disconnect_disk
[[ -z $TEMPORARY_DISK ]] || rm -f "$TEMPORARY_DISK"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
find_free_nbd() {
local device
for device in /sys/class/block/nbd*; do
[[ -e $device ]] || continue
[[ ! -e $device/pid ]] || continue
printf '/dev/%s\n' "${device##*/}"
return 0
done
return 1
}
connect_disk() {
local disk="$1" mode="${2:-read-write}"
local -a options=(--format=qcow2)
ACTIVE_NBD="$(find_free_nbd)" || {
echo "No unused NBD device is available." >&2
return 1
}
[[ $mode == read-only ]] && options+=(--read-only)
if ! sudo qemu-nbd "${options[@]}" --connect="$ACTIVE_NBD" "$disk"; then
ACTIVE_NBD=""
return 1
fi
if ! sudo udevadm settle; then
disconnect_disk
return 1
fi
}
mount_partition() {
local partition="$1"
MOUNT_DIR="$(mktemp -d)" || return 1
if ! sudo mount -o "${2:-defaults}" "$partition" "$MOUNT_DIR"; then
rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true
MOUNT_DIR=""
return 1
fi
MOUNTED=1
}
largest_free_region() {
sudo parted -ms "$1" unit B print free </dev/null 2>/dev/null |
awk -F: '
$NF ~ /free/ {
size=$4
gsub(/B/, "", size)
if (size+0 > max+0) max=size
}
END { print max+0 }
'
}
validate_test_disk() {
local part_count pttype free_space_b min_free_b
local esp data valid=1
[[ -f $TEST_DISK ]] || return 1
qemu-img check -q "$TEST_DISK" >/dev/null 2>&1 || return 1
connect_disk "$TEST_DISK" read-only || return 1
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
part_count=$(lsblk -nro TYPE "$ACTIVE_NBD" | awk '$1 == "part" { count++ } END { print count+0 }')
pttype=$(lsblk -dnro PTTYPE "$ACTIVE_NBD" 2>/dev/null || true)
free_space_b=$(largest_free_region "$ACTIVE_NBD")
min_free_b=$((MIN_FREE_GIB * 1024 * 1024 * 1024))
[[ $pttype == gpt ]] || valid=0
(( part_count == 2 )) || valid=0
(( free_space_b >= min_free_b )) || valid=0
[[ $(sudo blkid -s TYPE -o value "$esp" 2>/dev/null || true) == vfat ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$esp" 2>/dev/null || true) == WINDOWS_ESP ]] || valid=0
[[ $(sudo blkid -s TYPE -o value "$data" 2>/dev/null || true) == ext4 ]] || valid=0
[[ $(sudo blkid -s LABEL -o value "$data" 2>/dev/null || true) == WINDOWS_DATA ]] || valid=0
if (( valid )); then
mount_partition "$esp" ro || valid=0
if (( valid )); then
sudo test -d "$MOUNT_DIR/EFI/Microsoft" || valid=0
fi
cleanup_mount
fi
if (( valid )); then
mount_partition "$data" ro || valid=0
if (( valid )); then
sudo test -f "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" || valid=0
fi
cleanup_mount
fi
disconnect_disk
(( valid ))
}
create_test_disk() {
local esp data
mkdir -p "$(dirname "$TEST_DISK")"
TEMPORARY_DISK="${TEST_DISK}.creating.$$"
rm -f "$TEMPORARY_DISK"
gum style --foreground 6 "Creating synthetic Windows test disk at $TEST_DISK"
qemu-img create -f qcow2 "$TEMPORARY_DISK" "${DISK_SIZE_GIB}G"
connect_disk "$TEMPORARY_DISK"
sudo parted --script "$ACTIVE_NBD" mklabel gpt
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_ESP fat32 1MiB 513MiB
sudo parted --script "$ACTIVE_NBD" set 1 esp on
sudo parted --script "$ACTIVE_NBD" mkpart WINDOWS_DATA ext4 513MiB 20GiB
sudo partprobe "$ACTIVE_NBD"
sudo udevadm settle
esp="${ACTIVE_NBD}p1"
data="${ACTIVE_NBD}p2"
sudo mkfs.fat -F32 -n WINDOWS_ESP "$esp" >/dev/null
sudo mkfs.ext4 -F -L WINDOWS_DATA "$data" >/dev/null 2>&1
mount_partition "$esp"
sudo mkdir -p "$MOUNT_DIR/EFI/Microsoft/Boot"
printf '%s\n' 'Synthetic Windows ESP for Omarchy ISO testing.' |
sudo tee "$MOUNT_DIR/EFI/Microsoft/README.txt" >/dev/null
cleanup_mount
mount_partition "$data"
printf '%s\n' 'This existing partition must survive the Omarchy installation.' |
sudo tee "$MOUNT_DIR/OMARCHY-MUST-NOT-TOUCH.txt" >/dev/null
cleanup_mount
sync
disconnect_disk
mv -f "$TEMPORARY_DISK" "$TEST_DISK"
TEMPORARY_DISK=""
}
select_iso() {
local -a isos=()
if [[ -n $ISO ]]; then
[[ -f $ISO ]] || {
echo "ISO not found: $ISO" >&2
return 1
}
realpath "$ISO"
return 0
fi
mapfile -t isos < <(
find "$REPO_ROOT/release" -maxdepth 1 -type f -name '*.iso' -printf '%T@ %p\n' 2>/dev/null |
sort -nr |
cut -d' ' -f2-
)
(( ${#isos[@]} > 0 )) || {
echo "No ISO found under $REPO_ROOT/release." >&2
return 1
}
printf '%s\n' "${isos[@]}" | gum choose --header "Select ISO to install"
}
omarchy-pkg-add qemu-full edk2-ovmf parted dosfstools e2fsprogs
sudo modprobe nbd max_part=16
if [[ -f $TEST_DISK ]] && (( ! RECREATE )); then
gum style --foreground 6 "Verifying existing test disk at $TEST_DISK"
if validate_test_disk; then
gum style --foreground 2 "✓ Synthetic Windows test disk is ready"
elif gum confirm --default=false "The existing test disk is not a pristine fixture. Recreate it?"; then
create_test_disk
else
gum style "Left the existing disk untouched."
exit 0
fi
else
create_test_disk
fi
# The synthetic fixture has no real Windows bootloader, so reset OVMF state for
# every run. The ISO still sees EFI/Microsoft and must leave that ESP untouched.
mkdir -p "$(dirname "$TEST_OVMF_VARS")"
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$TEST_OVMF_VARS"
gum style --border rounded --padding "0 1" --border-foreground 6 \
"Synthetic Windows disk ready" \
"Disk: $TEST_DISK" \
"Layout: 512MiB Windows ESP + existing data + 64GiB+ free"
if gum confirm "Start installation on this disk?"; then
ISO="$(select_iso)"
trap - EXIT INT TERM
exec "$SCRIPT_DIR/omarchy-iso-boot" \
--reuse \
--disk "$TEST_DISK" \
--ovmf-vars "$TEST_OVMF_VARS" \
"$ISO" \
-- -boot once=d,menu=on
fi
+15 -68
View File
@@ -4,8 +4,8 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves" VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
TMP_DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}" TMP_DISK="/tmp/omarchy-iso-boot.qcow2"
TMP_OVMF="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}" TMP_OVMF="/tmp/OVMF_VARS.4m.fd"
mkdir -p "$VM_DIR" mkdir -p "$VM_DIR"
@@ -18,7 +18,7 @@ save_vm() {
fi fi
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
echo "Error: No active VM found at $TMP_DISK / $TMP_OVMF" echo "Error: No active VM found in /tmp"
echo "Boot a VM first with: omarchy-iso-boot [iso]" echo "Boot a VM first with: omarchy-iso-boot [iso]"
exit 1 exit 1
fi fi
@@ -53,44 +53,7 @@ list_vms() {
} }
boot_vm() { boot_vm() {
local name="" local name="$1"
local boot_args=()
local active_disk="$TMP_DISK"
local active_ovmf="$TMP_OVMF"
if [[ $# -gt 0 && ${1:-} != --* ]]; then
name="$1"
shift
fi
while (( $# > 0 )); do
case "$1" in
--disk)
active_disk="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--disk=*)
active_disk="${1#*=}"
boot_args+=("$1")
shift
;;
--ovmf-vars)
active_ovmf="${2:-}"
boot_args+=("$1" "${2:-}")
shift 2
;;
--ovmf-vars=*)
active_ovmf="${1#*=}"
boot_args+=("$1")
shift
;;
*)
boot_args+=("$1")
shift
;;
esac
done
if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then if [[ -z "$(ls -A "$VM_DIR" 2>/dev/null)" ]]; then
echo "No VM snapshots available" echo "No VM snapshots available"
@@ -110,34 +73,25 @@ boot_vm() {
exit 1 exit 1
fi fi
if [[ -z $active_disk || -z $active_ovmf ]]; then if [[ -f "$TMP_DISK" ]] && ! gum confirm "Override existing /tmp VM?"; then
echo "Error: active disk and OVMF vars paths must not be empty"
exit 1
fi
if [[ -f $active_disk ]] && ! gum confirm "Override existing active VM disk at $active_disk?"; then
exit 0 exit 0
fi fi
echo "Copying VM '$name' to $active_disk..." echo "Copying VM '$name' to /tmp..."
mkdir -p "$(dirname "$active_disk")" "$(dirname "$active_ovmf")" cp "$vm_path/omarchy-iso-boot.qcow2" "$TMP_DISK"
cp "$vm_path/omarchy-iso-boot.qcow2" "$active_disk" cp "$vm_path/OVMF_VARS.4m.fd" "$TMP_OVMF"
cp "$vm_path/OVMF_VARS.4m.fd" "$active_ovmf"
gum style --foreground 212 "✓ VM ready. Booting..." gum style --foreground 212 "✓ VM ready. Booting..."
exec omarchy-iso-boot "${boot_args[@]}" /dev/null reuse exec omarchy-iso-boot /dev/null reuse
} }
command="${1:-}" case "${1:-}" in
[[ $# -gt 0 ]] && shift
case "$command" in
save) save)
save_vm "${1:-}" save_vm "$2"
;; ;;
boot) boot)
boot_vm "$@" boot_vm "$2"
;; ;;
list) list)
list_vms list_vms
@@ -147,16 +101,9 @@ case "$command" in
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \ "$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
"" \ "" \
"$(gum style --foreground 245 'Commands:')" \ "$(gum style --foreground 245 'Commands:')" \
" save [name] Save current active VM" \ " save [name] Save current VM from /tmp" \
" boot [name] [boot-options] Boot saved VM (interactive if no name)" \ " boot [name] Boot saved VM (interactive if no name)" \
" list List available VMs" \ " list List available VMs" \
"" \
"$(gum style --foreground 245 'Boot options:')" \
" --ssh-port PORT Forward host PORT to guest SSH (default: 2222)" \
" --ssh-host HOST Host address for SSH forwarding (default: 127.0.0.1)" \
" --disk PATH Active disk path, useful for parallel VMs" \
" --ovmf-vars PATH Active OVMF vars path, useful for parallel VMs" \
" --no-network Boot without a virtual NIC" \
"" \ "" \
"$(gum style --foreground 245 'Current VMs:')" "$(gum style --foreground 245 'Current VMs:')"
list_vms 2>/dev/null || echo " None" list_vms 2>/dev/null || echo " None"
-1
View File
@@ -12,4 +12,3 @@ openssh
pipewire pipewire
snapper snapper
sof-firmware sof-firmware
tailscale
+63 -285
View File
@@ -2,327 +2,105 @@
set -e set -e
OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" # Note that these are packages installed to the Arch container used to build the ISO.
OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}"
# Quattro, edge, dev, and local-source ISOs install the dev packages explicitly.
# Those package recipes track the quattro branch. This avoids relying on
# pacman's provides=omarchy resolution and shows the real package names being
# tested in the offline mirror and target install.
case "$OMARCHY_ISO_REF" in
quattro|edge|dev|local)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
;;
*)
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings}"
;;
esac
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
# Packages installed into the Arch container used to build the ISO.
pacman-key --init pacman-key --init
pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Sy archlinux-keyring
# Full upgrade, not just -Sy: docker never re-pulls :latest once it's cached, pacman --noconfirm -Sy archiso git sudo base-devel jq grub
# so this container can be months behind the mirror it installs from. A plain
# -Sy install is then a partial upgrade — new packages linked against a glibc
# the container doesn't have yet.
pacman --noconfirm -Syu archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli
# Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # Pre-import the omarchy signing key so pacman can verify packages without a keyserver lookup
# during the build without keyserver lookups).
pacman-key --add /builder/omarchy.gpg pacman-key --add /builder/omarchy.gpg
pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571
# omarchy-keyring is needed inside the offline mirror too. # Install omarchy-keyring for package verification during build
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Sy omarchy-keyring
pacman-key --populate omarchy pacman-key --populate omarchy
# Append the [omarchy] repo to the container's /etc/pacman.conf so subsequent # Setup build locations
# tools (notably makepkg in build-omarchy-packages.sh) can resolve omarchy- build_cache_dir="/var/cache"
# only build deps like limine-snapper-sync and limine-mkinitcpio-hook.
if ! grep -q '^\[omarchy\]' /etc/pacman.conf; then
awk '/^\[omarchy\]/,/^$/' /configs/pacman-online-${OMARCHY_MIRROR}.conf >> /etc/pacman.conf
fi
# Build locations
build_cache_dir=/var/cache
offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline" offline_mirror_dir="$build_cache_dir/airootfs/var/cache/omarchy/mirror/offline"
mkdir -p "$build_cache_dir" "$offline_mirror_dir" mkdir -p $build_cache_dir/
mkdir -p $offline_mirror_dir/
# Seed from the official Arch releng profile. # We base our ISO on the official arch ISO (releng) config
cp -r /archiso/configs/releng/* "$build_cache_dir/" cp -r /archiso/configs/releng/* $build_cache_dir/
rm "$build_cache_dir/airootfs/etc/motd" rm "$build_cache_dir/airootfs/etc/motd"
# We rely on the global CDN; drop reflector. # Avoid using reflector for mirror identification as we are relying on the global CDN
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service" rm -rf "$build_cache_dir/airootfs/etc/systemd/system/multi-user.target.wants/reflector.service"
rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d" rm -rf "$build_cache_dir/airootfs/etc/systemd/system/reflector.service.d"
rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector" rm -rf "$build_cache_dir/airootfs/etc/xdg/reflector"
# Bring in our archiso profile additions. # Bring in our configs
cp -r /configs/* "$build_cache_dir/" cp -r /configs/* $build_cache_dir/
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
# Persist OMARCHY_MIRROR so it's available at install time
echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror"
echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref"
cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <<EOF
OMARCHY_RUNTIME_PACKAGE=$OMARCHY_RUNTIME_PACKAGE
OMARCHY_SETTINGS_PACKAGE=$OMARCHY_SETTINGS_PACKAGE
OMARCHY_NVIM_PACKAGE=$OMARCHY_NVIM_PACKAGE
EOF
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then # Setup Omarchy itself
touch "$build_cache_dir/airootfs/usr/share/omarchy-iso/install-debug" if [[ -d /omarchy ]]; then
{ cp -rp /omarchy "$build_cache_dir/airootfs/root/omarchy"
echo "debug=1" else
echo "built_at=$(date -Is)" git clone -b $OMARCHY_INSTALLER_REF https://github.com/$OMARCHY_INSTALLER_REPO.git "$build_cache_dir/airootfs/root/omarchy"
echo "ref=$OMARCHY_ISO_REF"
echo "mirror=$OMARCHY_MIRROR"
echo "runtime_package=$OMARCHY_RUNTIME_PACKAGE"
echo "settings_package=$OMARCHY_SETTINGS_PACKAGE"
echo "nvim_package=$OMARCHY_NVIM_PACKAGE"
if [[ -d /omarchy-source ]]; then
echo "omarchy_source=/omarchy-source"
git -c safe.directory=/omarchy-source -C /omarchy-source rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_commit=/' || true
git -c safe.directory=/omarchy-source -C /omarchy-source status --short 2>/dev/null | sed 's/^/omarchy_status=/' || true
fi
if [[ -d /omarchy-pkgs ]]; then
echo "omarchy_pkgs_source=/omarchy-pkgs"
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_pkgs_commit=/' || true
git -c safe.directory=/omarchy-pkgs -C /omarchy-pkgs status --short 2>/dev/null | sed 's/^/omarchy_pkgs_status=/' || true
fi
} > "$build_cache_dir/airootfs/usr/share/omarchy-iso/build-info"
fi fi
# When --local-source is in effect, build omarchy* from the mounted source # Make log uploader available in the ISO too
# trees and drop them in the offline mirror. Otherwise pacman -Syw below mkdir -p "$build_cache_dir/airootfs/usr/local/bin/"
# downloads the published versions from the omarchy network mirror. cp "$build_cache_dir/airootfs/root/omarchy/bin/omarchy-upload-log" "$build_cache_dir/airootfs/usr/local/bin/omarchy-upload-log"
if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then
bash /builder/build-omarchy-packages.sh "$offline_mirror_dir"
LOCAL_OMARCHY_BUILD=1
fi
# Node.js binary for offline mise install. # Copy the Omarchy Plymouth theme to the ISO
mkdir -p "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy"
cp -r "$build_cache_dir/airootfs/root/omarchy/default/plymouth/"* "$build_cache_dir/airootfs/usr/share/plymouth/themes/omarchy/"
# Download and verify Node.js binary for offline installation
NODE_DIST_URL="https://nodejs.org/dist/latest" NODE_DIST_URL="https://nodejs.org/dist/latest"
# Get checksums and parse filename and SHA
NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt") NODE_SHASUMS=$(curl -fsSL "$NODE_DIST_URL/SHASUMS256.txt")
NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}') NODE_FILENAME=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $2}')
NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}') NODE_SHA=$(echo "$NODE_SHASUMS" | grep "linux-x64.tar.gz" | awk '{print $1}')
# Download the tarball
curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME" curl -fsSL "$NODE_DIST_URL/$NODE_FILENAME" -o "/tmp/$NODE_FILENAME"
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c -
# Verify SHA256 checksum
echo "$NODE_SHA /tmp/$NODE_FILENAME" | sha256sum -c - || {
echo "ERROR: Node.js checksum verification failed!"
exit 1
}
# Copy to ISO
mkdir -p "$build_cache_dir/airootfs/opt/packages/" mkdir -p "$build_cache_dir/airootfs/opt/packages/"
cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/"
# Packages installed into the live ISO environment itself (NOT the target system). # Add our additional packages to packages.x86_64
# The selected omarchy-settings package is needed here so its post_install hook arch_packages=(linux-t2 git gum jq openssl plymouth tzupdate omarchy-keyring lvm2 cryptsetup parted)
# drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the printf '%s\n' "${arch_packages[@]}" >>"$build_cache_dir/packages.x86_64"
# live initramfs.
arch_packages=(linux-t2 git gum jq openssl plymouth python-terminaltexteffects 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 # Build list of all the packages needed for the offline mirror
# stock linux is a second kernel nobody boots: ~147MB of ISO, plus its own archiso all_packages=($(cat "$build_cache_dir/packages.x86_64"))
# initramfs, copied into both the ISO tree and the size-constrained FAT EFI image. all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-base.packages" | grep -v '^$'))
# all_packages+=($(grep -v '^#' "$build_cache_dir/airootfs/root/omarchy/install/omarchy-other.packages" | grep -v '^$'))
# It cannot just be deleted — releng's broadcom-wl hard-depends on it, and it is all_packages+=($(grep -v '^#' /builder/archinstall.packages | grep -v '^$'))
# the only releng package that does, so pacman would drag the kernel straight back
# in. broadcom-wl is a prebuilt module for stock linux and cannot load on the
# kernel we boot, so it has done nothing since we started booting T2 anyway. The
# install is entirely offline and the live environment needs no Wi-Fi driver.
#
# Anchored so linux-t2 and linux-firmware are untouched.
sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64"
# Build the offline mirror: everything pacstrap might want during the target
# install. With --local-source, the omarchy* packages we just built are
# 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
base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages)
else
# Pull the same package lists out of the freshly-downloaded Omarchy runtime
# package so we don't need a local checkout in the non-local-source path.
bootstrap_cache_dir=/tmp/omarchy-pkg-bootstrap
rm -rf "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap /tmp/omarchy-pkglists
mkdir -p "$bootstrap_cache_dir" /tmp/offlinedb-bootstrap
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_RUNTIME_PACKAGE" --cachedir "$bootstrap_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null
omarchy_pkg=$(find "$bootstrap_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_RUNTIME_PACKAGE-*.pkg.tar.zst" | sort | head -1)
if [[ -z $omarchy_pkg ]]; then
echo "ERROR: downloaded package for $OMARCHY_RUNTIME_PACKAGE not found in $bootstrap_cache_dir" >&2
exit 1
fi
mkdir -p /tmp/omarchy-pkglists
bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages
base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages)
fi
mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso"
cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages"
# Collect every package we want available in the offline mirror.
declare -a all_packages
mapfile -t all_packages < <(
{
cat "$build_cache_dir/packages.x86_64"
grep -hv '^#\|^$' "${base_pkg_lists[@]}"
grep -hv '^#\|^$' /builder/archinstall.packages
# 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
)
# 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.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
mapfile -t all_packages < <(
printf '%s\n' "${all_packages[@]}" |
grep -Fxv \
-e "$OMARCHY_RUNTIME_PACKAGE" \
-e "$OMARCHY_SETTINGS_PACKAGE" \
-e "$OMARCHY_NVIM_PACKAGE" || true
)
fi
# Download all the packages to the offline mirror inside the ISO
mkdir -p /tmp/offlinedb mkdir -p /tmp/offlinedb
download_offline_packages() { pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "${all_packages[@]}" --cachedir $offline_mirror_dir/ --dbpath /tmp/offlinedb
pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \ repo-add --new "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
"${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed
}
# A repository may occasionally republish a package without changing its # Create a symlink to the offline mirror instead of duplicating it.
# filename. Pacman detects that the persistent cached copy no longer matches # mkarchiso needs packages at /var/cache/omarchy/mirror/offline in the container,
# the refreshed repository checksum and deletes it, but still fails the # but they're actually in $build_cache_dir/airootfs/var/cache/omarchy/mirror/offline
# transaction. Retry once so the now-missing package is downloaded.
if ! download_offline_packages; then
echo "Offline package download failed; retrying after pacman cleaned invalid cached files..." >&2
download_offline_packages
fi
# Resolve the exact filenames chosen by the same synced package databases used
# for the download. Pruning by this transaction (rather than merely keeping the
# 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 "/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
exit 1
fi
mapfile -t required_package_files <<< "$resolved_package_files"
# The online transaction intentionally excludes packages built from the local
# checkouts. Add those exact artifacts back to the keep-set after verifying
# that the local build left exactly one file for each selected package name.
if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then
for local_package_name in \
"$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE"; do
local_package_file=""
for candidate in "$offline_mirror_dir/$local_package_name-"*.pkg.tar.*; do
[[ -f $candidate && $candidate != *.sig ]] || continue
read -r candidate_name _ < <(pacman -Qp "$candidate" 2>/dev/null) || continue
[[ $candidate_name == "$local_package_name" ]] || continue
if [[ -n $local_package_file ]]; then
echo "ERROR: multiple local builds found for $local_package_name" >&2
exit 1
fi
local_package_file="${candidate##*/}"
done
if [[ -z $local_package_file ]]; then
echo "ERROR: local build not found for $local_package_name" >&2
exit 1
fi
required_package_files+=("$local_package_file")
done
fi
printf '%s\n' "${required_package_files[@]}" |
bash /builder/prune-offline-mirror.sh "$offline_mirror_dir"
# Rebuild the offline repo db from scratch so size/checksum/depends entries
# always reflect only the package files selected for this build.
rm -f "$offline_mirror_dir"/offline.db* "$offline_mirror_dir"/offline.files*
repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar.zst
# mkarchiso expects the mirror at /var/cache/omarchy/mirror/offline inside the
# container (the airootfs path); symlink rather than duplicate.
mkdir -p /var/cache/omarchy/mirror mkdir -p /var/cache/omarchy/mirror
ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline ln -s "$offline_mirror_dir" "/var/cache/omarchy/mirror/offline"
# Denominator for the install dashboard's progress bar. Resolving the mirror's # Copy the offline pacman.conf to the ISO's /etc directory so the live environment uses our
# own package lists against the mirror we just indexed, with an empty local db, # same config when booted.
# is the question pacstrap asks at install time — same resolver, same repo, same cp $build_cache_dir/pacman-offline.conf "$build_cache_dir/airootfs/etc/pacman.conf"
# lists — so no hand-kept constant can drift.
#
# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and
# intel-ucode because the mirror must contain either. phases.py records expected
# and actual in the timing JSON, so growing drift shows up in acceptance runs.
# The early-bootstrap set is already inside this closure, so restating it would
# only add a second list to drift.
resolve_expected_packages() {
local resolve_root=/tmp/omarchy-expected-packages
local resolved
local -a targets
rm -rf "$resolve_root" # Finally, we assemble the entire ISO
mkdir -p "$resolve_root/var/lib/pacman" mkarchiso -v -w "$build_cache_dir/work/" -o "/out/" "$build_cache_dir/"
mapfile -t targets < <( # Fix ownership of output files to match host user
{ if [ -n "$HOST_UID" ] && [ -n "$HOST_GID" ]; then
grep -hv '^#\|^$' /builder/archinstall.packages chown -R "$HOST_UID:$HOST_GID" /out/
# Read the shipped copy, which is what _runtime_package_list reads at
# install time, not the build-time source it came from.
grep -hv '^#\|^$' \
"$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages"
printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \
"$OMARCHY_NVIM_PACKAGE"
} | sort -u
)
pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -Sy >/dev/null || return 1
# Capture before counting: no pipefail here, so a pacman failure inside a
# pipeline would become a plausible partial count, which never trips the
# dashboard's fallback.
resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \
--root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \
--noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1
printf '%s\n' "$resolved" | sort -u | grep -c .
}
# Worth failing the build over: -S --print only aborts when a target is missing
# from the offline repo, which would fail pacstrap the same way. A count that
# merely looks wrong is not — the dashboard falls back without the file.
if ! expected_packages="$(resolve_expected_packages)"; then
echo "ERROR: could not resolve the target package count from the offline mirror." >&2
echo " pacman -S --print aborts the whole transaction if any single target" >&2
echo " is missing, so this almost certainly means pacstrap would fail the" >&2
echo " same way at install time." >&2
exit 1
fi
if (( expected_packages < 600 || expected_packages > 2000 )); then
echo "WARNING: resolved target package count $expected_packages is outside the" >&2
echo " expected 600-2000 range; shipping no denominator so the install" >&2
echo " dashboard falls back to its time-based curve." >&2
else
printf '%s\n' "$expected_packages" \
>"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages"
echo "Target install resolves to $expected_packages packages."
fi
# Live ISO uses the same offline pacman.conf.
cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf"
# Build the ISO.
mkarchiso -v -w "$build_cache_dir/work/" -o /out/ "$build_cache_dir/"
# Match host UID/GID on output.
if [[ -n $HOST_UID && -n $HOST_GID ]]; then
chown -R "$HOST_UID:$HOST_GID" /out/
fi fi
-85
View File
@@ -1,85 +0,0 @@
#!/bin/bash
# Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs)
# and place the resulting .pkg.tar.zst files in the offline mirror.
set -e
offline_mirror_dir="$1"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: build-omarchy-packages.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d /omarchy-source ]]; then
echo "ERROR: /omarchy-source not mounted (pass --local-source or set OMARCHY_SOURCE_PATH)" >&2
exit 1
fi
if [[ ! -d /omarchy-pkgs ]]; then
echo "ERROR: /omarchy-pkgs not mounted (set OMARCHY_PKGS_PATH or place ../omarchy-pkgs)" >&2
exit 1
fi
work_dir=/tmp/omarchy-pkg-build
rm -rf "$work_dir"
mkdir -p "$work_dir"
if ! id builder &>/dev/null; then
useradd -m -s /bin/bash builder
fi
echo 'builder ALL=(ALL) NOPASSWD: /usr/bin/pacman' > /etc/sudoers.d/99-omarchy-pkg-builder
chmod 440 /etc/sudoers.d/99-omarchy-pkg-builder
chown builder:builder "$work_dir"
pacman -Sy --noconfirm
: "${OMARCHY_RUNTIME_PACKAGE:=omarchy-dev}"
: "${OMARCHY_SETTINGS_PACKAGE:=omarchy-settings-dev}"
: "${OMARCHY_NVIM_PACKAGE:=omarchy-nvim}"
packages=(
"$OMARCHY_SETTINGS_PACKAGE"
"$OMARCHY_RUNTIME_PACKAGE"
"$OMARCHY_NVIM_PACKAGE"
)
# Local-source packages must replace every cached build of the same package,
# even when the checkout's generated pkgver sorts below a published build.
# Otherwise the later generic cache pruning can silently keep edge instead.
for pkg in "${packages[@]}"; do
rm -f "$offline_mirror_dir/$pkg-"*.pkg.tar.*
done
for pkg in "${packages[@]}"; do
echo "----------------------------------------"
echo "Building $pkg"
echo "----------------------------------------"
pkg_work="$work_dir/$pkg"
if [[ ! -d "/omarchy-pkgs/pkgbuilds/$pkg" ]]; then
echo "ERROR: package source not found: /omarchy-pkgs/pkgbuilds/$pkg" >&2
exit 1
fi
cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work"
chown -R builder:builder "$pkg_work"
su builder -c "
cd '$pkg_work' &&
PKGDEST='$work_dir' \
OMARCHY_SRC=/omarchy-source \
makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f
"
done
mkdir -p "$offline_mirror_dir"
for package_file in "$work_dir"/*.pkg.tar.zst; do
destination="$offline_mirror_dir/$(basename "$package_file")"
# A cached signature belongs to the previously downloaded or locally built
# package. Keeping it beside a newly built package makes pacman reject the
# otherwise valid local-source build.
rm -f "$destination" "$destination.sig"
mv "$package_file" "$destination"
done
echo
echo "Built Omarchy packages, placed in $offline_mirror_dir:"
ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |'
-67
View File
@@ -1,67 +0,0 @@
#!/bin/bash
set -euo pipefail
offline_mirror_dir="${1:-}"
if [[ -z $offline_mirror_dir ]]; then
echo "Usage: prune-offline-mirror.sh <offline-mirror-dir>" >&2
exit 1
fi
if [[ ! -d $offline_mirror_dir ]]; then
echo "ERROR: offline mirror directory not found: $offline_mirror_dir" >&2
exit 1
fi
# Read the exact package filenames selected by pacman on stdin. Validate the
# complete selection before deleting anything: an empty/partial resolution must
# never turn a warm-cache build into a broken mirror.
declare -A required=()
while IFS= read -r filename; do
[[ -n $filename ]] || continue
if [[ $filename == */* || $filename != *.pkg.tar.* || $filename == *.sig ]]; then
echo "ERROR: invalid required package filename: $filename" >&2
exit 1
fi
required["$filename"]=1
done
if (( ${#required[@]} == 0 )); then
echo "ERROR: refusing to prune the offline mirror with an empty package selection" >&2
exit 1
fi
missing=()
for filename in "${!required[@]}"; do
[[ -f "$offline_mirror_dir/$filename" ]] || missing+=("$filename")
done
if (( ${#missing[@]} > 0 )); then
echo "ERROR: refusing to prune; selected packages are missing from the mirror:" >&2
printf ' %s\n' "${missing[@]}" >&2
exit 1
fi
stale=()
while IFS= read -r -d '' package_file; do
filename="${package_file##*/}"
[[ -n ${required[$filename]+x} ]] || stale+=("$package_file")
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*' ! -name '*.sig' -print0
)
if (( ${#stale[@]} > 0 )); then
echo "Pruning package archives not selected for the offline mirror:"
for package_file in "${stale[@]}"; do
echo " $(basename "$package_file")"
rm -f -- "$package_file" "$package_file.sig"
done
fi
# A failed or interrupted download can leave a signature without its archive.
# It cannot be indexed and only wastes space in the ISO.
while IFS= read -r -d '' signature_file; do
[[ -f ${signature_file%.sig} ]] || rm -f -- "$signature_file"
done < <(
find "$offline_mirror_dir" -maxdepth 1 -type f \
-name '*.pkg.tar.*.sig' -print0
)
@@ -1,18 +0,0 @@
# mkinitcpio preset file for the 'linux-t2' package on archiso
#
# The filename is the pkgbase, and that is load-bearing. Kernel packages ship
# nothing in /boot — they only install usr/lib/modules/<kver>/vmlinuz — so
# mkinitcpio's alpm hook sources /etc/mkinitcpio.d/<pkgbase>.preset and copies
# THAT package's kernel to whatever ALL_kver names (is_kernelcopy in
# /usr/share/libalpm/scripts/mkinitcpio). Named linux.preset, this pointed the
# stock kernel at the T2 filenames, so the ISO booted stock Arch while every
# boot entry said linux-t2 and T2 Macs got no keyboard or trackpad.
#
# releng ships its own linux.preset for the stock kernel; leave it alone.
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -0,0 +1,9 @@
# mkinitcpio preset file for the 'linux' package on archiso
PRESETS=('archiso')
ALL_kver='/boot/vmlinuz-linux-t2'
archiso_config='/etc/mkinitcpio.conf.d/archiso.conf'
archiso_image="/boot/initramfs-linux-t2.img"
@@ -1,21 +0,0 @@
# remove from airootfs!
#
# Mirror omarchy-settings' /etc/skel shell defaults into /root so the live ISO
# console has the same bashrc, starship prompt, and aliases as an installed
# Omarchy system. Fires during airootfs pacstrap (when omarchy-settings is
# installed); the "remove from airootfs" magic string causes
# zzzz99-remove-custom-hooks-from-airootfs.hook to delete this file before
# the squashfs is built, so no runtime hook remains.
[Trigger]
Operation = Install
Operation = Upgrade
Type = Package
Target = omarchy-settings
[Action]
Description = Mirroring /etc/skel shell defaults into /root for the live ISO...
When = PostTransaction
Depends = coreutils
Depends = omarchy-settings
Exec = /bin/sh -c 'install -d -m 0700 /root/.config && for f in .bashrc .bash_profile; do [ -f "/etc/skel/$f" ] && install -m 0644 "/etc/skel/$f" "/root/$f"; done; [ -f /etc/skel/.config/starship.toml ] && install -m 0644 /etc/skel/.config/starship.toml /root/.config/starship.toml; true'
+257 -113
View File
@@ -1,120 +1,264 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#
# Live ISO entry point on tty1: set up the live VT, run the configurator
# wizard, then hand off to the Python install orchestrator. Mirrors the
# stream/env contract from the previously-working installer:
# - stdout teed to /var/log/omarchy-install.log (CSI-stripped) AND to tty
# - stderr direct to /dev/tty so gum (which draws its TUI on stderr)
# renders correctly
# - CLICOLOR_FORCE/FORCE_COLOR so gum emits ANSI even with stdout piped
# - COLUMNS/LINES so gum picks up real terminal size
set -euo pipefail set -euo pipefail
[[ $(tty) == /dev/tty1 ]] || exit 0 use_omarchy_helpers() {
export OMARCHY_PATH="/root/omarchy"
export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)" export OMARCHY_INSTALL="/root/omarchy/install"
if [[ -f /root/omarchy_iso_ref ]]; then export OMARCHY_INSTALL_LOG_FILE="/var/log/omarchy-install.log"
export OMARCHY_ISO_REF="$(cat /root/omarchy_iso_ref)" export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)"
fi source /root/omarchy/install/helpers/all.sh
if [[ -f /usr/share/omarchy-iso/package-targets ]]; then
# shellcheck disable=SC1091
source /usr/share/omarchy-iso/package-targets
export OMARCHY_RUNTIME_PACKAGE OMARCHY_SETTINGS_PACKAGE OMARCHY_NVIM_PACKAGE
fi
export OMARCHY_PATH=/usr/share/omarchy
export OMARCHY_INSTALL=$OMARCHY_PATH/install
export OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log
if [[ -f /usr/share/omarchy-iso/install-debug ]]; then
export OMARCHY_INSTALL_DEBUG=1
fi
# Tokyo Night palette so the live VT matches the installed look.
set_tokyo_night_colors() {
echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a"
echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7"
echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868"
echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68"
echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff"
echo -en "\e]PFc0caf5"
echo -en "\033[0m"
clear
}
set_tokyo_night_colors
mkdir -p /var/log
touch "$OMARCHY_INSTALL_LOG_FILE"
export COLUMNS=$(tput cols)
export LINES=$(tput lines)
exec > >(tee >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$OMARCHY_INSTALL_LOG_FILE") 2>/dev/null) 2>/dev/tty
export CLICOLOR_FORCE=1
export FORCE_COLOR=1
if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then
echo "=== Omarchy ISO debug build ==="
[[ -f /usr/share/omarchy-iso/build-info ]] && cat /usr/share/omarchy-iso/build-info
pacman -Q omarchy-settings omarchy-keyring 2>/dev/null || true
echo "================================"
fi
# Warm the page cache for the bundled packages while the user works through the
# wizard. The install reads ~3GB out of the offline mirror, and pacman consumes
# it at only ~33MB/s, so on media slower than that the install is read-bound and
# every byte cached here is a byte it never waits for. On faster media this costs
# nothing but otherwise-idle bandwidth: the medium is untouched while the user
# types, and the target disk it writes to later is a different device.
#
# Clean page cache only, so the kernel reclaims it under pressure instead of
# OOMing, and a budget so small machines never evict what was just warmed.
# Set OMARCHY_NO_PREFETCH=1 to A/B the same ISO with this disabled.
warm_offline_mirror() {
local mirror=/var/cache/omarchy/mirror/offline
local budget_kb spent_kb=0 size_kb path
[[ ${OMARCHY_NO_PREFETCH:-} == 1 ]] && return 0
[[ -d $mirror ]] || return 0
budget_kb=$(($(awk '/^MemAvailable:/ { print $2 }' /proc/meminfo) / 2))
((budget_kb > 262144)) || return 0
# Largest first: the install reads most of the mirror, so when the budget
# cannot cover all of it this still front-loads the bytes that dominate.
while read -r size_kb path; do
((spent_kb + size_kb > budget_kb)) && continue
cat -- "$path" >/dev/null 2>&1 || true
spent_kb=$((spent_kb + size_kb))
done < <(du -k "$mirror"/*.pkg.tar.zst 2>/dev/null | sort -rn)
} }
warm_offline_mirror & run_configurator() {
warm_pid=$! set_tokyo_night_colors
trap 'kill "$warm_pid" 2>/dev/null' EXIT
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
# everything downstream runs the ordinary path against ordinary inputs.
if /usr/local/bin/omarchy-cidata-load; then
echo "Autoinstall configuration found on cidata drive; skipping the configurator."
export OMARCHY_UI_INTERACTIVE=no
else
./configurator ./configurator
fi export OMARCHY_USER="$(jq -r '.users[0].username' user_credentials.json)"
}
# The foreground dashboard is now the sole visible install UI owner. It starts install_arch() {
# the actual installer as a non-interactive child, logs child output, waits for clear_logo
# completion, then renders the final installed-time/reboot prompt itself. gum style --foreground 3 --padding "1 0 0 $PADDING_LEFT" "Installing..."
export OMARCHY_DASHBOARD_TTY="$(tty)" echo
rm -f /run/omarchy-install/state.json
/usr/local/bin/omarchy-install-dashboard \ touch /var/log/omarchy-install.log
"$OMARCHY_INSTALL_LOG_FILE" \
/run/omarchy-install/state.json \ start_log_output
-- \
/usr/local/bin/omarchy-iso-install \ # Set CURRENT_SCRIPT for the trap to display better when nothing is returned for some reason
--config /root/user_configuration.json \ CURRENT_SCRIPT="install_base_system"
--creds /root/user_credentials.json \ install_base_system > >(sed -u 's/\x1b\[[0-9;]*[a-zA-Z]//g' >>/var/log/omarchy-install.log) 2>&1
--full-name-file /root/user_full_name.txt \ unset CURRENT_SCRIPT
--email-file /root/user_email_address.txt \ stop_log_output
--encrypt-file /root/user_encrypt_installation.txt \ }
--authorized-keys-file /root/authorized_keys \
--tailscale-authkey-file /root/tailscale_authkey install_omarchy() {
chroot_bash -lc "sudo pacman -S --noconfirm --needed gum" >/dev/null
chroot_bash -lc "source /home/$OMARCHY_USER/.local/share/omarchy/install.sh"
configure_login_for_unencrypted_install
# Reboot if requested by installer
if [[ -f /mnt/var/tmp/omarchy-install-completed ]]; then
reboot
fi
}
# Set Tokyo Night color scheme for the terminal
set_tokyo_night_colors() {
if [[ $(tty) == "/dev/tty"* ]]; then
# Tokyo Night color palette
echo -en "\e]P01a1b26" # black (background)
echo -en "\e]P1f7768e" # red
echo -en "\e]P29ece6a" # green
echo -en "\e]P3e0af68" # yellow
echo -en "\e]P47aa2f7" # blue
echo -en "\e]P5bb9af7" # magenta
echo -en "\e]P67dcfff" # cyan
echo -en "\e]P7a9b1d6" # white
echo -en "\e]P8414868" # bright black
echo -en "\e]P9f7768e" # bright red
echo -en "\e]PA9ece6a" # bright green
echo -en "\e]PBe0af68" # bright yellow
echo -en "\e]PC7aa2f7" # bright blue
echo -en "\e]PDbb9af7" # bright magenta
echo -en "\e]PE7dcfff" # bright cyan
echo -en "\e]PFc0caf5" # bright white (foreground)
# Set default foreground and background
echo -en "\033[0m"
clear
fi
}
install_disk() {
jq -er 'first(.disk_config.device_modifications[]? | select(.wipe == true) | .device)' user_configuration.json
}
cleanup_install_disk() {
local disk="$1"
if [[ -z "$disk" || ! -b "$disk" ]]; then
echo "Could not determine install disk for cleanup" >&2
return 1
fi
echo "Cleaning up existing holders on install disk: $disk"
# Ensure that no mounts exist from past install attempts.
findmnt -R /mnt >/dev/null && umount -R /mnt || true
# Turn off swap and unmount anything backed by the selected disk, including
# device-mapper children from a previous install. Active LVM/swap holders can
# prevent the kernel from re-reading the partition table after archinstall
# wipes and recreates it.
while read -r dev; do
[[ -b "$dev" ]] || continue
swapoff "$dev" 2>/dev/null || true
while read -r target; do
[[ -n "$target" ]] || continue
umount "$target" 2>/dev/null || true
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
done < <(lsblk -rnpo PATH "$disk")
# Deactivate any LVM volume groups whose physical volumes live on the selected
# disk. This is the common case when replacing Fedora/Alma/RHEL installs.
while read -r dev type; do
[[ "$type" == "disk" || "$type" == "part" || "$type" == "crypt" ]] || continue
while read -r vg; do
[[ -n "$vg" ]] || continue
vgchange -an "$vg" 2>/dev/null || true
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
done < <(lsblk -rnpo PATH,TYPE "$disk")
# Close any LUKS mappings stacked on the selected disk after filesystems and
# swap have been released.
while read -r dev type; do
[[ "$type" == "crypt" ]] || continue
cryptsetup close "$dev" 2>/dev/null || true
done < <(lsblk -rnpo PATH,TYPE "$disk")
blockdev --flushbufs "$disk" 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle || true
}
install_base_system() {
# Initialize and populate the keyring
pacman-key --init
pacman-key --populate archlinux
pacman-key --populate omarchy
# Sync the offline database so pacman can find packages
pacman -Sy --noconfirm
cleanup_install_disk "$(install_disk)"
# Workarounds for archinstall 4.2 regressions under Python 3.14:
# 1. sync_log_to_install_medium: `self.target / absolute_logfile` drops
# self.target because the RHS is absolute, so Path.copy() raises EINVAL
# (source == target).
# 2. _add_limine_bootloader: `Path.copy(efi_dir_path)` raises IsADirectoryError
# because 3.14's Path.copy treats target as a literal path, not a directory
# (shutil.copy used to auto-append the source filename).
sed -i \
-e 's|logfile_target = self\.target / absolute_logfile$|logfile_target = self.target / absolute_logfile.relative_to("/")|' \
-e 's|(limine_path / file)\.copy(efi_dir_path)|(limine_path / file).copy(efi_dir_path / file)|' \
-e "s|(limine_path / 'limine-bios.sys')\.copy(boot_limine_path)|(limine_path / 'limine-bios.sys').copy(boot_limine_path / 'limine-bios.sys')|" \
/usr/lib/python3.14/site-packages/archinstall/lib/installer.py
# Install using files generated by the ./configurator
# Skip NTP and WKD sync since we're offline (keyring is pre-populated in ISO)
archinstall \
--config user_configuration.json \
--creds user_credentials.json \
--silent \
--skip-ntp \
--skip-wkd \
--skip-wifi-check
# Archinstall unmounts the ESP when it finishes. Omarchy's boot finalizer
# needs the generated Limine config and EFI artifacts available under /boot.
if ! mountpoint -q /mnt/boot; then
arch-chroot /mnt mount /boot
fi
# The installed fstab keeps the ESP root-only, but Omarchy finalization runs
# as the target user and must discover the Limine config before using sudo to
# replace it. Temporarily allow reads and directory traversal during install.
boot_device=$(findmnt -nro SOURCE --target /mnt/boot)
umount /mnt/boot
mount -t vfat -o rw,fmask=0022,dmask=0022 "$boot_device" /mnt/boot
if ! arch-chroot -u "$OMARCHY_USER" /mnt test -x /boot; then
echo "Target user cannot access the mounted ESP" >&2
return 1
fi
# After archinstall sets up the base system but before our installer runs,
# we need to ensure the offline pacman.conf is in place
cp /etc/pacman.conf /mnt/etc/pacman.conf
# Mount the offline mirror so it's accessible in the chroot
mkdir -p /mnt/var/cache/omarchy/mirror/offline
mount --bind /var/cache/omarchy/mirror/offline /mnt/var/cache/omarchy/mirror/offline
# Mount the packages dir so it's accessible in the chroot
mkdir -p /mnt/opt/packages
mount --bind /opt/packages /mnt/opt/packages
# No need to ask for sudo during the installation (omarchy itself responsible for removing after install)
mkdir -p /mnt/etc/sudoers.d
cat >/mnt/etc/sudoers.d/99-omarchy-installer <<EOF
root ALL=(ALL:ALL) NOPASSWD: ALL
%wheel ALL=(ALL:ALL) NOPASSWD: ALL
$OMARCHY_USER ALL=(ALL:ALL) NOPASSWD: ALL
EOF
chmod 440 /mnt/etc/sudoers.d/99-omarchy-installer
# Copy the local omarchy repo to the user's home directory
mkdir -p /mnt/home/$OMARCHY_USER/.local/share/
cp -r /root/omarchy /mnt/home/$OMARCHY_USER/.local/share/
chown -R 1000:1000 /mnt/home/$OMARCHY_USER/.local/
# Ensure all necessary scripts are executable
find /mnt/home/$OMARCHY_USER/.local/share/omarchy -type f -path "*/bin/*" -exec chmod +x {} \;
chmod +x /mnt/home/$OMARCHY_USER/.local/share/omarchy/boot.sh 2>/dev/null || true
find /mnt/home/$OMARCHY_USER/.local/share/omarchy/default/waybar -type f -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true
}
configure_login_for_unencrypted_install() {
if [[ $(<user_encrypt_installation.txt) != "false" ]]; then
return
fi
# Unencrypted installs must stop at SDDM so the user password is entered
# before reaching the desktop. Omarchy's normal encrypted path may autologin
# because the disk password was already entered at boot.
#
# Keep the Omarchy SDDM theme and seed SDDM's last user/session state so
# first boot looks like the SDDM screen shown after logging out of Omarchy.
mkdir -p /mnt/etc/sddm.conf.d
rm -f /mnt/etc/sddm.conf.d/autologin.conf
cat >/mnt/etc/sddm.conf.d/99-omarchy-login.conf <<EOF
[Theme]
Current=omarchy
[Users]
RememberLastUser=true
RememberLastSession=true
EOF
mkdir -p /mnt/var/lib/sddm
cat >/mnt/var/lib/sddm/state.conf <<EOF
[Last]
Session=omarchy.desktop
User=$OMARCHY_USER
EOF
rm -f /mnt/etc/systemd/system/getty@tty1.service.d/autologin.conf
arch-chroot /mnt chown sddm:sddm /var/lib/sddm /var/lib/sddm/state.conf >/dev/null 2>&1 || true
arch-chroot /mnt systemctl enable sddm.service >/dev/null 2>&1 || true
}
chroot_bash() {
HOME=/home/$OMARCHY_USER \
arch-chroot -u $OMARCHY_USER /mnt/ \
env OMARCHY_CHROOT_INSTALL=1 \
OMARCHY_USER_NAME="$(<user_full_name.txt)" \
OMARCHY_USER_EMAIL="$(<user_email_address.txt)" \
OMARCHY_MIRROR="$OMARCHY_MIRROR" \
USER="$OMARCHY_USER" \
HOME="/home/$OMARCHY_USER" \
/bin/bash "$@"
}
if [[ $(tty) == "/dev/tty1" ]]; then
use_omarchy_helpers
run_configurator
install_arch
install_omarchy
fi
+74 -605
View File
@@ -1,48 +1,10 @@
#!/bin/bash #!/bin/bash
# On the live ISO, .automated_script.sh exports OMARCHY_PATH and the package # Rely on Omarchy for styling and error handling
# targets before running us. The defaults keep bin/omarchy-iso-configurator source "$OMARCHY_INSTALL/helpers/all.sh"
# dry runs working outside the ISO.
OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
OMARCHY_RUNTIME_PACKAGE="${OMARCHY_RUNTIME_PACKAGE:-omarchy}"
OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}"
LOGO_PATH="$OMARCHY_PATH/logo.txt" # Ensure tzupdate is available for guessing timezone
command -v tzupdate >/dev/null || sudo pacman -Sy --noconfirm tzupdate
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH")
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
export GUM_CONFIRM_SELECTED_FOREGROUND="0"
export GUM_CONFIRM_SELECTED_BACKGROUND="2"
export GUM_CONFIRM_UNSELECTED_FOREGROUND="7"
export GUM_CONFIRM_UNSELECTED_BACKGROUND="0"
# The console can grow after boot (the framebuffer settles late, especially in
# VMs), so remeasure at every redraw rather than trusting startup geometry.
# gum draws on /dev/tty, so measure that rather than inherited COLUMNS.
measure_terminal() {
TERM_WIDTH=$(stty size 2>/dev/null </dev/tty | awk '{print $2}')
(( TERM_WIDTH > 0 )) || TERM_WIDTH=${COLUMNS:-80}
PADDING_LEFT=$(((TERM_WIDTH - LOGO_WIDTH) / 2))
(( PADDING_LEFT < 0 )) && PADDING_LEFT=0
PADDING_LEFT_SPACES=$(printf "%*s" "$PADDING_LEFT" "")
PADDING="0 0 0 $PADDING_LEFT"
export GUM_CHOOSE_PADDING="$PADDING"
export GUM_FILTER_PADDING="$PADDING"
export GUM_INPUT_PADDING="$PADDING"
export GUM_SPIN_PADDING="$PADDING"
export GUM_TABLE_PADDING="$PADDING"
export GUM_CONFIRM_PADDING="$PADDING"
}
measure_terminal
clear_logo() {
measure_terminal
printf "\033[H\033[2J"
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
}
abort() { abort() {
gum style "${1:-Aborted installation}" gum style "${1:-Aborted installation}"
@@ -54,15 +16,10 @@ abort() {
step() { step() {
clear_logo clear_logo
echo echo
gum style --padding "0 0 0 $PADDING_LEFT" "$1" gum style "$1"
echo echo
} }
# Padded body text to line up with the logo and gum widgets.
say() {
gum style --padding "0 0 0 $PADDING_LEFT" "$@"
}
notice() { notice() {
clear_logo clear_logo
echo echo
@@ -145,11 +102,7 @@ user_form() {
username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort
if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then
if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then break
notice "Username is reserved for system" 1
else
break
fi
else else
notice "Username must be alphanumeric with no spaces" 1 notice "Username must be alphanumeric with no spaces" 1
fi fi
@@ -281,447 +234,6 @@ get_root_disk() {
fi fi
} }
# Compute partition device path, handling NVMe/mmcblk's pN naming.
partition_path() {
local _disk="$1" _num="$2"
if [[ "$_disk" == *nvme* || "$_disk" == *mmcblk* ]]; then
echo "${_disk}p${_num}"
else
echo "${_disk}${_num}"
fi
}
# BitLocker writes the "-FVE-FS-" signature at offset 3 of the partition.
detect_bitlocker() {
local p sig
for p in $(lsblk -lnpo NAME,TYPE "$disk" | awk '$2=="part"{print $1}'); do
sig=$(dd if="$p" bs=1 skip=3 count=8 status=none 2>/dev/null)
if [[ "$sig" == "-FVE-FS-" ]]; then
echo "$p"
return 0
fi
done
return 1
}
# Look for an existing Windows ESP (FAT partition containing EFI/Microsoft) on $disk.
detect_windows_esp() {
local p tmp_mp
for p in $(blkid -t TYPE=vfat -o device 2>/dev/null); do
[[ "$p" == "$disk"* ]] || continue
tmp_mp=$(mktemp -d)
if mount -o ro "$p" "$tmp_mp" 2>/dev/null; then
if [[ -d "$tmp_mp/EFI/Microsoft" ]]; then
umount "$tmp_mp"; rmdir "$tmp_mp"
echo "$p"
return 0
fi
umount "$tmp_mp"
fi
rmdir "$tmp_mp" 2>/dev/null || true
done
return 1
}
to_gb() {
awk -v bytes="$1" 'BEGIN {printf "%.1fGB", bytes/1024/1024/1024}'
}
# T2 Macs need their own kernel for keyboard/wifi drivers.
detect_kernel() {
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
echo "linux-t2"
else
echo "linux"
fi
}
# Give udev a moment to create a device node after partitioning.
wait_for_device() {
local i
for i in 1 2 3 4 5; do
[[ -b "$1" ]] && return 0
sleep 1
done
}
# Save the configurator answers the installer consumes: git identity and
# encryption choice as plain files, account details as archinstall credentials.
write_user_files() {
echo "$full_name" >user_full_name.txt
echo "$email_address" >user_email_address.txt
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
local password_escaped password_hash_escaped username_escaped
password_escaped=$(echo -n "$password" | jq -Rsa)
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
username_escaped=$(echo -n "$username" | jq -Rsa)
local credentials_encryption_line=""
if [[ $encrypt_installation == true ]]; then
credentials_encryption_line=" \"encryption_password\": $password_escaped,"
fi
cat <<-_EOF_ >user_credentials.json
{
$credentials_encryption_line
"root_enc_password": $password_hash_escaped,
"users": [
{
"enc_password": $password_hash_escaped,
"groups": [],
"sudo": true,
"username": $username_escaped
}
]
}
_EOF_
}
# Show what a dry run would have written, then leave no trace behind.
print_dry_run_files() {
echo -e "User Configuration:"
cat user_configuration.json
echo -e "\n\nUser Credentials:"
cat user_credentials.json
echo -e "\n\nUser Full Name:"
cat user_full_name.txt
echo -e "\nUser Email Address:"
cat user_email_address.txt
echo -e "\nDisk Encryption:"
cat user_encrypt_installation.txt
rm -f user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt
}
# Shared error screen for every "can't fit Omarchy here" outcome of the
# partition install. Offers the partition tool as the meaningful second
# action so users have a way to actually fix the situation.
not_enough_space() {
local available="${1:-0}"
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 "Back" --negative "Open partition tool" "Return to installation mode?"; then
open_partition_tool
fi
return 1
}
run_partition_install() {
local dry=false
[[ "${1:-}" == "dry" ]] && dry=true
step "Checking for BitLocker on $disk"
local bl_part
bl_part=$(detect_bitlocker || true)
if [[ -n "$bl_part" ]]; then
say --foreground 1 "BitLocker signature detected on $bl_part."
say "Suspend or disable BitLocker in Windows before installing alongside it."
abort "Aborted: BitLocker is active on this disk."
fi
# Omarchy always creates its own dedicated ESP in free space and never
# adopts an existing (Windows) ESP. Sharing the Windows ESP is fragile:
# Windows Update/repair can reclaim or reformat it, the typical 100-260MiB
# 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 "Checking existing EFI partitions on $disk"
local detected_win_esp
detected_win_esp=$(detect_windows_esp || true)
if [[ -n "$detected_win_esp" ]]; then
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 "A new EFI partition will be created in free space."
fi
step "Analyzing free space on $disk"
partprobe "$disk" 2>/dev/null || true
sleep 1
# An unlabeled disk has no partition table for parted to scan, so we
# synthesize a whole-disk free region and mklabel gpt before mkpart.
local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
if [[ -z "$pt_type" ]]; then
disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null)
if [[ -n "$disk_size_b" && "$disk_size_b" -gt 0 ]]; then
local _mib=$((1024 * 1024))
local _start=$_mib
local _end=$(( (disk_size_b - _mib) / _mib * _mib ))
FREE_SPACE_INFO="$_start $_end $((_end - _start))"
needs_mklabel=true
fi
else
# -s (script mode) keeps parted from prompting on stderr for warnings
# like a stale GPT backup; without it the configurator hangs invisibly.
FREE_SPACE_INFO=$(parted -ms "$disk" unit B print free </dev/null 2>/dev/null | \
awk -F: '
$NF ~ /free/ {
start=$2; end=$3; size=$4;
gsub(/B/, "", start);
gsub(/B/, "", end);
gsub(/B/, "", size);
if (size+0 > max_size+0) {
max_size=size; max_start=start; max_end=end;
}
}
END {
if (max_size+0 > 0) printf "%s %s %s\n", max_start, max_end, max_size;
}')
fi
if [[ -z "$FREE_SPACE_INFO" ]]; then
not_enough_space 0
return $?
fi
local FREE_START_B FREE_END_B FREE_SIZE_B
read -r FREE_START_B FREE_END_B FREE_SIZE_B <<<"$FREE_SPACE_INFO"
local mib=$((1024 * 1024)) gib=$((1024 * 1024 * 1024))
local ALIGN=$mib
local EFI_SIZE_B=$((2 * gib))
local MIN_INSTALL_B=$((32 * gib)) # Minimum total Omarchy allocation
local EFI_START_B EFI_END_B ROOT_START_B ROOT_END_B
EFI_START_B=$(( (FREE_START_B + ALIGN - 1) / ALIGN * ALIGN ))
EFI_END_B=$((EFI_START_B + EFI_SIZE_B))
ROOT_START_B=$(( (EFI_END_B + 1 + ALIGN - 1) / ALIGN * ALIGN ))
ROOT_END_B=$((FREE_END_B / ALIGN * ALIGN))
if (( ROOT_END_B <= ROOT_START_B )); then
not_enough_space "$FREE_SIZE_B"
return $?
fi
local INSTALL_MAX_B=$((ROOT_END_B - EFI_START_B))
if (( INSTALL_MAX_B < MIN_INSTALL_B )); then
not_enough_space "$INSTALL_MAX_B"
return $?
fi
# Default to encrypted and hide the unencrypted path behind Ctrl+C,
# matching the full-disk flow. The dedicated Omarchy ESP always supports
# LUKS, so encryption is always on the table here.
local mode="encrypted" affirmative confirm_status
while true; do
clear_logo
echo
say "Install Omarchy in the $(to_gb $INSTALL_MAX_B) of free space."
case $mode in
encrypted)
say --foreground 8 "Press Ctrl+C for unencrypted install."
affirmative="Yes, install"
;;
unencrypted)
affirmative="Yes, install without encryption"
;;
esac
echo
gum confirm --affirmative "$affirmative" --negative "No, change it" \
"Confirm installing on $disk"
confirm_status=$?
case $confirm_status in
0)
[[ $mode == "encrypted" ]] && encrypt_installation=true || encrypt_installation=false
break
;;
1)
return 1
;;
130)
case $mode in
encrypted) mode="unencrypted" ;;
unencrypted) mode="encrypted" ;;
esac
;;
*)
abort
;;
esac
done
local esp_mount_in_target
if [[ "$encrypt_installation" == "true" ]]; then
esp_mount_in_target=/boot
else
esp_mount_in_target=/efi
fi
kernel_choice=$(detect_kernel)
local last_part efi_part_num root_part_num
last_part=$(lsblk -n -o PARTN "$disk" 2>/dev/null | grep -E '^[0-9]+$' | sort -n | tail -1)
efi_part_num=$(( ${last_part:-0} + 1 ))
root_part_num=$(( efi_part_num + 1 ))
local efi_dev root_partition_device root_mapper
efi_dev=$(partition_path "$disk" "$efi_part_num")
root_partition_device=$(partition_path "$disk" "$root_part_num")
if [[ "$encrypt_installation" == "true" ]]; then
root_mapper="/dev/mapper/omarchy_root"
else
root_mapper="$root_partition_device"
fi
if $dry; then
$needs_mklabel && say "[dry] would initialize GPT on $disk"
say "[dry] would create root partition at $root_partition_device"
say "[dry] would create EFI partition at $efi_dev"
say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice"
else
step "Creating partitions on $disk"
if $needs_mklabel; then
parted --script "$disk" mklabel gpt
partprobe "$disk" 2>/dev/null || true
sleep 1
fi
parted --script "$disk" mkpart primary fat32 "${EFI_START_B}B" "${EFI_END_B}B"
parted --script "$disk" set "$efi_part_num" esp on
parted --script "$disk" name "$efi_part_num" "OMARCHY_EFI"
parted --script "$disk" mkpart primary btrfs "${ROOT_START_B}B" "${ROOT_END_B}B"
parted --script "$disk" name "$root_part_num" "OMARCHY_ROOT"
partprobe "$disk"
sync
sleep 2
wait_for_device "$efi_dev"
wait_for_device "$root_partition_device"
if [[ "$encrypt_installation" == "true" ]]; then
step "Setting up LUKS2 on $root_partition_device"
printf "%s" "$password" | cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" -
printf "%s" "$password" | cryptsetup open "$root_partition_device" omarchy_root -
root_mapper="/dev/mapper/omarchy_root"
else
wipefs -af "$root_partition_device"
root_mapper="$root_partition_device"
fi
step "Creating Btrfs filesystem and subvolumes"
mkfs.btrfs -f -L OMARCHY "$root_mapper"
wait_for_device "$root_mapper"
mkdir -p /mnt/btrfs-root
mount "$root_mapper" /mnt/btrfs-root
btrfs subvolume create /mnt/btrfs-root/@
btrfs subvolume create /mnt/btrfs-root/@home
btrfs subvolume create /mnt/btrfs-root/@log
btrfs subvolume create /mnt/btrfs-root/@pkg
umount /mnt/btrfs-root
rmdir /mnt/btrfs-root
mount -o noatime,compress=zstd,subvol=@ "$root_mapper" /mnt
mkdir -p /mnt/home /mnt/var/log /mnt/var/cache/pacman/pkg /mnt"$esp_mount_in_target"
mount -o noatime,compress=zstd,subvol=@home "$root_mapper" /mnt/home
mount -o noatime,compress=zstd,subvol=@log "$root_mapper" /mnt/var/log
mount -o noatime,compress=zstd,subvol=@pkg "$root_mapper" /mnt/var/cache/pacman/pkg
mkfs.fat -F32 -n OMARCHY_EFI "$efi_dev"
mount "$efi_dev" /mnt"$esp_mount_in_target"
fi
clear
write_user_files
local luks_uuid_json="null" luks_uuid
if [[ "$encrypt_installation" == "true" ]]; then
if $dry; then
luks_uuid="00000000-0000-0000-0000-000000000000"
else
luks_uuid=$(blkid -s UUID -o value "$root_partition_device")
fi
luks_uuid_json="\"$luks_uuid\""
fi
cat <<-_EOF_ >user_configuration.json
{
"app_config": null,
"archinstall-language": "English",
"auth_config": {},
"audio_config": { "audio": "pipewire" },
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false },
"custom_commands": [],
"omarchy_install": {
"mode": "protected",
"target_mount": "/mnt",
"boot": {
"esp_mount": "$esp_mount_in_target",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": false
},
"storage": {
"esp_device": "$efi_dev",
"root_device": "$root_partition_device",
"root_mapper": "$root_mapper",
"luks_uuid": $luks_uuid_json,
"kernel": "$kernel_choice"
}
},
"disk_config": {
"config_type": "pre_mounted_config",
"mountpoint": "/mnt"
},
"hostname": "$hostname",
"kernels": [ "$kernel_choice" ],
"network_config": { "type": "iso" },
"ntp": true,
"parallel_downloads": 8,
"script": null,
"services": [],
"swap": true,
"timezone": "$timezone",
"locale_config": {
"kb_layout": "$keyboard",
"sys_enc": "UTF-8",
"sys_lang": "en_US.UTF-8"
},
"mirror_config": {
"custom_repositories": [],
"custom_servers": [
{"url": "https://mirror.omarchy.org/\$repo/os/\$arch"},
{"url": "https://mirror.rackspace.com/archlinux/\$repo/os/\$arch"},
{"url": "https://geo.mirror.pkgbuild.com/\$repo/os/\$arch"}
],
"mirror_regions": {},
"optional_repositories": []
},
"packages": [
"base-devel",
"git",
"omarchy-keyring",
"$OMARCHY_SETTINGS_PACKAGE",
"$OMARCHY_RUNTIME_PACKAGE"
],
"profile_config": {
"gfx_driver": null,
"greeter": null,
"profile": {}
},
"version": "3.0.9"
}
_EOF_
if $dry; then
print_dry_run_files
fi
}
disk_form() { disk_form() {
step "Let's select where to install Omarchy..." step "Let's select where to install Omarchy..."
@@ -753,60 +265,7 @@ disk_form() {
disk=$(echo "$selected_display" | awk '{print $1}') disk=$(echo "$selected_display" | awk '{print $1}')
} }
# STEP 4: INSTALL MODE disk_form
open_partition_tool() {
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 "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0
clear
cfdisk "$disk" || true
partprobe "$disk" 2>/dev/null || true
sync
sleep 1
}
# If the disk has no partition table or no unallocated free space, the only
# viable path is a full-disk install. Skip the install-mode picker and go
# straight to confirmation.
requires_full_disk_install() {
local pt_type free_space_b
pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null)
# a) No partition table: disk is empty
[[ -z "$pt_type" ]] && return 0
# b) No usable free space
free_space_b=$(parted -ms "$disk" unit B print free </dev/null 2>/dev/null | \
awk -F: '
$NF ~ /free/ {
size=$4;
gsub(/B/, "", size);
if (size+0 > max+0) max=size;
}
END {
print max+0;
}')
[[ -z "$free_space_b" || "$free_space_b" -eq 0 ]]
}
install_mode_form() {
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 ]]; then
choices+=("Free space install (alongside existing data)")
fi
choices+=("Choose a different disk")
step "Let's select how to install Omarchy..."
install_mode=$(gum choose --header "Select installation mode on $disk" "${choices[@]}") || abort
}
confirm_disk_overwrite() { confirm_disk_overwrite() {
local mode="encrypted" local mode="encrypted"
@@ -815,10 +274,10 @@ confirm_disk_overwrite() {
while true; do while true; do
clear_logo clear_logo
echo echo
say "Everything will be overwritten. There is no recovery possible." gum style "Everything will be overwritten. There is no recovery possible."
if [[ $mode == "encrypted" ]]; then if [[ $mode == "encrypted" ]]; then
say --foreground 8 "Press Ctrl+C for unencrypted install." gum style --foreground 8 "Press Ctrl+C for unencrypted install."
affirmative="Yes, install" affirmative="Yes, install"
else else
affirmative="Yes, install without encryption" affirmative="Yes, install without encryption"
@@ -846,45 +305,44 @@ confirm_disk_overwrite() {
done done
} }
select_installation() { while ! confirm_disk_overwrite; do
local full_disk_only disk_form
done
while true; do
if requires_full_disk_install; then
full_disk_only=true
install_mode="Full disk install"
else
full_disk_only=false
install_mode_form
fi
case "$install_mode" in
"Full disk install")
if confirm_disk_overwrite; then
return 0
fi
if $full_disk_only; then
disk_form
fi
;;
"Free space install (alongside existing data)")
if run_partition_install "$@"; then
exit 0
fi
;;
"Choose a different disk")
disk_form
;;
esac
done
}
disk_form
select_installation "$@"
clear clear
write_user_files # Save user full name and email address, so the autostart can
# set them as OMARCHY_USER_NAME and OMARCHY_USER_EMAIL
echo "$full_name" >user_full_name.txt
echo "$email_address" >user_email_address.txt
# Escape username and password for safe inclusion
password_escaped=$(echo -n "$password" | jq -Rsa)
password_hash_escaped=$(echo -n "$password_hash" | jq -Rsa)
username_escaped=$(echo -n "$username" | jq -Rsa)
if [[ $encrypt_installation == true ]]; then
credentials_encryption_line=" \"encryption_password\": $password_escaped,"
else
credentials_encryption_line=""
fi
cat <<-_EOF_ >user_credentials.json
{
$credentials_encryption_line
"root_enc_password": $password_hash_escaped,
"users": [
{
"enc_password": $password_hash_escaped,
"groups": [],
"sudo": true,
"username": $username_escaped
}
]
}
_EOF_
printf '%s\n' "$encrypt_installation" >user_encrypt_installation.txt
# Setup partition layout # Setup partition layout
disk_size=$(lsblk -bdno SIZE "$disk") disk_size=$(lsblk -bdno SIZE "$disk")
@@ -899,7 +357,12 @@ boot_partition_size=$((2 * gib))
main_partition_start=$((boot_partition_size + boot_partition_start)) main_partition_start=$((boot_partition_size + boot_partition_start))
main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_reserve)) main_partition_size=$((disk_size_in_mib - main_partition_start - gpt_backup_reserve))
kernel_choice=$(detect_kernel) # Detect T2 Mac and set appropriate kernel
if lspci -nn 2>/dev/null | grep -q "106b:180[12]"; then
kernel_choice="linux-t2"
else
kernel_choice="linux"
fi
if [[ $encrypt_installation == true ]]; then if [[ $encrypt_installation == true ]]; then
disk_encryption_config=$(cat <<-_EOF_ disk_encryption_config=$(cat <<-_EOF_
@@ -909,7 +372,7 @@ if [[ $encrypt_installation == true ]]; then
"lvm_volumes": [], "lvm_volumes": [],
"iter_time": 2000, "iter_time": 2000,
"partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ], "partitions": [ "8c2c2b92-1070-455d-b76a-56263bab24aa" ],
"encryption_password": $(echo -n "$password" | jq -Rsa) "encryption_password": $password_escaped
} }
_EOF_ _EOF_
) )
@@ -923,22 +386,14 @@ cat <<-_EOF_ >user_configuration.json
"archinstall-language": "English", "archinstall-language": "English",
"auth_config": {}, "auth_config": {},
"audio_config": { "audio": "pipewire" }, "audio_config": { "audio": "pipewire" },
"bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false }, "bootloader": "Limine",
"custom_commands": [], "custom_commands": [],
"omarchy_install": {
"mode": "full_disk",
"target_mount": "/mnt",
"boot": {
"esp_mount": "/boot",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": true
},
"storage": {
"kernel": "$kernel_choice"
}
},
"disk_config": { "disk_config": {
"btrfs_options": {
"snapshot_config": {
"type": "Snapper"
}
},
"config_type": "default_layout", "config_type": "default_layout",
"device_modifications": [ "device_modifications": [
{ {
@@ -1024,8 +479,7 @@ cat <<-_EOF_ >user_configuration.json
"base-devel", "base-devel",
"git", "git",
"omarchy-keyring", "omarchy-keyring",
"$OMARCHY_SETTINGS_PACKAGE", "snapper"
"$OMARCHY_RUNTIME_PACKAGE"
], ],
"profile_config": { "profile_config": {
"gfx_driver": null, "gfx_driver": null,
@@ -1037,5 +491,20 @@ cat <<-_EOF_ >user_configuration.json
_EOF_ _EOF_
if [[ $1 == "dry" ]]; then if [[ $1 == "dry" ]]; then
print_dry_run_files echo -e "User Configuration:"
cat user_configuration.json
echo -e "\n\nUser Credentials:"
cat user_credentials.json
echo -e "\n\nUser Full Name:"
cat user_full_name.txt
echo -e "\nUser Email Address:"
cat user_email_address.txt
echo -e "\nDisk Encryption:"
cat user_encrypt_installation.txt
rm user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt
fi fi
@@ -1,57 +0,0 @@
#!/bin/bash
#
# Autoinstall: a drive labeled cidata (the cloud-init NoCloud label, so Proxmox,
# libvirt and Packer all know how to attach one) carrying the configurator's own
# output files stands in for the wizard. Copy those files into /root and
# everything downstream runs the ordinary path against ordinary inputs.
#
# Exits 0 when an autoinstall drive was loaded and the configurator can be
# skipped. The required pair is the same one the orchestrator treats as
# mandatory; the rest are optional there too, so don't be stricter here.
# Anything less than the pair means this isn't an autoinstall drive, and the
# caller falls back to the wizard.
#
# The optional argument prefixes every path the script touches; tests use it to
# run against a sandbox instead of the live system.
set -uo pipefail
root=${1:-}
by_label=$root/dev/disk/by-label
dest=$root/root
mnt=$root/run/cidata
# The by-label symlinks come from udev, and on a fast boot this can run before
# device enumeration has finished. Wait for the queue to drain instead of
# racing it and silently dropping a headless install into the wizard.
udevadm settle 2>/dev/null || true
device=""
for label in cidata CIDATA; do
[[ -e $by_label/$label ]] && { device=$by_label/$label; break; }
done
[[ -n $device ]] || exit 1
mkdir -p "$mnt"
mount -o ro "$device" "$mnt" || exit 1
loaded=1
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
done
if cp "${files[@]}" "$dest"/; then
loaded=0
else
# A partial copy must not leave files behind: the wizard cleans up only
# what it writes itself, so a half-copied authorized_keys would leak into
# the interactive install that follows.
for file in "${files[@]}"; do
rm -f "$dest/${file##*/}"
done
fi
fi
umount "$mnt"
exit $loaded
@@ -1,757 +0,0 @@
#!/usr/bin/env bash
# Foreground Omarchy ISO install UI supervisor.
#
# This process is the only owner of the visible install UI. It launches the
# actual installer as a non-interactive child, sends child output to the support
# log, renders progress while the child runs, then renders the final installed
# time + reboot prompt itself. There is deliberately no background dashboard.
set -euo pipefail
usage() {
echo "Usage: $0 <log-file> <state-file> -- <installer command...>" >&2
exit 2
}
(( $# >= 4 )) || usage
LOG_FILE="$1"
STATE_FILE="$2"
shift 2
[[ ${1:-} == "--" ]] || usage
shift
(( $# > 0 )) || usage
INSTALL_CMD=("$@")
TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}"
LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt"
# Autoinstall has no one at the keyboard, and every prompt this dashboard owns
# reads from the TTY, so each would hang forever. Suppress them here rather than
# in the scripts they live in: this process is the only owner of the visible UI.
interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; }
CSI=$'\033['
RESET="${CSI}0m"
DIM="${CSI}2m"
HIDE_CURSOR="${CSI}?25l"
SHOW_CURSOR="${CSI}?25h"
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.
export GUM_CONFIRM_PROMPT_FOREGROUND="${GUM_CONFIRM_PROMPT_FOREGROUND:-6}"
export GUM_CONFIRM_SELECTED_FOREGROUND="${GUM_CONFIRM_SELECTED_FOREGROUND:-0}"
export GUM_CONFIRM_SELECTED_BACKGROUND="${GUM_CONFIRM_SELECTED_BACKGROUND:-2}"
export GUM_CONFIRM_UNSELECTED_FOREGROUND="${GUM_CONFIRM_UNSELECTED_FOREGROUND:-7}"
export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0}"
child_pid=""
child_pgid=""
tips=(
"Super + Space opens the Omarchy menu for apps, settings, and more"
"Super + K shows all the key bindings"
"Super is the Windows or command key on your keyboard"
"Use Xournal++ to sign PDFs"
"Share files with phones and laptops using LocalSend"
"Turn any website into an app with Install > Web App in the menu"
"Super + Return opens a terminal, Super + Shift + Return the browser"
"Edit images with Pinta, videos with Kdenlive, docs with LibreOffice"
"Super + Ctrl + Print grabs text off the screen with OCR"
"Print takes a screenshot, Alt + Print records the screen"
"Set a reminder with Super + Ctrl + R"
"Switch themes from Style > Theme in the Omarchy menu"
"Double-click the menu bar to make it transparent"
"Run a full Windows VM via Install > Windows in the menu"
"Super + Ctrl + V opens the clipboard manager"
"Super + 1 through 0 switches workspaces, add Shift to bring the window"
"Super + Print picks a color from anywhere on screen"
"Keep the system fresh with Update in the Omarchy menu"
)
cleanup() {
printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true
}
dashboard_log() {
printf '[dashboard] %s\n' "$*" >>"$LOG_FILE" 2>/dev/null || true
}
stop_child() {
[[ -n ${child_pid:-} ]] || return 0
if kill -0 "$child_pid" 2>/dev/null; then
if [[ -n ${child_pgid:-} ]]; then
kill -- -"$child_pgid" 2>/dev/null || true
fi
kill "$child_pid" 2>/dev/null || true
for _ in {1..20}; do
kill -0 "$child_pid" 2>/dev/null || break
sleep 0.05
done
if kill -0 "$child_pid" 2>/dev/null; then
[[ -n ${child_pgid:-} ]] && kill -9 -- -"$child_pgid" 2>/dev/null || true
kill -9 "$child_pid" 2>/dev/null || true
fi
fi
wait "$child_pid" 2>/dev/null || true
}
interrupt() {
local code="$1"
stop_child
cleanup
exit "$code"
}
trap cleanup EXIT
trap 'interrupt 130' INT
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#* }"; }
repeat() {
local char="$1" count="$2" out="" i
for ((i=0; i<count; i++)); do out+="$char"; done
printf '%s' "$out"
}
logo_width() {
if [[ -f $LOGO_PATH ]]; then
awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH"
else
printf '81\n'
fi
}
logo_height() {
if [[ -f $LOGO_PATH ]]; then
wc -l <"$LOGO_PATH"
else
printf '1\n'
fi
}
LOGO_WIDTH="$(logo_width)"
LOGO_HEIGHT="$(logo_height)"
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH"
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_PM=10
# ── Install progress ─────────────────────────────────────────────────────────
#
# 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.
# tau is a shape constant, not a duration prediction.
# work packages installed / PKG_TOTAL, for the package phase only (~85% of
# the install). libalpm makes one directory per package under
# <target>/var/lib/pacman/local; the denominator comes from
# build-iso.sh, so there is no second package list to drift.
#
# Per-mille because a whole-percent asymptote over a 2-point band has two
# reachable values and stops moving. No give-up predicate: two discontinuous
# curves behind a monotonic clamp freeze. With no signal the floor alone still
# finishes the band.
EXPECTED_PACKAGES_FILE="${OMARCHY_EXPECTED_PACKAGES_FILE:-/usr/share/omarchy-iso/expected-packages}"
PKG_TOTAL=0
if [[ -r $EXPECTED_PACKAGES_FILE ]]; then
read -r PKG_TOTAL _ <"$EXPECTED_PACKAGES_FILE" 2>/dev/null || PKG_TOTAL=0
fi
[[ $PKG_TOTAL =~ ^[0-9]+$ ]] || PKG_TOTAL=0
# 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 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
POS=10 # position, per-mille, monotonic
PROGRESS_PM=10
NOW=0 # monotonic seconds, refreshed once per frame
DASH_T0=0
PHASE_NAME="" # last phase this process observed
PHASE_T0=0 # NOW when it was first observed
PHASE_LO=10 # POS on entry, for an unknown phase with no index
PKG_DB_DIR=""
PKG_COUNT=0
PKG_BASE=-1 # package count when the phase started
PKG_SEEN=0 # running max of packages since PKG_BASE
PKG_LAST_CHANGE=0
PKG_BONUS=0
# Monotonic clock: tzupdate and systemd-timesyncd can step the wall clock
# mid-install, which would jump every time-driven band to its ceiling.
set_now() {
local up rest
if read -r up rest </proc/uptime 2>/dev/null && [[ $up == [0-9]* ]]; then
NOW=${up%%.*}
else
NOW=$EPOCHSECONDS
fi
return 0
}
left_padding() {
local width="${1:-$CONTENT_WIDTH}" cols pad
cols="$(term_cols)"
pad=$(( (cols - width) / 2 ))
(( pad < 0 )) && pad=0
printf '%*s' "$pad" ''
}
visible_len() {
local text="$1"
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"; }
line_at() {
local width="${1:-$CONTENT_WIDTH}" indent="${2:-0}"
shift 2
printf '\r%s%s%*s' "$CLEAR_LINE" "$(left_padding "$width")" "$indent" ''
printf '%b' "$*"
}
center() {
local text="$1" width="${2:-$CONTENT_WIDTH}" len inner_pad
len="$(visible_len "$text")"
inner_pad=$(( (width - len) / 2 ))
(( inner_pad < 0 )) && inner_pad=0
printf '\r%s%s%*s%b%s\n' "$CLEAR_LINE" "$(left_padding "$width")" "$inner_pad" '' "$text" "$RESET"
}
render_logo() {
local pad
pad="$(left_padding "$LOGO_WIDTH")"
if [[ -f $LOGO_PATH ]]; then
while IFS= read -r line; do
printf '\r%s%s%b%s%b\n' "$CLEAR_LINE" "$pad" "$GREEN" "$line" "$RESET"
done <"$LOGO_PATH"
else
center "${GREEN}Omarchy${RESET}" "$CONTENT_WIDTH"
fi
}
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
# package and nothing else in it is a directory, so */ is exact. One glob, no
# forks: this runs twice a second while the disk is saturated.
count_packages() {
local -a entries
PKG_COUNT=0
[[ -n $PKG_DB_DIR ]] || return 0
entries=("$PKG_DB_DIR"/*/)
# Unmatched globs expand to the pattern; nullglob stays off script-wide.
if (( ${#entries[@]} == 1 )) && [[ ! -d ${entries[0]} ]]; then
return 0
fi
PKG_COUNT=${#entries[@]}
return 0
}
# 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 bar.
install_progress() {
local phase finished index total target
local lo hi tau span t pos work gap n pkg_signal=0
local -a state
PROGRESS_PM=$POS
[[ -f $STATE_FILE ]] || return 0
mapfile -t state < <(jq -r '
(.current_phase // "Starting installation"),
((.finished_at // 0) | tonumber? // 0 | floor),
((.current_index // -1) | tonumber? // -1 | floor),
((.total_phases // 0) | tonumber? // 0 | floor),
(.target // "/mnt")
' "$STATE_FILE" 2>/dev/null)
(( ${#state[@]} == 5 )) || return 0
phase="${state[0]}" finished="${state[1]}"
index="${state[2]}" total="${state[3]}" target="${state[4]}"
[[ -n $target ]] || target="/mnt"
if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then
POS=1000
PROGRESS_PM=1000
return 0
fi
# Transitions are observed here rather than read from the state file, which
# keeps wall clock out of the dashboard entirely.
if [[ $phase != "$PHASE_NAME" ]]; then
PHASE_NAME="$phase"
PHASE_T0=$NOW
PHASE_LO=$POS
PKG_BASE=-1
PKG_SEEN=0
PKG_BONUS=0
PKG_LAST_CHANGE=$NOW
fi
# Bands in per-mille, plus tau. The tail bands are wide relative to what they
# cost on NVMe: run_system_finalizer can DKMS-build a driver and
# finalize_limine_boot rebuilds every UKI, so they vary most on real hardware.
# Over-allotting costs a step at a boundary; under-allotting costs a stall.
case "$phase" in
"Starting installation") lo=10 hi=25 tau=5 ;;
"Preparing live environment") lo=25 hi=50 tau=25 ;;
"Preparing install target") lo=50 hi=65 tau=20 ;;
"Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;;
"Configuring hibernation") lo=700 hi=720 tau=10 ;;
"Configuring system") lo=720 hi=850 tau=120 ;;
"Finalizing Limine boot") lo=850 hi=950 tau=90 ;;
"Finalizing user") lo=950 hi=978 tau=30 ;;
"Configuring login") lo=978 hi=988 tau=5 ;;
"Configuring DNS resolver") lo=988 hi=994 tau=5 ;;
"Validating boot setup") lo=994 hi=1000 tau=5 ;;
*)
# Unknown names used to inherit a 1->99 sweep. Derive an even band from
# the published index instead; with no index, a bounded slice, so a
# renamed phase degrades to one cell rather than a runaway.
if (( total >= 2 && index >= 0 && index < total )); then
lo=$(( 10 + 990 * index / total ))
hi=$(( 10 + 990 * (index + 1) / total ))
else
lo=$PHASE_LO
hi=$(( PHASE_LO + 30 ))
fi
tau=60
;;
esac
(( pkg_signal && PKG_TOTAL > 0 )) || pkg_signal=0
(( pkg_signal )) && tau=$PKG_FLOOR_TAU
t=$(( NOW - PHASE_T0 ))
(( t < 0 )) && t=0
span=$(( hi - lo ))
(( span < 2 )) && span=2
(( tau < 1 )) && tau=1
pos=$(( lo + (span - 1) * t / (t + tau) ))
if (( pkg_signal )); then
PKG_DB_DIR="$target/var/lib/pacman/local"
count_packages
# Baseline on first sight: a target that already holds packages would
# 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
# Running max: a reinstalled package briefly drops its version-stamped dir.
if (( n > PKG_SEEN )); then
PKG_SEEN=$n
PKG_LAST_CHANGE=$NOW
fi
gap=$(( NOW - PKG_LAST_CHANGE ))
(( gap < 0 )) && gap=0
(( gap > PKG_BONUS_MAX_PM )) && gap=$PKG_BONUS_MAX_PM
(( gap > PKG_BONUS )) && PKG_BONUS=$gap
work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS ))
(( work > pos )) && pos=$work
fi
(( pos > hi - 1 )) && pos=$(( hi - 1 ))
(( pos > POS )) && POS=$pos
(( POS > 1000 )) && POS=1000
PROGRESS_PM=$POS
return 0
}
# 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[@]} ))]}"
}
render_static() {
printf '%s' "$CLEAR"
blank_line
render_logo
blank_line
}
render_dynamic() {
local 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
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Installing Omarchy" "$CONTENT_WIDTH"
blank_line
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"
}
install_duration() {
local path line duration
if [[ -f $STATE_FILE ]]; then
duration="$(jq -r '
((.started_at // 0) | tonumber? // 0) as $started
| ((.finished_at // 0) | tonumber? // 0) as $finished
| if $started <= 0 or $finished <= 0 or $finished < $started then empty
else (($finished - $started) | round) as $secs
| if $secs >= 3600 then "\($secs / 3600 | floor)h \($secs % 3600 / 60 | floor)m \($secs % 60)s"
else "\($secs / 60 | floor)m \($secs % 60)s"
end
end
' "$STATE_FILE" 2>/dev/null || true)"
[[ -n $duration ]] && { printf '%s' "$duration"; return 0; }
fi
for path in /mnt/var/log/omarchy-install.log "$LOG_FILE"; do
[[ -f $path ]] || continue
for prefix in Total Omarchy; do
line="$(grep "^$prefix:" "$path" 2>/dev/null | tail -n 1 || true)"
[[ -n $line ]] || continue
duration="${line#*:}"
duration="${duration#${duration%%[![:space:]]*}}"
[[ -n $duration ]] && { printf '%s' "$duration"; return 0; }
done
done
return 0
}
render_finish() {
local duration effect_canvas_width
duration="$(install_duration || true)"
duration="${duration:-Complete}"
effect_canvas_width="$(term_cols)"
(( effect_canvas_width > 1 )) && effect_canvas_width=$((effect_canvas_width - 1))
{
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
blank_line
render_logo
blank_line
center "Installed Omarchy in ${duration}" "$CONTENT_WIDTH"
blank_line
} >"$TTY_PATH"
if command -v tte >/dev/null 2>&1 && command -v timeout >/dev/null 2>&1 && [[ -f $LOGO_PATH ]]; then
# TTE reuse mode restores a saved cursor before moving up over its input.
# Save this anchor so the effect redraws the same logo rows shown above.
printf '%s%d;1H\0337' "$CSI" "$((LOGO_HEIGHT + 2))" >"$TTY_PATH"
timeout 8s tte -i "$LOGO_PATH" \
--canvas-width "$effect_canvas_width" \
--anchor-text c \
--frame-rate 260 \
--reuse-canvas \
laseretch \
>"$TTY_PATH" 2>/dev/null || true
printf '%s%d;1H' "$CSI" "$((LOGO_HEIGHT + 8))" >"$TTY_PATH"
fi
}
reboot_prompt() {
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.
interactive || return 0
# Position the cursor one blank line below the title so the button sits right there.
printf '%s%d;1H%s' "$CSI" "$((LOGO_HEIGHT + 5))" "$CLEAR_LINE" >"$TTY_PATH"
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 "Reboot Now" \
--negative "" \
"" <"$TTY_PATH" >"$TTY_PATH" 2>&1
}
truncate_for_width() {
local text="$1" width="$2"
if (( ${#text} > width )); then
printf '%s…' "${text:0:$((width - 1))}"
else
printf '%s' "$text"
fi
}
render_failure_log_tail() {
local rows lines width line tail_log label
rows=$(term_size)
rows=${rows%% *}
lines=$((rows - LOGO_HEIGHT - 18))
(( lines < 6 )) && lines=6
(( lines > 14 )) && lines=14
width=$((CONTENT_WIDTH - 4))
(( width < 40 )) && width=40
tail_log="${OMARCHY_FAILURE_TAIL_LOG:-}"
if [[ -z $tail_log ]]; then
if [[ -f /mnt/var/log/omarchy-install.log ]]; then
tail_log=/mnt/var/log/omarchy-install.log
else
tail_log="$LOG_FILE"
fi
fi
[[ -f $tail_log ]] || return 0
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
line_at "$CONTENT_WIDTH" 2 "$(truncate_for_width "$line" "$width")"
printf '\n'
done
}
find_log_uploader() {
local candidate
for candidate in omarchy-upload-log /usr/local/bin/omarchy-upload-log /usr/bin/omarchy-upload-log /mnt/usr/bin/omarchy-upload-log; do
if [[ $candidate == */* ]]; then
[[ -x $candidate ]] && { printf '%s' "$candidate"; return 0; }
elif command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
return 1
}
prompt_enter() {
printf '\nPress Enter to continue…' >"$TTY_PATH"
IFS= read -r _ <"$TTY_PATH" || true
}
upload_failure_log() {
local uploader
printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
if uploader=$(find_log_uploader); then
"$uploader" install >"$TTY_PATH" 2>&1 || true
else
{
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
}
view_failure_log() {
printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
if command -v less >/dev/null 2>&1 && [[ -f $LOG_FILE ]]; then
less "$LOG_FILE" <"$TTY_PATH" >"$TTY_PATH" 2>&1 || true
else
tail -n 200 "$LOG_FILE" >"$TTY_PATH" 2>&1 || true
prompt_enter
fi
}
render_failure() {
local status="$1" state_summary="${2:-}" line
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
blank_line
render_logo
blank_line
center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH"
center "Installer exited with status $status" "$CONTENT_WIDTH"
if [[ -n $state_summary ]]; then
blank_line
while IFS= read -r line; do
[[ -n $line ]] || continue
center "${DIM}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH"
done <<<"$state_summary"
fi
blank_line
render_failure_log_tail
blank_line
center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH"
blank_line
}
failure_menu() {
local choice uploader
[[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0
# The failure screen and log tail have already rendered; exit with the
# installer's status instead of blocking on a menu nobody can answer.
interactive || return 0
while true; do
uploader=""
uploader=$(find_log_uploader || true)
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")
upload_failure_log
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
;;
"View full log")
view_failure_log
render_failure "${failure_status:-1}" "${failure_summary:-}" >"$TTY_PATH" 2>/dev/null || true
;;
"Reboot")
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
;;
"Power off")
poweroff 2>/dev/null || systemctl poweroff 2>/dev/null || true
;;
"Drop to shell"|"")
return 0
;;
esac
done
}
sanitize_child_output() {
# Archinstall is only a library subsystem in Omarchy's installer. Its own
# post-install success/reboot banner can be buffered until process exit and
# appear after an Omarchy failure, which makes the unified log contradictory.
# Keep that third-party banner out of Omarchy's support log; the raw
# archinstall log is still uploaded separately by omarchy-upload-log.
sed -u \
-e $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' \
-e '/^Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers$/d' \
-e '/^Enabling service systemd-timesyncd$/d' \
-e '/^Setting password for root$/d' \
-e '/^Updating \/mnt\/etc\/fstab$/d' \
-e '/^Syncing the system\.\.\.$/d' \
-e '/^Installation completed without any errors\.$/d' \
-e '/^Log files temporarily available at \/var\/log\/archinstall\.$/d' \
-e '/^You may reboot when ready\.$/d'
}
launch_child() {
mkdir -p "$(dirname "$LOG_FILE")"
# Child is non-interactive. All visible UI belongs to this supervisor.
PYTHONUNBUFFERED=1 setsid "${INSTALL_CMD[@]}" </dev/null > >(sanitize_child_output >>"$LOG_FILE") 2>&1 &
child_pid=$!
child_pgid=$child_pid
}
[[ -e $TTY_PATH ]] || exit 2
printf '%s' "$HIDE_CURSOR" >"$TTY_PATH"
render_static >"$TTY_PATH"
set_now
DASH_T0=$NOW
PHASE_T0=$NOW
PKG_LAST_CHANGE=$NOW
launch_child
while jobs -pr | grep -qx "$child_pid"; do
render_dynamic >"$TTY_PATH" 2>/dev/null || true
sleep 0.5
done
set +e
wait "$child_pid"
status=$?
set -e
child_pid=""
child_pgid=""
dashboard_log "installer child exited with status $status"
if (( status == 0 )); then
# 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
if (( status != 0 )); then
failure_status="$status"
failure_summary=""
if [[ -f $STATE_FILE ]]; then
failure_summary="$(jq -r '
(.current_phase // empty | "last installer phase: \(.)"),
((.phases // []) | map(select(.status == "failed")) | last // empty
| "failed phase: \(.name): \(.error)")
' "$STATE_FILE" 2>/dev/null || true)"
if [[ -n $failure_summary ]]; then
while IFS= read -r line; do
[[ -n $line ]] && dashboard_log "$line"
done <<<"$failure_summary"
fi
fi
render_failure "$failure_status" "$failure_summary" >"$TTY_PATH" 2>/dev/null || true
failure_menu
exit "$failure_status"
fi
set +e
render_finish
finish_render_status=$?
set -e
if (( finish_render_status != 0 )); then
dashboard_log "finish screen render failed with status $finish_render_status; using fallback"
{
printf '%s%s' "$SHOW_CURSOR" "$CLEAR"
printf '\nFinished installing\n\n'
} >"$TTY_PATH" 2>/dev/null || true
fi
if reboot_prompt; then
if [[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]]; then
exit 0
fi
printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
reboot 2>/dev/null || systemctl reboot 2>/dev/null || true
fi
exit 0
@@ -1,47 +0,0 @@
#!/bin/bash
#
# Clean up any holders / mounts / LVM / crypt devices on the install disk
# before archinstall starts wiping. Idempotent — called once per install attempt
# from the orchestrator's cleanup_disk phase.
#
# Ported verbatim from .automated_script.sh's cleanup_install_disk(). Kept in
# bash because lsblk / findmnt / lvm / cryptsetup are all easier to drive
# here than from Python.
set -uo pipefail
if [[ ${1:-} == --protected ]]; then
target="${2:-}"
[[ -n $target ]] || { echo "Usage: $0 --protected <target>" >&2; exit 1; }
findmnt -R "$target" >/dev/null 2>&1 && umount -R "$target" || true
exit 0
fi
disk="${1:-}"
[[ -n $disk && -b $disk ]] || { echo "Need a block device argument; got '$disk'" >&2; exit 1; }
echo "Cleaning up existing holders on install disk: $disk"
findmnt -R /mnt >/dev/null && umount -R /mnt || true
while read -r dev; do
[[ -b $dev ]] || continue
swapoff "$dev" 2>/dev/null || true
while read -r target; do
[[ -n $target ]] && umount "$target" 2>/dev/null || true
done < <(findmnt -rn -S "$dev" -o TARGET 2>/dev/null || true)
done < <(lsblk -rnpo PATH "$disk")
while read -r dev type; do
[[ $type == disk || $type == part || $type == crypt ]] || continue
while read -r vg; do
[[ -n $vg ]] && vgchange -an "$vg" 2>/dev/null || true
done < <(pvs --noheadings -o vg_name "$dev" 2>/dev/null | awk '{$1=$1; print}' | sort -u)
done < <(lsblk -rnpo PATH,TYPE "$disk")
while read -r dev type; do
[[ $type == crypt ]] && cryptsetup close "$dev" 2>/dev/null || true
done < <(lsblk -rnpo PATH,TYPE "$disk")
blockdev --flushbufs "$disk" 2>/dev/null || true
partprobe "$disk" 2>/dev/null || true
udevadm settle || true
@@ -1,26 +0,0 @@
#!/bin/bash
#
# Live-ISO entry point for the Omarchy install orchestrator. Invoked from
# /root/.automated_script.sh after the configurator wizard has produced its
# user_*.json / user_*.txt files. The outer shim owns install logging.
set -eEo pipefail
# Parse our args; convert to env vars before exec'ing Python so archinstall's
# own argparse (which runs at module import time inside the orchestrator)
# doesn't choke on flags it doesn't recognize.
while [[ $# -gt 0 ]]; do
case $1 in
--config) export OMARCHY_INSTALL_CONFIG="$2"; shift 2 ;;
--creds) export OMARCHY_INSTALL_CREDS="$2"; shift 2 ;;
--full-name-file) export OMARCHY_INSTALL_FULL_NAME_FILE="$2"; shift 2 ;;
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;;
--authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;;
--tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;;
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
esac
done
cd /usr/share/omarchy-iso
exec python -m orchestrator.main
Whitespace-only changes.
@@ -1,246 +0,0 @@
"""Thin compatibility wall around the archinstall Python library.
ONLY this module imports from archinstall. Everything else uses these helpers.
If archinstall's API churns, the blast radius is contained here.
Tested against archinstall 4.4 (Python 3.14).
The canonical call sequence (mirrored from archinstall.scripts.guided.py) is:
FilesystemHandler(disk_config).perform_filesystem_operations()
with Installer(mountpoint, disk_config, kernels=, silent=) as inst:
# guided.py:84-85 SKIPS this for DiskLayoutType.Pre_mount — pre-mounted
# configs do their own mounting before the Installer context opens.
if disk_config.config_type != DiskLayoutType.Pre_mount:
inst.mount_ordered_layout()
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
inst.set_mirrors(handler, mirror_config, on_target=True)
inst.setup_swap(algo=...)
inst.create_users(users)
inst.add_additional_packages(packages)
inst.set_timezone(tz)
inst.activate_time_synchronization()
inst.set_user_password(root_user)
inst.enable_service(services)
inst.genfstab()
Our orchestrator installs Omarchy's Limine files directly instead of invoking
archinstall's bootloader helper, so EFI paths and efibootmgr labels are ours
from the start.
"""
from __future__ import annotations
import importlib
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
# Imports are top-level so a missing/incompatible archinstall surfaces at
# orchestrator startup, not deep inside a phase.
from archinstall.lib.args import ArchConfig, ArchConfigHandler
from archinstall.lib.authentication.authentication_handler import AuthenticationHandler
from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.disk.utils import get_parent_device_path, get_unique_path_for_device, udev_sync
from archinstall.lib.hardware import SysInfo
from archinstall.lib.installer import Installer
from archinstall.lib.mirror.mirror_handler import MirrorListHandler
from archinstall.lib.models import Bootloader
from archinstall.lib.models.device import DiskLayoutType, EncryptionType
from archinstall.lib.models.users import User
from .ui import info
def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler:
"""Build an ArchConfigHandler from on-disk JSON.
archinstall's ArchConfigHandler reads --config / --creds from sys.argv
via argparse at construction time (lib/args.py:_parse_args). It does
NOT consult env vars for these paths. We can't pass them on our real
argv because the wrapper strips them before exec'ing Python (so other
archinstall arg-parsing code doesn't choke on our flags), so we hand
archinstall a synthetic argv just for this call.
"""
import sys
saved_argv = sys.argv
sys.argv = [
saved_argv[0] if saved_argv else "omarchy-iso-install",
"--config", str(config_path),
"--creds", str(creds_path),
]
try:
return ArchConfigHandler()
finally:
sys.argv = saved_argv
def make_mirror_handler(offline: bool = True) -> MirrorListHandler:
return MirrorListHandler(offline=offline, verbose=False)
def perform_filesystem_operations(arch_config: ArchConfig) -> None:
"""Partition, format, encrypt. archinstall's FilesystemHandler is its own
object (separate from Installer) so we run it before opening the
Installer context manager.
parted's partition-table commit races udev: the disk wipe and the first
BLKPG partition registration trigger probes that briefly hold the disk
open, and the kernel then refuses to register the remaining partitions
(_ped.IOException: "Partition(s) ... have been written, but we have been
unable to inform the kernel"). The table is written correctly when that
happens — only the kernel's view is stale — so settle udev and redo the
operations. The wipe/partition/format sequence is idempotent."""
if not arch_config.disk_config:
raise RuntimeError("disk_config missing from arch config")
handler = FilesystemHandler(arch_config.disk_config)
# archinstall's perform_filesystem_operations defaults to a ~4.5s
# "5...4...3...2...1" countdown (FilesystemHandler._final_warning) before it
# wipes the disk. That warning is meant for its interactive TUI; in our
# orchestrated install it renders nowhere and just sleeps. Suppress it when
# the running archinstall still takes the flag — newer releases dropped both
# the countdown and the parameter, so only pass it when it's accepted.
fs_kwargs = (
{"show_countdown": False}
if _method_accepts(handler.perform_filesystem_operations, "show_countdown")
else {}
)
attempts = 3
for attempt in range(1, attempts + 1):
udev_sync()
try:
handler.perform_filesystem_operations(**fs_kwargs)
return
except Exception as exc:
if attempt == attempts or "unable to inform the kernel" not in str(exc):
raise
info(f"› partition commit lost a udev race (attempt {attempt}/{attempts}); retrying")
@contextmanager
def open_installer(
arch_config: ArchConfig,
mountpoint: Path,
silent: bool = True,
) -> Iterator[Installer]:
"""Yield an open Installer; ensures __exit__ runs even on exception so
/mnt is left clean for a retry."""
if not arch_config.disk_config:
raise RuntimeError("disk_config missing from arch config")
with Installer(
mountpoint,
arch_config.disk_config,
kernels=arch_config.kernels,
silent=silent,
) as installer:
yield installer
def is_encrypted(arch_config: ArchConfig) -> bool:
disk = arch_config.disk_config
if not disk or not disk.disk_encryption:
return False
return disk.disk_encryption.encryption_type != EncryptionType.NO_ENCRYPTION
def is_pre_mount(arch_config: ArchConfig) -> bool:
return bool(
arch_config.disk_config
and arch_config.disk_config.config_type == DiskLayoutType.Pre_mount
)
def bootloader_enabled(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader != Bootloader.NO_BOOTLOADER)
def is_limine(arch_config: ArchConfig) -> bool:
bl = arch_config.bootloader_config
return bool(bl and bl.bootloader == Bootloader.Limine)
def has_uefi() -> bool:
return SysInfo.has_uefi()
def parent_device_path(dev_path: Path) -> Path:
return get_parent_device_path(dev_path)
def unique_device_path(dev_path: Path) -> Path | None:
return get_unique_path_for_device(dev_path)
def _application_handler():
"""Return archinstall's application handler across archinstall versions."""
module = importlib.import_module("archinstall.lib.applications.application_handler")
handler = getattr(module, "application_handler", None)
if handler is not None and callable(getattr(handler, "install_applications", None)):
return handler
handler_class = getattr(module, "ApplicationHandler", None)
if handler_class is None:
raise RuntimeError("archinstall application handler is unavailable")
try:
handler = handler_class()
except TypeError as exc:
raise RuntimeError("archinstall ApplicationHandler cannot be constructed") from exc
if not callable(getattr(handler, "install_applications", None)):
raise RuntimeError("archinstall ApplicationHandler has no install_applications method")
return handler
def _method_accepts(method, name: str) -> bool:
"""Return whether a bound archinstall method accepts a named argument.
Do not use inspect.signature here: Python 3.14 may evaluate archinstall's
lazy annotations, and some archinstall releases annotate with names that are
only imported under TYPE_CHECKING.
"""
fn = getattr(method, "__func__", method)
code = getattr(fn, "__code__", None)
if code is None:
return True
positional = code.co_varnames[:code.co_argcount]
kwonly = code.co_varnames[code.co_argcount:code.co_argcount + code.co_kwonlyargcount]
return name in (*positional, *kwonly)
def _method_accepts_users(method) -> bool:
return _method_accepts(method, "users")
def install_applications(installer: Installer, arch_config: ArchConfig) -> None:
"""Install archinstall application selections such as PipeWire audio.
The configurator still writes archinstall's audio_config. We own phase
ordering now, but should not silently drop archinstall's hardware-aware
application installers (SOF/ALSA firmware detection, PipeWire packages,
Bluetooth selections, etc.).
"""
app_config = arch_config.app_config
if not app_config:
return
users = arch_config.auth_config.users if arch_config.auth_config else None
handler = _application_handler()
install_applications_method = handler.install_applications
if _method_accepts_users(install_applications_method):
install_applications_method(installer, app_config, users)
else:
install_applications_method(installer, app_config)
def root_user(arch_config: ArchConfig) -> User | None:
auth = arch_config.auth_config
if not auth or not auth.root_enc_password:
return None
return User("root", auth.root_enc_password, False)
@@ -1,126 +0,0 @@
"""Install context: parsed configurator output, invocation paths, and a
mutable `state` dict for objects that live across phases (e.g., the
archinstall config handler and mirror list handler)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class InstallContext:
config_path: Path
creds_path: Path
full_name: str
email: str
encrypt: bool
authorized_keys_path: Path | None
tailscale_authkey_path: Path | None
user_configuration: dict
user_credentials: dict
arch_config_path: Path
omarchy_install: dict[str, Any]
target: Path = Path("/mnt")
omarchy_path: Path = Path("/usr/share/omarchy")
state_dir: Path = Path("/run/omarchy-install")
log_path: Path = Path("/var/log/omarchy-install.log")
target_log_path: Path = Path("/mnt/var/log/omarchy-install.log")
# Mutable per-run state shared across phases (e.g., 'arch_config_handler',
# 'mirror_handler'). Phases populate as needed; later phases read.
state: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_env(cls) -> "InstallContext":
config_str = os.environ.get("OMARCHY_INSTALL_CONFIG")
creds_str = os.environ.get("OMARCHY_INSTALL_CREDS")
if not config_str or not creds_str:
raise RuntimeError("OMARCHY_INSTALL_CONFIG and OMARCHY_INSTALL_CREDS must be set")
config_path = Path(config_str)
creds_path = Path(creds_str)
user_configuration = json.loads(config_path.read_text())
omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration)
arch_configuration = dict(user_configuration)
arch_configuration.pop("omarchy_install", None)
state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install"))
state_dir.mkdir(parents=True, exist_ok=True)
arch_config_path = state_dir / "archinstall-user_configuration.json"
arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n")
ctx = cls(
config_path=config_path,
creds_path=creds_path,
full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_FILE")),
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"),
authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")),
tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")),
user_configuration=user_configuration,
user_credentials=json.loads(creds_path.read_text()),
arch_config_path=arch_config_path,
omarchy_install=omarchy_install,
state_dir=state_dir,
)
disk_config = user_configuration.get("disk_config", {})
target_mount = omarchy_install.get("target_mount") or disk_config.get("mountpoint")
if target_mount:
ctx.target = Path(target_mount)
return ctx
@property
def username(self) -> str:
return self.user_credentials["users"][0]["username"]
@property
def mode(self) -> str:
if mode := self.omarchy_install.get("mode"):
return mode
cfg_type = self.user_configuration.get("disk_config", {}).get("config_type")
return "protected" if cfg_type == "pre_mounted_config" else "full_disk"
@property
def is_protected(self) -> bool:
return self.mode == "protected"
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"
return {
"mode": mode,
"target_mount": disk_config.get("mountpoint") or "/mnt",
"boot": {
"esp_mount": "/boot",
"esp_path": "/EFI/limine",
"efi_binary": "limine_x64.efi",
"enable_fallback": mode == "full_disk",
},
"storage": {},
}
def _optional_path(path: str | None) -> Path | None:
"""A path the caller always passes but that need not exist. The live ISO
passes --authorized-keys-file on every install; only autoinstall drives put
a file there."""
if not path:
return None
p = Path(path)
return p if p.exists() else None
def _read_text(path: str | None) -> str:
if not path:
return ""
p = Path(path)
if not p.exists():
return ""
return p.read_text().strip()
@@ -1,62 +0,0 @@
"""Offline target keyboard configuration.
archinstall's set_keyboard_language boots the installed system in a
systemd-nspawn container just to run localectl. systemd-firstboot --root
produces the part of that output Omarchy actually consumes — KEYMAP for the
console plus the XKB* settings in vconsole.conf that omarchy's
detect-keyboard-layout.sh copies into Hyprland's kb_layout — without booting
anything. The Xorg 00-keyboard.conf that localectl also writes is not
generated: nothing on an Omarchy system reads it.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
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 (the configurator offers
two, ba and khmer), matching archinstall: warn and keep the default.
"""
if not language.strip():
return True
result = subprocess.run(
["localectl", "--no-pager", "list-keymaps"],
check=False,
text=True,
capture_output=True,
)
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()}:
return False
# systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the
# FONT= line archinstall's set_vconsole wrote earlier.
vconsole_path = target / "etc" / "vconsole.conf"
font = None
if vconsole_path.exists():
font = next(
(line for line in vconsole_path.read_text().splitlines() if line.startswith("FONT=")),
None,
)
result = subprocess.run(
["systemd-firstboot", f"--root={target}", f"--keymap={language}", "--force"],
check=False,
text=True,
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.strip() or "systemd-firstboot returned an error"
raise RuntimeError(f"Unable to configure keyboard layout {language}: {detail}")
if font:
vconsole_path.write_text(vconsole_path.read_text() + font + "\n")
return True
@@ -1,104 +0,0 @@
"""Omarchy install orchestrator.
Single tool that owns the full install phase ordering, with archinstall used as
a library subsystem (not as the top-level installer).
The live-ISO wrapper consumes CLI args and passes configuration paths via
OMARCHY_INSTALL_* environment variables before Python starts. This keeps
archinstall's import-time CLI parsing from seeing Omarchy-specific flags.
"""
from __future__ import annotations
import sys
from .context import InstallContext
from .phases import PhaseError, run
from .ui import error, info
def build_phases(ctx: InstallContext):
"""Phase order. Each entry is (display name, callable taking InstallContext).
The ordering is the whole point of this orchestrator: package-install
hooks (limine-mkinitcpio-hook, in particular) and useradd happen at
points where their prerequisites are guaranteed to be in place.
Full-disk and protected installs use the same phase sequence. The
configurator only changes the JSON input: full-disk asks archinstall to
create/mount the layout, while protected provides an already-mounted target
and the partition details Omarchy needs for boot/fstab generation.
"""
from .phases_impl import (
prepare_live,
prepare_install_target,
arch_install_system,
configure_hibernation,
run_system_finalizer,
finalize_limine_boot,
run_chroot_finalizer,
configure_dns_resolver,
configure_login,
configure_ssh_access,
configure_tailscale,
validate_boot,
)
return [
("Preparing live environment", prepare_live),
("Preparing install target", prepare_install_target),
("Installing Arch + Omarchy", arch_install_system),
("Configuring hibernation", configure_hibernation),
("Configuring system", run_system_finalizer),
("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login),
("Configuring SSH access", configure_ssh_access),
("Configuring Tailscale", configure_tailscale),
("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot),
]
def main() -> int:
try:
ctx = InstallContext.from_env()
except RuntimeError as e:
error(f"Configuration error: {e}")
return 2
info(f"Installing Omarchy for {ctx.username} → {ctx.target}")
from .phases_impl import (
boost_cpu_governor,
cleanup_bind_mounts,
cleanup_protected_state,
cleanup_target_hook_masks,
restore_cpu_governors,
)
governors = boost_cpu_governor()
success = False
try:
try:
run(ctx, build_phases(ctx))
success = True
except PhaseError:
error("Installation halted.")
return 1
except KeyboardInterrupt:
error("Installation interrupted.")
return 130
info("Installation complete.")
return 0
finally:
restore_cpu_governors(governors)
cleanup_bind_mounts(ctx)
cleanup_target_hook_masks(ctx)
if not success:
cleanup_protected_state(ctx)
if __name__ == "__main__":
sys.exit(main())
@@ -1,105 +0,0 @@
"""Phase state machine. Each phase is a (name, callable) pair; callables take
the InstallContext and either return cleanly or raise to abort the install."""
from __future__ import annotations
import json
import os
import time
import traceback
from collections.abc import Callable
from pathlib import Path
from .context import InstallContext
from .ui import error, info
PhaseFn = Callable[[InstallContext], None]
class PhaseError(Exception):
"""Raised when a phase fails. Wrapped with the phase name."""
def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None:
ctx.state_dir.mkdir(parents=True, exist_ok=True)
state_path = ctx.state_dir / "state.json"
state = {
"started_at": time.time(),
# The dashboard counts packages under <target>/var/lib/pacman/local;
# publish the path rather than have the UI assume /mnt.
"target": str(ctx.target),
"total_phases": len(phases),
"current_index": 0,
"current_phase": "Starting installation",
"phases": [],
}
_write_state(state_path, state)
for index, (name, fn) in enumerate(phases):
state["current_index"] = index
state["current_phase"] = name
state["phase_started_at"] = time.time()
_write_state(state_path, state)
info(f"› {name}")
started = time.time()
try:
fn(ctx)
except Exception as exc: # noqa: BLE001
elapsed = time.time() - started
state["phases"].append({
"name": name,
"status": "failed",
"elapsed": elapsed,
"error": str(exc),
})
_write_state(state_path, state)
error(f"Phase '{name}' failed after {elapsed:.1f}s: {exc}")
traceback.print_exc()
raise PhaseError(f"phase {name} failed: {exc}") from exc
elapsed = time.time() - started
state["phases"].append({"name": name, "status": "ok", "elapsed": elapsed})
_write_state(state_path, state)
state["current_index"] = max(len(phases) - 1, 0)
state["current_phase"] = "Installation complete"
state["finished_at"] = time.time()
# Expected vs actual for the bar's denominator, so drift is visible in
# acceptance runs rather than only by watching a bar creep.
state["installed_packages"] = _installed_package_count(ctx.target)
state["expected_packages"] = _expected_package_count()
_write_state(state_path, state)
timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json"
timing_path.parent.mkdir(parents=True, exist_ok=True)
_write_state(timing_path, state)
def _installed_package_count(target: Path) -> int:
"""Packages libalpm installed into the target — one directory each under
local/, which is what the dashboard counts live."""
local_db = target / "var" / "lib" / "pacman" / "local"
try:
with os.scandir(local_db) as entries:
return sum(1 for entry in entries if entry.is_dir())
except OSError:
return 0
def _expected_package_count() -> int:
path = Path("/usr/share/omarchy-iso/expected-packages")
try:
return int(path.read_text().split()[0])
except (OSError, ValueError, IndexError):
return 0
def _write_state(path: Path, state: dict) -> None:
# Dashboard polls this file while phases update it. Write atomically so the
# reader never observes a truncated/partial JSON document and resets UI.
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(json.dumps(state, indent=2, default=str))
tmp.replace(path)
File diff suppressed because it is too large. Load diff
@@ -1,28 +0,0 @@
"""Orchestrator progress lines.
These land in the install log, not on a screen: omarchy-install-dashboard owns
the visible UI, captures the orchestrator's stdout into the support log, and
strips CSI sequences on the way in. The colouring was therefore never seen by
anyone, while gum charged a Go process start per line for it. Plain writes keep
the same indented shape at no cost.
Flush every line: pacstrap and arch-chroot inherit this fd, so buffering here
would reorder our lines against theirs in the log.
"""
from __future__ import annotations
import sys
def _emit(text: str) -> None:
sys.stdout.write(f"\n {text}\n")
sys.stdout.flush()
def info(text: str) -> None:
_emit(text)
def error(text: str) -> None:
_emit(text)
@@ -2,4 +2,4 @@ title Omarchy (x86_64, UEFI)
sort-key 01 sort-key 01
linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 linux /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 options archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0
+2 -2
View File
@@ -54,13 +54,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
+2 -2
View File
@@ -30,13 +30,13 @@ timeout_style=hidden
menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' { menuentry "Omarchy (%ARCH%, ${archiso_platform})" --class arch --class gnu-linux --class gnu --class os --id 'archlinux' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0 initramfs_async=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" quiet splash xe.enable_panel_replay=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' { menuentry "Omarchy with speakup screen reader (%ARCH%, ${archiso_platform})" --hotkey s --class arch --class gnu-linux --class gnu --class os --id 'archlinux-accessibility' {
set gfxpayload=keep set gfxpayload=keep
linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0 initramfs_async=0 linux /%INSTALL_DIR%/boot/%ARCH%/vmlinuz-linux-t2 archisobasedir=%INSTALL_DIR% img_dev=UUID=${archiso_img_dev_uuid} img_loop="${iso_path}" accessibility=on xe.enable_panel_replay=0
initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img initrd /%INSTALL_DIR%/boot/%ARCH%/initramfs-linux-t2.img
} }
+1 -8
View File
@@ -13,13 +13,6 @@ ParallelDownloads = 5
SigLevel = Required DatabaseOptional SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional LocalFileSigLevel = Optional
# No signature checks for the offline mirror: it ships inside the ISO, whose
# integrity is already covered by the airootfs sha512 and the ISO signature.
# "Optional TrustAll" is NOT a bypass — Optional verifies whenever a .sig file
# exists (they all do), and pacstrap verifies against the LIVE keyring (pacman
# -r relocates the root but not GpgDir). That made installs depend on archiso's
# slow boot-time pacman-init.service having finished, which stalled or failed
# pacstrap on real hardware with "required key missing from keyring".
[offline] [offline]
SigLevel = Never SigLevel = Optional TrustAll
Server = file:///var/cache/omarchy/mirror/offline/ Server = file:///var/cache/omarchy/mirror/offline/
-1
View File
@@ -30,6 +30,5 @@ SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch Server = https://pkgs.omarchy.org/edge/$arch
[arch-mact2] [arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never SigLevel = Never
+1 -2
View File
@@ -27,9 +27,8 @@ Include = /etc/pacman.d/mirrorlist
[omarchy] [omarchy]
SigLevel = Optional TrustAll SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch Server = https://pkgs.omarchy.org/rc/$arch
[arch-mact2] [arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never SigLevel = Never
-1
View File
@@ -30,6 +30,5 @@ SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/stable/$arch Server = https://pkgs.omarchy.org/stable/$arch
[arch-mact2] [arch-mact2]
Server = https://mirror.funami.tech/arch-mact2/os/x86_64
Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release Server = https://github.com/NoaHimesaka1873/arch-mact2-mirror/releases/download/release
SigLevel = Never SigLevel = Never
+3 -22
View File
@@ -12,34 +12,15 @@ bootmodes=('bios.syslinux' 'uefi.grub')
arch="x86_64" arch="x86_64"
pacman_conf="pacman-offline.conf" pacman_conf="pacman-offline.conf"
airootfs_image_type="squashfs" airootfs_image_type="squashfs"
# Package archives in the offline mirror are already zstd-compressed. Storing airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M')
# them in an outer stream saves little space but makes pacman decompress the
# outer layer while hashing and extracting every package during installation.
#
# Everything else in the live root is zstd rather than xz. Squashfs decompresses
# on the page-fault path through a single stream (CONFIG_SQUASHFS_DECOMP_SINGLE),
# where xz manages ~100MB/s against zstd's ~900MB/s, and the live root is read
# cold on every boot: kernel, plymouth, systemd, python, archinstall, gum. The
# whole ISO grows well under a percent for it, and dropping the x86 BCJ filter
# also removes one of the blockers listed in plans/aarch64-support.md.
airootfs_image_tool_options=(
'-comp' 'zstd'
'-Xcompression-level' '19'
'-b' '1M'
'-action' 'uncompressed@subpathname(var/cache/omarchy/mirror/offline)'
)
bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19') bootstrap_tarball_compression=('zstd' '-c' '-T0' '--auto-threads=logical' '--long' '-19')
file_permissions=( file_permissions=(
["/etc/shadow"]="0:0:400" ["/etc/shadow"]="0:0:400"
["/root"]="0:0:750" ["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755" ["/root/.automated_script.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700" ["/root/.gnupg"]="0:0:700"
["/root/configurator"]="0:0:755"
["/usr/local/bin/choose-mirror"]="0:0:755" ["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/omarchy-cidata-load"]="0:0:755" ["/root/configurator"]="0:0:755"
["/usr/local/bin/omarchy-iso-cleanup-disk"]="0:0:755"
["/usr/local/bin/omarchy-install-dashboard"]="0:0:755"
["/usr/local/bin/omarchy-iso-install"]="0:0:755"
["/usr/local/bin/omarchy-upload-log"]="0:0:755"
["/var/cache/omarchy/mirror/offline/"]="0:0:775" ["/var/cache/omarchy/mirror/offline/"]="0:0:775"
["/usr/local/bin/omarchy-upload-log"]="0:0:755"
) )
+3 -3
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NBD) MENU LABEL Omarchy install medium (x86_64, NBD)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y initramfs_async=0 APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% archiso_nbd_srv=${pxeserver} cms_verify=y
SYSAPPEND 3 SYSAPPEND 3
LABEL arch64_nfs LABEL arch64_nfs
@@ -17,7 +17,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, NFS) MENU LABEL Omarchy install medium (x86_64, NFS)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y initramfs_async=0 APPEND archisobasedir=%INSTALL_DIR% archiso_nfs_srv=${pxeserver}:/run/archiso/bootmnt cms_verify=y
SYSAPPEND 3 SYSAPPEND 3
LABEL arch64_http LABEL arch64_http
@@ -28,5 +28,5 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, HTTP) MENU LABEL Omarchy install medium (x86_64, HTTP)
LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX ::/%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD ::/%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y initramfs_async=0 APPEND archisobasedir=%INSTALL_DIR% archiso_http_srv=http://${pxeserver}/ cms_verify=y
SYSAPPEND 3 SYSAPPEND 3
+2 -2
View File
@@ -6,7 +6,7 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS) MENU LABEL Omarchy install medium (x86_64, BIOS)
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0 initramfs_async=0 APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% quiet splash xe.enable_panel_replay=0
# Accessibility boot option # Accessibility boot option
LABEL arch64speech LABEL arch64speech
@@ -17,4 +17,4 @@ ENDTEXT
MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech MENU LABEL Omarchy install medium (x86_64, BIOS) with ^speech
LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2 LINUX /%INSTALL_DIR%/boot/x86_64/vmlinuz-linux-t2
INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img INITRD /%INSTALL_DIR%/boot/x86_64/initramfs-linux-t2.img
APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0 initramfs_async=0 APPEND archisobasedir=%INSTALL_DIR% archisosearchuuid=%ARCHISO_UUID% accessibility=on xe.enable_panel_replay=0
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -16,7 +16,7 @@ These exist outside omarchy-iso. Flagged for visibility — without them, nothin
1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution. 1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution.
2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port. 2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port.
3. **Omarchy ISO architecture guard** must allow supported architectures or scope any x86_64-only checks behind an explicit guard. 3. **Omarchy installer guard** at `~/.local/share/omarchy/install/preflight/guard.sh:25` hard-aborts on `uname -m != x86_64`. Must be relaxed to allow aarch64 (or scoped behind a flag).
4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM. 4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM.
--- ---
@@ -76,9 +76,9 @@ aarch64 has no BIOS — only UEFI. Drop the syslinux path entirely on ARM.
The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file. The cleanest cut: keep one `grub.cfg` shared, strip the x86 shell/memtest fragments at build time when `OMARCHY_ARCH=aarch64`. Don't fork the file.
### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset` ### 6. mkinitcpio preset — `configs/airootfs/etc/mkinitcpio.d/linux.preset`
Names `vmlinuz-linux-t2` and `initramfs-linux-t2.img`. The filename is the pkgbase and the alpm hook keys off it, so aarch64 does not rename this file — it simply does not ship it, and releng's own `linux.preset` covers the stock kernel. Drop `linux-t2` from `arch_packages` on aarch64 and the boot entries point at `vmlinuz-linux` / `initramfs-linux.img`. References `vmlinuz-linux-t2` and `initramfs-linux-t2.img` (lines 5, 8). For aarch64, swap to `vmlinuz-linux` / `initramfs-linux.img`. Generate the file at build time from a template, keyed on arch.
### 7. Configurator — `configs/airootfs/root/configurator` ### 7. Configurator — `configs/airootfs/root/configurator`
@@ -109,9 +109,9 @@ Both scripts hardcode the OVMF path; small, mechanical fix.
### 10. Release script — `bin/omarchy-iso-release:52` ### 10. Release script — `bin/omarchy-iso-release:52`
```bash ```bash
latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*${OMARCHY_ARCH}-"$ISO_REF".iso | head -n1) latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1)
``` ```
Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${ISO_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized. Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${INSTALLER_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized.
### 11. CI — `.github/workflows/nightly-build.yml` ### 11. CI — `.github/workflows/nightly-build.yml`
@@ -145,7 +145,7 @@ Code:
- `configs/efiboot/loader/loader.conf` — per-arch default entry - `configs/efiboot/loader/loader.conf` — per-arch default entry
- `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time - `configs/efiboot/loader/entries/01-archiso-x86_64-linux.conf` — generate aarch64 sibling at build time
- `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64 - `configs/grub/grub.cfg`, `loopback.cfg` — strip x86-only fragments on aarch64
- `configs/airootfs/etc/mkinitcpio.d/linux-t2.preset` — omit on aarch64 (drop linux-t2 there; releng's linux.preset covers the stock kernel) - `configs/airootfs/etc/mkinitcpio.d/linux.preset` — template by arch (drop linux-t2 on aarch64)
- `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe - `configs/airootfs/root/configurator` — branch archinstall JSON mirror list on `uname -m`; optionally guard lspci T2 probe
- `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64 - `.github/workflows/nightly-build.yml` — matrix x86_64/aarch64
-187
View File
@@ -1,187 +0,0 @@
# Autoinstall Plan
## Goal
Let the standard Omarchy ISO install itself with no keyboard present, driven by config files handed to the VM on a second drive. This makes Omarchy usable as a Packer/Proxmox base image for disposable dev environments: create VM, boot, walk away, SSH in.
## Product Requirements
- No ISO rebuild. The shipped ISO autoinstalls when config is present and runs the configurator when it is not.
- No new menu entry, no new boot parameter, no separate ISO flavor.
- Config format is exactly what the configurator already writes — no second schema to maintain.
- The installed machine is reachable over SSH with a key supplied at install time.
- Interactive installs behave identically to today.
---
## Chosen Architecture
### Trigger: config presence, not a mode flag
The installer looks for a drive labeled `cidata` at startup. If it is there and carries the configurator's output files, those files are copied into `/root` and `./configurator` is skipped — the rest of the install runs the ordinary path against ordinary inputs. If it is not there, nothing changes.
This is Ryan Hughes' suggestion from PR #72 and it is what keeps the diff small: the install pipeline can be rewritten freely as long as it keeps reading its five files out of `/root`, and autoinstall follows along for free.
`cidata` is the cloud-init `NoCloud` label, so Proxmox, libvirt, and Packer all already know how to attach one.
### What the ISO already does for us
One thing that would otherwise need building is already true on a stock install: `NetworkManager.service` is enabled and active with DHCP by default, so autoinstall needs no `systemd-networkd` unit — one would contend with NetworkManager.
Remote access needs three things, all verified against a real install rather than inferred:
- `authorized_keys` for the user.
- `systemctl enable sshd.service`. Omarchy ships `openssh` but leaves the service disabled.
- `ufw allow ssh`. `install/config/firewall.sh` opens LocalSend (53317) and docker DNS and nothing else, and ufw runs default-deny incoming.
Still no loosening of `PasswordAuthentication` — key auth only.
Do not take `manifests/fresh-4-semantic.json` as the authority on any of this. It claims `sshd.service: enabled` and carries an `allow tcp 22` ufw rule; a real install of this ISO has neither. It is a snapshot of one machine's state, not a contract about stock behavior, and trusting it cost two test cycles.
### Non-interactivity belongs to the dashboard
`omarchy-install-dashboard` owns every prompt that survives to the end of an install: the reboot confirm and, on failure, the action menu. Both are `gum` calls reading from the TTY, and both hang forever on a headless VM. Autoinstall therefore sets one env var that the dashboard reads, rather than patching prompts out of scripts after the fact — which is what PR #72 did to `finished.sh`, and what everyone correctly disliked.
---
## Non-Goals
- No config generator, validator, or schema doc. The configurator is the source of truth for the format; users copy its output.
- No secret management. Whoever builds the `cidata` drive owns the password hash and the public key on it.
- No PXE, no network-fetched config, no cloud-init compatibility beyond reusing the drive label.
- No changes to disk selection, encryption, or any install phase behavior.
---
## Repo Implementation Plan
## 1) Detect the cidata drive and skip the configurator
### Files
- `configs/airootfs/usr/local/bin/omarchy-cidata-load`
- `configs/airootfs/root/.automated_script.sh`
- `configs/profiledef.sh`
### Changes
- Add `omarchy-cidata-load` alongside the other install commands in `/usr/local/bin` (with the matching `file_permissions` entry in `profiledef.sh`). It exits 0 when an autoinstall drive was loaded:
- `udevadm settle` first: the by-label symlinks come from udev, and probing before enumeration finishes would silently drop a headless install into the wizard.
- Look for `/dev/disk/by-label/cidata` then `/dev/disk/by-label/CIDATA`; exit non-zero if neither exists.
- Mount read-only on a temp mountpoint; exit non-zero if the mount fails.
- Require both `user_configuration.json` and `user_credentials.json` on the drive — this is the same pair `InstallContext.from_env` treats as mandatory. Missing either means the drive is not an autoinstall drive; unmount and exit non-zero.
- Copy the required pair plus, when present, `user_full_name.txt`, `user_email_address.txt`, `user_encrypt_installation.txt`, and `authorized_keys` into `/root` with a single `cp`, and exit non-zero if the copy fails — a "loaded" drive with missing inputs would fail the install later with a worse error.
- Unmount before exiting. Leave nothing mounted for the install to trip over.
- The script takes an optional path prefix so the unit tests can point it at a sandbox; production callers pass nothing.
- Replace the bare `./configurator` call in `.automated_script.sh` with: run `omarchy-cidata-load`; on success export `OMARCHY_UI_INTERACTIVE=no`, on failure run `./configurator`.
- Pass `--authorized-keys-file /root/authorized_keys` to `omarchy-iso-install` in the dashboard invocation, alongside the existing five flags. Always pass it; the orchestrator no-ops when the file is absent, which keeps the interactive path on the identical command line.
### Notes
- The three text files are optional by construction: `_read_text` in `context.py` returns `""` for a missing path and `encrypt` falls back to false. Do not add stricter checks here than the orchestrator itself applies.
- `omarchy-cidata-load` must not run before `warm_offline_mirror` is backgrounded — the mirror warm should keep its head start.
### Acceptance criteria
- ISO with no `cidata` drive attached: configurator runs, byte-identical UX to today.
- ISO with a `cidata` drive carrying the five files: configurator never appears; install starts within seconds of boot.
- ISO with a drive labeled `cidata` that is empty or carries only one of the two required files: falls back to the configurator rather than failing the boot.
- Nothing remains mounted from `/dev/disk/by-label/cidata` once the install begins.
---
## 2) Non-interactive dashboard
### File
- `configs/airootfs/usr/local/bin/omarchy-install-dashboard`
### Changes
- Read `OMARCHY_UI_INTERACTIVE` once near the other env reads at the top.
- In `reboot_prompt()` (`:474`): when non-interactive, return 0 immediately instead of calling `gum confirm`. The existing `OMARCHY_UI_AUTO_REBOOT=no` check at `:737` stays in front of the actual `reboot`, so a debug run can set both and stop on the finish screen without a keyboard.
- In `failure_menu()` (`:597`): treat non-interactive as equivalent to the existing `OMARCHY_UI_FAILURE_ACTION=exit` early return (`:600`), so a failed autoinstall exits with the installer's status instead of blocking on `gum choose` forever. The failure screen and log tail still render, and the serial console still captures them.
### Why here and not in the caller
The dashboard is the only process that owns the visible UI. Suppressing its prompts anywhere else means editing scripts it invokes — the `finished.sh` `sed` from PR #72 — which breaks the moment those scripts move.
### Acceptance criteria
- Autoinstall run reaches the finish screen and reboots with no input.
- Autoinstall run whose install fails renders the failure screen, exits non-zero, and does not hang.
- Interactive run still prompts for reboot and still offers the failure menu.
- `OMARCHY_UI_INTERACTIVE=no` plus `OMARCHY_UI_AUTO_REBOOT=no` stops at the finish screen without prompting.
---
## 3) SSH keys as an orchestrator phase
### Files
- `configs/airootfs/usr/local/bin/omarchy-iso-install`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py`
- `configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py`
### Changes
- `omarchy-iso-install`: add `--authorized-keys-file` to the arg loop, exporting `OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE`. Same shape as the five existing flags, ahead of the `unknown arg` catch-all (`:19`).
- `context.py`: add an `authorized_keys_path: Path | None` field populated from that variable in `from_env`, `None` when unset or when the file does not exist.
- `phases_impl.py`: add `configure_ssh_access(ctx)`:
- Return immediately when `ctx.authorized_keys_path` is `None`.
- Read the file as sshd's own authorized_keys format, dropping blank lines and # comments; write the keys one per line to `<target>/home/<user>/.ssh/authorized_keys`.
- `.ssh` at `0700`, `authorized_keys` at `0600`, both owned by the user via `arch-chroot <target> chown -R <user>:<user>`. Do not hardcode uid 1000 as PR #72 did — ask the target for it.
- `arch-chroot <target> systemctl enable sshd.service`. Works cleanly in the chroot.
- `arch-chroot <target> ufw allow ssh`. This one exits **non-zero** in a chroot (`ERROR: problem running`) because ufw cannot reach netfilter, but it writes the rule to `/etc/ufw/user.rules` before failing, and that file is what `ufw.service` loads on first boot. So ignore the exit status and assert the rule landed in the file instead. No ufw-presence check: ufw is part of Omarchy's default package set, and the rule assertion already fails loudly if it is somehow missing.
- An `authorized_keys` with no usable keys must fail the phase loudly. A machine that installs "successfully" and is then unreachable is worse than one that stops with an error on screen.
- `main.py`: register `("Configuring SSH access", configure_ssh_access)` in `build_phases` after `configure_login` (`:52`) and before `validate_boot`.
### Model to follow
`configure_login` (`phases_impl.py:1251`) — direct writes against `ctx.target`, `arch-chroot` reserved for ownership and `systemctl`. Match its structure.
### Acceptance criteria
- Install with `authorized_keys`: `ssh <user>@<vm>` works with the corresponding private key on first boot, no password.
- Install without `authorized_keys`: phase runs, does nothing, install unaffected; `~/.ssh` is not created.
- `authorized_keys` is `0600` and owned by the install user, not root.
- Port 22 is open in ufw on the installed system. Test this by connecting, not by reading the phase log — the rule is written from a chroot that cannot verify it.
- Password SSH auth is left at the distro default — autoinstall does not enable it.
- Interactive installs are unaffected: the flag is passed but the file never exists.
---
## 4) Documentation
### File
- `README.md`
### Changes
- New "Autoinstall" section covering: what the `cidata` drive is, the file table, how to produce the drive, and a Proxmox `qm create` example with disk-first boot order so the empty disk falls through to the ISO on the first boot and boots the installed system afterward.
- Show `openssl passwd -6` for the credential hash and the `authorized_keys` format.
- State plainly that the config files are the configurator's own output — the documented way to get a starting `user_configuration.json` is to run one interactive install and copy what it wrote.
- Do not repeat PR #72's networking claims. NetworkManager comes enabled with DHCP on a stock install; sshd ships disabled, which is why the SSH phase exists.
---
## 5) Validation
### Unit tests
`./test/all` runs everything under `test/`: `cidata-load-test.sh` exercises `omarchy-cidata-load` against a sandbox with `mount`/`umount`/`udevadm` stubbed, and `test_configure_ssh_access.py` runs the orchestrator phase against a temp target with `subprocess.run` recorded and ufw's write-the-rule-then-fail chroot behavior simulated. The Python tests run through `unittest` discovery alongside the repo's existing `test_*.py` suites.
### Manual matrix
1. Build the ISO from this branch.
2. Interactive control run: boot with no `cidata` drive, install normally, confirm nothing changed.
3. Autoinstall run on Proxmox: attach ISO + `cidata` drive, boot, no input; confirm the configurator never renders, the install completes, the machine reboots on its own, and SSH with the key works against the booted system.
4. Encrypted autoinstall: `user_encrypt_installation.txt` set true; confirm the LUKS prompt appears on boot (this is the one autoinstall case that still needs a human at first boot — document it).
5. Full Packer cycle: build, destroy, rebuild from the same `cidata` drive; confirm reproducibility.
### Negative tests
- Drive labeled `cidata` with no files → configurator runs.
- Drive with `user_configuration.json` only → configurator runs.
- An `authorized_keys` with no usable keys → install stops in the remote-access phase with a visible error, does not reboot into an unreachable machine.
- Install failure under autoinstall (e.g. a disk target that does not exist) → failure screen renders, process exits non-zero, no hang.
### Regression surface
The interactive path shares every line of this change except the `omarchy-cidata-load` branch, so run the standard acceptance harness (`./bin/omarchy-iso-test`) on the built ISO before proposing the PR.
---
## Relationship to PR #72
PR #72 targeted the pre-`quattro` tree and cannot be rebased: `use_omarchy_helpers`, `run_configurator`, `install_arch`, `install_omarchy`, and `chroot_bash` no longer exist. This plan reimplements the same feature against the current tree and drops three things that PR deliberately flagged as questionable:
- The `finished.sh` `sed` — replaced by section 2. The dashboard renders the reboot prompt now.
- The `systemd-networkd` DHCP unit — unnecessary; NetworkManager is already enabled and would contend with it.
- The `PasswordAuthentication yes` rewrite — unnecessary and a security downgrade.
`load_cidata` is the one piece that carries over close to intact. PR #72's `ufw allow ssh` also carries over: it was dropped from the first draft of this plan on the reasoning that an enabled sshd is a reachable sshd, and a test install proved otherwise. That PR was right to include it.
+1 -1
View File
@@ -60,7 +60,7 @@ Create a second ISO flavor for OEM preloading that is installed to internal stor
- `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry. - `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry.
- `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block. - `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block.
- When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`. - When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`.
- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_ISO_REF` suffix). - Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_INSTALLER_REF` suffix).
### Hardening ### Hardening
- Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`. - Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.
-124
View File
@@ -1,124 +0,0 @@
# Tailscale Autoinstall Plan
## Goal
Let an autoinstall drive carry a Tailscale auth key so the installed machine
joins the tailnet on its own. Combined with `authorized_keys`, this completes
the disposable-VM story: create VM, boot, walk away, `ssh user@machine` over
the tailnet from anywhere — no LAN discovery, no port forwarding.
## Product Requirements
- Same trigger philosophy as the rest of autoinstall: presence of a
`tailscale_authkey` file on the `cidata` drive, no new mode flag. No file,
no change — byte-identical behavior to today.
- The join happens when the network is available, not when the installer runs.
- No package installs from the network on first boot.
- The auth key does not persist on the installed system after a successful join.
---
## Chosen Architecture
### The package is bundled, not fetched
The `tailscale` package rides in the offline mirror (~10MB on the ISO) and is
installed during the ordinary package phase — but only when the auth key file
is present, so a stock install stays stock.
The alternative — installing from the network on first boot — was rejected
outright. A bare `pacman -Sy tailscale` on a fresh machine is a partial
upgrade waiting to corrupt something, and the "correct" version, `pacman -Syu
tailscale`, means an unattended full system upgrade racing the user's first
login (potentially swapping the kernel out on a machine that has booted once).
It would also fight Omarchy's own update flow and make the join depend on
mirror availability. The offline mirror is a coherent snapshot; installing
from it keeps the target consistent and the install deterministic and
offline-capable.
### The join is a first-boot unit, not an install step
`tailscale up` needs a running tailscaled, and there is no systemd in the
chroot. So the installer only stages: the key at `/etc/tailscale/authkey`
(root, `0600`), `tailscaled.service` enabled, and an
`omarchy-tailscale-join.service` that runs `tailscale up --auth-key
file:/etc/tailscale/authkey` on first boot.
"When the network is available" lives in the unit, not the installer:
`After=network-online.target` for ordering, plus an in-unit retry loop
(`until tailscale up …; do sleep 15; done`), because network-online can be
reached before there is real connectivity.
The unit is `Type=simple`, not oneshot, and that is load-bearing: target
units implicitly gain `After=` for their `Wants=`, so a oneshot wanted by
`multi-user.target` holds the whole boot — SDDM included — hostage until the
join finishes or times out, and once timed out it never retries within that
boot. (Caught by review.) Type=simple counts as started the moment it forks:
boot proceeds normally while the join retries in the background for as long
as the boot lasts.
Cleanup is sequenced inside the script, after `tailscale up` succeeds — the
key is removed and the unit disables itself, so the key never outlives the
join and the unit never runs again. On a boot that never gets connectivity
both survive (`ConditionPathExists` gates re-runs on the key), so a machine
installed offline joins on the first boot that can.
The machine appears on the tailnet under the hostname from
`user_configuration.json` — nothing extra to configure.
### Firewall
`ufw allow in on tailscale0`, the same write-the-rule-then-ignore-the-chroot-
exit-code dance as `configure_ssh_access` (ufw cannot reach netfilter in a
chroot but records the rule in `user.rules`, which is what first boot loads).
Without it the node joins and is then unreachable over the tailnet — worse
than not joining.
### Secrets
Same stance the autoinstall plan takes for the LUKS passphrase: whoever
builds the `cidata` drive owns the key on it. Docs recommend a reusable,
pre-authorized (tagged) key so one drive image serves N machines, and note
that ephemeral keys fit disposable VMs. No validation of the key beyond
"exactly one non-comment line" — Tailscale key formats are theirs to change.
---
## Implementation
- `builder/archinstall.packages`: add `tailscale` so it lands in the offline
mirror on every build.
- `omarchy-cidata-load`: add `tailscale_authkey` to the optional copy list.
- `.automated_script.sh`: pass `--tailscale-authkey-file /root/tailscale_authkey`
(always; the orchestrator no-ops when the file is absent, like
`--authorized-keys-file`).
- `omarchy-iso-install`: parse the flag into
`OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE`.
- `context.py`: `tailscale_authkey_path: Path | None` via `_optional_path`.
- `phases_impl.py`:
- `arch_install_system`: when the key is present, add `tailscale` to the
additional packages while the offline mirror is still bind-mounted.
- `configure_tailscale(ctx)`: no-op when the path is `None`; otherwise
stage key + unit, enable services, open ufw. Fails loudly on an empty or
ambiguous key file and when the tailscale binary is missing from the
target (an ISO built before the package was bundled).
- `main.py`: register `("Configuring Tailscale", configure_tailscale)` after
`configure_ssh_access`.
- `README.md`: file-table row + a Tailscale paragraph in the Autoinstall
section.
- Tests: `tailscale_authkey` cases in `cidata-load-test.sh`;
`test_configure_tailscale.py` mirroring the SSH phase tests.
## Acceptance criteria
- Drive with `tailscale_authkey`: machine appears on the tailnet under its
configured hostname after first boot with network; `/etc/tailscale/authkey`
is gone; `omarchy-tailscale-join.service` is disabled.
- First boot without network: no failure surfaced to the user; the join
happens on the first boot that has connectivity.
- Drive without the file: no tailscale package on the target, no unit, no
ufw rule — indistinguishable from today.
- Empty or multi-line key file: install stops in the Tailscale phase with a
visible error.
- Interactive installs: unaffected; the flag is passed but the file never
exists.
View File
Whitespace-only changes.
-13
View File
@@ -1,13 +0,0 @@
#!/bin/bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
for test in "$ROOT"/test/*-test.sh; do
printf '==> %s\n' "${test#$ROOT/}"
bash "$test"
done
printf '==> test/test_*.py\n'
python3 -m unittest discover -t "$ROOT" -s "$ROOT/test"
-172
View File
@@ -1,172 +0,0 @@
#!/bin/bash
#
# Unit tests for omarchy-cidata-load. The script takes a path prefix, so every
# case runs against a throwaway sandbox with mount/umount/udevadm stubbed out:
# "mounting" copies the fake drive's contents into the mountpoint, and every
# stub logs its invocation so the cases can assert what was (not) called.
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
CIDATA_LOAD="$ROOT/configs/airootfs/usr/local/bin/omarchy-cidata-load"
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
}
work=$(mktemp -d)
trap 'chmod -R u+w "$work"; rm -rf "$work"' EXIT
stub_dir="$work/stubs"
mkdir -p "$stub_dir"
cat >"$stub_dir/udevadm" <<'STUB'
#!/bin/bash
printf 'udevadm %s\n' "$*" >>"$TEST_LOG"
STUB
cat >"$stub_dir/mount" <<'STUB'
#!/bin/bash
printf 'mount %s\n' "$*" >>"$TEST_LOG"
[[ ${MOUNT_FAIL:-} == 1 ]] && exit 32
device=$3 mountpoint=$4
cp -a "$(readlink -f "$device")"/. "$mountpoint"/
STUB
cat >"$stub_dir/umount" <<'STUB'
#!/bin/bash
printf 'umount %s\n' "$*" >>"$TEST_LOG"
STUB
chmod +x "$stub_dir"/*
new_sandbox() {
sandbox=$(mktemp -d "$work/sandbox.XXXXXX")
mkdir -p "$sandbox/dev/disk/by-label" "$sandbox/root" "$sandbox/media"
export TEST_LOG="$sandbox/calls.log"
: >"$TEST_LOG"
}
attach_drive() {
ln -s "$sandbox/media" "$sandbox/dev/disk/by-label/$1"
}
run_load() {
PATH="$stub_dir:$PATH" "$CIDATA_LOAD" "$sandbox"
}
write_required_pair() {
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
echo '{"users": []}' >"$sandbox/media/user_credentials.json"
}
# No cidata drive attached: fall back to the wizard without mounting anything.
new_sandbox
! run_load || fail "no drive exits non-zero"
! grep -q '^mount ' "$TEST_LOG" || fail "no drive mounts nothing"
pass "no drive falls back to the wizard"
# The probe must wait for udev to finish enumerating before concluding there
# is no drive.
grep -q '^udevadm settle$' "$TEST_LOG" || fail "probe settles udev first"
pass "probe settles udev first"
# A drive with the full file set: everything lands in /root and the drive is
# unmounted afterwards.
new_sandbox
attach_drive cidata
write_required_pair
echo "Jeff" >"$sandbox/media/user_full_name.txt"
echo "jeff@example.com" >"$sandbox/media/user_email_address.txt"
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey"
run_load || fail "full file set loads"
for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
done
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
pass "full file set loads, copies everything, and unmounts"
# The uppercase label variant some tools produce works too.
new_sandbox
attach_drive CIDATA
write_required_pair
run_load || fail "uppercase CIDATA label loads"
pass "uppercase CIDATA label loads"
# The required pair alone is a valid autoinstall drive; the optional files
# stay optional.
new_sandbox
attach_drive cidata
write_required_pair
run_load || fail "required pair alone loads"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "required pair alone copies no optional files"
pass "required pair alone loads without optional files"
# Optional files are copied individually when present.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
run_load || fail "required pair plus authorized_keys loads"
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
[[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied"
[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied"
pass "present optional files are copied, absent ones skipped"
# Half the required pair is not an autoinstall drive: unmount and fall back.
new_sandbox
attach_drive cidata
echo '{"disk_config": {}}' >"$sandbox/media/user_configuration.json"
! run_load || fail "half the required pair exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "half the required pair copies nothing"
grep -q '^umount ' "$TEST_LOG" || fail "half the required pair still unmounts"
pass "half the required pair falls back and unmounts"
# An empty drive labeled cidata is not an autoinstall drive either.
new_sandbox
attach_drive cidata
! run_load || fail "empty drive exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "empty drive still unmounts"
pass "empty drive falls back and unmounts"
# A drive that will not mount falls back rather than failing the boot.
new_sandbox
attach_drive cidata
write_required_pair
! MOUNT_FAIL=1 run_load || fail "mount failure exits non-zero"
! grep -q '^umount ' "$TEST_LOG" || fail "mount failure has nothing to unmount"
pass "mount failure falls back to the wizard"
# A copy failure must not report a loaded drive: the install would start with
# missing inputs. Fall back and let the wizard produce them instead.
new_sandbox
attach_drive cidata
write_required_pair
chmod 555 "$sandbox/root"
! run_load 2>/dev/null || fail "copy failure exits non-zero"
grep -q '^umount ' "$TEST_LOG" || fail "copy failure still unmounts"
pass "copy failure falls back and unmounts"
# A partial copy must clean up after itself: the wizard removes only what it
# writes, so anything the loader left behind would leak into the interactive
# install that follows.
new_sandbox
attach_drive cidata
write_required_pair
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
mkdir "$sandbox/root/user_credentials.json"
! run_load 2>/dev/null || fail "partial copy exits non-zero"
[[ ! -e $sandbox/root/user_configuration.json ]] || fail "partial copy removes what it copied"
[[ ! -e $sandbox/root/authorized_keys ]] || fail "partial copy leaves no authorized_keys behind"
pass "partial copy cleans up what it copied"
-153
View File
@@ -1,153 +0,0 @@
"""Unit tests for the orchestrator's configure_ssh_access phase.
The phase touches the target only through the filesystem and arch-chroot, so
the tests run it against a temp directory with subprocess.run recorded and the
ufw side effect (writing user.rules) simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_ssh_access
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n"
class ConfigureSshAccessTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authorized_keys=None):
authorized_keys_path = None
if authorized_keys is not None:
authorized_keys_path = self.target / "authorized_keys"
authorized_keys_path.write_text(authorized_keys)
return types.SimpleNamespace(target=self.target, username="jeff", authorized_keys_path=authorized_keys_path)
def configure(self, **kwargs):
phases_impl.configure_ssh_access(self.ctx(**kwargs))
def authorized_keys(self):
return self.target / "home" / "jeff" / ".ssh" / "authorized_keys"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authorized_keys_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "home" / "jeff" / ".ssh").exists())
def test_installs_keys_one_per_line(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\nssh-rsa BBBB jeff@work\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authorized_keys="# work laptop\n\n ssh-ed25519 AAAA jeff@host \n")
self.assertEqual(self.authorized_keys().read_text(), "ssh-ed25519 AAAA jeff@host\n")
def test_keys_with_options_pass_through(self):
key = 'command="/usr/bin/true",no-pty ssh-ed25519 AAAA jeff@host'
self.configure(authorized_keys=f"{key}\n")
self.assertEqual(self.authorized_keys().read_text(), f"{key}\n")
def test_ssh_dir_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
mode = stat.S_IMODE((self.target / "home" / "jeff" / ".ssh").stat().st_mode)
self.assertEqual(mode, 0o700)
def test_authorized_keys_is_private(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(stat.S_IMODE(self.authorized_keys().stat().st_mode), 0o600)
def test_chowns_ssh_dir_to_the_user(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("chown"), [
["arch-chroot", str(self.target), "chown", "-R", "jeff:jeff", "/home/jeff/.ssh"],
])
def test_enables_sshd(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable", "sshd.service"],
])
def test_allows_ssh_through_ufw_despite_chroot_exit_status(self):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "ssh"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for port 22"):
self.configure(authorized_keys="ssh-ed25519 AAAA jeff@host\n")
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="")
def test_blank_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="\n \n")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no SSH keys"):
self.configure(authorized_keys="# no keys here\n")
class OptionalPathTest(unittest.TestCase):
def test_unset_and_missing_paths_are_none(self):
from orchestrator.context import _optional_path
self.assertIsNone(_optional_path(None))
self.assertIsNone(_optional_path(""))
self.assertIsNone(_optional_path("/does/not/exist/authorized_keys"))
def test_existing_path_is_returned(self):
from orchestrator.context import _optional_path
with tempfile.NamedTemporaryFile() as f:
self.assertEqual(_optional_path(f.name), Path(f.name))
if __name__ == "__main__":
unittest.main()
-164
View File
@@ -1,164 +0,0 @@
"""Unit tests for the orchestrator's configure_tailscale phase.
Same harness as the SSH access tests: the phase touches the target only
through the filesystem and arch-chroot, so it runs against a temp directory
with subprocess.run recorded and the ufw side effect (writing user.rules)
simulated by the fake.
"""
import stat
import sys
import tempfile
import types
import unittest
from pathlib import Path
from subprocess import CompletedProcess
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso"))
# phases_impl imports the archinstall adapter at module scope, which pulls in
# the archinstall library that only exists on the live ISO. configure_tailscale
# never touches it, so stub the adapter out before the import.
sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter")
from orchestrator import phases_impl # noqa: E402
UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n"
class ConfigureTailscaleTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.target = Path(self.tmp.name)
self.calls = []
info_patch = mock.patch.object(phases_impl, "info")
info_patch.start()
self.addCleanup(info_patch.stop)
run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run)
run_patch.start()
self.addCleanup(run_patch.stop)
self.ufw_writes_rule = True
def fake_run(self, cmd, **kwargs):
self.calls.append(cmd)
if cmd[2] == "ufw":
if self.ufw_writes_rule:
rules = self.target / "etc" / "ufw" / "user.rules"
rules.parent.mkdir(parents=True, exist_ok=True)
rules.write_text(UFW_RULE)
# ufw cannot reach netfilter inside a chroot and exits non-zero
# even when it recorded the rule; the phase must not trust this.
return CompletedProcess(cmd, 1)
return CompletedProcess(cmd, 0)
def ctx(self, authkey=None, tailscale_installed=True):
authkey_path = None
if authkey is not None:
authkey_path = self.target / "tailscale_authkey"
authkey_path.write_text(authkey)
if tailscale_installed:
binary = self.target / "usr" / "bin" / "tailscale"
binary.parent.mkdir(parents=True, exist_ok=True)
binary.touch()
return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path)
def configure(self, **kwargs):
phases_impl.configure_tailscale(self.ctx(**kwargs))
def staged_key(self):
return self.target / "etc" / "tailscale" / "authkey"
def unit(self):
return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service"
def chrooted(self, program):
return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program]
def test_no_authkey_is_a_no_op(self):
self.configure()
self.assertEqual(self.calls, [])
self.assertFalse((self.target / "etc" / "tailscale").exists())
def test_stages_the_key(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_drops_blank_lines_and_comments(self):
self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n")
self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n")
def test_key_dir_and_file_are_root_private(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700)
self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600)
def test_installs_the_join_unit(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text)
# The key must not outlive a successful join, the unit must not run
# again after one, and cleanup must be sequenced after the join
# succeeds -- inside the script, not in ExecStartPost.
self.assertIn(
"ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;"
" do sleep 15; done; rm -f /etc/tailscale/authkey;"
" systemctl disable omarchy-tailscale-join.service'",
text,
)
def test_join_unit_does_not_hold_up_boot(self):
# A Type=oneshot wanted by multi-user.target holds the whole boot
# (SDDM included) hostage until the join finishes or times out --
# target units implicitly gain After= for their Wants=.
self.configure(authkey="tskey-auth-kFAKEKEY\n")
text = self.unit().read_text()
self.assertIn("Type=simple", text)
self.assertNotIn("Type=oneshot", text)
self.assertNotIn("TimeoutStartSec", text)
def test_unit_avoids_systemd_variable_expansion(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertNotIn("$", self.unit().read_text())
def test_enables_tailscaled_and_the_join(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("systemctl"), [
["arch-chroot", str(self.target), "systemctl", "enable",
"tailscaled.service", "omarchy-tailscale-join.service"],
])
def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
self.assertEqual(self.chrooted("ufw"), [
["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"],
])
def test_fails_when_ufw_does_not_record_the_rule(self):
self.ufw_writes_rule = False
with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"):
self.configure(authkey="tskey-auth-kFAKEKEY\n")
def test_fails_when_tailscale_is_not_on_the_target(self):
with self.assertRaisesRegex(RuntimeError, "not installed on the target"):
self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False)
def test_empty_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="")
def test_comment_only_file_fails_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "contains no auth key"):
self.configure(authkey="# no key here\n")
def test_multiple_keys_fail_the_phase(self):
with self.assertRaisesRegex(RuntimeError, "expected exactly one"):
self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n")
if __name__ == "__main__":
unittest.main()
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/python
import importlib.util
import re
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py"
SPEC = importlib.util.spec_from_file_location("installer_keyboard", MODULE_PATH)
KEYBOARD = importlib.util.module_from_spec(SPEC)
assert SPEC.loader
SPEC.loader.exec_module(KEYBOARD)
CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text()
KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL)
assert KEYBOARD_BLOCK
SUPPORTED_KEYMAPS = [
line.split("|", 1)[1]
for line in KEYBOARD_BLOCK.group(1).splitlines()
]
UNSUPPORTED_KEYMAPS = {"ba", "khmer"}
class KeyboardConfigurationTest(unittest.TestCase):
def target(self, directory: str) -> Path:
target = Path(directory)
(target / "etc").mkdir()
(target / "etc/vconsole.conf").write_text(
"KEYMAP=us\nFONT=default8x16\n"
)
return target
def test_writes_keymap_and_xkb_settings_and_preserves_font(self):
# XKBLAYOUT is load-bearing: omarchy's detect-keyboard-layout.sh copies
# it into Hyprland's kb_layout on the installed system.
with tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertTrue(KEYBOARD.configure_keyboard(target, "us"))
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn("KEYMAP=us", lines)
self.assertIn("XKBLAYOUT=us", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_all_configurator_keymaps_are_known_to_localectl(self):
for keymap in SUPPORTED_KEYMAPS:
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
configured = KEYBOARD.configure_keyboard(target, keymap)
self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS)
if configured:
lines = (target / "etc/vconsole.conf").read_text().splitlines()
self.assertIn(f"KEYMAP={keymap}", lines)
self.assertEqual(lines.count("FONT=default8x16"), 1)
def test_unknown_and_empty_keymaps_match_archinstall_behavior(self):
for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):
with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory:
target = self.target(directory)
self.assertEqual(KEYBOARD.configure_keyboard(target, keymap), expected)
self.assertEqual(
(target / "etc/vconsole.conf").read_text(),
"KEYMAP=us\nFONT=default8x16\n",
)
if __name__ == "__main__":
unittest.main()
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/python
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRUNER = ROOT / "builder/prune-offline-mirror.sh"
class OfflineMirrorPruningTest(unittest.TestCase):
def run_pruner(self, mirror: Path, required: list[str]):
return subprocess.run(
["bash", str(PRUNER), str(mirror)],
input="".join(f"{filename}\n" for filename in required),
capture_output=True,
text=True,
)
def add_file(self, mirror: Path, filename: str):
(mirror / filename).write_bytes(b"package")
def test_keeps_only_exactly_selected_package_archives(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
keep = "keep-2.0-1-x86_64.pkg.tar.zst"
other = "other-1.0-1-any.pkg.tar.zst"
stale_version = "keep-1.0-1-x86_64.pkg.tar.zst"
removed_package = "removed-1.0-1-x86_64.pkg.tar.zst"
orphan_signature = "interrupted-1.0-1-x86_64.pkg.tar.zst.sig"
for filename in (keep, other, stale_version, removed_package):
self.add_file(mirror, filename)
for filename in (keep, stale_version, removed_package):
self.add_file(mirror, f"{filename}.sig")
self.add_file(mirror, orphan_signature)
self.add_file(mirror, "offline.db.tar.gz")
result = self.run_pruner(mirror, [keep, other])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
{path.name for path in mirror.iterdir()},
{keep, f"{keep}.sig", other, "offline.db.tar.gz"},
)
self.assertIn(stale_version, result.stdout)
self.assertIn(removed_package, result.stdout)
def test_missing_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(
mirror, ["missing-1.0-1-x86_64.pkg.tar.zst"]
)
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("selected packages are missing", result.stderr)
def test_empty_selection_aborts_before_deleting_anything(self):
with tempfile.TemporaryDirectory() as directory:
mirror = Path(directory)
cached = "cached-1.0-1-x86_64.pkg.tar.zst"
self.add_file(mirror, cached)
result = self.run_pruner(mirror, [])
self.assertNotEqual(result.returncode, 0)
self.assertTrue((mirror / cached).exists())
self.assertIn("empty package selection", result.stderr)
if __name__ == "__main__":
unittest.main()