Tests / tests (push) Successful in 2m56s
unl0kr already prompts for the LUKS passphrase on screen, so a login manager on top of it would be a second prompt. The login screen now only collects a fallback (none/greetd/sddm/gdm) when unl0kr is selected; resolve_login_manager applies it only when install_unl0kr falls back to the systemd initramfs, and the summary shows both states. Also ships /etc/unl0kr.conf (breezy-dark) plus the empty /usr/share/unl0kr/unl0kr.conf the package omits so unl0kr stops warning about an unparseable config, and pins Hyprland in the user's AccountsService record so gdm autologin does not fall back to gnome.desktop.
2393 lines
91 KiB
Bash
Executable File
2393 lines
91 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"
|
|
# true when unl0kr was picked: no login manager is installed if unl0kr
|
|
# succeeds, and LOGIN_MANAGER only holds the fallback for when it doesn't.
|
|
LOGIN_MANAGER_SKIP="false"
|
|
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() {
|
|
if [[ "$LUKS" == "true" && "$UNL0KR" == "true" ]]; then
|
|
screen_unl0kr_login
|
|
return
|
|
fi
|
|
LOGIN_MANAGER_SKIP="false"
|
|
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"
|
|
}
|
|
|
|
# unl0kr already asks for the passphrase on screen at boot, so a login
|
|
# manager on top would be a second prompt. Skip it, but collect a fallback
|
|
# now: install_unl0kr can still fall back to the systemd initramfs, and the
|
|
# installer must not stop to ask mid-install.
|
|
screen_unl0kr_login() {
|
|
LOGIN_MANAGER_SKIP="true"
|
|
clear
|
|
header "Login Manager (unl0kr)"
|
|
echo ""
|
|
gum style --foreground 15 \
|
|
"unl0kr is selected, so no login manager will be installed:" \
|
|
"after the LUKS passphrase is entered with unl0kr, the system logs in on" \
|
|
"tty1 and Hyprland starts automatically (autologin is asked during install)." \
|
|
"" \
|
|
"If unl0kr fails to install, the installer falls back to the standard" \
|
|
"passphrase prompt. Pick the login manager to use in that case:" \
|
|
"" \
|
|
" 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 "+ Fallback login manager (Enter = none)" \
|
|
"none" "greetd" "sddm" "gdm") || die "No fallback login manager selected."
|
|
log "Login manager: skipped for unl0kr (fallback: $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
|
|
|
|
local login_line="${LOGIN_MANAGER}"
|
|
if [[ "$LOGIN_MANAGER_SKIP" == "true" ]]; then
|
|
login_line="none - unl0kr (fallback: ${LOGIN_MANAGER})"
|
|
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_line}" \
|
|
"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
|
|
}
|
|
|
|
# unl0kr parses /usr/share/unl0kr/unl0kr.conf first; the package doesn't
|
|
# ship it, so inih fails to open it and unl0kr prints "Ignoring invalid
|
|
# config file" on every boot. An empty file parses cleanly - FILES+= puts
|
|
# it in the image (the hook only adds /etc/unl0kr.conf). Our own
|
|
# /etc/unl0kr.conf pins the dark theme instead of trusting the PKGBUILD's
|
|
# sed on the upstream (light) default.
|
|
mkdir -p "${TARGET}/usr/share/unl0kr"
|
|
: >"${TARGET}/usr/share/unl0kr/unl0kr.conf"
|
|
cat >"${TARGET}/etc/unl0kr.conf" <<'UNL0KR'
|
|
[general]
|
|
animations=true
|
|
|
|
[keyboard]
|
|
autohide=false
|
|
layout=us
|
|
popovers=true
|
|
|
|
[textarea]
|
|
obscured=true
|
|
|
|
[theme]
|
|
default=breezy-dark
|
|
alternate=breezy-light
|
|
UNL0KR
|
|
echo 'FILES+=(/usr/share/unl0kr/unl0kr.conf)' \
|
|
>>"${TARGET}/etc/mkinitcpio.conf.d/alvin.conf"
|
|
log "unl0kr: wrote /etc/unl0kr.conf (breezy-dark) + empty /usr/share/unl0kr/unl0kr.conf"
|
|
|
|
# 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)"
|
|
}
|
|
|
|
# Runs after install_unl0kr, which resets UNL0KR to false when it falls
|
|
# back to the systemd initramfs - so UNL0KR still being true here means
|
|
# unl0kr really is in the image and no login manager is wanted.
|
|
resolve_login_manager() {
|
|
[[ "$LOGIN_MANAGER_SKIP" == "true" ]] || return 0
|
|
if [[ "$UNL0KR" == "true" ]]; then
|
|
LOGIN_MANAGER="none"
|
|
info "unl0kr is installed - skipping the login manager (tty1 + Hyprland)."
|
|
log "Login manager: none (unl0kr installed)"
|
|
else
|
|
warn "unl0kr fell back - using the fallback login manager: ${LOGIN_MANAGER}."
|
|
log "Login manager: fallback ${LOGIN_MANAGER} (unl0kr not installed)"
|
|
fi
|
|
}
|
|
|
|
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
|
|
# gdm (autologin included) starts the session stored in the user's
|
|
# AccountsService record; without one it picks gnome.desktop, which
|
|
# the gdm install drags in. Pin Hyprland.
|
|
local as_dir="${TARGET}/var/lib/AccountsService/users"
|
|
mkdir -p "$as_dir"
|
|
cat >"${as_dir}/${USERNAME}" <<'ACCOUNT'
|
|
[User]
|
|
Session=hyprland
|
|
SessionType=wayland
|
|
SystemAccount=false
|
|
ACCOUNT
|
|
chmod 600 "${as_dir}/${USERNAME}"
|
|
log "gdm: AccountsService session pinned to hyprland"
|
|
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
|
|
resolve_login_manager
|
|
install_login_manager
|
|
install_autologin
|
|
install_update
|
|
install_cleanup
|
|
}
|
|
|
|
main "$@"
|