#!/usr/bin/env bash # Foreground Omarchy ISO install UI supervisor. # # This process is the only owner of the visible install UI. It launches the # actual installer as a non-interactive child, sends child output to the support # log, renders progress while the child runs, then renders the final installed # time + reboot prompt itself. There is deliberately no background dashboard. set -euo pipefail usage() { echo "Usage: $0 -- " >&2 exit 2 } (( $# >= 4 )) || usage LOG_FILE="$1" STATE_FILE="$2" shift 2 [[ ${1:-} == "--" ]] || usage shift (( $# > 0 )) || usage INSTALL_CMD=("$@") TTY_PATH="${OMARCHY_DASHBOARD_TTY:-/dev/tty}" LOGO_PATH="${OMARCHY_PATH:-/usr/share/omarchy}/logo.txt" # Autoinstall has no one at the keyboard, and every prompt this dashboard owns # reads from the TTY, so each would hang forever. Suppress them here rather than # in the scripts they live in: this process is the only owner of the visible UI. interactive() { [[ ${OMARCHY_UI_INTERACTIVE:-} != "no" ]]; } CSI=$'\033[' RESET="${CSI}0m" DIM="${CSI}2m" HIDE_CURSOR="${CSI}?25l" SHOW_CURSOR="${CSI}?25h" CLEAR="${CSI}2J${CSI}H" CLEAR_LINE="${CSI}2K" CLEAR_TO_END="${CSI}J" GREEN="${CSI}32m" RED="${CSI}31m" # The dashboard owns the final reboot prompt now, so keep the gum confirm # styling that used to come from install/helpers/presentation.sh. export GUM_CONFIRM_PROMPT_FOREGROUND="${GUM_CONFIRM_PROMPT_FOREGROUND:-6}" export GUM_CONFIRM_SELECTED_FOREGROUND="${GUM_CONFIRM_SELECTED_FOREGROUND:-0}" export GUM_CONFIRM_SELECTED_BACKGROUND="${GUM_CONFIRM_SELECTED_BACKGROUND:-2}" export GUM_CONFIRM_UNSELECTED_FOREGROUND="${GUM_CONFIRM_UNSELECTED_FOREGROUND:-7}" export GUM_CONFIRM_UNSELECTED_BACKGROUND="${GUM_CONFIRM_UNSELECTED_BACKGROUND:-0}" child_pid="" child_pgid="" whimsy=( "delivering delightful defaults" "saving setup struggles" "picking polished packages" "curating cohesive colors" "preparing pleasant pixels" "hyping happy hyprland" "integrating intuitive interfaces" "bootstrapping beautiful boxes" "crafting cozy commands" "making machines marvelous" "polishing pretty pixels" "tuning tasteful terminals" "weaving wonderful workflows" ) tips=( "Super + Space opens the Omarchy menu for apps, settings, and more" "Super + K shows all the key bindings" "Super is the Windows or command key on your keyboard" "Use Xournal++ to sign PDFs" "Share files with phones and laptops using LocalSend" "Turn any website into an app with Install > Web App in the menu" "Super + Return opens a terminal, Super + Shift + Return the browser" "Edit images with Pinta, videos with Kdenlive, docs with LibreOffice" "Super + Ctrl + Print grabs text off the screen with OCR" "Print takes a screenshot, Alt + Print records the screen" "Set a reminder with Super + Ctrl + R" "Switch themes from Style > Theme in the Omarchy menu" "Double-click the menu bar to make it transparent" "Run a full Windows VM via Install > Windows in the menu" "Super + Ctrl + V opens the clipboard manager" "Super + 1 through 0 switches workspaces, add Shift to bring the window" "Super + Print picks a color from anywhere on screen" "Keep the system fresh with Update in the Omarchy menu" ) # omarchy_text and its Chinese table come from the setup form the configurator uses. SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}" [[ -r $SETUP_FORM ]] || SETUP_FORM="${OMARCHY_PATH:-/usr/share/omarchy}/install/provisioning/setup-form.sh" source "$SETUP_FORM" OMARCHY_ZH+=( ["Tip: %s"]="提示:%s" ["Installed Omarchy"]="Omarchy 安装完成" ["Installed Omarchy in %s"]="Omarchy 安装完成,用时 %s" ["Reboot Now"]="立即重启" ["Last log lines:"]="日志末尾:" ["Last target log lines:"]="目标系统日志末尾:" ["Press Enter to continue…"]="按回车继续……" ["No log uploader is available in this environment."]="这个环境没有日志上传工具。" ["Install log: %s"]="安装日志:%s" ["Target log: %s"]="目标系统日志:%s" ["Omarchy installation stopped"]="Omarchy 安装已停止" ["Installer exited with status %s"]="安装程序退出,状态码 %s" ["Get help at https://omarchy.org/discord"]="求助:https://omarchy.org/discord" ["What would you like to do?"]="接下来做什么?" ["Upload log for support"]="上传日志求助" ["View full log"]="查看完整日志" ["Drop to shell"]="进入 shell" ["Reboot"]="重启" ["Power off"]="关机" ["The install medium was misread"]="安装介质读取出错" ["This package is intact on this ISO, so the download and the write were fine:"]="这个软件包在 ISO 中完好,下载和写入都没有问题:" ["Try another USB port or another stick, and test this machine's memory"]="换一个 USB 口或换一个 U 盘,并检测这台机器的内存" ["The install medium is damaged"]="安装介质已损坏" ["This package no longer matches the checksum it was built with:"]="这个软件包和构建时的校验和不再一致:" ["Re-download the ISO, verify it with sha256sum, and write the USB again"]="重新下载 ISO,用 sha256sum 校验,再重新写入 U 盘" ["If a verified ISO fails here too, the USB stick itself is the problem"]="如果校验过的 ISO 在这里也失败,问题出在 U 盘本身" ["The install medium is damaged or misread"]="安装介质已损坏或读取出错" ["pacman rejected this package, read straight off this ISO:"]="pacman 拒绝了这个直接从 ISO 读出的软件包:" ["Verify your download with sha256sum, then write the USB again"]="用 sha256sum 校验下载的文件,再重新写入 U 盘" ["If the download verifies, the stick or this machine's memory is at fault"]="如果下载校验无误,问题出在 U 盘或这台机器的内存" ) if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]]; then whimsy=( "备好贴心的默认设置" "省掉装机的折腾" "挑选打磨过的软件包" "调配协调的配色" "准备悦目的像素" "唤醒 Hyprland" "接好顺手的界面" "搭起漂亮的系统" "写好趁手的命令" "让机器焕然一新" "擦亮每一个像素" "调好终端的品味" "织出流畅的工作流" ) tips=( "Super + Space 打开 Omarchy 菜单:应用、设置等" "Super + K 查看全部快捷键" "Super 是键盘上的 Windows 或 Command 键" "用 Xournal++ 给 PDF 签名" "用 LocalSend 和手机、电脑互传文件" "菜单 安装 > 网页应用 把任意网站变成应用" "Super + Return 打开终端,Super + Shift + Return 打开浏览器" "图片用 Pinta,视频用 Kdenlive,文档用 LibreOffice" "Super + Ctrl + Print 用 OCR 抓取屏幕文字" "Print 截屏,Alt + Print 录屏" "Super + Ctrl + R 设置提醒" "菜单 风格 > 主题 切换主题" "双击菜单栏让它透明" "菜单 安装 > Windows 运行完整的 Windows 虚拟机" "Super + Ctrl + V 打开剪贴板管理器" "Super + 1 到 0 切换工作区,加 Shift 带上窗口" "Super + Print 拾取屏幕上任意位置的颜色" "用菜单里的 更新 让系统保持最新" ) fi cleanup() { printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true } dashboard_log() { printf '[dashboard] %s\n' "$*" >>"$LOG_FILE" 2>/dev/null || true } stop_child() { [[ -n ${child_pid:-} ]] || return 0 if kill -0 "$child_pid" 2>/dev/null; then if [[ -n ${child_pgid:-} ]]; then kill -- -"$child_pgid" 2>/dev/null || true fi kill "$child_pid" 2>/dev/null || true for _ in {1..20}; do kill -0 "$child_pid" 2>/dev/null || break sleep 0.05 done if kill -0 "$child_pid" 2>/dev/null; then [[ -n ${child_pgid:-} ]] && kill -9 -- -"$child_pgid" 2>/dev/null || true kill -9 "$child_pid" 2>/dev/null || true fi fi wait "$child_pid" 2>/dev/null || true } interrupt() { local code="$1" stop_child cleanup exit "$code" } trap cleanup EXIT trap 'interrupt 130' INT trap 'interrupt 143' TERM term_size() { stty size 2>/dev/null <"$TTY_PATH" || printf '24 80\n'; } term_cols() { local size; size="$(term_size)"; printf '%s\n' "${size#* }"; } # Read the actual character dimensions instead of assuming an 8 x 16 font. # Linux VTs expose the font through KDFONTOP; desktop terminals may expose # pixels in their window size or answer the standard cell-size query. term_cell_size() { python3 - "$TTY_PATH" <<'PY' import fcntl import os import re import select import struct import sys import termios import time import tty with open(sys.argv[1], "r+b", buffering=0) as terminal: fd = terminal.fileno() rows, cols, width, height = struct.unpack( "HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8))) if rows and cols and width and height: print(round(width / cols), round(height / rows)) sys.exit() try: font = bytearray(struct.pack("@5IP", 1, 0, 0, 0, 0, 0)) fcntl.ioctl(fd, 0x4B72, font) # KDFONTOP / KD_FONT_OP_GET, no glyph data _, _, width, height, _ = struct.unpack_from("@5I", font) if width and height: print(width, height) sys.exit() except OSError: pass settings = termios.tcgetattr(fd) try: tty.setcbreak(fd, termios.TCSANOW) os.write(fd, b"\x1b[16t") response = b"" deadline = time.monotonic() + 0.15 while select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]: response += os.read(fd, 64) match = re.search(rb"\x1b\[6;(\d+);(\d+)t", response) if match: height, width = map(int, match.groups()) if width and height: print(width, height) sys.exit() if time.monotonic() >= deadline: break finally: termios.tcsetattr(fd, termios.TCSANOW, settings) print(8, 16) # Conventional console font when dimensions aren't available. PY } logo_width() { if [[ -f $LOGO_PATH ]]; then # The logo is drawn in box-drawing glyphs, three bytes each in UTF-8, so # awk's `length` returns 197 rather than 81 unless the locale happens to be # UTF-8 — and a width wider than the console makes left_padding clamp to # zero, which puts the logo flush left. Count bytes deliberately (LC_ALL=C) # and drop the continuation bytes instead, so the answer is columns in every # locale rather than only in the ones that have a charset. LC_ALL=C awk ' { gsub(/[\200-\277]/, ""); if (length > max) max = length } END { print max+0 } ' "$LOGO_PATH" else printf '81\n' 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" TOP_ROW=1 LAST_SIZE="" LAST_PM=10 # Wordmark, blank, title, and the three rows gum confirm uses. FINISH_HEIGHT=$((LOGO_HEIGHT + 5)) FINISH_TOP_ROW=2 # ── Install progress ───────────────────────────────────────────────────────── # # Position = max(floor, work), both non-decreasing, in per-mille of the drawing. # # 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 progress may run ahead of the count PKG_TAIL_PM=40 # band held back so stalled progress 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 cols="$(term_cols)" pad=$(( (cols - width) / 2 )) (( pad < 0 )) && pad=0 printf '%*s' "$pad" '' } visible_len() { local text="$1" # wc -L counts display columns, so double-width CJK centers too. printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | wc -L } blank_line() { printf '\r%s\n' "$CLEAR_LINE"; } line_at() { local width="${1:-$CONTENT_WIDTH}" indent="${2:-0}" shift 2 printf '\r%s%s%*s' "$CLEAR_LINE" "$(left_padding "$width")" "$indent" '' printf '%b' "$*" } center() { local text="$1" width="${2:-$CONTENT_WIDTH}" len inner_pad len="$(visible_len "$text")" inner_pad=$(( (width - len) / 2 )) (( inner_pad < 0 )) && inner_pad=0 printf '\r%s%s%*s%b%s\n' "$CLEAR_LINE" "$(left_padding "$width")" "$inner_pad" '' "$text" "$RESET" } render_logo() { local pad pad="$(left_padding "$LOGO_WIDTH")" if [[ -f $LOGO_PATH ]]; then while IFS= read -r line; do printf '\r%s%s%b%s%b\n' "$CLEAR_LINE" "$pad" "$GREEN" "$line" "$RESET" done <"$LOGO_PATH" else center "${GREEN}Omarchy${RESET}" "$CONTENT_WIDTH" fi } # The icon uses half-block glyphs, scaled to the terminal's actual cell aspect # ratio so its square silhouette stays square. No graphics protocol is needed. # Coordinates follow the 15 x 15 grid of Omarchy's icon.svg. Each segment # paints the full stroke width, starting at the bottom middle of the outer ring. ICON_WIDTH=30 ICON_HEIGHT=15 CELL_WIDTH=8 CELL_HEIGHT=16 ICON_LEFT=1 ICON_STEPS=0 ICON_DRAWN=0 DISPLAY_PM=0 ICON_FRAME="" declare -a ICON_ORDER=() ICON_TRACE=() ICON_FILL=() icon_segment() { local scale="$1" x1="$2" y1="$3" x2="$4" y2="$5" local x y from to if (( y1 == y2 )); then from=$(( (x1 * ICON_WIDTH + 14) / 15 )) to=$(( ((x2 + 1) * ICON_WIDTH + 14) / 15 )) if (( x2 < x1 )); then from=$(( ((x1 + 1) * ICON_WIDTH + 14) / 15 - 1 )) to=$(( (x2 * ICON_WIDTH + 14) / 15 - 1 )) fi for ((x=from; x!=to; x+=(x2 >= x1 ? 1 : -1))); do for ((y=y1 * scale; y<(y1 + 1) * scale; y++)); do icon_pixel "$((y * ICON_WIDTH + x))" done done else from=$(( y1 * scale )) to=$(( (y2 + 1) * scale )) if (( y2 < y1 )); then from=$(( (y1 + 1) * scale - 1 )) to=$(( y2 * scale - 1 )) fi for ((y=from; y!=to; y+=(y2 >= y1 ? 1 : -1))); do for ((x=(x1 * ICON_WIDTH + 14) / 15; x<((x1 + 1) * ICON_WIDTH + 14) / 15; x++)); do icon_pixel "$((y * ICON_WIDTH + x))" done done fi } icon_pixel() { local pixel="$1" if [[ ! -v ICON_TRACE[pixel] ]]; then ICON_ORDER+=("$pixel") ICON_TRACE[pixel]=${#ICON_ORDER[@]} fi } build_icon() { local scale="$1" x y row local -a mask=( 111111111111111 100000010000001 101111110001101 101000000000101 101000000000101 101000000000101 101000000000101 111000000000101 101000000000101 101000000000101 101000000000101 101000000000101 101111111111101 100000010000001 111111110111111 ) ICON_WIDTH=$(( (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) )) ICON_HEIGHT=$(((15 * scale + 1) / 2)) ICON_ORDER=() ICON_TRACE=() ICON_FILL=() ICON_DRAWN=0 # Start at the bottom middle, follow the outer left edge to the top cross, # then descend into the inner shape. Fill its two bridges as we reach them. icon_segment "$scale" 7 14 0 14 icon_segment "$scale" 0 14 0 0 icon_segment "$scale" 0 0 7 0 icon_segment "$scale" 7 0 7 2 icon_segment "$scale" 7 2 2 2 icon_segment "$scale" 2 2 2 7 icon_segment "$scale" 2 7 0 7 icon_segment "$scale" 2 7 2 12 icon_segment "$scale" 2 12 7 12 icon_segment "$scale" 7 12 7 14 icon_segment "$scale" 7 12 12 12 icon_segment "$scale" 12 12 12 2 icon_segment "$scale" 12 2 11 2 # Finish from the top cross: right along the outside, down, then left. icon_segment "$scale" 7 0 14 0 icon_segment "$scale" 14 0 14 14 icon_segment "$scale" 14 14 9 14 ICON_STEPS=${#ICON_ORDER[@]} for ((y=0; y<15 * scale; y++)); do row=${mask[y / scale]} for ((x=0; x 0 )) && upper=90 (( step > 0 && step <= drawn )) && upper=32 step=${ICON_TRACE[pixel + ICON_WIDTH]:-0} (( step > 0 )) && lower=90 (( step > 0 && step <= drawn )) && lower=32 fi if (( upper == 0 && lower == 0 )); then ICON_FRAME+="${RESET} " elif (( upper == lower )); then ICON_FRAME+="${CSI}${upper};49m█" elif (( lower == 0 )); then ICON_FRAME+="${CSI}${upper};49m▀" elif (( upper == 0 )); then ICON_FRAME+="${CSI}${lower};49m▄" else ICON_FRAME+="${CSI}${upper};$((lower + 10))m▀" fi } render_icon() { local pm="$1" full="${2:-0}" drawn i pixel row col drawn=$((pm * ICON_STEPS / 1000)) ICON_FRAME="" if (( full || pm == 1000 )); then for ((row=0; row/dev/null) (( ${#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 POS=1000 PROGRESS_PM=1000 return 0 fi # 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") 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 ;; "Staging provisioning") lo=850 hi=855 tau=5 ;; "Finalizing Limine boot") lo=855 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=998 tau=5 ;; "Creating factory snapshot") lo=998 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 (( 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 progress 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 } # Keep the original loading messages on their own clock, so progress jumps # cannot skip phrases and animation frame rate does not change their cadence. current_whimsy() { printf '%s' "${whimsy[$(( (NOW - DASH_T0) / 4 % ${#whimsy[@]} ))]}" } # One tip per 8 seconds, in order. Use elapsed time, since rendering can stretch # under pacstrap I/O, when the tips may be the most visible sign of activity. current_tip() { printf '%s' "${tips[$(( (NOW - DASH_T0) / 8 % ${#tips[@]} ))]}" } # Vertically center a block of the given height, echoing the row it starts on. # Center both the progress icon and the final wordmark on the console. centered_top_row() { local height="$1" rows top rows="$(term_size)" rows="${rows%% *}" [[ $rows =~ ^[0-9]+$ ]] || rows=24 top=$(( (rows - height) / 2 )) (( top < 0 )) && top=0 printf '%d' "$(( top + 1 ))" } measure_progress_layout() { local rows cols scale=1 LAST_SIZE="$(term_size)" read -r rows cols <<<"$LAST_SIZE" read -r CELL_WIDTH CELL_HEIGHT < <(term_cell_size 2>/dev/null || printf '8 16\n') (( rows >= 34 && cols >= 49 )) && scale=2 (( rows >= 50 && cols >= 64 )) && scale=3 while (( scale > 1 && (15 * scale * CELL_HEIGHT + CELL_WIDTH) / (2 * CELL_WIDTH) > cols - 4 )); do scale=$((scale - 1)) done build_icon "$scale" TOP_ROW="$(centered_top_row "$((ICON_HEIGHT + 6))")" ICON_LEFT=$(( (cols - ICON_WIDTH) / 2 + 1 )) (( ICON_LEFT < 1 )) && ICON_LEFT=1 PROGRESS_TEXT_WIDTH=$((cols - 2)) (( PROGRESS_TEXT_WIDTH > CONTENT_WIDTH )) && PROGRESS_TEXT_WIDTH=$CONTENT_WIDTH return 0 } render_static() { measure_progress_layout printf '%s' "$CLEAR" render_icon "$DISPLAY_PM" 1 } update_progress() { set_now install_progress local pm=$PROGRESS_PM [[ $pm =~ ^[0-9]+$ ]] || pm=$LAST_PM # Only the child's successful exit can complete the drawing. (( pm > 999 )) && pm=999 # POS is monotone by construction; this is a guard, not the mechanism. (( pm < LAST_PM )) && pm=$LAST_PM LAST_PM=$pm } render_dynamic() { local step message tip # The framebuffer can settle late in VMs. Rebuild at the new size, preserving # progress, instead of leaving the drawing at the old screen coordinates. [[ "$(term_size)" == "$LAST_SIZE" ]] || render_static # Ease phase jumps over several frames, with the percentage following the # same position as the trace. The measured progress still never goes back. step=$(( (LAST_PM - DISPLAY_PM + 3) / 4 )) DISPLAY_PM=$((DISPLAY_PM + step)) render_icon "$DISPLAY_PM" message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")" tip="$(truncate_for_width "$(omarchy_text "Tip: %s" "$(current_tip)")" "$PROGRESS_TEXT_WIDTH")" printf '%s%d;1H' "$CSI" "$((TOP_ROW + ICON_HEIGHT + 1))" center "${GREEN}$((DISPLAY_PM / 10))%${RESET}" "$PROGRESS_TEXT_WIDTH" center "${GREEN}${message}${RESET}" "$PROGRESS_TEXT_WIDTH" blank_line center "${DIM}${tip}${RESET}" "$PROGRESS_TEXT_WIDTH" printf '%s' "$CLEAR_TO_END" } install_duration() { local path line duration if [[ -f $STATE_FILE ]]; then duration="$(jq -r ' ((.started_at // 0) | tonumber? // 0) as $started | ((.finished_at // 0) | tonumber? // 0) as $finished | if $started <= 0 or $finished <= 0 or $finished < $started then empty else (($finished - $started) | round) as $secs | if $secs >= 3600 then "\($secs / 3600 | floor)h \($secs % 3600 / 60 | floor)m \($secs % 60)s" else "\($secs / 60 | floor)m \($secs % 60)s" end end ' "$STATE_FILE" 2>/dev/null || true)" [[ -n $duration ]] && { printf '%s' "$duration"; return 0; } fi for path in /mnt/var/log/omarchy-install.log "$LOG_FILE"; do [[ -f $path ]] || continue for prefix in Total Omarchy; do line="$(grep "^$prefix:" "$path" 2>/dev/null | tail -n 1 || true)" [[ -n $line ]] || continue duration="${line#*:}" duration="${duration#${duration%%[![:space:]]*}}" [[ -n $duration ]] && { printf '%s' "$duration"; return 0; } done done return 0 } render_finish() { local duration effect_canvas_width title duration="$(install_duration || true)" if [[ -n $duration ]]; then title=$(omarchy_text "Installed Omarchy in %s" "$duration") else title=$(omarchy_text "Installed Omarchy") fi # ttfx centers its text two columns right of plain centering. A canvas two # columns narrower aligns the animation with the title and fallback logo. effect_canvas_width="$(term_cols)" (( effect_canvas_width > 2 )) && effect_canvas_width=$((effect_canvas_width - 2)) # Set before anything that can fail, so reboot_prompt still lands on the # button row even if the logo or the effect below never renders. FINISH_TOP_ROW="$(centered_top_row "$FINISH_HEIGHT")" { # Leave the wordmark area blank: laseretch reveals it for the first time. printf '%s%s%s%d;1H' "$HIDE_CURSOR" "$CLEAR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 1))" center "$title" "$CONTENT_WIDTH" blank_line } >"$TTY_PATH" if [[ -f $LOGO_PATH ]]; then # Reuse mode restores a saved cursor before moving up over its input. Save # this anchor so the effect occupies the reserved wordmark rows. printf '%s%d;1H\0337' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT))" >"$TTY_PATH" # Laseretch runs ~4s at 260fps, so the 8s cap is a stuck-process guard rather # than a trim. --xterm-colors keeps the whole effect in the 256-index palette # the way the greeter does, since the console reduces indexed codes well and # truecolor ones badly. It settles on indexed 2, the Tokyo Night green used # throughout the installer. The laser and sparks keep their own hot palette. timeout 8s ttfx -i "$LOGO_PATH" \ --canvas-width "$effect_canvas_width" \ --anchor-text c \ --frame-rate 260 \ --reuse-canvas \ --xterm-colors \ laseretch \ --final-gradient-stops 2 \ >"$TTY_PATH" 2>/dev/null || { dashboard_log "logo effect skipped (ttfx exited $?)" { printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH" } else { printf '%s%d;1H' "$CSI" "$FINISH_TOP_ROW"; render_logo; } >"$TTY_PATH" fi printf '%s%s%d;1H' "$SHOW_CURSOR" "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 5))" >"$TTY_PATH" } reboot_prompt() { local prompt_pad label # Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still # applies, so setting both stops on the finish screen without a keyboard. interactive || return 0 # gum paints its prompt row at the cursor and the button two rows under it, so # anchor on the row right below the title to put the button as close to it as # gum allows. FINISH_HEIGHT budgets for the same three rows. printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH" # gum pads the button label by four columns on each side. label=$(omarchy_text "Reboot Now") prompt_pad=$(( ( $(term_cols) - $(visible_len "$label") - 8 ) / 2 )) (( prompt_pad < 0 )) && prompt_pad=0 gum confirm \ --padding "0 0 0 $prompt_pad" \ --show-help=false \ --default \ --affirmative "$label" \ --negative "" \ "" <"$TTY_PATH" >"$TTY_PATH" 2>&1 } # Columns, not characters: each CJK character takes two. truncate_for_width() { local text="$1" width="$2" if (( $(printf '%s' "$text" | wc -L) <= width )); then printf '%s' "$text" return fi text=${text:0:$((width - 1))} while (( $(printf '%s' "$text" | wc -L) > width - 1 )); do text=${text%?} done printf '%s…' "$text" } render_failure_log_tail() { # The caller passes the rows it spent above this point that the constant below # does not know about, so the tail gives way rather than pushing the top of # the screen off a short console. local reserved="${1:-0}" rows lines width line tail_log label rows=$(term_size) rows=${rows%% *} lines=$((rows - LOGO_HEIGHT - 18 - reserved)) (( lines < 6 )) && lines=6 (( lines > 14 )) && lines=14 width=$((CONTENT_WIDTH - 4)) (( width < 40 )) && width=40 tail_log="${OMARCHY_FAILURE_TAIL_LOG:-}" if [[ -z $tail_log ]]; then if [[ -f /mnt/var/log/omarchy-install.log ]]; then tail_log=/mnt/var/log/omarchy-install.log else tail_log="$LOG_FILE" fi fi [[ -f $tail_log ]] || return 0 label=$(omarchy_text "Last log lines:") [[ $tail_log == /mnt/* ]] && label=$(omarchy_text "Last target log lines:") line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}" printf '\n' tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do line_at "$CONTENT_WIDTH" 2 "$(truncate_for_width "$line" "$width")" printf '\n' done } find_log_uploader() { local candidate for candidate in omarchy-upload-log /usr/local/bin/omarchy-upload-log /usr/bin/omarchy-upload-log /mnt/usr/bin/omarchy-upload-log; do if [[ $candidate == */* ]]; then [[ -x $candidate ]] && { printf '%s' "$candidate"; return 0; } elif command -v "$candidate" >/dev/null 2>&1; then command -v "$candidate" return 0 fi done return 1 } prompt_enter() { printf '\n%s' "$(omarchy_text "Press Enter to continue…")" >"$TTY_PATH" IFS= read -r _ <"$TTY_PATH" || true } upload_failure_log() { local uploader printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true if uploader=$(find_log_uploader); then "$uploader" install >"$TTY_PATH" 2>&1 || true else { printf '%s\n' "$(omarchy_text "No log uploader is available in this environment.")" printf '%s\n' "$(omarchy_text "Install log: %s" "$LOG_FILE")" [[ -f /mnt/var/log/omarchy-install.log ]] && printf '%s\n' "$(omarchy_text "Target log: %s" /mnt/var/log/omarchy-install.log)" } >"$TTY_PATH" fi prompt_enter } view_failure_log() { printf '%s%s' "$SHOW_CURSOR" "$CLEAR" >"$TTY_PATH" 2>/dev/null || true if command -v less >/dev/null 2>&1 && [[ -f $LOG_FILE ]]; then less "$LOG_FILE" <"$TTY_PATH" >"$TTY_PATH" 2>&1 || true else tail -n 200 "$LOG_FILE" >"$TTY_PATH" 2>&1 || true prompt_enter fi } # A failure the medium explains is worth saying before the log tail, because # the tail is where "invalid or corrupted package" already scrolled past # without ever naming the USB stick it came off. diagnose_media() { local logs=("$LOG_FILE") [[ -f /mnt/var/log/omarchy-install.log ]] && logs+=(/mnt/var/log/omarchy-install.log) [[ -n ${OMARCHY_FAILURE_TAIL_LOG:-} ]] && logs+=("$OMARCHY_FAILURE_TAIL_LOG") omarchy-install-diagnose-media "${logs[@]}" 2>/dev/null } # Rows the diagnosis occupies, its leading blank included. media_diagnosis_rows() { local diagnosis="$1" if [[ -z $diagnosis ]]; then printf '0\n' else printf '%s\n' "$(( $(grep -c '' <<<"$diagnosis") + 1 ))" fi } render_media_diagnosis() { local diagnosis="$1" line first=1 [[ -n $diagnosis ]] || return 0 blank_line while IFS= read -r line; do # A blank line inside the diagnosis is deliberate: it separates what # happened from what to do about it, so it is drawn rather than skipped. if [[ -z $line ]]; then blank_line continue fi # The support log keeps English; the screen shows the translation, and package names pass through. [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]] && line=${OMARCHY_ZH[$line]:-$line} if (( first )); then center "${RED}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH" first=0 else center "$(truncate_for_width "$line" "$CONTENT_WIDTH")" "$CONTENT_WIDTH" fi done <<<"$diagnosis" } render_failure() { local status="$1" state_summary="${2:-}" media_diagnosis="${3:-}" line printf '%s%s' "$SHOW_CURSOR" "$CLEAR" blank_line render_logo blank_line center "${RED}$(omarchy_text "Omarchy installation stopped")${RESET}" "$CONTENT_WIDTH" center "$(omarchy_text "Installer exited with status %s" "$status")" "$CONTENT_WIDTH" render_media_diagnosis "$media_diagnosis" if [[ -n $state_summary ]]; then blank_line while IFS= read -r line; do [[ -n $line ]] || continue center "${DIM}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH" done <<<"$state_summary" fi blank_line render_failure_log_tail "$(media_diagnosis_rows "$media_diagnosis")" blank_line center "${DIM}$(omarchy_text "Get help at https://omarchy.org/discord")${RESET}" "$CONTENT_WIDTH" blank_line } failure_menu() { local choice uploader actions shown i [[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0 # The failure screen and log tail have already rendered; exit with the # installer's status instead of blocking on a menu nobody can answer. interactive || return 0 while true; do uploader="" uploader=$(find_log_uploader || true) actions=("View full log" "Drop to shell" "Reboot" "Power off") [[ -n $uploader ]] && actions=("Upload log for support" "${actions[@]}") shown=() for i in "${!actions[@]}"; do shown+=("$(omarchy_text "${actions[i]}")") done choice=$(gum choose \ --height "$((${#actions[@]} + 1))" \ --header "$(omarchy_text "What would you like to do?")" \ "${shown[@]}" \ <"$TTY_PATH" 2>"$TTY_PATH") || choice="" for i in "${!shown[@]}"; do [[ ${shown[i]} == "$choice" ]] && choice=${actions[i]} done case "$choice" in "Upload log for support") upload_failure_log render_failure "${failure_status:-1}" "${failure_summary:-}" "${failure_media_diagnosis:-}" >"$TTY_PATH" 2>/dev/null || true ;; "View full log") view_failure_log render_failure "${failure_status:-1}" "${failure_summary:-}" "${failure_media_diagnosis:-}" >"$TTY_PATH" 2>/dev/null || true ;; "Reboot") reboot 2>/dev/null || systemctl reboot 2>/dev/null || true ;; "Power off") poweroff 2>/dev/null || systemctl poweroff 2>/dev/null || true ;; "Drop to shell"|"") return 0 ;; esac done } sanitize_child_output() { # Archinstall is only a library subsystem in Omarchy's installer. Its own # post-install success/reboot banner can be buffered until process exit and # appear after an Omarchy failure, which makes the unified log contradictory. # Keep that third-party banner out of Omarchy's support log; the raw # archinstall log is still uploaded separately by omarchy-upload-log. sed -u \ -e $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' \ -e '/^Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers$/d' \ -e '/^Enabling service systemd-timesyncd$/d' \ -e '/^Setting password for root$/d' \ -e '/^Updating \/mnt\/etc\/fstab$/d' \ -e '/^Syncing the system\.\.\.$/d' \ -e '/^Installation completed without any errors\.$/d' \ -e '/^Log files temporarily available at \/var\/log\/archinstall\.$/d' \ -e '/^You may reboot when ready\.$/d' } launch_child() { mkdir -p "$(dirname "$LOG_FILE")" # Child is non-interactive. All visible UI belongs to this supervisor. PYTHONUNBUFFERED=1 setsid "${INSTALL_CMD[@]}" >(sanitize_child_output >>"$LOG_FILE") 2>&1 & child_pid=$! child_pgid=$child_pid } [[ -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 # Keep package counting/state reads at 2 Hz; animate the trace at 10 Hz. (( frame % 5 == 0 )) && update_progress render_dynamic >"$TTY_PATH" 2>/dev/null || true frame=$((frame + 1)) sleep 0.1 done set +e wait "$child_pid" status=$? set -e child_pid="" child_pgid="" dashboard_log "installer child exited with status $status" if (( status == 0 )); then # Finish the trace even when a short-lived child exits between state polls. LAST_PM=1000 PHASE_NAME="Installation complete" while (( DISPLAY_PM < 999 )); do render_dynamic >"$TTY_PATH" 2>/dev/null || true sleep 0.05 done DISPLAY_PM=1000 render_dynamic >"$TTY_PATH" 2>/dev/null || true sleep 0.4 fi if (( status != 0 )); then failure_status="$status" failure_summary="" if [[ -f $STATE_FILE ]]; then failure_summary="$(jq -r ' (.current_phase // empty | "last installer phase: \(.)"), ((.phases // []) | map(select(.status == "failed")) | last // empty | "failed phase: \(.name): \(.error)") ' "$STATE_FILE" 2>/dev/null || true)" if [[ -n $failure_summary ]]; then while IFS= read -r line; do [[ -n $line ]] && dashboard_log "$line" done <<<"$failure_summary" fi fi failure_media_diagnosis="$(diagnose_media || true)" render_failure "$failure_status" "$failure_summary" "$failure_media_diagnosis" >"$TTY_PATH" 2>/dev/null || true # Recorded after the screen is drawn, not before: the log tail on that screen # reads this same file, and the diagnosis printed twice reads as two findings. if [[ -n $failure_media_diagnosis ]]; then while IFS= read -r line; do [[ -n $line ]] && dashboard_log "$line" done <<<"$failure_media_diagnosis" fi failure_menu exit "$failure_status" fi # Deferred-provisioning installs finish silently and reboot on their own — the # owner completes setup (and sees the send-off) at first boot, so the "Installed # Omarchy in ..." celebration and the Reboot Now prompt belong to a direct # install only. if [[ ${OMARCHY_UI_DEFER_PROVISIONING:-} == "yes" ]]; then printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true [[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]] && exit 0 reboot 2>/dev/null || systemctl reboot 2>/dev/null || true exit 0 fi set +e render_finish finish_render_status=$? set -e if (( finish_render_status != 0 )); then dashboard_log "finish screen render failed with status $finish_render_status; using fallback" { printf '%s%s' "$SHOW_CURSOR" "$CLEAR" printf '\nFinished installing\n\n' } >"$TTY_PATH" 2>/dev/null || true fi if reboot_prompt; then if [[ ${OMARCHY_UI_AUTO_REBOOT:-} == "no" ]]; then exit 0 fi printf '%s' "$CLEAR" >"$TTY_PATH" 2>/dev/null || true reboot 2>/dev/null || systemctl reboot 2>/dev/null || true fi exit 0