installer: harden dashboard handoff

This commit is contained in:
Ryan Hughes committed 2026-05-22 19:15:06 -04:00
1 parent 61f84a95cf
commit 0734e982ca
4 files changed
+235 -93

No files matched your search

+33 -3
View File
@@ -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.
@@ -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/tty || printf '24 80\n'
}
term_cols() {
local size
size="$(term_size)"
printf '%s\n' "${size#* }"
}
term_size() { stty size 2>/dev/null </dev/tty || printf '24 80\n'; }
term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; }
repeat() {
local char="$1" count="$2" out=""
local char="$1" count="$2" out="" i
for ((i=0; i<count; i++)); do out+="$char"; done
printf '%s' "$out"
}
@@ -72,9 +63,21 @@ logo_width() {
fi
}
logo_height() {
if [[ -f $LOGO_PATH ]]; then
wc -l <"$LOGO_PATH"
else
printf '1\n'
fi
}
LOGO_WIDTH="$(logo_width)"
LOGO_HEIGHT="$(logo_height)"
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
CONTENT_WIDTH="$LOGO_WIDTH"
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
LAST_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
@@ -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)
@@ -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/<pid>/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")