#!/usr/bin/env bash
#
# nosignal.sh — one script: turn a stock Arch ISO into a graphical Arch desktop
# installer ISO. The installed system boots into SDDM (themed to match the
# desktop — layer changes 16+19; was greetd+tuigreet) and runs Hyprland,
# themed with the caelestia dotfiles
# (https://github.com/caelestia-dots/caelestia).
#
# Architecture (Phase 3 — FULLY OFFLINE): the ISO bundles EVERYTHING needed
# for the install. At build time, this script:
#   * builds the caelestia AUR packages (shell/CLI/meta + quickshell-git and
#     friends) in a devtools clean chroot on the build host,
#   * downloads the complete pacstrap dependency tree (including every GPU
#     driver variant and the prebuilt Limine snapshot tools from the omarchy
#     repo) into a local pacman repo,
#   * vendors the caelestia dotfiles + quickshell-overview as pinned tarballs,
# and places all of it on the ISO9660 (outside the squashfs, visible to the
# live environment at /run/archiso/bootmnt/nosignal). The installer pacstraps
# entirely from that file:// repo and lays down the whole desktop — dotfiles,
# theming, workspace overview, Btrfs+Limine snapshots — at INSTALL TIME in the
# chroot. No network is needed at install or on first boot: the machine boots
# straight into the themed SDDM greeter and a fully themed desktop. (The old
# first-boot build stage — paru bootstrap, nmtui gate, tty1 autologin — is gone.)
#
# Network IS needed on the BUILD host (AUR builds + repo download), and the
# Wi-Fi prompt remains in the installer as an OPTIONAL step purely so the
# credentials can be persisted into the installed system.
#
# NOTE: this is the *ISO builder*. It bundles and installs the upstream
# caelestia dotfiles; it is not affiliated with that project.
#
# Usage:
#   1. Drop a stock Arch ISO (https://archlinux.org/download/, file starts
#      with 'archlinux-') into the same folder as this script.
#   2. ./nosignal.sh
#   3. Output: nosignal-arch-YYYYMMDD.iso (Ventoy-compatible, dd-bootable).
#
# The offline payload is cached in ./offline/ — subsequent builds reuse the
# built AUR packages and previously downloaded repo packages. Delete that
# folder (or individual .built-* stamps in offline/aur/) to force a rebuild.
#
# Dependencies (build host): xorriso, squashfs-tools, git, sha512sum, sudo,
# devtools (mkarchroot/makechrootpkg), pacman-contrib (paccache).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WORK="$SCRIPT_DIR/work"
OFFLINE="$SCRIPT_DIR/offline"
INVOKING_USER="${SUDO_USER:-$(id -un)}"
INVOKING_GROUP="$(id -gn "$INVOKING_USER")"
OUT_ISO="$SCRIPT_DIR/nosignal-arch-$(date +%Y%m%d).iso"

# ---------------------------------------------------------------- pins ------
# The validated combination (VM-certified): caelestia dotfiles + the AUR
# release PKGBUILDs current at that commit, quickshell pinned to the commit
# the shell was validated against, and the overview sidecar pin.
CAELESTIA_DOTS_REPO="https://github.com/caelestia-dots/caelestia.git"
CAELESTIA_DOTS_COMMIT="757b7f26b8e637754ddd498671360dd6ba7e89ef"
OVERVIEW_REPO="https://github.com/Shanu-Kumawat/quickshell-overview.git"
OVERVIEW_COMMIT="74918ba66e0714017a6a5bfb9bf2affa66dabbc1"
QUICKSHELL_GIT_COMMIT="b66495f"   # short hash is fine for makepkg #commit=
# NoSignal's fork of the caelestia shell — we BUILD this instead of the upstream
# caelestia-shell so the interface/layout is under our control and never gets
# overwritten by an upstream update (the package provides/conflicts caelestia-shell
# and is pinned here, so pacman/yay never replaces it). Updates are deliberate:
# advance the fork's `nosignal` branch, bump the pin, rebuild. The fork's
# packaging/PKGBUILD builds the shell from this exact commit.
NOSIGNAL_SHELL_REPO="https://github.com/28allday/nosignal-shell.git"
NOSIGNAL_SHELL_COMMIT="0ae859311310661555dae089c91f2c0bfaaec10b"
# OS theme wallpapers — the user's own Moebius-style generated art (11 pieces,
# 2944x1648, repo-local in assets/wallpapers/; replaced the third-party Last
# Horizon set 2026-06-11, which also clears the redistribution-rights concern).
# The desktop palette is generated FROM the default wallpaper by caelestia's
# dynamic Material scheme at install time.
WALLPAPERS_DIR="$SCRIPT_DIR/assets/wallpapers"
# foam-sea since change 18 (was wanderer.png). Build-time default only — no
# migration, so existing installs keep whatever the user picked.
DEFAULT_WALLPAPER="foam-sea.png"
# omarchy-send release binary (LocalSend-compatible LAN file transfer TUI).
OMARCHY_SEND_REPO="28allday/omarchy-send"
OMARCHY_SEND_VERSION="v0.1.11"
# NoSignal layer — the "os updates" round developed and live-tested on real
# hardware (keybind cheatsheet, bash default shell, nosignal-update, yay+Shelly
# store, Omarchy keybindings, omadots polish, hyprmoncfg, settings Updates
# page, system polish). The tree is vendored verbatim onto the ISO and baked
# into ~/.local/share/nosignal at install time. Dev-only docs (changelog,
# builder/test notes) are kept unshipped in the repo's dev-docs/ folder.
NOSIGNAL_LAYER_DIR="$SCRIPT_DIR/os updates"
# LazyVim starter (omadots' nvim setup, change 6) — pinned, .git stripped.
LAZYVIM_REPO="https://github.com/LazyVim/starter"
LAZYVIM_COMMIT="803bc181d7c0d6d5eeba9274d9be49b287294d99"
# Flathub remote definition (change 4). The .flatpakrepo file embeds the GPG
# key, so `flatpak remote-add` from it works OFFLINE in the install chroot.
FLATHUB_REPO_URL="https://dl.flathub.org/repo/flathub.flatpakrepo"

# ----------------------------------------------------- package manifests ----
# BASE_PKGS is the single source of truth for the non-GPU pacstrap set: it is
# written to the ISO as /nosignal/base-packages.list and read back by the
# installer (so the download closure and the installed set can never drift).
BASE_PKGS=(
  base linux linux-firmware intel-ucode amd-ucode
  networkmanager openssh sudo git curl wget vim less base-devel
  limine efibootmgr zram-generator
  btrfs-progs snapper snap-pac
  pipewire pipewire-pulse pipewire-alsa pipewire-jack wireplumber
  polkit hyprpolkitagent qt5-wayland qt6-wayland xdg-user-dirs xdg-utils
  hyprland uwsm xdg-desktop-portal-hyprland xdg-desktop-portal-gtk
  hyprpicker hyprlock hypridle hyprsunset wl-clipboard cliphist inotify-tools
  grim slurp swappy brightnessctl playerctl pavucontrol
  # Display manager: SDDM (layer change 16 — DeckShift's desktop<->gaming
  # session switching rewrites SDDM config; greetd/tuigreet couldn't do this).
  # xorg-server is for SDDM's X11 GREETER only (DisplayServer=x11, the
  # reliable path) — the Hyprland session itself stays Wayland.
  sddm xorg-server plymouth
  # NB: fish must STAY even though bash is the default shell (NoSignal layer
  # change 2) — caelestia-meta hard-depends on it. foot stays as a meta dep
  # too (its menu entries are hidden by the layer's system-polish change).
  foot fish fastfetch starship btop jq eza trash-cli
  adw-gtk-theme papirus-icon-theme ttf-jetbrains-mono-nerd
  ttf-material-symbols-variable ttf-cascadia-code-nerd
  noto-fonts noto-fonts-emoji
  kitty-terminfo foot-terminfo
  ufw
  caelestia-meta
  # default terminal (foot stays as caelestia-themed fallback)
  kitty
  # everyday apps
  chromium nautilus gvfs gvfs-mtp
  mpv imv papers gnome-calculator gnome-text-editor
  libreoffice-fresh
  # default music player (layer change 24): CLIAmp, prebuilt in [omarchy]
  # (no AUR build); pulls ffmpeg + yt-dlp into the closure
  cliamp
  # monitor layout/profiles TUI + hotplug daemon (writes the same
  # ~/.config/hypr/monitors.conf the base config sources; superseded
  # nwg-displays — keep a SINGLE writer of that file). AUR-built.
  hyprmoncfg
  # software installation story: yay (AUR helper, also used by
  # nosignal-update) + Shelly GUI store (both AUR-built) + flatpak backend
  flatpak archlinux-appstream-data
  yay-bin shelly-bin
  # NoSignal layer tooling: checkupdates (updates panel), desktop notify,
  # lspci for the Additions page's GPU-detected OBS encoders (change 25 —
  # present on the test boxes only as an incidental dep; make it explicit)
  pacman-contrib libnotify pciutils
  # omadots developer polish (starship/btop/eza already above)
  tmux neovim fzf bat zoxide
  # base default dev tooling (layer change 33): GitHub CLI (`gh`)
  github-cli
  # bluetooth
  bluez bluez-utils blueman
  # laptop: audio firmware + power profiles
  sof-firmware power-profiles-daemon
  # printing + network discovery
  cups system-config-printer avahi nss-mdns
  # GUI Wi-Fi fallback (non-CLI escape hatch beside the bar's Wi-Fi panel) +
  # GL/Vulkan info tools so `prime-run glxinfo`/`vulkaninfo` work for verifying
  # hybrid-GPU offload (hardware-test gap, 2026-06-20)
  nm-connection-editor mesa-utils vulkan-tools
)

# Every GPU driver variant goes into the offline repo; the installer detects
# the hardware and installs only the matching subset. KEEP IN SYNC with the
# GPU detection block inside write_installer below.
GPU_ALL_PKGS=(
  mesa vulkan-icd-loader
  nvidia-open-dkms nvidia-utils egl-wayland libva-nvidia-driver
  nvidia-settings linux-headers
  nvidia-prime                       # prime-run offload (hybrid laptops)
  vulkan-radeon
  vulkan-intel intel-media-driver
  qemu-guest-agent spice-vdagent
  vulkan-swrast
)

# Prebuilt binaries from the omarchy repo (skips their GraalVM AUR build).
LIMINE_TOOLS=(limine-snapper-sync limine-mkinitcpio-hook)

# AUR packages built in the clean chroot, in dependency order (each build can
# resolve the previously built ones through the local bootstrap repo).
# caelestia-meta is NOT here — it builds from the dotfiles clone on the host.
AUR_BUILD_ORDER=(
  app2unit libcava python-materialyoucolor ttf-rubik-vf qtengine
  quickshell-git caelestia-cli nosignal-shell
  # NoSignal layer round — independent of the caelestia chain (yay-bin and
  # shelly-bin repackage release binaries; hyprmoncfg ships a Go release)
  yay-bin shelly-bin hyprmoncfg
)

# Clean up the work dir on any exit (success, error, or Ctrl-C). 2>/dev/null
# is in case $WORK never got created (e.g. we exited before mkdir). The
# offline payload cache in $OFFLINE is deliberately NOT cleaned.
trap 'sudo rm -rf "$WORK" 2>/dev/null || true' EXIT

