Files
omarchycn/bin/omarchy-provision-owner
T
David Heinemeier HanssonandClaude Opus 5 f228c4d390 Stop checking for commands the package set guarantees
gum and ttfx ship in omarchy-base.packages and setfont comes with kbd, so
none of them needs a presence check — and every setfont call already
tolerates a failure anyway. btrfs-progs is in omarchy-other.packages and a
reset genuinely cannot proceed without it, so that one keeps its guard and
moves to the helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 03:31:17 -07:00

1088 lines
40 KiB
Bash
Executable File

#!/bin/bash
# omarchy:summary=First-boot provisioning: create the user on a machine installed in deferred provisioning
# omarchy:requires-sudo=true
# omarchy:hidden=true
# Runs on tty1 via omarchy-provision-owner.service when /var/lib/omarchy/provisioning/pending
# exists — the state a deferred-provisioning ISO install or omarchy-system-factory-reset leaves
# behind. Asks for the user (the configurator's Step 2), creates it with the
# groups system setup recorded, finalizes it offline from the stashed Node
# tarball, re-keys LUKS from the throwaway install passphrase to the user's
# password, and hands off to SDDM.
set -euo pipefail
PROVISIONING_DIR=/var/lib/omarchy/provisioning
OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
LOG_FILE=/var/log/omarchy-provision-owner.log
if (( EUID != 0 )); then
echo "Error: omarchy-provision-owner must run as root" >&2
exit 1
fi
[[ -f $PROVISIONING_DIR/pending ]] || exit 0
# Never set up a user on a half-wiped system: while a factory wipe is still
# pending (i.e. it failed this boot), stand down and let it retry next boot.
if [[ -f $PROVISIONING_DIR/wipe-pending ]]; then
echo "omarchy-provision-owner: factory wipe still pending; not running user setup" >&2
exit 1
fi
export PATH="$OMARCHY_PATH/bin:$PATH"
# The shared setup form — the same questions, prompts, and validation the ISO
# configurator's user step asks. The ISO vendors this very file out of the
# runtime package it bundles, so an install and the first boot that finishes it
# can never offer different layouts or accept different usernames.
source "$OMARCHY_PATH/install/provisioning/setup-form.sh"
# Unlike a fresh install target, this machine already has accounts; a pinned
# resume (below) bypasses the prompt entirely.
omarchy_username_taken() { getent passwd "$1" >/dev/null; }
LOGO_PATH="$OMARCHY_PATH/logo.txt"
LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH" 2>/dev/null || echo 0)
LOGO_HEIGHT=$(wc -l <"$LOGO_PATH" 2>/dev/null || echo 0)
(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=81
(( LOGO_HEIGHT > 0 )) || LOGO_HEIGHT=1
export GUM_CONFIRM_PROMPT_FOREGROUND="6"
export GUM_CONFIRM_SELECTED_FOREGROUND="0"
export GUM_CONFIRM_SELECTED_BACKGROUND="2"
export GUM_CONFIRM_UNSELECTED_FOREGROUND="7"
export GUM_CONFIRM_UNSELECTED_BACKGROUND="0"
# Match the first-boot console font to the ISO installer's apparent text size.
# The installer renders on GRUB's low-resolution console (gfxpayload=keep, on
# the ~768-row EFI GOP mode) where the default 8x16 font fills the screen. The
# installed system reaches native KMS resolution, where 8x16 is tiny — so
# first-boot provisioning looked far smaller than the installer it continues.
#
# Pick the kbd console font whose row count lands closest to that installer feel
# (~48 rows), but never one wide enough to wrap the 81-column logo. On a console
# that is already installer-sized (low resolution) this lands on the default
# font, i.e. a no-op. All three fonts ship with kbd, so there's no dependency.
scale_console_font() {
[[ $(tty 2>/dev/null) == /dev/tty* ]] || return 0
# Pick the console font empirically: apply each candidate, read the columns
# fbcon actually hands back, and keep the one whose row count lands nearest a
# ~48-row target while still leaving room for the 81-column logo. Measuring
# the real result beats computing from /sys/class/graphics/fb0/virtual_size,
# which under virgl can report a size the text console never actually reaches —
# that mismatch is what wrapped the logo into the top-left corner.
local target_rows=48
local fonts=("default8x16" "sun12x22" "latarcyrheb-sun32")
local best="" best_diff=999999 name rows cols diff
for name in "${fonts[@]}"; do
setfont "$name" 2>/dev/null || continue
read -r rows cols < <(stty size </dev/tty 2>/dev/null || printf '0 0\n')
[[ $rows =~ ^[0-9]+$ && $cols =~ ^[0-9]+$ ]] || continue
(( cols > LOGO_WIDTH )) || continue # must clear the 81-col logo
diff=$(( rows > target_rows ? rows - target_rows : target_rows - rows ))
(( diff < best_diff )) && { best_diff=$diff; best="$name"; }
done
# Fall back to the narrowest font if nothing cleared the logo width, so the
# form stays usable even on an unexpectedly tiny console.
setfont "${best:-default8x16}" 2>/dev/null || true
}
# Same Tokyo Night VT palette the ISO configurator sets, so the first-boot
# form looks like a continuation of the install.
set_tokyo_night_colors() {
[[ $(tty 2>/dev/null) == /dev/tty* ]] || return 0
echo -en "\e]P01a1b26"; echo -en "\e]P1f7768e"; echo -en "\e]P29ece6a"
echo -en "\e]P3e0af68"; echo -en "\e]P47aa2f7"; echo -en "\e]P5bb9af7"
echo -en "\e]P67dcfff"; echo -en "\e]P7a9b1d6"; echo -en "\e]P8414868"
echo -en "\e]P9f7768e"; echo -en "\e]PA9ece6a"; echo -en "\e]PBe0af68"
echo -en "\e]PC7aa2f7"; echo -en "\e]PDbb9af7"; echo -en "\e]PE7dcfff"
echo -en "\e]PFc0caf5"
echo -en "\033[0m"
clear
}
measure_terminal() {
TERM_WIDTH=$(stty size 2>/dev/null </dev/tty | awk '{print $2}')
(( TERM_WIDTH > 0 )) || TERM_WIDTH=${COLUMNS:-80}
PADDING_LEFT=$(((TERM_WIDTH - LOGO_WIDTH) / 2))
(( PADDING_LEFT < 0 )) && PADDING_LEFT=0
PADDING_LEFT_SPACES=$(printf "%*s" "$PADDING_LEFT" "")
local padding="0 0 0 $PADDING_LEFT"
export GUM_CHOOSE_PADDING="$padding"
export GUM_INPUT_PADDING="$padding"
export GUM_SPIN_PADDING="$padding"
export GUM_TABLE_PADDING="$padding"
export GUM_CONFIRM_PADDING="$padding"
}
clear_logo() {
measure_terminal
printf "\033[H\033[2J"
gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")"
}
step() {
clear_logo
echo
gum style --padding "0 0 0 $PADDING_LEFT" "$1"
echo
}
say() {
gum style --padding "0 0 0 $PADDING_LEFT" "$@"
}
notice() {
clear_logo
echo
gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}"
echo
}
log_step() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] oem-setup: $1" >>"$LOG_FILE"
}
# ── Setup progress screen ────────────────────────────────────────────────────
#
# A port of the ISO install dashboard's renderer, so first-boot account setup
# looks like a continuation of the install: same logo header, same 34-cell
# bar, same rotating tips. Position = max(floor, work) in
# per-mille, monotonic; the floor is asymptotic per phase band and the work
# signal counts finalize-user's run_logged scripts in the log.
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"
WHITE="${CSI}37m"
DARK="${CSI}90m"
STATE_FILE=/run/omarchy-provision-owner.state
FINALIZE_WARNING_FLAG=/run/omarchy-provision-owner.finalize-warning
DYNAMIC_ROW=$((LOGO_HEIGHT + 3))
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"
)
term_cols() {
local cols
cols=$(stty size 2>/dev/null </dev/tty | awk '{print $2}')
[[ $cols =~ ^[0-9]+$ ]] && (( cols > 0 )) || cols=${COLUMNS:-80}
printf '%s' "$cols"
}
# A fingerprint of the console geometry: the VT size plus the framebuffer's
# identity and pixel size. On a fresh first boot the VT can come up in a
# transitional ~80x25 mode and only widen to the real resolution a second or
# more later, when virtio-gpu's KMS takes over (or the SDL window's size lands).
# Watching this signature settle — rather than trusting one early reading — is
# what keeps the greeter from measuring that transient and rendering into it.
console_signature() {
local size fb_size="" fb_name=""
size=$(stty size 2>/dev/null </dev/tty) || return 1
[[ $size =~ ^[0-9]+\ [0-9]+$ ]] || return 1
[[ -r /sys/class/graphics/fb0/virtual_size ]] && fb_size=$(<"/sys/class/graphics/fb0/virtual_size")
[[ -r /sys/class/graphics/fb0/name ]] && fb_name=$(<"/sys/class/graphics/fb0/name")
printf '%s|%s|%s' "$size" "$fb_name" "$fb_size"
}
# Block until the console signature holds unchanged for a genuine quiet stretch
# (quiet_samples * 100ms), giving up after max_samples * 100ms. Returns 0 once
# quiet, 1 on timeout.
wait_console_stable() {
local max_samples="${1:-100}" quiet_samples="${2:-15}"
local previous="" current="" quiet=0 i
for ((i = 0; i < max_samples; i++)); do
current=$(console_signature 2>/dev/null || true)
if [[ -n $current && $current == "$previous" ]]; then
quiet=$((quiet + 1))
(( quiet >= quiet_samples )) && return 0
else
previous=$current
quiet=0
fi
sleep 0.1
done
return 1
}
repeat() {
local char="$1" count="$2" out="" i
for ((i = 0; i < count; i++)); do out+="$char"; done
printf '%s' "$out"
}
left_padding() {
local width="${1:-$LOGO_WIDTH}" cols pad
cols=$(term_cols)
pad=$(((cols - width) / 2))
(( pad < 0 )) && pad=0
printf '%*s' "$pad" ''
}
visible_len() {
local text="$1"
text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')"
printf '%s' "${#text}"
}
blank_line() { printf '\r%s\n' "$CLEAR_LINE"; }
line_at() {
local width="${1:-$LOGO_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:-$LOGO_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 line
pad="$(left_padding "$LOGO_WIDTH")"
while IFS= read -r line; do
printf '\r%s%s%b%s%b\n' "$CLEAR_LINE" "$pad" "$GREEN" "$line" "$RESET"
done <"$LOGO_PATH"
}
progress_bar() {
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"
}
# Monotonic clock so an NTP step during setup cannot jump the bar.
set_now() {
local up rest
if read -r up rest </proc/uptime 2>/dev/null && [[ $up == [0-9]* ]]; then
NOW=${up%%.*}
else
NOW=$EPOCHSECONDS
fi
return 0
}
# One tip per 8 seconds, elapsed-time driven like the install dashboard.
current_tip() {
printf '%s' "${tips[$(((NOW - SETUP_T0) / 8 % ${#tips[@]}))]}"
}
SETUP_POS=10
SETUP_PHASE=""
SETUP_PHASE_T0=0
SETUP_T0=0
NOW=0
FINALIZE_BASE=-1
FINALIZE_TOTAL=$(grep -c '^run_logged' "$OMARCHY_PATH/install/user/all.sh" 2>/dev/null || echo 0)
# Whether this boot will re-key LUKS (encrypted installs stage a throwaway key).
# The re-key rebuilds the UKI and is the slowest single step, so it needs a wide
# band of its own; unencrypted installs skip it and let finalize take the room.
REKEY_PENDING=false
[[ -f $PROVISIONING_DIR/luks-key ]] && REKEY_PENDING=true
# Per-mille bands per phase: "lo hi tau". tau shapes the asymptotic time floor;
# it is not a duration prediction. A wide band moves visibly; a narrow one looks
# stuck. Skipped phases leave a gap the next band's floor crosses on its own.
phase_band() {
if $REKEY_PENDING; then
# Encrypted: finalize is quick, the LUKS re-key (UKI rebuild) is the long
# pole — give it nearly half the bar so it keeps moving while it works.
case $1 in
finalize) echo "80 480 40" ;;
rekey) echo "480 950 30" ;;
boot) echo "950 990 20" ;;
done) echo "1000 1000 1" ;;
*) echo "10 80 3" ;;
esac
else
# Unencrypted: finalize is the dominant step.
case $1 in
finalize) echo "80 900 45" ;;
boot) echo "900 990 20" ;;
done) echo "1000 1000 1" ;;
*) echo "10 80 3" ;;
esac
fi
}
setup_progress() {
local phase lo hi tau t span floor work completed
phase=$(cat "$STATE_FILE" 2>/dev/null || true)
[[ -n $phase ]] || phase=account
if [[ $phase != "$SETUP_PHASE" ]]; then
SETUP_PHASE=$phase
SETUP_PHASE_T0=$NOW
FINALIZE_BASE=-1
fi
read -r lo hi tau <<<"$(phase_band "$phase")"
(( lo < SETUP_POS )) && lo=$SETUP_POS
t=$((NOW - SETUP_PHASE_T0))
span=$((hi - lo))
floor=$lo
(( span > 1 )) && floor=$((lo + (span - 1) * t / (t + tau)))
work=0
if [[ $phase == "finalize" ]] && (( FINALIZE_TOTAL > 0 )); then
completed=$(grep -cE '] (Completed|Failed): ' "$LOG_FILE" 2>/dev/null || true)
[[ $completed =~ ^[0-9]+$ ]] || completed=0
(( FINALIZE_BASE < 0 )) && FINALIZE_BASE=$completed
completed=$((completed - FINALIZE_BASE))
(( completed < 0 )) && completed=0
(( completed > FINALIZE_TOTAL )) && completed=$FINALIZE_TOTAL
work=$((lo + (hi - lo) * completed / FINALIZE_TOTAL))
fi
(( floor > SETUP_POS )) && SETUP_POS=$floor
(( work > SETUP_POS )) && SETUP_POS=$work
(( SETUP_POS > 1000 )) && SETUP_POS=1000
return 0
}
render_setup_static() {
printf '%s%s' "$HIDE_CURSOR" "$CLEAR"
blank_line
render_logo
blank_line
}
render_setup_dynamic() {
set_now
setup_progress
printf '%s%d;1H' "$CSI" "$DYNAMIC_ROW"
center "Setting up your machine" "$LOGO_WIDTH"
blank_line
line_at "$LOGO_WIDTH" $(((LOGO_WIDTH - 34) / 2)) ""
progress_bar "$SETUP_POS" 34
printf '\n'
blank_line
center "${DIM}Tip:${RESET} ${GREEN}$(current_tip)${RESET}" "$LOGO_WIDTH"
printf '%s' "$CLEAR_TO_END"
}
# The first thing a new owner sees, shown once before the keyboard step:
# the logo (vertically centered like the boot logo) running a looping ColorShift,
# the Omarchy tagline, and a hint. Return skips ahead into setup at any time.
greeter_screen() {
local anim="" drawn_sig cur_sig resized=0
# Paint the whole splash for the console's *current* geometry. Kept in a
# nested function so the wait loop below can repaint it verbatim if the VT
# resizes out from under us: a late resize (virtio-gpu KMS handoff, or the SDL
# window's size arriving) scrolls the old frame into the top-left and resets
# the DEC saved cursor ttfx paints from — exactly the tiled/garbled logo we saw
# on a fresh first boot. Redrawing on every resize beats any fixed-length
# startup wait, which can only ever guess when the console has stopped moving.
_greeter_draw() {
local rows cols top content_h logo_row tagline_row hint_row
local tagline hint tpad hpad pad line i
cols=$(term_cols)
rows=$(stty size 2>/dev/null </dev/tty | awk '{print $1}')
[[ $rows =~ ^[0-9]+$ ]] || rows=${LINES:-24}
tagline="Beautiful, Modern & Opinionated Linux by DHH"
hint="Press Return to Start Setup"
printf '%s%s' "$HIDE_CURSOR" "$CLEAR"
# Console still too narrow for the 81-column logo (a transient small mode):
# skip the logo and animation rather than wrap them into mush — just center
# the words. The redraw loop repaints the full splash once the VT widens.
if (( cols <= LOGO_WIDTH )); then
local mid=$(( rows / 2 ))
tpad=$(( (cols - ${#tagline}) / 2 )); (( tpad < 0 )) && tpad=0
hpad=$(( (cols - ${#hint}) / 2 )); (( hpad < 0 )) && hpad=0
printf '%s%d;%dH%s' "$CSI" "$mid" "$((tpad + 1))" "$tagline"
printf '%s%d;%dH%s%s%s' "$CSI" "$((mid + 2))" "$((hpad + 1))" "$DIM" "$hint" "$RESET"
return 0
fi
# logo + blank + tagline + blank + hint
content_h=$(( LOGO_HEIGHT + 4 ))
top=$(( (rows - content_h) / 2 )); (( top < 0 )) && top=0
logo_row=$(( top + 1 ))
tagline_row=$(( top + LOGO_HEIGHT + 2 ))
hint_row=$(( top + LOGO_HEIGHT + 4 ))
# Draw each logo row at an explicit column so nothing can wrap it even if a
# measurement is off by one; left_padding centers it for the current width.
pad="$(left_padding "$LOGO_WIDTH")"
i=0
while IFS= read -r line; do
printf '%s%d;1H%s%b%s%b' "$CSI" "$((logo_row + i))" "$pad" "$GREEN" "$line" "$RESET"
i=$((i + 1))
done <"$LOGO_PATH"
tpad=$(( (cols - ${#tagline}) / 2 )); (( tpad < 0 )) && tpad=0
printf '%s%d;%dH%s' "$CSI" "$tagline_row" "$((tpad + 1))" "$tagline"
hpad=$(( (cols - ${#hint}) / 2 )); (( hpad < 0 )) && hpad=0
printf '%s%d;%dH%s%s%s' "$CSI" "$hint_row" "$((hpad + 1))" "$DIM" "$hint" "$RESET"
# ColorShift the logo: a green base (indexed color 2) with a cyan accent (6)
# drifting through, settling on green. Indexed ANSI colors, not hex — the
# framebuffer console can't render ttfx's truecolor faithfully (it crushes the
# palette to a muddy lavender), but the 16 indexed colors map to the Tokyo
# Night palette and render true. One long-running invocation (many cycles) so
# it never restarts — a restart is what flashed. The effect reads from
# /dev/null so it never swallows the Return the foreground read waits on;
# --reuse-canvas paints upward from the saved cursor, anchored one row below
# the logo to repaint exactly the rows drawn above. --canvas-width is cols-1
# to stay off the autowrap column.
printf '%s%d;1H\0337' "$CSI" "$((logo_row + LOGO_HEIGHT))"
# Run ttfx directly (not inside a `while` subshell) so $anim is ttfx's own
# PID: killing a wrapping subshell would orphan ttfx, which then keeps
# painting the logo over the keyboard step. --cycles is high enough that it
# never ends on its own before Return.
ttfx -i "$LOGO_PATH" \
--canvas-width "$((cols - 1))" \
--anchor-text c \
--frame-rate 60 \
--reuse-canvas \
colorshift \
--gradient-stops 2 10 6 10 \
--gradient-frames 3 \
--cycles 1000 \
--final-gradient-stops 2 \
</dev/null >/dev/tty 2>/dev/null &
anim=$!
}
_greeter_kill_anim() {
[[ -n ${anim:-} ]] || return 0
# Guard both: `kill` returns non-zero if ttfx already exited (crash, or a
# resize race), and `wait` reports ttfx's kill signal (143) — either would
# abort provisioning under `set -e` and drop straight to the login screen.
kill "$anim" 2>/dev/null || true
wait "$anim" 2>/dev/null || true
anim=""
}
# Let the console settle before the first paint, then size the font to it.
wait_console_stable 100 15 || true
scale_console_font
wait_console_stable 30 5 || true
trap 'resized=1' WINCH
_greeter_draw
drawn_sig=$(console_signature 2>/dev/null || true)
# Wait for Return, but keep watching the geometry. On any resize (SIGWINCH or
# a changed signature) tear the animation down, settle, re-fit the font, and
# repaint — so a resize arriving five seconds in looks the same as one that
# never happened.
while true; do
if IFS= read -r -t 0.2 _ </dev/tty; then
break
fi
cur_sig=$(console_signature 2>/dev/null || true)
if (( resized )) || [[ -n $cur_sig && $cur_sig != "$drawn_sig" ]]; then
_greeter_kill_anim
stty sane </dev/tty 2>/dev/null || true
wait_console_stable 50 5 || true
scale_console_font
wait_console_stable 30 5 || true
_greeter_draw
drawn_sig=$(console_signature 2>/dev/null || true)
# Clear last so the font-fitting's own SIGWINCHes don't re-trigger a redraw.
resized=0
fi
done
trap - WINCH
_greeter_kill_anim
# ttfx leaves the tty in raw/no-echo mode when killed; restore it or the gum
# prompts in the keyboard step that follows silently die. Then clear the
# leftover animation frame.
stty sane </dev/tty 2>/dev/null || true
printf '%s%s%s' "$RESET" "$CLEAR" "$SHOW_CURSOR"
}
# The keyboard step the ISO configurator runs — deferred to first boot for OEM
# installs, so the machine's owner picks their own layout. Applied immediately
# (live VT + persisted) so the password typed next, and the LUKS re-key below,
# use the chosen layout.
keyboard_form() {
local status
while true; do
step "Let's setup your keyboard..."
omarchy_prompt_keyboard && status=0 || status=$?
((status == 0)) && break
# Esc means "back", and nothing precedes the first screen, so re-ask.
((status == OMARCHY_FORM_BACK)) && continue
# Ctrl+C is the way out of a screen the owner cannot get past. Rebooting is
# safe but not an escape from setup: this service runs again at next boot,
# and encrypted machines still auto-unlock from the staged keyfile until the
# re-key below. Confirm it so a stray Ctrl+C doesn't bounce the machine.
confirm_reboot && exec systemctl reboot
done
apply_keyboard "$keyboard"
}
confirm_reboot() {
clear_logo
echo
say "Setup starts again after the reboot."
echo
gum confirm --affirmative "Yes, reboot" --negative "No, keep setting up" "Reboot this machine?"
}
# Load the layout on the live VT and persist it for the installed system.
# systemd-firstboot writes both the console KEYMAP and the XKB layout Hyprland
# reads, matching what the ISO's configure_keyboard does at install time. A
# keymap localectl doesn't know keeps the default rather than failing (defensive;
# the picker no longer offers any such layout).
apply_keyboard() {
local keymap="$1"
[[ $(tty 2>/dev/null) == /dev/tty* ]] && loadkeys "$keymap" 2>/dev/null || true
if localectl --no-pager list-keymaps 2>/dev/null | grep -qix "$keymap"; then
systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 || \
localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1 || \
log_step "could not persist keymap $keymap"
else
log_step "keymap $keymap unknown to localectl; keeping the default"
fi
}
# The same username/password/name/email form as the ISO configurator's Step 2.
user_form() {
step "Let's setup your user account..."
# A prior attempt that already created the account pins the username, so a
# retry cannot strand that account by choosing a different name.
if [[ -f $PROVISIONING_DIR/setup-user ]]; then
username=$(<"$PROVISIONING_DIR/setup-user")
say "Continuing setup for user: $username"
echo
else
omarchy_prompt_username || return $?
fi
omarchy_prompt_password || return $?
omarchy_prompt_identity || return $?
# Hostname and timezone are deferred to first boot with the rest of the user
# step: the deferred install seeds neutral placeholders (omarchy/UTC) and the
# owner overwrites them here.
omarchy_prompt_hostname || return $?
step "Let's set your timezone..."
omarchy_prompt_timezone || return $?
}
confirm_form() {
clear_logo
echo
echo -e "Field,Value
Keyboard,${keyboard_label:-English (US)}
Username,$username
Password,$(printf "%${#password}s" | tr ' ' '*')
Full name,${full_name:-[Skipped]}
Email address,${email_address:-[Skipped]}
Hostname,${hostname:-omarchy}
Timezone,${timezone:-UTC}" |
gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/"
echo
gum confirm --negative "No, change it" "Does this look right?"
}
# Groups recorded by omarchy-setup-system's scripts at install time
# (/var/lib/omarchy/provisioning/groups), filtered to groups that exist on this system.
user_groups() {
local groups="wheel" group
if [[ -f $PROVISIONING_DIR/groups ]]; then
while IFS= read -r group; do
[[ -n $group ]] || continue
getent group "$group" >/dev/null || continue
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
done <"$PROVISIONING_DIR/groups"
fi
echo "$groups"
}
# Resolve the crypto_LUKS partition backing the root, or return non-zero if
# the root is not on LUKS. Prefers the cmdline cryptdevice= spec (archinstall
# writes PARTUUID=, the pre-mounted path UUID=), and falls back to walking the
# device tree for roots reached via rd.luks/crypttab with a plain /dev/mapper
# root and no cryptdevice=.
luks_device() {
local spec
spec=$(grep -o 'cryptdevice=[^ :]*' /proc/cmdline | head -1 | cut -d= -f2-)
case $spec in
UUID=*) echo "/dev/disk/by-uuid/${spec#UUID=}"; return 0 ;;
PARTUUID=*) echo "/dev/disk/by-partuuid/${spec#PARTUUID=}"; return 0 ;;
LABEL=*) echo "/dev/disk/by-label/${spec#LABEL=}"; return 0 ;;
PARTLABEL=*) echo "/dev/disk/by-partlabel/${spec#PARTLABEL=}"; return 0 ;;
/dev/*) echo "$spec"; return 0 ;;
esac
# No cryptdevice=: walk the root source's ancestors (lsblk -s inverts the
# tree) for the first crypto_LUKS parent.
local src part
src=$(findmnt -no SOURCE / | sed 's/\[.*//')
[[ -n $src ]] || return 1
part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}')
[[ -n $part ]] && { echo "$part"; return 0; }
return 1
}
encrypted_install() {
luks_device >/dev/null 2>&1
}
create_user() {
# Pin the username so a retry after a later failure resumes this exact
# account rather than creating a second privileged one.
echo "$username" >"$PROVISIONING_DIR/setup-user"
if getent passwd "$username" >/dev/null; then
# Resuming a partially-completed earlier attempt: refresh what the form
# collected this time around.
usermod -aG "$(user_groups)" ${full_name:+-c "$full_name"} "$username"
else
useradd -m -G "$(user_groups)" -s /bin/bash \
${full_name:+-c "$full_name"} "$username"
fi
printf '%s:%s\n' "$username" "$password" | chpasswd
printf '%s:%s\n' root "$password" | chpasswd
# deferred-provisioning installs skip archinstall's create_users, which is what normally
# uncomments %wheel in /etc/sudoers. Always write the drop-in: detecting an
# existing grant is error-prone (omarchy ships narrow %wheel NOPASSWD rules
# for specific commands), and a duplicate grant is harmless.
echo "%wheel ALL=(ALL:ALL) ALL" >/etc/sudoers.d/00-omarchy-wheel
chmod 440 /etc/sudoers.d/00-omarchy-wheel
}
install_authorized_keys() {
[[ -f $PROVISIONING_DIR/authorized_keys ]] || return 0
local ssh_dir="/home/$username/.ssh"
mkdir -p "$ssh_dir"
cp "$PROVISIONING_DIR/authorized_keys" "$ssh_dir/authorized_keys"
chmod 700 "$ssh_dir"
chmod 600 "$ssh_dir/authorized_keys"
chown -R "$username:$username" "$ssh_dir"
}
configure_login() {
mkdir -p /var/lib/sddm
printf '[Last]\nSession=omarchy.desktop\nUser=%s\n' "$username" >/var/lib/sddm/state.conf
chown -R sddm:sddm /var/lib/sddm 2>/dev/null || true
# Autologin straight into the desktop after first-boot setup — the owner just
# authenticated in the form, so we don't make them retype at SDDM.
mkdir -p /etc/sddm.conf.d
printf '[Autologin]\nUser=%s\nSession=omarchy.desktop\n' "$username" >/etc/sddm.conf.d/autologin.conf
# Encrypted installs keep autologin permanently (the LUKS prompt is the auth
# boundary). Unencrypted installs autologin only this first boot, then a
# one-shot service removes the drop-in so later boots use the normal SDDM
# login and the disk isn't left permanently open.
encrypted_install || install_autologin_once_cleanup
}
# Install a self-removing service that makes the autologin last exactly one
# boot — this one, where the owner just authenticated in the wizard. The unit is
# created now, but graphical.target's job for THIS boot is already computed, so
# it won't run this boot (this boot autologins). On the NEXT boot it's in the
# fresh transaction, ordered Before the display manager, so it runs before SDDM
# reads its config: it removes the autologin drop-in (that boot shows the normal
# login) and deletes its own unit and enablement symlink. Ordering Before= (not
# After=) is what makes it deterministic — no sleep/race against SDDM's startup.
install_autologin_once_cleanup() {
local unit=omarchy-provision-autologin-once.service
cat >"/etc/systemd/system/$unit" <<UNIT
[Unit]
Description=Drop the first-boot autologin before the next login
Before=display-manager.service
ConditionPathExists=/etc/sddm.conf.d/autologin.conf
[Service]
Type=oneshot
ExecStart=/usr/bin/rm -f /etc/sddm.conf.d/autologin.conf
ExecStartPost=/usr/bin/rm -f /etc/systemd/system/graphical.target.wants/$unit /etc/systemd/system/$unit
[Install]
WantedBy=graphical.target
UNIT
mkdir -p /etc/systemd/system/graphical.target.wants
ln -sf "../$unit" "/etc/systemd/system/graphical.target.wants/$unit"
}
# Apply the owner's hostname, deferred to first boot with the rest of the user
# step, overwriting the placeholder the deferred install seeded.
configure_hostname() {
[[ -n ${hostname:-} ]] || return 0
hostnamectl set-hostname "$hostname" 2>&1 ||
printf '%s\n' "$hostname" >/etc/hostname
}
# Apply the owner's timezone, deferred to first boot with the rest of the user
# step, overwriting the placeholder (UTC) the deferred install seeded.
configure_timezone() {
[[ -n ${timezone:-} ]] || return 0
if timedatectl set-timezone "$timezone" 2>&1; then
return 0
fi
# Fallback only if the zone file exists, so a bad zone can't point
# /etc/localtime at nothing and break every localtime read afterward.
[[ -e /usr/share/zoneinfo/$timezone ]] &&
ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime 2>&1 || true
}
finalize_user() {
local home shell
home=$(getent passwd "$username" | cut -d: -f6)
shell=$(getent passwd "$username" | cut -d: -f7)
runuser -u "$username" -- env \
HOME="$home" \
USER="$username" \
LOGNAME="$username" \
SHELL="${shell:-/bin/bash}" \
OMARCHY_PATH="$OMARCHY_PATH" \
OMARCHY_INSTALL="$OMARCHY_PATH/install" \
OMARCHY_SETUP_CONTEXT=provision-owner \
OMARCHY_USER_NAME="$full_name" \
OMARCHY_USER_EMAIL="$email_address" \
OMARCHY_INSTALL_LOG_FILE="$LOG_FILE" \
OMARCHY_LOG_TO_STDOUT=1 \
"$OMARCHY_PATH/bin/omarchy-provision-user" --force --first-install
}
# Move the LUKS volume from the throwaway install passphrase to the user's
# password: add the user's key, kill every other slot (throwaway + any seller
# keys a reset left behind), then rebuild the UKI without the embedded
# auto-unlock keyfile.
#
# Failing here must be LOUD (abort the attempt, offer retry): silently keeping
# the staged auto-unlock keyfile would leave the disk effectively unencrypted
# forever.
rekey_luks() {
[[ -f $PROVISIONING_DIR/luks-key ]] || return 0
local device
if ! device=$(luks_device) || [[ ! -e $device ]]; then
log_step "cannot locate the LUKS device from /proc/cmdline: $(cat /proc/cmdline)"
say --foreground 1 "Could not locate the LUKS device to re-key."
return 1
fi
if ! cryptsetup open --test-passphrase --key-file "$PROVISIONING_DIR/luks-key" "$device" 2>>"$LOG_FILE"; then
log_step "staged LUKS key does not unlock $device"
say --foreground 1 "The staged LUKS key no longer unlocks $device."
return 1
fi
# Add the user's key (a retry with a different password just adds another
# slot; all but the current one are killed once the rebuild succeeds).
cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password")
# Rebuild the no-auto-unlock UKI FIRST, keeping the throwaway key and slot as
# a fallback. Only once that succeeds do we kill the other slots and destroy
# the staged key — so a limine-update failure leaves a recoverable,
# still-auto-unlocking state to retry, never a disk locked to a password the
# user may have just changed.
rm -f /etc/omarchy/provisioning.key \
/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \
/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf
reset_limine_config
if ! limine-update >>"$LOG_FILE" 2>&1; then
log_step "limine-update failed during re-key; restoring auto-unlock for retry"
install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key
echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \
>/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf
echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf
limine-update >>"$LOG_FILE" 2>&1 || true
return 1
fi
local new_slot slot other_slots
new_slot=$(cryptsetup open --test-passphrase --verbose --key-file <(printf '%s' "$password") "$device" 2>&1 |
grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1)
# Retiring the throwaway/seller slots must be all-or-nothing: if we can't
# identify the user's slot or a kill fails, keep the staged key and retry —
# never shred it while a slot the seller knows still unlocks the disk.
if [[ -z $new_slot ]]; then
log_step "could not identify the user's LUKS slot after re-key; keeping the staged key for retry"
say --foreground 1 "Could not confirm the LUKS re-key; will retry."
return 1
fi
if ! other_slots=$(cryptsetup luksDump "$device" | awk '/^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }'); then
log_step "luksDump failed while retiring slots; keeping the staged key for retry"
say --foreground 1 "Could not enumerate LUKS slots; will retry."
return 1
fi
for slot in $other_slots; do
[[ $slot == "$new_slot" ]] && continue
if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then
log_step "failed to kill LUKS slot $slot; keeping the staged key for retry"
say --foreground 1 "Could not remove the throwaway LUKS key; will retry."
return 1
fi
done
shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key"
}
# Start the ESP's limine.conf over from the shipped template and drop foreign
# machine-id state before rebuilding. limine-entry-tool keys OS entries by
# machine-id: after a factory reset gave this machine a fresh identity, the
# previous system's entry would survive the rebuild with a stale UKI hash,
# sort first, and make Limine stop at a hash-mismatch warning.
esp_path() {
local esp=""
if [[ -f /etc/default/limine ]]; then
esp=$(sed -n 's/^ESP_PATH=["'\'']\?\([^"'\'']*\).*/\1/p' /etc/default/limine | tail -1)
fi
echo "${esp:-/boot}"
}
# A factory reset gives the machine a fresh machine-id, but limine-entry-tool
# keys its limine.conf entries by machine-id — entries from the previous
# identity would linger and go hash-stale on the first UKI rebuild.
limine_entries_stale() {
local esp machine_id
esp=$(esp_path)
machine_id=$(cat /etc/machine-id 2>/dev/null || true)
[[ -f $esp/limine.conf && -n $machine_id ]] || return 1
# Stale if any foreign machine-id entry lingers, OR if this machine has no
# entry at all (e.g. a failed earlier rebuild left the entry-less template).
grep -o 'machine-id=[0-9a-f]*' "$esp/limine.conf" 2>/dev/null |
grep -qv "machine-id=$machine_id" && return 0
! grep -q "machine-id=$machine_id" "$esp/limine.conf"
}
reset_limine_config() {
local esp template found="" machine_id old_id old_ids=""
esp=$(esp_path)
# Only remove machine-ids the old (Omarchy-managed) limine.conf referenced;
# a shared ESP may hold other installations' machine-id directories.
if [[ -f $esp/limine.conf ]]; then
# `|| true`: an entry-less limine.conf (left by a failed earlier rebuild)
# has no machine-id lines, so grep exits 1 and pipefail would abort the
# assignment under `set -e` — turning a recoverable retry into a dead loop.
old_ids=$(grep -o 'machine-id=[0-9a-f]\{32\}' "$esp/limine.conf" | cut -d= -f2 | sort -u || true)
fi
for template in "$OMARCHY_PATH/install/assets/limine/limine.conf" \
"$OMARCHY_PATH/default/limine/limine.conf"; do
if [[ -f $template ]]; then
cp "$template" "$esp/limine.conf"
found=1
break
fi
done
if [[ -z $found ]]; then
log_step "no limine.conf template found; keeping the existing config"
return 0
fi
machine_id=$(cat /etc/machine-id 2>/dev/null || true)
for old_id in $old_ids; do
[[ $old_id == "$machine_id" ]] && continue
rm -rf "${esp:?}/$old_id"
done
}
cleanup_oem_state() {
# Keep groups + packages: omarchy-system-factory-reset's degraded path (machines
# without @factory) re-arms first-boot setup from the live copies.
rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user"
rm -f /etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service
systemctl daemon-reload 2>/dev/null || true
}
# The provisioning work, backgrounded under the progress screen. Phase writes
# to STATE_FILE drive the bar; all output lands in the log.
run_provisioning() {
log_step "creating user $username"
create_user
install_authorized_keys
configure_login
log_step "setting hostname to ${hostname:-omarchy}"
configure_hostname
log_step "setting timezone to ${timezone:-UTC}"
configure_timezone
log_step "finalizing user"
echo finalize >"$STATE_FILE"
if ! finalize_user; then
log_step "finalize-user failed (continuing; user can retry after login)"
touch "$FINALIZE_WARNING_FLAG"
fi
if [[ -f $PROVISIONING_DIR/luks-key ]]; then
log_step "re-keying LUKS to the user's password"
echo rekey >"$STATE_FILE"
rekey_luks
log_step "LUKS re-key complete"
fi
# After a factory reset on an unencrypted machine nothing above rebuilds the
# boot entries, so entries keyed to the previous machine identity would
# linger and go hash-stale on the first UKI rebuild. Refresh them now.
if limine_entries_stale; then
log_step "refreshing boot entries for the new machine identity"
echo boot >"$STATE_FILE"
reset_limine_config
limine-update
fi
log_step "cleaning up provisioning state"
cleanup_oem_state
log_step "first-boot setup complete"
}
run_setup() {
local status
while true; do
keyboard_form
user_form && status=0 || status=$?
if ((status != 0)); then
# Esc unwinds to the keyboard step. Ctrl+C is the only other way out of a
# prompt, and it offers the reboot instead of returning a value.
((status == OMARCHY_FORM_SIGNAL)) && confirm_reboot && exec systemctl reboot
continue
fi
confirm_form && break
done
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
rm -f "$FINALIZE_WARNING_FLAG"
echo account >"$STATE_FILE"
run_provisioning >>"$LOG_FILE" 2>&1 &
local worker=$!
set_now
SETUP_T0=$NOW
SETUP_PHASE_T0=$NOW
render_setup_static
while kill -0 "$worker" 2>/dev/null; do
render_setup_dynamic
sleep 0.5
done
local status=0
wait "$worker" || status=$?
printf '%s' "$SHOW_CURSOR"
(( status == 0 )) || return "$status"
# One full-bar frame so the finish doesn't cut in mid-progress.
echo done >"$STATE_FILE"
render_setup_dynamic
sleep 1
if [[ -f $FINALIZE_WARNING_FLAG ]]; then
rm -f "$FINALIZE_WARNING_FLAG"
clear_logo
echo
say --foreground 1 "User finalization reported errors (see $LOG_FILE)."
say --foreground 1 "Run 'omarchy-provision-user --force' after login to retry."
sleep 3
fi
# Deferred first boot hands straight off to the display manager: SDDM starts
# the moment this oneshot exits (Before=display-manager.service) and autologs
# in on encrypted installs. No timed celebration screen and no "start" button
# here — that send-off belongs to a direct install, not first-boot setup.
clear_logo
echo
say "Starting Omarchy..."
sleep 1
}
# A failed first-boot setup must not strand the machine at a user-less login
# screen. Each attempt runs as its own process — bash ignores errexit inside
# `while !` conditions, but a child process keeps its own set -e — and failure
# offers a retry; create_user and friends are idempotent, so retrying is safe.
if [[ ${1:-} == "--attempt" ]]; then
run_setup
exit 0
fi
main() {
# Font sizing waits for the console to settle, so it lives inside
# greeter_screen (which owns that settle) rather than running here on a VT
# that may still be in a transitional mode this early in first boot.
set_tokyo_night_colors
greeter_screen
while ! "$0" --attempt; do
clear_logo
echo
say --foreground 1 "Setup hit an error (details in $LOG_FILE)."
echo
if ! gum confirm --affirmative "Try again" --negative "Drop to console" "Retry first-boot setup?"; then
# Give an actual usable console: this service owns tty1 (and Conflicts the
# getty), and on a failed deferred setup there's no user account and root
# is locked — so exiting would strand the machine with no way in. Exec a
# root shell on tty1; SDDM starts once it exits.
say "Dropping to a root shell. Run 'omarchy-provision-owner' to retry setup."
exec /bin/bash
fi
done
}
main