#!/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. A platform whose boot package owns its boot # chain prepares it, drops the install key's unlock from it and records the # kept key slot through omarchy-lifecycle-dispatch. 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" REKEY_STATE=$PROVISIONING_DIR/luks-rekey.state source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh" # 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/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 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_FILTER_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 printf '%*s\033[32m● \033[0m%s\n' "$PADDING_LEFT" '' "$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 # Rows the setup screen occupies: logo, blank, title, blank, bar, blank, tip. SETUP_HEIGHT=$((LOGO_HEIGHT + 6)) SETUP_TOP_ROW=1 DYNAMIC_ROW=$((LOGO_HEIGHT + 3)) LAST_SETUP_SIZE="" 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" "Sign PDFs and fill them in with Signed" "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, trim videos with Omacut, 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 0 )) || cols=${COLUMNS:-80} printf '%s' "$cols" } term_size() { stty size 2>/dev/null /dev/null /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 /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 } # Vertically center the setup block the way greeter_screen centers its splash, # so the screen the owner watches through finalization is composed rather than # stacked from row one — and first boot bookends the install identically. measure_setup_layout() { local rows LAST_SETUP_SIZE=$(term_size) rows=${LAST_SETUP_SIZE%% *} [[ $rows =~ ^[0-9]+$ ]] || rows=24 SETUP_TOP_ROW=$(((rows - SETUP_HEIGHT) / 2)) (( SETUP_TOP_ROW < 0 )) && SETUP_TOP_ROW=0 SETUP_TOP_ROW=$((SETUP_TOP_ROW + 1)) DYNAMIC_ROW=$((SETUP_TOP_ROW + LOGO_HEIGHT + 1)) } render_setup_static() { measure_setup_layout printf '%s%s%s%d;1H' "$HIDE_CURSOR" "$CLEAR" "$CSI" "$SETUP_TOP_ROW" render_logo blank_line } render_setup_dynamic() { # The VT can still resize under us — the same virtio-gpu KMS handoff the # greeter watches for. left_padding recomputes every frame, but the logo is # drawn once, so repaint it at the new center rather than leaving the centered # block stranded against a stale geometry. [[ $(term_size) == "$LAST_SETUP_SIZE" ]] || render_setup_static 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 2 ? cols - 2 : cols))" \ --anchor-text c \ --frame-rate 60 \ --reuse-canvas \ --xterm-colors \ colorshift \ --gradient-stops 2 10 6 10 \ --gradient-frames 3 \ --cycles 1000 \ --final-gradient-stops 2 \ /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/null || true) if (( resized )) || [[ -n $cur_sig && $cur_sig != "$drawn_sig" ]]; then _greeter_kill_anim stty sane /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/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. A layout that did not take is asked for again: the # password form must never be typed in a layout the disk unlock won't use. # Three failures in a row mean the machine can't set a layout at all (a # missing or broken localectl, say), so the attempt fails and setup's own # retry-or-console screen takes over instead of asking forever. keyboard_form() { local status failures=0 while true; do step "Let's setup your keyboard..." omarchy_prompt_keyboard && status=0 || status=$? if ((status == 0)); then apply_keyboard "$keyboard" && break if ((++failures >= 3)); then log_step "no keyboard layout could be set after $failures attempts" notice "Setup could not set a keyboard layout on this machine." 4 return 1 fi notice "Could not set the ${keyboard_label:-$keyboard} keyboard layout. Choose it again." 4 continue fi # 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 omarchy_persist_input_selection } 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?" } # XKB settings for the form's keymaps that kbd-model-map lacks, as its rows for # the nearest keymap give them, so the desktop types what the console does: # kbd's cz is QWERTY and bg-cp1251 phonetic. Non-Latin layouts keep a US layout # to switch to, so the shared password stays typeable. keyboard_xkb_settings() { local layout variant="" options=terminate:ctrl_alt_bksp local switch=terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll case $1 in colemak) layout=us variant=colemak ;; bg-cp1251) layout=bg,us variant=phonetic, options=$switch ;; cz) layout=cz,us variant=qwerty, options=$switch ;; de_CH-latin1) layout=ch ;; kyrgyz) layout=kg,us options=$switch ;; no-latin1) layout=no ;; pl) layout=pl ;; ua) layout=ua,us options=$switch ;; *) return 1 ;; esac printf 'XKBLAYOUT=%s\nXKBMODEL=pc105\n' "$layout" [[ -z $variant ]] || printf 'XKBVARIANT=%s\n' "$variant" printf 'XKBOPTIONS=%s\n' "$options" } # Load the layout on the live VT and persist it for the installed system, or # fail. systemd-firstboot writes both the console KEYMAP and the XKB layout # Hyprland reads, matching what the ISO's configure_keyboard does at install # time. The boot files ask for the disk password in the layout vconsole.conf # names, so a keymap localectl doesn't know, a failed loadkeys, or a file that # does not read back as the chosen keymap with an XKB layout is a failure. apply_keyboard() { local keymap=$1 keymaps saved if [[ $(tty 2>/dev/null) == /dev/tty* ]] && ! loadkeys "$keymap" >>"$LOG_FILE" 2>&1; then log_step "loadkeys could not load keymap $keymap" return 1 fi if ! keymaps=$(localectl --no-pager list-keymaps 2>>"$LOG_FILE"); then log_step "localectl could not list keymaps to check $keymap" return 1 fi if ! grep -qxF -- "$keymap" <<<"$keymaps"; then log_step "keymap $keymap unknown to localectl" return 1 fi if ! systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 && ! localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1; then log_step "could not persist keymap $keymap" return 1 fi # systemd-firstboot writes an XKB layout only for keymaps in kbd-model-map, # and several the form offers are not, so Hyprland would come up in US. if ! grep -q '^XKBLAYOUT=.' /etc/vconsole.conf 2>/dev/null; then keyboard_xkb_settings "$keymap" >>/etc/vconsole.conf 2>>"$LOG_FILE" || log_step "no XKB layout known for keymap $keymap" fi # A clean shell, so nothing this process exported stands in for the file. saved=$(env -i /bin/bash --noprofile --norc -c \ '. /etc/vconsole.conf && [[ -n ${XKBLAYOUT:-} ]] && printf %s "${KEYMAP:-}"' 2>>"$LOG_FILE") || saved="" if [[ $saved != "$keymap" ]]; then log_step "/etc/vconsole.conf does not read back as keymap $keymap with an XKB layout" return 1 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-apply-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 # Never replay old privileged group defaults. Docker is always opt-in. # Input is only retained when the factory image has one of the features # whose installer deliberately grants access to raw input devices. [[ $group == "docker" ]] && continue if [[ $group == "input" ]] && ! pacman -Qq xpadneo-dkms &>/dev/null && ! pacman -Qq ydotool &>/dev/null; then continue fi 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 -nsrpo 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 source "$OMARCHY_PATH/install/helpers/browser-policy.sh" for dir in "${BROWSER_POLICY_MANAGED_DIRS[@]}"; do [[ -d $dir || -L $dir ]] || continue browser_policy_setup_dir "$dir" done } 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 sed "s|@UNIT@|$unit|g" >"/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 # 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 fi configure_wireless_regdom } # The install's hardware step found only the UTC placeholder, which names no # country, so the wireless regulatory domain comes from the owner's timezone # here. No reboot follows setup, so the radios get it now the way udev applies # it at every boot. configure_wireless_regdom() { bash "$OMARCHY_PATH/install/hardware/set-wireless-regdom.sh" 2>&1 || return 0 if omarchy-cmd-present set-wireless-regdom; then set-wireless-regdom 2>&1 || true fi } 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 } # During the provisioning window the staged key rides in the UKI's initramfs, # named on the kernel cmdline, so boot unlocks unattended. PROVISIONING_UNLOCK_FILES=( /etc/omarchy/provisioning.key /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf ) limine_auto_unlock_present() { local file for file in "${PROVISIONING_UNLOCK_FILES[@]}"; do [[ -e $file ]] && return 0 done return 1 } # Rebuild the UKI without the embedded keyfile. A failed rebuild restores the # auto-unlock, so the retry still boots unattended. limine_auto_unlock_drop() { rm -f "${PROVISIONING_UNLOCK_FILES[@]}" reset_limine_config if limine-update >>"$LOG_FILE" 2>&1; then return 0 fi # Once the staged key is destroyed there is no auto-unlock to restore: the # boot files already ask for the owner's password. The rebuild is still # retried for the boot entries. if [[ -f $PROVISIONING_DIR/luks-key ]]; 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 else log_step "limine-update failed during re-key; the staged key is gone, so no auto-unlock is restored" fi limine-update >>"$LOG_FILE" 2>&1 || true return 1 } # A platform whose boot package implements provision-commit and provision-verify # stages the unlock in its own boot chain and owns it; everywhere else it rides # in the Limine UKI above. Deciding fails closed: the unlock then counts as # present and cannot be dropped. UNLOCK_OWNER="" unlock_owner() { [[ -z ${UNLOCK_OWNER:-} ]] || return 0 local commit verify commit=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-commit) || return 1 verify=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-verify) || return 1 if [[ -n $commit && -n $verify ]]; then UNLOCK_OWNER=platform elif [[ -z $commit && -z $verify ]]; then UNLOCK_OWNER=limine else log_step "the platform implements only one of provision-commit and provision-verify" return 1 fi } luks_auto_unlock_present() { unlock_owner || return 0 if [[ $UNLOCK_OWNER == "platform" ]]; then ! "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-verify >>"$LOG_FILE" 2>&1 else limine_auto_unlock_present fi } luks_auto_unlock_drop() { unlock_owner || return 1 if [[ $UNLOCK_OWNER == "platform" ]]; then "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-commit >>"$LOG_FILE" 2>&1 else limine_auto_unlock_drop fi } # The platform records the slot the re-key keeps, and no recovery slot; a no-op # where nothing does. luks_record_slots() { "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1" "recovery=" >>"$LOG_FILE" 2>&1 } # Move the LUKS volume from the throwaway install passphrase to the user's # password and retire every other slot (throwaway + any seller keys a reset # left behind). luks_rekey journals each step, so an interrupted attempt # resumes where it stopped. rekey_luks() { luks_rekey_pending || 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 luks_rekey "$device" } # The re-key's fingerprint of the console keyboard layout the boot files ask for # the disk password in: a hash of vconsole.conf's keymap and XKB settings, in # file order since a later duplicate wins, so the journal never names the layout. luks_boot_layout() { if [[ ! -f /etc/vconsole.conf ]]; then echo none return fi awk '/^[ \t]*(KEYMAP|KEYMAP_TOGGLE|XKB[A-Z_]*)=/ { sub(/^[ \t]+/, ""); print }' /etc/vconsole.conf | sha256sum | cut -d' ' -f1 } # A re-key interrupted after the staged key was retired can only finish with # the password the disk already holds. rekey_accepts_password() { local - set +x local device [[ -f $REKEY_STATE ]] || return 0 if ! device=$(luks_device) || [[ ! -e $device ]]; then return 0 fi luks_rekey_accepts_password "$device" } # 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 stages the bundled Node # tarball from these live copies when the factory snapshot predates it. rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" "$REKEY_STATE" 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)" touch "$FINALIZE_WARNING_FLAG" fi if luks_rekey_pending; 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 if luks_staged_unlock_remains; then log_step "refusing to finish setup while the staged LUKS key or its auto-unlock remains" return 1 fi log_step "cleaning up provisioning state" cleanup_oem_state log_step "first-boot setup complete" } # Where the platform's boot package owns the boot chain, it must be ready to # finish setup, and able to record the kept key slot, before the owner is asked # anything. platform_ready() { local error if error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-prepare 2>&1 >>"$LOG_FILE") && error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1 >/dev/null); then return 0 fi error=${error:-"The boot package could not prepare this machine for setup."} log_step "the boot package is not ready for setup: $error" clear_logo echo say --foreground 1 "$error" sleep 5 return 1 } run_setup() { local status platform_ready || return 1 while true; do keyboard_form || return 1 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 || continue if ! rekey_accepts_password; then notice "Setup already set the disk password. Enter that same password." 4 continue fi 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 # The cursor comes back only on the failure path, where the retry prompt needs # it. A clean run keeps it hidden through the last frame and the handoff. local status=0 wait "$worker" || status=$? if (( status != 0 )); then printf '%s' "$SHOW_CURSOR" return "$status" fi # 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)." 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. The # finished progress bar is the last frame; clearing it for a "Starting # Omarchy..." card would only add a flash of screen before the handoff. } # 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