# ===========================================================================
# write_installer — emit the live-ISO installer to the given path.
# This function holds the script that runs on the *target* machine inside
# the live ISO (auto-launched on tty1). It is intentionally embedded here so
# this file is the only artifact needed to rebuild the installer ISO.
# ===========================================================================
write_installer() {
  cat > "$1" <<'__INSTALLER_PAYLOAD__'
#!/usr/bin/env bash
set -euo pipefail

MARKER="/tmp/.installer-ran"
if [ -f "$MARKER" ]; then
  echo
  echo "  Installer already ran this session."
  echo "  To retry: rm $MARKER && bash $0"
  echo
  exec /bin/bash
fi
touch "$MARKER"

# Mirror stdout/stderr to /tmp/installer.log. If the script aborts, the user
# can switch to tty2, log in as root, and `cat /tmp/installer.log` to see the
# last "==> ..." marker reached and any error printed by the failing command.
exec > >(tee /tmp/installer.log) 2>&1

if [ ! -d /sys/firmware/efi ]; then
  echo "ERROR: not booted in UEFI mode. UEFI-only installer." >&2
  exec /bin/bash
fi

clear

# --- styled UI helpers (pure bash, no extra packages). They draw to /dev/tty
# directly so the arrow-key redraws never spam the tee'd installer.log. The UI
# is centre-aligned to the console width. --------------------------------------
NSI_G=$'\033[38;5;150m'       # NoSignal green
NSI_GB=$'\033[1;38;5;150m'    # bold green
NSI_DIM=$'\033[0;90m'         # dim/grey
NSI_B=$'\033[1m'
NSI_R=$'\033[0m'

# Console width (from the real console — stdout is tee'd to a pipe). Default 80.
nsi_cols() {
  local c; c=$(stty size </dev/tty 2>/dev/null | awk '{print $2}')
  case "$c" in ''|*[!0-9]*) c=80 ;; esac
  [ "$c" -ge 20 ] 2>/dev/null || c=80
  printf '%s' "$c"
}
# Left-pad string for centring a block of visible width $1.
nsi_pad() {
  local cols p; cols=$(nsi_cols); p=$(( (cols - $1) / 2 )); [ "$p" -lt 0 ] && p=0
  printf '%*s' "$p" ''
}
# Centred line of PLAIN text. $2=colour code (optional), $3=reset (optional).
cecho() { printf '%s%s%s%s\n' "$(nsi_pad "${#1}")" "${2:-}" "$1" "${3:-}"; }
# Centred read into VAR: cread VAR "prompt". csecret = silent (passwords).
cread()   { printf '%s' "$(nsi_pad "${#2}")" >/dev/tty; read -rp  "$2" "$1" </dev/tty; }
csecret() { printf '%s' "$(nsi_pad "${#2}")" >/dev/tty; read -rsp "$2" "$1" </dev/tty; echo; }

# tui_menu "Title" "opt1" "opt2" ... -> sets MENU_CHOICE (0-based index) + MENU_VALUE.
# Navigate with ↑/↓ (or j/k), Enter selects. Centre-aligned as a block.
tui_menu() {
  local title="$1"; shift
  local -a opts=("$@")
  local n=${#opts[@]} sel=0 first=1 i key rest o w
  local lines=$(( n + 3 ))    # title + blank + n options + hint
  local hint='↑/↓ move · Enter select'
  # Block width = widest of title, "❯ "+option, hint — then centre that block.
  local maxw=${#title}
  for o in "${opts[@]}"; do w=$(( ${#o} + 2 )); [ "$w" -gt "$maxw" ] && maxw=$w; done
  [ "${#hint}" -gt "$maxw" ] && maxw=${#hint}
  local pad; pad=$(nsi_pad "$maxw")
  printf '\033[?25l' >/dev/tty
  while true; do
    if [ "$first" -eq 1 ]; then first=0; else printf '\033[%dA' "$lines" >/dev/tty; fi
    {
      printf '\r\033[2K%s%s%s%s\n\r\033[2K\n' "$pad" "$NSI_GB" "$title" "$NSI_R"
      for i in "${!opts[@]}"; do
        if [ "$i" -eq "$sel" ]; then
          printf '\r\033[2K%s%s❯%s %s%s%s\n' "$pad" "$NSI_GB" "$NSI_R" "$NSI_B" "${opts[$i]}" "$NSI_R"
        else
          printf '\r\033[2K%s  %s%s%s\n' "$pad" "$NSI_DIM" "${opts[$i]}" "$NSI_R"
        fi
      done
      printf '\r\033[2K%s%s%s%s\n' "$pad" "$NSI_DIM" "$hint" "$NSI_R"
    } >/dev/tty
    IFS= read -rsn1 key </dev/tty || true
    case "$key" in
      $'\033')
        IFS= read -rsn2 -t 0.01 rest </dev/tty || true
        case "$rest" in
          '[A'|'OA') sel=$(( (sel - 1 + n) % n )) ;;
          '[B'|'OB') sel=$(( (sel + 1) % n )) ;;
        esac ;;
      k) sel=$(( (sel - 1 + n) % n )) ;;
      j) sel=$(( (sel + 1) % n )) ;;
      '') break ;;
    esac
  done
  printf '\033[?25h' >/dev/tty
  MENU_CHOICE=$sel
  MENU_VALUE="${opts[$sel]}"
}

# Install-progress UI. During the heavy install phase ALL command output is
# redirected to /tmp/installer.log (off-screen) and the user sees only a single
# CENTRED spinner + the current phase name on /dev/tty — calm and consistent
# with the centred menus, no scrolling formatting/build-hook spam. Full detail
# stays in the log (Ctrl+Alt+F2 -> cat /tmp/installer.log).
NSI_PHASE_FILE=/tmp/nsi-phase
NSI_SPIN_PID=""
# Centred line drawn straight to the real console (stdout is the log mid-install).
tcecho() { printf '%s%s%s%s\n' "$(nsi_pad "${#1}")" "${2:-}" "$1" "${3:-}" >/dev/tty; }
# Set the phase label the spinner shows.
nsi_phase() { printf '%s' "$1" > "$NSI_PHASE_FILE" 2>/dev/null || true; }
nsi_spin_start() {
  printf '\033[?25l' >/dev/tty   # hide cursor
  (
    set +e
    local frames='/-\|' i=0 msg vis pad
    while true; do
      msg=$(cat "$NSI_PHASE_FILE" 2>/dev/null); [ -n "$msg" ] || msg="Working"
      vis=$(( ${#msg} + 5 ))     # frame + 2 spaces + msg + ellipsis
      pad=$(nsi_pad "$vis")
      printf '\r\033[2K%s%s%s%s  %s%s…%s' \
        "$pad" "$NSI_GB" "${frames:i%4:1}" "$NSI_R" "$NSI_B" "$msg" "$NSI_R" >/dev/tty
      i=$((i+1)); sleep 0.15
    done
  ) &
  NSI_SPIN_PID=$!
}
nsi_spin_stop() {
  [ -n "$NSI_SPIN_PID" ] && { kill "$NSI_SPIN_PID" 2>/dev/null || true; wait "$NSI_SPIN_PID" 2>/dev/null || true; NSI_SPIN_PID=""; }
  printf '\r\033[2K\033[?25h' >/dev/tty   # clear the spinner line, show cursor
}
# ERR trap during install: stop the spinner, restore on-screen output, and show
# which phase failed + the tail of the log (full detail on tty2).
nsi_fail() {
  trap - ERR
  nsi_spin_stop
  exec >/dev/tty 2>&1
  echo
  cecho "Install FAILED at: $(cat "$NSI_PHASE_FILE" 2>/dev/null)" "$NSI_GB" "$NSI_R"
  cecho "See the log on tty2:  Ctrl+Alt+F2  →  cat /tmp/installer.log" "$NSI_DIM" "$NSI_R"
  echo
  tail -n 25 /tmp/installer.log 2>/dev/null
}

# Banner — centred as a block. ASCII + box-drawing + Latin-1 middot only: the
# Linux console font has no CJK glyphs, so the kana rendered as blocks and (being
# double-width) pushed the right border out. The 入力信号なし wordmark lives on
# the Plymouth splash (a PNG) instead. Borders are 46 cells; interior is 44.
BPAD=$(nsi_pad 46)
printf '%s%s%s\n'   "$BPAD" "$NSI_GB" "┌────────────────────────────────────────────┐"
printf '%s%s%s\n'   "$BPAD" "$NSI_GB" "│           NoSignal  ·  NO SIGNAL           │"
printf '%s%s%s\n'   "$BPAD" "$NSI_GB" "└────────────────────────────────────────────┘"
printf '%s'         "$NSI_R"
cecho "Arch · Hyprland · Caelestia · SDDM — fully offline installer" "$NSI_DIM" "$NSI_R"
echo

# ----------------------------------------------------- offline payload ------
# Everything installs from the package repo bundled on the install media.
# archiso's copytoram defaults to AUTO and fires on every non-optical boot
# (USB stick + enough RAM): the initramfs copies airootfs.sfs to RAM, then
# UNMOUNTS and rmdir's /run/archiso/bootmnt — taking /nosignal with it. CD
# boots are exempt, which is why VM -cdrom certification never caught it.
# Remount the install media by the UUID archiso itself booted from.
NOSIGNAL_PAYLOAD=/run/archiso/bootmnt/nosignal
if [ ! -f "$NOSIGNAL_PAYLOAD/repo/nosignal.db" ]; then
  cecho "Boot media unmounted by copytoram — remounting the offline repo..." "$NSI_DIM" "$NSI_R"
  iso_uuid=$(sed -n 's/.*archisosearchuuid=\([^ ]*\).*/\1/p' /proc/cmdline)
  iso_label=$(sed -n 's/.*archisolabel=\([^ ]*\).*/\1/p' /proc/cmdline)
  iso_dev=""
  for _ in $(seq 1 15); do
    if [ -n "$iso_uuid" ] && [ -e "/dev/disk/by-uuid/$iso_uuid" ]; then
      iso_dev="/dev/disk/by-uuid/$iso_uuid"; break
    fi
    if [ -n "$iso_label" ] && [ -e "/dev/disk/by-label/$iso_label" ]; then
      iso_dev="/dev/disk/by-label/$iso_label"; break
    fi
    sleep 1
  done
  if [ -n "$iso_dev" ]; then
    mkdir -p /run/archiso/bootmnt
    mount -o ro "$iso_dev" /run/archiso/bootmnt || true
  fi
fi
if [ ! -f "$NOSIGNAL_PAYLOAD/repo/nosignal.db" ] || [ ! -f "$NOSIGNAL_PAYLOAD/base-packages.list" ]; then
  echo "ERROR: offline package repo not found on the install media" >&2
  echo "       (expected $NOSIGNAL_PAYLOAD/repo). Bad burn or wrong ISO?" >&2
  exec /bin/bash
fi
# Strip comments/blank lines so the list file can be annotated.
mapfile -t BASE_PKGS < <(grep -vE '^\s*(#|$)' "$NOSIGNAL_PAYLOAD/base-packages.list")
cecho "Offline repo found (${#BASE_PKGS[@]} base packages)." "$NSI_DIM" "$NSI_R"
echo

# pacman config used for every install-time operation: ONLY the bundled repo.
# The installed system gets the normal online mirrors instead (written later).
# SigLevel Never: the bundled packages were verified when the ISO was BUILT
# (official sigs checked on download; AUR pkgs built locally) — the ISO is the
# trust boundary. Anything weaker fails: e.g. 'Optional TrustAll' still tries
# to VERIFY any signature embedded in the repo db and dies on the missing key.
cat > /tmp/nosignal-pacman.conf <<'OFFLINECONF'
[options]
Architecture = x86_64
SigLevel = Never

[nosignal]
Server = file:///run/archiso/bootmnt/nosignal/repo
OFFLINECONF

# ---------------------------------------------------------------- network ----
# The install is fully offline — no network is needed here or on first boot.
# Wired boxes DHCP automatically via NetworkManager; Wi-Fi users connect from
# the desktop's Wi-Fi panel (click-to-connect) or nm-connection-editor on first
# boot. So the installer asks for nothing here.

# ---------------------------------------------------------------- prompts ----
while true; do
  cread HOSTNAME "Hostname [nosignal]: "
  HOSTNAME="${HOSTNAME:-nosignal}"
  [[ "$HOSTNAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}$ ]] && break
  cecho "Invalid (RFC 1123: letters/digits/hyphen, no leading hyphen, max 63 chars)."
done

while true; do
  cread USERNAME "Username: "
  [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] && break
  cecho "Invalid (lowercase, starts a-z or _, max 31 chars)."
done

while true; do
  csecret PW1 "Password for $USERNAME (also used for root): "
  csecret PW2 "Confirm password: "
  if [ -z "$PW1" ]; then cecho "Empty — try again."; continue; fi
  if [ "$PW1" != "$PW2" ]; then cecho "Mismatch — try again."; continue; fi
  break
done
USER_PW="$PW1"
echo

# Localisation — pick a region from a curated list; it sets the timezone, locale,
# console keymap and Hyprland xkb layout together (no more three cryptic prompts).
# "Other" drops to the search-based prompts for anything not listed. The curated
# values are all standard tzdata/locale.gen/kbd names present on the ISO.
tui_menu "Where are you?  (sets time zone, language & keyboard)" \
  "United Kingdom" \
  "Ireland" \
  "United States" \
  "Canada" \
  "Australia" \
  "Germany" \
  "France" \
  "Spain" \
  "Italy" \
  "Netherlands" \
  "Other (advanced — type & search)"
case "$MENU_VALUE" in
  "United Kingdom") TIMEZONE=Europe/London;    LOCALE=en_GB.UTF-8; KEYMAP=uk;        XKB_LAYOUT=gb ;;
  "Ireland")        TIMEZONE=Europe/Dublin;    LOCALE=en_IE.UTF-8; KEYMAP=uk;        XKB_LAYOUT=gb ;;
  "United States")  TIMEZONE=America/New_York; LOCALE=en_US.UTF-8; KEYMAP=us;        XKB_LAYOUT=us ;;
  "Canada")         TIMEZONE=America/Toronto;  LOCALE=en_CA.UTF-8; KEYMAP=us;        XKB_LAYOUT=us ;;
  "Australia")      TIMEZONE=Australia/Sydney; LOCALE=en_AU.UTF-8; KEYMAP=us;        XKB_LAYOUT=us ;;
  "Germany")        TIMEZONE=Europe/Berlin;    LOCALE=de_DE.UTF-8; KEYMAP=de-latin1; XKB_LAYOUT=de ;;
  "France")         TIMEZONE=Europe/Paris;     LOCALE=fr_FR.UTF-8; KEYMAP=fr-latin1; XKB_LAYOUT=fr ;;
  "Spain")          TIMEZONE=Europe/Madrid;    LOCALE=es_ES.UTF-8; KEYMAP=es;        XKB_LAYOUT=es ;;
  "Italy")          TIMEZONE=Europe/Rome;      LOCALE=it_IT.UTF-8; KEYMAP=it;        XKB_LAYOUT=it ;;
  "Netherlands")    TIMEZONE=Europe/Amsterdam; LOCALE=nl_NL.UTF-8; KEYMAP=us;        XKB_LAYOUT=us ;;
  *)
    # Other (advanced): the previous search-based prompts. Enter '?text' to search.
    while true; do
      cread TIMEZONE "Timezone [Europe/London] ('?text' searches): "
      TIMEZONE="${TIMEZONE:-Europe/London}"
      if [[ "$TIMEZONE" == \?* ]]; then
        timedatectl list-timezones 2>/dev/null | grep -i -- "${TIMEZONE#\?}" | head -25 | sed 's/^/    /'
        continue
      fi
      [ -f "/usr/share/zoneinfo/$TIMEZONE" ] && break
      cecho "Not found (e.g. Europe/London, America/New_York, Australia/Sydney)."
    done
    while true; do
      cread LOCALE "Locale [en_GB.UTF-8] ('?text' searches): "
      LOCALE="${LOCALE:-en_GB.UTF-8}"
      if [[ "$LOCALE" == \?* ]]; then
        grep -oE '^#?[^ ]+ ' /etc/locale.gen | tr -d '# ' | grep -i -- "${LOCALE#\?}" | head -25 | sed 's/^/    /'
        continue
      fi
      grep -oE '^#?[^ ]+ ' /etc/locale.gen | tr -d '# ' | grep -qxF "$LOCALE" && break
      cecho "Not found (e.g. en_GB.UTF-8, en_US.UTF-8, de_DE.UTF-8)."
    done
    while true; do
      cread KEYMAP "Console keymap [uk] ('?text' searches): "
      KEYMAP="${KEYMAP:-uk}"
      if [[ "$KEYMAP" == \?* ]]; then
        localectl list-keymaps 2>/dev/null | grep -i -- "${KEYMAP#\?}" | head -25 | sed 's/^/    /'
        continue
      fi
      localectl list-keymaps 2>/dev/null | grep -qxF "$KEYMAP" && break
      cecho "Not found (e.g. uk, us, de-latin1, fr-latin1)."
    done
    # Map the console keymap to an XKB layout for Hyprland's kb_layout. The names
    # usually agree (us, de, it, es, pl...); this table covers the common
    # exceptions, and the fallback strips variant suffixes (cz-qwertz -> cz).
    case "$KEYMAP" in
      uk) XKB_LAYOUT="gb" ;;
      fr-latin1|fr-latin9) XKB_LAYOUT="fr" ;;
      pt-latin1|pt-latin9) XKB_LAYOUT="pt" ;;
      br-abnt2) XKB_LAYOUT="br" ;;
      la-latin1) XKB_LAYOUT="latam" ;;
      sv-latin1) XKB_LAYOUT="se" ;;
      sg|sg-latin1) XKB_LAYOUT="ch" ;;
      dvorak*) XKB_LAYOUT="us" ;;
      *) XKB_LAYOUT="${KEYMAP%%[-.]*}" ;;
    esac
    ;;
esac
cecho "Region set: ${TIMEZONE} · ${LOCALE} · keymap ${KEYMAP}" "$NSI_DIM" "$NSI_R"

# Disk — arrow-key pick from the real disks, then a plain Yes/No confirm
# (defaults to No). No more typing the device path.
echo
mapfile -t DISKS < <(lsblk -d -n -p -o NAME,SIZE,MODEL | grep -Ev '/dev/(loop|sr|zram|fd)')
if [ "${#DISKS[@]}" -eq 0 ]; then
  cecho "No disks found. Dropping to shell."
  exec /bin/bash
fi
while true; do
  DISK_LABELS=()
  for d in "${DISKS[@]}"; do
    DISK_LABELS+=("$(awk '{name=$1; size=$2; $1=""; $2=""; sub(/^[[:space:]]+/,""); printf "%-16s %-9s %s", name, size, $0}' <<<"$d")")
  done
  tui_menu "Install to which disk?  (everything on it is erased)" "${DISK_LABELS[@]}"
  DISK=$(awk '{print $1}' <<<"${DISKS[$MENU_CHOICE]}")
  tui_menu "Erase $DISK and install NoSignal?" \
    "No — choose a different disk" \
    "Yes — ERASE $DISK and install"
  [ "$MENU_CHOICE" -eq 1 ] && break
done

if [[ "$DISK" =~ nvme|mmcblk ]]; then
  EFI_PART="${DISK}p1"; ROOT_PART="${DISK}p2"
else
  EFI_PART="${DISK}1"; ROOT_PART="${DISK}2"
fi

# ---------------------------------------------------------------- install ----
# Go quiet + centred for the heavy phase: the prompts are done, so hide every
# command's output to the log and show a single centred spinner with the current
# phase name (consistent with the centred menus, no formatting/build-hook spam).
clear
# Silence kernel printk on the console — disk format/mount triggers harmless
# btrfs/ext4 probe messages ("VFS: Can't find ext4 filesystem") that write
# straight to the VT and would splatter over the clean spinner. (dmesg keeps
# them; this only stops console printing. Not restored — a reboot follows.)
dmesg -D 2>/dev/null || echo 1 > /proc/sys/kernel/printk 2>/dev/null || true
tcecho "Installing NoSignal" "$NSI_GB" "$NSI_R"
tcecho "this takes a few minutes — sit tight" "$NSI_DIM" "$NSI_R"
printf '\n' >/dev/tty
nsi_phase "Preparing the disk"
exec >>/tmp/installer.log 2>&1
trap nsi_fail ERR
nsi_spin_start
echo "==> Wiping $DISK..."
swapoff -a || true
umount -R /mnt 2>/dev/null || true
wipefs -af "$DISK"
sgdisk --zap-all "$DISK"

echo "==> Partitioning (1G EFI + rest btrfs)..."
# 1 GiB ESP (was 512M): UKIs are self-contained kernel+initramfs+microcode, and
# the NVIDIA UKI is ~139 MB (driver + GSP firmware). With ENABLE_LIMINE_FALLBACK
# (a 2nd UKI) plus multiple kernel versions, a 512 MB ESP can fill and UKI writes
# then fail (finding F4 secondary risk). 1 GiB is safe headroom on every GPU.
sgdisk -n1:0:+1G -t1:ef00 -c1:EFI "$DISK"
sgdisk -n2:0:0     -t2:8300 -c2:ROOT "$DISK"
partprobe "$DISK"
udevadm settle

echo "==> Formatting (btrfs root for Limine snapshots)..."
mkfs.fat -F32 -n EFI "$EFI_PART"
mkfs.btrfs -f -L ROOT "$ROOT_PART"

# Btrfs subvolume layout for snapshot/rollback (root-only snapshots):
#   @          -> /            (snapshotted + rolled back)
#   @home      -> /home        (separate, so a rollback never reverts user data)
#   @snapshots -> /.snapshots  (separate, so snapshots survive a rollback of @)
#   @log       -> /var/log     (separate, so logs survive a rollback — useful)
echo "==> Creating btrfs subvolumes..."
mount "$ROOT_PART" /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@snapshots
btrfs subvolume create /mnt/@log
umount /mnt

echo "==> Mounting subvolumes..."
BTRFS_OPTS="noatime,compress=zstd,space_cache=v2"
mount -o "$BTRFS_OPTS,subvol=@" "$ROOT_PART" /mnt
mkdir -p /mnt/boot /mnt/home /mnt/.snapshots /mnt/var/log
mount -o "$BTRFS_OPTS,subvol=@home"      "$ROOT_PART" /mnt/home
mount -o "$BTRFS_OPTS,subvol=@snapshots" "$ROOT_PART" /mnt/.snapshots
mount -o "$BTRFS_OPTS,subvol=@log"       "$ROOT_PART" /mnt/var/log
mount "$EFI_PART" /mnt/boot

# ------------------------------------------------------------ GPU drivers ----
# Hardware-detect the GPU(s) and pick driver packages, borrowing CachyOS chwd's
# method: scan PCI display-class controllers (class 0300/0302/0380) and map the
# vendor id (10de NVIDIA / 1002 AMD / 8086 Intel) to a driver set — plus the VM
# vendors so Hyprland comes up in a guest. Done natively (no chwd binary, no
# CachyOS repos / lib32 / cachyos-kernel deps) so the ISO stays vanilla Arch.
# Multiple GPUs union their package sets (e.g. an Intel/AMD iGPU + NVIDIA dGPU).
# ALL variants below are present in the offline repo (see GPU_ALL_PKGS in the
# builder — keep the two in sync).
echo "==> Detecting GPU(s)..."
GPU_NVIDIA=0; GPU_AMD=0; GPU_INTEL=0; GPU_VM=0
IGPU_PCI_ID=""   # vendor:device of the iGPU (for hybrid Vulkan device steering)
while IFS= read -r gpuline; do
  case "$gpuline" in
    *'[10de:'*) GPU_NVIDIA=1 ;;
    *'[1002:'*) GPU_AMD=1;   [ -z "$IGPU_PCI_ID" ] && IGPU_PCI_ID=$(printf '%s\n' "$gpuline" | grep -oE '1002:[0-9a-fA-F]{4}' | head -1) ;;
    *'[8086:'*) GPU_INTEL=1; [ -z "$IGPU_PCI_ID" ] && IGPU_PCI_ID=$(printf '%s\n' "$gpuline" | grep -oE '8086:[0-9a-fA-F]{4}' | head -1) ;;
    *'[1af4:'*|*'[1b36:'*|*'[1234:'*|*'[15ad:'*|*'[80ee:'*) GPU_VM=1 ;;
  esac
