#!/usr/bin/env bash # alvin-install — Gum TUI Arch Linux installer with LUKS encryption # shellcheck disable=SC2034 set -Eeuo pipefail readonly LOG=/var/log/alvin-install.log readonly PASS_MIN=8 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; } DISK="" KEYMAP="" USERNAME="" PASS="" HOSTNAME="" GIT_NAME="" GIT_EMAIL="" TIMEZONE="" LOCALE="" LUKS="true" FILESYSTEM="ext4" GH_USERNAME="" UNL0KR="false" TPM_STATUS="" LOGIN_MANAGER="none" AUTOLOGIN="" # SSHD is the choice made on the sshd screen; SSHD_APPLIED is only flipped once # the target's sshd.service has actually been enabled, so the final banner can # report reality instead of the request. SSHD="true" SSHD_APPLIED="false" TPM_DISCLAIMER="" TARGET_DB_SYNCED="false" 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 } trap cleanup EXIT # ── error surfacing ─────────────────────────────────────────────────────────── # Step output normally ends up in $LOG only. These helpers make sure a failure # is ALSO shown on screen, paged through `gum pager` so long outputs stay # inspectable instead of scrolling away or hiding in a file nobody opens. # Page stdin to the user with a context line. $1 = context. gum is a hard # 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 } # 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" } # Page the $LOG lines appended after $1 (a line count taken with # `wc -l < "$LOG"` before the step ran). $2 = context. A mark at or past the # 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" } # Run a command, tee its combined output into $LOG (as the old # `>>"$LOG" 2>&1` redirects did - live, so an interrupted command keeps its # 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. # -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. 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 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" } # 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 } trap 'on_err "$LINENO"' ERR (( EUID == 0 )) || { echo "Run as root" >&2; 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" } divider() { 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 )); } # 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 # come from one arch-chroot call chained with && : separate substitutions # 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: } # `gsettings get` prints GVariant text: '...' normally, "..." when the value # contains an apostrophe, with \n \t \\ \' \" escapes inside. Reduce that to # the plain string the original `gsettings set` was called with, so a read-back # can be compared against the replayed command. One left-to-right pass: doing # 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" } # Refresh the target's package databases once before the first target-side # pacman/yay call (cachyos is appended to the target's pacman.conf after # 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 } 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." } # ── 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." } 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" } 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" } screen_disk() { 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 local disks=() while IFS= read -r line; do local name size model name=$(echo "$line" | awk '{print $1}') size=$(echo "$line" | awk '{print $2}') model=$(echo "$line" | awk '{$1=$2=""; sub(/^[ \t]+/,""); print}') [[ "$name" == "$live_dev" ]] && continue disks+=("${name} ${size} ${model}") done < <(lsblk -dpno NAME,SIZE,MODEL,TYPE | awk '$NF=="disk" {print $1,$2,$3}') (( ${#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}') gum confirm --affirmative "Wipe ${DISK}" --negative "Go back" \ "WARNING: ALL data on ${DISK} will be destroyed!" && break done 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" } 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" } screen_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" } screen_password() { 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)" } screen_hostname() { clear header "Hostname" echo "" HOSTNAME=$(gum input --placeholder "archlinux" --char-limit 64 --header "+ Hostname") \ || die "No hostname entered." [[ -z "$HOSTNAME" ]] && HOSTNAME="archlinux" 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}'" } screen_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" } 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" } 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}" } screen_summary() { clear header "Installation Summary" echo "" local enc_status="No" [[ "$LUKS" == "true" ]] && enc_status="Yes (uses login password)" 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 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." } # ── Install steps ───────────────────────────────────────────────────────────── install_partition() { info "Partitioning ${DISK}..." log "Partitioning $DISK" 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% partprobe "$DISK" udevadm settle --timeout=30 wipefs -af "$(part 1)" "$(part 2)" ok "Partitioned." } install_luks() { [[ "$LUKS" != "true" ]] && return 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 ok "LUKS unlocked." } install_format_mount() { 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 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" ok "Formatted and mounted." } install_pacstrap() { 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=() # 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 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") done # 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 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." } install_fstab() { 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" # 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 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." } install_locale() { info "Setting locale..." arch-chroot "$TARGET" sed -i "s/^#${LOCALE}/${LOCALE}/" /etc/locale.gen arch-chroot "$TARGET" locale-gen echo "LANG=${LOCALE}" > "${TARGET}/etc/locale.conf" echo "KEYMAP=${KEYMAP}" > "${TARGET}/etc/vconsole.conf" ok "Locale and keymap set." } install_hostname() { info "Setting hostname..." echo "$HOSTNAME" > "${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 ok "NetworkManager enabled." } install_sshd() { [[ "$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 } install_user() { info "Creating user..." 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}" printf '%s:%s\n' "$USERNAME" "$PASS" | arch-chroot "$TARGET" chpasswd 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" ok "User ${USERNAME} created (wheel group, sudo enabled). Root locked." } install_git_ssh() { 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}>" 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 "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" 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 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}." } install_end4_dots() { 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)..." 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) 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" # 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 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 # ── 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." 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) ──────────────────────────── # Shared by install_mkinitcpio/install_bootloader on the happy path and by # unl0kr_fallback_mode_a, so the hook list and kernel cmdline are written in # exactly one shape everywhere - drift between the two places that write them # is precisely the failure mode the fallback exists to undo. write_mode_a_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 } # $1 = LUKS partition UUID, $2 = optional extra cmdline option(s), appended # 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" } install_mkinitcpio() { info "Generating initramfs configuration..." if [[ "$LUKS" != "true" ]]; then arch-chroot "$TARGET" mkinitcpio -P 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 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' 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 # 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 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}" 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 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 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 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 # 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 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 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 info "Installing unl0kr (touchscreen LUKS unlock)..." sync_target_dbs 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" 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}" # 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")") 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" 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 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 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' [Desktop Entry] Name=Hyprland Comment=Hyprland wayland session Exec=Hyprland Type=Application DesktopNames=Hyprland DESKTOP 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 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' # 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 fi # 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}..." # 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' [terminal] vt = 1 [default_session] 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 log "Login manager: ${LOGIN_MANAGER}" } # ── Autologin ───────────────────────────────────────────────────────────────── install_autologin() { 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)" 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})" 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" < "${TARGET}/etc/greetd/config.toml" < "${TARGET}/etc/greetd/config.toml" <<'TOML' [terminal] vt = 1 [default_session] 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" < "$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)" 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}" 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 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 } main "$@"