2296 lines
87 KiB
Bash
Executable File
2296 lines
87 KiB
Bash
Executable File
#!/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"
|
|
UPDATE_STATUS="not run"
|
|
|
|
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 (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.
|
|
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
|
|
|
|
# -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."
|
|
|
|
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 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}'"
|
|
}
|
|
|
|
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)."
|
|
|
|
# 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."
|
|
}
|
|
|
|
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
|
|
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"
|
|
|
|
ok "Locale and keymap set."
|
|
}
|
|
|
|
install_hostname() {
|
|
info "Setting hostname..."
|
|
echo "$HOSTNAME" >"${TARGET}/etc/hostname"
|
|
cat >"${TARGET}/etc/hosts" <<EOF
|
|
127.0.0.1 localhost
|
|
::1 localhost
|
|
127.0.1.1 ${HOSTNAME}.localdomain ${HOSTNAME}
|
|
EOF
|
|
ok "Hostname set."
|
|
}
|
|
|
|
install_network() {
|
|
info "Configuring network..."
|
|
|
|
arch-chroot "$TARGET" systemctl enable NetworkManager
|
|
|
|
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."
|
|
}
|
|
|
|
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 -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 "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 -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
|
|
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 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"
|
|
|
|
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.
|
|
# 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) ────────────────────────────
|
|
# 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
|
|
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
|
|
|
|
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
|
|
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."
|
|
}
|
|
|
|
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 -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
|
|
}
|
|
|
|
# ── 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 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
|
|
|
|
# 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" <<EOF
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=-/usr/bin/agetty --autologin ${USERNAME} --noclear %I \$TERM
|
|
EOF
|
|
log "Autologin: getty@tty1 drop-in written"
|
|
else
|
|
rm -f "$dropin"
|
|
fi
|
|
;;
|
|
greetd)
|
|
# Whole-file rewrite makes both directions idempotent.
|
|
if [[ "$AUTOLOGIN" == "enabled" ]]; then
|
|
cat >"${TARGET}/etc/greetd/config.toml" <<EOF
|
|
[terminal]
|
|
vt = 1
|
|
|
|
[default_session]
|
|
command = "tuigreet --cmd Hyprland"
|
|
user = "greeter"
|
|
|
|
[initial_session]
|
|
user = "${USERNAME}"
|
|
command = "Hyprland"
|
|
EOF
|
|
log "Autologin: greetd [initial_session] enabled"
|
|
else
|
|
cat >"${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" <<EOF
|
|
[Autologin]
|
|
User=${USERNAME}
|
|
Session=hyprland.desktop
|
|
EOF
|
|
log "Autologin: sddm autologin.conf written"
|
|
else
|
|
rm -f "$sddm_auto"
|
|
fi
|
|
;;
|
|
gdm)
|
|
local gdm_conf="${TARGET}/etc/gdm/custom.conf"
|
|
if [[ "$AUTOLOGIN" == "enabled" ]]; then
|
|
mkdir -p "$(dirname "$gdm_conf")"
|
|
cat >"$gdm_conf" <<EOF
|
|
[daemon]
|
|
AutomaticLoginEnable=true
|
|
AutomaticLogin=${USERNAME}
|
|
EOF
|
|
log "Autologin: gdm custom.conf written"
|
|
elif [[ -f "$gdm_conf" ]]; then
|
|
sed -i -e '/^AutomaticLoginEnable=/d' \
|
|
-e '/^AutomaticLogin=/d' "$gdm_conf"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# TPM disclaimer for the final summary. Scoped to LUKS installs: without
|
|
# encryption there is no passphrase/TPM story to disclaim.
|
|
TPM_DISCLAIMER=""
|
|
if [[ "$LUKS" == "true" && "$TPM_STATUS" != "enrolled" ]]; then
|
|
local reason
|
|
case "$TPM_STATUS" in
|
|
unsupported) reason="no usable TPM2 device was detected" ;;
|
|
failed) reason="enrollment failed (see ${LOG})" ;;
|
|
skipped-unl0kr) reason="it is disabled because unl0kr is enabled" ;;
|
|
skipped-unl0kr-fallback) reason="unl0kr was selected, so TPM2 enrollment was never offered (and was not re-offered after unl0kr fell back to the standard initramfs)" ;;
|
|
skipped) reason="it was skipped during installation" ;;
|
|
*) reason="it was not set up" ;;
|
|
esac
|
|
TPM_DISCLAIMER="TPM2 auto-unlock is disabled: ${reason}. The LUKS passphrase is still required at boot."
|
|
log "TPM disclaimer: ${TPM_DISCLAIMER}"
|
|
fi
|
|
|
|
ok "Login set: manager=${LOGIN_MANAGER}, autologin=${AUTOLOGIN}."
|
|
}
|
|
|
|
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
|
|
# 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
|
|
[[ -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 ""
|
|
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_update
|
|
install_cleanup
|
|
}
|
|
|
|
main "$@"
|