done < <(lspci -nn 2>/dev/null | grep -Ei 'VGA compatible controller|3D controller|Display controller')

# Hybrid-laptop awareness. An iGPU (Intel/AMD) present ALONGSIDE an NVIDIA dGPU on
# a LAPTOP is almost always muxless Optimus: the internal panel is wired to the
# iGPU and the dGPU is only a secondary render device. Forcing the NVIDIA GLX/
# VA-API globally — correct when the display hangs off the NVIDIA card (desktop,
# or a laptop MUX set to discrete) — then breaks/slows the iGPU-driven Wayland
# session and keeps the dGPU awake on battery. So on a hybrid laptop we keep the
# iGPU primary, ship prime-run for on-demand offload, and enable dGPU runtime
# power management, while STILL installing the NVIDIA driver + KMS so offload
# works. Desktops with NVIDIA present are unaffected (no laptop chassis/battery).
# Override the heuristic with NOSIGNAL_GPU_PRIMARY=nvidia|igpu|auto (default auto).
GPU_HYBRID_LAPTOP=0
IGPU_VAAPI=radeonsi
if [ "$GPU_NVIDIA" = 1 ] && { [ "$GPU_INTEL" = 1 ] || [ "$GPU_AMD" = 1 ]; }; then
  is_laptop=0
  ct=$(cat /sys/class/dmi/id/chassis_type 2>/dev/null || echo 0)
  case "$ct" in 8|9|10|11|14|30|31|32) is_laptop=1 ;; esac
  for b in /sys/class/power_supply/BAT*; do [ -e "$b" ] && is_laptop=1; done
  case "${NOSIGNAL_GPU_PRIMARY:-auto}" in
    igpu)   GPU_HYBRID_LAPTOP=1 ;;
    nvidia) GPU_HYBRID_LAPTOP=0 ;;
    *)      GPU_HYBRID_LAPTOP=$is_laptop ;;
  esac
  # iGPU VA-API backend (the panel-connected decoder): Intel -> iHD, AMD -> radeonsi.
  [ "$GPU_INTEL" = 1 ] && IGPU_VAAPI=iHD
fi

GPU_PKGS=(mesa vulkan-icd-loader)
GPU_SUMMARY=()
if [ "$GPU_NVIDIA" = 1 ]; then
  # Open-kernel-module NVIDIA (Turing/RTX+; the current default). DKMS so it
  # tracks the stock 'linux' kernel; linux-headers builds it during pacstrap.
  GPU_PKGS+=(nvidia-open-dkms nvidia-utils egl-wayland libva-nvidia-driver nvidia-settings linux-headers)
  GPU_SUMMARY+=("NVIDIA (nvidia-open-dkms + Wayland KMS)")
fi
if [ "$GPU_AMD" = 1 ]; then
  GPU_PKGS+=(vulkan-radeon)
  GPU_SUMMARY+=("AMD (mesa / vulkan-radeon)")
fi
if [ "$GPU_INTEL" = 1 ]; then
  GPU_PKGS+=(vulkan-intel intel-media-driver)
  GPU_SUMMARY+=("Intel (mesa / vulkan-intel)")
fi
if [ "$GPU_VM" = 1 ]; then
  GPU_PKGS+=(qemu-guest-agent spice-vdagent)
  GPU_SUMMARY+=("VM guest (virtio / qemu)")
fi
if [ "${#GPU_SUMMARY[@]}" -eq 0 ]; then
  # Nothing recognised — software rendering keeps Hyprland able to start.
  GPU_PKGS+=(vulkan-swrast)
  GPU_SUMMARY+=("unknown — software fallback (vulkan-swrast)")
fi
if [ "$GPU_HYBRID_LAPTOP" = 1 ]; then
  # prime-run for on-demand dGPU offload; iGPU stays primary (see env block below).
  GPU_PKGS+=(nvidia-prime)
  GPU_SUMMARY+=("hybrid laptop -> iGPU primary + NVIDIA PRIME offload (prime-run)")
fi
echo "    Driver target(s): ${GPU_SUMMARY[*]}"

nsi_phase "Installing the NoSignal desktop (this is the slow part)"
pacstrap -C /tmp/nosignal-pacman.conf -K /mnt "${BASE_PKGS[@]}" "${GPU_PKGS[@]}"

echo "==> Generating fstab..."
genfstab -U /mnt >> /mnt/etc/fstab

# F7: genfstab inherits the ESP's live mount options (fmask=0022,dmask=0022),
# which make /boot world-readable — exposing the bootloader random-seed and
# limine.conf (kernel cmdline, root PARTUUID, snapshot history) to any local
# user. systemd's bootctl flags this at every boot as a security hole. Tighten
# the vfat (ESP) line to root rw / group r / others none: files rw-r----- and
# dirs rwxr-x---. (Only the ESP is vfat here, so the vfat-line match is exact.)
sed -i -E '/[[:space:]]vfat[[:space:]]/{ s/fmask=[0-7]+/fmask=0137/; s/dmask=[0-7]+/dmask=0027/ }' /mnt/etc/fstab
if grep -qE '[[:space:]]vfat[[:space:]].*fmask=0137' /mnt/etc/fstab; then
  echo "    ESP hardened (fmask=0137,dmask=0027 — not world-readable)."
else
  echo "    WARNING: could not harden ESP mount perms in fstab — review /boot fmask/dmask."
fi

nsi_phase "Configuring the system"
echo "==> Configuring base system in chroot..."
# Localisation from the prompts (written from outside the chroot — simpler
# than threading variables through a quoted heredoc).
ln -sf "/usr/share/zoneinfo/$TIMEZONE" /mnt/etc/localtime
sed -i "s|^#${LOCALE} |${LOCALE} |" /mnt/etc/locale.gen
# en_US.UTF-8 as an always-present fallback locale.
sed -i 's|^#en_US.UTF-8 UTF-8|en_US.UTF-8 UTF-8|' /mnt/etc/locale.gen
echo "LANG=$LOCALE" > /mnt/etc/locale.conf
echo "KEYMAP=$KEYMAP" > /mnt/etc/vconsole.conf
# Resolve .local hostnames via Avahi (printers, other LAN boxes): insert
# mdns_minimal before the resolve/dns sources, the standard nss-mdns setup.
sed -i '/^hosts:/ s/resolve/mdns_minimal [NOTFOUND=return] resolve/' /mnt/etc/nsswitch.conf
arch-chroot /mnt /bin/bash <<'CHROOT'
set -e
hwclock --systohc
locale-gen
sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoers
systemctl enable NetworkManager.service
systemctl enable bluetooth.service
systemctl enable cups.socket
systemctl enable avahi-daemon.service
systemctl enable power-profiles-daemon.service
# Process sysusers so the 'sddm' system user exists (pacstrap doesn't run
# the package's sysusers.d automatically). Harmless if already present.
systemd-sysusers || true
CHROOT

# --------------------------------------------------------------- Plymouth ----
# Boot splash between Limine and the SDDM greeter: insert the `plymouth`
# initramfs hook right after the init manager hook and set the NoSignal theme
# (the "NO SIGNAL / 入力信号なし" logo centred on black). With the quiet/splash
# kernel cmdline, the boot shows that splash instead of scrolling kernel text.
echo "==> Configuring Plymouth (NoSignal boot splash)..."
# Ship the NoSignal Plymouth theme (vendored). Root dir is 'nosignal' ->
# /usr/share/plymouth/themes/nosignal. Must land BEFORE the initramfs rebuild
# below so the theme's images/script are embedded in the initramfs.
install -d -m 755 /mnt/usr/share/plymouth/themes
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/nosignal-plymouth.tar.gz" \
  -C /mnt/usr/share/plymouth/themes
# The plymouth hook goes right after the init manager hook. Arch's default
# mkinitcpio.conf now uses the `systemd` hook (not `udev`), so handle both:
# plymouth after `systemd` if present, else after `udev`. Idempotent.
if ! grep -qE '^HOOKS=.*\bplymouth\b' /mnt/etc/mkinitcpio.conf; then
  if grep -qE '^HOOKS=.*\bsystemd\b' /mnt/etc/mkinitcpio.conf; then
    sed -i '/^HOOKS=/ s/\bsystemd\b/systemd plymouth/' /mnt/etc/mkinitcpio.conf
  else
    sed -i '/^HOOKS=/ s/\budev\b/udev plymouth/' /mnt/etc/mkinitcpio.conf
  fi
fi
# Set NoSignal as the default theme (no -R: the mkinitcpio -P below rebuilds the
# initramfs and embeds it). Falls back to the built-in spinner if it can't apply.
arch-chroot /mnt plymouth-set-default-theme nosignal \
  || arch-chroot /mnt plymouth-set-default-theme spinner || true

# ------------------------------------------------------------- NVIDIA KMS ----
# Borrowed from chwd's pre_install hook: early-load the NVIDIA modules and drop
# the 'kms' hook (which would otherwise bind nouveau to the card), enable DRM
# modeset, and set the Wayland EGL/VA env. This is what actually lets Hyprland
# start on NVIDIA. No-op on AMD/Intel/VM-only systems.
if [ "$GPU_NVIDIA" = 1 ]; then
  echo "==> Configuring NVIDIA DRM/KMS for Wayland (chwd-style)..."
  install -d -m 755 /mnt/etc/mkinitcpio.conf.d
  cat > /mnt/etc/mkinitcpio.conf.d/10-nosignal-nvidia.conf <<'NV'
# Generated by NoSignal. Early-load the NVIDIA modules for KMS.
MODULES+=(nvidia nvidia_modeset nvidia_uvm nvidia_drm)
NV
  cat > /mnt/etc/mkinitcpio.conf.d/20-nosignal-nvidia-kms.conf <<'NVK'
# Generated by NoSignal. Drop the 'kms' hook so nouveau isn't pulled in early.
HOOKS=(${HOOKS[@]/kms/})
NVK
  cat > /mnt/etc/modprobe.d/nvidia.conf <<'NVMOD'
options nvidia_drm modeset=1 fbdev=1
NVMOD
  # System-wide EGL/VA env. caelestia symlinks ~/.config/hypr, so per-user hypr
  # env files would be clobbered — /etc/environment is read for the session.
  if [ "$GPU_HYBRID_LAPTOP" = 1 ]; then
    # Muxless Optimus: the iGPU drives the panel. Do NOT force NVIDIA GLX/VA-API
    # globally — that would push the whole session onto the dGPU. Leave GLX to the
    # iGPU, point VA-API at the iGPU decoder, and let apps opt into the dGPU with
    # `prime-run`. Enable dGPU runtime power management (D3) so it sleeps when idle
    # (Turing+; nvidia-open supports it — matches the nvidia-open-dkms gate).
    cat >> /mnt/etc/environment <<NVENVH
# --- NoSignal hybrid-laptop GPU env (iGPU primary; run dGPU apps with prime-run) ---
LIBVA_DRIVER_NAME=$IGPU_VAAPI
NVENVH
    # RTD3 Cause 2 (2026-06-20 hybrid battery finding): renderD128 is the NVIDIA
    # node (first/default), so naive Vulkan clients open the dGPU and pin it out of
    # runtime-D3. Steer mesa's default Vulkan device to the iGPU so only prime-run
    # apps touch the dGPU. prime-run still wins (it sets __NV_PRIME_RENDER_OFFLOAD
    # + __VK_LAYER_NV_optimus, which override this). GL/EGL clients on Wayland
    # already render on the compositor's iGPU; VA-API is pinned to the iGPU above.
    # NOTE: best-effort steering — the on-battery dGPU-sleep re-test is the gate.
    if [ -n "$IGPU_PCI_ID" ]; then
      cat >> /mnt/etc/environment <<NVENVS
MESA_VK_DEVICE_SELECT=$IGPU_PCI_ID
NVENVS
    fi
    cat > /mnt/etc/modprobe.d/nvidia-pm.conf <<'NVPM'
# Fine-grained dGPU runtime power management for Optimus battery life (Turing+).
options nvidia NVreg_DynamicPowerManagement=0x02
NVPM
    install -d -m 755 /mnt/etc/udev/rules.d
    cat > /mnt/etc/udev/rules.d/80-nosignal-nvidia-pm.rules <<'NVUDEV'
# Let the NVIDIA dGPU autosuspend when idle (runtime D3). VGA + 3D controllers.
#
# RTD3 Cause 1 (2026-06-20 hardware test, Acer AMD iGPU + RTX 3050): the nvidia
# driver binds the dGPU INSIDE the initramfs (mkinitcpio `kms` hook pulls it in
# even with MODULES=()), before real-root udevd exists. Coldplug then replays the
# device as ACTION=="add", which an ACTION=="bind" rule never matched, leaving
# power/control at the kernel default "on" (dGPU never reached D3, ~4 W idle).
# DRIVER=nvidia is already set at coldplug, so match the bound device with NO
# ACTION gate. Keep restoring "auto" on unbind for a later rebind/probe.
SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="auto"
SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="auto"
ACTION=="unbind", SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030000", TEST=="power/control", ATTR{power/control}="auto"
ACTION=="unbind", SUBSYSTEM=="pci", DRIVERS=="nvidia", ATTR{vendor}=="0x10de", ATTR{class}=="0x030200", TEST=="power/control", ATTR{power/control}="auto"
NVUDEV
  else
    # NVIDIA drives the display (desktop, or a laptop MUX set to discrete): force
    # the NVIDIA GLX/VA-API session-wide.
    cat >> /mnt/etc/environment <<'NVENV'
# --- NoSignal NVIDIA Wayland env ---
LIBVA_DRIVER_NAME=nvidia
__GLX_VENDOR_LIBRARY_NAME=nvidia
NVD_BACKEND=direct
NVENV
  fi
fi

# ---------------------------------------------------------- Acer power -------
# Acer Predator/Nitro laptops keep the dGPU's higher power states LOCKED until
# acer_wmi loads with predator_v4=1 — then the NitroSense/Turbo key cycles them
# (e.g. 35->40->50->60 W). NoSignal didn't set this out of the box, so the dGPU
# was capped low (hardware test 2026-06-20). Bake it in, gated on Acer hardware
# AND a kernel acer_wmi that accepts the option, so non-Acer machines are
# untouched. nvidia-powerd (Dynamic Boost) is enabled when an NVIDIA driver is
# present (the unit only exists then). Platform profile stays the Omarchy default
# (balanced) — the user can raise it. Source: 28allday/Acer-Power-Control-Omarchy.
SYS_VENDOR=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || echo "")
if printf '%s' "$SYS_VENDOR" | grep -qi acer \
   && modinfo -p acer_wmi 2>/dev/null | grep -q "^predator_v4:"; then
  echo "==> Acer laptop detected — unlocking Predator/Nitro GPU power (predator_v4=1)..."
  printf 'options acer_wmi predator_v4=1\n' > /mnt/etc/modprobe.d/acer-wmi.conf
  if [ "$GPU_NVIDIA" = 1 ]; then
    arch-chroot /mnt systemctl enable nvidia-powerd >/dev/null 2>&1 \
      && echo "    nvidia-powerd enabled (Dynamic Boost)" \
      || echo "    nvidia-powerd not enabled (optional)"
  fi
fi

nsi_phase "Installing the bootloader"
echo "==> Installing Limine bootloader (UEFI)..."
ROOT_PARTUUID=$(blkid -s PARTUUID -o value "$ROOT_PART")
# Root is the btrfs @ subvolume — Limine/kernel needs rootflags to mount it.
# The quiet/splash flags give a clean boot: Plymouth shows the NoSignal splash
# (NO SIGNAL logo on black) and the kernel/systemd "loading" text is suppressed,
# so the boot goes Limine -> NoSignal splash -> SDDM greeter with no scroll.
KERNEL_OPTS="root=PARTUUID=$ROOT_PARTUUID rw rootflags=subvol=@"
KERNEL_OPTS="$KERNEL_OPTS quiet splash loglevel=3 systemd.show_status=false rd.udev.log_level=3 vt.global_cursor_default=0"
# Disable zswap: the swap device is zram (already compressed RAM), so zswap in
# front of it would double-compress pages (zswap -> zram) and waste CPU.
KERNEL_OPTS="$KERNEL_OPTS zswap.enabled=0"
# NVIDIA needs DRM modeset on the cmdline too (belt-and-suspenders with the
# modprobe.d option) for Wayland/Hyprland.
[ "$GPU_NVIDIA" = 1 ] && KERNEL_OPTS="$KERNEL_OPTS nvidia_drm.modeset=1"

# /mnt/boot is the FAT32 ESP. Drop Limine's UEFI binary at BOTH the spec-defined
# vendor path (/EFI/limine) and the removable-media fallback (/EFI/BOOT/BOOTX64
# .EFI). The fallback is what makes the disk boot without an NVRAM entry — the
# efibootmgr call below often can't write NVRAM (chroot / VMs / locked firmware),
# so the fallback path is the reliable one; the NVRAM entry is a nicety.
install -d /mnt/boot/EFI/limine /mnt/boot/EFI/BOOT
cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/limine/BOOTX64.EFI
cp /mnt/usr/share/limine/BOOTX64.EFI /mnt/boot/EFI/BOOT/BOOTX64.EFI

