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

@@ -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)