From 4f60a18c875ac49d1e0fb0d2e067f9c76b3e97df Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 28 Jul 2026 07:34:24 -0700 Subject: [PATCH] Drive the install bar from packages, not from a stopwatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_progress() mapped each phase to a start%, an end% and a hard-coded "expected seconds", then interpolated on elapsed time. Those seconds were calibrated on one machine, so the bar was only ever honest on that machine. Measured against a real 69s install on an XPS 13, the table expected 145s: 95s for a phase that took 58.6s, 10s for one that took 2.6s. On slow media it is wrong the other way — the bar reaches each phase's ceiling in a fraction of the real time and then sits there. Today's clamp turns that into a 386-second freeze at 85% on a USB2 install. Phases do not even scale together: package installation is bound by media read and disk write, the UKI rebuild is CPU and compression bound. No single seconds table can be right for both. So stop predicting durations. Position is now max(floor, work), both non-decreasing: floor an asymptote over the phase's band, on time since this process first saw the phase. tau is a shape constant, not a prediction — wrong by 3x it changes the pace of a crawl, never the correctness. work only for "Installing Arch + Omarchy", which is ~85% of the install. libalpm creates one directory per package under /var/lib/pacman/local, so counting them counts finished work. The denominator is resolved at ISO build time by pacman's own resolver against the offline mirror, so there is no second package list to drift. phases.py records expected and actual in the timing JSON on every install, so drift shows up in acceptance runs. There is deliberately no give-up predicate: two discontinuous curves selected by a toggle, behind a monotonic clamp, is a freeze generator. If the signal never appears — no denominator, wrong target, implausible count — the floor alone still walks the band to its end. Position moves to per-mille. That is load-bearing, not precision theatre: these are asymptotes, and in whole percent an asymptote over a 2-point band has two reachable values and stops moving within seconds. Time now comes from /proc/uptime. The live ISO runs tzupdate and systemd-timesyncd, and the old code read EPOCHSECONDS, so a clock step mid-install jumped every band straight to its ceiling. Also fixes drift the old table had accumulated: it still weighted five phases that no longer exist and had no entry at all for "Preparing install target", which fell through to a 1->99 sweep. Unknown names now derive an even band from the index the orchestrator already publishes. bin/omarchy-iso-installer fakes a pacman database so the preview exercises the package path instead of the fallback; PREVIEW_PACKAGES tunes the count. Verified by simulation against the real logic: a 69s NVMe install, a 6-minute USB2 install with a 70s pre-extraction stall, an implausible denominator, an unknown phase name, and a target that already holds packages. All monotonic, all reach 100%. Not yet run on real hardware. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-iso-installer | 58 +++- builder/build-iso.sh | 64 +++++ .../usr/local/bin/omarchy-install-dashboard | 254 ++++++++++++++---- .../share/omarchy-iso/orchestrator/phases.py | 27 ++ 4 files changed, 341 insertions(+), 62 deletions(-) diff --git a/bin/omarchy-iso-installer b/bin/omarchy-iso-installer index 12a6488..2fb9b53 100755 --- a/bin/omarchy-iso-installer +++ b/bin/omarchy-iso-installer @@ -2,49 +2,89 @@ # Preview the shipped Omarchy installer dashboard without building an ISO. set -euo pipefail +# The dashboard drives the package phase from the target's pacman database, so +# fake one here; without it the preview falls back to its time curve and the bar +# barely moves, which reads as a bug. +PREVIEW_PACKAGES="${PREVIEW_PACKAGES:-925}" + write_simulation_state() { local state_file="$1" started_at="$2" phase_started_at="$3" current_phase="$4" - local finished_at="${5:-null}" temporary="${state_file}.tmp" + local target="$5" index="$6" total="$7" + local finished_at="${8:-null}" temporary="${state_file}.tmp" jq -n \ --argjson started_at "$started_at" \ --argjson phase_started_at "$phase_started_at" \ --arg current_phase "$current_phase" \ + --arg target "$target" \ + --argjson current_index "$index" \ + --argjson total_phases "$total" \ --argjson finished_at "$finished_at" \ '{ started_at: $started_at, phase_started_at: $phase_started_at, - current_phase: $current_phase + current_phase: $current_phase, + target: $target, + current_index: $current_index, + total_phases: $total_phases } + if $finished_at == null then {} else {finished_at: $finished_at} end' \ >"$temporary" mv -f -- "$temporary" "$state_file" } +# Fill the fake database over the phase's duration, so the bar is driven by the +# same count it uses on a real install. +simulate_packages() { + local db="$1" duration="$2" total="$3" steps=25 i n + for ((i = 1; i <= steps; i++)); do + n=$(( total * i / steps )) + while (( $(ls -1 "$db" 2>/dev/null | wc -l) < n )); do + mkdir -p "$db/preview-$(( $(ls -1 "$db" 2>/dev/null | wc -l) ))-1.0-1" + done + sleep "$(awk -v d="$duration" -v s="$steps" 'BEGIN { printf "%.3f", d / s }')" + done +} + simulate_install() { local state_file="$1" local started_at="$EPOCHREALTIME" - local phase phase_started_at finished_at duration i + local phase phase_started_at finished_at duration i index total + local target db + target="$(dirname -- "$state_file")/target" + db="$target/var/lib/pacman/local" + mkdir -p "$db" local -a phases=( "Starting installation" 0.5 "Preparing live environment" 0.8 + "Preparing install target" 0.3 "Installing Arch + Omarchy" 5.0 + "Configuring hibernation" 0.4 "Configuring system" 1.2 "Finalizing Limine boot" 1.0 "Finalizing user" 1.0 "Configuring login" 0.6 + "Configuring DNS resolver" 0.3 "Validating boot setup" 0.8 ) + total=$(( ${#phases[@]} / 2 )) for ((i = 0; i < ${#phases[@]}; i += 2)); do phase="${phases[i]}" duration="${phases[i + 1]}" + index=$(( i / 2 )) phase_started_at="$EPOCHREALTIME" - write_simulation_state "$state_file" "$started_at" "$phase_started_at" "$phase" - sleep "$duration" + write_simulation_state "$state_file" "$started_at" "$phase_started_at" \ + "$phase" "$target" "$index" "$total" + if [[ $phase == "Installing Arch + Omarchy" ]]; then + simulate_packages "$db" "$duration" "$PREVIEW_PACKAGES" + else + sleep "$duration" + fi done finished_at="$EPOCHREALTIME" - write_simulation_state "$state_file" "$started_at" "$finished_at" "Installation complete" "$finished_at" + write_simulation_state "$state_file" "$started_at" "$finished_at" \ + "Installation complete" "$target" "$(( total - 1 ))" "$total" "$finished_at" sleep 0.3 } @@ -91,6 +131,12 @@ log_file="$preview_dir/install.log" state_file="$preview_dir/state.json" touch "$log_file" +# Match what the simulated database will reach, so the preview shows the +# package-driven bar rather than the missing-signal fallback. +printf '%s\n' "$PREVIEW_PACKAGES" >"$preview_dir/expected-packages" + OMARCHY_UI_AUTO_REBOOT=no \ + OMARCHY_EXPECTED_PACKAGES_FILE="$preview_dir/expected-packages" \ + PREVIEW_PACKAGES="$PREVIEW_PACKAGES" \ "$dashboard" "$log_file" "$state_file" -- \ "$0" --simulate-install "$state_file" diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 57a339c..46a9b57 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -234,6 +234,70 @@ repo-add "$offline_mirror_dir/offline.db.tar.gz" "$offline_mirror_dir/"*.pkg.tar mkdir -p /var/cache/omarchy/mirror ln -sf "$offline_mirror_dir" /var/cache/omarchy/mirror/offline +# Denominator for the install dashboard's progress bar. Resolving the mirror's +# own package lists against the mirror we just indexed, with an empty local db, +# is the question pacstrap asks at install time — same resolver, same repo, same +# lists — so no hand-kept constant can drift. +# +# It over-counts by ~1 in 925: archinstall.packages lists both amd-ucode and +# intel-ucode because the mirror must contain either. phases.py records expected +# and actual in the timing JSON, so growing drift shows up in acceptance runs. +# The early-bootstrap set is already inside this closure, so restating it would +# only add a second list to drift. +resolve_expected_packages() { + local resolve_root=/tmp/omarchy-expected-packages + local resolved + local -a targets + + rm -rf "$resolve_root" + mkdir -p "$resolve_root/var/lib/pacman" + + mapfile -t targets < <( + { + grep -hv '^#\|^$' /builder/archinstall.packages + # Read the shipped copy, which is what _runtime_package_list reads at + # install time, not the build-time source it came from. + grep -hv '^#\|^$' \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" + printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" \ + "$OMARCHY_NVIM_PACKAGE" + } | sort -u + ) + + pacman --config "$build_cache_dir/pacman-offline.conf" \ + --root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \ + --noconfirm -Sy >/dev/null || return 1 + + # Capture before counting: no pipefail here, so a pacman failure inside a + # pipeline would become a plausible partial count, which never trips the + # dashboard's fallback. + resolved="$(pacman --config "$build_cache_dir/pacman-offline.conf" \ + --root "$resolve_root" --dbpath "$resolve_root/var/lib/pacman" \ + --noconfirm -S --print --print-format '%n' "${targets[@]}")" || return 1 + + printf '%s\n' "$resolved" | sort -u | grep -c . +} + +# Worth failing the build over: -S --print only aborts when a target is missing +# from the offline repo, which would fail pacstrap the same way. A count that +# merely looks wrong is not — the dashboard falls back without the file. +if ! expected_packages="$(resolve_expected_packages)"; then + echo "ERROR: could not resolve the target package count from the offline mirror." >&2 + echo " pacman -S --print aborts the whole transaction if any single target" >&2 + echo " is missing, so this almost certainly means pacstrap would fail the" >&2 + echo " same way at install time." >&2 + exit 1 +fi +if (( expected_packages < 600 || expected_packages > 2000 )); then + echo "WARNING: resolved target package count $expected_packages is outside the" >&2 + echo " expected 600-2000 range; shipping no denominator so the install" >&2 + echo " dashboard falls back to its time-based curve." >&2 +else + printf '%s\n' "$expected_packages" \ + >"$build_cache_dir/airootfs/usr/share/omarchy-iso/expected-packages" + echo "Target install resolves to $expected_packages packages." +fi + # Live ISO uses the same offline pacman.conf. cp "$build_cache_dir/pacman-offline.conf" "$build_cache_dir/airootfs/etc/pacman.conf" diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index 7d3b3a1..6f6c474 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -137,7 +137,63 @@ LOGO_HEIGHT="$(logo_height)" (( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1 CONTENT_WIDTH="$LOGO_WIDTH" DYNAMIC_ROW=$((LOGO_HEIGHT + 3)) -LAST_PCT=1 +LAST_PM=10 + +# ── Install progress ───────────────────────────────────────────────────────── +# +# Position = max(floor, work), both non-decreasing, in per-mille of the bar. +# +# floor lo + (span-1) * t / (t+tau), on time since this process saw the +# phase. Asymptotic, so it approaches the band end without reaching it. +# tau is a shape constant, not a duration prediction. +# work packages installed / PKG_TOTAL, for the package phase only (~85% of +# the install). libalpm makes one directory per package under +# /var/lib/pacman/local; the denominator comes from +# build-iso.sh, so there is no second package list to drift. +# +# Per-mille because a whole-percent asymptote over a 2-point band has two +# reachable values and stops moving. No give-up predicate: two discontinuous +# curves behind a monotonic clamp freeze. With no signal the floor alone still +# finishes the band. +EXPECTED_PACKAGES_FILE="${OMARCHY_EXPECTED_PACKAGES_FILE:-/usr/share/omarchy-iso/expected-packages}" +PKG_TOTAL=0 +if [[ -r $EXPECTED_PACKAGES_FILE ]]; then + read -r PKG_TOTAL _ <"$EXPECTED_PACKAGES_FILE" 2>/dev/null || PKG_TOTAL=0 +fi +[[ $PKG_TOTAL =~ ^[0-9]+$ ]] || PKG_TOTAL=0 +# A wrong denominator is worse than none: it never triggers the fallback. +(( PKG_TOTAL < 300 || PKG_TOTAL > 5000 )) && PKG_TOTAL=0 + +PKG_BONUS_MAX_PM=100 # cap on how far the bar may run ahead of the count +PKG_TAIL_PM=40 # band held back so a stalled bar has somewhere to go +PKG_FLOOR_TAU=900 # with a work signal, time is only a backstop until the + # first package, so slow it until it cannot outrun data + +POS=10 # position, per-mille, monotonic +PROGRESS_PM=10 +NOW=0 # monotonic seconds, refreshed once per frame +DASH_T0=0 +PHASE_NAME="" # last phase this process observed +PHASE_T0=0 # NOW when it was first observed +PHASE_LO=10 # POS on entry, for an unknown phase with no index +PKG_DB_DIR="" +PKG_COUNT=0 +PKG_BASE=-1 # package count when the phase started +PKG_SEEN=0 # running max of packages since PKG_BASE +PKG_LAST_CHANGE=0 +PKG_BONUS=0 + +# Monotonic clock: tzupdate and systemd-timesyncd can step the wall clock +# mid-install, which would jump every time-driven band to its ceiling. +set_now() { + local up rest + if read -r up rest /dev/null && [[ $up == [0-9]* ]]; then + NOW=${up%%.*} + else + NOW=$EPOCHSECONDS + fi + return 0 +} left_padding() { local width="${1:-$CONTENT_WIDTH}" cols pad @@ -183,72 +239,146 @@ render_logo() { } progress_bar() { - local pct="$1" width="${2:-34}" filled empty - (( pct < 0 )) && pct=0 - (( pct > 100 )) && pct=100 - filled=$(( pct * width / 100 )) + local pm="$1" width="${2:-34}" filled empty + (( pm < 0 )) && pm=0 + (( pm > 1000 )) && pm=1000 + filled=$(( pm * width / 1000 )) empty=$(( width - filled )) printf '%s%s%s%s%s' "$WHITE" "$(repeat █ "$filled")" "$DARK" "$(repeat ░ "$empty")" "$RESET" } +# Packages libalpm has finished installing. local/ holds one directory per +# package and nothing else in it is a directory, so */ is exact. One glob, no +# forks: this runs twice a second while the disk is saturated. +count_packages() { + local -a entries + PKG_COUNT=0 + [[ -n $PKG_DB_DIR ]] || return 0 + entries=("$PKG_DB_DIR"/*/) + # Unmatched globs expand to the pattern; nullglob stays off script-wide. + if (( ${#entries[@]} == 1 )) && [[ ! -d ${entries[0]} ]]; then + return 0 + fi + PKG_COUNT=${#entries[@]} + return 0 +} + +# Sets PROGRESS_PM. Never call in a command substitution: POS, the phase timer +# and the package counters must survive between frames. Print nothing — output +# here would land on the TTY between the cursor escape and the bar. install_progress() { - local phase finished phase_started elapsed span active pct - local start=1 end=99 expected=90 + local phase finished index total target + local lo hi tau span t pos work gap n pkg_signal=0 local -a state - [[ -f $STATE_FILE ]] || { printf '1'; return; } + PROGRESS_PM=$POS + [[ -f $STATE_FILE ]] || return 0 mapfile -t state < <(jq -r ' (.current_phase // "Starting installation"), ((.finished_at // 0) | tonumber? // 0 | floor), - ((.phase_started_at // .started_at // 0) | tonumber? // 0 | floor) + ((.current_index // -1) | tonumber? // -1 | floor), + ((.total_phases // 0) | tonumber? // 0 | floor), + (.target // "/mnt") ' "$STATE_FILE" 2>/dev/null) - (( ${#state[@]} == 3 )) || { printf '1'; return; } - phase="${state[0]}" finished="${state[1]}" phase_started="${state[2]}" + (( ${#state[@]} == 5 )) || return 0 + phase="${state[0]}" finished="${state[1]}" + index="${state[2]}" total="${state[3]}" target="${state[4]}" + [[ -n $target ]] || target="/mnt" if (( finished > 0 )) || [[ $phase == "Installation complete" ]]; then - printf '100' - return + POS=1000 + PROGRESS_PM=1000 + return 0 fi - # Phase weights: start%, end%, expected seconds in phase. + # Transitions are observed here rather than read from the state file, which + # keeps wall clock out of the dashboard entirely. + if [[ $phase != "$PHASE_NAME" ]]; then + PHASE_NAME="$phase" + PHASE_T0=$NOW + PHASE_LO=$POS + PKG_BASE=-1 + PKG_SEEN=0 + PKG_BONUS=0 + PKG_LAST_CHANGE=$NOW + fi + + # Bands in per-mille, plus tau. The tail bands are wide relative to what they + # cost on NVMe: run_system_finalizer can DKMS-build a driver and + # finalize_limine_boot rebuilds every UKI, so they vary most on real hardware. + # Over-allotting costs a step at a boundary; under-allotting costs a stall. case "$phase" in - "Starting installation") start=1 end=2 expected=2 ;; - "Preparing live environment") start=2 end=6 expected=5 ;; - "Verifying protected mounts") start=6 end=8 expected=3 ;; - "Installing Arch + Omarchy") start=8 end=86 expected=95 ;; - "Installing base system") start=8 end=84 expected=85 ;; - "Configuring protected boot") start=84 end=86 expected=8 ;; - "Configuring hibernation") start=86 end=88 expected=5 ;; - "Configuring system") start=88 end=93 expected=10 ;; - "Finalizing Limine boot") start=93 end=96 expected=8 ;; - "Finalizing user") start=96 end=98 expected=10 ;; - "Finalizing in chroot") start=88 end=98 expected=10 ;; - "Configuring login") start=98 end=99 expected=3 ;; - "Configuring DNS resolver") start=99 end=100 expected=2 ;; - "Validating protected boot setup") start=99 end=100 expected=5 ;; - "Validating boot setup") start=99 end=100 expected=5 ;; + "Starting installation") lo=10 hi=25 tau=5 ;; + "Preparing live environment") lo=25 hi=50 tau=25 ;; + "Preparing install target") lo=50 hi=65 tau=20 ;; + "Installing Arch + Omarchy") lo=65 hi=700 tau=150 pkg_signal=1 ;; + "Configuring hibernation") lo=700 hi=720 tau=10 ;; + "Configuring system") lo=720 hi=850 tau=120 ;; + "Finalizing Limine boot") lo=850 hi=950 tau=90 ;; + "Finalizing user") lo=950 hi=978 tau=30 ;; + "Configuring login") lo=978 hi=988 tau=5 ;; + "Configuring DNS resolver") lo=988 hi=994 tau=5 ;; + "Validating boot setup") lo=994 hi=1000 tau=5 ;; + *) + # Unknown names used to inherit a 1->99 sweep. Derive an even band from + # the published index instead; with no index, a bounded slice, so a + # renamed phase degrades to one cell rather than a runaway. + if (( total >= 2 && index >= 0 && index < total )); then + lo=$(( 10 + 990 * index / total )) + hi=$(( 10 + 990 * (index + 1) / total )) + else + lo=$PHASE_LO + hi=$(( PHASE_LO + 30 )) + fi + tau=60 + ;; esac - (( phase_started > 0 )) || phase_started=$EPOCHSECONDS - elapsed=$(( EPOCHSECONDS - phase_started )) - (( elapsed < 0 )) && elapsed=0 - span=$(( end - start )) - (( span < 0 )) && span=0 - (( expected < 1 )) && expected=1 - active=$(( elapsed * span / expected )) - (( active > span - 1 )) && active=$(( span - 1 )) - (( active < 0 )) && active=0 - pct=$(( start + active )) - (( pct > 100 )) && pct=100 - printf '%s' "$pct" + (( pkg_signal && PKG_TOTAL > 0 )) || pkg_signal=0 + (( pkg_signal )) && tau=$PKG_FLOOR_TAU + + t=$(( NOW - PHASE_T0 )) + (( t < 0 )) && t=0 + span=$(( hi - lo )) + (( span < 2 )) && span=2 + (( tau < 1 )) && tau=1 + + pos=$(( lo + (span - 1) * t / (t + tau) )) + + if (( pkg_signal )); then + PKG_DB_DIR="$target/var/lib/pacman/local" + count_packages + # Baseline on first sight: a target that already holds packages would + # otherwise pin the bar at the band ceiling for the whole phase. + (( PKG_BASE < 0 )) && PKG_BASE=$PKG_COUNT + n=$(( PKG_COUNT - PKG_BASE )) + (( n < 0 )) && n=0 + # Running max: a reinstalled package briefly drops its version-stamped dir. + if (( n > PKG_SEEN )); then + PKG_SEEN=$n + PKG_LAST_CHANGE=$NOW + fi + gap=$(( NOW - PKG_LAST_CHANGE )) + (( gap < 0 )) && gap=0 + (( gap > PKG_BONUS_MAX_PM )) && gap=$PKG_BONUS_MAX_PM + (( gap > PKG_BONUS )) && PKG_BONUS=$gap + work=$(( lo + (span - 1 - PKG_TAIL_PM) * PKG_SEEN / PKG_TOTAL + PKG_BONUS )) + (( work > pos )) && pos=$work + fi + + (( pos > hi - 1 )) && pos=$(( hi - 1 )) + (( pos > POS )) && POS=$pos + (( POS > 1000 )) && POS=1000 + PROGRESS_PM=$POS + return 0 } -# One tip per ~8 seconds (frames tick every 0.5s), in order, so each stays -# readable and none are skipped when progress jumps. +# One tip per 8 seconds, in order. On elapsed time, not a frame counter: a frame +# is 0.5s of sleep plus a render, which stretches under pacstrap I/O — on the +# machine where the tips, not the bar, are carrying liveness. current_tip() { - local frame="$1" - printf '%s' "${tips[$(( frame / 16 % ${#tips[@]} ))]}" + printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}" } render_static() { @@ -259,21 +389,24 @@ render_static() { } render_dynamic() { - local frame="$1" pct - pct="$(install_progress)" - [[ $pct =~ ^[0-9]+$ ]] || pct=$LAST_PCT - (( pct > 100 )) && pct=100 - (( pct < LAST_PCT )) && pct=$LAST_PCT - LAST_PCT=$pct + local pm + set_now + install_progress + pm=$PROGRESS_PM + [[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM + (( pm > 1000 )) && pm=1000 + # POS is monotone by construction; this is a guard, not the mechanism. + (( pm < LAST_PM )) && pm=$LAST_PM + LAST_PM=$pm printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW" center "Installing Omarchy" "$CONTENT_WIDTH" blank_line line_at "$CONTENT_WIDTH" $(( (CONTENT_WIDTH - 34) / 2 )) "" - progress_bar "$pct" 34 + progress_bar "$pm" 34 printf '\n' blank_line - center "${DIM}Tip:${RESET} ${GREEN}$(current_tip "$frame")${RESET}" "$CONTENT_WIDTH" + center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$CONTENT_WIDTH" printf '%s' "$CLEAR_TO_END" } @@ -541,12 +674,14 @@ launch_child() { [[ -e $TTY_PATH ]] || exit 2 printf '%s' "$HIDE_CURSOR" >"$TTY_PATH" render_static >"$TTY_PATH" +set_now +DASH_T0=$NOW +PHASE_T0=$NOW +PKG_LAST_CHANGE=$NOW launch_child -frame=0 while jobs -pr | grep -qx "$child_pid"; do - render_dynamic "$frame" >"$TTY_PATH" 2>/dev/null || true - frame=$((frame + 1)) + render_dynamic >"$TTY_PATH" 2>/dev/null || true sleep 0.5 done @@ -559,6 +694,13 @@ child_pgid="" dashboard_log "installer child exited with status $status" +if (( status == 0 )); then + # finished_at lands microseconds before the child exits, so the 0.5s poll + # rarely renders a full bar before the finish screen replaces it. + render_dynamic >"$TTY_PATH" 2>/dev/null || true + sleep 0.4 +fi + if (( status != 0 )); then failure_status="$status" failure_summary="" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py index 31a6c76..cb84244 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases.py @@ -4,6 +4,7 @@ the InstallContext and either return cleanly or raise to abort the install.""" from __future__ import annotations import json +import os import time import traceback from collections.abc import Callable @@ -25,6 +26,9 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: state_path = ctx.state_dir / "state.json" state = { "started_at": time.time(), + # The dashboard counts packages under /var/lib/pacman/local; + # publish the path rather than have the UI assume /mnt. + "target": str(ctx.target), "total_phases": len(phases), "current_index": 0, "current_phase": "Starting installation", @@ -63,6 +67,10 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: state["current_index"] = max(len(phases) - 1, 0) state["current_phase"] = "Installation complete" state["finished_at"] = time.time() + # Expected vs actual for the bar's denominator, so drift is visible in + # acceptance runs rather than only by watching a bar creep. + state["installed_packages"] = _installed_package_count(ctx.target) + state["expected_packages"] = _expected_package_count() _write_state(state_path, state) timing_path = ctx.target / "var" / "log" / "omarchy-install-timing.json" @@ -70,6 +78,25 @@ def run(ctx: InstallContext, phases: list[tuple[str, PhaseFn]]) -> None: _write_state(timing_path, state) +def _installed_package_count(target: Path) -> int: + """Packages libalpm installed into the target — one directory each under + local/, which is what the dashboard counts live.""" + local_db = target / "var" / "lib" / "pacman" / "local" + try: + with os.scandir(local_db) as entries: + return sum(1 for entry in entries if entry.is_dir()) + except OSError: + return 0 + + +def _expected_package_count() -> int: + path = Path("/usr/share/omarchy-iso/expected-packages") + try: + return int(path.read_text().split()[0]) + except (OSError, ValueError, IndexError): + return 0 + + def _write_state(path: Path, state: dict) -> None: # Dashboard polls this file while phases update it. Write atomically so the # reader never observes a truncated/partial JSON document and resets UI.