# limine.conf lives on the ESP root; boot():/ resolves to that same ESP, where
# the kernel/ucode/initramfs all sit (since /boot IS the ESP). Microcode modules
# must precede the main initramfs.
# timeout: 10 — keep the Limine menu up 10s before auto-booting (user choice;
# the limine snapshot tooling only manages entries, it never rewrites this).
#
# F4 (dead-boot after a kernel update): with ENABLE_UKI=yes the first kernel
# update DELETES /boot/vmlinuz-linux + initramfs-linux.img (they're folded into
# the UKI). The OLD seed made the *first/default* manual entry `protocol: linux`
# pointing at those now-missing files, with no `default_entry:` — so once the
# UKI hook ran, the default auto-boot target dead-booted to a TTY. The previous
# post-install conversion mis-fired (ran once, no-op'd, marked applied), so the
# durable fix lives HERE in the seed:
#   * entry 1 = `protocol: efi` -> the UKI (boots once the UKI exists, which is
#     at install in the normal path, or at worst after the first kernel update).
#   * entry 2 = a `protocol: linux` fallback for the rare pre-UKI first boot
#     (snapshot/limine-update failed at install, so no UKI yet). Becomes a no-op
#     stub once the UKI exists; harmless, never the default.
#   * `default_entry: 1` pins the UKI entry as default regardless of the auto
#     entries limine appends below it on every kernel update (limine preserves
#     these manual entries + general directives verbatim across regenerations,
#     so this seed is durable — no per-update hook needed).
cat > /mnt/boot/limine.conf <<LIMINE
timeout: 10
default_entry: 1
interface_branding: NoSignal

/NoSignal (Arch Linux)
    protocol: efi
    path: boot():/EFI/Linux/nosignal_linux.efi
    cmdline: $KERNEL_OPTS

/NoSignal (fallback kernel)
    protocol: linux
    path: boot():/vmlinuz-linux
    cmdline: $KERNEL_OPTS
    module_path: boot():/intel-ucode.img
    module_path: boot():/amd-ucode.img
    module_path: boot():/initramfs-linux.img
LIMINE

# Best-effort NVRAM boot entry (ignored if firmware NVRAM isn't writable here).
arch-chroot /mnt efibootmgr --create --disk "$DISK" --part 1 \
  --loader '\EFI\limine\BOOTX64.EFI' --label 'NoSignal (Limine)' --unicode 2>/dev/null || true

# ------------------------------------------------- Btrfs + Limine snapshots ---
# Omarchy-style bootable snapshots, configured AT INSTALL TIME (this all lived
# in the first-boot script before Phase 3): limine-snapper-sync + the UKI-based
# limine-mkinitcpio-hook + snapper (root only). After this, every pacman
# transaction auto-snapshots (snap-pac), and each snapshot shows as a bootable
# entry in the Limine menu — pick one to roll back a bad update. Resilient: if
# any of this fails, the basic Limine boot set up above still works.
echo "==> Configuring Btrfs + Limine snapshots..."

# /etc/default/limine is read by the limine tools to build UKIs + boot entries.
# It must exist BEFORE the tools are installed/run. Carries the same cmdline.
cat > /mnt/etc/default/limine <<LIMINE_DEFAULT
TARGET_OS_NAME="NoSignal"
ESP_PATH="/boot"
KERNEL_CMDLINE[default]+="$KERNEL_OPTS"
ENABLE_UKI=yes
CUSTOM_UKI_NAME="nosignal"
ENABLE_LIMINE_FALLBACK=yes
FIND_BOOTLOADERS=yes
BOOT_ORDER="*, *fallback, Snapshots"
MAX_SNAPSHOT_ENTRIES=5
SNAPSHOT_FORMAT_CHOICE=5
LIMINE_DEFAULT

# btrfs-overlayfs initramfs hook — lets Limine boot a read-only snapshot.
# NB: written as a FULL HOOKS= line (current hooks + btrfs-overlayfs). The
# NVIDIA kms-strip override is 20-* so it still applies after this (10-*).
cur_hooks="$(grep '^HOOKS=' /mnt/etc/mkinitcpio.conf | sed 's/^HOOKS=(//; s/)$//')"
if ! grep -qw btrfs-overlayfs <<<"$cur_hooks"; then
  install -d -m 755 /mnt/etc/mkinitcpio.conf.d
  echo "HOOKS=($cur_hooks btrfs-overlayfs)" > /mnt/etc/mkinitcpio.conf.d/10-nosignal-btrfs.conf
fi

# The snapshot tools come PREBUILT from the omarchy repo (bundled in the
# offline repo) — installed in a second pacstrap now that their config exists.
# KEEP IN SYNC with LIMINE_TOOLS in the builder.
nsi_phase "Installing snapshot + boot tooling"
pacstrap -C /tmp/nosignal-pacman.conf /mnt limine-snapper-sync limine-mkinitcpio-hook

# snapper "root" config. /.snapshots is already a mounted @snapshots subvol,
# which collides with snapper create-config (it wants to make that subvol) —
# so do the documented dance: unmount, let snapper create its own, delete it,
# then remount our @snapshots. --no-dbus because there's no snapperd in chroot.
SNAPSHOTS_OK=0
if umount /mnt/.snapshots \
   && rm -rf /mnt/.snapshots \
   && arch-chroot /mnt snapper --no-dbus -c root create-config / \
   && arch-chroot /mnt btrfs subvolume delete /.snapshots \
   && mkdir /mnt/.snapshots \
   && mount -o "$BTRFS_OPTS,subvol=@snapshots" "$ROOT_PART" /mnt/.snapshots \
   && chmod 750 /mnt/.snapshots; then
  # Root-only, keep 5, no timeline (pre/post pacman snapshots via snap-pac).
  arch-chroot /mnt snapper --no-dbus -c root set-config NUMBER_LIMIT=5 NUMBER_LIMIT_IMPORTANT=5 TIMELINE_CREATE=no || true
  arch-chroot /mnt btrfs quota disable / 2>/dev/null || true   # qgroup accounting is a perf drag
  arch-chroot /mnt systemctl enable limine-snapper-sync.service || true
  SNAPSHOTS_OK=1
  echo "    Snapshots configured."
else
  echo "    snapper setup failed — snapshots disabled (basic Limine boot unaffected)."
fi

# Rebuild the initramfs once, now that Plymouth, any NVIDIA overrides and the
# btrfs-overlayfs hook are all in place — then let the limine tooling generate
# the UKIs and the final boot entries.
# Full path: limine-mkinitcpio-hook ships a /usr/local/bin/mkinitcpio wrapper
# that PROMPTS [Y/n] after -P ("run limine-mkinitcpio now?") — an interactive
# blocker mid-install. limine-update below regenerates the entries anyway.
nsi_phase "Building the boot image"
arch-chroot /mnt /usr/bin/mkinitcpio -P
if [ "$SNAPSHOTS_OK" = 1 ]; then
  if arch-chroot /mnt limine-update; then
    echo "    Limine UKI entries generated."
  else
    echo "    limine-update failed — basic Limine boot still works."
  fi
fi

# ---------------------------------------------------------------- firewall ---
# Default deny inbound, allow outbound (a desktop needs no open inbound ports).
# ufw in a chroot can't apply rules to netfilter and returns non-zero on some
# versions — tolerate that; ENABLED=yes in ufw.conf + the enabled service make
# the policy apply at boot.
echo "==> Configuring firewall (ufw)..."
arch-chroot /mnt ufw default deny incoming  || true
arch-chroot /mnt ufw default allow outgoing || true
# omarchy-send / LocalSend protocol (TCP transfers + UDP discovery).
arch-chroot /mnt ufw allow 53317 || true
# mDNS responses (Avahi: .local resolution, printer/device discovery).
arch-chroot /mnt ufw allow 5353/udp || true
sed -i 's/^ENABLED=no/ENABLED=yes/' /mnt/etc/ufw/ufw.conf 2>/dev/null || true
arch-chroot /mnt systemctl enable ufw.service || true

echo "==> Writing hostname / hosts..."
echo "$HOSTNAME" > /mnt/etc/hostname
cat > /mnt/etc/hosts <<HOSTS
127.0.0.1   localhost
::1         localhost
127.0.1.1   $HOSTNAME.localdomain $HOSTNAME
HOSTS

echo "==> Creating user $USERNAME..."
# video/audio/input groups for a desktop session; wheel for sudo.
arch-chroot /mnt useradd -m -G wheel,video,audio,input -s /bin/bash "$USERNAME"
printf 'root:%s\n%s:%s\n' "$USER_PW" "$USERNAME" "$USER_PW" | arch-chroot /mnt chpasswd

# ----------------------------------------------------------- ssh handoff ----
# SSH is OPTIONAL on a desktop. If the build host baked a public key we install
# it and enable sshd (handy for remote rescue / setup), opening 22 in ufw.
# Otherwise sshd stays disabled — this is a desktop, not a server.
if [ -f /root/master.pub ]; then
  echo "==> Installing master SSH key for $USERNAME + enabling sshd..."
  install -m 700 -d "/mnt/home/$USERNAME/.ssh"
  install -m 600 /root/master.pub "/mnt/home/$USERNAME/.ssh/authorized_keys"
  arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "/home/$USERNAME/.ssh"
  install -m 755 -d /mnt/etc/ssh/sshd_config.d
  cat > /mnt/etc/ssh/sshd_config.d/10-key-only.conf <<'SSHD'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
SSHD
  arch-chroot /mnt systemctl enable sshd.service || true
  arch-chroot /mnt ufw allow 22/tcp || true
else
  echo "    (no /root/master.pub on the ISO — sshd left disabled)"
fi

# ------------------------------------------------- online repos (post-boot) --
# The INSTALLED system uses the normal online mirrors, not the install media.
# The archiso live mirrorlist is reflector-generated only when online — on an
# offline install it can be empty/commented, so write a sane default the user
# can refine later. NB: nothing is synced yet; the user's first package
# operation should be `sudo pacman -Syu`.
echo "==> Writing pacman mirrorlist for the installed system..."
cat > /mnt/etc/pacman.d/mirrorlist <<'MIRROR'
## NoSignal default mirror (geo-routed). Refine with reflector if you like:
##   sudo reflector --latest 10 --sort rate --save /etc/pacman.d/mirrorlist
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
MIRROR

# The omarchy repo serves PREBUILT updates for the Limine snapshot tools
# (otherwise they rebuild via a ~530MB GraalVM toolchain from the AUR).
# NB: SigLevel is Optional TrustAll (unsigned) — same as Omarchy uses; it means
# trusting omarchy's binaries. Only the two limine-* packages come from it.
echo "==> Adding omarchy repo (prebuilt Limine snapshot tool updates)..."
cat >> /mnt/etc/pacman.conf <<'OMARCHY_REPO'

