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.