diff --git a/bin/omarchy-iso-boot b/bin/omarchy-iso-boot index 4cd7436..eb2bbca 100755 --- a/bin/omarchy-iso-boot +++ b/bin/omarchy-iso-boot @@ -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 < 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[@]}" diff --git a/bin/omarchy-vm b/bin/omarchy-vm index f5cf601..ef2418f 100755 --- a/bin/omarchy-vm +++ b/bin/omarchy-vm @@ -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" diff --git a/bin/test-installer-ui-flow b/bin/test-installer-ui-flow deleted file mode 100755 index 592bb9a..0000000 --- a/bin/test-installer-ui-flow +++ /dev/null @@ -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" <>"\$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" <"$fail_worker" </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" diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 84082c4..5c6ea37 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -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 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 diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index a61e2ee..2e3dc2f 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -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[@]}" >(sed -u 's/\x1b\[[0-9;?]*[A-Za-z]//g' >>"$LOG_FILE") 2>&1 & + PYTHONUNBUFFERED=1 setsid "${INSTALL_CMD[@]}" >(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 diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py index 6e2afe7..e46ace1 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py @@ -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: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 020ce02..a46c590 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -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), ] diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 207dca2..3eeed88 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -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)