[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/$arch
OMARCHY_REPO

# Enable [multilib] (layer change 15 — gaming enablement): the lib32-* deps
# Steam/gaming installers (DeckShift) need live there. Uncomments ONLY the
# [multilib] block (leaves [multilib-testing] alone). Nothing is synced here —
# the install is offline; the user's first `pacman -Syu` pulls the db.
echo "==> Enabling [multilib] repo in the installed system..."
sed -i '/^#\[multilib\]/,/^#Include/ s/^#//' /mnt/etc/pacman.conf

# ---------------------------------------------------------- zram swap --------
# A desktop with NO swap OOM-kills heavy workloads; zram gives compressed
# in-RAM swap (no disk partition needed). zram-generator is a systemd
# generator: the package + this config auto-activate systemd-zram-setup@zram0
# at boot, no explicit enable needed.
echo "==> Configuring zram swap..."
cat > /mnt/etc/systemd/zram-generator.conf <<'ZRAM'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
ZRAM
chmod 644 /mnt/etc/systemd/zram-generator.conf

# ------------------------------------- CachyOS-derived performance tweaks ----
# Cherry-picked from CachyOS-Settings (github.com/CachyOS/CachyOS-Settings),
# same borrow-the-method approach as the chwd GPU detection: a curated subset
# of their sysctl + udev rules, vendored as plain text (no CachyOS repos or
# kernel). swappiness=100 is correct WITH zram (compressed swap is cheap).
echo "==> Applying CachyOS-derived performance tweaks..."
cat > /mnt/etc/sysctl.d/99-nosignal-performance.conf <<'SYSCTL'
# NoSignal — desktop responsiveness tweaks (curated from CachyOS-Settings).
kernel.nmi_watchdog = 0
kernel.split_lock_mitigate = 0
vm.max_map_count = 2147483642
vm.swappiness = 100
vm.vfs_cache_pressure = 50
# With zram (RAM-fast, per-page decompression) swap read-ahead just wastes CPU,
# so disable it (Arch Wiki / Fedora recommend page-cluster=0 with zram).
vm.page-cluster = 0
net.ipv4.tcp_fastopen = 3
SYSCTL
cat > /mnt/etc/udev/rules.d/60-ioschedulers.rules <<'IOSCHED'
# NoSignal — I/O scheduler per device type (from CachyOS-Settings):
# NVMe -> none, SATA/eMMC SSD -> mq-deadline, rotational -> bfq.
ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="sd[a-z]*|mmcblk[0-9]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="1", ATTR{queue/scheduler}="bfq"
IOSCHED

# ---------------------------------------------- ~/.local/bin on PATH --------
# Put ~/.local/bin onto every login shell's PATH so user-installed binaries are
# runnable without manual PATH edits. /etc/profile.d/*.sh is sourced by
# /etc/profile for any login shell, before user dotfiles.
echo "==> Adding ~/.local/bin to PATH for all login shells..."
install -d -m 755 /mnt/etc/profile.d
cat > /mnt/etc/profile.d/local-bin.sh <<'PROFILE_LOCAL_BIN'
# Add ~/.local/bin to PATH for login shells (idempotent).
case ":$PATH:" in
  *":$HOME/.local/bin:"*) ;;
  *) [ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH" ;;
esac
PROFILE_LOCAL_BIN
chmod 644 /mnt/etc/profile.d/local-bin.sh

# --------------------------------------------------------------- SDDM --------
# Display manager: SDDM (layer change 16, REQUIRED — DeckShift's
# desktop<->gaming session switching rewrites SDDM config + restarts it;
# greetd/tuigreet couldn't do this). Password greeter, NO autologin baked —
# DeckShift manages its own autologin/session entries post-install (change 17,
# opt-in). Canonical source: the layer's display-manager-sddm/
# sddm-10-nosignal.conf; mirrored here to keep the installer self-contained.
echo "==> Writing + enabling SDDM..."
install -d -m 755 /mnt/etc/sddm.conf.d
cat > /mnt/etc/sddm.conf.d/10-nosignal.conf <<'SDDMCONF'
# NoSignal SDDM config (installed to /etc/sddm.conf.d/10-nosignal.conf).
[General]
# X11 greeter — the reliable, well-trodden path (needs xorg-server). The Hyprland
# SESSION still runs on Wayland; only SDDM's own greeter uses X here.
# For an X-free image instead, use the Wayland greeter:
#   DisplayServer=wayland
#   [Wayland]
#   CompositorCommand=weston --shell=kiosk   (adds `weston` to the image)
DisplayServer=x11
Numlock=on

[Users]
# The desktop session to prefer. hyprland-uwsm.desktop launches Hyprland through
# uwsm exactly like the old greetd config — keep this (NOT plain hyprland.desktop),
# or caelestia's uwsm/systemd-managed session env won't apply.
# (SDDM remembers the last-used session per user; DeckShift overrides this when
#  switching to/from its Gamescope gaming session.)
SDDMCONF
chmod 644 /mnt/etc/sddm.conf.d/10-nosignal.conf
# Seed SDDM's remembered last user/session so the very first greeter already
# preselects the uwsm-managed Hyprland session (hyprland-uwsm.desktop — the
# same canonical uwsm launch the old tuigreet --cmd used; plain
# hyprland.desktop would lose caelestia's uwsm/systemd-managed session env).
# SDDM rewrites this file itself after every login. mkdir -p (not install -d):
# the sddm package owns /var/lib/sddm with its own ownership/mode — don't touch.
mkdir -p /mnt/var/lib/sddm
cat > /mnt/var/lib/sddm/state.conf <<SDDMSTATE
[Last]
Session=/usr/share/wayland-sessions/hyprland-uwsm.desktop
User=$USERNAME
SDDMSTATE
# 644, NOT 600: the GREETER (running as the 'sddm' user) reads this file to
# prefill the last user/session — 600 root:root leaves the username field
# empty on first boot (caught in VM cert). SDDM itself writes it 644.
chmod 644 /mnt/var/lib/sddm/state.conf
arch-chroot /mnt systemctl enable sddm.service

# ------------------------------------------------- caelestia desktop ---------
# Lay down the complete caelestia desktop for the user AT INSTALL TIME, exactly
# replicating what upstream's install.fish does on a live system (investigated
# against a completed install): dotfiles clone in ~/.local/share/caelestia,
# symlinks from ~/.config into it, the colour scheme seeded, plus the NoSignal
# restraint config and the workspace-overview sidecar. The caelestia PACKAGES
# (shell/cli/meta + deps) were already installed by pacstrap from the bundled
# repo.
echo "==> Installing caelestia desktop for $USERNAME (offline)..."
USER_HOME="/home/$USERNAME"
M_HOME="/mnt$USER_HOME"

install -d -m 755 "$M_HOME/.local/share" "$M_HOME/.config"
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/caelestia-dotfiles.tar.gz" -C "$M_HOME/.local/share"
chmod u+x "$M_HOME/.local/share/caelestia/hypr/scripts/wsaction.fish" 2>/dev/null || true

# The symlink set upstream install.fish creates (targets are the FINAL paths
# as seen on the booted system, so they dangle here in the live env — fine).
for link in hypr foot fish uwsm btop; do
  [ -e "$M_HOME/.local/share/caelestia/$link" ] || continue
  ln -sfn "$USER_HOME/.local/share/caelestia/$link" "$M_HOME/.config/$link"
done
[ -e "$M_HOME/.local/share/caelestia/starship.toml" ] \
  && ln -sfn "$USER_HOME/.local/share/caelestia/starship.toml" "$M_HOME/.config/starship.toml"

# --- NoSignal restraint config (Phase 2) ---------------------------------------
# Overrides on top of caelestia's defaults. Pure config — Caelestia's own
# token/appearance system reads these; no shell code is touched, and deleting
# any file restores stock caelestia. Canonical source: the nosignal-shell config
# repo (nosignal/config/); mirrored here to keep the installer self-contained.
echo "==> Applying NoSignal desktop restraint config..."
install -d -m 755 "$M_HOME/.config/caelestia"
# Restrained design tokens: SQUARE corners (rounding ramp zeroed — redesign goal;
# the 77 Tokens.rounding.* bindings honour these, appearance.rounding.scale does
# not feed them; rounding.full omitted so genuine circles stay), tighter
# spacing/padding, denser fonts, snappier animations (overrides Caelestia's
# config-driven Tokens system).
cat > "$M_HOME/.config/caelestia/shell-tokens.json" <<'NOSIGNAL_TOKENS'
{
  "appearance": {
    "rounding": { "extraSmall": 0, "small": 0, "medium": 0, "large": 0, "largeIncreased": 0, "extraLarge": 0, "extraLargeIncreased": 0, "extraExtraLarge": 0 },
    "spacing": { "extraSmall": 3, "small": 6, "medium": 9, "large": 13, "largeIncreased": 16, "extraLarge": 22, "extraLargeIncreased": 26, "extraExtraLarge": 38 },
    "padding": { "extraSmall": 3, "small": 6, "medium": 9, "large": 13, "largeIncreased": 16, "extraLarge": 22, "extraLargeIncreased": 26, "extraExtraLarge": 38 },
    "fontSize": { "small": 10, "smaller": 11, "normal": 12, "larger": 14, "large": 16, "extraLarge": 24 },
    "animDurations": { "small": 120, "normal": 200, "large": 320, "extraLarge": 450, "expressiveFastSpatial": 200, "expressiveDefaultSpatial": 300, "expressiveSlowSpatial": 400, "expressiveFastEffects": 100, "expressiveDefaultEffects": 150, "expressiveSlowEffects": 220 }
  }
}
NOSIGNAL_TOKENS
# Appearance restraint + config-level cuts: opaque panels, Material Symbols
# Outlined icons, drop the Performance/Weather dashboard tabs, visualiser off.
# F9: NoSignal launches Hyprland via uwsm, but caelestia's built-in default
# session.commands.logout targets a plain (non-uwsm) session, so the session
# menu's Logout button was a no-op. `uwsm stop` is the documented way to end a
# uwsm session (drops back to SDDM); shutdown/reboot/hibernate use systemctl and
# are left at caelestia's defaults.
cat > "$M_HOME/.config/caelestia/shell.json" <<'NOSIGNAL_SHELL'
{
  "appearance": {
    "transparency": { "enabled": false },
    "rounding": { "scale": 0 },
    "font": {
      "headline": { "family": "JetBrainsMono Nerd Font" },
      "title": { "family": "JetBrainsMono Nerd Font" },
      "body": { "family": "JetBrainsMono Nerd Font" },
      "label": { "family": "JetBrainsMono Nerd Font" },
      "mono": { "family": "JetBrainsMono Nerd Font" },
      "icon": { "family": "Material Symbols Outlined" }
    }
  },
  "general": {
    "apps": {
      "terminal": ["kitty"],
      "explorer": ["nautilus"]
    }
  },
  "session": {
    "commands": {
      "logout": ["uwsm", "stop"]
    }
  },
  "dashboard": { "showPerformance": false, "showWeather": true },
  "background": { "visualiser": { "enabled": false } },
  "bar": {
    "entries": [
      { "id": "logo", "enabled": true },
      { "id": "workspaces", "enabled": true },
      { "id": "spacer", "enabled": true },
      { "id": "clock", "enabled": true },
      { "id": "spacer", "enabled": true },
      { "id": "statusIcons", "enabled": true },
      { "id": "tray", "enabled": true },
      { "id": "power", "enabled": true }
    ]
  }
}
NOSIGNAL_SHELL
# Flatten the Hyprland glassmorphism (blur/shadow off, solid windows, tighter
# corners + gaps). Sourced by caelestia's hyprland.conf right after variables.conf.
cat > "$M_HOME/.config/caelestia/hypr-vars.conf" <<'NOSIGNAL_HYPRVARS'
# NoSignal restraint — flatten Caelestia's glassmorphism (deleting restores stock).
# Default applications (caelestia's stock picks aren't installed: zen-browser/
# codium/thunar — point the Super-key app binds at what NoSignal actually ships).
$terminal = kitty
$browser = chromium
$editor = gnome-text-editor
$fileExplorer = nautilus
$blurEnabled = false
$blurPopups = false
$blurInputMethods = false
$shadowEnabled = false
$windowOpacity = 1.0
$windowRounding = 0
$workspaceGaps = 10
$windowGapsIn = 4
$windowGapsOut = 8
$singleWindowGapsOut = 12
NOSIGNAL_HYPRVARS

# --- Workspace Overview (standalone sidecar) ----------------------------------
# quickshell-overview runs as its OWN qs instance next to caelestia, toggled by
# Super+Tab over IPC. Caelestia's shell is never modified (no fork/package).
echo "==> Installing workspace Overview (sidecar)..."
install -d -m 755 "$M_HOME/.config/quickshell"
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/quickshell-overview.tar.gz" -C "$M_HOME/.config/quickshell"
# theme-match caelestia + low-RAM event previews + no blur
cat > "$M_HOME/.config/quickshell/overview/config.json" <<'NOSIGNAL_OV_CFG'
{
  "appearance": { "colorSource": "caelestia" },
  "overview": { "previewMode": "event", "previewsEnabled": true, "closeOnFocusLoss": true, "hideEmptyRows": true, "effects": { "enableBlur": false, "enableBackdrop": false, "glassMode": false } }
}
NOSIGNAL_OV_CFG

# --- omarchy-send (LAN file transfer, LocalSend-compatible) -------------------
# Vendored release binary; system-wide so every user gets it. The desktop entry
# opens the TUI in a floating kitty (TUI.float class — float rule above).
echo "==> Installing omarchy-send..."
install -m 755 "$NOSIGNAL_PAYLOAD/vendor/omarchy-send" /mnt/usr/local/bin/omarchy-send
install -d -m 755 /mnt/usr/share/applications /mnt/usr/share/icons/hicolor/scalable/apps
cat > /mnt/usr/share/icons/hicolor/scalable/apps/omarchy-send.svg <<'OMS_ICON'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
  <rect width="256" height="256" rx="56" fill="#16161e"/>
  <path d="M214 42 L42 114 L110 142 Z" fill="#7aa2f7"/>
  <path d="M214 42 L110 142 L130 214 L158 166 Z" fill="#5a7fd6"/>
</svg>
OMS_ICON
cat > /mnt/usr/share/applications/omarchy-send.desktop <<'OMS_DESKTOP'
[Desktop Entry]
Name=Omarchy-Send
Comment=Send & receive files over the LAN (LocalSend-compatible)
Exec=kitty --class TUI.float -e omarchy-send
Icon=omarchy-send
Terminal=false
Type=Application
Categories=Network;FileTransfer;
Keywords=localsend;share;transfer;airdrop;
OMS_DESKTOP

# --- OS theme wallpapers -------------------------------------------------------
# The user's own Moebius-style art set. caelestia's dynamic Material scheme is
# generated FROM the default wallpaper (foam-sea.png, change 18) in the scheme
# step below.
echo "==> Installing NoSignal wallpapers..."
install -d -m 755 "$M_HOME/Pictures/Wallpapers"
tar -xf "$NOSIGNAL_PAYLOAD/vendor/nosignal-wallpapers.tar" -C "$M_HOME/Pictures/Wallpapers"

# Chromium on Wayland: pick the ozone backend automatically (native Wayland
# under Hyprland instead of XWayland).
cat > /mnt/etc/chromium-flags.conf <<'CHROMIUM_FLAGS'
--ozone-platform-hint=auto
CHROMIUM_FLAGS
# autostart the sidecar + toggle bind via caelestia's last-sourced user hook.
# Overview lives on Super+GRAVE (not Tab): the Omarchy keymap appended below
# puts next-workspace on Super+Tab — see dev-docs/BUILDER-HANDOFF.md.
cat > "$M_HOME/.config/caelestia/hypr-user.conf" <<'NOSIGNAL_HYPRUSER'
# NoSignal — overview sidecar autostart + keybind (deleting restores stock).
exec-once = qs -c overview -d
bind = Super, Grave, exec, qs ipc -c overview call overview toggle

# F10: open Caelestia Settings (nexus). Super+Alt+Space is free (NoSignal moved
# the old float-toggle to Super+T). caelestia:nexus is the registered global
# shortcut that opens the Settings window.
bind = Super+Alt, Space, global, caelestia:nexus

# hyprmoncfg writes monitor layout/resolution + workspace assignments here
# (the files are pre-created empty so Hyprland never warns about the source;
# hyprmoncfg refuses to write monitors.conf unless it is sourced).
source = ~/.config/hypr/monitors.conf
source = ~/.config/hypr/workspaces.conf

# omarchy-send launches in a floating terminal window
windowrule = float true, match:class TUI\.float
windowrule = size 1100 700, match:class TUI\.float

# Passwordless-sudo password prompt (Settings -> Services). Dedicated class +
# centered/pinned floating window; SudoToggleRow also dispatches `focuswindow` so
# the prompt grabs keyboard focus (without it the prompt missed keystrokes and
# three blank tries tripped pam_faillock — hardware bug 2026-06-20).
# NB: `stayfocused` is NOT valid in Hyprland 0.55's match: windowrule grammar
# (errors at parse time), so focus is forced from the shell via hyprctl instead.
windowrule = float on, match:class nosignal-sudo
windowrule = size 640 220, match:class nosignal-sudo
windowrule = center on, match:class nosignal-sudo
windowrule = pin on, match:class nosignal-sudo

# polkit auth agent — none ran out of the box, so GUI privilege prompts (pkexec,
# nm-connection-editor system connections, etc.) silently failed. Non-fatal.
exec-once = systemctl --user start hyprpolkitagent.service
NOSIGNAL_HYPRUSER
# Keyboard layout from the installer prompt (appended unquoted — $XKB_LAYOUT).
{
  echo ''
  echo '# Keyboard layout chosen at install'
  echo "input:kb_layout = $XKB_LAYOUT"
} >> "$M_HOME/.config/caelestia/hypr-user.conf"

# F1-B / F11-2: the hypr-user.conf override above was NOT winning — caelestia's
# stock hypr/hyprland/input.conf hardcodes `kb_layout = us` and is sourced such
# that it overrides the variable set above, so the Wayland session AND the lock
# surface came up US-layout on a GB system (mistyped password symbols were F1's
# original trigger). Set the chosen layout in that authoritative file too. The
# dotfiles are extracted once at install (not a package), so this is durable.
# ~/.config/hypr is a symlink into the clone; edit the real file under it.
INPUT_CONF="$M_HOME/.local/share/caelestia/hypr/hyprland/input.conf"
# Fall back to locating whichever file in the hypr tree carries kb_layout, in
# case upstream moves it (keeps the fix working across caelestia layout changes).
if [ ! -f "$INPUT_CONF" ]; then
  INPUT_CONF=$(grep -rlE '^[[:space:]]*kb_layout[[:space:]]*=' \
    "$M_HOME/.local/share/caelestia/hypr/" 2>/dev/null | head -n1)
fi
if [ -n "$INPUT_CONF" ] && [ -f "$INPUT_CONF" ]; then
  if grep -qE '^[[:space:]]*kb_layout[[:space:]]*=' "$INPUT_CONF"; then
    sed -i "s|^\([[:space:]]*kb_layout[[:space:]]*=[[:space:]]*\).*|\1$XKB_LAYOUT|" "$INPUT_CONF"
  else
    printf '\n# Keyboard layout chosen at install (NoSignal)\ninput {\n    kb_layout = %s\n}\n' "$XKB_LAYOUT" >> "$INPUT_CONF"
  fi
  echo "    Hyprland kb_layout set to $XKB_LAYOUT in input.conf"
else
  echo "    NOTE: caelestia input.conf not found — relying on hypr-user.conf kb_layout only"
fi

# Empty targets for hyprmoncfg (live in the dotfiles clone — ~/.config/hypr
# is a symlink into it; hyprmoncfg follows the symlink when saving).
touch "$M_HOME/.local/share/caelestia/hypr/monitors.conf" \
      "$M_HOME/.local/share/caelestia/hypr/workspaces.conf"

# kitty: bash is the default shell (NoSignal layer change 2 — Omarchy setup);
# ~/.bashrc applies caelestia's colour-scheme escape sequences at terminal
# start (cat sequences.txt), so kitty gets themed the same way fish did.
install -d -m 755 "$M_HOME/.config/kitty"
cat > "$M_HOME/.config/kitty/kitty.conf" <<'NOSIGNAL_KITTY'
# NoSignal defaults — kitty is the default terminal ($terminal in hypr-vars.conf).
shell bash
font_family JetBrainsMono Nerd Font
font_size 12.0
enable_audio_bell no
confirm_os_window_close 0
NOSIGNAL_KITTY

# fastfetch: the NoSignal layout (Hardware / Software / Age-Uptime boxes + the
# NoSignal logo). Shipped as a real config — NOT symlinked into the caelestia
# clone (fastfetch was dropped from the symlink loop above) so it overrides the
# dotfiles default. The logo lives beside it; config.jsonc points at it.
install -d -m 755 "$M_HOME/.config/fastfetch"
install -m 644 "$NOSIGNAL_PAYLOAD/vendor/fastfetch-config.jsonc" \
  "$M_HOME/.config/fastfetch/config.jsonc"
install -m 644 "$NOSIGNAL_PAYLOAD/vendor/fastfetch-logo.txt" \
  "$M_HOME/.config/fastfetch/logo.txt"

# ---------------------------------------------- NoSignal layer (changes 1-26) --
# The "os updates" rounds, baked in at install time: keybind cheatsheet, bash
# default shell, nosignal-update mechanism, yay+Shelly store, Omarchy default
# keybindings, omadots developer polish, hyprmoncfg, settings Updates page,
# system polish (1-9); universal copy/paste, screenshots, the launcher/
# dashboard/monitor fix-ups, gaming enablement, SDDM + themed greeter (10-16,
# 18-19); Wi-Fi wrong-password recovery, monitor profile hot-load, yay-menu
# pre-answers, tidied cheatsheet, CLIAmp music player, Settings Additions
# page, uuctl hidden (20-26, the 2026-06-12 hardware-test round). Change 17
# (deckshift-login) SHIPS in the tree but is NOT applied — gaming is opt-in
# post-install. The packages came from pacstrap; everything else comes from
# the layer tree itself, so the ISO and a live-updated box are identical.
# Conf fragments are appended FROM the extracted tree so they cannot drift.
nsi_phase "Applying the NoSignal layer"
echo "==> Installing NoSignal layer (os-updates round)..."
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/nosignal-layer.tar.gz" -C "$M_HOME/.local/share"
LAYER="$M_HOME/.local/share/nosignal"
chmod +x "$LAYER/nosignal-update/bin/nosignal-update" "$LAYER/nosignal-update/migrations/"*.sh

# On-box management skill: a fresh Claude Code on this machine auto-discovers
# the `nosignal` skill (its description is surfaced in the available-skills list)
# and can help the USER configure / fix / maintain their install — strictly
# end-user system management, NOT the dev/QA test protocol. The skill's
# references/ are copied from the layer's single-source docs at install time, so
# they track the build and can never drift (the staleness trap the frozen
# ONBOX-TEST-NOTES.md fell into). The originals stay in ~/.local/share/nosignal/.
SKILL_DIR="$M_HOME/.claude/skills/nosignal"
install -d -m 755 "$SKILL_DIR/references"
install -m 644 "$LAYER/onbox-skill/SKILL.md"   "$SKILL_DIR/SKILL.md"
install -m 644 "$LAYER/ONBOX-AI-NOTES.md"       "$SKILL_DIR/references/ONBOX-AI-NOTES.md"
install -m 644 "$LAYER/NoSignal-keybindings.md" "$SKILL_DIR/references/NoSignal-keybindings.md"

# Commands onto ~/.local/bin (already on PATH via the profile drop above).
# nosignal-update stays a symlink INTO the layer tree — it locates its
# migrations by resolving its own path.
install -d -m 755 "$M_HOME/.local/bin"
install -m 755 "$LAYER/nosignal-keybinds" "$LAYER/nosignal-keybinds-gen" \
  "$LAYER/system-polish/nosignal-webapp-install" \
  "$LAYER/system-polish/nosignal-webapp-remove" \
  "$LAYER/system-polish/nosignal-welcome" \
  "$LAYER/updates-panel/nosignal-update-check" \
  "$LAYER/super-clipboard/super-copy" \
  "$LAYER/super-clipboard/super-paste" \
  "$LAYER/screenshots/nosignal-screenshot" \
  "$LAYER/additions-installer/nosignal-additions" \
  "$LAYER/app-theme-awareness/nosignal-app-theme-sync" \
  "$LAYER/kernel-reboot-notify/nosignal-reboot-check" \
  "$M_HOME/.local/bin/"
# Omarchy CLI shims (change 15) — let Omarchy-targeted gaming installers
# (DeckShift) run on NoSignal: omarchy-pkg-add -> yay, steam installer, NVIDIA
# GSP probes, restart-walker no-op.
install -m 755 "$LAYER/gaming-enablement/omarchy-pkg-add" \
  "$LAYER/gaming-enablement/omarchy-install-gaming-steam" \
  "$LAYER/gaming-enablement/omarchy-hw-nvidia-gsp" \
  "$LAYER/gaming-enablement/omarchy-hw-nvidia-without-gsp" \
  "$LAYER/gaming-enablement/omarchy-restart-walker" \
  "$M_HOME/.local/bin/"
ln -sf "$USER_HOME/.local/share/nosignal/nosignal-update/bin/nosignal-update" \
  "$M_HOME/.local/bin/nosignal-update"

# --- change 27 (xdg-terminal-exec-handler, finding F2): app2unit hardcodes its
# terminal handler to `xdg-terminal-exec`, which NoSignal never shipped, so any
# Terminal=true desktop entry (and app2unit -T) failed with a critical
# notification. Ship a small shim on PATH that launches the configured terminal
# ($TERMINAL, else kitty), plus a system xdg-terminals.list pointing at kitty.
# (The shim self-removes if the real freedesktop package ever lands in a system
# bin — see the component installer.)
install -m 755 "$LAYER/xdg-terminal-exec-handler/xdg-terminal-exec" \
  "$M_HOME/.local/bin/xdg-terminal-exec"
install -d -m 755 /mnt/etc/xdg
printf 'kitty.desktop\n' > /mnt/etc/xdg/xdg-terminals.list
chmod 644 /mnt/etc/xdg/xdg-terminals.list

# Mark every shipped migration as applied — the layer is baked in, so the
# user's first `nosignal-update` must report "0 new migrations" (they are
# idempotent, but re-running needs network/sudo and is noisy). Do NOT create
# the `welcomed` stamp: the first-login welcome notice must fire.
install -d -m 755 "$M_HOME/.local/state/nosignal"
for m in "$LAYER/nosignal-update/migrations/"*.sh; do
  basename "$m"
done > "$M_HOME/.local/state/nosignal/applied"

# --- change 29 (limine-uki-dead-entry, finding F4, BLOCKER): FIXED IN THE SEED.
# The dead-boot is now prevented at the source — the seeded limine.conf above
# ships a `protocol: efi` -> UKI entry as entry 1 with `default_entry: 1`, plus a
# `protocol: linux` fallback as entry 2. Limine preserves these manual entries +
# `default_entry` verbatim across every kernel-update regeneration, so the
# default boot target can never become the dead vmlinuz entry. No post-install
# conversion is needed (the old one-shot conversion mis-fired on 20260613). The
# limine-uki-dead-entry/ component is kept in the layer ONLY as a manual repair
# tool for boxes installed from a pre-fix ISO; it is NOT run at install anymore.

# --- change 2: default shell bash, matching Omarchy. The fish PACKAGE stays
# (caelestia-meta hard-depends on it) and the hypr config keeps calling the
# *.fish script paths — replaced with bash ports under the same filenames.
echo "==> Default shell: bash (Omarchy setup)..."
install -d -m 755 "$M_HOME/.local/share/omarchy/default"
cp -a "$LAYER/fish-to-bash/bash" "$M_HOME/.local/share/omarchy/default/bash"
install -m 644 "$LAYER/fish-to-bash/bashrc" "$M_HOME/.bashrc"
install -m 755 "$LAYER/fish-to-bash/hypr-wsaction.bash" \
  "$M_HOME/.local/share/caelestia/hypr/scripts/wsaction.fish"
install -m 755 "$LAYER/fish-to-bash/hypr-configs.bash" \
  "$M_HOME/.local/share/caelestia/hypr/scripts/configs.fish"
# foot config lives in the dotfiles clone (~/.config/foot is a symlink)
sed -i 's/^shell=.*/shell=bash/' "$M_HOME/.local/share/caelestia/foot/foot.ini"
# Square corners everywhere (redesign goal): caelestia's rules.conf hardcodes
# `rounding 10` on Steam + some XWayland windows, ignoring $windowRounding=0.
# Zero them so those windows aren't the only rounded ones on the desktop.
sed -i -E 's/^(windowrule = )rounding 10,/\1rounding 0,/' \
  "$M_HOME/.local/share/caelestia/hypr/hyprland/rules.conf" 2>/dev/null || true

# --- change 6: omadots developer polish. starship/btop replace the caelestia
# symlinks made above (re-linking restores stock); git behaviors arrive via
# include.path so user identity stays out of the image; LazyVim from vendor.
echo "==> omadots developer polish..."
rm -f "$M_HOME/.config/starship.toml"
install -m 644 "$LAYER/omadots-extras/starship.toml" "$M_HOME/.config/starship.toml"
rm -f "$M_HOME/.config/btop"
install -d -m 755 "$M_HOME/.config/btop" "$M_HOME/.config/tmux" "$M_HOME/.config/git"
install -m 644 "$LAYER/omadots-extras/btop.conf" "$M_HOME/.config/btop/btop.conf"
install -m 644 "$LAYER/omadots-extras/tmux.conf" "$M_HOME/.config/tmux/tmux.conf"
install -m 644 "$LAYER/omadots-extras/omadots.gitconfig" "$M_HOME/.config/git/omadots.gitconfig"
cat > "$M_HOME/.gitconfig" <<GITINCLUDE
# NoSignal — omadots git aliases/behaviors. Your identity goes here, per-user.
[include]
	path = $USER_HOME/.config/git/omadots.gitconfig
GITINCLUDE
tar -xzf "$NOSIGNAL_PAYLOAD/vendor/lazyvim-starter.tar.gz" -C "$M_HOME/.config"

# --- changes 1+4+5+7+9+10+11: keybindings + binds, appended from the layer
# tree. omarchy-keys-vars remaps the $kb* variables (hypr-vars.conf is sourced
# before keybinds.conf consumes them); the user-conf fragments land last.
# Changes 10+11 are the marked blocks from the component installers, extracted
# verbatim (markers included) so a later nosignal-update re-run of those
# installers detects them as already present and skips.
echo "==> Omarchy keymap + layer binds..."
{
  echo ''
  cat "$LAYER/omarchy-keys/omarchy-keys-vars.conf"
} >> "$M_HOME/.config/caelestia/hypr-vars.conf"
{
  echo ''
  cat "$LAYER/hyprland-keybinds-help.conf"
  echo ''
  cat "$LAYER/software-install/hyprland-software-install.conf"
  echo ''
  cat "$LAYER/omarchy-keys/omarchy-keys-user.conf"
  echo ''
  cat "$LAYER/monitor-control/hyprland-monitor-control.conf"
  echo ''
  sed -n '/# >>> super-clipboard/,/# <<< super-clipboard/p' \
    "$LAYER/super-clipboard/install-super-clipboard.sh"
  echo ''
  sed -n '/# >>> nosignal screenshots/,/# <<< nosignal screenshots/p' \
    "$LAYER/screenshots/install-screenshots.sh"
  echo ''
  # change 24: CLIAmp marked block (float rules + Super+M launch-or-focus) —
  # shipped as a standalone conf fragment, appended VERBATIM markers included.
  cat "$LAYER/cliamp-music/hyprland-cliamp.conf"
  echo ''
  echo '# NoSignal — one-time first-login welcome (stamps ~/.local/state/nosignal/welcomed)'
  echo 'exec-once = ~/.local/bin/nosignal-welcome'
} >> "$M_HOME/.config/caelestia/hypr-user.conf"

# --- change 11: screenshots land in ~/Pictures/Screenshots (clipboard too);
# swappy (the caelestia region/freeze annotate binds) saves to the same place.
install -d -m 755 "$M_HOME/Pictures/Screenshots" "$M_HOME/.config/swappy"
install -m 644 "$LAYER/screenshots/swappy-config" "$M_HOME/.config/swappy/config"

# --- changes 7+8: user services. No systemd in the chroot — enable = the
# symlink systemctl would create, targets read from each unit's [Install].
echo "==> Enabling user services (hyprmoncfgd, update check timer)..."
install -d -m 755 "$M_HOME/.config/systemd/user/default.target.wants" \
                  "$M_HOME/.config/systemd/user/timers.target.wants"
install -m 644 "$LAYER/updates-panel/nosignal-update-check.service" \
               "$LAYER/updates-panel/nosignal-update-check.timer" \
               "$M_HOME/.config/systemd/user/"
ln -sf ../nosignal-update-check.timer \
  "$M_HOME/.config/systemd/user/timers.target.wants/nosignal-update-check.timer"
ln -sf /usr/lib/systemd/user/hyprmoncfgd.service \
  "$M_HOME/.config/systemd/user/default.target.wants/hyprmoncfgd.service"
# change 12 (fix for change 7): point hyprmoncfgd at hypr-user.conf (the file
# that actually sources monitors.conf — it refuses to write otherwise) and
# gate its start until Hyprland's IPC answers (avoids a login-race error
# notification). Same flags are baked into the Super+Ctrl+H bind above.
install -d -m 755 "$M_HOME/.config/systemd/user/hyprmoncfgd.service.d"
install -m 644 "$LAYER/monitor-control-fix/hyprmoncfgd-override.conf" \
  "$M_HOME/.config/systemd/user/hyprmoncfgd.service.d/override.conf"
# change 21 (monitor-hotload): path unit watches ~/.config/hyprmoncfg/profiles
# and try-restarts hyprmoncfgd, so a plain "Save Profile" in the Super+Ctrl+H
# TUI applies live. The watched dir must exist (empty — the no-pre-seeded-
# profiles rule from change 7 still stands).
install -m 644 "$LAYER/monitor-hotload/hyprmoncfgd-rescan.path" \
               "$LAYER/monitor-hotload/hyprmoncfgd-rescan.service" \
               "$M_HOME/.config/systemd/user/"
ln -sf ../hyprmoncfgd-rescan.path \
  "$M_HOME/.config/systemd/user/default.target.wants/hyprmoncfgd-rescan.path"
install -d -m 755 "$M_HOME/.config/hyprmoncfg/profiles"

# --- change 30 (app-theme-awareness, finding F6): make external apps
# (Chrome/Electron/Firefox/GTK/Qt) follow Caelestia's light/dark mode. The sync
# script (installed to ~/.local/bin above) mirrors `caelestia scheme get` to the
# freedesktop appearance portal color-scheme + the GTK prefer-dark flag; a .path
# unit re-runs it whenever Caelestia's scheme.json changes, and .service runs it
# at login. portals.conf pins the Settings portal to gtk so the value is served.
echo "==> Enabling app-theme awareness (follow Caelestia dark/light)..."
install -m 644 "$LAYER/app-theme-awareness/nosignal-app-theme.service" \
               "$LAYER/app-theme-awareness/nosignal-app-theme.path" \
               "$M_HOME/.config/systemd/user/"
ln -sf ../nosignal-app-theme.path \
  "$M_HOME/.config/systemd/user/default.target.wants/nosignal-app-theme.path"
ln -sf ../nosignal-app-theme.service \
  "$M_HOME/.config/systemd/user/default.target.wants/nosignal-app-theme.service"
install -d -m 755 "$M_HOME/.config/xdg-desktop-portal"
install -m 644 "$LAYER/app-theme-awareness/portals.conf" \
  "$M_HOME/.config/xdg-desktop-portal/portals.conf"

# --- changes 8/25/20: Updates page, Additions page, and Wi-Fi wrong-password
# recovery are now BAKED INTO the pinned nosignal-shell fork (Phase 2 — we own
# the shell, so the old patch-script + pacman-hook machinery that re-applied
# these after every caelestia-shell upgrade is gone). The Updates/Additions
# CLIs + units are still installed above; only the shell-side QML edits moved
# into the fork source. The Additions status cache is still seeded below.
install -d -m 755 /mnt/etc/pacman.d/hooks

# --- change 31 (kernel-reboot-notify, finding F5): a pacman PostTransaction
# hook that prints a "reboot required" reminder (and "boot the UKI entry") in
# the pacman/yay output whenever a kernel image changes, so out-of-band
# `pacman -Syu`/`yay` updates warn too. The desktop notification + state stamp
# are handled by nosignal-reboot-check, which nosignal-update calls at its end.
echo "==> Installing kernel-reboot reminder pacman hook..."
install -m 644 "$LAYER/kernel-reboot-notify/95-nosignal-kernel-reboot.hook" \
  /mnt/etc/pacman.d/hooks/95-nosignal-kernel-reboot.hook

# --- change 28 (caelestia-lock-faillock, finding F1, BLOCKER): the faillock-free
# lock-screen PAM service (assets/pam.d/caelestia) and the Pam.qml repoint
# (config: "passwd" -> "caelestia") are now BAKED INTO the pinned nosignal-shell
# fork (Phase 2). No patch script or pacman hook needed — the fork ships the
# correct lock auth, so a desktop screen-lock can never faillock the user out
# of their own session.

# --- change 36 (sudo-timed-nopasswd): a Settings -> Services toggle (+ CLI)
# that grants the user NOPASSWD: ALL for 15 minutes then auto-reverts (a root
# systemd timer), with a boot-clean safety net and a sudoers `visudo -cf`
# validation before install. Installs the CLI + boot-clean service + the panel
# toggle; does NOT enable sudoless (that's a deliberate user action). The
# SudoToggleRow + its ServicesPage insert are now BAKED INTO the pinned
# nosignal-shell fork (Phase 2), so NOSIGNAL_SKIP_SHELL_PATCH tells the installer
# to skip the (now redundant) QML patch and no pacman hook is written. The CLI +
# boot-clean safety-net service still install here.
echo "==> Installing time-boxed passwordless-sudo toggle..."
arch-chroot /mnt env NOSIGNAL_SKIP_SHELL_PATCH=1 sh "$USER_HOME/.local/share/nosignal/sudo-timed-nopasswd/install-sudo-timed-nopasswd.sh" \
  || echo "    (sudo-timed-nopasswd install failed — CLI/toggle may be absent)"

# --- change 37 (cachyos-repo-switch): a Settings -> Services toggle (+ CLI) that
# switches the system to/from CachyOS optimized builds + the linux-cachyos kernel,
# passwordless and auto-detecting the best x86-64 tier (v4 > v3). Installs the CLI
# + the visudo-validated sudoers drop-in + the panel toggle; does NOT enable the
# CachyOS repos (a deliberate user action — heavy, networked, reboots the kernel).
# CachyRepoToggleRow + its ServicesPage insert are BAKED INTO the pinned
# nosignal-shell fork (Phase 2), so NOSIGNAL_SKIP_SHELL_PATCH skips the redundant
# QML patch. The CLI + sudoers install here. (gawk/curl/kitty/limine already in
# the base closure; the helper live-fetches the CachyOS keyring/mirrorlist/awk at
# enable time — enabling CachyOS is online by nature.)
echo "==> Installing CachyOS repo + kernel switch toggle..."
arch-chroot /mnt env NOSIGNAL_SKIP_SHELL_PATCH=1 sh "$USER_HOME/.local/share/nosignal/cachyos-repo-switch/install-cachyos-repo-switch.sh" \
  || echo "    (cachyos-repo-switch install failed — CLI/toggle may be absent)"

# --- change 4: Flathub remote for Shelly's flatpak pages. The vendored
# .flatpakrepo embeds the GPG key, so this is an offline-safe config write.
# Staged in /root, NOT /tmp: arch-chroot mounts a fresh tmpfs over the
# target's /tmp, which shadows anything copied there from outside.
echo "==> Configuring Flathub remote..."
cp "$NOSIGNAL_PAYLOAD/vendor/flathub.flatpakrepo" /mnt/root/flathub.flatpakrepo
arch-chroot /mnt flatpak remote-add --if-not-exists flathub /root/flathub.flatpakrepo \
  || echo "    (flathub remote-add failed — once online run: flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo)"
rm -f /mnt/root/flathub.flatpakrepo

# --- changes 9+26: hide launcher clutter via per-user overrides. The PACKAGES
# stay (avahi-daemon is needed for .local/printer discovery, foot is a
# caelestia-meta dep, uuctl ships with uwsm) — only their menu entries
# disappear.
echo "==> Hiding launcher clutter..."
install -d -m 755 "$M_HOME/.local/share/applications"
for id in avahi-discover bssh bvnc qv4l2 qvidcap foot footclient foot-server \
          org.gnupg.pinentry-qt org.gnupg.pinentry-qt5 uuctl; do
  [ -f "/mnt/usr/share/applications/$id.desktop" ] || continue
  cat > "$M_HOME/.local/share/applications/$id.desktop" <<'HIDDEN_ENTRY'
[Desktop Entry]
Type=Application
Name=Hidden by NoSignal
NoDisplay=true
Hidden=true
HIDDEN_ENTRY
done
# (change 9's printing piece — cups.socket — is already enabled in the base.)

# --- hardware-test 2026-06-15 (finding F-T2): stop the redundant blueman tray
# applet from autostarting. The base ships /etc/xdg/autostart/blueman.desktop,
# but blueman-applet duplicates the redesign's own bar Bluetooth pill + BlueZ
# panel (and was the only thing keeping the now-gated system-tray pill visible).
# A per-user XDG autostart override (Hidden=true) suppresses just the applet;
# the blueman package stays so blueman-manager can still be launched by hand and
# Bluetooth control is unaffected.
echo "==> Disabling redundant blueman tray applet..."
install -d -m 755 "$M_HOME/.config/autostart"
cat > "$M_HOME/.config/autostart/blueman.desktop" <<'BLUEMAN_OFF'
[Desktop Entry]
Type=Application
Name=blueman-applet
Comment=NoSignal: redundant with the bar Bluetooth pill + BlueZ panel — disabled
Hidden=true
X-GNOME-Autostart-enabled=false
BLUEMAN_OFF
chmod 644 "$M_HOME/.config/autostart/blueman.desktop"

# --- change 24: CLIAmp default music player. The package came from pacstrap
# ([omarchy] repo); the Super+M bind is the marked block appended above. Here:
# the desktop entry (launches in kitty, class cliamp) + the audio MIME
# defaults. Nothing else writes ~/.config/mimeapps.list at install time (the
# dotfiles ship none), so a plain write is safe.
echo "==> CLIAmp default music player..."
install -m 644 "$LAYER/cliamp-music/cliamp.desktop" \
  "$M_HOME/.local/share/applications/cliamp.desktop"
{
  echo '[Default Applications]'
  for m in audio/mpeg audio/mp4 audio/x-m4a audio/aac audio/flac audio/x-flac \
           audio/ogg audio/x-vorbis+ogg audio/x-opus+ogg audio/wav audio/x-wav \
           audio/webm; do
    echo "$m=cliamp.desktop"
  done
} > "$M_HOME/.config/mimeapps.list"
chmod 644 "$M_HOME/.config/mimeapps.list"

# --- change 19: SDDM greeter themed to match the desktop. Theme dir + sync
# script + [Theme] drop-in baked from the layer tree. The shipped theme.conf
# already carries the foam-sea Material palette (matching the change-18
# default wallpaper), so the first-boot greeter matches the desktop before
# any sync has run; the background is seeded from the same wallpaper here.
# sddm-theme-sync needs a logged-in user to mirror, so it is NOT run in the
# chroot — after a wallpaper change the user runs `sudo sddm-theme-sync`.
echo "==> Installing SDDM greeter theme (caelestia)..."
install -d -m 755 /mnt/usr/share/sddm/themes/caelestia/backgrounds
install -m 644 "$LAYER/sddm-theme/caelestia/Main.qml" \
  /mnt/usr/share/sddm/themes/caelestia/Main.qml
install -m 644 "$LAYER/sddm-theme/caelestia/metadata.desktop" \
  /mnt/usr/share/sddm/themes/caelestia/metadata.desktop
install -m 644 "$LAYER/sddm-theme/caelestia/theme.conf" \
  /mnt/usr/share/sddm/themes/caelestia/theme.conf
install -m 644 "$M_HOME/Pictures/Wallpapers/nosignal/foam-sea.png" \
  /mnt/usr/share/sddm/themes/caelestia/backgrounds/wallpaper.png
install -m 755 "$LAYER/sddm-theme/sddm-theme-sync" /mnt/usr/local/bin/sddm-theme-sync
cat > /mnt/etc/sddm.conf.d/20-sddm-theme.conf <<'SDDMTHEME'
# Greeter theme matching the desktop scheme (sddm-theme component).
# Remove this file to fall back to SDDM's default greeter.
[Theme]
Current=caelestia
SDDMTHEME
chmod 644 /mnt/etc/sddm.conf.d/20-sddm-theme.conf

arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "$USER_HOME"

# --- colour scheme + wallpaper -------------------------------------------------
# The OS theme: set the default Moebius wallpaper (foam-sea.png, change 18 —
# matches the baked SDDM greeter theme), then switch the scheme to
# `dynamic` so caelestia generates the Material palette FROM the wallpaper
# (whole shell + terminals pick it up). Both CLI calls are headless-safe (PIL +
# file writes, no compositor needed; verified the same way scheme-set was).
# Order matters: `wallpaper -f` first (writes the state + thumbnail), THEN
# `scheme set -n dynamic` (regenerates colours from that thumbnail).
# Fallbacks keep the old behaviour: shadotheme scheme, no wallpaper state.
echo "==> Seeding caelestia theme (foam-sea wallpaper + dynamic scheme)..."
as_user() {
  arch-chroot /mnt runuser -u "$USERNAME" -- \
    env HOME="$USER_HOME" XDG_STATE_HOME="$USER_HOME/.local/state" \
        XDG_CONFIG_HOME="$USER_HOME/.config" XDG_CACHE_HOME="$USER_HOME/.cache" \
    "$@"
}
WALL="$USER_HOME/Pictures/Wallpapers/nosignal/foam-sea.png"
if as_user caelestia wallpaper -f "$WALL" >/dev/null 2>&1 \
   && as_user caelestia scheme set -n dynamic >/dev/null 2>&1 \
   && [ -f "$M_HOME/.local/state/caelestia/scheme.json" ]; then
  echo "    Dynamic scheme generated from $WALL"
else
  echo "    Dynamic scheme failed in chroot — falling back to shadotheme."
  as_user caelestia scheme set -n shadotheme >/dev/null 2>&1 || true
fi
if [ ! -f "$M_HOME/.local/state/caelestia/scheme.json" ]; then
  install -d -m 755 "$M_HOME/.local/state/caelestia"
  install -m 644 "$NOSIGNAL_PAYLOAD/vendor/scheme-shadotheme.json" \
    "$M_HOME/.local/state/caelestia/scheme.json"
fi

# --- change 25 (cont.): seed the Additions status cache so the page has data
# on first open (all 15 items not-installed on a fresh image — the page IS the
# opt-in). Needs as_user, hence here rather than next to the QML patch.
as_user "$USER_HOME/.local/bin/nosignal-additions" status >/dev/null 2>&1 \
  || echo "    (Additions status seed failed — the page populates on first re-check)"

arch-chroot /mnt chown -R "$USERNAME:$USERNAME" "$USER_HOME/.local/state" 2>/dev/null || true

# --------------------------------------------------------- install cleanup ---
# The file:// repo must not leak into the installed system: drop its synced db
# (the [nosignal] repo is not in the target's pacman.conf) and clear the package
# cache copies pacstrap made (~3.5GB — the install media still has them all).
nsi_phase "Finishing up"
echo "==> Cleaning install-time package cache..."
rm -f /mnt/var/lib/pacman/sync/nosignal.*
rm -f /mnt/var/cache/pacman/pkg/*

# Heavy phase done — stop the spinner, restore on-screen output, show a centred
# completion screen.
nsi_spin_stop
trap - ERR
exec >/dev/tty 2>&1
clear
tcecho "NoSignal install complete" "$NSI_GB" "$NSI_R"
printf '\n' >/dev/tty
tcecho "Remove the install media and reboot." "$NSI_B" "$NSI_R"
printf '\n' >/dev/tty
tcecho "First boot goes straight to the graphical login (SDDM, themed) —" "$NSI_DIM" "$NSI_R"
tcecho "log in and the themed Hyprland desktop starts. No internet needed;" "$NSI_DIM" "$NSI_R"
tcecho "run 'sudo pacman -Syu' once when you're online to sync databases." "$NSI_DIM" "$NSI_R"
printf '\n' >/dev/tty
PROMPT="Press ENTER to reboot now (Ctrl+C for a shell)... "
printf '%s' "$(nsi_pad "${#PROMPT}")" >/dev/tty
read -rp "$PROMPT" </dev/tty
# Flush ALL pending writes to disk before unmounting. With the btrfs subvolume
# layout there are 5 mounts under /mnt; `umount -R` can fail partway (it's
# tolerated below), which previously left the just-written bootloader files
# (limine.conf, BOOTX64.EFI) cached-but-unflushed on the FAT ESP — they ended up
# 0 bytes on disk and the machine wouldn't boot. An explicit sync guarantees the
# ESP writes hit the block device regardless of how the unmount goes.
sync
umount -R /mnt || true
sync
reboot
__INSTALLER_PAYLOAD__
  chmod +x "$1"
}

# ===========================================================================
# build_offline_payload — assemble ./offline/iso (mapped to /nosignal on the
# ISO): the complete pacman repo, vendored source tarballs, and the base
# package list. Cached: AUR builds stamp offline/aur/.built-*, downloads skip
# files already present.
# ===========================================================================
build_offline_payload() {
  echo "==> Building offline payload (cached in $OFFLINE)..."
  mkdir -p "$OFFLINE/iso/repo" "$OFFLINE/iso/vendor" "$OFFLINE/aur"

  # ---- vendored source tarballs (pinned) ----------------------------------
  if [ ! -f "$OFFLINE/iso/vendor/caelestia-dotfiles.tar.gz" ]; then
    echo "==> Vendoring caelestia dotfiles ($CAELESTIA_DOTS_COMMIT)..."
    rm -rf "$OFFLINE/aur/caelestia"
    git clone "$CAELESTIA_DOTS_REPO" "$OFFLINE/aur/caelestia"
    git -C "$OFFLINE/aur/caelestia" checkout -q "$CAELESTIA_DOTS_COMMIT"
    # Tarball root dir is 'caelestia' -> extracts to ~/.local/share/caelestia.
    # .git is kept so the user can pull/diff upstream later.
    tar -czf "$OFFLINE/iso/vendor/caelestia-dotfiles.tar.gz" -C "$OFFLINE/aur" caelestia
  fi
  if [ ! -f "$OFFLINE/iso/vendor/quickshell-overview.tar.gz" ]; then
    echo "==> Vendoring quickshell-overview ($OVERVIEW_COMMIT)..."
    rm -rf "$OFFLINE/aur/overview"
    git clone "$OVERVIEW_REPO" "$OFFLINE/aur/overview"
    git -C "$OFFLINE/aur/overview" checkout -q "$OVERVIEW_COMMIT"
    # Tarball root dir is 'overview' -> extracts to ~/.config/quickshell/overview.
    tar -czf "$OFFLINE/iso/vendor/quickshell-overview.tar.gz" -C "$OFFLINE/aur" overview
  fi
  # OS theme wallpapers — repo-local user art, re-tarred EVERY build (like the
  # layer tree) so image swaps always reach the next ISO. PNGs don't compress,
  # so plain tar. Stale Last Horizon tarballs from older builds are removed.
  if [ ! -d "$WALLPAPERS_DIR" ] || [ ! -f "$WALLPAPERS_DIR/$DEFAULT_WALLPAPER" ]; then
    echo "ERROR: wallpapers not found at $WALLPAPERS_DIR (need $DEFAULT_WALLPAPER)" >&2
    exit 1
  fi
  echo "==> Vendoring OS theme wallpapers..."
  rm -f "$OFFLINE/iso/vendor/lasthorizon-wallpapers.tar.gz"
  # Tarball root dir is 'nosignal' -> extracts to ~/Pictures/Wallpapers/nosignal.
  tar -cf "$OFFLINE/iso/vendor/nosignal-wallpapers.tar" \
    -C "$(dirname "$WALLPAPERS_DIR")" \
    --transform 's|^wallpapers|nosignal|' wallpapers

  # Plymouth boot-splash theme (NO SIGNAL logo on black) — repo-local, re-tarred
  # EVERY build (like the wallpapers/layer) so logo/script edits reach the ISO.
  # Tarball root dir is 'nosignal' -> extracts to
  # /usr/share/plymouth/themes/nosignal.
  if [ ! -f "$SCRIPT_DIR/assets/plymouth/nosignal/nosignal.script" ]; then
    echo "ERROR: Plymouth theme not found at $SCRIPT_DIR/assets/plymouth/nosignal" >&2
    exit 1
  fi
  echo "==> Vendoring Plymouth boot-splash theme..."
  tar -czf "$OFFLINE/iso/vendor/nosignal-plymouth.tar.gz" \
    -C "$SCRIPT_DIR/assets/plymouth" nosignal
  if [ ! -f "$OFFLINE/iso/vendor/lazyvim-starter.tar.gz" ]; then
    echo "==> Vendoring LazyVim starter ($LAZYVIM_COMMIT)..."
    rm -rf "$OFFLINE/aur/lazyvim-starter"
    git clone "$LAZYVIM_REPO" "$OFFLINE/aur/lazyvim-starter"
    git -C "$OFFLINE/aur/lazyvim-starter" checkout -q "$LAZYVIM_COMMIT"
    rm -rf "$OFFLINE/aur/lazyvim-starter/.git"
    # Tarball root dir is 'nvim' -> extracts to ~/.config/nvim.
    tar -czf "$OFFLINE/iso/vendor/lazyvim-starter.tar.gz" -C "$OFFLINE/aur" \
      --transform 's|^lazyvim-starter|nvim|' lazyvim-starter
  fi
  if [ ! -f "$OFFLINE/iso/vendor/flathub.flatpakrepo" ]; then
    echo "==> Vendoring Flathub remote definition..."
    curl -fSL --proto '=https' --tlsv1.2 \
      -o "$OFFLINE/iso/vendor/flathub.flatpakrepo" "$FLATHUB_REPO_URL"
  fi
  # NoSignal layer tree — local source, small, and actively iterated on: re-tar
  # it EVERY build (no cache stamp) so layer edits always reach the next ISO.
  if [ ! -d "$NOSIGNAL_LAYER_DIR" ]; then
    echo "ERROR: NoSignal layer tree not found at $NOSIGNAL_LAYER_DIR" >&2
    exit 1
  fi
  echo "==> Vendoring NoSignal layer tree..."
  # Tarball root dir is 'nosignal' -> extracts to ~/.local/share/nosignal.
  tar -czf "$OFFLINE/iso/vendor/nosignal-layer.tar.gz" \
    -C "$(dirname "$NOSIGNAL_LAYER_DIR")" \
    --transform 's|^os updates|nosignal|' "os updates"

  if [ ! -f "$OFFLINE/iso/vendor/omarchy-send" ]; then
    echo "==> Vendoring omarchy-send ($OMARCHY_SEND_VERSION)..."
    curl -fSL --proto '=https' --tlsv1.2 -o "$OFFLINE/iso/vendor/omarchy-send" \
      "https://github.com/$OMARCHY_SEND_REPO/releases/download/$OMARCHY_SEND_VERSION/omarchy-send-linux-amd64"
    chmod 755 "$OFFLINE/iso/vendor/omarchy-send"
  fi
  install -m 644 "$SCRIPT_DIR/assets/scheme-shadotheme.json" "$OFFLINE/iso/vendor/"
  install -m 644 "$SCRIPT_DIR/assets/fastfetch-config.jsonc" \
                 "$SCRIPT_DIR/assets/fastfetch-logo.txt" "$OFFLINE/iso/vendor/"

  # ---- bootstrap repo db (so the build chroot can reference it from day 0) -
  if [ ! -e "$OFFLINE/iso/repo/nosignal.db" ]; then
    tar -czf "$OFFLINE/iso/repo/nosignal.db.tar.gz" --files-from /dev/null
    tar -czf "$OFFLINE/iso/repo/nosignal.files.tar.gz" --files-from /dev/null
    ln -sf nosignal.db.tar.gz   "$OFFLINE/iso/repo/nosignal.db"
    ln -sf nosignal.files.tar.gz "$OFFLINE/iso/repo/nosignal.files"
  fi

  # ---- build chroot --------------------------------------------------------
  # Clean-room AUR builds via devtools. The chroot's pacman.conf includes the
  # local bootstrap repo: arch-nspawn auto-bind-mounts file:// Server dirs, so
  # each built package becomes resolvable by the next build (caelestia-shell
  # needs caelestia-cli + quickshell-git etc.).
  cat > "$OFFLINE/chroot-pacman.conf" <<CHROOTPAC
[options]
Architecture = auto
SigLevel = Required DatabaseOptional
LocalFileSigLevel = Optional
ParallelDownloads = 8

[nosignal]
SigLevel = Optional TrustAll
Server = file://$OFFLINE/iso/repo

[core]
Include = /etc/pacman.d/mirrorlist

[extra]
Include = /etc/pacman.d/mirrorlist
CHROOTPAC
  if [ ! -d "$OFFLINE/chroot/root" ]; then
    echo "==> Creating clean build chroot (devtools mkarchroot)..."
    mkdir -p "$OFFLINE/chroot"
    sudo mkarchroot -C "$OFFLINE/chroot-pacman.conf" "$OFFLINE/chroot/root" base-devel
  fi

  # ---- AUR packages (clean-chroot builds, dependency order) ----------------
  local name p
  for name in "${AUR_BUILD_ORDER[@]}"; do
    if [ -f "$OFFLINE/aur/.built-$name" ]; then
      echo "    $name: cached."
      continue
    fi
    echo "==> Building AUR package: $name..."
    rm -rf "${OFFLINE:?}/aur/$name"
    if [ "$name" = "nosignal-shell" ]; then
      # NOT an AUR package — it's our fork of the caelestia shell. Pull the
      # PKGBUILD from the fork at the pinned commit and pin the git source to
      # the same commit (reproducible, exactly like quickshell-git below). The
      # PKGBUILD already carries DISTRIBUTOR + the `git` makedepend that the
      # shell's M3Shapes CMake FetchContent needs in a clean chroot, so no
      # further patching is required here.
      mkdir -p "$OFFLINE/aur/$name"
      git clone --depth 1 "$NOSIGNAL_SHELL_REPO" -b nosignal "$OFFLINE/aur/$name.fork"
      ( cd "$OFFLINE/aur/$name.fork" && git fetch --depth 1 origin "$NOSIGNAL_SHELL_COMMIT" && git checkout -q "$NOSIGNAL_SHELL_COMMIT" )
      cp "$OFFLINE/aur/$name.fork/packaging/PKGBUILD" "$OFFLINE/aur/$name/PKGBUILD"
      rm -rf "$OFFLINE/aur/$name.fork"
      sed -i 's|#branch=nosignal|#commit='"$NOSIGNAL_SHELL_COMMIT"'|' "$OFFLINE/aur/$name/PKGBUILD"
    else
      git clone --depth 1 "https://aur.archlinux.org/$name.git" "$OFFLINE/aur/$name"
    fi
    if [ "$name" = "quickshell-git" ]; then
      # Pin to the validated commit (makepkg git source fragment).
      sed -i 's|git+\$url\.git|&#commit='"$QUICKSHELL_GIT_COMMIT"'|' "$OFFLINE/aur/$name/PKGBUILD"
    fi
    # Self-heal the clean chroot's [nosignal] repo path: a project rename
    # (hyprOS -> nosignal) leaves a stale Server path baked into the chroot's
    # persistent pacman.conf, which only bites the first chroot build after the
    # move (cached AUR pkgs skip the build). Keep it pointed at the live OFFLINE.
    for pc in "$OFFLINE"/chroot/*/etc/pacman.conf; do
      [ -f "$pc" ] && sudo sed -i "s#file:///.*/offline/iso/repo#file://$OFFLINE/iso/repo#g" "$pc"
    done
    # Refresh the ROOT chroot's sync dbs first: makechrootpkg -c clones the
    # copy from root, and the dependency install inside runs withOUT -Sy — so
    # without this, packages repo-added to [nosignal] after mkarchroot are
    # invisible and chained builds fail with "target not found".
    sudo arch-nspawn "$OFFLINE/chroot/root" pacman -Syu --noconfirm
    ( cd "$OFFLINE/aur/$name" && sudo makechrootpkg -c -r "$OFFLINE/chroot" )
    for p in "$OFFLINE/aur/$name"/*.pkg.tar.zst; do
      [[ "$(basename "$p")" == *-debug-* ]] && continue
      cp -f "$p" "$OFFLINE/iso/repo/"
      repo-add -R "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo/$(basename "$p")"
    done
    touch "$OFFLINE/aur/.built-$name"
  done

  # caelestia-meta builds from the PKGBUILD inside the dotfiles repo itself
  # (the AUR snapshot lags it — the dotfiles clone is the authoritative
  # source). Pure metapackage: no build() step, so plain makepkg -d on the
  # host is safe (no dep pollution; pkgver() just needs git).
  # PRIVACY: host makepkg records builddir/startdir in the package's
  # .BUILDINFO — built from $HOME that would ship the builder's username on a
  # distributable ISO. Build from a neutral /tmp copy instead. (Chroot-built
  # packages don't have this problem: their paths are /build.)
  if [ ! -f "$OFFLINE/aur/.built-caelestia-meta" ]; then
    echo "==> Building caelestia-meta (from the pinned dotfiles clone)..."
    if [ ! -d "$OFFLINE/aur/caelestia/.git" ]; then
      rm -rf "$OFFLINE/aur/caelestia"
      git clone "$CAELESTIA_DOTS_REPO" "$OFFLINE/aur/caelestia"
      git -C "$OFFLINE/aur/caelestia" checkout -q "$CAELESTIA_DOTS_COMMIT"
    fi
    meta_tmp=$(mktemp -d /tmp/nosignal-meta.XXXXXX)
    cp -a "$OFFLINE/aur/caelestia" "$meta_tmp/caelestia"   # keep .git (pkgver)
    ( cd "$meta_tmp/caelestia" && rm -f ./*.pkg.tar.zst \
        && BUILDDIR="$meta_tmp/build" makepkg -df --noconfirm )
    for p in "$meta_tmp/caelestia"/*.pkg.tar.zst; do
      cp -f "$p" "$OFFLINE/iso/repo/"
      repo-add -R "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo/$(basename "$p")"
    done
    rm -rf "$meta_tmp"
    touch "$OFFLINE/aur/.built-caelestia-meta"
  fi

  # ---- full repo dependency closure ----------------------------------------
  # Resolve EVERYTHING the installer can possibly pacstrap — base set, every
  # GPU variant, the limine tools (prebuilt, from the omarchy repo) and the
  # caelestia stack (from the local repo, pulling its repo deps) — against an
  # EMPTY local db, and download into the repo dir. Already-present files are
  # skipped, so this is cheap on rebuilds.
  echo "==> Downloading full package dependency closure..."
  cat > "$OFFLINE/dl-pacman.conf" <<DLPAC
[options]
Architecture = x86_64
ParallelDownloads = 8
SigLevel = Required DatabaseOptional

[nosignal]
SigLevel = Optional TrustAll
Server = file://$OFFLINE/iso/repo

[core]
Server = https://geo.mirror.pkgbuild.com/\$repo/os/\$arch

[extra]
Server = https://geo.mirror.pkgbuild.com/\$repo/os/\$arch

[omarchy]
SigLevel = Optional TrustAll
Server = https://pkgs.omarchy.org/edge/\$arch
DLPAC
  sudo rm -rf "$OFFLINE/dl-db"   # populated by root (sudo pacman -Syw)
  mkdir -p "$OFFLINE/dl-db"
  sudo pacman -Syw --noconfirm \
    --config "$OFFLINE/dl-pacman.conf" \
    --dbpath "$OFFLINE/dl-db" \
    --cachedir "$OFFLINE/iso/repo" \
    "${BASE_PKGS[@]}" "${GPU_ALL_PKGS[@]}" "${LIMINE_TOOLS[@]}"

  # ---- finalise the repo ----------------------------------------------------
  # Prune superseded package versions, then rebuild the db from everything
  # present so it exactly matches the files shipped on the ISO.
  if command -v paccache >/dev/null 2>&1; then
    paccache -rqk1 -c "$OFFLINE/iso/repo" || true
  else
    echo "    (paccache not found — old package versions may bloat the ISO;"
    echo "     install pacman-contrib to enable pruning)"
  fi

  # F5 (2026-06-12 hardware test): hyprland 0.55.3 segfaults at screen lock
  # (renderer SEGV during lock/DPMS monitor churn; the watchdog then drops to
  # the confusing safe-mode "basic desktop"). 0.55.4 backports the #15048 fix
  # and was retested clean on the test box — refuse to ship anything older.
  local hyprver
  hyprver=$(basename "$(ls "$OFFLINE/iso/repo"/hyprland-[0-9]*.pkg.tar.zst | sort -V | tail -1)")
  hyprver=${hyprver#hyprland-}; hyprver=${hyprver%-x86_64.pkg.tar.zst}
  if [ "$(vercmp "$hyprver" 0.55.4)" -lt 0 ]; then
    echo "ERROR: offline repo has hyprland $hyprver — F5 requires >= 0.55.4" >&2
    echo "       (lock-screen renderer SEGV). Refresh mirrors and rebuild." >&2
    exit 1
  fi
  echo "    hyprland $hyprver (>= 0.55.4, F5 lock-screen fix) OK"

  echo "==> Building repo database..."
  # Drop detached signatures (omarchy ships them): repo-add would embed them
  # in the db and pacman then REQUIRES the signing key even under TrustAll.
  # Offline install verifies nothing (SigLevel Never) — the sigs are dead weight.
  rm -f "$OFFLINE/iso/repo"/*.sig
  rm -f "$OFFLINE/iso/repo"/nosignal.db* "$OFFLINE/iso/repo"/nosignal.files* "$OFFLINE/iso/repo"/*.old
  repo-add -q "$OFFLINE/iso/repo/nosignal.db.tar.gz" "$OFFLINE/iso/repo"/*.pkg.tar.zst
  # ISO9660-safe: ship the .db/.files as REAL files, not symlinks.
  local f
  for f in db files; do
    rm -f "$OFFLINE/iso/repo/nosignal.$f"
    cp "$OFFLINE/iso/repo/nosignal.$f.tar.gz" "$OFFLINE/iso/repo/nosignal.$f"
  done

  # ---- base package list (single source of truth for the installer) --------
  printf '%s\n' "${BASE_PKGS[@]}" > "$OFFLINE/iso/base-packages.list"

  echo "==> Offline payload ready: $(du -sh "$OFFLINE/iso" | cut -f1)"
}

# ===========================================================================
# Host-side workflow: locate stock ISO, build offline payload, unsquash,
# inject, re-squash, repack, output.
# ===========================================================================

# ---- locate stock ISO ----------------------------------------------------
shopt -s nullglob
CANDIDATES=("$SCRIPT_DIR"/archlinux-*.iso)
shopt -u nullglob
STOCK_ISO=""
for iso in "${CANDIDATES[@]}"; do
  [[ "$(basename "$iso")" == *NoSignal* ]] && continue
  STOCK_ISO="$iso"
  break
done

if [ -z "$STOCK_ISO" ]; then
  echo "ERROR: No stock Arch ISO found in $SCRIPT_DIR" >&2
  echo >&2
  echo "Download the latest from https://archlinux.org/download/ and put it" >&2
  echo "in this folder (filename must start with 'archlinux-')." >&2
  exit 1
fi

echo "  Stock ISO: $(basename "$STOCK_ISO")"
echo "  Output:    $(basename "$OUT_ISO")"
echo

# ---- tooling check -------------------------------------------------------
for cmd in xorriso unsquashfs mksquashfs git sha512sum mkarchroot makechrootpkg repo-add makepkg; do
  command -v "$cmd" >/dev/null || {
    echo "ERROR: missing tool: $cmd" >&2
    echo "       install with: sudo pacman -S libisoburn squashfs-tools git coreutils devtools pacman-contrib" >&2
    exit 1
  }
done

# ---- OPTIONAL master SSH public key (OPT-IN ONLY) -------------------------
# The ISO is distributable: by default NO key is baked and sshd stays disabled
# on installed systems — nothing identifying the build host ships on the
# image. For a personal/dev build (remote rescue over SSH), opt in explicitly:
#   SSH_PUBKEY=/path/to/key.pub ./nosignal.sh
# (The old behaviour of auto-baking ~/.ssh/id_*.pub was removed deliberately —
#  do not reintroduce it; a give-away ISO must never carry the builder's key.)
SSH_PUBKEY="${SSH_PUBKEY:-}"
HAVE_KEY=0
if [ -z "$SSH_PUBKEY" ] || [ "$SSH_PUBKEY" = "none" ]; then
  echo "  SSH key:   (none — sshd disabled on installs; distributable default."
  echo "              Opt in with SSH_PUBKEY=/path/to/key.pub for a dev build.)"
elif [ -f "$SSH_PUBKEY" ]; then
  HAVE_KEY=1
  echo "  SSH key:   $SSH_PUBKEY (DEV BUILD — key baked in, sshd enabled on installs;"
  echo "              do NOT distribute this ISO)"
else
  echo "ERROR: SSH_PUBKEY=$SSH_PUBKEY not found" >&2
  exit 1
fi

# ---- offline payload ------------------------------------------------------
build_offline_payload

# ---- workspace -----------------------------------------------------------
sudo rm -rf "$WORK"
mkdir -p "$WORK"
SFS_DIR="$WORK/airootfs"

# ---- pull squashfs out of the stock ISO ---------------------------------
echo "==> Extracting airootfs.sfs from stock ISO..."
xorriso -osirrox on -indev "$STOCK_ISO" \
  -extract /arch/x86_64/airootfs.sfs "$WORK/airootfs-stock.sfs" 2>&1 | tail -3

echo "==> Unsquashing airootfs (slow: ~1 min)..."
sudo unsquashfs -d "$SFS_DIR" "$WORK/airootfs-stock.sfs" >/dev/null

# ---- inject payload ------------------------------------------------------
if [ "$HAVE_KEY" = 1 ]; then
  echo "==> Staging master SSH key for first-boot SSH access..."
  sudo install -m 644 -o root -g root "$SSH_PUBKEY" "$SFS_DIR/root/master.pub"
fi

echo "==> Injecting installer.sh..."
INSTALLER_TMP="$WORK/installer.sh"
write_installer "$INSTALLER_TMP"
sudo install -m 755 -o root -g root "$INSTALLER_TMP" "$SFS_DIR/root/installer.sh"

echo "==> Masking online-only units (they hang/hijack the console offline)..."
# With no network, archlinux-keyring-wkd-sync pulls systemd-time-wait-sync,
# which waits for NTP sync FOREVER ("no limit") and repaints the console with
# its start-job spinner right over the running installer. Useless offline.
for unit in systemd-time-wait-sync.service archlinux-keyring-wkd-sync.service archlinux-keyring-wkd-sync.timer; do
  sudo ln -sf /dev/null "$SFS_DIR/etc/systemd/system/$unit"
done

echo "==> Wiring getty@tty1 to auto-launch the installer..."
sudo mkdir -p "$SFS_DIR/etc/systemd/system/getty@tty1.service.d"
sudo tee "$SFS_DIR/etc/systemd/system/getty@tty1.service.d/override.conf" >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=-/usr/bin/bash /root/installer.sh
StandardInput=tty
StandardOutput=tty
Restart=no
RestartPreventExitStatus=1 2 3 4 5 6 7 8
EOF

# ---- extend USB enumeration timeout in boot loader cmdline --------------
# Slow / fussy USB controllers (some Intel mini-PCs, NUCs) don't enumerate USB
# block devices within archiso's default search window. Adding rootdelay=60 to
# the kernel cmdline makes initramfs wait 60s before mounting root — fast
# hardware finds the device in 2s and moves on; slow hardware gets breathing
# room. consoleblank=0 keeps the live install console from DPMS-blanking.
echo "==> Patching bootloader cmdlines (rootdelay=60 for slow USB enumeration)..."
mkdir -p "$WORK/boot-edit"
BOOT_CFG_FILES=(
  /loader/entries/01-archiso-linux.conf
  /loader/entries/02-archiso-speech-linux.conf
  /boot/syslinux/archiso_sys-linux.cfg
)
for path in "${BOOT_CFG_FILES[@]}"; do
  dest="$WORK/boot-edit/$(basename "$path")"
  # Don't pipe xorriso straight into tail — that masks its exit status under
  # 'set -e', so a missing boot file would slip through here and instead blow
  # up later at the unconditional -map during repack, with a murkier error.
  if ! xorriso -osirrox on -indev "$STOCK_ISO" -extract "$path" "$dest" 2>"$WORK/xorriso-extract.log"; then
    echo "ERROR: failed to extract $path from stock ISO:" >&2
    tail -3 "$WORK/xorriso-extract.log" >&2
    exit 1
  fi
done
for f in "$WORK/boot-edit"/*; do
  [ -f "$f" ] || continue
  sed -i -E '/archisosearchuuid=/ { /rootdelay=/!     s/$/ rootdelay=60/     }' "$f"
  sed -i -E '/archisosearchuuid=/ { /consoleblank=/!  s/$/ consoleblank=0/   }' "$f"
done

# ---- re-squash -----------------------------------------------------------
echo "==> Re-squashing airootfs (slow: ~2 min)..."
sudo rm -f "$WORK/airootfs.sfs"
sudo mksquashfs "$SFS_DIR" "$WORK/airootfs.sfs" \
  -comp xz -Xbcj x86 -b 1M -noappend -no-progress -quiet

# ---- regenerate sha512 --------------------------------------------------
echo "==> Recalculating airootfs.sha512..."
( cd "$WORK" && sudo sha512sum airootfs.sfs | sudo tee airootfs.sha512 >/dev/null )

# ---- repack ISO ---------------------------------------------------------
# -boot_image any replay regenerates the hybrid MBR/GPT + El Torito layout
# SIZED TO THE NEW IMAGE. Never copy the stock system_area verbatim: its
# partition table describes the stock 1.2GB ISO, so everything past that
# (the /nosignal payload) lands OUTSIDE any partition and is unreadable when
# the media is mounted via a partition device (USB boot on real hardware).
echo "==> Repacking ISO (offline repo + replayed boot layout + volume UUID)..."
sudo rm -f "$OUT_ISO"

# Force output volume UUID to match the stock's. archiso's initramfs hook
# searches for the boot media by this UUID (also baked into the kernel cmdline
# as archisosearchuuid=) — if xorriso regenerates the UUID with a fresh build
# timestamp, the search misses the USB entirely. Pinning the UUID keeps stock
# cmdline + marker file in sync.
STOCK_UUID=$(blkid -s UUID -o value "$STOCK_ISO")
STOCK_UUID_RAW=$(echo "$STOCK_UUID" | tr -d '-')

xorriso \
  -indev "$STOCK_ISO" \
  -outdev "$OUT_ISO" \
  -volume_date "uuid" "$STOCK_UUID_RAW" \
  -boot_image any replay \
  -rm /arch/x86_64/airootfs.sfs -- \
  -map "$WORK/airootfs.sfs" /arch/x86_64/airootfs.sfs \
  -rm /arch/x86_64/airootfs.sha512 -- \
  -map "$WORK/airootfs.sha512" /arch/x86_64/airootfs.sha512 \
  -rm /loader/entries/01-archiso-linux.conf -- \
  -map "$WORK/boot-edit/01-archiso-linux.conf" /loader/entries/01-archiso-linux.conf \
  -rm /loader/entries/02-archiso-speech-linux.conf -- \
  -map "$WORK/boot-edit/02-archiso-speech-linux.conf" /loader/entries/02-archiso-speech-linux.conf \
  -rm /boot/syslinux/archiso_sys-linux.cfg -- \
  -map "$WORK/boot-edit/archiso_sys-linux.cfg" /boot/syslinux/archiso_sys-linux.cfg \
  -map "$OFFLINE/iso" /nosignal \
  -end 2>&1 | tail -5

# ---- cleanup ------------------------------------------------------------
sudo chown "$INVOKING_USER:$INVOKING_GROUP" "$OUT_ISO"
sudo rm -rf "$WORK"

echo
echo "=========================================================="
echo "  Done: $OUT_ISO"
echo "  Size: $(du -h "$OUT_ISO" | cut -f1)"
echo
echo "  Fully offline installer — no network needed on the target."
echo
echo "  Burn to USB:"
echo "    sudo dd if='$OUT_ISO' of=/dev/sdX bs=4M status=progress conv=fsync"
echo
echo "  Or drop $(basename "$OUT_ISO") into your Ventoy USB."
echo "=========================================================="
