Improve ISO installer flow and VM tooling

This commit is contained in:
Ryan Hughes committed 2026-05-25 19:54:12 -04:00
1 parent c50d0bc299
commit afa8293b2b
8 files changed
+807 -246

No files matched your search

+119 -12
View File
@@ -2,28 +2,135 @@
set -e
ISO="$1"
REUSE="$2"
ISO=""
REUSE=""
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=()
if [ $# -eq 0 ]; then
usage() {
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")
if gum confirm --default=false "Reuse existing disk?"; then
REUSE="reuse"
fi
fi
if [ -z "$ISO" ]; then
echo "Usage: omarchy-iso-boot [release/omarchy.iso] [reuse]"
echo " SSH available at: ssh -p 2222 root@localhost (after installing openssh)"
if [[ -z $ISO ]]; then
usage
exit 1
fi
if [[ -z $DISK || -z $OVMF_VARS ]]; then
echo "Disk and OVMF vars paths must not be empty" >&2
exit 1
fi
omarchy-pkg-add qemu-full edk2-ovmf
DISK="/tmp/omarchy-iso-boot.qcow2"
if [[ ! -f $DISK || $REUSE != "reuse" ]]; then
qemu-img create -f qcow2 "$DISK" 40G
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd /tmp/OVMF_VARS.4m.fd
cp /usr/share/edk2/x64/OVMF_VARS.4m.fd "$OVMF_VARS"
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
# Boot from ISO before we've installed, then boot from drive after
@@ -32,7 +139,7 @@ qemu-system-x86_64 \
-smp "$(nproc)" \
-m 8192 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
-drive if=pflash,format=raw,file=/tmp/OVMF_VARS.4m.fd \
-drive if=pflash,format=raw,file="$OVMF_VARS" \
-drive file="$DISK",format=qcow2,if=none,id=drive0 \
-device virtio-blk-pci,drive=drive0,bootindex=1 \
-drive file="$ISO",media=cdrom,if=none,format=raw,id=cdrom0 \
@@ -40,6 +147,6 @@ qemu-system-x86_64 \
-device virtio-vga-gl \
-display gtk,gl=on \
-usb -device usb-tablet \
-netdev user,id=net0,hostfwd=tcp::2222-:22 \
-device virtio-net-pci,netdev=net0 \
-boot menu=on
"${NET_ARGS[@]}" \
-boot menu=on \
"${EXTRA_QEMU_ARGS[@]}"
+68 -15
View File
@@ -4,8 +4,8 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VM_DIR="$(dirname "$SCRIPT_DIR")/vm-saves"
TMP_DISK="/tmp/omarchy-iso-boot.qcow2"
TMP_OVMF="/tmp/OVMF_VARS.4m.fd"
TMP_DISK="${OMARCHY_VM_DISK:-/tmp/omarchy-iso-boot.qcow2}"
TMP_OVMF="${OMARCHY_VM_OVMF_VARS:-/tmp/OVMF_VARS.4m.fd}"
mkdir -p "$VM_DIR"
@@ -18,7 +18,7 @@ save_vm() {
fi
if [[ ! -f "$TMP_DISK" || ! -f "$TMP_OVMF" ]]; then
echo "Error: No active VM found in /tmp"
echo "Error: No active VM found at $TMP_DISK / $TMP_OVMF"
echo "Boot a VM first with: omarchy-iso-boot [iso]"
exit 1
fi
@@ -53,7 +53,44 @@ list_vms() {
}
boot_vm() {
local name="$1"
local name=""
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
echo "No VM snapshots available"
@@ -73,25 +110,34 @@ boot_vm() {
exit 1
fi
if [[ -f "$TMP_DISK" ]] && ! gum confirm "Override existing /tmp VM?"; then
if [[ -z $active_disk || -z $active_ovmf ]]; 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
fi
echo "Copying VM '$name' to /tmp..."
cp "$vm_path/omarchy-iso-boot.qcow2" "$TMP_DISK"
cp "$vm_path/OVMF_VARS.4m.fd" "$TMP_OVMF"
echo "Copying VM '$name' to $active_disk..."
mkdir -p "$(dirname "$active_disk")" "$(dirname "$active_ovmf")"
cp "$vm_path/omarchy-iso-boot.qcow2" "$active_disk"
cp "$vm_path/OVMF_VARS.4m.fd" "$active_ovmf"
gum style --foreground 212 "✓ VM ready. Booting..."
exec omarchy-iso-boot /dev/null reuse
exec omarchy-iso-boot "${boot_args[@]}" /dev/null reuse
}
case "${1:-}" in
command="${1:-}"
[[ $# -gt 0 ]] && shift
case "$command" in
save)
save_vm "$2"
save_vm "${1:-}"
;;
boot)
boot_vm "$2"
boot_vm "$@"
;;
list)
list_vms
@@ -101,9 +147,16 @@ case "${1:-}" in
"$(gum style --bold 'omarchy-vm') - VM snapshot manager" \
"" \
"$(gum style --foreground 245 'Commands:')" \
" save [name] Save current VM from /tmp" \
" boot [name] Boot saved VM (interactive if no name)" \
" list List available VMs" \
" save [name] Save current active VM" \
" boot [name] [boot-options] Boot saved VM (interactive if no name)" \
" 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:')"
list_vms 2>/dev/null || echo " None"
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env bash
# Local smoke test for the foreground installer UI supervisor.
# Runs without rebuilding the ISO.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DASHBOARD="$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard"
AUTOMATED="$ROOT/configs/airootfs/root/.automated_script.sh"
ORCH="$ROOT/configs/airootfs/usr/share/omarchy-iso/orchestrator"
OMARCHY_PATH_FOR_TEST="${OMARCHY_PATH_FOR_TEST:-$ROOT/../omarchy-installer}"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
strip_ansi() {
python - "$1" <<'PY'
from pathlib import Path
import re, sys
s = Path(sys.argv[1]).read_text(errors="ignore")
s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", s)
sys.stdout.write(s)
PY
}
assert_contains() {
local file="$1" needle="$2" stripped
stripped="$tmp/stripped.$(basename "$file")"
strip_ansi "$file" >"$stripped"
if ! grep -Fq "$needle" "$stripped"; then
echo "Expected $file to contain: $needle" >&2
exit 1
fi
}
assert_not_contains() {
local file="$1" needle="$2" stripped
stripped="$tmp/stripped.$(basename "$file")"
strip_ansi "$file" >"$stripped"
if grep -Fq "$needle" "$stripped"; then
echo "Expected $file not to contain: $needle" >&2
exit 1
fi
}
write_state_py='import json, sys, time, pathlib
path=pathlib.Path(sys.argv[1]); phase=sys.argv[2]
state={"started_at":time.time(),"current_phase":phase,"phase_started_at":time.time(),"phases":[]}
tmp=path.with_name(".state.json.tmp")
tmp.write_text(json.dumps(state))
tmp.replace(path)'
success_worker="$tmp/success-worker"
cat >"$success_worker" <<EOF
#!/usr/bin/env bash
set -euo pipefail
state="\$1"; log="\$2"
mkdir -p "\$(dirname "\$state")" "\$(dirname "\$log")"
python - "\$state" "Preparing live environment" <<'PY'
$write_state_py
PY
sleep 0.2
python - "\$state" "Installing Arch + Omarchy" <<'PY'
$write_state_py
PY
sleep 0.4
python - "\$state" "Validating boot setup" <<'PY'
$write_state_py
PY
sleep 0.2
cat >>"\$log" <<'LOG'
=== Installation Time Summary ===
Omarchy: 1m 40s
=================================
LOG
EOF
chmod +x "$success_worker"
no_duration_worker="$tmp/no-duration-worker"
cat >"$no_duration_worker" <<EOF
#!/usr/bin/env bash
set -euo pipefail
state="\$1"
mkdir -p "\$(dirname "\$state")"
python - "\$state" "Validating boot setup" <<'PY'
$write_state_py
PY
sleep 0.2
EOF
chmod +x "$no_duration_worker"
fail_worker="$tmp/fail-worker"
cat >"$fail_worker" <<EOF
#!/usr/bin/env bash
set -euo pipefail
state="\$1"
mkdir -p "\$(dirname "\$state")"
python - "\$state" "Finalizing in chroot" <<'PY'
$write_state_py
PY
echo "Syncing the system..."
echo "Installation completed without any errors."
printf '\033[?25h'
echo "You may reboot when ready."
sleep 0.2
exit 7
EOF
chmod +x "$fail_worker"
success_state="$tmp/success-state.json"
success_log="$tmp/success.log"
success_capture="$tmp/success.capture"
OMARCHY_UI_AUTO_REBOOT=no OMARCHY_PATH="$OMARCHY_PATH_FOR_TEST" script -qfec \
"$DASHBOARD '$success_log' '$success_state' -- '$success_worker' '$success_state' '$success_log'" \
"$success_capture" >/dev/null
assert_contains "$success_capture" "Installing Omarchy"
assert_contains "$success_capture" "Installed in 1m 40s"
no_duration_state="$tmp/no-duration-state.json"
no_duration_log="$tmp/no-duration.log"
no_duration_capture="$tmp/no-duration.capture"
OMARCHY_UI_AUTO_REBOOT=no OMARCHY_PATH="$OMARCHY_PATH_FOR_TEST" script -qfec \
"$DASHBOARD '$no_duration_log' '$no_duration_state' -- '$no_duration_worker' '$no_duration_state'" \
"$no_duration_capture" >/dev/null
assert_contains "$no_duration_capture" "Finished installing"
fail_state="$tmp/fail-state.json"
fail_log="$tmp/fail.log"
fail_capture="$tmp/fail.capture"
set +e
OMARCHY_UI_AUTO_REBOOT=no OMARCHY_PATH="$OMARCHY_PATH_FOR_TEST" script -qfec \
"$DASHBOARD '$fail_log' '$fail_state' -- '$fail_worker' '$fail_state'" \
"$fail_capture" >/dev/null
fail_status=$?
set -e
if [[ $fail_status -ne 7 ]]; then
echo "Expected failure worker status 7, got $fail_status" >&2
exit 1
fi
assert_contains "$fail_log" "Installation completed without any errors."
assert_contains "$fail_log" "[dashboard] installer child exited with status 7"
assert_contains "$fail_log" "[dashboard] last installer phase: Finalizing in chroot"
python - "$fail_log" <<'PY'
from pathlib import Path
import sys
if "\x1b[?25h" in Path(sys.argv[1]).read_text(errors="ignore"):
raise SystemExit("CSI cursor sequence leaked into log")
PY
assert_not_contains "$fail_capture" "Installed in"
assert_not_contains "$fail_capture" "Finished installing"
bash -n "$DASHBOARD" "$AUTOMATED"
python -m py_compile "$ORCH"/*.py
git -C "$ROOT" diff --check
if rg -n 'dashboard\.pid|dashboard\.stop|OMARCHY_INSTALL_DASHBOARD|_stop_install_dashboard|_dashboard_|killpg|current_phase.*Finishing|confirm\(|Reboot now' \
"$AUTOMATED" "$DASHBOARD" "$ORCH"; then
echo "Forbidden legacy dashboard lifecycle pattern found" >&2
exit 1
fi
if ! grep -Fq '/usr/local/bin/omarchy-install-dashboard \' "$AUTOMATED"; then
echo "Expected .automated_script.sh to call dashboard in foreground" >&2
exit 1
fi
if grep -Eq 'omarchy-install-dashboard .*&' "$AUTOMATED"; then
echo "Dashboard must not be launched in background" >&2
exit 1
fi
echo "installer UI flow smoke test passed"
+279 -31
View File
@@ -1,20 +1,276 @@
#!/bin/bash
# The configurator normally runs under this small PTY proxy so Ctrl-T can work
# as a global tools shortcut even while gum owns the terminal in raw mode. The
# child configurator keeps stdout on the existing stream (so generated config and
# logs behave normally), while stderr and /dev/tty traffic are relayed through the
# PTY. Pressing Ctrl-T kills the current configurator screen, opens Tools, then
# starts the configurator over at keyboard selection.
if [[ -z "${OMARCHY_CONFIGURATOR_INNER:-}" && -z "${OMARCHY_CONFIGURATOR_NO_KEY_PROXY:-}" ]] && { [[ -t 0 ]] || [[ -t 2 ]]; }; then
OMARCHY_CONFIGURATOR_PYTHON=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)
if [[ -n "$OMARCHY_CONFIGURATOR_PYTHON" ]]; then
export OMARCHY_CONFIGURATOR_SCRIPT
OMARCHY_CONFIGURATOR_SCRIPT=$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || printf '%s' "${BASH_SOURCE[0]}")
exec "$OMARCHY_CONFIGURATOR_PYTHON" - "$@" <<'PY'
import errno
import fcntl
import os
import pty
import select
import signal
import sys
import termios
import time
CTRL_T = b"\x14"
SCRIPT = os.environ["OMARCHY_CONFIGURATOR_SCRIPT"]
ARGS = sys.argv[1:]
try:
tty_fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
except OSError:
env = os.environ.copy()
env["OMARCHY_CONFIGURATOR_INNER"] = "1"
os.execvpe("bash", ["bash", SCRIPT] + ARGS, env)
stdout_fd = os.dup(1)
original_tty = termios.tcgetattr(tty_fd)
current_master = None
current_pgid = None
def restore_tty():
try:
termios.tcsetattr(tty_fd, termios.TCSADRAIN, original_tty)
except termios.error:
pass
def write_tty(data):
if data:
os.write(tty_fd, data)
def copy_window_size(master):
try:
size = fcntl.ioctl(tty_fd, termios.TIOCGWINSZ, b"\0" * 8)
fcntl.ioctl(master, termios.TIOCSWINSZ, size)
except OSError:
pass
def on_winch(signum, frame):
if current_master is not None:
copy_window_size(current_master)
if current_pgid is not None:
try:
os.killpg(current_pgid, signal.SIGWINCH)
except OSError:
pass
def on_termination(signum, frame):
if current_pgid is not None:
try:
os.killpg(current_pgid, signum)
except OSError:
pass
restore_tty()
raise SystemExit(128 + signum)
def exit_code(status):
if os.WIFEXITED(status):
return os.WEXITSTATUS(status)
if os.WIFSIGNALED(status):
return 128 + os.WTERMSIG(status)
return 1
def spawn_child(tools_only=False):
global current_master, current_pgid
pid, master = pty.fork()
if pid == 0:
os.dup2(stdout_fd, 1)
try:
os.close(stdout_fd)
except OSError:
pass
env = os.environ.copy()
env["OMARCHY_CONFIGURATOR_INNER"] = "1"
env["OMARCHY_CONFIGURATOR_CTRL_T"] = "1"
if tools_only:
env["OMARCHY_CONFIGURATOR_TOOLS_ONLY"] = "1"
else:
env.pop("OMARCHY_CONFIGURATOR_TOOLS_ONLY", None)
os.execvpe("bash", ["bash", SCRIPT] + ARGS, env)
os.set_blocking(master, False)
current_master = master
current_pgid = pid
copy_window_size(master)
return pid, master
def wait_for_child(pid, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
try:
done, status = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
return 0
if done == pid:
return exit_code(status)
time.sleep(0.05)
return None
def terminate_child(pid):
for sig, timeout in ((signal.SIGTERM, 0.5), (signal.SIGKILL, 0.2)):
try:
os.killpg(pid, sig)
except ProcessLookupError:
return 0
except OSError:
try:
os.kill(pid, sig)
except OSError:
return 0
code = wait_for_child(pid, timeout)
if code is not None:
return code
return 130
def drain_master(master):
while True:
try:
data = os.read(master, 4096)
except BlockingIOError:
return
except OSError:
return
if not data:
return
write_tty(data)
def relay_child(tools_only=False):
global current_master, current_pgid
pid, master = spawn_child(tools_only)
try:
while True:
try:
done, status = os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
drain_master(master)
return 0
if done == pid:
drain_master(master)
return exit_code(status)
try:
readable, _, _ = select.select([tty_fd, master], [], [], 0.1)
except InterruptedError:
continue
if tty_fd in readable:
try:
data = os.read(tty_fd, 1024)
except OSError:
data = b""
if not data:
continue
if not tools_only and CTRL_T in data:
before = data.split(CTRL_T, 1)[0]
if before:
try:
os.write(master, before)
except OSError:
pass
terminate_child(pid)
drain_master(master)
return "tools"
try:
os.write(master, data)
except OSError:
pass
if master in readable:
try:
data = os.read(master, 4096)
except BlockingIOError:
continue
except OSError as exc:
if exc.errno != errno.EIO:
raise
data = b""
if data:
write_tty(data)
finally:
current_master = None
current_pgid = None
try:
os.close(master)
except OSError:
pass
signal.signal(signal.SIGWINCH, on_winch)
signal.signal(signal.SIGHUP, on_termination)
signal.signal(signal.SIGTERM, on_termination)
# Use raw-ish input so Ctrl-T/Ctrl-C can be intercepted and relayed, but keep
# the real terminal's output processing intact. Full raw mode disables ONLCR,
# causing gum/logo newlines to stair-step across the screen.
tty_attrs = termios.tcgetattr(tty_fd)
tty_attrs[0] &= ~(termios.BRKINT | termios.ICRNL | termios.INPCK | termios.ISTRIP | termios.IXON)
tty_attrs[2] |= termios.CS8
tty_attrs[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
tty_attrs[6][termios.VMIN] = 1
tty_attrs[6][termios.VTIME] = 0
termios.tcsetattr(tty_fd, termios.TCSADRAIN, tty_attrs)
try:
while True:
result = relay_child(tools_only=False)
if result == "tools":
relay_child(tools_only=True)
continue
raise SystemExit(result)
finally:
restore_tty()
try:
os.close(stdout_fd)
os.close(tty_fd)
except OSError:
pass
PY
fi
fi
unset OMARCHY_CONFIGURATOR_PYTHON
# Minimal live-ISO presentation helpers. Keep these local to the ISO: the
# installed Omarchy package owns target setup helpers, while the ISO owns the
# installer UI that runs before any target packages exist.
OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
LOGO_PATH="${LOGO_PATH:-$OMARCHY_PATH/logo.txt}"
TERM_HEIGHT=${LINES:-24}
TERM_WIDTH=${COLUMNS:-80}
if [[ -e /dev/tty ]]; then
TERM_SIZE=$(stty size 2>/dev/null </dev/tty || true)
fi
if [[ -n ${TERM_SIZE:-} ]]; then
TERM_HEIGHT=${TERM_SIZE%% *}
TERM_WIDTH=${TERM_SIZE##* }
else
TERM_HEIGHT=${LINES:-24}
TERM_WIDTH=${COLUMNS:-80}
if [[ $TERM_SIZE =~ ^([0-9]+)[[:space:]]+([0-9]+)$ ]] && (( BASH_REMATCH[1] > 0 && BASH_REMATCH[2] > 0 )); then
TERM_HEIGHT=${BASH_REMATCH[1]}
TERM_WIDTH=${BASH_REMATCH[2]}
fi
fi
if [[ -f $LOGO_PATH ]]; then
@@ -60,10 +316,20 @@ abort() {
exit 1
}
tools_hint() {
[[ -n "${OMARCHY_CONFIGURATOR_CTRL_T:-}" && -z "${OMARCHY_CONFIGURATOR_TOOLS_ONLY:-}" ]] || return 0
# Draw the shortcut as a fixed footer instead of in the form body. Keep the
# cursor where the active gum widget expects it; do not print a newline from
# the bottom row or the VT will scroll.
printf "\0337\033[%s;1H\033[2K%s\033[38;5;8mCtrl-T Tools\033[0m\0338" "$TERM_HEIGHT" "$PADDING_LEFT_SPACES"
}
step() {
clear_logo
echo
gum style --padding "0 0 0 $PADDING_LEFT" "$1"
tools_hint
echo
}
@@ -80,8 +346,8 @@ notice() {
}
# Tools menu — recovery-style actions kept off the main install path.
# Inlines its own disk picker so the launcher can run before disk_form is
# defined further down in the script.
# Inlines its own disk picker so Ctrl-T can open it before disk_form is defined
# further down in the script.
tools_menu() {
while true; do
step "Tools"
@@ -126,28 +392,10 @@ tools_menu() {
done
}
# Landing screen: Enter installs, Ctrl-T opens the tools menu.
launcher_form() {
while true; do
step "Install Omarchy"
say "Press Enter to install Omarchy on this machine."
echo
say --foreground 8 "Ctrl-T Tools"
local key
IFS= read -rsn1 key || true
case "$key" in
""|$'\n'|$'\r')
return 0
;;
$'\x14')
tools_menu
;;
esac
done
}
launcher_form
if [[ -n "${OMARCHY_CONFIGURATOR_TOOLS_ONLY:-}" ]]; then
tools_menu
exit 0
fi
# STEP 1: KEYBOARD LAYOUT
@@ -32,8 +32,17 @@ CLEAR="${CSI}2J${CSI}H"
CLEAR_LINE="${CSI}2K"
CLEAR_TO_END="${CSI}J"
GREEN="${CSI}32m"
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=""
@@ -206,6 +215,7 @@ weights = [
('Finalizing user', 96, 98, 10),
('Finalizing in chroot', 88, 98, 10),
('Configuring login', 98, 99, 3),
('Configuring DNS resolver', 99, 100, 2),
('Validating protected boot setup', 99, 100, 5),
('Validating boot setup', 99, 100, 5),
]
@@ -268,6 +278,30 @@ render_dynamic() {
install_duration() {
local path line duration
if [[ -f $STATE_FILE ]]; then
duration="$(python - "$STATE_FILE" <<'PY' 2>/dev/null || true
import json, sys
try:
state = json.load(open(sys.argv[1]))
started = float(state.get("started_at") or 0)
finished = float(state.get("finished_at") or 0)
except Exception:
raise SystemExit(0)
if started <= 0 or finished <= 0 or finished < started:
raise SystemExit(0)
seconds = int(round(finished - started))
hours, remainder = divmod(seconds, 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
print(f"{hours}h {minutes}m {seconds}s", end="")
else:
print(f"{minutes}m {seconds}s", end="")
PY
)"
[[ -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
@@ -313,10 +347,185 @@ reboot_prompt() {
"" <"$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
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.
setsid "${INSTALL_CMD[@]}" </dev/null > >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$LOG_FILE") 2>&1 &
PYTHONUNBUFFERED=1 setsid "${INSTALL_CMD[@]}" </dev/null > >(sanitize_child_output >>"$LOG_FILE") 2>&1 &
child_pid=$!
child_pgid=$child_pid
}
@@ -343,8 +552,10 @@ child_pgid=""
dashboard_log "installer child exited with status $status"
if (( status != 0 )); then
failure_status="$status"
failure_summary=""
if [[ -f $STATE_FILE ]]; then
state_summary="$(python - "$STATE_FILE" <<'PY' 2>/dev/null || true
failure_summary="$(python - "$STATE_FILE" <<'PY' 2>/dev/null || true
import json, sys
try:
state = json.load(open(sys.argv[1]))
@@ -359,14 +570,15 @@ for item in reversed(state.get("phases", [])):
break
PY
)"
if [[ -n ${state_summary:-} ]]; then
if [[ -n $failure_summary ]]; then
while IFS= read -r line; do
[[ -n $line ]] && dashboard_log "$line"
done <<<"$state_summary"
done <<<"$failure_summary"
fi
fi
printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true
exit "$status"
render_failure "$failure_status" "$failure_summary" >"$TTY_PATH" 2>/dev/null || true
failure_menu
exit "$failure_status"
fi
set +e
@@ -34,6 +34,7 @@ from the start.
from __future__ import annotations
import importlib
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
@@ -144,6 +145,63 @@ 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_users(method) -> bool:
"""Return whether a bound archinstall method accepts the users 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 "users" in (*positional, *kwonly)
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:
@@ -36,6 +36,7 @@ def build_phases(ctx: InstallContext):
run_system_finalizer,
finalize_limine_boot,
run_chroot_finalizer,
configure_dns_resolver,
configure_login,
validate_boot,
)
@@ -49,6 +50,7 @@ def build_phases(ctx: InstallContext):
("Finalizing Limine boot", finalize_limine_boot),
("Finalizing user", run_chroot_finalizer),
("Configuring login", configure_login),
("Configuring DNS resolver", configure_dns_resolver),
("Validating boot setup", validate_boot),
]
@@ -14,7 +14,7 @@ Phase ordering (full-disk and protected/pre-mounted):
run_system_finalizer → arch-chroot root omarchy-setup-system
finalize_limine_boot → final Limine config/UKI build after hardware drop-ins
run_chroot_finalizer → arch-chroot -u user omarchy-setup-user
configure_login → sddm autologin for unencrypted installs
configure_login → sddm state + encrypted-install autologin
validate_boot → assert UKI / limine.conf / kernel cmdline are sane
"""
@@ -182,6 +182,10 @@ def arch_install_system(ctx: InstallContext) -> None:
if config.auth_config and config.auth_config.users:
installer.create_users(config.auth_config.users)
if config.app_config:
info("› installing archinstall application selections")
arch.install_applications(installer, config)
info("› installing Omarchy runtime + omarchy-base.packages")
installer.add_additional_packages(_runtime_package_list(ctx))
finally:
@@ -901,6 +905,29 @@ def _ensure_finalizer_log_started(ctx: InstallContext) -> tuple[str, int]:
return ctx.state["omarchy_start_time"], ctx.state["omarchy_start_epoch"]
def _target_user_env(ctx: InstallContext, user: str) -> list[str]:
home = f"/home/{user}"
shell = "/bin/bash"
passwd = ctx.target / "etc" / "passwd"
try:
for line in passwd.read_text(errors="ignore").splitlines():
fields = line.split(":")
if len(fields) >= 7 and fields[0] == user:
home = fields[5] or home
shell = fields[6] or shell
break
except OSError:
pass
return [
f"HOME={home}",
f"USER={user}",
f"LOGNAME={user}",
f"SHELL={shell}",
]
def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str | None = None) -> None:
_prepare_target_setup(ctx)
omarchy_start_time, omarchy_start_epoch = _ensure_finalizer_log_started(ctx)
@@ -929,6 +956,7 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
f"OMARCHY_USER_EMAIL={ctx.email}",
f"OMARCHY_MIRROR={mirror_channel}",
"OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log",
"OMARCHY_LOG_TO_STDOUT=1",
]
if _install_debug_enabled():
env_extras.append("OMARCHY_INSTALL_DEBUG=1")
@@ -937,6 +965,7 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str
chroot_cmd = ["arch-chroot"]
if user:
chroot_cmd += ["-u", user]
env_extras.extend(_target_user_env(ctx, user))
chroot_cmd += [str(ctx.target), "env", "--unset=XDG_RUNTIME_DIR", *env_extras, *cmd]
try:
@@ -1069,31 +1098,54 @@ def run_chroot_finalizer(ctx: InstallContext) -> None:
user=ctx.username,
)
def configure_dns_resolver(ctx: InstallContext) -> None:
"""Put the installed system in systemd-resolved stub mode.
Arch's systemd-resolved docs explicitly say not to create this symlink from
inside arch-chroot because /etc/resolv.conf may be a bind mount from the
live environment. Do it from the ISO against /mnt instead.
"""
resolv_conf = ctx.target / "etc" / "resolv.conf"
target = "../run/systemd/resolve/stub-resolv.conf"
if resolv_conf.is_symlink() and os.readlink(resolv_conf) == target:
return
info("› configuring /etc/resolv.conf for systemd-resolved")
resolv_conf.parent.mkdir(parents=True, exist_ok=True)
resolv_conf.unlink(missing_ok=True)
resolv_conf.symlink_to(target)
def _read_omarchy_mirror() -> str:
p = Path("/root/omarchy_mirror")
return p.read_text().strip() if p.exists() else "stable"
# ─────────────────────────────────────────────────────────────────────────────
# configure_login: sddm autologin for unencrypted installs only (encrypted
# installs already get a LUKS unlock prompt, no need for sddm autologin).
# configure_login: seed SDDM's last user/session for the password-only Omarchy
# greeter. Encrypted installs autologin because the LUKS prompt is the auth
# boundary; unencrypted installs leave SDDM as the auth screen.
# ─────────────────────────────────────────────────────────────────────────────
def configure_login(ctx: InstallContext) -> None:
if ctx.encrypt:
return
sddm_dir = ctx.target / "etc" / "sddm.conf.d"
sddm_dir.mkdir(parents=True, exist_ok=True)
(sddm_dir / "99-omarchy-login.conf").write_text(
"[Theme]\nCurrent=omarchy\n\n"
"[Users]\nRememberLastUser=true\nRememberLastSession=true\n"
)
(sddm_dir / "autologin.conf").write_text(
"[Autologin]\n"
f"User={ctx.username}\n"
"Session=omarchy.desktop\n"
)
autologin_conf = sddm_dir / "autologin.conf"
if ctx.encrypt:
autologin_conf.write_text(
"[Autologin]\n"
f"User={ctx.username}\n"
"Session=omarchy.desktop\n"
)
else:
autologin_conf.unlink(missing_ok=True)
state_dir = ctx.target / "var" / "lib" / "sddm"
state_dir.mkdir(parents=True, exist_ok=True)