From 0734e982ca3032c80d911841fb82a677e3c69da1 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 22 May 2026 19:15:06 -0400 Subject: [PATCH] installer: harden dashboard handoff --- configs/airootfs/root/.automated_script.sh | 36 +++- .../usr/local/bin/omarchy-install-dashboard | 131 ++++++--------- .../share/omarchy-iso/orchestrator/phases.py | 6 +- .../omarchy-iso/orchestrator/phases_impl.py | 155 ++++++++++++++++-- 4 files changed, 235 insertions(+), 93 deletions(-) diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 4660a31..8370658 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -45,19 +45,49 @@ cd /root # Keep the actual install screen calm: the dashboard owns /dev/tty while the # noisy installer stream is captured to the support log. -/usr/local/bin/omarchy-install-dashboard "$OMARCHY_INSTALL_LOG_FILE" /run/omarchy-install/state.json /mnt/var/log/omarchy-install.log >/dev/tty 2>&1 & +/usr/local/bin/omarchy-install-dashboard "$OMARCHY_INSTALL_LOG_FILE" /run/omarchy-install/state.json >/dev/tty 2>&1 & dashboard_pid=$! export OMARCHY_INSTALL_DASHBOARD_PID="$dashboard_pid" stop_install_dashboard() { if [[ -n ${dashboard_pid:-} ]]; then - kill "$dashboard_pid" 2>/dev/null || true + if kill -0 "$dashboard_pid" 2>/dev/null; then + kill "$dashboard_pid" 2>/dev/null || true + for _ in {1..20}; do + kill -0 "$dashboard_pid" 2>/dev/null || break + sleep 0.05 + done + if kill -0 "$dashboard_pid" 2>/dev/null; then + kill -9 "$dashboard_pid" 2>/dev/null || true + fi + fi wait "$dashboard_pid" 2>/dev/null || true unset dashboard_pid fi printf "\033[?25h" >/dev/tty } -trap stop_install_dashboard EXIT INT TERM +dashboard_error_handler() { + [[ $ERROR_HANDLING == "true" ]] && return + local exit_code=$? + stop_install_dashboard + catch_errors "$exit_code" +} + +dashboard_exit_handler() { + local exit_code=$? + stop_install_dashboard + if (( exit_code != 0 )) && [[ $ERROR_HANDLING != "true" ]]; then + catch_errors "$exit_code" + else + show_cursor + fi + exit "$exit_code" +} + +trap dashboard_error_handler ERR +trap dashboard_exit_handler EXIT +trap 'stop_install_dashboard; catch_errors 130' INT +trap 'stop_install_dashboard; catch_errors 143' TERM # Absolute paths because omarchy-iso-install cd's into /usr/share/omarchy-iso # before exec'ing python; relative paths would resolve against the wrong dir. diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index ace9131..b16e98f 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -1,27 +1,24 @@ #!/usr/bin/env bash # Render the live Omarchy install dashboard on tty1 while installer output is -# captured to logs. Intentionally has no external UI dependencies. +# captured to logs. This is presentation only: it must never read input or gate +# install completion. set -euo pipefail LOG_FILE="${1:-/var/log/omarchy-install.log}" STATE_FILE="${2:-/run/omarchy-install/state.json}" -TARGET_LOG_FILE="${3:-/mnt/var/log/omarchy-install.log}" LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt" CSI=$'\033[' RESET="${CSI}0m" -BOLD="${CSI}1m" DIM="${CSI}2m" HIDE_CURSOR="${CSI}?25l" SHOW_CURSOR="${CSI}?25h" CLEAR="${CSI}2J${CSI}H" -HOME="${CSI}H" CLEAR_LINE="${CSI}2K" CLEAR_TO_END="${CSI}J" # Use the 16-color ANSI palette configured by .automated_script.sh. The Linux # VT does not reliably map 256-color indexes the same way terminal emulators do. GREEN="${CSI}32m" -GRAY="${CSI}37m" DARK="${CSI}90m" whimsy=( @@ -46,20 +43,14 @@ whimsy=( cleanup() { printf '%s%s' "$RESET" "$SHOW_CURSOR" >/dev/tty 2>/dev/null || true } -trap cleanup EXIT INT TERM +trap cleanup EXIT +trap 'cleanup; exit 0' INT TERM -term_size() { - stty size 2>/dev/null /dev/null 0 )) || LOGO_WIDTH=81 +(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1 CONTENT_WIDTH="$LOGO_WIDTH" +DYNAMIC_ROW=$((LOGO_HEIGHT + 3)) +LAST_PCT=1 left_padding() { local width="${1:-$CONTENT_WIDTH}" cols pad @@ -90,9 +93,7 @@ visible_len() { printf '%s' "${#text}" } -blank_line() { - printf '\r%s\n' "$CLEAR_LINE" -} +blank_line() { printf '\r%s\n' "$CLEAR_LINE"; } line_at() { local width="${1:-$CONTENT_WIDTH}" indent="${2:-0}" @@ -134,50 +135,41 @@ install_progress() { [[ -f $STATE_FILE ]] || { printf '1'; return; } python - "$STATE_FILE" <<'PY' 2>/dev/null || printf '1' import json, sys, time - path = sys.argv[1] try: - with open(path) as f: - state = json.load(f) + state = json.load(open(path)) except Exception: print(1, end='') raise SystemExit - phase = state.get('current_phase') or 'Starting installation' if state.get('finished_at') or phase == 'Installation complete': print(100, end='') raise SystemExit - -# Internal weighting only. The user sees one Omarchy install bar, but the bar -# moves according to where the orchestrator is in the real install. +# Internal weighting only. Keep estimates conservative but close to observed +# ISO installs so the bar does not sit at 12% then jump to 90%. weights = [ - ('Starting installation', 1, 2, 3), - ('Preparing live environment', 2, 5, 10), - ('Verifying protected mounts', 5, 8, 10), - ('Installing Arch + Omarchy', 8, 86, 1500), - ('Installing base system', 8, 86, 1500), - ('Configuring protected boot', 86, 90, 20), - ('Finalizing in chroot', 90, 96, 180), - ('Configuring login', 96, 98, 10), - ('Validating protected boot setup', 98, 99, 10), - ('Validating boot setup', 98, 99, 10), - ('Finishing', 99, 100, 5), + ('Starting installation', 1, 2, 2), + ('Preparing live environment', 2, 6, 5), + ('Verifying protected mounts', 6, 8, 3), + ('Installing Arch + Omarchy', 8, 86, 80), + ('Installing base system', 8, 86, 80), + ('Configuring protected boot', 86, 90, 8), + ('Finalizing in chroot', 90, 96, 25), + ('Configuring login', 96, 97, 3), + ('Validating protected boot setup', 97, 99, 5), + ('Validating boot setup', 97, 99, 5), + ('Finishing', 99, 100, 3), ] - -start, end, expected = 1, 99, 600 +start, end, expected = 1, 99, 90 for name, s, e, seconds in weights: if phase == name: start, end, expected = s, e, seconds break - phase_started = state.get('phase_started_at') or state.get('started_at') or time.time() elapsed = max(0, time.time() - float(phase_started)) span = max(0, end - start) -# Move monotonically through most of the current phase, but leave the last -# point for the actual phase transition so the bar never needs to go backward. active = min(max(span - 1, 0), int((elapsed / max(expected, 1)) * span)) -pct = min(100, start + active) -print(pct, end='') +print(min(100, start + active), end='') PY } @@ -193,62 +185,45 @@ raise SystemExit(0 if state.get('finished_at') else 1) PY } -last_log_line() { - local file line files - if [[ -s $TARGET_LOG_FILE && $TARGET_LOG_FILE -nt $LOG_FILE ]]; then - files=("$TARGET_LOG_FILE" "$LOG_FILE") - else - files=("$LOG_FILE" "$TARGET_LOG_FILE") - fi - - for file in "${files[@]}"; do - [[ -s $file ]] || continue - line="$(grep -avE '^(\s*|===|\[.*\] (Starting|Completed):)' "$file" 2>/dev/null | tail -n 1 || true)" - if [[ -n $line ]]; then - line="${line//$'\r'/}" - line="$(printf '%s' "$line" | sed -E 's/\x1b\[[0-9;?]*[A-Za-z]//g')" - printf '%s' "${line:0:$((CONTENT_WIDTH - 8))}" - return - fi - done - printf 'starting installer' -} - current_whimsy() { local frame="$1" pct="$2" printf '%s' "${whimsy[$(( (pct / 8 + frame / 8) % ${#whimsy[@]} ))]}" } -render() { - local frame="$1" pct spin log_line - pct="$(install_progress)" - [[ $pct =~ ^[0-9]+$ ]] || pct=1 - (( pct > 100 )) && pct=100 - spin=("◐" "◓" "◑" "◒") - log_line="$(last_log_line)" - - printf '%s' "$HOME" +render_static() { + printf '%s' "$CLEAR" blank_line render_logo blank_line +} + +render_dynamic() { + local frame="$1" pct spin + pct="$(install_progress)" + [[ $pct =~ ^[0-9]+$ ]] || pct=$LAST_PCT + (( pct > 100 )) && pct=100 + (( pct < LAST_PCT )) && pct=$LAST_PCT + LAST_PCT=$pct + spin=("◐" "◓" "◑" "◒") + + printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW" center "Installing Omarchy" "$CONTENT_WIDTH" blank_line line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 40) / 2 )) "" progress_bar "$pct" 34 printf '\n' blank_line - center "Now: $log_line" "$CONTENT_WIDTH" - blank_line center "${GREEN}${spin[$((frame % 4))]} $(current_whimsy "$frame" "$pct") ${RESET}" "$CONTENT_WIDTH" blank_line center "${DIM}Logs are being saved for support${RESET}" "$CONTENT_WIDTH" printf '%s' "$CLEAR_TO_END" } -printf '%s%s' "$HIDE_CURSOR" "$CLEAR" >/dev/tty +printf '%s' "$HIDE_CURSOR" >/dev/tty +render_static >/dev/tty frame=0 while true; do - render "$frame" >/dev/tty + render_dynamic "$frame" >/dev/tty if install_finished; then sleep 1 break diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py index ffbd8fd..79cae4b 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py @@ -67,4 +67,8 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: def _write_state(path: Path, state: dict) -> None: - path.write_text(json.dumps(state, indent=2, default=str)) + # 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) 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 deb003b..7bbd97e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -820,7 +820,7 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: subprocess.run(["mount", "--bind", src, str(target_dst)], check=True) ctx.state["bind_mounts"].append(str(target_dst)) - # 3: sudoers shim. Cleaned up by omarchy's first-run flow. + # 3: sudoers shim. Removed after finalize.sh returns. sudoers = ctx.target / "etc" / "sudoers.d" / "99-omarchy-installer" sudoers.parent.mkdir(parents=True, exist_ok=True) sudoers.write_text( @@ -871,6 +871,7 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: mirror_channel = _read_omarchy_mirror() env_extras = [ "OMARCHY_INSTALL_MODE=offline", + "OMARCHY_CHROOT_FINALIZER=1", "OMARCHY_PATH=/usr/share/omarchy", f"OMARCHY_INSTALL={tooling_path / 'install'}", f"OMARCHY_START_TIME={omarchy_start_time}", @@ -890,7 +891,10 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: *env_extras, "/bin/bash", str(tooling_path / "finalize.sh"), ] - subprocess.run(cmd, check=True) + try: + subprocess.run(cmd, check=True) + finally: + sudoers.unlink(missing_ok=True) def _read_omarchy_mirror() -> str: @@ -1047,16 +1051,77 @@ def cleanup_protected_state(ctx: InstallContext) -> None: # finish: prompt for reboot. Bind mounts are unwound in main()'s finally. # ───────────────────────────────────────────────────────────────────────────── -def _stop_install_dashboard() -> None: - pid = os.environ.get("OMARCHY_INSTALL_DASHBOARD_PID") - if not pid: - return +def _dashboard_stopped(pid: int) -> bool: + stat = Path(f"/proc/{pid}/stat") + if not stat.exists(): + return True try: - os.kill(int(pid), 15) - except (OSError, ValueError): + # /proc//stat field 3 is process state. A zombie cannot write to + # the tty anymore; the shell parent will reap it when control returns. + return stat.read_text().split()[2] == "Z" + except OSError: + return True + + +def _pid_is_dashboard(pid: int) -> bool: + try: + cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ") + except OSError: + return False + return b"omarchy-install-dashboard" in cmdline + + +def _dashboard_pids() -> list[int]: + pids: list[int] = [] + pid_text = os.environ.get("OMARCHY_INSTALL_DASHBOARD_PID") + if pid_text: + try: + pid = int(pid_text) + if _pid_is_dashboard(pid): + pids.append(pid) + except ValueError: + pass + + # Belt-and-suspenders fallback in case the env var was lost before finish. + try: + proc = subprocess.run( + ["pgrep", "-f", "omarchy-install-dashboard"], + capture_output=True, + text=True, + check=False, + ) + for line in proc.stdout.splitlines(): + try: + pid = int(line) + except ValueError: + continue + if pid not in pids and _pid_is_dashboard(pid): + pids.append(pid) + except OSError: pass - # Give the shell dashboard a moment to restore the cursor before gum draws. - time.sleep(0.1) + return pids + + +def _stop_install_dashboard() -> None: + pids = _dashboard_pids() + for signal_number in (15, 9): + for pid in pids: + if _dashboard_stopped(pid): + continue + if not _pid_is_dashboard(pid): + continue + try: + os.kill(pid, signal_number) + except OSError: + pass + deadline = time.time() + (1.0 if signal_number == 15 else 0.5) + while time.time() < deadline: + if all(_dashboard_stopped(pid) for pid in pids): + break + time.sleep(0.05) + if all(_dashboard_stopped(pid) for pid in pids): + break + try: with open("/dev/tty", "w", encoding="utf-8") as tty: tty.write("\033[?25h\033[H\033[2J") @@ -1065,8 +1130,76 @@ def _stop_install_dashboard() -> None: pass +def _tty_size() -> tuple[int, int]: + try: + with open("/dev/tty", "rb") as tty: + res = subprocess.run( + ["stty", "size"], + stdin=tty, + capture_output=True, + text=True, + check=True, + ) + rows, cols = res.stdout.strip().split() + return int(rows), int(cols) + except Exception: # noqa: BLE001 + return 24, 80 + + +def _center_text(text: str, width: int) -> str: + pad = max((width - len(text)) // 2, 0) + return " " * pad + text + + +def _install_duration(ctx: InstallContext) -> str | None: + candidates = [ + ctx.target / "var" / "log" / "omarchy-install.log", + Path("/var/log/omarchy-install.log"), + ] + patterns = ("Total:", "Omarchy:") + for path in candidates: + if not path.exists(): + continue + try: + lines = path.read_text(errors="ignore").splitlines() + except OSError: + continue + for prefix in patterns: + for line in reversed(lines[-80:]): + if line.startswith(prefix): + duration = line.split(":", 1)[1].strip() + if duration: + return duration + return None + + +def _render_finish_screen(ctx: InstallContext) -> None: + _, cols = _tty_size() + logo_path = Path("/usr/share/omarchy/logo.txt") + green = "\033[32m" + reset = "\033[0m" + try: + with open("/dev/tty", "w", encoding="utf-8") as tty: + tty.write("\033[?25h\033[H\033[2J\n") + if logo_path.exists(): + logo_lines = logo_path.read_text(errors="ignore").splitlines() + logo_width = max((len(line) for line in logo_lines), default=0) + left = max((cols - logo_width) // 2, 0) + for line in logo_lines: + tty.write(" " * left + green + line + reset + "\n") + else: + tty.write(_center_text(green + "Omarchy" + reset, cols) + "\n") + tty.write("\n") + duration = _install_duration(ctx) + message = f"Installed in {duration}" if duration else "Finished installing" + tty.write(_center_text(message, cols) + "\n\n") + tty.flush() + except OSError: + pass + + def finish(ctx: InstallContext) -> None: _stop_install_dashboard() - info("Installation finished. Reboot when ready.") + _render_finish_screen(ctx) if confirm("Reboot now?", default=True): os.system("reboot")