diff --git a/airootfs/usr/local/bin/alvin-install b/airootfs/usr/local/bin/alvin-install index 31ed1ea..508ba8d 100755 --- a/airootfs/usr/local/bin/alvin-install +++ b/airootfs/usr/local/bin/alvin-install @@ -10,7 +10,11 @@ readonly ESP_SIZE="1GiB" readonly TARGET="/mnt" readonly -a PKGS_EXCLUDE=(archiso mkinitcpio-archiso mkinitcpio-nfs-utils syslinux memtest86+) -skip() { local x; for x in "${PKGS_EXCLUDE[@]}"; do [[ "$x" == "$1" ]] && return 0; done; return 1; } +skip() { + local x + for x in "${PKGS_EXCLUDE[@]}"; do [[ "$x" == "$1" ]] && return 0; done + return 1 +} DISK="" KEYMAP="" @@ -35,17 +39,18 @@ SSHD="true" SSHD_APPLIED="false" TPM_DISCLAIMER="" TARGET_DB_SYNCED="false" +UPDATE_STATUS="not run" -log() { echo "[$(date '+%H:%M:%S')] $*" >> "$LOG"; } +log() { echo "[$(date '+%H:%M:%S')] $*" >>"$LOG"; } cleanup() { - set +e - rm -f "${TARGET}/etc/sudoers.d/99-end4-install" \ - "${TARGET}/etc/sudoers.d/99-ssh-import-id" \ - "${TARGET}/etc/sudoers.d/99-unl0kr-install" 2>/dev/null - umount -R "$TARGET" 2>/dev/null - cryptsetup close cryptroot 2>/dev/null - true + set +e + rm -f "${TARGET}/etc/sudoers.d/99-end4-install" \ + "${TARGET}/etc/sudoers.d/99-ssh-import-id" \ + "${TARGET}/etc/sudoers.d/99-unl0kr-install" 2>/dev/null + umount -R "$TARGET" 2>/dev/null + cryptsetup close cryptroot 2>/dev/null + true } trap cleanup EXIT @@ -58,20 +63,24 @@ trap cleanup EXIT # prerequisite of this installer (check_prereqs), so its pager is used # instead of depending on less being present. page_output() { - local ctx="$1" - if [[ -c /dev/tty && -t 1 ]]; then - { gum style --foreground 11 --bold "$ctx"; echo ""; cat; } | gum pager || true - else - gum style --foreground 9 --bold "$ctx" || true - cat >&2 || true - fi + local ctx="$1" + if [[ -c /dev/tty && -t 1 ]]; then + { + gum style --foreground 11 --bold "$ctx" + echo "" + cat + } | gum pager || true + else + gum style --foreground 9 --bold "$ctx" || true + cat >&2 || true + fi } # Page the last $2 lines (default 200) of $LOG - for output that was already # redirected there. $1 = context. page_log_tail() { - [[ -f "$LOG" ]] || return 0 - tail -n "${2:-200}" "$LOG" | page_output "$1" + [[ -f "$LOG" ]] || return 0 + tail -n "${2:-200}" "$LOG" | page_output "$1" } # Page the $LOG lines appended after $1 (a line count taken with @@ -79,11 +88,11 @@ page_log_tail() { # end of the log means nothing new was written - skip instead of opening a # pager on an empty screen. page_log_since() { - local mark="$1" total=0 - [[ -f "$LOG" ]] || return 0 - total=$(wc -l <"$LOG") || total=0 - (( mark < total )) || return 0 - sed -n "$((mark + 1)),\$p" "$LOG" | page_output "$2" + local mark="$1" total=0 + [[ -f "$LOG" ]] || return 0 + total=$(wc -l <"$LOG") || total=0 + ((mark < total)) || return 0 + sed -n "$((mark + 1)),\$p" "$LOG" | page_output "$2" } # Run a command, tee its combined output into $LOG (as the old @@ -91,89 +100,101 @@ page_log_since() { # partial output) and - on failure - page the lines it added so the error # reaches the user, not just the log. Returns the command's exit status. # Usage: run_logged [-s SPINNER [-t TITLE]] "what failed" cmd [args...] -# -s show a gum spinner while cmd runs: dot for linear commands, jump for -# ones that compile. Frames render on stderr (the terminal), never $LOG. +# -s show a gum spinner while cmd runs (always dot, for a consistent look). +# Frames render on stderr (the terminal), never $LOG. # -t spinner title; omit when an info line already says what is running. run_logged() { - local ctx="" spinner="" title="" rc=0 mark=0 total=0 - while [[ "${1:-}" == -[st] ]]; do - case "$1" in - -s) spinner="$2"; shift 2 ;; - -t) title="$2"; shift 2 ;; - esac - done - ctx="$1" - shift - # Line count before the command, so only ITS output gets paged on failure. - # A missing $LOG (mark 0) is fine: tee creates it, so lines 1..n are - # exactly this command's output. + local ctx="" spinner="" title="" rc=0 mark=0 total=0 + while [[ "${1:-}" == -[st] ]]; do + case "$1" in + -s) + spinner="$2" + shift 2 + ;; + -t) + title="$2" + shift 2 + ;; + esac + done + ctx="$1" + shift + # Line count before the command, so only ITS output gets paged on failure. + # A missing $LOG (mark 0) is fine: tee creates it, so lines 1..n are + # exactly this command's output. + if [[ -f "$LOG" ]]; then + mark=$(wc -l <"$LOG") || mark=0 + fi + # tee appends to $LOG while /dev/null keeps the terminal clean. With + # pipefail the pipeline status is the command's own; the only false + # failure possible is tee itself failing to write (e.g. disk full). + if [[ -n "$spinner" ]]; then + # gum spin propagates the command's exit status; --show-output keeps + # its stdout (the command's output) free of spinner frames for tee, + # while the spinner itself renders on stderr = the terminal. The sh + # wrapper merges the command's stderr into that stdout first, so no + # output bypasses $LOG. + local -a spin=(gum spin --show-output --spinner "$spinner") + if [[ -n "$title" ]]; then + spin+=(--title "$title") + fi + "${spin[@]}" -- sh -c '"$@" 2>&1' sh "$@" | + tee -a "$LOG" >/dev/null || rc=$? + else + "$@" 2>&1 | tee -a "$LOG" >/dev/null || rc=$? + fi + if ((rc != 0)); then if [[ -f "$LOG" ]]; then - mark=$(wc -l <"$LOG") || mark=0 + total=$(wc -l <"$LOG") || total=0 fi - # tee appends to $LOG while /dev/null keeps the terminal clean. With - # pipefail the pipeline status is the command's own; the only false - # failure possible is tee itself failing to write (e.g. disk full). - if [[ -n "$spinner" ]]; then - # gum spin propagates the command's exit status; --show-output keeps - # its stdout (the command's output) free of spinner frames for tee, - # while the spinner itself renders on stderr = the terminal. The sh - # wrapper merges the command's stderr into that stdout first, so no - # output bypasses $LOG. - local -a spin=(gum spin --show-output --spinner "$spinner") - if [[ -n "$title" ]]; then - spin+=(--title "$title") - fi - "${spin[@]}" -- sh -c '"$@" 2>&1' sh "$@" \ - | tee -a "$LOG" >/dev/null || rc=$? + if ((mark < total)); then + page_log_since "$mark" "${ctx} (exit ${rc}) - output below, full log: $LOG" else - "$@" 2>&1 | tee -a "$LOG" >/dev/null || rc=$? + # Silent failure: nothing to page, but still surface where and why. + gum style --foreground 11 --bold \ + "${ctx} (exit ${rc}) - no output captured, full log: $LOG" || true fi - if (( rc != 0 )); then - if [[ -f "$LOG" ]]; then - total=$(wc -l <"$LOG") || total=0 - fi - if (( mark < total )); then - page_log_since "$mark" "${ctx} (exit ${rc}) - output below, full log: $LOG" - else - # Silent failure: nothing to page, but still surface where and why. - gum style --foreground 11 --bold \ - "${ctx} (exit ${rc}) - no output captured, full log: $LOG" || true - fi - fi - return "$rc" + fi + return "$rc" } # ERR trap: a fatal failure aborts the install (errexit) - say where, then # page the recent log so the actual error is on screen, not log-only. on_err() { - local rc=$? - trap - ERR - gum style --foreground 9 --bold "Installation failed (exit ${rc}) at line $1. Full log: $LOG" || true - page_log_tail "Last output before the failure - q to quit, scroll for more" 300 || true + local rc=$? + trap - ERR + gum style --foreground 9 --bold "Installation failed (exit ${rc}) at line $1. Full log: $LOG" || true + page_log_tail "Last output before the failure - q to quit, scroll for more" 300 || true } trap 'on_err "$LINENO"' ERR -(( EUID == 0 )) || { echo "Run as root" >&2; exit 1; } +((EUID == 0)) || { + echo "Run as root" >&2 + exit 1 +} -die() { gum style --foreground 9 --bold "ERROR: $*"; exit 1; } +die() { + gum style --foreground 9 --bold "ERROR: $*" + exit 1 +} info() { gum style --foreground 12 --bold "$*"; } ok() { gum style --foreground 10 --bold "$*"; } warn() { gum style --foreground 11 --bold "$*"; } header() { - gum style \ - --foreground "#FAFAFA" --background "#6C91BF" \ - --padding "1 2" --bold --align center --width 70 \ - "$1" + gum style \ + --foreground "#FAFAFA" --background "#6C91BF" \ + --padding "1 2" --bold --align center --width 70 \ + "$1" } divider() { - gum style --foreground 8 "──────────────────────────────────────────────────────────" + gum style --foreground 8 "──────────────────────────────────────────────────────────" } part() { [[ "$DISK" =~ [0-9]$ ]] && echo "${DISK}p$1" || echo "${DISK}$1"; } -valid_username() { [[ "$1" =~ ^[a-z_][a-z0-9_-]*$ ]] && (( ${#1} <= 32 )); } +valid_username() { [[ "$1" =~ ^[a-z_][a-z0-9_-]*$ ]] && ((${#1} <= 32)); } # Numeric uid:gid of $USERNAME as seen inside $TARGET. Always needed for # chowns from the live medium, whose /etc/passwd has no $USERNAME. Both ids @@ -181,7 +202,7 @@ valid_username() { [[ "$1" =~ ^[a-z_][a-z0-9_-]*$ ]] && (( ${#1} <= 32 )); } # would only propagate the last status, so a lone `id -u` failure could # silently produce ':1000' and abort at the wrong line. target_owner() { - arch-chroot "$TARGET" sh -c "id -u '$USERNAME' && id -g '$USERNAME'" | paste -sd: + arch-chroot "$TARGET" sh -c "id -u '$USERNAME' && id -g '$USERNAME'" | paste -sd: } # `gsettings get` prints GVariant text: '...' normally, "..." when the value @@ -191,31 +212,31 @@ target_owner() { # global replacements instead would corrupt sequences like \\n (literal # backslash + n must not become a newline). gvariant_unescape() { - local s=$1 out="" i n c - if [[ "$s" == \'*\' || "$s" == \"*\" ]]; then - s=${s:1:${#s}-2} - n=${#s} - for ((i = 0; i < n; i++)); do - c=${s:i:1} - if [[ "$c" == "\\" && $((i + 1)) -lt $n ]]; then - i=$((i + 1)) - case "${s:i:1}" in - n) out+=$'\n' ;; - t) out+=$'\t' ;; - r) out+=$'\r' ;; - a) out+=$'\a' ;; - b) out+=$'\b' ;; - f) out+=$'\f' ;; - v) out+=$'\v' ;; - *) out+="${s:i:1}" ;; - esac - else - out+=$c - fi - done - s=$out - fi - printf '%s' "$s" + local s=$1 out="" i n c + if [[ "$s" == \'*\' || "$s" == \"*\" ]]; then + s=${s:1:${#s}-2} + n=${#s} + for ((i = 0; i < n; i++)); do + c=${s:i:1} + if [[ "$c" == "\\" && $((i + 1)) -lt $n ]]; then + i=$((i + 1)) + case "${s:i:1}" in + n) out+=$'\n' ;; + t) out+=$'\t' ;; + r) out+=$'\r' ;; + a) out+=$'\a' ;; + b) out+=$'\b' ;; + f) out+=$'\f' ;; + v) out+=$'\v' ;; + *) out+="${s:i:1}" ;; + esac + else + out+=$c + fi + done + s=$out + fi + printf '%s' "$s" } # Refresh the target's package databases once before the first target-side @@ -223,1096 +244,1105 @@ gvariant_unescape() { # pacstrap, so its db is not there yet; keyrings are re-populated as a # cheap safety net). Failures only warn - later steps fall back on their own. sync_target_dbs() { - [[ "${TARGET_DB_SYNCED}" == "true" ]] && return 0 - info "Syncing target package databases..." - run_logged -s dot "pacman-key --populate failed" \ - arch-chroot "$TARGET" pacman-key --populate archlinux cachyos chaotic || true - if run_logged -s dot "Target pacman -Syy failed" \ - arch-chroot "$TARGET" pacman -Syy --noconfirm; then - TARGET_DB_SYNCED="true" - ok "Target databases synced." - else - warn "Target pacman -Syy failed (output above) - continuing with existing databases." - fi + [[ "${TARGET_DB_SYNCED}" == "true" ]] && return 0 + info "Syncing target package databases..." + run_logged -s dot "pacman-key --populate failed" \ + arch-chroot "$TARGET" pacman-key --populate archlinux cachyos chaotic || true + if run_logged -s dot "Target pacman -Syy failed" \ + arch-chroot "$TARGET" pacman -Syy --noconfirm; then + TARGET_DB_SYNCED="true" + ok "Target databases synced." + else + warn "Target pacman -Syy failed (output above) - continuing with existing databases." + fi } check_prereqs() { - command -v gum >/dev/null 2>&1 || die "gum is not installed" - [[ -d /sys/firmware/efi ]] || die "UEFI not detected. This installer requires UEFI boot." + command -v gum >/dev/null 2>&1 || die "gum is not installed" + [[ -d /sys/firmware/efi ]] || die "UEFI not detected. This installer requires UEFI boot." } # ── Screens ─────────────────────────────────────────────────────────────────── screen_welcome() { - clear - header "Alvin Arch Linux Installer" - echo "" - gum style --foreground 15 --align center \ - "This will install Arch Linux with:" \ - " * LUKS disk encryption (default)" \ - " * GRUB bootloader" \ - " * NetworkManager" \ - " * Your user + git config" \ - "" - divider - echo "" - gum confirm --affirmative "Begin installation" --negative "Abort" \ - "Ready to begin?" || die "Installation aborted by user." + clear + header "Alvin Arch Linux Installer" + echo "" + gum style --foreground 15 --align center \ + "This will install Arch Linux with:" \ + " * LUKS disk encryption (default)" \ + " * GRUB bootloader" \ + " * NetworkManager" \ + " * Your user + git config" \ + "" + divider + echo "" + gum confirm --affirmative "Begin installation" --negative "Abort" \ + "Ready to begin?" || die "Installation aborted by user." } screen_keymap() { - clear - header "Keyboard Layout" - echo "" - gum style --foreground 15 \ - "Select the keyboard layout of your physical keyboard." \ - "This affects passwords and the LUKS passphrase at boot." \ - "" \ - "The default is the US layout (us)." - echo "" - if gum confirm --affirmative "Use us (default)" --negative "Choose another layout" \ - "Use US keyboard layout (us)?"; then - KEYMAP="us" - else - KEYMAP=$(localectl list-keymaps | gum filter --header "+ Keymap" --placeholder "Search keymaps...") \ - || die "No keymap selected." - fi - [[ -z "$KEYMAP" ]] && die "No keymap selected." - loadkeys "$KEYMAP" 2>/dev/null || true - log "Keymap: $KEYMAP" + clear + header "Keyboard Layout" + echo "" + gum style --foreground 15 \ + "Select the keyboard layout of your physical keyboard." \ + "This affects passwords and the LUKS passphrase at boot." \ + "" \ + "The default is the US layout (us)." + echo "" + if gum confirm --affirmative "Use us (default)" --negative "Choose another layout" \ + "Use US keyboard layout (us)?"; then + KEYMAP="us" + else + KEYMAP=$(localectl list-keymaps | gum filter --header "+ Keymap" --placeholder "Search keymaps...") || + die "No keymap selected." + fi + [[ -z "$KEYMAP" ]] && die "No keymap selected." + loadkeys "$KEYMAP" 2>/dev/null || true + log "Keymap: $KEYMAP" } screen_network() { - clear - header "Network Setup" - echo "" - gum style --foreground 15 --align center \ - "A network connection is required for installation." \ - "" \ - "When nmtui opens:" \ - " 1. Select 'Activate a connection'" \ - " 2. Use arrow keys to pick your WiFi network" \ - " 3. Enter the password and press Enter" \ - " 4. Press Esc to exit nmtui" - echo "" - divider - echo "" - gum confirm --affirmative "Open nmtui" --negative "Skip (already connected)" \ - "Open NetworkManager TUI?" && nmtui || true - echo "" - info "Verifying network connection..." - local try=0 - while ! ping -c1 -W3 archlinux.org &>/dev/null; do - try=$((try + 1)) - if (( try >= 5 )); then - gum confirm --affirmative "Retry" --negative "Abort" \ - "Network check failed. Retry?" || die "No network connection." - try=0 - fi - sleep 2 - done - ok "Network verified." - log "Network: connected" + clear + header "Network Setup" + echo "" + gum style --foreground 15 --align center \ + "A network connection is required for installation." \ + "" \ + "When nmtui opens:" \ + " 1. Select 'Activate a connection'" \ + " 2. Use arrow keys to pick your WiFi network" \ + " 3. Enter the password and press Enter" \ + " 4. Press Esc to exit nmtui" + echo "" + divider + echo "" + gum confirm --affirmative "Open nmtui" --negative "Skip (already connected)" \ + "Open NetworkManager TUI?" && nmtui || true + echo "" + info "Verifying network connection..." + local try=0 + while ! ping -c1 -W3 archlinux.org &>/dev/null; do + try=$((try + 1)) + if ((try >= 5)); then + gum confirm --affirmative "Retry" --negative "Abort" \ + "Network check failed. Retry?" || die "No network connection." + try=0 + fi + sleep 2 + done + ok "Network verified." + log "Network: connected" } screen_disk() { - clear - header "Select Installation Disk" - echo "" + clear + header "Select Installation Disk" + echo "" - # The boot medium: the bootmnt source, or - when copytoram unmounted it - - # the device carrying the archisosearchuuid= volume. A whole-disk source - # (hybrid ISO, sr0) has no PKNAME and is itself the disk to hide. - local live_dev="" src="" pk="" search_uuid="" - if [[ -d /run/archiso/bootmnt ]]; then - src=$(findmnt -no SOURCE /run/archiso/bootmnt 2>/dev/null || true) - fi - if [[ -z "$src" ]]; then - search_uuid=$(sed -n 's/.*archisosearchuuid=\([^[:space:]]*\).*/\1/p' /proc/cmdline) - [[ -n "$search_uuid" ]] && src=$(blkid -U "$search_uuid" 2>/dev/null || true) - fi - if [[ -n "$src" ]]; then - pk=$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1 || true) - live_dev="/dev/${pk:-$(basename "$src")}" - fi + # The boot medium: the bootmnt source, or - when copytoram unmounted it - + # the device carrying the archisosearchuuid= volume. A whole-disk source + # (hybrid ISO, sr0) has no PKNAME and is itself the disk to hide. + local live_dev="" src="" pk="" search_uuid="" + if [[ -d /run/archiso/bootmnt ]]; then + src=$(findmnt -no SOURCE /run/archiso/bootmnt 2>/dev/null || true) + fi + if [[ -z "$src" ]]; then + search_uuid=$(sed -n 's/.*archisosearchuuid=\([^[:space:]]*\).*/\1/p' /proc/cmdline) + [[ -n "$search_uuid" ]] && src=$(blkid -U "$search_uuid" 2>/dev/null || true) + fi + if [[ -n "$src" ]]; then + pk=$(lsblk -no PKNAME "$src" 2>/dev/null | head -n1 || true) + live_dev="/dev/${pk:-$(basename "$src")}" + fi - # -P quotes every field (and escapes quotes inside), so multi-word or - # empty MODELs parse correctly - matched by regex, never eval'd. - local disks=() line name size model - local re='^NAME="([^"]*)" SIZE="([^"]*)" MODEL="([^"]*)" TYPE="disk"$' - while IFS= read -r line; do - [[ "$line" =~ $re ]] || continue - name=${BASH_REMATCH[1]} size=${BASH_REMATCH[2]} model=${BASH_REMATCH[3]} - [[ "$name" == "$live_dev" ]] && continue - [[ "$name" == /dev/zram* ]] && continue # compressed RAM swap, TYPE=disk - disks+=("${name} ${size} ${model:-unknown model}") - done < <(lsblk -dpnP -o NAME,SIZE,MODEL,TYPE) + # -P quotes every field (and escapes quotes inside), so multi-word or + # empty MODELs parse correctly - matched by regex, never eval'd. + local disks=() line name size model + local re='^NAME="([^"]*)" SIZE="([^"]*)" MODEL="([^"]*)" TYPE="disk"$' + while IFS= read -r line; do + [[ "$line" =~ $re ]] || continue + name=${BASH_REMATCH[1]} size=${BASH_REMATCH[2]} model=${BASH_REMATCH[3]} + [[ "$name" == "$live_dev" ]] && continue + [[ "$name" == /dev/zram* ]] && continue # compressed RAM swap, TYPE=disk + disks+=("${name} ${size} ${model:-unknown model}") + done < <(lsblk -dpnP -o NAME,SIZE,MODEL,TYPE) - (( ${#disks[@]} > 0 )) || die "No disks found." + ((${#disks[@]} > 0)) || die "No disks found." - while true; do - local choice - choice=$(gum choose --header "+ Target disk (live USB hidden)" "${disks[@]}") \ - || die "No disk selected." - DISK=$(echo "$choice" | awk '{print $1}') + while true; do + local choice + choice=$(gum choose --header "+ Target disk (live USB hidden)" "${disks[@]}") || + die "No disk selected." + DISK=$(echo "$choice" | awk '{print $1}') - gum confirm --affirmative "Wipe ${DISK}" --negative "Go back" \ - "WARNING: ALL data on ${DISK} will be destroyed!" && break - done + gum confirm --affirmative "Wipe ${DISK}" --negative "Go back" \ + "WARNING: ALL data on ${DISK} will be destroyed!" && break + done - log "Disk: $DISK" + log "Disk: $DISK" } screen_luks() { - clear - header "Disk Encryption" - echo "" - gum style --foreground 15 --align center \ - "LUKS encrypts your root filesystem." \ - "The passphrase is the same as your login password." \ - "Your data stays protected if the device is lost or stolen." - echo "" - echo "" - if gum confirm --affirmative "Enable LUKS encryption" --negative "No encryption" \ - "Enable LUKS disk encryption? (default: yes)"; then - LUKS="true" - else - LUKS="false" - fi - log "LUKS: $LUKS" + clear + header "Disk Encryption" + echo "" + gum style --foreground 15 --align center \ + "LUKS encrypts your root filesystem." \ + "The passphrase is the same as your login password." \ + "Your data stays protected if the device is lost or stolen." + echo "" + echo "" + if gum confirm --affirmative "Enable LUKS encryption" --negative "No encryption" \ + "Enable LUKS disk encryption? (default: yes)"; then + LUKS="true" + else + LUKS="false" + fi + log "LUKS: $LUKS" } screen_unl0kr() { - UNL0KR="false" - if [[ "$LUKS" != "true" ]]; then - log "unl0kr: disabled (LUKS off)" - return - fi - clear - header "Touchscreen Unlock (unl0kr)" - echo "" - gum style --foreground 15 --align center \ - "[EXPERIMENTAL] unl0kr lets you type the LUKS passphrase" \ - "on screen - for tablets and touch-only devices." \ - "" \ - "It puts a modified busybox crypt hook plus touchscreen" \ - "drivers into the initramfs. Test in a VM first: if it" \ - "breaks, the installed system will not boot." \ - "" \ - "Note: enabling unl0kr disables TPM2 auto-unlock" \ - "(the two are mutually exclusive)." - echo "" - # gum confirm selects its affirmative on Enter, which would default this - # experimental feature to ON; gum choose makes Enter pick the first - # (recommended) row instead. - local unl0kr_choice - unl0kr_choice=$(gum choose --header "+ unl0kr (default: no)" \ - "No - standard unlock (recommended)" \ - "Yes - experimental touchscreen unlock") || die "unl0kr question cancelled." - if [[ "$unl0kr_choice" == Yes* ]]; then - UNL0KR="true" - fi - log "unl0kr: $UNL0KR" + UNL0KR="false" + if [[ "$LUKS" != "true" ]]; then + log "unl0kr: disabled (LUKS off)" + return + fi + clear + header "Touchscreen Unlock (unl0kr)" + echo "" + gum style --foreground 15 --align center \ + "[EXPERIMENTAL] unl0kr lets you type the LUKS passphrase" \ + "on screen - for tablets and touch-only devices." \ + "" \ + "It puts a modified busybox crypt hook plus touchscreen" \ + "drivers into the initramfs. Test in a VM first: if it" \ + "breaks, the installed system will not boot." \ + "" \ + "Note: enabling unl0kr disables TPM2 auto-unlock" \ + "(the two are mutually exclusive)." + echo "" + # gum confirm selects its affirmative on Enter, which would default this + # experimental feature to ON; gum choose makes Enter pick the first + # (recommended) row instead. + local unl0kr_choice + unl0kr_choice=$(gum choose --header "+ unl0kr (default: no)" \ + "No - standard unlock (recommended)" \ + "Yes - experimental touchscreen unlock") || die "unl0kr question cancelled." + if [[ "$unl0kr_choice" == Yes* ]]; then + UNL0KR="true" + fi + log "unl0kr: $UNL0KR" } screen_filesystem() { - clear - header "Filesystem" - echo "" - FILESYSTEM=$(gum choose --header "+ Root filesystem" "ext4" "btrfs") \ - || die "No filesystem selected." - log "Filesystem: $FILESYSTEM" + clear + header "Filesystem" + echo "" + FILESYSTEM=$(gum choose --header "+ Root filesystem" "ext4" "btrfs") || + die "No filesystem selected." + log "Filesystem: $FILESYSTEM" } screen_username() { - clear - header "Create User" - echo "" - local input - input=$(gum input --placeholder "username" --char-limit 32 --header "+ Username") \ - || die "No username entered." - [[ -z "$input" ]] && die "Username cannot be empty." - valid_username "$input" || die "Invalid username. Use lowercase letters, digits, _ or -." - USERNAME="$input" - log "Username: $USERNAME" + clear + header "Create User" + echo "" + local input + input=$(gum input --placeholder "username" --char-limit 32 --header "+ Username") || + die "No username entered." + [[ -z "$input" ]] && die "Username cannot be empty." + valid_username "$input" || die "Invalid username. Use lowercase letters, digits, _ or -." + USERNAME="$input" + log "Username: $USERNAME" } screen_password() { - clear - header "Set Password" - echo "" - gum style --foreground 15 \ - "This password will be used for login and LUKS disk encryption." - echo "" + clear + header "Set Password" + echo "" + gum style --foreground 15 \ + "This password will be used for login and LUKS disk encryption." + echo "" - local pass1 pass2 - pass1=$(gum input --password --header "+ Password") || die "No password entered." - (( ${#pass1} >= PASS_MIN )) || die "Password must be at least ${PASS_MIN} characters." - pass2=$(gum input --password --header "+ Confirm password") || die "No confirmation entered." - [[ "$pass1" == "$pass2" ]] || die "Passwords do not match." - PASS="$pass1" - log "Password: set (${#PASS} chars)" + local pass1 pass2 + pass1=$(gum input --password --header "+ Password") || die "No password entered." + ((${#pass1} >= PASS_MIN)) || die "Password must be at least ${PASS_MIN} characters." + pass2=$(gum input --password --header "+ Confirm password") || die "No confirmation entered." + [[ "$pass1" == "$pass2" ]] || die "Passwords do not match." + PASS="$pass1" + log "Password: set (${#PASS} chars)" } screen_hostname() { - clear - header "Hostname" - echo "" - HOSTNAME=$(gum input --placeholder "archlinux" --char-limit 63 --header "+ Hostname") \ - || die "No hostname entered." - [[ -z "$HOSTNAME" ]] && HOSTNAME="archlinux" - [[ "$HOSTNAME" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,62})$ && "$HOSTNAME" != *- ]] \ - || die "Invalid hostname. Use letters, digits and '-' (max 63, no leading/trailing '-')." - log "Hostname: $HOSTNAME" + clear + header "Hostname" + echo "" + HOSTNAME=$(gum input --placeholder "archlinux" --char-limit 63 --header "+ Hostname") || + die "No hostname entered." + [[ -z "$HOSTNAME" ]] && HOSTNAME="archlinux" + [[ "$HOSTNAME" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,62})$ && "$HOSTNAME" != *- ]] || + die "Invalid hostname. Use letters, digits and '-' (max 63, no leading/trailing '-')." + log "Hostname: $HOSTNAME" } screen_git() { - clear - header "Git & SSH Configuration" - echo "" - gum style --foreground 15 \ - "Optional steps - press Enter on any field to skip it." \ - "The GitHub username imports that account's SSH public keys;" \ - "Git config sets user.name/user.email for new repositories." - echo "" - GH_USERNAME=$(gum input --placeholder "octocat (empty to skip)" \ - --header "+ GitHub username for SSH keys (optional)") || die "Git configuration cancelled." - GIT_NAME=$(gum input --value "$USERNAME" --header "+ Git name (Enter keeps '${USERNAME}')") \ - || die "Git configuration cancelled." - GIT_EMAIL=$(gum input --placeholder "user@example.com (empty to skip)" \ - --header "+ Git email (optional)") || die "Git configuration cancelled." - log "Git: name='${GIT_NAME}' email='${GIT_EMAIL}' github='${GH_USERNAME}'" + clear + header "Git & SSH Configuration" + echo "" + gum style --foreground 15 \ + "Optional steps - press Enter on any field to skip it." \ + "The GitHub username imports that account's SSH public keys;" \ + "Git config sets user.name/user.email for new repositories." + echo "" + GH_USERNAME=$(gum input --placeholder "octocat (empty to skip)" \ + --header "+ GitHub username for SSH keys (optional)") || die "Git configuration cancelled." + GIT_NAME=$(gum input --value "$USERNAME" --header "+ Git name (Enter keeps '${USERNAME}')") || + die "Git configuration cancelled." + GIT_EMAIL=$(gum input --placeholder "user@example.com (empty to skip)" \ + --header "+ Git email (optional)") || die "Git configuration cancelled." + log "Git: name='${GIT_NAME}' email='${GIT_EMAIL}' github='${GH_USERNAME}'" } screen_timezone() { - clear - header "Timezone" - echo "" - TIMEZONE=$(timedatectl list-timezones | gum filter --header "+ Timezone" --placeholder "Search...") \ - || die "No timezone selected." - log "Timezone: $TIMEZONE" + clear + header "Timezone" + echo "" + TIMEZONE=$(timedatectl list-timezones | gum filter --header "+ Timezone" --placeholder "Search...") || + die "No timezone selected." + log "Timezone: $TIMEZONE" } screen_locale() { - clear - header "Locale" - echo "" - local locales - locales=$(sed -n 's/^#\?\([a-z][a-z]*_[A-Z][A-Z]\.UTF-8\) UTF-8.*/\1/p' /etc/locale.gen | sort -u) - [[ -z "$locales" ]] && locales="en_US.UTF-8" - LOCALE=$(echo "$locales" | gum filter --header "+ Locale" --placeholder "Search...") \ - || die "No locale selected." - log "Locale: $LOCALE" + clear + header "Locale" + echo "" + local locales + locales=$(sed -n 's/^#\?\([a-z][a-z]*_[A-Z][A-Z]\.UTF-8\) UTF-8.*/\1/p' /etc/locale.gen | sort -u) + [[ -z "$locales" ]] && locales="en_US.UTF-8" + LOCALE=$(echo "$locales" | gum filter --header "+ Locale" --placeholder "Search...") || + die "No locale selected." + log "Locale: $LOCALE" } screen_login_manager() { - clear - header "Login Manager" - echo "" - gum style --foreground 15 \ - "How should the desktop session start after boot?" \ - "" \ - " none - plain tty login, Hyprland starts automatically" \ - " greetd - lightweight TUI greeter (tuigreet)" \ - " sddm - Qt display manager (already installed)" \ - " gdm - GNOME display manager (large download)" - echo "" - LOGIN_MANAGER=$(gum choose --header "+ Login manager (Enter = none)" \ - "none" "greetd" "sddm" "gdm") || die "No login manager selected." - log "Login manager: $LOGIN_MANAGER" + clear + header "Login Manager" + echo "" + gum style --foreground 15 \ + "How should the desktop session start after boot?" \ + "" \ + " none - plain tty login, Hyprland starts automatically" \ + " greetd - lightweight TUI greeter (tuigreet)" \ + " sddm - Qt display manager (already installed)" \ + " gdm - GNOME display manager (large download)" + echo "" + LOGIN_MANAGER=$(gum choose --header "+ Login manager (Enter = none)" \ + "none" "greetd" "sddm" "gdm") || die "No login manager selected." + log "Login manager: $LOGIN_MANAGER" } screen_sshd() { - clear - header "SSH Server" - echo "" - local -a ask_lines=( - "Enable the SSH server (sshd) in the installed system?" - "" - " - Starts at boot (systemctl enable sshd)" - " - Arch's default config allows password login, and this password" - " is the same as your LUKS passphrase - enable it only on networks" - " you trust, or harden sshd afterwards (keys-only auth, firewall)." - ) - # The GitHub import is optional (screen_git), so only mention it when the - # user actually gave a username - and it happens later, during install. - if [[ -n "$GH_USERNAME" ]]; then - ask_lines+=(" - GitHub SSH keys for ${USERNAME} are imported during install") - fi - gum style --foreground 15 "${ask_lines[@]}" - echo "" - # --default pre-selects the affirmative so Enter accepts the default. - if gum confirm --default --affirmative "Enable sshd" --negative "Skip" \ - "Enable the SSH server?"; then - SSHD="true" - else - SSHD="false" - fi - log "SSH server: ${SSHD}" + clear + header "SSH Server" + echo "" + local -a ask_lines=( + "Enable the SSH server (sshd) in the installed system?" + "" + " - Starts at boot (systemctl enable sshd)" + " - Arch's default config allows password login, and this password" + " is the same as your LUKS passphrase - enable it only on networks" + " you trust, or harden sshd afterwards (keys-only auth, firewall)." + ) + # The GitHub import is optional (screen_git), so only mention it when the + # user actually gave a username - and it happens later, during install. + if [[ -n "$GH_USERNAME" ]]; then + ask_lines+=(" - GitHub SSH keys for ${USERNAME} are imported during install") + fi + gum style --foreground 15 "${ask_lines[@]}" + echo "" + # --default pre-selects the affirmative so Enter accepts the default. + if gum confirm --default --affirmative "Enable sshd" --negative "Skip" \ + "Enable the SSH server?"; then + SSHD="true" + else + SSHD="false" + fi + log "SSH server: ${SSHD}" } screen_summary() { - clear - header "Installation Summary" - echo "" + clear + header "Installation Summary" + echo "" - local enc_status="No" - [[ "$LUKS" == "true" ]] && enc_status="Yes (uses login password)" + local enc_status="No" + [[ "$LUKS" == "true" ]] && enc_status="Yes (uses login password)" - local unl0kr_status="${UNL0KR}" - [[ "$LUKS" != "true" ]] && unl0kr_status="n/a" + local unl0kr_status="${UNL0KR}" + [[ "$LUKS" != "true" ]] && unl0kr_status="n/a" - local git_line="skipped" - if [[ -n "$GIT_EMAIL" ]]; then - git_line="${GIT_NAME:-$USERNAME} <${GIT_EMAIL}>" - fi - local ssh_line="skipped" - if [[ -n "$GH_USERNAME" ]]; then - ssh_line="gh:${GH_USERNAME}" - fi + local git_line="skipped" + if [[ -n "$GIT_EMAIL" ]]; then + git_line="${GIT_NAME:-$USERNAME} <${GIT_EMAIL}>" + fi + local ssh_line="skipped" + if [[ -n "$GH_USERNAME" ]]; then + ssh_line="gh:${GH_USERNAME}" + fi - gum style --foreground 15 --border normal --border-foreground 8 \ - --padding "0 2" --width 60 \ - "Disk: ${DISK}" \ - "Encryption: ${enc_status}" \ - "unl0kr: ${unl0kr_status}" \ - "Filesystem: ${FILESYSTEM}" \ - "Username: ${USERNAME}" \ - "Hostname: ${HOSTNAME}" \ - "Keymap: ${KEYMAP}" \ - "Locale: ${LOCALE}" \ - "Timezone: ${TIMEZONE}" \ - "Login mgr: ${LOGIN_MANAGER}" \ - "SSH server: $([[ "$SSHD" == "true" ]] && echo enabled || echo skipped)" \ - "SSH keys: ${ssh_line}" \ - "Git: ${git_line}" + gum style --foreground 15 --border normal --border-foreground 8 \ + --padding "0 2" --width 60 \ + "Disk: ${DISK}" \ + "Encryption: ${enc_status}" \ + "unl0kr: ${unl0kr_status}" \ + "Filesystem: ${FILESYSTEM}" \ + "Username: ${USERNAME}" \ + "Hostname: ${HOSTNAME}" \ + "Keymap: ${KEYMAP}" \ + "Locale: ${LOCALE}" \ + "Timezone: ${TIMEZONE}" \ + "Login mgr: ${LOGIN_MANAGER}" \ + "SSH server: $([[ "$SSHD" == "true" ]] && echo enabled || echo skipped)" \ + "SSH keys: ${ssh_line}" \ + "Git: ${git_line}" - echo "" - echo "TPM2 auto-unlock (if applicable) and autologin are asked about during installation." - echo "" - gum confirm --affirmative "Install" --negative "Abort" \ - "Proceed with installation?" || die "Installation aborted by user." + echo "" + echo "TPM2 auto-unlock (if applicable) and autologin are asked about during installation." + echo "" + gum confirm --affirmative "Install" --negative "Abort" \ + "Proceed with installation?" || die "Installation aborted by user." } # ── Install steps ───────────────────────────────────────────────────────────── install_partition() { - info "Partitioning ${DISK}..." - log "Partitioning $DISK" + info "Partitioning ${DISK}..." + log "Partitioning $DISK" - wipefs -af "$DISK" 2>>"$LOG" - sgdisk --zap-all "$DISK" 2>>"$LOG" + wipefs -af "$DISK" 2>>"$LOG" + sgdisk --zap-all "$DISK" 2>>"$LOG" - parted -s "$DISK" \ - mklabel gpt \ - mkpart ESP fat32 1MiB "${ESP_SIZE}" \ - set 1 esp on \ - mkpart root "${ESP_SIZE}" 100% + parted -s "$DISK" \ + mklabel gpt \ + mkpart ESP fat32 1MiB "${ESP_SIZE}" \ + set 1 esp on \ + mkpart root "${ESP_SIZE}" 100% - partprobe "$DISK" - udevadm settle --timeout=30 + partprobe "$DISK" + udevadm settle --timeout=30 - wipefs -af "$(part 1)" "$(part 2)" + wipefs -af "$(part 1)" "$(part 2)" - ok "Partitioned." + ok "Partitioned." } install_luks() { - [[ "$LUKS" != "true" ]] && return + [[ "$LUKS" != "true" ]] && return - info "Setting up LUKS encryption on $(part 2)..." - log "LUKS format $(part 2)" + info "Setting up LUKS encryption on $(part 2)..." + log "LUKS format $(part 2)" - printf '%s' "$PASS" | cryptsetup luksFormat \ - --batch-mode --type luks2 --key-file=- "$(part 2)" - printf '%s' "$PASS" | cryptsetup open \ - --key-file=- "$(part 2)" cryptroot + printf '%s' "$PASS" | cryptsetup luksFormat \ + --batch-mode --type luks2 --key-file=- "$(part 2)" + printf '%s' "$PASS" | cryptsetup open \ + --key-file=- "$(part 2)" cryptroot - ok "LUKS unlocked." + ok "LUKS unlocked." } install_format_mount() { - info "Formatting..." - mkfs.fat -F32 -n ESP "$(part 1)" 2>>"$LOG" + info "Formatting..." + mkfs.fat -F32 -n ESP "$(part 1)" 2>>"$LOG" - local root_dev - if [[ "$LUKS" == "true" ]]; then - root_dev="/dev/mapper/cryptroot" - else - root_dev="$(part 2)" - fi + local root_dev + if [[ "$LUKS" == "true" ]]; then + root_dev="/dev/mapper/cryptroot" + else + root_dev="$(part 2)" + fi - if [[ "$FILESYSTEM" == "btrfs" ]]; then - mkfs.btrfs -f -L root "$root_dev" 2>>"$LOG" - else - mkfs.ext4 -F -L root "$root_dev" 2>>"$LOG" - fi + if [[ "$FILESYSTEM" == "btrfs" ]]; then + mkfs.btrfs -f -L root "$root_dev" 2>>"$LOG" + else + mkfs.ext4 -F -L root "$root_dev" 2>>"$LOG" + fi - info "Mounting..." - mount "$root_dev" "$TARGET" - mkdir -p "${TARGET}/boot" - mount "$(part 1)" "${TARGET}/boot" + info "Mounting..." + mount "$root_dev" "$TARGET" + mkdir -p "${TARGET}/boot" + mount "$(part 1)" "${TARGET}/boot" - ok "Formatted and mounted." + ok "Formatted and mounted." } install_pacstrap() { - info "Building package list..." + info "Building package list..." - local pkglist="/run/archiso/bootmnt/arch/pkglist.x86_64.txt" - local pkgs=() - local dropped=() - local filter_ok=false - local -a src_lines=() + local pkglist="/run/archiso/bootmnt/arch/pkglist.x86_64.txt" + local pkgs=() + local dropped=() + local filter_ok=false + local -a src_lines=() - # The medium's pkglist comes from `pacman -Q` at build time: lines are - # "name version". With copytoram the boot medium is unmounted and the - # file is gone - the live system's own `pacman -Q` is the same data. - if [[ -f "$pkglist" ]]; then - mapfile -t src_lines < "$pkglist" - else - warn "${pkglist} not found (copytoram?) - using the live system's package list." - log "Pacstrap: ${pkglist} missing, falling back to pacman -Q" - mapfile -t src_lines < <(pacman -Q 2>>"$LOG" || true) + # The medium's pkglist comes from `pacman -Q` at build time: lines are + # "name version". With copytoram the boot medium is unmounted and the + # file is gone - the live system's own `pacman -Q` is the same data. + if [[ -f "$pkglist" ]]; then + mapfile -t src_lines <"$pkglist" + else + warn "${pkglist} not found (copytoram?) - using the live system's package list." + log "Pacstrap: ${pkglist} missing, falling back to pacman -Q" + mapfile -t src_lines < <(pacman -Q 2>>"$LOG" || true) + fi + ((${#src_lines[@]} > 0)) || + die "No package list: ${pkglist} is missing and pacman -Q returned nothing." + + # Parse only the first field, then drop names not resolvable in the + # live session's repos (incl. [local-aur] = /opt/localrepo). + info "Syncing package databases for resolvability check..." + if run_logged -s dot "pacman -Syy failed (resolvability check)" \ + pacman -Syy --noconfirm; then + declare -A available=() + local db_line + while IFS= read -r db_line; do + [[ -n "$db_line" ]] && available["$db_line"]=1 + done < <(pacman -Slq 2>>"$LOG" || true) + ((${#available[@]} > 0)) && filter_ok=true + else + warn "pacman -Syy failed (output above) — installing without resolvability filter." + fi + + local line pkg + while IFS= read -r line; do + [[ -z "$line" ]] && continue + pkg=${line%% *} + skip "$pkg" && continue + if $filter_ok && [[ -z "${available[$pkg]:-}" ]]; then + dropped+=("$pkg") + continue fi - (( ${#src_lines[@]} > 0 )) \ - || die "No package list: ${pkglist} is missing and pacman -Q returned nothing." + pkgs+=("$pkg") + done < <(printf '%s\n' "${src_lines[@]}") - # Parse only the first field, then drop names not resolvable in the - # live session's repos (incl. [local-aur] = /opt/localrepo). - info "Syncing package databases for resolvability check..." - if run_logged -s dot "pacman -Syy failed (resolvability check)" \ - pacman -Syy --noconfirm; then - declare -A available=() - local db_line - while IFS= read -r db_line; do - [[ -n "$db_line" ]] && available["$db_line"]=1 - done < <(pacman -Slq 2>>"$LOG" || true) - (( ${#available[@]} > 0 )) && filter_ok=true - else - warn "pacman -Syy failed (output above) — installing without resolvability filter." - fi - - local line pkg - while IFS= read -r line; do - [[ -z "$line" ]] && continue - pkg=${line%% *} - skip "$pkg" && continue - if $filter_ok && [[ -z "${available[$pkg]:-}" ]]; then - dropped+=("$pkg") - continue - fi - pkgs+=("$pkg") - done < <(printf '%s\n' "${src_lines[@]}") - - if (( ${#dropped[@]} > 0 )); then - warn "Skipped ${#dropped[@]} pkglist package(s) not found in configured repos:" - gum style --foreground 11 "${dropped[@]/#/ - }" - local d - for d in "${dropped[@]}"; do - log "Pacstrap: skipped unresolvable: $d" - done - fi - - # Hardcoded essentials are never filtered — they must be in allowed repos. - for p in base linux linux-firmware linux-cachyos linux-cachyos-headers \ - networkmanager grub efibootmgr sudo git intel-ucode amd-ucode \ - mkinitcpio cryptsetup dosfstools e2fsprogs btrfs-progs \ - cachyos-keyring cachyos-mirrorlist; do - local found=false - for existing in "${pkgs[@]+"${pkgs[@]}"}"; do - [[ "$existing" == "$p" ]] && found=true && break - done - $found || pkgs+=("$p") + if ((${#dropped[@]} > 0)); then + warn "Skipped ${#dropped[@]} pkglist package(s) not found in configured repos:" + gum style --foreground 11 "${dropped[@]/#/ - }" + local d + for d in "${dropped[@]}"; do + log "Pacstrap: skipped unresolvable: $d" done + fi - # sshd was requested but openssh is not in the medium's pkglist (or was - # filtered out above) — without this, install_sshd's enable would fail. - if [[ "$SSHD" == "true" ]]; then - local found_ssh=false - for existing in "${pkgs[@]+"${pkgs[@]}"}"; do - [[ "$existing" == "openssh" ]] && found_ssh=true && break - done - $found_ssh || pkgs+=(openssh) - log "Pacstrap: openssh guaranteed in package list (sshd requested)" - fi + # Hardcoded essentials are never filtered — they must be in allowed repos. + for p in base linux linux-firmware linux-cachyos linux-cachyos-headers \ + networkmanager grub efibootmgr sudo git intel-ucode amd-ucode \ + mkinitcpio cryptsetup dosfstools e2fsprogs btrfs-progs \ + cachyos-keyring cachyos-mirrorlist; do + local found=false + for existing in "${pkgs[@]+"${pkgs[@]}"}"; do + [[ "$existing" == "$p" ]] && found=true && break + done + $found || pkgs+=("$p") + done - log "Pacstrap: ${#pkgs[@]} packages, ${#dropped[@]} skipped" - run_logged -s dot -t "Installing ${#pkgs[@]} packages (this may take a while)" \ - "pacstrap failed" \ - pacstrap "$TARGET" "${pkgs[@]}" --noconfirm \ - || die "pacstrap failed - the target system is incomplete (output above, full log: $LOG)." + # sshd was requested but openssh is not in the medium's pkglist (or was + # filtered out above) — without this, install_sshd's enable would fail. + if [[ "$SSHD" == "true" ]]; then + local found_ssh=false + for existing in "${pkgs[@]+"${pkgs[@]}"}"; do + [[ "$existing" == "openssh" ]] && found_ssh=true && break + done + $found_ssh || pkgs+=(openssh) + log "Pacstrap: openssh guaranteed in package list (sshd requested)" + fi - # mono's post_install runs cert-sync before the post-transaction hook has - # generated ca-certificates.crt, leaving mono's certificate store empty. - if [[ -x "${TARGET}/usr/bin/cert-sync" ]]; then - arch-chroot "$TARGET" cert-sync --quiet /etc/ssl/certs/ca-certificates.crt \ - >> "$LOG" 2>&1 || warn "cert-sync failed - mono apps may reject TLS certificates." - fi + log "Pacstrap: ${#pkgs[@]} packages, ${#dropped[@]} skipped" + run_logged -s dot -t "Installing ${#pkgs[@]} packages (this may take a while)" \ + "pacstrap failed" \ + pacstrap "$TARGET" "${pkgs[@]}" --noconfirm || + die "pacstrap failed - the target system is incomplete (output above, full log: $LOG)." - ok "Packages installed." + # mono's post_install runs cert-sync before the post-transaction hook has + # generated ca-certificates.crt, leaving mono's certificate store empty. + if [[ -x "${TARGET}/usr/bin/cert-sync" ]]; then + arch-chroot "$TARGET" cert-sync --quiet /etc/ssl/certs/ca-certificates.crt \ + >>"$LOG" 2>&1 || warn "cert-sync failed - mono apps may reject TLS certificates." + fi + + ok "Packages installed." } install_fstab() { - info "Generating fstab..." - genfstab -U "$TARGET" >> "${TARGET}/etc/fstab" - ok "fstab generated." + info "Generating fstab..." + genfstab -U "$TARGET" >>"${TARGET}/etc/fstab" + ok "fstab generated." } install_cachyos_repo() { - info "Configuring CachyOS and chaotic-aur repositories..." - local conf="${TARGET}/etc/pacman.conf" + info "Configuring CachyOS and chaotic-aur repositories..." + local conf="${TARGET}/etc/pacman.conf" - # Same order as the ISO build (pacman.conf): cachyos ahead of [core], so - # packages in both keep resolving to the cachyos builds pacstrap - # installed instead of flipping to extra on the first -Syu. - if ! grep -q '^\[cachyos\]' "$conf" 2>/dev/null; then - if grep -q '^\[core\]' "$conf"; then - sed -i '0,/^\[core\]/s//[cachyos]\nInclude = \/etc\/pacman.d\/cachyos-mirrorlist\n\n[core]/' "$conf" - else - printf '\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n' >> "$conf" - fi - fi - # chaotic-aur packages (yay, ...) came from the ISO - without the repo - # they turn foreign and never update. - if ! grep -q '^\[chaotic-aur\]' "$conf" 2>/dev/null \ - && [[ -f "${TARGET}/etc/pacman.d/chaotic-mirrorlist" ]]; then - printf '\n[chaotic-aur]\nInclude = /etc/pacman.d/chaotic-mirrorlist\n' >> "$conf" + # Same order as the ISO build (pacman.conf): cachyos ahead of [core], so + # packages in both keep resolving to the cachyos builds pacstrap + # installed instead of flipping to extra on the first -Syu. + if ! grep -q '^\[cachyos\]' "$conf" 2>/dev/null; then + if grep -q '^\[core\]' "$conf"; then + sed -i '0,/^\[core\]/s//[cachyos]\nInclude = \/etc\/pacman.d\/cachyos-mirrorlist\n\n[core]/' "$conf" + else + printf '\n[cachyos]\nInclude = /etc/pacman.d/cachyos-mirrorlist\n' >>"$conf" fi + fi + # chaotic-aur packages (yay, ...) came from the ISO - without the repo + # they turn foreign and never update. + if ! grep -q '^\[chaotic-aur\]' "$conf" 2>/dev/null && + [[ -f "${TARGET}/etc/pacman.d/chaotic-mirrorlist" ]]; then + printf '\n[chaotic-aur]\nInclude = /etc/pacman.d/chaotic-mirrorlist\n' >>"$conf" + fi - # [local-aur] lives only on the live medium (file:///opt/localrepo). The - # target's pacman.conf does not normally contain it - pacstrap is run - # without -P, so it keeps the stock file shipped by the pacman package - - # but drop it anyway in case that ever changes. - sed -i '/^\[local-aur\]$/,/^$/d' "${TARGET}/etc/pacman.conf" 2>/dev/null || true + # [local-aur] lives only on the live medium (file:///opt/localrepo). The + # target's pacman.conf does not normally contain it - pacstrap is run + # without -P, so it keeps the stock file shipped by the pacman package - + # but drop it anyway in case that ever changes. + sed -i '/^\[local-aur\]$/,/^$/d' "${TARGET}/etc/pacman.conf" 2>/dev/null || true - ok "CachyOS + chaotic-aur repos configured." + ok "CachyOS + chaotic-aur repos configured." } install_timezone() { - info "Setting timezone..." - arch-chroot "$TARGET" ln -sf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime - arch-chroot "$TARGET" hwclock --systohc || true - ok "Timezone set." + info "Setting timezone..." + arch-chroot "$TARGET" ln -sf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime + arch-chroot "$TARGET" hwclock --systohc || true + ok "Timezone set." } install_locale() { - info "Setting locale..." + info "Setting locale..." - arch-chroot "$TARGET" sed -i "s/^#${LOCALE}/${LOCALE}/" /etc/locale.gen - arch-chroot "$TARGET" locale-gen + arch-chroot "$TARGET" sed -i "s/^#${LOCALE}/${LOCALE}/" /etc/locale.gen + run_logged -s dot -t "Generating locales" "locale-gen failed" \ + arch-chroot "$TARGET" locale-gen || + die "locale-gen failed (output above, full log: $LOG)." - echo "LANG=${LOCALE}" > "${TARGET}/etc/locale.conf" - echo "KEYMAP=${KEYMAP}" > "${TARGET}/etc/vconsole.conf" + echo "LANG=${LOCALE}" >"${TARGET}/etc/locale.conf" + echo "KEYMAP=${KEYMAP}" >"${TARGET}/etc/vconsole.conf" - ok "Locale and keymap set." + ok "Locale and keymap set." } install_hostname() { - info "Setting hostname..." - echo "$HOSTNAME" > "${TARGET}/etc/hostname" - cat > "${TARGET}/etc/hosts" <"${TARGET}/etc/hostname" + cat >"${TARGET}/etc/hosts" </dev/null; then - mkdir -p "${TARGET}/etc/NetworkManager/system-connections" - cp -a "$nm_dir"/* "${TARGET}/etc/NetworkManager/system-connections/" - chmod 600 "${TARGET}/etc/NetworkManager/system-connections"/* - log "WiFi profiles copied." - fi + local nm_dir="/etc/NetworkManager/system-connections" + if [[ -d "$nm_dir" ]] && ls "$nm_dir"/* &>/dev/null; then + mkdir -p "${TARGET}/etc/NetworkManager/system-connections" + cp -a "$nm_dir"/* "${TARGET}/etc/NetworkManager/system-connections/" + chmod 600 "${TARGET}/etc/NetworkManager/system-connections"/* + log "WiFi profiles copied." + fi - ok "NetworkManager enabled." + ok "NetworkManager enabled." } install_sshd() { - [[ "$SSHD" != "true" ]] && return 0 + [[ "$SSHD" != "true" ]] && return 0 - info "Enabling SSH server..." - if [[ ! -x "${TARGET}/usr/bin/sshd" ]]; then - warn "openssh is missing from the target - cannot enable sshd." - log "SSH server: enable skipped (sshd binary missing in target)" - return 0 - fi - if run_logged "Could not enable sshd.service" \ - arch-chroot "$TARGET" systemctl enable sshd.service; then - SSHD_APPLIED="true" - log "SSH server: enabled" - ok "sshd enabled (starts at boot)." - else - warn "Could not enable sshd.service (output above)." - log "SSH server: enable failed" - fi + info "Enabling SSH server..." + if [[ ! -x "${TARGET}/usr/bin/sshd" ]]; then + warn "openssh is missing from the target - cannot enable sshd." + log "SSH server: enable skipped (sshd binary missing in target)" + return 0 + fi + if run_logged "Could not enable sshd.service" \ + arch-chroot "$TARGET" systemctl enable sshd.service; then + SSHD_APPLIED="true" + log "SSH server: enabled" + ok "sshd enabled (starts at boot)." + else + warn "Could not enable sshd.service (output above)." + log "SSH server: enable failed" + fi } install_user() { - info "Creating user..." + info "Creating user..." - cp -rT /etc/skel "${TARGET}/etc/skel" + cp -rT /etc/skel "${TARGET}/etc/skel" - # fish is the default shell (skel ships its config); bash only if the - # target somehow lacks it, so the account is never left without a shell. - local user_shell="/usr/bin/fish" - [[ -x "${TARGET}${user_shell}" ]] || user_shell="/bin/bash" - arch-chroot "$TARGET" useradd -m -G wheel -s "$user_shell" "$USERNAME" - log "User shell: ${user_shell}" + # fish is the default shell (skel ships its config); bash only if the + # target somehow lacks it, so the account is never left without a shell. + local user_shell="/usr/bin/fish" + [[ -x "${TARGET}${user_shell}" ]] || user_shell="/bin/bash" + arch-chroot "$TARGET" useradd -m -G wheel -s "$user_shell" "$USERNAME" + log "User shell: ${user_shell}" - printf '%s:%s\n' "$USERNAME" "$PASS" | arch-chroot "$TARGET" chpasswd + printf '%s:%s\n' "$USERNAME" "$PASS" | arch-chroot "$TARGET" chpasswd - arch-chroot "$TARGET" passwd -l root + arch-chroot "$TARGET" passwd -l root - echo '%wheel ALL=(ALL:ALL) ALL' > "${TARGET}/etc/sudoers.d/10-wheel" - chmod 440 "${TARGET}/etc/sudoers.d/10-wheel" + echo '%wheel ALL=(ALL:ALL) ALL' >"${TARGET}/etc/sudoers.d/10-wheel" + chmod 440 "${TARGET}/etc/sudoers.d/10-wheel" - ok "User ${USERNAME} created (wheel group, sudo enabled). Root locked." + ok "User ${USERNAME} created (wheel group, sudo enabled). Root locked." } install_git_ssh() { - local home_in_target="/home/${USERNAME}" - local owner - owner=$(target_owner) + local home_in_target="/home/${USERNAME}" + local owner + owner=$(target_owner) - # ── git identity (skipped when no email was entered) ───────────────────── - if [[ -n "$GIT_EMAIL" ]]; then - info "Configuring git..." - local git_name="${GIT_NAME:-$USERNAME}" - run_logged "git config user.name failed" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - git config --global user.name "$git_name" \ - || warn "git config user.name failed (output above)." - run_logged "git config user.email failed" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - git config --global user.email "$GIT_EMAIL" \ - || warn "git config user.email failed (output above)." - ok "Git configured for ${USERNAME} (${git_name} <${GIT_EMAIL}>)." - log "Git: ${git_name} <${GIT_EMAIL}>" + # ── git identity (skipped when no email was entered) ───────────────────── + if [[ -n "$GIT_EMAIL" ]]; then + info "Configuring git..." + local git_name="${GIT_NAME:-$USERNAME}" + run_logged "git config user.name failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + git config --global user.name "$git_name" || + warn "git config user.name failed (output above)." + run_logged "git config user.email failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + git config --global user.email "$GIT_EMAIL" || + warn "git config user.email failed (output above)." + ok "Git configured for ${USERNAME} (${git_name} <${GIT_EMAIL}>)." + log "Git: ${git_name} <${GIT_EMAIL}>" + else + warn "No git email entered - skipping git identity." + log "Git: skipped" + fi + + # ── SSH keys (skipped when no GitHub username was entered) ─────────────── + if [[ -z "$GH_USERNAME" ]]; then + log "SSH keys: skipped (no GitHub username)" + return 0 + fi + + info "Importing SSH keys for github.com/${GH_USERNAME}..." + local ssh_dir="${TARGET}${home_in_target}/.ssh" + mkdir -p "$ssh_dir" + # ssh-import-id runs as $USERNAME and opens ~/.ssh/authorized_keys itself; + # the mkdir above ran as root, so hand the directory over BEFORE the + # import - otherwise it dies on PermissionError every single time. + chown "$owner" "$ssh_dir" 2>/dev/null || true + chmod 700 "$ssh_dir" 2>/dev/null || true + + sync_target_dbs + + # ssh-import-id from the AUR via yay, as the new user. The AUR PKGBUILD + # does not declare its runtime dependency python-distro, so install that + # first - without it the tool dies with ModuleNotFoundError. update.sh may + # already have put ssh-import-id into the ISO (localrepo), so build only + # when the target does not have it yet. yay needs passwordless sudo here; + # the drop-in is removed immediately afterwards. + local sid_ok="false" + if arch-chroot "$TARGET" pacman -Q ssh-import-id >/dev/null 2>&1; then + sid_ok="true" + log "ssh-import-id: already present in target" + else + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >"${TARGET}/etc/sudoers.d/99-ssh-import-id" + chmod 440 "${TARGET}/etc/sudoers.d/99-ssh-import-id" + run_logged -s dot -t "Searching the AUR for ssh-import-id" \ + "yay -Ss ssh-import-id found nothing" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -Ss ssh-import-id || + warn "yay -Ss ssh-import-id found nothing (output above)." + if run_logged -s dot -t "Installing ssh-import-id" \ + "pacman -S python-distro failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm python-distro && + run_logged -s dot -t "Installing ssh-import-id" \ + "yay failed to install ssh-import-id" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -S --noconfirm ssh-import-id; then + sid_ok="true" + log "ssh-import-id: installed via yay" else - warn "No git email entered - skipping git identity." - log "Git: skipped" + warn "yay could not install ssh-import-id (output above) - falling back to github.com keys." fi + rm -f "${TARGET}/etc/sudoers.d/99-ssh-import-id" + fi - # ── SSH keys (skipped when no GitHub username was entered) ─────────────── - if [[ -z "$GH_USERNAME" ]]; then - log "SSH keys: skipped (no GitHub username)" - return 0 - fi - - info "Importing SSH keys for github.com/${GH_USERNAME}..." - local ssh_dir="${TARGET}${home_in_target}/.ssh" - mkdir -p "$ssh_dir" - # ssh-import-id runs as $USERNAME and opens ~/.ssh/authorized_keys itself; - # the mkdir above ran as root, so hand the directory over BEFORE the - # import - otherwise it dies on PermissionError every single time. - chown "$owner" "$ssh_dir" 2>/dev/null || true - chmod 700 "$ssh_dir" 2>/dev/null || true - - sync_target_dbs - - # ssh-import-id from the AUR via yay, as the new user. The AUR PKGBUILD - # does not declare its runtime dependency python-distro, so install that - # first - without it the tool dies with ModuleNotFoundError. update.sh may - # already have put ssh-import-id into the ISO (localrepo), so build only - # when the target does not have it yet. yay needs passwordless sudo here; - # the drop-in is removed immediately afterwards. - local sid_ok="false" - if arch-chroot "$TARGET" pacman -Q ssh-import-id >/dev/null 2>&1; then - sid_ok="true" - log "ssh-import-id: already present in target" + if [[ "$sid_ok" == "true" ]]; then + if run_logged -s dot -t "Importing GitHub SSH keys" "ssh-import-id failed" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" ssh-import-id "gh:${GH_USERNAME}"; then + ok "SSH keys imported via ssh-import-id." + log "SSH keys: ssh-import-id gh:${GH_USERNAME}" else - echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-ssh-import-id" - chmod 440 "${TARGET}/etc/sudoers.d/99-ssh-import-id" - run_logged "yay -Ss ssh-import-id found nothing" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -Ss ssh-import-id \ - || warn "yay -Ss ssh-import-id found nothing (output above)." - if run_logged -s dot -t "Installing ssh-import-id" \ - "pacman -S python-distro failed" \ - arch-chroot "$TARGET" pacman -S --noconfirm python-distro \ - && run_logged -s jump -t "Installing ssh-import-id" \ - "yay failed to install ssh-import-id" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -S --noconfirm ssh-import-id; then - sid_ok="true" - log "ssh-import-id: installed via yay" - else - warn "yay could not install ssh-import-id (output above) - falling back to github.com keys." - fi - rm -f "${TARGET}/etc/sudoers.d/99-ssh-import-id" + warn "ssh-import-id failed (output above) - falling back to github.com keys." + sid_ok="false" fi + fi - if [[ "$sid_ok" == "true" ]]; then - if run_logged "ssh-import-id failed" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" ssh-import-id "gh:${GH_USERNAME}"; then - ok "SSH keys imported via ssh-import-id." - log "SSH keys: ssh-import-id gh:${GH_USERNAME}" - else - warn "ssh-import-id failed (output above) - falling back to github.com keys." - sid_ok="false" - fi + # Fallback: pull the account's public keys straight from GitHub (per-key + # dedupe keeps re-runs idempotent). + if [[ "$sid_ok" != "true" ]]; then + local keys_url="https://github.com/${GH_USERNAME}.keys" + local keys="" curl_rc=0 + keys=$(curl -fsSL --max-time 30 "$keys_url" 2>>"$LOG") || curl_rc=$? + if ((curl_rc != 0)); then + warn "Could not fetch ${keys_url} (curl exit ${curl_rc}) - no SSH keys imported." + # curl's stderr went to $LOG only - put it in front of the user. + page_log_tail "curl error output (q to quit)" 60 || true + log "SSH keys: curl failed (exit ${curl_rc}) for ${keys_url}" + elif [[ -z "$keys" ]]; then + # HTTP 200 with an empty body: the account exists but simply has + # no registered keys - not a network problem. + warn "github.com/${GH_USERNAME} has no SSH keys registered - nothing to import." + log "SSH keys: github.com/${GH_USERNAME} has no keys" + else + local auth="${ssh_dir}/authorized_keys" + local line + touch "$auth" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + grep -qxF -- "$line" "$auth" 2>/dev/null || printf '%s\n' "$line" >>"$auth" + done <<<"$keys" + ok "SSH keys appended from ${keys_url}." + log "SSH keys: curl ${keys_url}" fi + fi - # Fallback: pull the account's public keys straight from GitHub (per-key - # dedupe keeps re-runs idempotent). - if [[ "$sid_ok" != "true" ]]; then - local keys_url="https://github.com/${GH_USERNAME}.keys" - local keys="" curl_rc=0 - keys=$(curl -fsSL --max-time 30 "$keys_url" 2>>"$LOG") || curl_rc=$? - if (( curl_rc != 0 )); then - warn "Could not fetch ${keys_url} (curl exit ${curl_rc}) - no SSH keys imported." - # curl's stderr went to $LOG only - put it in front of the user. - page_log_tail "curl error output (q to quit)" 60 || true - log "SSH keys: curl failed (exit ${curl_rc}) for ${keys_url}" - elif [[ -z "$keys" ]]; then - # HTTP 200 with an empty body: the account exists but simply has - # no registered keys - not a network problem. - warn "github.com/${GH_USERNAME} has no SSH keys registered - nothing to import." - log "SSH keys: github.com/${GH_USERNAME} has no keys" - else - local auth="${ssh_dir}/authorized_keys" - local line - touch "$auth" - while IFS= read -r line; do - [[ -z "$line" ]] && continue - grep -qxF -- "$line" "$auth" 2>/dev/null || printf '%s\n' "$line" >> "$auth" - done <<< "$keys" - ok "SSH keys appended from ${keys_url}." - log "SSH keys: curl ${keys_url}" - fi - fi - - chmod 700 "$ssh_dir" 2>/dev/null || true - chown "$owner" "$ssh_dir" 2>/dev/null || true - if [[ -f "${ssh_dir}/authorized_keys" ]]; then - chmod 600 "${ssh_dir}/authorized_keys" 2>/dev/null || true - chown "$owner" "${ssh_dir}/authorized_keys" 2>/dev/null || true - fi - ok "SSH setup finished for ${USERNAME}." + chmod 700 "$ssh_dir" 2>/dev/null || true + chown "$owner" "$ssh_dir" 2>/dev/null || true + if [[ -f "${ssh_dir}/authorized_keys" ]]; then + chmod 600 "${ssh_dir}/authorized_keys" 2>/dev/null || true + chown "$owner" "${ssh_dir}/authorized_keys" 2>/dev/null || true + fi + ok "SSH setup finished for ${USERNAME}." } install_end4_dots() { - if [[ ! -d /opt/dots-hyprland ]]; then - warn "No staged dots-hyprland at /opt/dots-hyprland — skipping end-4 dotfiles." - return - fi + if [[ ! -d /opt/dots-hyprland ]]; then + warn "No staged dots-hyprland at /opt/dots-hyprland — skipping end-4 dotfiles." + return + fi - info "Installing end-4 dotfiles (this may take a while)..." + info "Installing end-4 dotfiles (this may take a while)..." - local home_in_target="/home/${USERNAME}" - local repo_in_target="${home_in_target}/.dots-hyprland" - local repo_on_host="${TARGET}${repo_in_target}" - local dots_ok=true - local setups_done=true + local home_in_target="/home/${USERNAME}" + local repo_in_target="${home_in_target}/.dots-hyprland" + local repo_on_host="${TARGET}${repo_in_target}" + local dots_ok=true + local setups_done=true - # These chowns run on the live medium, whose /etc/passwd has no - # $USERNAME — the user exists only inside $TARGET — so name lookup fails - # ("chown: invalid user"). Resolve the numeric ids from the target and - # chown by number; the catch-all at the end of this function does the same. - local owner - owner=$(target_owner) + # These chowns run on the live medium, whose /etc/passwd has no + # $USERNAME — the user exists only inside $TARGET — so name lookup fails + # ("chown: invalid user"). Resolve the numeric ids from the target and + # chown by number; the catch-all at the end of this function does the same. + local owner + owner=$(target_owner) - cp -a /opt/dots-hyprland "$repo_on_host" - chown -R "$owner" "$repo_on_host" + cp -a /opt/dots-hyprland "$repo_on_host" + chown -R "$owner" "$repo_on_host" - echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-end4-install" - chmod 440 "${TARGET}/etc/sudoers.d/99-end4-install" + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >"${TARGET}/etc/sudoers.d/99-end4-install" + chmod 440 "${TARGET}/etc/sudoers.d/99-end4-install" - # Everything appended to $LOG from here belongs to this attempt; used - # further down to report which steps end-4 had to skip. - local log_mark=0 - if [[ -f "$LOG" ]]; then - log_mark=$(wc -l < "$LOG") - fi + # Everything appended to $LOG from here belongs to this attempt; used + # further down to report which steps end-4 had to skip. + local log_mark=0 + if [[ -f "$LOG" ]]; then + log_mark=$(wc -l <"$LOG") + fi - # stdin feeds "i" to end-4's x() [R/e:i] failure prompt, which otherwise - # blocks forever on a TTY whose prompt text is redirected to $LOG. - # Its systemd lines cannot work from inside this chroot: the medium's /run - # is bind-mounted in, so `systemctl --machine=…@.host --user enable … --now` - # has no target session (and --now would start services on the live medium - # anyway) - they fail on every install. "i" lets the run carry on with the - # venv, groups and config sync instead of aborting at the first such line; - # the usable part of that step is re-applied below and every skip is - # reported afterwards. - # --skip-alldeps: every dependency was already installed at ISO build time - # (customize_airootfs.sh runs ./setup install-deps), so this only copies - # configs, sets up services and builds the python venv from the warm cache. - # Both attempts run under a jump spinner (this step compiles the venv and - # pip bits): --show-output + the sh stderr-merge keep every line flowing - # into $LOG, the frames render on the terminal, and stdin still feeds the - # `yes i` process substitution through to ./setup. - if ! gum spin --show-output --spinner jump -- \ - sh -c '"$@" 2>&1' sh \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" bash -c \ - "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps" \ - < <(yes i) >>"$LOG"; then - warn "end-4 setup failed, retrying files only (see $LOG)." - setups_done=false - gum spin --show-output --spinner jump -- \ - sh -c '"$@" 2>&1' sh \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" bash -c \ - "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps --skip-allsetups" \ - < <(yes i) >>"$LOG" || dots_ok=false - # Both attempts' output exists only in $LOG - page it so the user can - # inspect what end-4 actually choked on. - page_log_since "$log_mark" "end-4 setup output (both attempts) - q to quit, scroll for more" || true - fi + # stdin feeds "i" to end-4's x() [R/e:i] failure prompt, which otherwise + # blocks forever on a TTY whose prompt text is redirected to $LOG. + # Its systemd lines cannot work from inside this chroot: the medium's /run + # is bind-mounted in, so `systemctl --machine=…@.host --user enable … --now` + # has no target session (and --now would start services on the live medium + # anyway) - they fail on every install. "i" lets the run carry on with the + # venv, groups and config sync instead of aborting at the first such line; + # the usable part of that step is re-applied below and every skip is + # reported afterwards. + # --skip-alldeps: every dependency was already installed at ISO build time + # (customize_airootfs.sh runs ./setup install-deps), so this only copies + # configs, sets up services and builds the python venv from the warm cache. + # Both attempts run under a dot spinner (this step compiles the venv and + # pip bits): --show-output + the sh stderr-merge keep every line flowing + # into $LOG, the frames render on the terminal, and stdin still feeds the + # `yes i` process substitution through to ./setup. + if ! gum spin --show-output -s dot -- \ + sh -c '"$@" 2>&1' sh \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" bash -c \ + "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps" \ + < <(yes i) >>"$LOG"; then + warn "end-4 setup failed, retrying files only (see $LOG)." + setups_done=false + gum spin --show-output -s dot -- \ + sh -c '"$@" 2>&1' sh \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" bash -c \ + "cd ${repo_in_target} && ./setup install -f -s --skip-alldeps --skip-allsetups" \ + < <(yes i) >>"$LOG" || dots_ok=false + # Both attempts' output exists only in $LOG - page it so the user can + # inspect what end-4 actually choked on. + page_log_since "$log_mark" "end-4 setup output (both attempts) - q to quit, scroll for more" || true + fi - rm -f "${TARGET}/etc/sudoers.d/99-end4-install" + rm -f "${TARGET}/etc/sudoers.d/99-end4-install" - if ! arch-chroot "$TARGET" id -nG "$USERNAME" | grep -qw input; then - arch-chroot "$TARGET" groupadd -f i2c - arch-chroot "$TARGET" usermod -aG video,i2c,input "$USERNAME" - warn "end-4 group setup was missing and has been applied." - fi + if ! arch-chroot "$TARGET" id -nG "$USERNAME" | grep -qw input; then + arch-chroot "$TARGET" groupadd -f i2c + arch-chroot "$TARGET" usermod -aG video,i2c,input "$USERNAME" + warn "end-4 group setup was missing and has been applied." + fi - # ── steps end-4 cannot take from inside the chroot ─────────────────────── - # `enable` alone is a pure file operation and needs no running session, so - # redo those here - without --now, since the target is not booted yet. - local mods_conf="${TARGET}/etc/modules-load.d/i2c-dev.conf" - if [[ ! -f "$mods_conf" ]]; then - mkdir -p "$(dirname "$mods_conf")" - echo i2c-dev > "$mods_conf" - fi + # ── steps end-4 cannot take from inside the chroot ─────────────────────── + # `enable` alone is a pure file operation and needs no running session, so + # redo those here - without --now, since the target is not booted yet. + local mods_conf="${TARGET}/etc/modules-load.d/i2c-dev.conf" + if [[ ! -f "$mods_conf" ]]; then + mkdir -p "$(dirname "$mods_conf")" + echo i2c-dev >"$mods_conf" + fi - if [[ -f "${TARGET}/usr/lib/systemd/system/bluetooth.service" ]]; then - run_logged "Could not enable bluetooth.service" \ - arch-chroot "$TARGET" systemctl enable bluetooth.service \ - || warn "Could not enable bluetooth.service (output above)." - fi - # `systemctl --user` cannot work in this chroot either: it needs - # /run/user/UID/bus, which does not exist until the target boots (it only - # ever reports "Failed to connect to user scope bus"). Write the symlink - # `enable` would write — the same absolute link systemctl produces. - if [[ -f "${TARGET}/usr/lib/systemd/user/ydotool.service" ]]; then - local yd_wants="${TARGET}${home_in_target}/.config/systemd/user/default.target.wants" - # Capture instead of redirecting: on failure the output is both - # appended to $LOG and paged to the user (as the bluetooth block's - # run_logged does), never kept in the log alone. - local yd_rc=0 yd_err="" - yd_err=$( { mkdir -p "$yd_wants" \ - && ln -sfn /usr/lib/systemd/user/ydotool.service "$yd_wants/ydotool.service"; } 2>&1 ) \ - || yd_rc=$? - if (( yd_rc == 0 )); then - log "ydotool.service enabled for ${USERNAME} (symlinked in user unit dir)." - else - if [[ -n "$yd_err" ]]; then - printf '%s\n' "$yd_err" >>"$LOG" - printf '%s\n' "$yd_err" | page_output "ydotool.service symlink failed (exit ${yd_rc}) - full log: $LOG" - fi - warn "Could not enable ydotool.service for ${USERNAME} (output above)." - fi - fi - - # end-4's gsettings calls run under its sudo session inside this chroot, - # so dconf sees root's environment (/run/user/0, /root/.local/...) and no - # session bus: every write fails with "failed to commit changes to - # dconf" while gsettings still exits 0 and reports "finished". Replay any - # gsettings set this run logged, with a clean environment and a - # throwaway session bus as the target user. gsettings exits 0 even when - # the commit fails, so read the key back in a second session — only a - # matching value counts as persisted. - if ((log_mark > 0)) \ - && [[ -x "${TARGET}/usr/bin/dbus-run-session" && -x "${TARGET}/usr/bin/gsettings" ]]; then - local gs_line gs_schema gs_key gs_val gs_got gs_norm gs_raw gs_err gs_rc - # Snapshot and parse first (the loop below appends to $LOG), keeping - # log order: a key set twice replays chronologically so the last - # write wins, exactly as end-4 ran it. Dedupe identical repeats only. - local -a gs_cmds=() - mapfile -t gs_cmds < <( - # Same ANSI-strip as the ignored-list parse below; the setup log - # shows each command once as its "Next command" line (the - # "…finished." echoes carry a prefix and never match ^gsettings). - sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | - sed -n 's/^\(gsettings set .*\)$/\1/p' | awk '!seen[$0]++' - ) - log "gsettings replay: ${#gs_cmds[@]} command(s) parsed from log" - # Parsing is coupled to end-4's v() echo format: if upstream changes - # it, the replay silently does nothing and the original dconf failure - # returns - so distinguish "none ran" from "none recognized". - if ((${#gs_cmds[@]} == 0)); then - gs_raw=$(sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | - grep -c 'gsettings set' || true) - if ((gs_raw > 0)); then - warn "setup log mentions gsettings ${gs_raw}x but none matched the replay parser (format drift?) - see $LOG" - fi - fi - for gs_line in "${gs_cmds[@]}"; do - [[ "$gs_line" =~ ^gsettings\ set\ ([^[:space:]]+)\ ([^[:space:]]+)\ (.+)$ ]] || continue - gs_schema=${BASH_REMATCH[1]} - gs_key=${BASH_REMATCH[2]} - gs_val=${BASH_REMATCH[3]} - # env -i: root's env is exactly what broke dconf; rebuild it for - # the target user with only what gsettings/dbus need. - # gsettings exits 0 even when the commit fails, so both the exit - # status AND the read-back below decide success; its output is - # kept in $LOG and paged on failure. - gs_err="" - gs_rc=0 - gs_err=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ - PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ - XDG_CONFIG_HOME="${home_in_target}/.config" \ - dbus-run-session -- \ - gsettings set "$gs_schema" "$gs_key" "$gs_val" 2>&1) || gs_rc=$? - if [[ -n "$gs_err" ]]; then - printf '%s\n' "$gs_err" >>"$LOG" - fi - if (( gs_rc == 0 )); then - gs_got=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ - PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ - XDG_CONFIG_HOME="${home_in_target}/.config" \ - dbus-run-session -- \ - gsettings get "$gs_schema" "$gs_key" 2>>"$LOG") || gs_got="" - # Normalize the GVariant read-back: strings come back quoted - # ('...' normally, "..." when the value contains an apostrophe) - # with \n \\ \' \" escapes, bools/ints bare ('true' vs true). - gs_norm=$(gvariant_unescape "$gs_got") - if [[ "$gs_norm" == "$gs_val" ]]; then - log "gsettings persisted: ${gs_schema} ${gs_key} = ${gs_val}" - else - if [[ -n "$gs_err" ]]; then - printf '%s\n' "$gs_err" | page_output "gsettings ${gs_schema} ${gs_key} command output (q to quit) - full log: $LOG" - fi - warn "gsettings ${gs_schema} ${gs_key} did not persist (read back: ${gs_got:-nothing}, see $LOG)." - fi - else - if [[ -n "$gs_err" ]]; then - printf '%s\n' "$gs_err" | page_output "gsettings set failed (exit ${gs_rc}) - output below, full log: $LOG" - fi - warn "Could not replay gsettings ${gs_schema} ${gs_key} (see $LOG)." - fi - done - fi - - # Everything end-4 answered "i" to. systemctl/gsettings are the lines known - # to be unusable in this chroot; anything else it had to skip is worth - # telling the user about. - local -a ignored=() - if ((log_mark > 0)); then - mapfile -t ignored < <( - sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | - sed -n 's/^.*Command "\(.*\)" has failed but ignored by user\..*$/\1/p' - ) - fi - if ((${#ignored[@]} > 0)); then - log "end-4 skipped ${#ignored[@]} step(s): ${ignored[*]}" - local step - local -a skipped=() - for step in "${ignored[@]}"; do - case "$step" in - *systemctl* | *gsettings*) ;; - *) skipped+=("$step") ;; - esac - done - if ((${#skipped[@]} > 0)); then - warn "end-4 had to skip: ${skipped[*]} (see $LOG)" - fi - fi - if ! $setups_done; then - warn "end-4 setup did not complete — rerun as ${USERNAME}: cd ~/.dots-hyprland && ./setup install-setups" - fi - - # end-4's install step rsync --delete's the whole ~/.config/quickshell - # directory (sdata/subcmd-install/3.files-legacy.sh), which drops the - # end4-pC fork skel put into the user's home - restore it afterwards. - local fork_src="/etc/skel/.config/quickshell/end4-pC" - local fork_dst="${TARGET}${home_in_target}/.config/quickshell/end4-pC" - if [[ -d "$fork_src" ]]; then - mkdir -p "$(dirname "$fork_dst")" - rm -rf "$fork_dst" - cp -a "$fork_src" "$fork_dst" - fi - - # Same for fish: end-4 syncs ~/.config/fish with rsync --delete, keeping - # only what upstream ships (auto-Hypr.fish, config.fish, fish_variables), - # which drops skel's extras (fisher/nvm functions, completions, - # fish_plugins) and restores its own `alias q 'qs -c ii'`. The default - # quickshell config on this ISO is end4-pC, so put the extras back and - # repoint that one alias. - local fish_src="/etc/skel/.config/fish" - local fish_dst="${TARGET}${home_in_target}/.config/fish" - if [[ -d "$fish_src" && -d "$fish_dst" ]]; then - rsync -a --exclude config.fish --exclude fish_variables \ - "$fish_src/" "$fish_dst/" - if [[ -f "${fish_dst}/config.fish" ]]; then - sed -i "s/alias q 'qs -c ii'/alias q 'qs -c end4-pC'/" "${fish_dst}/config.fish" - grep -qF "alias q 'qs -c end4-pC'" "${fish_dst}/config.fish" \ - || warn "fish 'q' alias still points at the upstream ii config (see $LOG)." - fi - fi - - local configs_ok=true - [[ -e "${TARGET}${home_in_target}/.config/hypr/hyprland.lua" \ - && -d "${TARGET}${home_in_target}/.config/quickshell/ii" \ - && -d "${TARGET}${home_in_target}/.config/quickshell/end4-pC" ]] || configs_ok=false - # Files alone are not enough: with --skip-alldeps the dependencies must - # have come from the ISO. hyprland and quickshell are installed at ISO - # build time, so their binaries missing here means pacstrap dropped them - # (they were not resolvable in the live repos). The venv is created from - # the image's uv cache at install time, so a missing python means the - # python step of end-4's setup never got to run. - if $configs_ok; then - [[ -x "${TARGET}/usr/bin/hyprctl" && -x "${TARGET}/usr/bin/quickshell" ]] \ - || configs_ok=false - - # uv writes bin/python as an absolute symlink into the chroot - # (/home/.../uv/python/...). Tested verbatim from the live medium - # that link dangles - /home/$USERNAME exists only under $TARGET - - # so walk the chain here and re-root absolute hops under $TARGET - # (relative hops stay inside the venv and need no repointing). - local venv_probe="${TARGET}${home_in_target}/.local/state/quickshell/.venv/bin/python" - local venv_link hops=0 - while [[ -L "$venv_probe" && $hops -lt 10 ]]; do - venv_link=$(readlink "$venv_probe") - if [[ "$venv_link" == /* ]]; then - venv_probe="${TARGET}${venv_link}" - else - venv_probe="$(dirname "$venv_probe")/$venv_link" - fi - hops=$((hops + 1)) - done - [[ -x "$venv_probe" ]] || configs_ok=false - fi - - if [[ -d /etc/skel/.config/hypr/custom ]]; then - cp -rfT /etc/skel/.config/hypr/custom "${TARGET}${home_in_target}/.config/hypr/custom" - fi - - # Presets ship with absolute paths from the build machine; repoint them at - # the home that was just created (the images they name are not shipped). - local presets="${TARGET}${home_in_target}/.config/illogical-impulse/presets" - local preset - for preset in "$presets"/*.json; do - [[ -f "$preset" ]] || continue - sed -i "s|/home/alvin/|/home/${USERNAME}/|g" "$preset" - done - - # Everything above that ran as root from the live medium (repo copy, - # fork/fish/hypr restores, and the presets sed -i just rewrote through a - # temp file) left root-owned files in the home — one pass fixes them all. - # Numeric ids again: this chown also runs on the medium, not in the chroot. - chown -R "$owner" "${TARGET}${home_in_target}" - - if $dots_ok && $configs_ok; then - log "end-4 dotfiles installed for ${USERNAME} (setups_done=${setups_done} skipped=${#ignored[@]})." - ok "end-4 dotfiles installed." + if [[ -f "${TARGET}/usr/lib/systemd/system/bluetooth.service" ]]; then + run_logged "Could not enable bluetooth.service" \ + arch-chroot "$TARGET" systemctl enable bluetooth.service || + warn "Could not enable bluetooth.service (output above)." + fi + # `systemctl --user` cannot work in this chroot either: it needs + # /run/user/UID/bus, which does not exist until the target boots (it only + # ever reports "Failed to connect to user scope bus"). Write the symlink + # `enable` would write — the same absolute link systemctl produces. + if [[ -f "${TARGET}/usr/lib/systemd/user/ydotool.service" ]]; then + local yd_wants="${TARGET}${home_in_target}/.config/systemd/user/default.target.wants" + # Capture instead of redirecting: on failure the output is both + # appended to $LOG and paged to the user (as the bluetooth block's + # run_logged does), never kept in the log alone. + local yd_rc=0 yd_err="" + yd_err=$({ mkdir -p "$yd_wants" && + ln -sfn /usr/lib/systemd/user/ydotool.service "$yd_wants/ydotool.service"; } 2>&1) || + yd_rc=$? + if ((yd_rc == 0)); then + log "ydotool.service enabled for ${USERNAME} (symlinked in user unit dir)." else - log "end-4 dotfiles incomplete for ${USERNAME} (setup_exit_ok=${dots_ok} configs=${configs_ok} setups=${setups_done} skipped=${#ignored[@]})." - warn "end-4 dotfiles incomplete (see $LOG) — as ${USERNAME}, run: cd ~/.dots-hyprland && ./setup install" + if [[ -n "$yd_err" ]]; then + printf '%s\n' "$yd_err" >>"$LOG" + printf '%s\n' "$yd_err" | page_output "ydotool.service symlink failed (exit ${yd_rc}) - full log: $LOG" + fi + warn "Could not enable ydotool.service for ${USERNAME} (output above)." fi + fi + + # end-4's gsettings calls run under its sudo session inside this chroot, + # so dconf sees root's environment (/run/user/0, /root/.local/...) and no + # session bus: every write fails with "failed to commit changes to + # dconf" while gsettings still exits 0 and reports "finished". Replay any + # gsettings set this run logged, with a clean environment and a + # throwaway session bus as the target user. gsettings exits 0 even when + # the commit fails, so read the key back in a second session — only a + # matching value counts as persisted. + if ((log_mark > 0)) && + [[ -x "${TARGET}/usr/bin/dbus-run-session" && -x "${TARGET}/usr/bin/gsettings" ]]; then + local gs_line gs_schema gs_key gs_val gs_got gs_norm gs_raw gs_err gs_rc + # Snapshot and parse first (the loop below appends to $LOG), keeping + # log order: a key set twice replays chronologically so the last + # write wins, exactly as end-4 ran it. Dedupe identical repeats only. + local -a gs_cmds=() + mapfile -t gs_cmds < <( + # Same ANSI-strip as the ignored-list parse below; the setup log + # shows each command once as its "Next command" line (the + # "…finished." echoes carry a prefix and never match ^gsettings). + sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | + sed -n 's/^\(gsettings set .*\)$/\1/p' | awk '!seen[$0]++' + ) + log "gsettings replay: ${#gs_cmds[@]} command(s) parsed from log" + # Parsing is coupled to end-4's v() echo format: if upstream changes + # it, the replay silently does nothing and the original dconf failure + # returns - so distinguish "none ran" from "none recognized". + if ((${#gs_cmds[@]} == 0)); then + gs_raw=$(sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | + grep -c 'gsettings set' || true) + if ((gs_raw > 0)); then + warn "setup log mentions gsettings ${gs_raw}x but none matched the replay parser (format drift?) - see $LOG" + fi + fi + for gs_line in "${gs_cmds[@]}"; do + [[ "$gs_line" =~ ^gsettings\ set\ ([^[:space:]]+)\ ([^[:space:]]+)\ (.+)$ ]] || continue + gs_schema=${BASH_REMATCH[1]} + gs_key=${BASH_REMATCH[2]} + gs_val=${BASH_REMATCH[3]} + # env -i: root's env is exactly what broke dconf; rebuild it for + # the target user with only what gsettings/dbus need. + # GIO_USE_VFS=local: otherwise the session bus activates gvfsd, whose + # gvfsd-fuse leaves a dead ~/.gvfs mount that breaks chown -R below. + # gsettings exits 0 even when the commit fails, so both the exit + # status AND the read-back below decide success; its output is + # kept in $LOG and paged on failure. + gs_err="" + gs_rc=0 + gs_err=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ + XDG_CONFIG_HOME="${home_in_target}/.config" \ + GIO_USE_VFS=local GVFS_DISABLE_FUSE=1 \ + dbus-run-session -- \ + gsettings set "$gs_schema" "$gs_key" "$gs_val" 2>&1) || gs_rc=$? + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" >>"$LOG" + fi + if ((gs_rc == 0)); then + gs_got=$(arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env -i HOME="$home_in_target" USER="$USERNAME" LOGNAME="$USERNAME" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin \ + XDG_CONFIG_HOME="${home_in_target}/.config" \ + GIO_USE_VFS=local GVFS_DISABLE_FUSE=1 \ + dbus-run-session -- \ + gsettings get "$gs_schema" "$gs_key" 2>>"$LOG") || gs_got="" + # Normalize the GVariant read-back: strings come back quoted + # ('...' normally, "..." when the value contains an apostrophe) + # with \n \\ \' \" escapes, bools/ints bare ('true' vs true). + gs_norm=$(gvariant_unescape "$gs_got") + if [[ "$gs_norm" == "$gs_val" ]]; then + log "gsettings persisted: ${gs_schema} ${gs_key} = ${gs_val}" + else + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" | page_output "gsettings ${gs_schema} ${gs_key} command output (q to quit) - full log: $LOG" + fi + warn "gsettings ${gs_schema} ${gs_key} did not persist (read back: ${gs_got:-nothing}, see $LOG)." + fi + else + if [[ -n "$gs_err" ]]; then + printf '%s\n' "$gs_err" | page_output "gsettings set failed (exit ${gs_rc}) - output below, full log: $LOG" + fi + warn "Could not replay gsettings ${gs_schema} ${gs_key} (see $LOG)." + fi + done + fi + + # Everything end-4 answered "i" to. systemctl/gsettings are the lines known + # to be unusable in this chroot; anything else it had to skip is worth + # telling the user about. + local -a ignored=() + if ((log_mark > 0)); then + mapfile -t ignored < <( + sed -n "$((log_mark + 1)),\$p" "$LOG" | sed $'s/\x1b\\[[0-9;]*m//g' | + sed -n 's/^.*Command "\(.*\)" has failed but ignored by user\..*$/\1/p' + ) + fi + if ((${#ignored[@]} > 0)); then + log "end-4 skipped ${#ignored[@]} step(s): ${ignored[*]}" + local step + local -a skipped=() + for step in "${ignored[@]}"; do + case "$step" in + *systemctl* | *gsettings*) ;; + *) skipped+=("$step") ;; + esac + done + if ((${#skipped[@]} > 0)); then + warn "end-4 had to skip: ${skipped[*]} (see $LOG)" + fi + fi + if ! $setups_done; then + warn "end-4 setup did not complete — rerun as ${USERNAME}: cd ~/.dots-hyprland && ./setup install-setups" + fi + + # end-4's install step rsync --delete's the whole ~/.config/quickshell + # directory (sdata/subcmd-install/3.files-legacy.sh), which drops the + # end4-pC fork skel put into the user's home - restore it afterwards. + local fork_src="/etc/skel/.config/quickshell/end4-pC" + local fork_dst="${TARGET}${home_in_target}/.config/quickshell/end4-pC" + if [[ -d "$fork_src" ]]; then + mkdir -p "$(dirname "$fork_dst")" + rm -rf "$fork_dst" + cp -a "$fork_src" "$fork_dst" + fi + + # Same for fish: end-4 syncs ~/.config/fish with rsync --delete, keeping + # only what upstream ships (auto-Hypr.fish, config.fish, fish_variables), + # which drops skel's extras (fisher/nvm functions, completions, + # fish_plugins) and restores its own `alias q 'qs -c ii'`. The default + # quickshell config on this ISO is end4-pC, so put the extras back and + # repoint that one alias. + local fish_src="/etc/skel/.config/fish" + local fish_dst="${TARGET}${home_in_target}/.config/fish" + if [[ -d "$fish_src" && -d "$fish_dst" ]]; then + rsync -a --exclude config.fish --exclude fish_variables \ + "$fish_src/" "$fish_dst/" + if [[ -f "${fish_dst}/config.fish" ]]; then + sed -i "s/alias q 'qs -c ii'/alias q 'qs -c end4-pC'/" "${fish_dst}/config.fish" + grep -qF "alias q 'qs -c end4-pC'" "${fish_dst}/config.fish" || + warn "fish 'q' alias still points at the upstream ii config (see $LOG)." + fi + fi + + local configs_ok=true + [[ -e "${TARGET}${home_in_target}/.config/hypr/hyprland.lua" && + -d "${TARGET}${home_in_target}/.config/quickshell/ii" && + -d "${TARGET}${home_in_target}/.config/quickshell/end4-pC" ]] || configs_ok=false + # Files alone are not enough: with --skip-alldeps the dependencies must + # have come from the ISO. hyprland and quickshell are installed at ISO + # build time, so their binaries missing here means pacstrap dropped them + # (they were not resolvable in the live repos). The venv is created from + # the image's uv cache at install time, so a missing python means the + # python step of end-4's setup never got to run. + if $configs_ok; then + [[ -x "${TARGET}/usr/bin/hyprctl" && -x "${TARGET}/usr/bin/quickshell" ]] || + configs_ok=false + + # uv's bin/python is a chain of absolute symlinks (the venv link AND + # uv's cpython-3.12-... dir link) into /home/$USERNAME, which exists + # only under $TARGET - resolve and run it inside the chroot instead. + arch-chroot "$TARGET" \ + "${home_in_target}/.local/state/quickshell/.venv/bin/python" -B -c '' \ + >>"$LOG" 2>&1 || configs_ok=false + fi + + if [[ -d /etc/skel/.config/hypr/custom ]]; then + cp -rfT /etc/skel/.config/hypr/custom "${TARGET}${home_in_target}/.config/hypr/custom" + fi + + # Presets ship with absolute paths from the build machine; repoint them at + # the home that was just created (the images they name are not shipped). + local presets="${TARGET}${home_in_target}/.config/illogical-impulse/presets" + local preset + for preset in "$presets"/*.json; do + [[ -f "$preset" ]] || continue + sed -i "s|/home/alvin/|/home/${USERNAME}/|g" "$preset" + done + + # Everything above that ran as root from the live medium (repo copy, + # fork/fish/hypr restores, and the presets sed -i just rewrote through a + # temp file) left root-owned files in the home — one pass fixes them all. + # Numeric ids again: this chown also runs on the medium, not in the chroot. + # A dead FUSE mount (gvfs ~/.gvfs, kio-fuse, ...) makes chown -R fail with + # EACCES even as root; unmount everything under the home first. The home + # is no mount point itself, so filter the full mount table by prefix + # (findmnt -R would match nothing); sort -r unmounts deepest first. The + # || true keeps an empty match from tripping the inherited ERR trap, + # whose message would otherwise be read as a mount path. + local mnt + while read -r mnt; do + umount "$mnt" 2>/dev/null || umount -l "$mnt" 2>/dev/null || true + log "unmounted stray mount in home: $mnt" + done < <(findmnt -rno TARGET 2>/dev/null | + awk -v p="${TARGET}${home_in_target}/" 'index($0, p) == 1' | sort -r || true) + rmdir "${TARGET}${home_in_target}/.gvfs" 2>/dev/null || true + chown -R "$owner" "${TARGET}${home_in_target}" + + if $dots_ok && $configs_ok; then + log "end-4 dotfiles installed for ${USERNAME} (setups_done=${setups_done} skipped=${#ignored[@]})." + ok "end-4 dotfiles installed." + else + log "end-4 dotfiles incomplete for ${USERNAME} (setup_exit_ok=${dots_ok} configs=${configs_ok} setups=${setups_done} skipped=${#ignored[@]})." + warn "end-4 dotfiles incomplete (see $LOG) — as ${USERNAME}, run: cd ~/.dots-hyprland && ./setup install" + fi } # ── Mode A boot configuration (systemd/sd-encrypt) ──────────────────────────── @@ -1322,7 +1352,7 @@ install_end4_dots() { # is precisely the failure mode the fallback exists to undo. write_mode_a_hooks() { - cat > "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS' + cat >"${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS' HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck) HOOKS } @@ -1331,466 +1361,470 @@ HOOKS # after root= (install_tpm passes rd.luks.options=...tpm2-device=auto to # activate an enrolled TPM2 slot; without it systemd-cryptsetup never uses it). write_mode_a_cmdline() { - local luks_uuid="$1" extra="${2:-}" - sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"rd.luks.name=${luks_uuid}=cryptroot root=/dev/mapper/cryptroot${extra:+ ${extra}}\"|" \ - "${TARGET}/etc/default/grub" + local luks_uuid="$1" extra="${2:-}" + sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"rd.luks.name=${luks_uuid}=cryptroot root=/dev/mapper/cryptroot${extra:+ ${extra}}\"|" \ + "${TARGET}/etc/default/grub" } install_mkinitcpio() { - info "Generating initramfs configuration..." + info "Generating initramfs configuration..." - if [[ "$LUKS" != "true" ]]; then - arch-chroot "$TARGET" mkinitcpio -P - ok "initramfs generated." - return - fi + if [[ "$LUKS" != "true" ]]; then + run_logged -s dot -t "Generating initramfs" "mkinitcpio -P failed" \ + arch-chroot "$TARGET" mkinitcpio -P || + die "mkinitcpio -P failed (output above, full log: $LOG)." + ok "initramfs generated." + return + fi - # Keep a pristine copy of the main config; everything this installer - # writes goes through the drop-in below (the image's archiso.conf drop-in - # and both kernel presets are untouched - the fallback image is built - # with `-S autodetect`). - local main_conf="${TARGET}/etc/mkinitcpio.conf" - if [[ -f "$main_conf" && ! -f "${main_conf}.alvin-bak" ]]; then - cp -a "$main_conf" "${main_conf}.alvin-bak" - log "mkinitcpio.conf backed up to mkinitcpio.conf.alvin-bak" - fi + # Keep a pristine copy of the main config; everything this installer + # writes goes through the drop-in below (the image's archiso.conf drop-in + # and both kernel presets are untouched - the fallback image is built + # with `-S autodetect`). + local main_conf="${TARGET}/etc/mkinitcpio.conf" + if [[ -f "$main_conf" && ! -f "${main_conf}.alvin-bak" ]]; then + cp -a "$main_conf" "${main_conf}.alvin-bak" + log "mkinitcpio.conf backed up to mkinitcpio.conf.alvin-bak" + fi - mkdir -p "${TARGET}/etc/mkinitcpio.conf.d" + mkdir -p "${TARGET}/etc/mkinitcpio.conf.d" - if [[ "$UNL0KR" == "true" ]]; then - # Mode B: busybox hooks + unl0kr (its ash hook parses `cryptdevice=` - # and drives the on-screen keyboard). The build is deferred to - # install_unl0kr: the package must exist first, and grub-mkconfig - # needs the final image for its initrd line. The two hook families - # must never be mixed in one HOOKS list. - cat > "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS' + if [[ "$UNL0KR" == "true" ]]; then + # Mode B: busybox hooks + unl0kr (its ash hook parses `cryptdevice=` + # and drives the on-screen keyboard). The build is deferred to + # install_unl0kr: the package must exist first, and grub-mkconfig + # needs the final image for its initrd line. The two hook families + # must never be mixed in one HOOKS list. + cat >"${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" <<'HOOKS' HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block unl0kr encrypt filesystems fsck) HOOKS - log "mkinitcpio: Mode B (unl0kr) HOOKS written; build deferred to install_unl0kr" - ok "initramfs hooks staged (image built after unl0kr install)." - return - fi + log "mkinitcpio: Mode B (unl0kr) HOOKS written; build deferred to install_unl0kr" + ok "initramfs hooks staged (image built after unl0kr install)." + return + fi - # Mode A: systemd initramfs with sd-encrypt (matches the rd.luks.name - # kernel cmdline written by install_bootloader). - write_mode_a_hooks - arch-chroot "$TARGET" mkinitcpio -P + # Mode A: systemd initramfs with sd-encrypt (matches the rd.luks.name + # kernel cmdline written by install_bootloader). + write_mode_a_hooks + run_logged -s dot -t "Generating initramfs" "mkinitcpio -P failed" \ + arch-chroot "$TARGET" mkinitcpio -P || + die "mkinitcpio -P failed (output above, full log: $LOG)." - ok "initramfs generated." + ok "initramfs generated." } install_bootloader() { - info "Installing GRUB bootloader..." - - if [[ "$LUKS" == "true" ]]; then - local luks_uuid - luks_uuid=$(blkid -s UUID -o value "$(part 2)") - if [[ "$UNL0KR" == "true" ]]; then - # Mode B: busybox unl0kr/encrypt hooks consume cryptdevice=. - sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"cryptdevice=UUID=${luks_uuid}:cryptroot root=/dev/mapper/cryptroot\"|" \ - "${TARGET}/etc/default/grub" - else - # Mode A: systemd/sd-encrypt consumes rd.luks.name=. - write_mode_a_cmdline "$luks_uuid" - fi - log "GRUB cmdline set for UNL0KR=${UNL0KR} (uuid=${luks_uuid})" - fi - - # Force-disable EFI BootNext menu entries on the installed system: - # /etc/grub.d/31_efi_bootnext exits immediately when this is set, so a - # one-shot firmware boot selection never shows up in the GRUB menu. - if grep -q '^GRUB_DISABLE_BOOTNEXT=' "${TARGET}/etc/default/grub"; then - sed -i 's/^GRUB_DISABLE_BOOTNEXT=.*/GRUB_DISABLE_BOOTNEXT=true/' \ - "${TARGET}/etc/default/grub" - else - printf '\nGRUB_DISABLE_BOOTNEXT=true\n' >> "${TARGET}/etc/default/grub" - fi - - # Two installs, both verified - this is the step whose failure strands a - # machine with no bootable firmware entry: - # 1. --removable → writes the removable fallback \EFI\BOOT\BOOTX64.EFI - # to the ESP FIRST, so it exists no matter what the NVRAM step does - # 2. normal → registers the ALVIN entry in the firmware NVRAM - # Some firmwares drop their NVRAM entries over time (the classic - # boot-stranding failure); with the fallback on the ESP the firmware - # still finds GRUB and the machine is never bricked. - # Caveat: the fallback is an unsigned direct copy of grubx64.efi, so it - # only boots with Secure Boot disabled or in setup mode - which booting - # the unsigned Arch ISO already implies. If Secure Boot support is ever - # added, the fallback MUST be shimx64.efi instead - otherwise the - # firmware rejects BOOTX64.EFI. - if ! run_logged -s dot "grub-install (removable fallback) failed" \ - arch-chroot "$TARGET" grub-install \ - --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ - --removable --recheck; then - die "grub-install --removable failed - \\EFI\\BOOT\\BOOTX64.EFI was not written (output above, full log: $LOG)." - fi - - # Verify the fallback file really landed: $TARGET/boot IS the ESP mount, - # so this checks the exact file firmware will look for. This is the "is - # there any bootloader at all" gate - it must run before any NVRAM - # failure is allowed to abort the install. - if [[ ! -f "${TARGET}/boot/EFI/BOOT/BOOTX64.EFI" ]]; then - die "Fallback bootloader missing: ${TARGET}/boot/EFI/BOOT/BOOTX64.EFI was not created - this system could be unbootable if the firmware drops its boot entry." - fi - log "Bootloader: removable fallback present (EFI/BOOT/BOOTX64.EFI)" - - if ! run_logged -s dot "grub-install (NVRAM boot entry) failed" \ - arch-chroot "$TARGET" grub-install \ - --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ - --recheck; then - # grub copies its EFI binaries before it asks efibootmgr to register - # the entry, so "files present + non-zero exit" means only the NVRAM - # registration failed - fall through to the host-side creation below. - if [[ -f "${TARGET}/boot/EFI/ALVIN/grubx64.efi" ]]; then - warn "grub-install could not register the NVRAM entry (output above) - trying from the live medium." - log "Bootloader: grub-install NVRAM registration failed; EFI/ALVIN/grubx64.efi present" - else - # Note the fallback honestly: BOOTX64.EFI exists, but grub.cfg is - # only generated at the END of this function - an install aborted - # here leaves no config, so the fallback alone does NOT boot yet. - die "grub-install failed - EFI/ALVIN/grubx64.efi was not written (output above, full log: $LOG). \\EFI\\BOOT\\BOOTX64.EFI exists, but the install stops before grub-mkconfig runs, so no bootable configuration was written." - fi - fi - - # Verify the NVRAM entry persisted. arch-chroot rbinds /sys and mounts - # efivarfs, so grub-install inside the chroot wrote the same variables - # efibootmgr reads here - there is no visibility difference; we verify - # and create on the live medium because that is the direct path to the - # real disk. Create the entry if grub-install could not persist it - # (write-protected or flaky NVRAM), then re-verify. Verification failure - # aborts the install. - local esp_partnum boot_entry - esp_partnum=$(lsblk -no PARTN "$(part 1)" 2>/dev/null | head -n1) || esp_partnum="" - esp_partnum=${esp_partnum:-1} - # Match the loader path, not just the label: a stale ALVIN entry left by - # a previous install on another disk would satisfy a bare label match. - boot_entry=$(efibootmgr -v 2>/dev/null \ - | grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" - if [[ -z "$boot_entry" ]]; then - warn "No ALVIN entry in the firmware NVRAM - creating one now." - if ! run_logged "efibootmgr could not create the NVRAM boot entry" \ - efibootmgr -c -d "$DISK" -p "$esp_partnum" -L ALVIN \ - -l '\EFI\ALVIN\grubx64.efi'; then - die "Could not create the firmware NVRAM boot entry (output above). Refusing to finish: the install stops before grub-mkconfig runs, so \\EFI\\BOOT\\BOOTX64.EFI exists but is not bootable yet. Check UEFI setup (NVRAM write access) and re-run." - fi - boot_entry=$(efibootmgr -v 2>/dev/null \ - | grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" - if [[ -z "$boot_entry" ]]; then - die "NVRAM boot entry still missing after efibootmgr -c. Refusing to finish: the install stops before grub-mkconfig runs, so the system is not bootable yet - check UEFI setup and re-run." - fi - fi - log "Bootloader: NVRAM entry verified: ${boot_entry}" + info "Installing GRUB bootloader..." + if [[ "$LUKS" == "true" ]]; then + local luks_uuid + luks_uuid=$(blkid -s UUID -o value "$(part 2)") if [[ "$UNL0KR" == "true" ]]; then - # Mode B: grub.cfg is written in install_unl0kr together with the - # final initramfs (a config generated now would not match it). - log "grub-mkconfig deferred (Mode B, after unl0kr build)" - ok "Bootloader installed (NVRAM entry + fallback verified; config finalized after unl0kr)." - return + # Mode B: busybox unl0kr/encrypt hooks consume cryptdevice=. + sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX=\"cryptdevice=UUID=${luks_uuid}:cryptroot root=/dev/mapper/cryptroot\"|" \ + "${TARGET}/etc/default/grub" + else + # Mode A: systemd/sd-encrypt consumes rd.luks.name=. + write_mode_a_cmdline "$luks_uuid" fi + log "GRUB cmdline set for UNL0KR=${UNL0KR} (uuid=${luks_uuid})" + fi - if ! run_logged -s dot "grub-mkconfig failed" \ - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then - die "grub-mkconfig failed - the installed system would boot into an empty GRUB menu (output above, full log: $LOG)." + # Force-disable EFI BootNext menu entries on the installed system: + # /etc/grub.d/31_efi_bootnext exits immediately when this is set, so a + # one-shot firmware boot selection never shows up in the GRUB menu. + if grep -q '^GRUB_DISABLE_BOOTNEXT=' "${TARGET}/etc/default/grub"; then + sed -i 's/^GRUB_DISABLE_BOOTNEXT=.*/GRUB_DISABLE_BOOTNEXT=true/' \ + "${TARGET}/etc/default/grub" + else + printf '\nGRUB_DISABLE_BOOTNEXT=true\n' >>"${TARGET}/etc/default/grub" + fi + + # Two installs, both verified - this is the step whose failure strands a + # machine with no bootable firmware entry: + # 1. --removable → writes the removable fallback \EFI\BOOT\BOOTX64.EFI + # to the ESP FIRST, so it exists no matter what the NVRAM step does + # 2. normal → registers the ALVIN entry in the firmware NVRAM + # Some firmwares drop their NVRAM entries over time (the classic + # boot-stranding failure); with the fallback on the ESP the firmware + # still finds GRUB and the machine is never bricked. + # Caveat: the fallback is an unsigned direct copy of grubx64.efi, so it + # only boots with Secure Boot disabled or in setup mode - which booting + # the unsigned Arch ISO already implies. If Secure Boot support is ever + # added, the fallback MUST be shimx64.efi instead - otherwise the + # firmware rejects BOOTX64.EFI. + if ! run_logged -s dot "grub-install (removable fallback) failed" \ + arch-chroot "$TARGET" grub-install \ + --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ + --removable --recheck; then + die "grub-install --removable failed - \\EFI\\BOOT\\BOOTX64.EFI was not written (output above, full log: $LOG)." + fi + + # Verify the fallback file really landed: $TARGET/boot IS the ESP mount, + # so this checks the exact file firmware will look for. This is the "is + # there any bootloader at all" gate - it must run before any NVRAM + # failure is allowed to abort the install. + if [[ ! -f "${TARGET}/boot/EFI/BOOT/BOOTX64.EFI" ]]; then + die "Fallback bootloader missing: ${TARGET}/boot/EFI/BOOT/BOOTX64.EFI was not created - this system could be unbootable if the firmware drops its boot entry." + fi + log "Bootloader: removable fallback present (EFI/BOOT/BOOTX64.EFI)" + + if ! run_logged -s dot "grub-install (NVRAM boot entry) failed" \ + arch-chroot "$TARGET" grub-install \ + --target=x86_64-efi --efi-directory=/boot --bootloader-id=ALVIN \ + --recheck; then + # grub copies its EFI binaries before it asks efibootmgr to register + # the entry, so "files present + non-zero exit" means only the NVRAM + # registration failed - fall through to the host-side creation below. + if [[ -f "${TARGET}/boot/EFI/ALVIN/grubx64.efi" ]]; then + warn "grub-install could not register the NVRAM entry (output above) - trying from the live medium." + log "Bootloader: grub-install NVRAM registration failed; EFI/ALVIN/grubx64.efi present" + else + # Note the fallback honestly: BOOTX64.EFI exists, but grub.cfg is + # only generated at the END of this function - an install aborted + # here leaves no config, so the fallback alone does NOT boot yet. + die "grub-install failed - EFI/ALVIN/grubx64.efi was not written (output above, full log: $LOG). \\EFI\\BOOT\\BOOTX64.EFI exists, but the install stops before grub-mkconfig runs, so no bootable configuration was written." fi + fi - ok "Bootloader installed (NVRAM entry + removable fallback verified)." + # Verify the NVRAM entry persisted. arch-chroot rbinds /sys and mounts + # efivarfs, so grub-install inside the chroot wrote the same variables + # efibootmgr reads here - there is no visibility difference; we verify + # and create on the live medium because that is the direct path to the + # real disk. Create the entry if grub-install could not persist it + # (write-protected or flaky NVRAM), then re-verify. Verification failure + # aborts the install. + local esp_partnum boot_entry + esp_partnum=$(lsblk -no PARTN "$(part 1)" 2>/dev/null | head -n1) || esp_partnum="" + esp_partnum=${esp_partnum:-1} + # Match the loader path, not just the label: a stale ALVIN entry left by + # a previous install on another disk would satisfy a bare label match. + boot_entry=$(efibootmgr -v 2>/dev/null | + grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" + if [[ -z "$boot_entry" ]]; then + warn "No ALVIN entry in the firmware NVRAM - creating one now." + if ! run_logged "efibootmgr could not create the NVRAM boot entry" \ + efibootmgr -c -d "$DISK" -p "$esp_partnum" -L ALVIN \ + -l '\EFI\ALVIN\grubx64.efi'; then + die "Could not create the firmware NVRAM boot entry (output above). Refusing to finish: the install stops before grub-mkconfig runs, so \\EFI\\BOOT\\BOOTX64.EFI exists but is not bootable yet. Check UEFI setup (NVRAM write access) and re-run." + fi + boot_entry=$(efibootmgr -v 2>/dev/null | + grep -iF '\EFI\ALVIN\grubx64.efi' | head -n1) || boot_entry="" + if [[ -z "$boot_entry" ]]; then + die "NVRAM boot entry still missing after efibootmgr -c. Refusing to finish: the install stops before grub-mkconfig runs, so the system is not bootable yet - check UEFI setup and re-run." + fi + fi + log "Bootloader: NVRAM entry verified: ${boot_entry}" + + if [[ "$UNL0KR" == "true" ]]; then + # Mode B: grub.cfg is written in install_unl0kr together with the + # final initramfs (a config generated now would not match it). + log "grub-mkconfig deferred (Mode B, after unl0kr build)" + ok "Bootloader installed (NVRAM entry + fallback verified; config finalized after unl0kr)." + return + fi + + if ! run_logged -s dot "grub-mkconfig failed" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then + die "grub-mkconfig failed - the installed system would boot into an empty GRUB menu (output above, full log: $LOG)." + fi + + ok "Bootloader installed (NVRAM entry + removable fallback verified)." } # ── TPM2 auto-unlock ────────────────────────────────────────────────────────── secure_boot_enabled() { - if bootctl status 2>/dev/null | grep -q 'Secure Boot: enabled'; then - return 0 - fi - # bootctl unavailable or unclear: read the efivar directly - # (bytes 0-3 = attributes, byte 4 = SecureBoot value). - local sbvar byte - for sbvar in /sys/firmware/efi/efivars/SecureBoot-*; do - [[ -f "$sbvar" ]] || continue - byte=$(od -An -tu1 -j4 -N1 "$sbvar" 2>/dev/null | tr -d ' \n') || byte="" - [[ "$byte" == "1" ]] && return 0 - return 1 - done + if bootctl status 2>/dev/null | grep -q 'Secure Boot: enabled'; then + return 0 + fi + # bootctl unavailable or unclear: read the efivar directly + # (bytes 0-3 = attributes, byte 4 = SecureBoot value). + local sbvar byte + for sbvar in /sys/firmware/efi/efivars/SecureBoot-*; do + [[ -f "$sbvar" ]] || continue + byte=$(od -An -tu1 -j4 -N1 "$sbvar" 2>/dev/null | tr -d ' \n') || byte="" + [[ "$byte" == "1" ]] && return 0 return 1 + done + return 1 } install_tpm() { - TPM_STATUS="" - if [[ "$LUKS" != "true" ]]; then - TPM_STATUS="skipped-no-luks" - log "TPM: skipped (no LUKS)" - return - fi - if [[ "$UNL0KR" == "true" ]]; then - TPM_STATUS="skipped-unl0kr" - info "TPM auto-unlock skipped: unl0kr handles unlock on screen instead." - log "TPM: skipped (unl0kr enabled)" - return - fi + TPM_STATUS="" + if [[ "$LUKS" != "true" ]]; then + TPM_STATUS="skipped-no-luks" + log "TPM: skipped (no LUKS)" + return + fi + if [[ "$UNL0KR" == "true" ]]; then + TPM_STATUS="skipped-unl0kr" + info "TPM auto-unlock skipped: unl0kr handles unlock on screen instead." + log "TPM: skipped (unl0kr enabled)" + return + fi - info "Checking for a TPM2 device..." - # Detected on the live medium: it sees the same /dev, /sys and efivarfs - # the installed system will boot against. - if [[ ! -e /sys/class/tpm/tpm0 ]] || ! systemd-analyze has-tpm2 >>"$LOG" 2>&1; then - TPM_STATUS="unsupported" - log "TPM: unsupported (no usable TPM2 device)" - return - fi - ok "TPM2 device detected." + info "Checking for a TPM2 device..." + # Detected on the live medium: it sees the same /dev, /sys and efivarfs + # the installed system will boot against. + if [[ ! -e /sys/class/tpm/tpm0 ]] || ! systemd-analyze has-tpm2 >>"$LOG" 2>&1; then + TPM_STATUS="unsupported" + log "TPM: unsupported (no usable TPM2 device)" + return + fi + ok "TPM2 device detected." - local tpm_choice - tpm_choice=$(gum choose --header "+ TPM2 auto-unlock (default: no)" \ - "No - keep the passphrase only" \ - "Yes - enrol TPM2 key (PCR 0+7)") || tpm_choice="No" - if [[ "$tpm_choice" != Yes* ]]; then - TPM_STATUS="skipped" - log "TPM: declined by user" - return - fi + local tpm_choice + tpm_choice=$(gum choose --header "+ TPM2 auto-unlock (default: no)" \ + "No - keep the passphrase only" \ + "Yes - enrol TPM2 key (PCR 0+7)") || tpm_choice="No" + if [[ "$tpm_choice" != Yes* ]]; then + TPM_STATUS="skipped" + log "TPM: declined by user" + return + fi - info "Enrolling TPM2 key (the LUKS passphrase is supplied automatically)..." - # systemd-cryptenroll never reads stdin: the unlock passphrase comes from - # $PASSWORD (stdin would instead trigger the ask-password agent and block - # until someone typed it on a console). It only adds a keyslot - no - # --wipe-slot - so the passphrase slot always stays as a fallback. - # Exported rather than put on the command line (env PASSWORD=...): argv - # is world-readable via /proc, the environment is not - and unset right - # after, so the secret exists only for the duration of this call. - export PASSWORD="$PASS" - local cenrc=0 - run_logged "systemd-cryptenroll failed" \ - systemd-cryptenroll --tpm2-device=auto \ - --tpm2-pcrs=0+7 "$(part 2)" || cenrc=$? - unset PASSWORD - if (( cenrc == 0 )); then - TPM_STATUS="enrolled" - # Enrolment alone does nothing at boot: systemd-cryptsetup only uses - # the TPM2 keyslot when the cmdline asks for it. install_bootloader - # (step 7) already wrote rd.luks.name= and ran grub-mkconfig, so - # rewrite the line with rd.luks.options=...tpm2-device=auto appended - # and regenerate grub.cfg now. - local luks_uuid - luks_uuid=$(blkid -s UUID -o value "$(part 2)") - write_mode_a_cmdline "$luks_uuid" "rd.luks.options=${luks_uuid}=tpm2-device=auto" - run_logged -s dot "grub-mkconfig failed (rd.luks.options)" \ - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg \ - || warn "grub-mkconfig failed after adding rd.luks.options (output above) - TPM2 unlock will not activate at boot." - ok "TPM2 auto-unlock enrolled and activated (rd.luks.options, PCR 0+7)." - else - TPM_STATUS="failed" - warn "TPM enrollment failed (output above) - passphrase unlock still works." - fi + info "Enrolling TPM2 key (the LUKS passphrase is supplied automatically)..." + # systemd-cryptenroll never reads stdin: the unlock passphrase comes from + # $PASSWORD (stdin would instead trigger the ask-password agent and block + # until someone typed it on a console). It only adds a keyslot - no + # --wipe-slot - so the passphrase slot always stays as a fallback. + # Exported rather than put on the command line (env PASSWORD=...): argv + # is world-readable via /proc, the environment is not - and unset right + # after, so the secret exists only for the duration of this call. + export PASSWORD="$PASS" + local cenrc=0 + run_logged -s dot -t "Enrolling the TPM2 unlock key" "systemd-cryptenroll failed" \ + systemd-cryptenroll --tpm2-device=auto \ + --tpm2-pcrs=0+7 "$(part 2)" || cenrc=$? + unset PASSWORD + if ((cenrc == 0)); then + TPM_STATUS="enrolled" + # Enrolment alone does nothing at boot: systemd-cryptsetup only uses + # the TPM2 keyslot when the cmdline asks for it. install_bootloader + # (step 7) already wrote rd.luks.name= and ran grub-mkconfig, so + # rewrite the line with rd.luks.options=...tpm2-device=auto appended + # and regenerate grub.cfg now. + local luks_uuid + luks_uuid=$(blkid -s UUID -o value "$(part 2)") + write_mode_a_cmdline "$luks_uuid" "rd.luks.options=${luks_uuid}=tpm2-device=auto" + run_logged -s dot "grub-mkconfig failed (rd.luks.options)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg || + warn "grub-mkconfig failed after adding rd.luks.options (output above) - TPM2 unlock will not activate at boot." + ok "TPM2 auto-unlock enrolled and activated (rd.luks.options, PCR 0+7)." + else + TPM_STATUS="failed" + warn "TPM enrollment failed (output above) - passphrase unlock still works." + fi - # TPM-only unlock with an unencrypted /boot is weak: anyone with physical - # access can swap kernel/initrd. Secure Boot is what closes that hole. - if [[ "$TPM_STATUS" == "enrolled" ]] && ! secure_boot_enabled; then - warn "Secure Boot is off: with an unencrypted /boot, anyone can replace the kernel/initrd and reach the decrypted disk. Consider enabling Secure Boot." - log "TPM: enrolled, Secure Boot off" - fi + # TPM-only unlock with an unencrypted /boot is weak: anyone with physical + # access can swap kernel/initrd. Secure Boot is what closes that hole. + if [[ "$TPM_STATUS" == "enrolled" ]] && ! secure_boot_enabled; then + warn "Secure Boot is off: with an unencrypted /boot, anyone can replace the kernel/initrd and reach the decrypted disk. Consider enabling Secure Boot." + log "TPM: enrolled, Secure Boot off" + fi } # ── unl0kr (experimental touchscreen unlock) ────────────────────────────────── unl0kr_fallback_mode_a() { - local why="$1" - warn "unl0kr unavailable (${why}) - falling back to the standard systemd initramfs." - log "unl0kr fallback to Mode A: ${why}" + local why="$1" + warn "unl0kr unavailable (${why}) - falling back to the standard systemd initramfs." + log "unl0kr fallback to Mode A: ${why}" - local main_conf="${TARGET}/etc/mkinitcpio.conf" - if [[ -f "${main_conf}.alvin-bak" ]]; then - cp -a "${main_conf}.alvin-bak" "$main_conf" - fi - # Mode A drop-in + matching cmdline; this also undoes install_mkinitcpio's - # Mode B HOOKS and install_bootloader's cryptdevice= line. - write_mode_a_hooks - if [[ "$LUKS" == "true" ]]; then - local luks_uuid - luks_uuid=$(blkid -s UUID -o value "$(part 2)") - write_mode_a_cmdline "$luks_uuid" - fi + local main_conf="${TARGET}/etc/mkinitcpio.conf" + if [[ -f "${main_conf}.alvin-bak" ]]; then + cp -a "${main_conf}.alvin-bak" "$main_conf" + fi + # Mode A drop-in + matching cmdline; this also undoes install_mkinitcpio's + # Mode B HOOKS and install_bootloader's cryptdevice= line. + write_mode_a_hooks + if [[ "$LUKS" == "true" ]]; then + local luks_uuid + luks_uuid=$(blkid -s UUID -o value "$(part 2)") + write_mode_a_cmdline "$luks_uuid" + fi - UNL0KR="false" - if [[ "$TPM_STATUS" == "skipped-unl0kr" ]]; then - # TPM enrollment was skipped because unl0kr was selected; now that the - # system is back on Mode A it would be possible, but the prompt has - # passed. Keep a distinct status so the summary can say so plainly - # instead of the generic "it was skipped". - TPM_STATUS="skipped-unl0kr-fallback" - fi + UNL0KR="false" + if [[ "$TPM_STATUS" == "skipped-unl0kr" ]]; then + # TPM enrollment was skipped because unl0kr was selected; now that the + # system is back on Mode A it would be possible, but the prompt has + # passed. Keep a distinct status so the summary can say so plainly + # instead of the generic "it was skipped". + TPM_STATUS="skipped-unl0kr-fallback" + fi - run_logged -s dot "mkinitcpio -P failed (unl0kr fallback)" \ - arch-chroot "$TARGET" mkinitcpio -P \ - || warn "mkinitcpio -P failed during unl0kr fallback (output above)." - run_logged -s dot "grub-mkconfig failed (unl0kr fallback)" \ - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg \ - || warn "grub-mkconfig failed during unl0kr fallback (output above)." - warn "The system will ask for the LUKS passphrase at boot - test a reboot before relying on it." + run_logged -s dot "mkinitcpio -P failed (unl0kr fallback)" \ + arch-chroot "$TARGET" mkinitcpio -P || + warn "mkinitcpio -P failed during unl0kr fallback (output above)." + run_logged -s dot "grub-mkconfig failed (unl0kr fallback)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg || + warn "grub-mkconfig failed during unl0kr fallback (output above)." + warn "The system will ask for the LUKS passphrase at boot - test a reboot before relying on it." } install_unl0kr() { - [[ "$UNL0KR" == "true" ]] || return 0 + [[ "$UNL0KR" == "true" ]] || return 0 - info "Installing unl0kr (touchscreen LUKS unlock)..." - sync_target_dbs + info "Installing unl0kr (touchscreen LUKS unlock)..." + sync_target_dbs - local home_in_target="/home/${USERNAME}" - local build_rc=0 + local home_in_target="/home/${USERNAME}" + local build_rc=0 - # yay runs as the new user and needs passwordless sudo for this one call. - echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" > "${TARGET}/etc/sudoers.d/99-unl0kr-install" - chmod 440 "${TARGET}/etc/sudoers.d/99-unl0kr-install" - run_logged -s jump -t "Building unl0kr from the AUR (this may take a while)" \ - "yay failed to build/install unl0kr" \ - arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ - env HOME="$home_in_target" yay -S --noconfirm unl0kr || build_rc=$? - rm -f "${TARGET}/etc/sudoers.d/99-unl0kr-install" + # yay runs as the new user and needs passwordless sudo for this one call. + echo "${USERNAME} ALL=(ALL) NOPASSWD:ALL" >"${TARGET}/etc/sudoers.d/99-unl0kr-install" + chmod 440 "${TARGET}/etc/sudoers.d/99-unl0kr-install" + run_logged -s dot -t "Building unl0kr from the AUR (this may take a while)" \ + "yay failed to build/install unl0kr" \ + arch-chroot "$TARGET" runuser -u "$USERNAME" -- \ + env HOME="$home_in_target" yay -S --noconfirm unl0kr || build_rc=$? + rm -f "${TARGET}/etc/sudoers.d/99-unl0kr-install" - if (( build_rc != 0 )); then - unl0kr_fallback_mode_a "yay failed to build it (exit ${build_rc})" - return 0 - fi + if ((build_rc != 0)); then + unl0kr_fallback_mode_a "yay failed to build it (exit ${build_rc})" + return 0 + fi - # Record what the AUR actually gave us - advertised and PKGBUILD versions - # have disagreed before, so trust pacman, not the web page. - local built_ver - built_ver=$(arch-chroot "$TARGET" pacman -Qi unl0kr 2>/dev/null \ - | awk -F': ' '/^Version/{print $2}') || built_ver="" - log "unl0kr built version: ${built_ver:-unknown}" + # Record what the AUR actually gave us - advertised and PKGBUILD versions + # have disagreed before, so trust pacman, not the web page. + local built_ver + built_ver=$(arch-chroot "$TARGET" pacman -Qi unl0kr 2>/dev/null | + awk -F': ' '/^Version/{print $2}') || built_ver="" + log "unl0kr built version: ${built_ver:-unknown}" - # The package must ship busybox initcpio hooks (its prepare() strips the - # systemd ones). Read both into the log to confirm hook order and which - # modules the install script pulls in by itself: it adds dm-crypt, crypto - # and DRI libraries but NO touchscreen/HID drivers - those come from the - # MODULES list below. - local hook="${TARGET}/usr/lib/initcpio/hooks/unl0kr" - local hook_install="${TARGET}/usr/lib/initcpio/install/unl0kr" - if [[ ! -f "$hook" || ! -f "$hook_install" ]]; then - unl0kr_fallback_mode_a "initcpio hook files missing after install" - return 0 - fi - { - echo "== unl0kr initcpio files (as installed) ==" - echo "-- hooks/unl0kr --" - cat "$hook" - echo "-- install/unl0kr --" - cat "$hook_install" - } >>"$LOG" 2>&1 || { - warn "Could not log the unl0kr hook files (see $LOG)." - page_log_tail "unl0kr hook file output (q to quit)" 40 || true - } + # The package must ship busybox initcpio hooks (its prepare() strips the + # systemd ones). Read both into the log to confirm hook order and which + # modules the install script pulls in by itself: it adds dm-crypt, crypto + # and DRI libraries but NO touchscreen/HID drivers - those come from the + # MODULES list below. + local hook="${TARGET}/usr/lib/initcpio/hooks/unl0kr" + local hook_install="${TARGET}/usr/lib/initcpio/install/unl0kr" + if [[ ! -f "$hook" || ! -f "$hook_install" ]]; then + unl0kr_fallback_mode_a "initcpio hook files missing after install" + return 0 + fi + { + echo "== unl0kr initcpio files (as installed) ==" + echo "-- hooks/unl0kr --" + cat "$hook" + echo "-- install/unl0kr --" + cat "$hook_install" + } >>"$LOG" 2>&1 || { + warn "Could not log the unl0kr hook files (see $LOG)." + page_log_tail "unl0kr hook file output (q to quit)" 40 || true + } - # Touchscreen/input stack for the initramfs: mkinitcpio fails the build - # on a MODULES entry it cannot resolve, and -P applies the list to every - # preset - so a module may enter MODULES only if EVERY installed kernel - # knows it (modinfo -k runs inside the target, where /lib/modules holds - # linux + linux-cachyos, not the live kernel). - local -a candidates=( - i2c_hid i2c_hid_acpi hid_multitouch hid_generic usbhid - intel_lpss i2c_i801 i2c_piix4 i2c_amd_mp2 - ) - local -a keep=() kvers=() - local kdir kver m ok_all - for kdir in "${TARGET}"/lib/modules/*; do - [[ -d "$kdir" ]] && kvers+=("$(basename "$kdir")") + # Touchscreen/input stack for the initramfs: mkinitcpio fails the build + # on a MODULES entry it cannot resolve, and -P applies the list to every + # preset - so a module may enter MODULES only if EVERY installed kernel + # knows it (modinfo -k runs inside the target, where /lib/modules holds + # linux + linux-cachyos, not the live kernel). + local -a candidates=( + i2c_hid i2c_hid_acpi hid_multitouch hid_generic usbhid + intel_lpss i2c_i801 i2c_piix4 i2c_amd_mp2 + ) + local -a keep=() kvers=() + local kdir kver m ok_all + for kdir in "${TARGET}"/lib/modules/*; do + [[ -d "$kdir" ]] && kvers+=("$(basename "$kdir")") + done + for m in "${candidates[@]}"; do + ok_all=1 + for kver in "${kvers[@]+"${kvers[@]}"}"; do + if ! arch-chroot "$TARGET" modinfo -k "$kver" "$m" >/dev/null 2>&1; then + ok_all=0 + break + fi done - for m in "${candidates[@]}"; do - ok_all=1 - for kver in "${kvers[@]+"${kvers[@]}"}"; do - if ! arch-chroot "$TARGET" modinfo -k "$kver" "$m" >/dev/null 2>&1; then - ok_all=0 - break - fi - done - if (( ok_all )); then - keep+=("$m") - fi - done - log "unl0kr MODULES: ${keep[*]:-none}" - if ((${#keep[@]} > 0)); then - printf 'MODULES=(%s)\n' "${keep[*]}" \ - >> "${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" + if ((ok_all)); then + keep+=("$m") fi + done + log "unl0kr MODULES: ${keep[*]:-none}" + if ((${#keep[@]} > 0)); then + printf 'MODULES=(%s)\n' "${keep[*]}" \ + >>"${TARGET}/etc/mkinitcpio.conf.d/alvin.conf" + fi - info "Rebuilding initramfs with unl0kr..." - if ! run_logged -s dot "mkinitcpio -P failed (unl0kr image)" \ - arch-chroot "$TARGET" mkinitcpio -P; then - unl0kr_fallback_mode_a "mkinitcpio -P failed" - return 0 - fi - # grub.cfg is written now, next to the final image (Mode B deferred it). - if ! run_logged -s dot "grub-mkconfig failed (unl0kr image)" \ - arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then - unl0kr_fallback_mode_a "grub-mkconfig failed" - return 0 - fi + info "Rebuilding initramfs with unl0kr..." + if ! run_logged -s dot "mkinitcpio -P failed (unl0kr image)" \ + arch-chroot "$TARGET" mkinitcpio -P; then + unl0kr_fallback_mode_a "mkinitcpio -P failed" + return 0 + fi + # grub.cfg is written now, next to the final image (Mode B deferred it). + if ! run_logged -s dot "grub-mkconfig failed (unl0kr image)" \ + arch-chroot "$TARGET" grub-mkconfig -o /boot/grub/grub.cfg; then + unl0kr_fallback_mode_a "grub-mkconfig failed" + return 0 + fi - # Prove the image really carries the hook and every module we asked for: - # `lsinitcpio -a` prints the hook run order and the included module list. - local analysis - if ! analysis=$(lsinitcpio -a "${TARGET}/boot/initramfs-linux.img" 2>>"$LOG"); then - page_log_tail "lsinitcpio error output (q to quit)" 40 || true - unl0kr_fallback_mode_a "lsinitcpio could not read the image" - return 0 - fi - if ! grep -qw 'unl0kr' <<< "$analysis"; then - unl0kr_fallback_mode_a "unl0kr hook missing from initramfs" - return 0 - fi - # Compare against the image's module list, expecting only modules that - # are LOADABLE for the kernel that built initramfs-linux.img: a builtin - # may legally sit in MODULES (mkinitcpio preloads it) but never appears - # in `lsinitcpio -a`'s list - it is part of the kernel itself - and - # expecting it there would make the check false-fail and discard a - # perfectly good unl0kr install on every run. - local -a expect=() missing=() - local linux_kver mi m_norm mods_section - linux_kver=$(arch-chroot "$TARGET" pacman -Ql linux 2>/dev/null \ - | sed -n 's|.* /usr/lib/modules/\([^/]*\)/modules\.builtin.*|\1|p' \ - | head -n1) || linux_kver="" - for m in "${keep[@]+"${keep[@]}"}"; do - mi=$(arch-chroot "$TARGET" modinfo -k "$linux_kver" "$m" 2>/dev/null || true) - if ! grep -q '(builtin)' <<< "$mi"; then - expect+=("$m") - fi - done - log "unl0kr image check: kernel=${linux_kver:-unknown} expect=${expect[*]:-none}" - mods_section=$(awk '/==> Included modules/{f=1;next} f&&/^==>/{exit} f' <<< "$analysis" \ - | tr -s ' \t' '\n' | tr '_' '-') - for m in "${expect[@]+"${expect[@]}"}"; do - m_norm=${m//_/-} - grep -qxF -- "$m_norm" <<< "$mods_section" || missing+=("$m") - done - if ((${#missing[@]} > 0)); then - unl0kr_fallback_mode_a "modules missing from initramfs: ${missing[*]}" - return 0 + # Prove the image really carries the hook and every module we asked for: + # `lsinitcpio -a` prints the hook run order and the included module list. + local analysis + if ! analysis=$(lsinitcpio -a "${TARGET}/boot/initramfs-linux.img" 2>>"$LOG"); then + page_log_tail "lsinitcpio error output (q to quit)" 40 || true + unl0kr_fallback_mode_a "lsinitcpio could not read the image" + return 0 + fi + if ! grep -qw 'unl0kr' <<<"$analysis"; then + unl0kr_fallback_mode_a "unl0kr hook missing from initramfs" + return 0 + fi + # Compare against the image's module list, expecting only modules that + # are LOADABLE for the kernel that built initramfs-linux.img: a builtin + # may legally sit in MODULES (mkinitcpio preloads it) but never appears + # in `lsinitcpio -a`'s list - it is part of the kernel itself - and + # expecting it there would make the check false-fail and discard a + # perfectly good unl0kr install on every run. + local -a expect=() missing=() + local linux_kver mi m_norm mods_section + linux_kver=$(arch-chroot "$TARGET" pacman -Ql linux 2>/dev/null | + sed -n 's|.* /usr/lib/modules/\([^/]*\)/modules\.builtin.*|\1|p' | + head -n1) || linux_kver="" + for m in "${keep[@]+"${keep[@]}"}"; do + mi=$(arch-chroot "$TARGET" modinfo -k "$linux_kver" "$m" 2>/dev/null || true) + if ! grep -q '(builtin)' <<<"$mi"; then + expect+=("$m") fi + done + log "unl0kr image check: kernel=${linux_kver:-unknown} expect=${expect[*]:-none}" + mods_section=$(awk '/==> Included modules/{f=1;next} f&&/^==>/{exit} f' <<<"$analysis" | + tr -s ' \t' '\n' | tr '_' '-') + for m in "${expect[@]+"${expect[@]}"}"; do + m_norm=${m//_/-} + grep -qxF -- "$m_norm" <<<"$mods_section" || missing+=("$m") + done + if ((${#missing[@]} > 0)); then + unl0kr_fallback_mode_a "modules missing from initramfs: ${missing[*]}" + return 0 + fi - # Every other preset image from this mkinitcpio -P (linux-cachyos, - # fallbacks) shares the same drop-in, but verify rather than assume. - local img - for img in "${TARGET}"/boot/initramfs-*.img; do - [[ -f "$img" ]] || continue - [[ "$img" == "${TARGET}/boot/initramfs-linux.img" ]] && continue - if ! lsinitcpio -a "$img" 2>/dev/null | grep -qw 'unl0kr'; then - warn "$(basename "$img") lacks the unl0kr hook (see $LOG) - the default image is fine." - fi - done + # Every other preset image from this mkinitcpio -P (linux-cachyos, + # fallbacks) shares the same drop-in, but verify rather than assume. + local img + for img in "${TARGET}"/boot/initramfs-*.img; do + [[ -f "$img" ]] || continue + [[ "$img" == "${TARGET}/boot/initramfs-linux.img" ]] && continue + if ! lsinitcpio -a "$img" 2>/dev/null | grep -qw 'unl0kr'; then + warn "$(basename "$img") lacks the unl0kr hook (see $LOG) - the default image is fine." + fi + done - ok "unl0kr installed; initramfs verified." - log "unl0kr: done (version=${built_ver:-unknown} modules=${#keep[@]})" + ok "unl0kr installed; initramfs verified." + log "unl0kr: done (version=${built_ver:-unknown} modules=${#keep[@]})" } # ── Login manager ───────────────────────────────────────────────────────────── ensure_hyprland_session() { - local desktop="${TARGET}/usr/share/wayland-sessions/hyprland.desktop" - if [[ -f "$desktop" ]]; then - return 0 - fi - mkdir -p "$(dirname "$desktop")" - cat > "$desktop" <<'DESKTOP' + local desktop="${TARGET}/usr/share/wayland-sessions/hyprland.desktop" + if [[ -f "$desktop" ]]; then + return 0 + fi + mkdir -p "$(dirname "$desktop")" + cat >"$desktop" <<'DESKTOP' [Desktop Entry] Name=Hyprland Comment=Hyprland wayland session @@ -1798,123 +1832,123 @@ Exec=Hyprland Type=Application DesktopNames=Hyprland DESKTOP - log "wayland-sessions/hyprland.desktop was missing - created a minimal one." + log "wayland-sessions/hyprland.desktop was missing - created a minimal one." } install_none_profile() { - local home_in_target="/home/${USERNAME}" - local owner - owner=$(target_owner) + local home_in_target="/home/${USERNAME}" + local owner + owner=$(target_owner) - local shell_name - shell_name=$(arch-chroot "$TARGET" getent passwd "$USERNAME" \ - | awk -F: '{print $NF}') || shell_name="/bin/bash" + local shell_name + shell_name=$(arch-chroot "$TARGET" getent passwd "$USERNAME" | + awk -F: '{print $NF}') || shell_name="/bin/bash" - local profile="" - case "$shell_name" in - *zsh) - profile="${home_in_target}/.zprofile" - ;; - *fish) - # conf.d is sourced automatically on every start. This runs after - # install_end4_dots, so end-4's rsync --delete of ~/.config/fish - # cannot remove it. - local fish_cfg="${TARGET}${home_in_target}/.config/fish" - if grep -rqsE 'start-hyprland|exec Hyprland|auto-Hypr' \ - "${fish_cfg}/config.fish" "${fish_cfg}/conf.d"; then - log "tty profile: fish config already launches Hyprland - skipped" - return 0 - fi - mkdir -p "${fish_cfg}/conf.d" - cat > "${fish_cfg}/conf.d/alvin-hyprland.fish" <<'FISH' + local profile="" + case "$shell_name" in + *zsh) + profile="${home_in_target}/.zprofile" + ;; + *fish) + # conf.d is sourced automatically on every start. This runs after + # install_end4_dots, so end-4's rsync --delete of ~/.config/fish + # cannot remove it. + local fish_cfg="${TARGET}${home_in_target}/.config/fish" + if grep -rqsE 'start-hyprland|exec Hyprland|auto-Hypr' \ + "${fish_cfg}/config.fish" "${fish_cfg}/conf.d"; then + log "tty profile: fish config already launches Hyprland - skipped" + return 0 + fi + mkdir -p "${fish_cfg}/conf.d" + cat >"${fish_cfg}/conf.d/alvin-hyprland.fish" <<'FISH' # alvin-install: start Hyprland on tty1 (pam_systemd sets XDG_VTNR). if status is-login; and test -z "$WAYLAND_DISPLAY"; and test "$XDG_VTNR" = 1 exec Hyprland end FISH - chown -R "$owner" "${fish_cfg}/conf.d" 2>/dev/null || true - ok "tty login will start Hyprland via ~/.config/fish/conf.d/alvin-hyprland.fish." - log "tty profile: fish conf.d/alvin-hyprland.fish (exec Hyprland on VT1)" - return 0 - ;; - *) - if [[ -f "${TARGET}${home_in_target}/.bash_profile" \ - || ! -f "${TARGET}${home_in_target}/.profile" ]]; then - profile="${home_in_target}/.bash_profile" - else - profile="${home_in_target}/.profile" - fi - ;; - esac - - # Already launching Hyprland (skel, end-4 dotfiles, a previous run)? - if [[ -f "${TARGET}${profile}" ]] && grep -qi 'hyprland' "${TARGET}${profile}"; then - log "tty profile: ${profile} already launches Hyprland - skipped" - return 0 + chown -R "$owner" "${fish_cfg}/conf.d" 2>/dev/null || true + ok "tty login will start Hyprland via ~/.config/fish/conf.d/alvin-hyprland.fish." + log "tty profile: fish conf.d/alvin-hyprland.fish (exec Hyprland on VT1)" + return 0 + ;; + *) + if [[ -f "${TARGET}${home_in_target}/.bash_profile" || + ! -f "${TARGET}${home_in_target}/.profile" ]]; then + profile="${home_in_target}/.bash_profile" + else + profile="${home_in_target}/.profile" fi + ;; + esac - # Launch plain Hyprland - the same way greetd/sddm/gdm paths do, so the - # login method does not silently change with the chosen manager. (The - # hyprland package also ships hyprland-uwsm.desktop for anyone who wants - # a uwsm-managed session; it stays pickable in the DM's session menu.) - { - echo "" - echo "# alvin-install: start Hyprland on tty1 (pam_systemd sets XDG_VTNR)." - echo "if [[ -z \"\${WAYLAND_DISPLAY:-}\" && \"\${XDG_VTNR:-}\" == \"1\" ]]; then" - echo " exec Hyprland" - echo "fi" - } >> "${TARGET}${profile}" + # Already launching Hyprland (skel, end-4 dotfiles, a previous run)? + if [[ -f "${TARGET}${profile}" ]] && grep -qi 'hyprland' "${TARGET}${profile}"; then + log "tty profile: ${profile} already launches Hyprland - skipped" + return 0 + fi - chown "$owner" "${TARGET}${profile}" 2>/dev/null || true - ok "tty login will start Hyprland via ${profile}." - log "tty profile: ${profile} (exec Hyprland on VT1)" + # Launch plain Hyprland - the same way greetd/sddm/gdm paths do, so the + # login method does not silently change with the chosen manager. (The + # hyprland package also ships hyprland-uwsm.desktop for anyone who wants + # a uwsm-managed session; it stays pickable in the DM's session menu.) + { + echo "" + echo "# alvin-install: start Hyprland on tty1 (pam_systemd sets XDG_VTNR)." + echo "if [[ -z \"\${WAYLAND_DISPLAY:-}\" && \"\${XDG_VTNR:-}\" == \"1\" ]]; then" + echo " exec Hyprland" + echo "fi" + } >>"${TARGET}${profile}" + + chown "$owner" "${TARGET}${profile}" 2>/dev/null || true + ok "tty login will start Hyprland via ${profile}." + log "tty profile: ${profile} (exec Hyprland on VT1)" } install_login_manager() { - info "Configuring login manager: ${LOGIN_MANAGER}..." + info "Configuring login manager: ${LOGIN_MANAGER}..." - # Exactly one way in: disable everything known first (this also clears - # the display-manager.service alias and any previous run's leftovers). - local u - for u in greetd sddm gdm; do - if [[ -f "${TARGET}/usr/lib/systemd/system/${u}.service" ]]; then - run_logged "Could not disable ${u}.service" \ - arch-chroot "$TARGET" systemctl disable "$u.service" \ - || warn "Could not disable ${u}.service (output above)." - fi - done - rm -f "${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf" + # Exactly one way in: disable everything known first (this also clears + # the display-manager.service alias and any previous run's leftovers). + local u + for u in greetd sddm gdm; do + if [[ -f "${TARGET}/usr/lib/systemd/system/${u}.service" ]]; then + run_logged "Could not disable ${u}.service" \ + arch-chroot "$TARGET" systemctl disable "$u.service" || + warn "Could not disable ${u}.service (output above)." + fi + done + rm -f "${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf" - # greetd/sddm/gdm all bind tty1 themselves - getty must not fight them - # for the device ('none' re-enables it in its branch). Each DM branch - # disables getty only AFTER its enable succeeded, so a failed enable - # never leaves the system without any login path. - case "$LOGIN_MANAGER" in - greetd) - sync_target_dbs - if ! arch-chroot "$TARGET" pacman -Si greetd >/dev/null 2>&1; then - warn "greetd is not resolvable in the target repos - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - if ! run_logged -s dot -t "Installing greetd + tuigreet" \ - "greetd install failed" \ - arch-chroot "$TARGET" pacman -S --noconfirm greetd greetd-tuigreet; then - warn "greetd install failed (output above) - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - # The package normally provides the greeter account; create it if - # this version does not (ArchWiki: user = "greeter"). - if ! arch-chroot "$TARGET" id greeter >/dev/null 2>&1; then - run_logged "Could not create the greeter user" \ - arch-chroot "$TARGET" useradd -r -M -s /usr/bin/nologin greeter \ - || warn "Could not create the greeter user (output above)." - fi - mkdir -p "${TARGET}/etc/greetd" - cat > "${TARGET}/etc/greetd/config.toml" <<'TOML' + # greetd/sddm/gdm all bind tty1 themselves - getty must not fight them + # for the device ('none' re-enables it in its branch). Each DM branch + # disables getty only AFTER its enable succeeded, so a failed enable + # never leaves the system without any login path. + case "$LOGIN_MANAGER" in + greetd) + sync_target_dbs + if ! arch-chroot "$TARGET" pacman -Si greetd >/dev/null 2>&1; then + warn "greetd is not resolvable in the target repos - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + if ! run_logged -s dot -t "Installing greetd + tuigreet" \ + "greetd install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm greetd greetd-tuigreet; then + warn "greetd install failed (output above) - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + # The package normally provides the greeter account; create it if + # this version does not (ArchWiki: user = "greeter"). + if ! arch-chroot "$TARGET" id greeter >/dev/null 2>&1; then + run_logged "Could not create the greeter user" \ + arch-chroot "$TARGET" useradd -r -M -s /usr/bin/nologin greeter || + warn "Could not create the greeter user (output above)." + fi + mkdir -p "${TARGET}/etc/greetd" + cat >"${TARGET}/etc/greetd/config.toml" <<'TOML' [terminal] vt = 1 @@ -1922,130 +1956,130 @@ vt = 1 command = "tuigreet --cmd Hyprland" user = "greeter" TOML - if run_logged "Could not enable greetd.service" \ - arch-chroot "$TARGET" systemctl enable greetd.service; then - run_logged "Could not disable getty@tty1.service" \ - arch-chroot "$TARGET" systemctl disable getty@tty1.service || true - ok "greetd + tuigreet enabled (Hyprland)." - else - warn "Could not enable greetd.service (output above) - keeping getty@tty1 as the login path." - fi - ;; - sddm) - sync_target_dbs - if ! arch-chroot "$TARGET" pacman -Si sddm >/dev/null 2>&1; then - warn "sddm is not resolvable in the target repos - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - if ! run_logged -s dot -t "Installing sddm" \ - "sddm install failed" \ - arch-chroot "$TARGET" pacman -S --noconfirm sddm; then - warn "sddm install failed (output above) - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - ensure_hyprland_session - if run_logged "Could not enable sddm.service" \ - arch-chroot "$TARGET" systemctl enable sddm.service; then - run_logged "Could not disable getty@tty1.service" \ - arch-chroot "$TARGET" systemctl disable getty@tty1.service || true - ok "sddm enabled (Hyprland)." - else - warn "Could not enable sddm.service (output above) - keeping getty@tty1 as the login path." - fi - ;; - gdm) - sync_target_dbs - warn "gdm pulls in GNOME components - expect a large download." - if ! arch-chroot "$TARGET" pacman -Si gdm >/dev/null 2>&1; then - warn "gdm is not resolvable in the target repos - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - if ! run_logged -s dot -t "Installing gdm" \ - "gdm install failed" \ - arch-chroot "$TARGET" pacman -S --noconfirm gdm; then - warn "gdm install failed (output above) - falling back to 'none'." - LOGIN_MANAGER="none" - install_none_profile - return - fi - ensure_hyprland_session - if run_logged "Could not enable gdm.service" \ - arch-chroot "$TARGET" systemctl enable gdm.service; then - run_logged "Could not disable getty@tty1.service" \ - arch-chroot "$TARGET" systemctl disable getty@tty1.service || true - ok "gdm enabled (Hyprland session)." - else - warn "Could not enable gdm.service (output above) - keeping getty@tty1 as the login path." - fi - ;; - *) - # none: keep getty on tty1; install_none_profile writes the - # login profile that starts the compositor. - run_logged "Could not enable getty@tty1.service" \ - arch-chroot "$TARGET" systemctl enable getty@tty1.service || true - install_none_profile - ;; - esac + if run_logged "Could not enable greetd.service" \ + arch-chroot "$TARGET" systemctl enable greetd.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true + ok "greetd + tuigreet enabled (Hyprland)." + else + warn "Could not enable greetd.service (output above) - keeping getty@tty1 as the login path." + fi + ;; + sddm) + sync_target_dbs + if ! arch-chroot "$TARGET" pacman -Si sddm >/dev/null 2>&1; then + warn "sddm is not resolvable in the target repos - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + if ! run_logged -s dot -t "Installing sddm" \ + "sddm install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm sddm; then + warn "sddm install failed (output above) - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + ensure_hyprland_session + if run_logged "Could not enable sddm.service" \ + arch-chroot "$TARGET" systemctl enable sddm.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true + ok "sddm enabled (Hyprland)." + else + warn "Could not enable sddm.service (output above) - keeping getty@tty1 as the login path." + fi + ;; + gdm) + sync_target_dbs + warn "gdm pulls in GNOME components - expect a large download." + if ! arch-chroot "$TARGET" pacman -Si gdm >/dev/null 2>&1; then + warn "gdm is not resolvable in the target repos - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + if ! run_logged -s dot -t "Installing gdm" \ + "gdm install failed" \ + arch-chroot "$TARGET" pacman -S --noconfirm gdm; then + warn "gdm install failed (output above) - falling back to 'none'." + LOGIN_MANAGER="none" + install_none_profile + return + fi + ensure_hyprland_session + if run_logged "Could not enable gdm.service" \ + arch-chroot "$TARGET" systemctl enable gdm.service; then + run_logged "Could not disable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl disable getty@tty1.service || true + ok "gdm enabled (Hyprland session)." + else + warn "Could not enable gdm.service (output above) - keeping getty@tty1 as the login path." + fi + ;; + *) + # none: keep getty on tty1; install_none_profile writes the + # login profile that starts the compositor. + run_logged "Could not enable getty@tty1.service" \ + arch-chroot "$TARGET" systemctl enable getty@tty1.service || true + install_none_profile + ;; + esac - log "Login manager: ${LOGIN_MANAGER}" + log "Login manager: ${LOGIN_MANAGER}" } # ── Autologin ───────────────────────────────────────────────────────────────── install_autologin() { - AUTOLOGIN="disabled" + AUTOLOGIN="disabled" - if [[ "$TPM_STATUS" == "enrolled" ]]; then - info "Autologin stays disabled: TPM2 auto-unlock is enrolled (the password is still required at login)." - log "Autologin: disabled (TPM enrolled)" + if [[ "$TPM_STATUS" == "enrolled" ]]; then + info "Autologin stays disabled: TPM2 auto-unlock is enrolled (the password is still required at login)." + log "Autologin: disabled (TPM enrolled)" + else + # Default: yes when LUKS is on (TPM did not take over unlock), no + # when nothing is encrypted - then autologin would mean zero auth. + local choice header_text + if [[ "$LUKS" == "true" ]]; then + header_text="+ Autologin (default: yes)" + choice=$(gum choose --header "$header_text" \ + "Yes - autologin to Hyprland" \ + "No - require the password") || choice="No" else - # Default: yes when LUKS is on (TPM did not take over unlock), no - # when nothing is encrypted - then autologin would mean zero auth. - local choice header_text - if [[ "$LUKS" == "true" ]]; then - header_text="+ Autologin (default: yes)" - choice=$(gum choose --header "$header_text" \ - "Yes - autologin to Hyprland" \ - "No - require the password") || choice="No" - else - header_text="+ Autologin (default: no) - no encryption + no password = no auth at all" - choice=$(gum choose --header "$header_text" \ - "No - require the password" \ - "Yes - autologin to Hyprland") || choice="No" - fi - if [[ "$choice" == Yes* ]]; then - AUTOLOGIN="enabled" - fi - log "Autologin: ${AUTOLOGIN} (luks=${LUKS} tpm=${TPM_STATUS})" + header_text="+ Autologin (default: no) - no encryption + no password = no auth at all" + choice=$(gum choose --header "$header_text" \ + "No - require the password" \ + "Yes - autologin to Hyprland") || choice="No" fi + if [[ "$choice" == Yes* ]]; then + AUTOLOGIN="enabled" + fi + log "Autologin: ${AUTOLOGIN} (luks=${LUKS} tpm=${TPM_STATUS})" + fi - # Apply idempotently: every run either writes its file or removes the one - # a previous run left behind, so turning autologin off stays clean. - case "$LOGIN_MANAGER" in - none) - local dropin="${TARGET}/etc/systemd/system/getty@tty1.service.d/autologin.conf" - if [[ "$AUTOLOGIN" == "enabled" ]]; then - mkdir -p "$(dirname "$dropin")" - cat > "$dropin" <"$dropin" < "${TARGET}/etc/greetd/config.toml" <"${TARGET}/etc/greetd/config.toml" < "${TARGET}/etc/greetd/config.toml" <<'TOML' + log "Autologin: greetd [initial_session] enabled" + else + cat >"${TARGET}/etc/greetd/config.toml" <<'TOML' [terminal] vt = 1 @@ -2067,147 +2101,195 @@ vt = 1 command = "tuigreet --cmd Hyprland" user = "greeter" TOML - fi - ;; - sddm) - local sddm_auto="${TARGET}/etc/sddm.conf.d/autologin.conf" - if [[ "$AUTOLOGIN" == "enabled" ]]; then - mkdir -p "$(dirname "$sddm_auto")" - cat > "$sddm_auto" <"$sddm_auto" < "$gdm_conf" <"$gdm_conf" </dev/null || true - unset PASS - log "Installation complete." - ok "Installation complete!" - echo "" - # Report what actually happened, not what was asked for: the enable step - # only warns when it cannot work (openssh missing / systemctl failed). - local ssh_state - if [[ "$SSHD" != "true" ]]; then - ssh_state="disabled" - elif [[ "$SSHD_APPLIED" == "true" ]]; then - ssh_state="enabled (starts at boot)" +# Last install step: the system is already bootable, so every failure here +# only warns. Re-ranks the Arch mirrorlist (the shipped one is whatever the +# ISO build had), refreshes the keyrings first so newer packages' signatures +# verify, then -Syu. That also moves packages pacstrap took from [core] onto +# the [cachyos] builds the target's pacman.conf now prefers. A new kernel +# triggers the mkinitcpio hook with the final HOOKS, and grub.cfg names +# /vmlinuz-linux etc. without versions, so the bootloader stays valid. +install_update() { + info "Updating mirrors and the installed system..." + sync_target_dbs + + local ml="${TARGET}/etc/pacman.d/mirrorlist" + if [[ -x "${TARGET}/usr/bin/reflector" ]]; then + cp -a "$ml" "${ml}.alvin-bak" 2>/dev/null || true + if run_logged -s dot -t "Ranking Arch mirrors (reflector)" "reflector failed" \ + arch-chroot "$TARGET" reflector --protocol https --age 12 --latest 20 \ + --sort rate --save /etc/pacman.d/mirrorlist; then + log "Update: mirrorlist re-ranked by reflector (backup: mirrorlist.alvin-bak)" else - ssh_state="NOT enabled - see ${LOG}" - fi - gum style --foreground 10 --align center \ - "Arch Linux installed successfully." \ - "" \ - "Login manager: ${LOGIN_MANAGER} Autologin: ${AUTOLOGIN:-disabled}" \ - "SSH server: ${ssh_state}" \ - "TPM2 auto-unlock: ${TPM_STATUS:-n/a} unl0kr: ${UNL0KR}" - if [[ -n "${TPM_DISCLAIMER}" ]]; then - echo "" - warn "${TPM_DISCLAIMER}" + [[ -s "${ml}.alvin-bak" ]] && cp -a "${ml}.alvin-bak" "$ml" + warn "reflector failed (output above) - keeping the shipped mirrorlist." fi + else + log "Update: reflector not installed in target - mirrorlist left as shipped" + fi + + local -a keyrings=() + mapfile -t keyrings < <(arch-chroot "$TARGET" pacman -Qq \ + archlinux-keyring cachyos-keyring chaotic-keyring 2>/dev/null || true) + if ((${#keyrings[@]} > 0)); then + run_logged -s dot -t "Updating keyrings" "keyring update failed" \ + arch-chroot "$TARGET" pacman -Sy --needed --noconfirm "${keyrings[@]}" || + warn "Keyring update failed (output above) - trying the upgrade anyway." + fi + + if run_logged -s dot -t "Updating the installed system (pacman -Syu)" \ + "pacman -Syu failed" \ + arch-chroot "$TARGET" pacman -Syu --noconfirm; then + UPDATE_STATUS="up to date" + log "Update: pacman -Syu complete" + ok "Installed system is up to date." + else + UPDATE_STATUS="FAILED - run sudo pacman -Syu after boot" + warn "pacman -Syu failed (output above) - the system boots; run 'sudo pacman -Syu' after first login." + fi +} + +install_cleanup() { + info "Cleaning up..." + cp "$LOG" "${TARGET}/var/log/alvin-install.log" 2>/dev/null || true + unset PASS + log "Installation complete." + ok "Installation complete!" + echo "" + # Report what actually happened, not what was asked for: the enable step + # only warns when it cannot work (openssh missing / systemctl failed). + local ssh_state + if [[ "$SSHD" != "true" ]]; then + ssh_state="disabled" + elif [[ "$SSHD_APPLIED" == "true" ]]; then + ssh_state="enabled (starts at boot)" + else + ssh_state="NOT enabled - see ${LOG}" + fi + gum style --foreground 10 --align center \ + "Arch Linux installed successfully." \ + "" \ + "Login manager: ${LOGIN_MANAGER} Autologin: ${AUTOLOGIN:-disabled}" \ + "SSH server: ${ssh_state} System update: ${UPDATE_STATUS}" \ + "TPM2 auto-unlock: ${TPM_STATUS:-n/a} unl0kr: ${UNL0KR}" + if [[ -n "${TPM_DISCLAIMER}" ]]; then echo "" - gum style --foreground 10 --align center \ - "Run 'reboot' to boot into your new system." - echo "" - # Debug aid: every failure was already paged at the moment it happened; - # this offers the complete record (all step output) on demand. - if gum confirm --affirmative "View install log" --negative "No thanks" \ - "Open the full install log (${LOG})?"; then - gum pager <"$LOG" || true - fi - echo "" - # Last line on screen after the optional log view - the instruction to - # reboot must be the final thing the user reads. - echo "Run 'reboot' to boot into your new system." + warn "${TPM_DISCLAIMER}" + fi + echo "" + gum style --foreground 10 --align center \ + "Run 'reboot' to boot into your new system." + echo "" + # Debug aid: every failure was already paged at the moment it happened; + # this offers the complete record (all step output) on demand. + if gum confirm --affirmative "View install log" --negative "No thanks" \ + "Open the full install log (${LOG})?"; then + gum pager <"$LOG" || true + fi + echo "" + # Last line on screen after the optional log view - the instruction to + # reboot must be the final thing the user reads. + echo "Run 'reboot' to boot into your new system." } # ── Main ────────────────────────────────────────────────────────────────────── main() { - check_prereqs + check_prereqs - screen_welcome - screen_keymap - screen_network - screen_disk - screen_luks - screen_unl0kr - screen_filesystem - screen_username - screen_password - screen_hostname - screen_git - screen_timezone - screen_locale - screen_login_manager - screen_sshd - screen_summary + screen_welcome + screen_keymap + screen_network + screen_disk + screen_luks + screen_unl0kr + screen_filesystem + screen_username + screen_password + screen_hostname + screen_git + screen_timezone + screen_locale + screen_login_manager + screen_sshd + screen_summary - install_partition - install_luks - install_format_mount - install_pacstrap - install_fstab - install_cachyos_repo - install_timezone - install_locale - install_hostname - install_network - install_sshd - install_user - install_git_ssh - install_end4_dots - install_mkinitcpio - install_bootloader - install_tpm - install_unl0kr - install_login_manager - install_autologin - install_cleanup + install_partition + install_luks + install_format_mount + install_pacstrap + install_fstab + install_cachyos_repo + install_timezone + install_locale + install_hostname + install_network + install_sshd + install_user + install_git_ssh + install_end4_dots + install_mkinitcpio + install_bootloader + install_tpm + install_unl0kr + install_login_manager + install_autologin + install_update + install_cleanup } main "$@"