installer: harden dashboard handoff
This commit is contained in:
4 files changed
+235
-93
No files matched your search
@@ -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")
|
||||
Reference in new issue
Block a user