#!/usr/bin/env bash
# veilor-os installer — TUI wrapper around anaconda kickstart install.
# Runs on tty1 in place of getty (live ISO boot path).
#
# Flow:
#   1. ASCII banner (assets/installer/banner.txt)
#   2. Menu: Install / Live desktop / Live shell / Reboot / Power off
#   3. If Install: collect answers via gum (disk, hostname, LUKS pw,
#      admin pw, locale)
#   4. Generate /run/install/veilor-generated.ks from template + answers
#      (full veilor-os install: package list + overlay + scripts + harden)
#   5. Exec anaconda --kickstart=/run/install/veilor-generated.ks
#   6. On finish: reboot into installed system
#
# v0.5.1 — gum (charm.sh) replaces whiptail; whiptail kept as fallback.
#          Generated kickstart now installs full veilor-os (matches live ks).

set -uo pipefail
export TERM="${TERM:-linux}"
# Log to /run (pure tmpfs) — /var/log overlays squashfs on the live ISO, so a
# bad sector on the USB medium turns `tee -a` into "input/output error" and
# kills the installer before the menu can render.
LOG=/run/veilor-installer.log

# require_tty MUST run before the tee redirect — process substitution
# replaces fd1 with a pipe, breaking `[[ -t 1 ]]`.
require_tty() {
    if ! [[ -t 0 && -t 1 ]]; then
        echo "[ERR] veilor-installer must run on a real tty" >&2
        exit 1
    fi
}
require_tty

# Tee output for log persistence, but only if LOG is writable. On a flaky USB
# the squashfs/overlay can throw I/O errors mid-write — tolerate that and
# keep the installer running without persistence rather than aborting.
if : >> "$LOG" 2>/dev/null; then
    exec > >(tee -a "$LOG") 2>&1
fi

# ── Branded styling for gum ─────────────────────────────────────────────
# colors.gum sets GUM_* env vars — pure-black palette from veilor-black KDE.
# Sourced at top so every prompt below picks up branded colors.
COLORS=/usr/share/veilor-os/assets/installer/colors.gum
[[ -r $COLORS ]] && source "$COLORS"

BANNER_FILE=/usr/share/veilor-os/assets/installer/banner.txt

# Detect TUI backend once. gum is preferred; whiptail is the fallback so the
# installer keeps working on minimal images or if /usr/local/bin/gum is
# missing (e.g. broken vendored binary, /usr remount issues).
if command -v gum >/dev/null 2>&1; then
    TUI=gum
elif command -v whiptail >/dev/null 2>&1; then
    TUI=whiptail
else
    echo "[ERR] neither gum nor whiptail available — cannot run TUI" >&2
    exit 1
fi

banner() {
    clear
    # Read version + date for version line. /etc/os-release has VERSION_ID.
    local ver=""
    [[ -r /etc/os-release ]] && ver=$(. /etc/os-release; echo "${VERSION_ID:-0.0}")
    local d
    d=$(date +%Y-%m-%d)
    local vline="veilor-os ${ver} · ${d} · live"

    if [[ -r $BANNER_FILE ]]; then
        # v0.6: staged line-by-line reveal of the banner before the
        # gum-style border draws around it. 40ms/line gives a subtle
        # "typewriter" feel — 5 lines × 40ms = 200ms total, fast enough
        # not to feel laggy but slow enough to land an aesthetic on the
        # very first frame the user sees. Once the reveal finishes we
        # clear and re-draw with the bordered gum-style version so the
        # operator never sees both stacked on top of each other.
        local line
        while IFS= read -r line; do
            printf '  %s\n' "$line"
            sleep 0.04
        done < "$BANNER_FILE"
        sleep 0.08
        clear

        if [[ $TUI == gum ]]; then
            # gum style: rounded border, banner + blank line + version line.
            gum style --border rounded --margin "0 2" --padding "1 3" \
                --border-foreground "${VEILOR_DIM:-240}" \
                --foreground "${VEILOR_FG:-15}" \
                "$(cat "$BANNER_FILE")" \
                "" \
                "$vline"
        else
            cat "$BANNER_FILE"
            echo
            echo "  $vline"
            echo
        fi
    else
        # Fallback ASCII if banner.txt missing (older overlay).
        cat << EOF

   veilor-os installer
   $vline

EOF
    fi
}

# ── TUI wrapper functions ───────────────────────────────────────────────
# Each prompt_* call abstracts gum / whiptail. Always emit the chosen value
# on stdout; non-zero exit on cancel/ESC. Callers use `||` to propagate.

# prompt_choose <header> <opt1> [opt2 ...]
# Single-select menu. Returns the selected option literal on stdout.
prompt_choose() {
    local header=$1; shift
    if [[ $TUI == gum ]]; then
        gum choose --header "$header" "$@"
    else
        # whiptail menu needs tag/desc pairs. Use the option as both.
        local args=()
        local opt
        for opt in "$@"; do args+=("$opt" "$opt"); done
        whiptail --title "veilor-os" --menu "$header" 18 70 8 \
            "${args[@]}" 3>&1 1>&2 2>&3
    fi
}

# prompt_choose_pairs <header> <tag1> <desc1> [tag2 desc2 ...]
# Tag/description menu. Returns the chosen tag.
# Used when display label differs from machine-readable value (e.g. disk
# path /dev/nvme0n1 vs description "476G WDC PC SN740").
prompt_choose_pairs() {
    local header=$1; shift
    if [[ $TUI == gum ]]; then
        # gum has no tag/desc concept — render "tag — desc" lines, parse.
        local lines=() i=1
        while (( i <= $# )); do
            local tag=${!i}; ((i++))
            local desc=${!i}; ((i++))
            lines+=("$tag — $desc")
        done
        local picked
        picked=$(printf '%s\n' "${lines[@]}" | gum choose --header "$header") || return 1
        # Strip " — desc" suffix to recover the tag.
        echo "${picked%% — *}"
    else
        whiptail --title "veilor-os" --menu "$header" 18 70 8 \
            "$@" 3>&1 1>&2 2>&3
    fi
}

# prompt_input <header> [default]
prompt_input() {
    local header=$1 default=${2:-}
    if [[ $TUI == gum ]]; then
        gum input --header "$header" --value "$default"
    else
        whiptail --title "veilor-os" --inputbox "$header" 10 60 "$default" \
            3>&1 1>&2 2>&3
    fi
}

# prompt_password <header>
#
# v0.6: gum-path replaced with bash `read -srp` because `gum input
# --password` rendered as a duplicate-"Install" + stray-T artefact on
# the linux fbcon since v0.5.27 (Agent 7 of the v0.6 polish research
# wave traced this to gum's bubbletea screen-restore writing back the
# previous menu buffer when the framebuffer terminfo lacked
# `civis/cnorm` cursor-hide sequences). bash `read -srp` is a single
# write to stdout + termios echo-off — no redraw, no glitch. Header
# rendered separately via gum style for visual parity with the rest
# of the installer.
prompt_password() {
    local header=$1
    if [[ $TUI == gum ]]; then
        # Render the prompt header as a styled box so it looks at home
        # next to the other gum prompts, then collect the password via
        # plain bash read on the next line. `read -s` disables echo,
        # `read -p` writes the prompt to stderr (so command-substitution
        # callers still get the password on stdout cleanly).
        gum style --foreground "${VEILOR_FG:-15}" --border rounded \
            --border-foreground "${VEILOR_DIM:-240}" --padding "0 2" -- "$header"
        local pw
        read -srp "  password: " pw
        echo >&2  # newline after silent read so next prompt isn't on same line
        printf '%s' "$pw"
    else
        whiptail --title "veilor-os" --passwordbox "$header" 10 60 \
            3>&1 1>&2 2>&3
    fi
}

# prompt_confirm <message>
# Exits 0 on yes, 1 on no — matches whiptail --yesno semantics.
prompt_confirm() {
    local msg=$1
    if [[ $TUI == gum ]]; then
        gum confirm "$msg"
    else
        whiptail --title "Confirm" --yesno "$msg" 16 60
    fi
}

# prompt_message <message>
# Non-blocking notice. gum has no msgbox; print styled + sleep.
prompt_message() {
    local msg=$1
    if [[ $TUI == gum ]]; then
        gum style --foreground "${VEILOR_FG:-15}" --border rounded \
            --border-foreground "${VEILOR_DIM:-240}" --padding "1 2" -- "$msg"
        sleep 2
    else
        whiptail --title "veilor-os" --msgbox "$msg" 10 60
    fi
}

# prompt_error <message>
# Same as message but with a red foreground for visibility.
prompt_error() {
    local msg=$1
    if [[ $TUI == gum ]]; then
        gum style --foreground 1 --border rounded --border-foreground 1 \
            --padding "1 2" -- "$msg"
        sleep 2
    else
        whiptail --title "Error" --msgbox "$msg" 10 60
    fi
}

main_menu() {
    # Empty header — banner already provides context. `──────` line splits
    # primary actions (top) from session controls (bottom). Cursor `❯` set
    # via GUM_CHOOSE_CURSOR in colors.gum.
    prompt_choose "" \
        "Install" \
        "live · KDE" \
        "live · shell" \
        "──────" \
        "Reboot" \
        "Power off"
}

collect_answers() {
    local disk hostname luks_pw admin_pw locale
    local disks_pairs

    # ── Disk ──
    # Build whiptail-style "tag desc" pairs. prompt_choose_pairs reshapes
    # for gum if needed.
    # shellcheck disable=SC2207
    disks_pairs=($(lsblk -dpno NAME,SIZE,MODEL | grep -E '^/dev/(sd|nvme|vd|mmcblk)' | \
        awk '{name=$1; size=$2; $1=""; $2=""; sub(/^ +/,""); gsub(/ /,"_"); model=$0; if(model=="")model="unknown"; print name, size"_"model}'))
    if [[ ${#disks_pairs[@]} -eq 0 ]]; then
        prompt_error "No installable disks found."
        return 1
    fi
    disk=$(prompt_choose_pairs "[1/3] Select install disk · WILL BE ERASED" "${disks_pairs[@]}") || return 1

    # ── Hostname ──
    # Hardcoded for branded consistency. Post-install: `hostnamectl set-hostname`.
    hostname="veilor"

    # Reject shell-special and sed-special chars in passwords. Generated
    # kickstart writes them via heredoc + sed substitution; bare $, ", \, `
    # would corrupt the ks line or partially expand at heredoc time.
    # &, |, /, newline are sed-special: & expands to the matched pattern
    # (so `aA1!@#%^&*()` becomes `aA1!@#%^__ADMIN_PW__*()`), | is our
    # delimiter, / would match if delimiter changes, newline breaks the
    # sed expression. sed_escape() below adds defense-in-depth, but we
    # also reject these at input so the user sees an immediate error
    # rather than a corrupted ks file. 8-char min for entropy.
    validate_pw() {
        local pw=$1 label=$2
        if [[ ${#pw} -lt 8 ]]; then
            prompt_error "Weak $label — minimum 8 characters."
            return 1
        fi
        if [[ $pw =~ [\"\$\\\`\&\|/$'\n'] ]]; then
            prompt_error "Invalid $label — cannot contain: \" \$ \\ \` & | / newline"
            return 1
        fi
        return 0
    }

    # ── LUKS passphrase ──
    # v0.6: prompt twice + string-compare. A typo in the LUKS passphrase
    # is unrecoverable — the disk is unmountable without it and we
    # don't escrow the key. Re-prompting until the two reads match
    # catches keyboard-layout surprises (US vs UK quote position is
    # the most common one) before they brick the install.
    local luks_pw_confirm
    while true; do
        luks_pw=$(prompt_password "[2/3] Encryption · LUKS2 passphrase (min 8)") || return 1
        validate_pw "$luks_pw" "passphrase" || continue
        luks_pw_confirm=$(prompt_password "[2/3] Confirm LUKS2 passphrase") || return 1
        if [[ $luks_pw == "$luks_pw_confirm" ]]; then
            break
        fi
        prompt_error "Passphrases do not match — try again."
    done

    # ── Admin password ──
    # Same confirm-twice pattern. Less catastrophic than LUKS (admin
    # password can be reset from a recovery shell) but a mismatch here
    # still locks the user out of their fresh install on first boot.
    local admin_pw_confirm
    while true; do
        admin_pw=$(prompt_password "[3/3] Admin user · password for 'admin'") || return 1
        validate_pw "$admin_pw" "password" || continue
        admin_pw_confirm=$(prompt_password "[3/3] Confirm admin password") || return 1
        if [[ $admin_pw == "$admin_pw_confirm" ]]; then
            break
        fi
        prompt_error "Passwords do not match — try again."
    done

    # ── Locale ──
    # Hardcoded en_US.UTF-8 for branded consistency. The picker that
    # used to live here (en_GB / en_US) only added confusion — both
    # locales install identically, the user couldn't notice the
    # difference, and the post-install `localectl set-locale` works for
    # any locale anyway. v0.7 post-install menu will offer locale + kb
    # layout switch with live preview. For the install flow, fixed.
    locale="en_US.UTF-8"

    # ── Confirmation ──
    # Render summary box + danger lines via gum style, then gum confirm.
    # Whiptail fallback: simple yesno.
    if [[ $TUI == gum ]]; then
        clear
        gum style --border rounded --margin "1 2" --padding "1 3" \
            --border-foreground "${VEILOR_DIM:-240}" \
            --foreground "${VEILOR_FG:-15}" \
            "Confirm install" \
            "" \
            "  Disk     $disk  $(gum style --foreground 1 'WILL BE ERASED')" \
            "  Locale   $locale" \
            "  LUKS     ✓ set" \
            "  Admin    ✓ set" \
            "" \
            "$(gum style --foreground 3 'This action is irreversible.')"
        gum confirm --affirmative "Yes, install" --negative "Cancel" "Proceed?" || return 1
    else
        whiptail --title "Confirm install" --yesno \
"About to install veilor-os:

  Disk:       $disk (WILL BE ERASED)
  Locale:     $locale
  LUKS:       set
  Admin pw:   set

This action is irreversible.

Proceed?" 16 60 || return 1
    fi

    # Export to caller via globals
    SEL_DISK=$disk
    SEL_HOSTNAME=$hostname
    SEL_LUKS_PW=$luks_pw
    SEL_ADMIN_PW=$admin_pw
    SEL_LOCALE=$locale
    return 0
}

# sed_escape — escape sed special chars in a replacement string.
# Replacement-side metacharacters: & (matched pattern), \ (escape),
# | (our chosen delimiter), / (alternate delimiter — escape too in case
# delimiter ever changes). Newline is rejected in validate_pw because
# escaping it portably across BSD/GNU sed is fiddly.
# Order matters: \ must be escaped FIRST so we don't double-escape the
# backslashes we're about to emit for &, |, /.
sed_escape() {
    printf '%s' "$1" | sed -e 's/[\\&|/]/\\&/g'
}

# detect_seed_pubkey — search attached cdroms for a NoCloud cidata seed
# with an ssh_authorized_keys entry. Returns the first key on stdout, or
# empty string if none found. Used by both auto-install.sh (cloud-init
# seed pre-built with host pubkey) and humans (drop a seed iso next to
# the install media).
detect_seed_pubkey() {
    local dev label tmpmnt key=""
    for dev in /dev/sr0 /dev/sr1 /dev/sr2 /dev/sr3; do
        [ -b "$dev" ] || continue
        label=$(blkid -o value -s LABEL "$dev" 2>/dev/null)
        if [[ $label == "cidata" || $label == "CIDATA" ]]; then
            tmpmnt=$(mktemp -d)
            if mount -o ro "$dev" "$tmpmnt" 2>/dev/null; then
                # NoCloud user-data format:
                #   ssh_authorized_keys:
                #     - ssh-ed25519 AAAA... user@host
                # Extract first ssh-* line, strip leading '- '.
                key=$(grep -E '^\s*-\s+ssh-' "$tmpmnt/user-data" 2>/dev/null \
                    | head -1 | sed -e 's/^\s*-\s*//' -e 's/[[:space:]]*$//')
                umount "$tmpmnt" 2>/dev/null
            fi
            rmdir "$tmpmnt" 2>/dev/null
            [[ -n $key ]] && { printf '%s' "$key"; return 0; }
        fi
    done
    return 1
}

generate_ks() {
    # Build kickstart for actual disk install.
    # NOTE: passwords go in via --plaintext to avoid storing crypted hash
    # collisions; anaconda hashes per /etc/login.defs at install time.
    #
    # %packages mirrors live ks lines 63-141 (full hardening pkg list,
    # minus livesys/anaconda-live which are live-only).
    # %post --nochroot copies overlay + scripts + assets from boot ISO.
    # %post (chroot) runs the same hardening scripts the live build runs.
    local out=/run/install/veilor-generated.ks
    local disk_basename
    disk_basename=$(basename "$SEL_DISK")
    mkdir -p /run/install
    # Single-quoted heredoc → no shell expansion. Substitute placeholders
    # via sed afterwards. Bulletproof against $/`/" in passwords.
    cat > "$out" << 'KSEOF' || return 1
# veilor-os installer-generated kickstart
# DO NOT commit this file — secrets inline.
url --mirrorlist="https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-43&arch=x86_64"
repo --name=fedora --baseurl="https://download.fedoraproject.org/pub/fedora/linux/releases/43/Everything/x86_64/os/" --install
# `updates` repo intentionally NOT added. Fedora's update stream pushes
# package versions whose %posttrans scriptlets sometimes fail under
# anaconda's `--cmdline` mode — most reliably reproduced as `man-db`
# failing during "Configuring man-db.x86_64", which dumps the whole
# transaction with no recoverable error message and prints "An error
# occurred during the transaction: The transaction process has ended
# with errors..". Reproduced in v0.5.26 + v0.5.27 VM tests against
# fresh installs days apart, so it is not a Fedora-mirrors-down blip.
# CI build kickstart already strips this line for the same reason
# (build-iso.yml line ~109). Users who want to update post-install run
# `dnf upgrade` or the v0.6 `veilor-update` wrapper.

keyboard --xlayouts='us'
lang __LOCALE__
timezone Europe/London --utc

firstboot --disable
eula --agreed
selinux --enforcing
services --enabled=sshd,fail2ban,usbguard,tuned,auditd,firewalld,chronyd,sddm

network --bootproto=dhcp --device=link --activate --hostname=__HOSTNAME__
firewall --enabled --service=ssh

rootpw --lock
user --name=admin --groups=wheel --gecos="veilor admin" --password=__ADMIN_PW__ --plaintext
__SSHKEY_DIRECTIVE__

# Full hardening cmdline (installed system, not live):
# - `lockdown=integrity` — kernel lockdown, integrity mode (signed module enforce)
# - `slab_nomerge` — refuse SLAB merging; harder heap-spray attacks
# - `init_on_alloc=1 init_on_free=1` — zero pages on alloc + free; defends
#   uninit-read class; ~5% perf hit acceptable on hardened workstation
# - `randomize_kstack_offset=on` — KASLR for kernel stack, per-syscall
# - `vsyscall=none` — kill legacy vsyscall page (Position-Independent
#   ROP-gadget surface)
# - `fbcon=nodefer` — keep linux framebuffer console alive through KMS
#   handoff so plymouth LUKS prompt remains visible on real GPUs.
#
# NOTE on --location: v0.5.30 used --location=none to skip anaconda's
# bootloader install (sidestep gen_grub_cfgstub). Side effect: anaconda
# also skipped CollectKernelArgumentsTask (installation.py:149-151), so
# `--append=` args were NEVER COLLECTED. kernel-install then wrote BLS
# entries with empty /etc/kernel/cmdline, falling through to the live
# ISO's /proc/cmdline — no rd.luks.uuid, no fbcon=nodefer, no hardening.
# Result: dracut emergency shell on first boot.
#
# v0.5.31 lets anaconda install the bootloader (default behavior, no
# --location flag). With our broad transaction_progress patch in the
# live ks, anaconda's gen_grub_cfgstub still runs, but if grub2-efi-x64's
# posttrans had a non-fatal scriptlet failure the patch swallows it
# without aborting. The %post chroot below STILL does belt-and-braces
# fixup (dnf reinstall, grub2-install, etc.) in case anaconda's path
# left something incomplete.
#
# Critically v0.5.31 also writes /etc/kernel/cmdline FIRST in %post then
# re-runs kernel-install per kernel. That's the canonical Fedora 43 path
# for landing args in BLS entries — kernel-install reads /etc/kernel/cmdline
# (90-loaderentry.install:84-95) when generating BLS option lines.
bootloader --append="lockdown=integrity module.sig_enforce=1 slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer i915.modeset=1 amdgpu.modeset=1 nvidia-drm.modeset=1 rd.vconsole.keymap=us"

# Disk: zero, LUKS2 (argon2id), btrfs subvolumes (no LVM intermediary).
# Native btrfs-on-LUKS matches Fedora KDE Spin defaults; LVM+btrfs combo
# triggered "mount failed: wrong fs type, bad option, bad superblock"
# under anaconda --cmdline. btrfs subvols give us the snapshot/rollback
# story without LVM's added complexity.
zerombr
clearpart --all --initlabel --drives=__DISK_BASENAME__
part /boot/efi --fstype=efi --size=600
part /boot --fstype=ext4 --size=1024
part btrfs.veilor --grow --encrypted --luks-version=luks2 --pbkdf=argon2id --passphrase=__LUKS_PW__
btrfs none --label=veilor btrfs.veilor
btrfs / --subvol --name=root LABEL=veilor
btrfs /home --subvol --name=home LABEL=veilor

# ── Packages — mirrors live ks (kickstart/veilor-os.ks 63-141), minus
#    live-only entries (livesys-scripts, anaconda-live, @anaconda-tools,
#    dracut-live, isomd5sum, xorriso) which are pointless on a real disk.
%packages --excludedocs
@^kde-desktop-environment
@kde-apps
@core
@hardware-support
@standard

kernel-modules
kernel-modules-extra
glibc-all-langpacks
dracut-config-generic
kernel
grub2-efi-x64
grub2-efi-x64-modules
grub2-pc
grub2-pc-modules
grub2-tools
grub2-tools-extra
shim-x64
efibootmgr
syslinux

# veilor-installer dependencies (kept on installed system so
# `sudo veilor-installer` from a recovery shell still works).
newt
parted
cryptsetup
lvm2
btrfs-progs

# core hardening tools
fail2ban
fail2ban-firewalld
usbguard
usbguard-tools
audit
policycoreutils-python-utils
tuned
chrony
firewalld
plymouth

# admin essentials
git
vim-enhanced
tmux
htop
podman
skopeo
NetworkManager
NetworkManager-wifi

# fonts
fontconfig
freetype
fira-code-fonts

# zram (no swap-on-disk)
zram-generator

# remove fluff
# Note: KDE Plasma 6 hard-deps on cups/geoclue2/ModemManager/PackageKit
# transitively; daemons disabled at runtime via 20-harden-kernel.sh.
-abrt*
-snapd
-kde-connect
-open-vm-tools-desktop
-mlocate

# Belt-and-braces with the kickstart/veilor-os.ks transaction_progress
# patch: even with the patch, man-db's transfiletriggerin in the F43
# RPM 6.0 toolchain dispatches a systemd-run that anaconda's chroot
# can race-with on exit. Excluding the package entirely guarantees the
# trigger never fires during install. Veilor users who want man pages
# install them post-firstboot via \`dnf install man-db man-pages\` or
# via the v0.6 \`veilor-postinstall\` welcome menu.
-man-db
-man-pages
-man-pages-overrides

%end

# ── Post-install (nochroot): copy overlay + scripts + assets from boot ISO.
# Mirror of live ks 144-196 trimmed to single-source: ISO mounted at
# /run/install/repo, veilor/ subtree contains overlay/, scripts/, assets/.
# (See build/Containerfile — ISO build copies the repo into veilor/.)
%post --nochroot --erroronfail
set -uo pipefail
DEST="${INSTALL_ROOT:-/mnt/sysimage}"
[[ -d $DEST ]] || { echo "[ERR] DEST=$DEST does not exist" >&2; exit 1; }

SRC=/run/install/repo/veilor
if [[ ! -d $SRC/overlay ]]; then
    echo "[ERR] $SRC/overlay missing — boot ISO did not include veilor/ tree" >&2
    exit 1
fi

echo "[INFO] using SRC=$SRC DEST=$DEST"
set -x
cp -a "$SRC/overlay/." "$DEST/" || echo "[ERR] overlay cp failed: $?"
mkdir -p "$DEST/usr/share/veilor-os" || echo "[ERR] mkdir failed: $?"
cp -a "$SRC/assets" "$DEST/usr/share/veilor-os/" || echo "[ERR] assets cp failed: $?"
cp -a "$SRC/scripts" "$DEST/usr/share/veilor-os/" || echo "[ERR] scripts cp failed: $?"
ls -la "$DEST/usr/share/veilor-os/" 2>&1 || echo "[ERR] dest dir missing post-cp"
# Force root ownership on everything we copied — `cp -a` preserves
# CI runner uid (1001), which makes sudo refuse to read /etc/sudoers.d.
chown -R 0:0 "$DEST/etc" "$DEST/usr/share/veilor-os" "$DEST/usr/local/bin" 2>&1 || echo "[WARN] chown failed"
set +x

{
    echo "=== %post --nochroot trace ==="
    date
    echo "SRC=$SRC DEST=$DEST"
    ls -la "$DEST/usr/share/veilor-os/" 2>&1
    ls -la "$DEST/usr/local/bin/" 2>&1
} > "$DEST/var/log/veilor-nochroot.log" 2>&1 || true
%end

# ── Post-install (chroot): apply hardening, theme, branding ────────────
%post
set -uo pipefail
exec > >(tee -a /var/log/veilor-install.log) 2>&1

echo "════════════════════════════════════════════════════════"
echo " veilor-os install — %post (real install)"
echo "════════════════════════════════════════════════════════"

REPO=/usr/share/veilor-os
chmod +x $REPO/scripts/*.sh $REPO/scripts/selinux/*.sh \
    /usr/local/bin/veilor-power /usr/local/bin/veilor-firstboot \
    /usr/local/bin/veilor-installer 2>/dev/null || true

# /etc/machine-id reset on first boot
> /etc/machine-id

# Apply hardening
bash $REPO/scripts/10-harden-base.sh
bash $REPO/scripts/20-harden-kernel.sh

# Build SELinux module
bash $REPO/scripts/selinux/build-policy.sh || echo "[WARN] SELinux build failed; load on first boot"

# Apply KDE theme + DuckSans + os-release branding
bash $REPO/scripts/kde-theme-apply.sh

# Disable plymouth at TWO layers:
#
# 1. Initramfs (the boot stage where LUKS unlock happens). Plymouth is
#    a dracut module; masking units in /etc/systemd/ has zero effect
#    here because dracut bundles its own copies into initramfs/.
#    Solution: omit_dracutmodules in dracut.conf.d, then regenerate
#    initramfs so the new config takes effect.
#
# 2. Real root (post-pivot, before SDDM). /dev/null symlinks mask all
#    plymouth services + the path-activated ask-password unit so they
#    never start when systemd is up.
#
# After both, LUKS prompt falls back to systemd-tty-ask-password-agent
# on tty1 — text "Please enter passphrase for disk... :" — works in
# QEMU sendkey AND on real hardware.

# Manual bootloader install (anaconda told to skip via --location=none)
#
# Anaconda's gen_grub_cfgstub script-runner (efi.py:194-201) is
# brittle on F43 RPM 6.0 cmdline mode — grub2-efi-x64's posttrans
# scriptlet may emit non-fatal errors that anaconda treats as
# transaction-fatal even with our error suppression patch. Doing
# the work in %post chroot bypasses that whole code path and gives
# us linear, debuggable steps.
#
# Order:
#   1. Re-run grub2-efi-x64 + shim-x64 scriptlets cleanly (dnf
#      reinstall in chroot has full PID 1 systemd context, so the
#      systemd-run inside man-db-style triggers actually runs). Re-
#      installing repopulates /boot/efi/EFI/fedora/ if it was empty.
#   2. grub2-install — generic + UEFI. UEFI path is the meaningful one
#      on virtio-vga and real hardware.
#   3. grub2-mkconfig — write /boot/grub2/grub.cfg + /boot/efi/EFI/fedora/grub.cfg.
#   4. efibootmgr — register the boot entry in NVRAM.
#
# Failure of any individual step is logged but does NOT abort the
# %post (set +e bracket). On a real failure the user sees the
# diagnostic text and can fix manually post-firstboot.

set +e
echo "════════════════════════════════════════════════════════"
echo " bootloader install (manual; anaconda skipped via --location=none)"
echo "════════════════════════════════════════════════════════"

# Disk we're targeting — anaconda already wrote /boot/efi mount, so
# the disk is whatever holds /boot/efi.
EFI_DISK=$(findmnt -n -o SOURCE /boot/efi 2>/dev/null | sed -E 's/p?[0-9]+$//')
[ -z "$EFI_DISK" ] && EFI_DISK="/dev/$(basename "$(realpath /sys/class/block/$(findmnt -n -o SOURCE /boot/efi | sed 's|/dev/||' | sed 's|p\?[0-9]\+$||') 2>/dev/null)")"
echo "[INFO] target disk for grub: ${EFI_DISK:-<unknown>}"

# Step 1: re-run grub2 + shim scriptlets in clean chroot
dnf reinstall -y grub2-efi-x64 grub2-efi-x64-modules grub2-pc grub2-pc-modules grub2-tools grub2-tools-extra shim-x64 efibootmgr 2>&1 | tail -10 || \
    echo "[WARN] dnf reinstall of grub stack failed"

# Step 2: install grub to ESP (UEFI path, primary)
mkdir -p /boot/efi/EFI/fedora
grub2-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=fedora --no-nvram 2>&1 | tail -5 || \
    echo "[WARN] grub2-install (efi) failed"

# Step 3: write the EFI grub.cfg stub (what gen_grub_cfgstub would have done)
if command -v gen_grub_cfgstub >/dev/null 2>&1; then
    gen_grub_cfgstub /boot/grub2 /boot/efi/EFI/fedora 2>&1 | tail -5 || \
        echo "[WARN] gen_grub_cfgstub failed (will fall back to grub2-mkconfig)"
fi
# Always also write a full grub.cfg in /boot/grub2 (BLS source) and
# regenerate the EFI cfg via grub2-mkconfig for redundancy
grub2-mkconfig -o /boot/grub2/grub.cfg 2>&1 | tail -3
[ -f /boot/efi/EFI/fedora/grub.cfg ] || \
    grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg 2>&1 | tail -3

# Step 4: register NVRAM entry
if [ -n "$EFI_DISK" ] && [ -e "$EFI_DISK" ]; then
    EFI_PART_NUM=$(findmnt -n -o SOURCE /boot/efi | grep -oE '[0-9]+$')
    [ -n "$EFI_PART_NUM" ] && \
        efibootmgr -c -d "$EFI_DISK" -p "$EFI_PART_NUM" -L "veilor-os" -l '\EFI\fedora\shimx64.efi' 2>&1 | tail -3 || \
        echo "[WARN] efibootmgr failed (NVRAM may already be set)"
fi

echo "[INFO] bootloader install: see above for any [WARN] lines"
# NOTE: deliberately NOT `set -e` here. The block above opened with
# `set +e` and the rest of %post is a sequence of best-effort hardening
# steps that have local `|| true` guards on the operations that may
# legitimately fail. Re-enabling errexit would cause `set -e` to abort
# the whole %post on the first non-guarded command (e.g. a `grep -q`
# returning 1). v0.5.30 had this bug and it silently truncated
# the LUKS args injection.

# GRUB branding: replace fedora distributor with veilor-os in menu titles.
# Drop rhgb quiet from default cmdline → all kernel/systemd messages
# visible. Plymouth `details` theme shows scrolling text (no splash, no
# logo). LUKS prompt rendered as text. Onyx-style boot.
sed -i \
    -e 's|^GRUB_DISTRIBUTOR=.*|GRUB_DISTRIBUTOR="veilor-os"|' \
    -e 's|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=""|' \
    /etc/default/grub 2>/dev/null || true

# Ensure rd.luks.uuid + rd.luks.name in cmdline. Anaconda --cmdline mode
# does not auto-add the LUKS args when partitioning is custom + encrypted
# manually; without rd.luks.uuid in cmdline, dracut's cryptsetup-generator
# never spawns the unlock unit, plymouth has no password to ask for, and
# dracut-initqueue loops on dev-disk-by-uuid for ~3min before dropping
# to emergency shell. Detect the LUKS partition + inject explicitly.
#
# Two write paths because Fedora 43 uses BLS (Boot Loader Specification):
#   1. /etc/default/grub  — read by `grub2-mkconfig` for the legacy
#                           grub.cfg menu, AND used as the source of
#                           truth for `kernel-install` when adding new
#                           kernels later.
#   2. /boot/loader/entries/*.conf  — the actual per-kernel BLS entries
#                                     that grub reads for cmdline. These
#                                     are NOT regenerated by
#                                     `grub2-mkconfig`; they are managed
#                                     by `grubby` (or `kernel-install`).
# Without both, the running kernel boots without rd.luks.uuid and the
# user lands in emergency shell on first boot.
LUKS_UUID=$(blkid -t TYPE=crypto_LUKS -o value -s UUID 2>/dev/null | head -1)
if [ -n "$LUKS_UUID" ]; then
    LUKS_ARGS="rd.luks.uuid=luks-${LUKS_UUID} rd.luks.options=luks-${LUKS_UUID}=tries=5,timeout=0"
    HARDEN_ARGS="lockdown=integrity slab_nomerge init_on_alloc=1 init_on_free=1 randomize_kstack_offset=on vsyscall=none fbcon=nodefer"

    # Find the running root UUID (the btrfs filesystem holding the root
    # subvol). At this point in %post chroot, `/` is the target root;
    # findmnt -o UUID resolves to the btrfs UUID anaconda chose.
    ROOT_UUID=$(findmnt -n -o UUID /)
    [ -z "$ROOT_UUID" ] && ROOT_UUID=$(blkid -s UUID -o value /dev/mapper/luks-${LUKS_UUID} 2>/dev/null)

    # Three write paths, in priority order:
    #
    #   Path A: /etc/kernel/cmdline (the canonical source of truth for
    #     `kernel-install`). Per /usr/lib/kernel/install.d/90-loaderentry.install
    #     lines 84-95, kernel-install reads /etc/kernel/cmdline first when
    #     authoring BLS entries. If we write this BEFORE re-running
    #     kernel-install, every BLS entry inherits our args. Persists
    #     across `dnf upgrade kernel`, `dnf reinstall grub2-*`, and any
    #     other path that re-fires kernel-install hooks.
    #
    #   Path B: /etc/default/grub (legacy GRUB_CMDLINE_LINUX). Read by
    #     `grub2-mkconfig` for the generated grub.cfg. Belt-and-braces;
    #     kernel-install ignores this, but grub2-mkconfig respects it.
    #
    #   Path C: grubby --update-kernel=ALL. Direct edit to BLS option
    #     lines. Acts as the last-writer in case our cmdline write didn't
    #     trigger a fresh kernel-install pass.
    #
    # Earlier veilor-os versions only used B+C. v0.5.31 adds Path A as
    # the primary, because v0.5.30 testing showed B+C are racy with
    # anaconda's own CreateBLSEntriesTask which uses kernel-install
    # internally and can rewrite entries from empty /etc/kernel/cmdline,
    # producing options lines with no rd.luks.uuid even when grubby
    # successfully ran.

    # Path A
    mkdir -p /etc/kernel
    if [ -n "$ROOT_UUID" ]; then
        echo "root=UUID=${ROOT_UUID} ro rootflags=subvol=root ${LUKS_ARGS} ${HARDEN_ARGS}" > /etc/kernel/cmdline
        echo "[INFO] wrote /etc/kernel/cmdline (canonical kernel-install source)"
    else
        echo "[WARN] could not determine root UUID; /etc/kernel/cmdline not written"
    fi

    # Path B
    if ! grep -q "rd.luks.uuid" /etc/default/grub 2>/dev/null; then
        sed -i "s|^GRUB_CMDLINE_LINUX=\"|GRUB_CMDLINE_LINUX=\"${LUKS_ARGS} |" /etc/default/grub
    fi

    # Re-run kernel-install for every kernel — picks up new /etc/kernel/cmdline,
    # rewrites BLS entries with our args. This is the load-bearing step.
    for kver in /lib/modules/*/; do
        kver=$(basename "$kver")
        [ -f "/lib/modules/$kver/vmlinuz" ] || continue
        kernel-install add "$kver" "/lib/modules/$kver/vmlinuz" 2>&1 | tail -3 || \
            echo "[WARN] kernel-install add $kver failed"
    done

    # Path C: belt-and-braces grubby update in case kernel-install missed any
    grubby --update-kernel=ALL --args="${LUKS_ARGS}" 2>&1 | tail -5 || true

    # Verification: every BLS entry MUST carry the LUKS arg.
    drift=$(grep -L "rd.luks.uuid" /boot/loader/entries/*.conf 2>/dev/null)
    if [ -n "$drift" ]; then
        echo "[ERR] BLS entries missing rd.luks.uuid after all 3 paths: $drift"
    else
        echo "[OK] all BLS entries carry rd.luks.uuid"
    fi
fi

# Verify anaconda wrote /etc/crypttab for the LUKS device. anaconda's
# custom-partitioning code path normally does this for `--encrypted`
# part directives; if it didn't (edge case, F43+ regressions), write
# a minimal entry so systemd-cryptsetup-generator can find the device
# at boot from the BLS args alone.
if [ -n "$LUKS_UUID" ] && ! grep -q "$LUKS_UUID" /etc/crypttab 2>/dev/null; then
    echo "luks-${LUKS_UUID} UUID=${LUKS_UUID} none discard" >> /etc/crypttab
    echo "[INFO] wrote /etc/crypttab fallback entry"
fi

# Switch plymouth to text-only `details` theme (scrolling boot log, no
# graphics, no logo). Theme is built-in to plymouth package, no asset
# install needed. v0.6 will ship custom veilor-themed plymouth.
plymouth-set-default-theme details 2>/dev/null || true

# Force-include LUKS + plymouth modules in initramfs. dracut autodetects
# crypt+plymouth from the running config, but custom-partitioning %post
# runs before dracut sees stable LUKS state, and stale initramfs files
# from anaconda's pre-install kernel may persist. Belt-and-braces.
mkdir -p /etc/dracut.conf.d
cat > /etc/dracut.conf.d/10-veilor-luks.conf <<'DRACUTEOF'
# veilor-os: guarantee LUKS + plymouth modules in initramfs
add_dracutmodules+=" crypt systemd-cryptsetup plymouth "
DRACUTEOF

# Regenerate initramfs with new theme + dracut.conf.d picks. Remove
# stale initramfs first so the regen actually rewrites bytes.
rm -f /boot/initramfs-*.img 2>/dev/null || true
dracut --force --regenerate-all 2>&1 | tail -5 || true

# Verify cryptsetup landed in initramfs. If not, LUKS unlock is impossible
# and the user gets emergency shell on first boot. Surfacing this early.
KVER=$(ls /lib/modules | head -1)
if [ -n "$KVER" ] && [ -f "/boot/initramfs-${KVER}.img" ]; then
    if ! lsinitrd "/boot/initramfs-${KVER}.img" 2>/dev/null | grep -q cryptsetup; then
        echo "[ERR] cryptsetup not found in initramfs — LUKS unlock will fail"
    fi
fi

# Regen grub.cfg with new branding (anaconda already wrote one; replace).
grub2-mkconfig -o /boot/grub2/grub.cfg 2>/dev/null || true
[ -f /boot/efi/EFI/fedora/grub.cfg ] && \
    grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg 2>/dev/null || true

# Suppress the auto-generated "Fedora Linux 0-rescue-…" BLS entry. Fedora
# ships a rescue kernel image alongside every install; the BLS entry that
# points at it is created by `kernel-install` and shows up in GRUB as a
# second menu item. For a branded distro it's noisy + reveals "Fedora"
# in the menu. The rescue image itself is harmless to keep on disk.
# Match both `<machine-id>-0-rescue.conf` (current Fedora 43 layout) and
# `<machine-id>-0-rescue-<kver>.conf` (older layout). The earlier glob
# `*-0-rescue-*.conf` required a trailing hyphen and missed the new form.
rm -f /boot/loader/entries/*-0-rescue*.conf 2>/dev/null || true

# Hostname: default to "veilor" rather than the localhost-live / fedora
# fallback that anaconda writes. User can override post-install with
# `hostnamectl set-hostname`.
echo "veilor" > /etc/hostname

# /etc/machine-info — feeds `hostnamectl status` and some BLS title
# rendering paths. PRETTY_HOSTNAME is what GRUB titles read when
# GRUB_DISTRIBUTOR doesn't override (Fedora 43 BLS entry titles ignore
# GRUB_DISTRIBUTOR; this is the closest we can get without rewriting
# every entry's `title` line).
cat > /etc/machine-info <<'MACHINFO'
PRETTY_HOSTNAME="veilor-os"
MACHINFO

# Rewrite the `title` line of every remaining BLS entry to use our
# brand. `kernel-install` will write new entries with "Fedora Linux …"
# but the file we ship + every kernel update goes through grubby which
# preserves whatever title was last set.
for entry in /boot/loader/entries/*.conf; do
    [ -f "$entry" ] || continue
    # Pull kernel version out of the existing title (e.g. "6.17.1-300.fc43.x86_64")
    kver=$(awk '/^version / {print $2}' "$entry")
    sed -i "s|^title .*|title veilor-os (${kver})|" "$entry"
done

# Symlink display-manager.service → sddm.service. (Anaconda usually
# handles this when sddm is the only DM, but be explicit.)
ln -sf /usr/lib/systemd/system/sddm.service /etc/systemd/system/display-manager.service

# Real install boots straight to SDDM (NOT to TTY1 installer like live).
systemctl set-default graphical.target

# zram swap (no disk swap; keys never leak to platter)
cat > /etc/systemd/zram-generator.conf << 'EOF'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
EOF

# Enable services
systemctl enable veilor-firstboot.service 2>/dev/null || true
systemctl enable veilor-modules-lock.service 2>/dev/null || true
systemctl enable sshd fail2ban usbguard tuned auditd firewalld chronyd sddm

# Default tuned profile = balanced (AC/battery udev rule will override)
tuned-adm profile veilor-balanced 2>/dev/null || true

# Force admin to set a fresh password on first login. The `user` directive
# above already created the account with the user's chosen password —
# `chage -d 0` expires it so SDDM/passwd prompts for change immediately.
chage -d 0 admin 2>/dev/null || true

# Lock root explicitly (kickstart --lock should already do this)
passwd -l root

# Sanity: zero references to onyx / personal IPs in installed system
if grep -rqi 'onyx\|192\.168\.0\.\|fedora\.local' /etc/veilor* /etc/tuned/profiles/veilor-* 2>/dev/null; then
    echo "[ERR] brand leak detected in /etc — investigate"
fi

echo "════════════════════════════════════════════════════════"
echo " veilor-os install complete"
echo "════════════════════════════════════════════════════════"
%end

# Reboot when done
reboot
KSEOF
    # Substitute placeholders. Use | as sed delimiter. validate_pw()
    # already rejects "$\`&|/\n at input — sed_escape() is defence in
    # depth in case future code paths feed unsanitised values (e.g.
    # locale/hostname from a file, or a relaxed validator).
    # Detect cloud-init seed pubkey (if attached) → embed as anaconda
    # `sshkey --username=admin` directive. Adds key to admin's
    # authorized_keys at install time so SSH validation works first boot.
    # No seed = empty directive line (anaconda treats blank line as no-op).
    local seed_pubkey sshkey_directive=""
    seed_pubkey=$(detect_seed_pubkey 2>/dev/null) || true
    if [[ -n $seed_pubkey ]]; then
        echo "[INFO] using cloud-init seed pubkey for admin authorized_keys"
        # sshkey directive — quote pubkey, no shell meta in pubkeys.
        sshkey_directive="sshkey --username=admin \"${seed_pubkey}\""
    fi

    sed -i \
        -e "s|__LOCALE__|$(sed_escape "$SEL_LOCALE")|" \
        -e "s|__HOSTNAME__|$(sed_escape "$SEL_HOSTNAME")|" \
        -e "s|__DISK_BASENAME__|$(sed_escape "$disk_basename")|" \
        -e "s|__LUKS_PW__|$(sed_escape "$SEL_LUKS_PW")|" \
        -e "s|__ADMIN_PW__|$(sed_escape "$SEL_ADMIN_PW")|" \
        -e "s|__SSHKEY_DIRECTIVE__|$(sed_escape "$sshkey_directive")|" \
        "$out"
    echo "[INFO] generated kickstart at $out"
    return 0
}

_dump_logs_to_host() {
    # If a virtio-9p share tagged "hostlogs" was attached by run-vm.sh,
    # mount it and dump every anaconda + installer log into it. Runs on
    # success AND failure (called via trap). No-op on real hardware where
    # the 9p tag doesn't exist.
    if ! grep -qw 9p /proc/filesystems 2>/dev/null; then return 0; fi
    local m=/mnt/hostlogs
    mkdir -p "$m"
    mount -t 9p -o trans=virtio,version=9p2000.L hostlogs "$m" 2>/dev/null || return 0
    cp -af /tmp/anaconda.log         "$m/" 2>/dev/null || true
    cp -af /tmp/program.log          "$m/" 2>/dev/null || true
    cp -af /tmp/storage.log          "$m/" 2>/dev/null || true
    cp -af /tmp/packaging.log        "$m/" 2>/dev/null || true
    cp -af /tmp/dnf.log              "$m/" 2>/dev/null || true
    cp -af /tmp/dnf.librepo.log      "$m/" 2>/dev/null || true
    cp -af /tmp/anaconda-cmdline.log "$m/" 2>/dev/null || true
    cp -af /var/log/veilor-installer.log "$m/" 2>/dev/null || true
    cp -af /run/install/veilor-generated.ks "$m/" 2>/dev/null || true
    dmesg > "$m/dmesg.log" 2>/dev/null || true
    journalctl -b > "$m/journal.log" 2>/dev/null || true
    sync
    umount "$m" 2>/dev/null || true
}

run_install() {
    # Capture logs to host on every exit path (success, failure, ^C).
    trap _dump_logs_to_host EXIT
    # Anaconda env setup (see comments below).
    export LANG="${SEL_LOCALE:-en_GB.UTF-8}"
    export LC_ALL="$LANG"
    # LANG / LC_ALL: anaconda's keyboard.activate_keyboard() raises
    # AnacondaError: 'LANG' if missing. tty1 inherits no locale by default.
    # XDG_RUNTIME_DIR: anaconda's display.setup_display() probes
    # os.getenv("XDG_RUNTIME_DIR") + Wayland socket and crashes with
    # TypeError on None. We're running unattended so no display needed,
    # but env var still has to exist.
    export XDG_RUNTIME_DIR="/run/user/$(id -u)"
    mkdir -p "$XDG_RUNTIME_DIR"
    chmod 0700 "$XDG_RUNTIME_DIR"

    if [[ $TUI == gum ]]; then
        clear
        # gum spin runs anaconda as subprocess. --show-output forwards
        # stdout/stderr to /tmp/anaconda-cmdline.log so we can debug.
        # --title shows live-updating header. --spinner dot is minimal.
        local rc=0
        gum spin --spinner dot \
            --title "Installing veilor-os to $SEL_DISK · this takes 10-30 min · logs on tty2" \
            --show-output \
            -- bash -c 'anaconda --cmdline --kickstart=/run/install/veilor-generated.ks 2>&1 | tee /tmp/anaconda-cmdline.log' || rc=$?
        if [[ $rc -eq 0 ]]; then
            gum style --foreground 2 --border rounded --margin "1 2" --padding "1 3" \
                "✓ Install complete" \
                "" \
                "System will reboot in 5 seconds." \
                "Remove the install media."
            sleep 5
            systemctl reboot
        else
            prompt_error "Anaconda exited non-zero (status $rc).
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
            return 1
        fi
    else
        prompt_message "Installing veilor-os to $SEL_DISK ...
This will take 10-30 minutes.
Logs: /var/log/veilor-installer.log + /tmp/anaconda.log"
        sleep 1
        if anaconda --cmdline --kickstart=/run/install/veilor-generated.ks; then
            prompt_message "Install complete. System will reboot.
Remove the install media after shutdown."
            sleep 3
            systemctl reboot
        else
            prompt_error "Anaconda exited non-zero.
Logs at /tmp/anaconda.log + /var/log/veilor-installer.log.
Press OK to drop to shell."
            return 1
        fi
    fi
}

drop_to_shell() {
    clear
    cat << 'EOF'
═══════════════════════════════════════════════════
 veilor-os live shell
═══════════════════════════════════════════════════

You are in a live, in-memory environment.
Nothing persists across reboot.

Re-run the installer:    sudo veilor-installer
Reboot:                  sudo systemctl reboot
Power off:               sudo systemctl poweroff

EOF
    exec /bin/bash --login
}

launch_desktop() {
    clear
    echo "Launching KDE Plasma..."
    sleep 1
    systemctl isolate graphical.target
    # systemd-isolate switches target; sddm spawns on tty1.
    # If user logs out, they come back here. Loop continues.
}

# ── Entry ──
# (require_tty already called above before exec redirect — see top of file)
banner

while true; do
    case "$(main_menu)" in
        "Install")
            if collect_answers && generate_ks; then
                run_install || continue
            fi
            ;;
        "live · KDE") launch_desktop ;;
        "live · shell") drop_to_shell ;;
        "──────") banner ;;  # separator clicked: redraw, no-op
        "Reboot") systemctl reboot ;;
        "Power off") systemctl poweroff ;;
        "") banner ;;  # ESC/cancel from menu: redraw
        *) banner ;;
    esac
done
