Dotfiles/setup/tui-install.sh

877 lines
56 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/bin/bash
# tui-install.sh — TUI installer for the_miro's Arch dotfiles
# -u: treat unset variables as errors; -o pipefail: a pipe fails if any stage fails.
# Intentionally omits -e so individual module failures can be handled explicitly.
set -uo pipefail
# ── Paths ─────────────────────────────────────────────────────────────────────
# Resolve the installer's own directory regardless of where the script was called from.
# BASH_SOURCE[0] stays correct even when the script is sourced rather than executed directly.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Dotfiles root is one level up from setup/.
DOTFILES_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
MODULES="$DOTFILES_DIR/setup/modules"
APPS="$MODULES/optional-Modules/apps"
LOG="$HOME/dotfiles-install.log"
# Private scratch space for the custom DIALOGRC theme file and a temporary colors.conf.
# mktemp -d creates a uniquely named directory that only this process owns.
TMP_D="$(mktemp -d)"
# Cleanup trap: remove the scratch dir and restore the terminal state on any exit signal.
# tput reset issues a full terminal reset sequence; stty sane is the bare-console fallback.
# '|| true' ensures the trap never returns a non-zero code, which would mask the real exit status.
trap 'rm -rf "$TMP_D"; tput reset 2>/dev/null || stty sane 2>/dev/null || true' EXIT INT TERM HUP
# Allow the caller to inject a different answerfile path via the ANSWERFILE env var.
# The default /answerfile.json is a conventional location for CI/PXE boot images.
ANSWERFILE="${ANSWERFILE:-/answerfile.json}"
ANSWERFILE_MODE=false
# Enable unattended mode only when the answerfile is actually present on disk.
[[ -f "$ANSWERFILE" ]] && ANSWERFILE_MODE=true
BACKTITLE="the_miro's Arch Dotfiles"
# ── Terminal dimensions (bare console safe) ───────────────────────────────────
# tput may fail on a bare TTY with no TERM set; fall back to safe 80×24 defaults
# so dialog sizing arithmetic never operates on empty strings.
TERM_H=$(tput lines 2>/dev/null || echo 24)
TERM_W=$(tput cols 2>/dev/null || echo 80)
# ── Cyberqueer dialog theme ───────────────────────────────────────────────────
# dialog reads its color scheme from $DIALOGRC at startup.
# Writing it to TMP_D (rather than ~/.dialogrc) avoids permanently altering
# the user's existing dialog configuration on the live system.
export DIALOGRC="$TMP_D/dialogrc"
# The heredoc uses single-quoted 'EOF' so no variable expansion happens inside;
# every value is a literal dialog color tuple: (FG, BG, BOLD).
cat > "$DIALOGRC" <<'EOF'
use_shadow = ON
use_colors = ON
screen_color = (BLACK,BLACK,ON)
shadow_color = (BLACK,BLACK,ON)
title_color = (MAGENTA,BLACK,ON)
border_color = (MAGENTA,BLACK,ON)
button_active_color = (BLACK,MAGENTA,ON)
button_inactive_color = (WHITE,BLACK,OFF)
button_key_active_color = (BLACK,CYAN,ON)
button_key_inactive_color = (CYAN,BLACK,ON)
button_label_active_color = (BLACK,MAGENTA,ON)
button_label_inactive_color = (WHITE,BLACK,OFF)
inputbox_color = (WHITE,BLACK,OFF)
inputbox_border_color = (MAGENTA,BLACK,ON)
menubox_color = (WHITE,BLACK,OFF)
menubox_border_color = (MAGENTA,BLACK,ON)
item_color = (WHITE,BLACK,OFF)
item_selected_color = (BLACK,MAGENTA,ON)
tag_color = (CYAN,BLACK,ON)
tag_selected_color = (BLACK,CYAN,ON)
tag_key_color = (CYAN,BLACK,ON)
tag_key_selected_color = (BLACK,CYAN,ON)
check_color = (WHITE,BLACK,OFF)
check_selected_color = (BLACK,MAGENTA,ON)
uarrow_color = (MAGENTA,BLACK,ON)
darrow_color = (MAGENTA,BLACK,ON)
EOF
# ── State ─────────────────────────────────────────────────────────────────────
# STEP counts completed modules; TOTAL is pre-computed by count_steps() before
# installation begins so run_module() can print accurate [N/TOTAL] progress.
STEP=0
TOTAL=0
# ── Helpers ───────────────────────────────────────────────────────────────────
require_dialog() {
# Short-circuit if dialog is already present; otherwise bootstrap it with pacman.
# This allows the script to be run on a fresh Arch install that hasn't yet installed dialog.
command -v dialog &>/dev/null && return
echo "dialog not found — installing..."
sudo pacman -S --noconfirm dialog || { echo "Failed to install dialog."; exit 1; }
}
require_jq() {
# Same bootstrap pattern as require_dialog: jq is only needed in answerfile mode
# but we install it on demand to avoid a hard dependency for interactive use.
command -v jq &>/dev/null && return
echo "jq not found — installing..."
sudo pacman -S --noconfirm jq || { echo "Failed to install jq."; exit 1; }
}
die() {
# Fatal error helper: clear the dialog overlay before printing so the message
# is readable, then exit 1. Uses stderr to keep it out of any $() capture.
clear
printf "\n Error: %s\n\n" "$1" >&2
exit 1
}
log_sep() {
# Write a visible separator plus a timestamp to the log before each module run.
# Appended with >> so earlier log entries are never overwritten mid-install.
printf "\n══════════════════════════════════\n %s\n %s\n" "$1" "$(date)" >> "$LOG"
}
run_module() {
local label="$1" script="$2"
STEP=$(( STEP + 1 ))
log_sep "[$STEP/$TOTAL] $label"
# Clear the dialog overlay so module output scrolls cleanly on the raw terminal.
clear
printf "\n\033[1;35m [$STEP/$TOTAL] %s\033[0m\n" "$label"
printf "\033[35m ─────────────────────────────────────────────\033[0m\n\n"
# Run the module script, merging stderr into stdout, and tee to the log.
# PIPESTATUS[0] captures the exit code of 'bash "$script"' even though
# 'tee' is the last command in the pipe (and would always succeed).
local rc=0
bash "$script" 2>&1 | tee -a "$LOG" || rc=${PIPESTATUS[0]}
if [[ $rc -ne 0 ]]; then
if [[ $ANSWERFILE_MODE == true ]]; then
# Unattended mode: log the failure and keep going; aborting mid-CI
# would require a full manual re-run.
printf "\n Warning: %s exited with code %d — continuing.\n" "$label" "$rc" | tee -a "$LOG"
else
# Interactive mode: ask the user whether to abort or continue.
# 'dialog --yesno' returns 0 for Yes and 1 for No; the '|| { ...; exit 1; }'
# fires on No, aborting the install.
dialog --backtitle "$BACKTITLE" \
--title " Module Failed " \
--yesno "$label exited with code $rc.\n\nContinue anyway?" 8 54 \
|| { clear; exit 1; }
fi
fi
}
count_steps() {
# Pre-count the total number of modules that will actually run so that
# run_module() can display accurate [N/TOTAL] progress from the first step.
# Each glob test mirrors the corresponding run_module() call below; they
# must stay in sync whenever a new module is added or removed.
local c="$1" de="$2" a="${3:-}"
TOTAL=0
# Base components: each keyword maps to exactly one module script.
[[ "$c" == *"pkg"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$c" == *"core"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$c" == *"svc"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$c" == *"shell"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$c" == *"plymouth"* ]] && TOTAL=$(( TOTAL + 1 ))
# A non-"none" DE selection always installs exactly one DE module.
[[ "$de" != "none" ]] && TOTAL=$(( TOTAL + 1 ))
# Optional app modules: one glob check per app, one increment per match.
# Glob syntax *"tag"* matches if the space-separated SELECTED_APPS string
# contains the tag anywhere — works because tags never share substrings.
[[ "$a" == *"ollama"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"llama-cpp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"open-webui"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"claude"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"networking-cli"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"disk-recovery"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"himalaya"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"gnuplot"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"blender-povray"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"toot"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"db-clients"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"mysql"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"productivity"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"yt-dlp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"sox"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"imagemagick"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"ffmpeg"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"localtunnel"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"butter"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"tlp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"steam"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"vesktop"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"spotify"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"prism"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"vintagestory"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"openarena"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"tetris"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"doom"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"sauerbraten"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"stuntrally"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"localsend"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"croc"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"opendeck"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"onlyoffice"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"wireshark"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"k8s"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"docker"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"podman"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"cockpit"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"ssh-server"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"freeipa-client"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"freeipa-server"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"freeipa-image"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"python"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"zfs"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"wprs"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"chromium"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"firefox-browser"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"zen-browser"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"nyxt"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"librewolf"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"min-browser"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"vscodium"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"zed-ide"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"geany"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"codeblocks"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"kate"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"rdp-client"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"lamco-rdp-server"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"qemu"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"xournal"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"gimp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"inkscape"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"krita"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"ardour"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"audacity"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"lmms"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"mixxx"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"cecilia"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"kdenlive"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"openshot"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"shotcut"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"anti-malware"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"timeshift"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"mail-notmuch"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"caldav-sync"* ]] && TOTAL=$(( TOTAL + 1 ))
}
# ── Answerfile ────────────────────────────────────────────────────────────────
# AF_* variables hold values parsed from the JSON answerfile.
# They are initialised to safe defaults so the rest of the script can reference
# them unconditionally whether or not answerfile mode is active.
AF_HOSTNAME=""
AF_COMPONENTS=""
AF_DE="none"
AF_APPS=""
AF_SHELL_RC="dotfiles"
AF_COLOR_TEXT=""
AF_COLOR_BG=""
AF_COLOR_HIGHLIGHT=""
AF_COLOR_DARK=""
AF_COLOR_RED=""
load_answerfile() {
require_jq
# -r outputs raw strings without JSON quoting.
# '// ""' / '// "none"' are jq's null-coalescing operator: if the key is
# absent from the file the expression yields the fallback instead.
AF_HOSTNAME=$(jq -r '.hostname // ""' "$ANSWERFILE")
# JSON arrays are flattened to a space-separated string to match the format
# that COMPONENTS and SELECTED_APPS expect throughout the rest of the script.
AF_COMPONENTS=$(jq -r '(.components // []) | join(" ")' "$ANSWERFILE")
AF_DE=$(jq -r '.desktop_environment // "none"' "$ANSWERFILE")
AF_APPS=$(jq -r '(.apps // []) | join(" ")' "$ANSWERFILE")
AF_SHELL_RC=$(jq -r '.shell_rc // "dotfiles"' "$ANSWERFILE")
# Color values are optional; an empty string means "keep the repo default".
AF_COLOR_TEXT=$(jq -r '.colors.COLOR_TEXT // ""' "$ANSWERFILE")
AF_COLOR_BG=$(jq -r '.colors.COLOR_BG // ""' "$ANSWERFILE")
AF_COLOR_HIGHLIGHT=$(jq -r '.colors.COLOR_HIGHLIGHT // ""' "$ANSWERFILE")
AF_COLOR_DARK=$(jq -r '.colors.COLOR_DARK // ""' "$ANSWERFILE")
AF_COLOR_RED=$(jq -r '.colors.COLOR_RED // ""' "$ANSWERFILE")
}
# ── MAC address helper ────────────────────────────────────────────────────────
get_mac_suffix() {
# Returns the MAC address of the first non-loopback interface, colons stripped.
# Used to make answerfile-derived hostnames unique per machine (e.g. "myhost-aabbccddee").
local mac
# awk state machine: sets iface=1 when it sees an interface line whose name
# does NOT start with "lo" ([^l][^o] skips "lo"), then captures the
# 'link/ether XX:XX:XX:XX:XX:XX' line and exits immediately after the first hit.
mac=$(ip link show 2>/dev/null \
| awk '/^[0-9]+: [^l][^o]/{iface=1} iface && /link\/ether/{print $2; iface=0; exit}')
# Strip colons from the MAC so it is safe to embed in a hostname (only [a-z0-9-] allowed).
printf '%s' "${mac//:/}"
}
# ── Preflight ─────────────────────────────────────────────────────────────────
if [[ $EUID -eq 0 ]]; then
# Root context (e.g. archiso chroot): shim sudo as a passthrough
# Module scripts call 'sudo' for privilege operations; when we are already root
# there is no sudo binary (or it may be absent), so inject a fake one that is
# just 'exec "$@"' — transparently forwarding all arguments.
mkdir -p "$TMP_D/bin"
printf '#!/bin/bash\nexec "$@"\n' > "$TMP_D/bin/sudo"
chmod +x "$TMP_D/bin/sudo"
# Prepend to PATH so this shim takes precedence over any real sudo.
export PATH="$TMP_D/bin:$PATH"
fi
# Hard guard: pacman is the package manager used by every module; without it
# the installer cannot function on this OS.
command -v pacman &>/dev/null || die "pacman not found — Arch Linux required."
# Ensure the dialog binary is available before we try to use it.
require_dialog
if $ANSWERFILE_MODE; then
load_answerfile
printf "Answerfile mode: %s\n" "$ANSWERFILE" | tee -a "$LOG"
fi
# Network check: -c1 sends one ICMP echo, -W3 waits up to 3 seconds for reply.
# We test against archlinux.org because that's where pacman will fetch packages.
if ! ping -c1 -W3 archlinux.org &>/dev/null; then
if $ANSWERFILE_MODE; then
# In unattended mode just warn; the caller decides whether the image
# is expected to be offline (e.g. local mirror pre-seeded in pacman).
printf "Warning: no internet connection detected.\n" | tee -a "$LOG"
else
# Give the user a chance to plug in a cable or run iwctl before we
# re-test. A second failed ping offers a soft-abort rather than a
# hard failure, in case the user wants to proceed with a local mirror.
dialog --backtitle "$BACKTITLE" \
--title " No Network Detected " \
--msgbox "\n No internet connection found.\n\n Wired: ensure the cable is plugged in.\n WiFi: switch to another TTY (Alt+F2)\n and run: iwctl\n\n Press OK once connected.\n" 13 58
if ! ping -c1 -W3 archlinux.org &>/dev/null; then
dialog --backtitle "$BACKTITLE" \
--title " Still Offline " \
--yesno "\n Still no internet connection.\n\n Packages cannot be downloaded without network access.\n\n Continue anyway?" 11 58 \
|| { clear; echo "Aborted — no network."; exit 1; }
fi
fi
fi
# Truncate the log file now that preflight passed; all prior output was to stdout.
# Entries appended from this point on are the authoritative install log.
> "$LOG"
printf "Dotfiles install: %s\nDotfiles dir: %s\n" "$(date)" "$DOTFILES_DIR" >> "$LOG"
# ── Welcome ───────────────────────────────────────────────────────────────────
if ! $ANSWERFILE_MODE; then
dialog --backtitle "$BACKTITLE" \
--title " Welcome " \
--msgbox "\n\
the_miro's Arch dotfiles installer\n\
Cyberqueer · Wayland · Hyprland\n\
─────────────────────────────────────────\n\
\n\
Arch Linux — network admin, development & gaming\n\
\n\
Source: $DOTFILES_DIR\n\
Log: $LOG\n" 14 62
fi
# ── Hostname ──────────────────────────────────────────────────────────────────
HOSTNAME_SET=""
if $ANSWERFILE_MODE; then
if [[ -n "$AF_HOSTNAME" ]]; then
# Append the stripped MAC address to the base name from the answerfile.
# This makes each cloned machine's hostname unique without needing per-host
# answerfiles (useful for mass-provisioning identical images).
MAC=$(get_mac_suffix)
HOSTNAME_SET="${AF_HOSTNAME}-${MAC}"
printf "Hostname (from answerfile + MAC): %s\n" "$HOSTNAME_SET" | tee -a "$LOG"
fi
else
# dialog --inputbox: 3>&1 1>&2 2>&3 swaps fd1 and fd2 so the user's typed
# value (written to stdout by dialog) is captured by the $() subshell, while
# dialog's UI (which needs stderr for the terminal) flows to the actual terminal.
# The '|| HOSTNAME_INPUT=""' handles the user pressing Esc (dialog returns 1).
HOSTNAME_INPUT=$(dialog --backtitle "$BACKTITLE" \
--title " Hostname " \
--inputbox "\n Hostname for this machine (leave blank to keep default).\n" 9 54 "" \
3>&1 1>&2 2>&3) || HOSTNAME_INPUT=""
HOSTNAME_SET="$HOSTNAME_INPUT"
fi
if [[ -n "$HOSTNAME_SET" ]]; then
# hostnamectl is the preferred method on systemd systems; fall back to writing
# /etc/hostname directly for minimal chroot environments that lack systemd.
sudo hostnamectl set-hostname "$HOSTNAME_SET" 2>/dev/null \
|| echo "$HOSTNAME_SET" | sudo tee /etc/hostname > /dev/null
printf "Hostname set: %s\n" "$HOSTNAME_SET" >> "$LOG"
fi
# ── Component selection ───────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
COMPONENTS="$AF_COMPONENTS"
else
# dialog --checklist args: height width list-height, then triplets of tag desc state.
# All four components are pre-selected ("on") because they form the expected base.
# The 3>&1 1>&2 2>&3 fd swap captures dialog's output (the selected tags) via $().
# Esc / Cancel returns exit code 1; the '|| { ...; exit 0; }' treats that as a clean abort.
COMPONENTS=$(dialog --backtitle "$BACKTITLE" \
--title " Select Components " \
--checklist "Space toggles · Enter confirms · Esc quits" 16 68 5 \
"pkg" "Package managers yay · nvm · rust" on \
"core" "Core packages 100+ base system packages" on \
"svc" "Core services NetworkManager · cronie · fail2ban" on \
"shell" "Shell setup zsh · nvim · yazi · micro · starship" on \
"plymouth" "Plymouth boot splash — skull logo + spinner" on \
3>&1 1>&2 2>&3) || { clear; echo "Aborted."; exit 0; }
fi
# ── DE selection ──────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
DE="$AF_DE"
else
# dialog --menu is a single-choice list; it outputs the selected tag.
# Esc returns exit code 1 — '|| DE="none"' defaults to skipping the DE,
# preserving whatever DE the user already has installed.
DE=$(dialog --backtitle "$BACKTITLE" \
--title " Desktop Environment " \
--menu "Select a desktop environment · Esc / none to skip:" 24 72 11 \
"hyprlua" "HyprLua — Hyprland with Lua config (recommended)" \
"niri" "Niri — scrollable-tiling Wayland compositor" \
"hyprland" "Hyprland — Wayland WM, hyprlang config (legacy)" \
"sway" "Sway — Wayland tiling WM" \
"kde-plasma" "KDE Plasma — feature-rich Wayland/X11 DE" \
"gnome" "GNOME — modern Wayland DE" \
"cosmic" "COSMIC — Rust-built Wayland DE (System76)" \
"xfce" "XFCE — lightweight X11 DE" \
"lxqt" "LXQt — lightweight Qt X11 DE" \
"none" "Skip DE installation" \
3>&1 1>&2 2>&3) || DE="none"
fi
# ── Apps selection ────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
SELECTED_APPS="$AF_APPS"
else
# Cap the dialog box at 40 rows but shrink to fit smaller terminals.
# The list height is the dialog height minus 8 rows of chrome (title, borders,
# prompt, buttons), with a minimum of 4 to remain usable on tiny screens.
_APP_H=$(( TERM_H - 2 < 40 ? TERM_H - 2 : 40 ))
_APP_LIST_H=$(( _APP_H - 8 < 4 ? 4 : _APP_H - 8 ))
SELECTED_APPS=$(dialog --backtitle "$BACKTITLE" \
--title " Applications " \
--checklist "Optional applications — installed after base components:" "$_APP_H" 76 "$_APP_LIST_H" \
"ollama" "Ollama local LLM runner + API server" off \
"llama-cpp" "llama.cpp standalone inference CLI + server" off \
"open-webui" "Open WebUI browser UI for Ollama / LLM backends" off \
"claude" "Claude Code Anthropic CLI via npm" off \
"networking-cli" "Networking CLI nmap · nethogs · mitmproxy · httpie" off \
"disk-recovery" "Disk Recovery ddrescue · f3" off \
"himalaya" "Himalaya terminal email client (AUR)" off \
"mail-notmuch" "Mail (notmuch) isync · msmtp · notmuch · alot stack" off \
"caldav-sync" "CalDAV Sync vdirsyncer · khal calendar sync" off \
"gnuplot" "Gnuplot scientific plotting" off \
"blender-povray" "Blender + POV-Ray 3D modelling & ray-tracing" off \
"toot" "toot Mastodon CLI client (AUR)" off \
"db-clients" "DB Clients pgcli · mycli" off \
"mysql" "MySQL / MariaDB mariadb server + setup" off \
"productivity" "Productivity taskwarrior · watson · jrnl" off \
"yt-dlp" "yt-dlp YouTube / media downloader" off \
"sox" "SoX audio processing toolkit" off \
"imagemagick" "ImageMagick image manipulation" off \
"ffmpeg" "FFmpeg extras thumbnailer · GStreamer codecs" off \
"localtunnel" "LocalTunnel expose localhost via tunnel" off \
"butter" "butter btrfs snapshot backup (AUR)" off \
"tlp" "TLP laptop power management" off \
"steam" "Steam gaming platform" off \
"vesktop" "Vesktop Discord + Vencord theme" off \
"spotify" "Spotify launcher + Spicetify theming" off \
"prism" "PrismLauncher Minecraft launcher (Flatpak)" off \
"vintagestory" "Vintage Story survival game (AUR)" off \
"openarena" "OpenArena open-source Quake III Arena" off \
"tetris" "Tetris CLI bastet · vitetris" off \
"doom" "Doom Chocolate Doom + Freedoom data" off \
"sauerbraten" "Sauerbraten open-source FPS (Cube 2)" off \
"stuntrally" "Stunt Rally rally racing game (Flatpak)" off \
"localsend" "LocalSend LAN file transfer (AUR)" off \
"croc" "croc cross-platform file transfer" off \
"opendeck" "OpenDeck Stream Deck controller (Flatpak+ydotool)" off \
"onlyoffice" "OnlyOffice office suite (AUR)" off \
"xournal" "Xournal++ note-taking & PDF annotator" off \
"gimp" "GIMP GNU image manipulation program" off \
"inkscape" "Inkscape vector graphics editor" off \
"krita" "Krita digital painting application" off \
"ardour" "Ardour professional DAW" off \
"audacity" "Audacity multi-track audio editor" off \
"lmms" "LMMS Linux MultiMedia Studio DAW" off \
"mixxx" "Mixxx DJ mixing software" off \
"cecilia" "Cecilia audio signal processing (AUR)" off \
"kdenlive" "Kdenlive KDE non-linear video editor" off \
"openshot" "OpenShot easy video editor" off \
"shotcut" "Shotcut cross-platform video editor" off \
"anti-malware" "Anti-Malware ClamAV · rkhunter · chkrootkit" off \
"timeshift" "Timeshift system snapshot / backup + autosnap" off \
"wireshark" "Wireshark network packet analyser (GUI)" off \
"k8s" "Kubernetes tools kubectl · podman-desktop" off \
"docker" "Docker docker · docker-compose" off \
"podman" "Podman rootless containers · buildah" off \
"cockpit" "Cockpit web UI · machines · podman" off \
"ssh-server" "SSH server openssh · key-auth · enabled" off \
"freeipa-client" "FreeIPA Client sssd + ipa-client-install + enrollment" off \
"freeipa-server" "FreeIPA Server interactive server setup + client gen" off \
"freeipa-image" "FreeIPA Image OCI/LXC/Proxmox LXC builder + Keycloak" off \
"python" "Python tools pyright · pipx · pynvim" off \
"zfs" "ZFS zfs-dkms kernel module" off \
"wprs" "WPRS wprs-git (AUR)" off \
\
"chromium" "Chromium open-source browser (official)" off \
"firefox-browser" "Firefox Mozilla browser (official)" off \
"zen-browser" "Zen Browser Firefox-based privacy browser (AUR)" off \
"nyxt" "Nyxt keyboard-driven browser (AUR)" off \
"librewolf" "LibreWolf hardened Firefox fork (AUR)" off \
"min-browser" "Min minimal Electron browser (AUR)" off \
\
"vscodium" "VSCodium telemetry-free VS Code (AUR)" off \
"zed-ide" "Zed high-performance Rust IDE (official)" off \
"geany" "Geany lightweight IDE + plugins (official)" off \
"codeblocks" "Code::Blocks C/C++ IDE (official)" off \
"kate" "Kate KDE advanced text editor (official)" off \
\
"rdp-client" "RDP Client Remmina + FreeRDP + VNC plugins" off \
"lamco-rdp-server" "Lamco RDP Server native Wayland RDP server (AUR, Rust)" off \
"qemu" "QEMU/KVM full virt stack + virt-manager GUI" off \
# Esc / Cancel yields exit code 1; treat it as "no optional apps selected"
# rather than a hard abort so the user can still install base components.
3>&1 1>&2 2>&3) || SELECTED_APPS=""
fi
# ── Shell RC preference ───────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
SHELL_RC="$AF_SHELL_RC"
else
SHELL_RC=$(dialog --backtitle "$BACKTITLE" \
--title " Shell Config for New Users " \
--menu "\n Should new users on this machine inherit the dotfiles' rc files?\n (Controls what gets copied to /etc/skel)\n" 13 68 2 \
"dotfiles" "Use the_miro's .zshrc / .bashrc / .vimrc from dotfiles" \
"defaults" "Skip — use system defaults for new users" \
3>&1 1>&2 2>&3) || SHELL_RC="defaults"
fi
# ── Confirmation (interactive mode only) ──────────────────────────────────────
if ! $ANSWERFILE_MODE; then
# Build a human-readable summary of everything that will be installed so the
# user can review the full list before any changes are made to the system.
SUMMARY=""
[[ -n "$HOSTNAME_SET" ]] && SUMMARY+=" ✦ Hostname: $HOSTNAME_SET\n"
[[ "$COMPONENTS" == *"pkg"* ]] && SUMMARY+=" ✦ Package managers (yay, nvm, rust)\n"
[[ "$COMPONENTS" == *"core"* ]] && SUMMARY+=" ✦ Core packages\n"
[[ "$COMPONENTS" == *"svc"* ]] && SUMMARY+=" ✦ Core services\n"
[[ "$COMPONENTS" == *"shell"* ]] && SUMMARY+=" ✦ Shell setup\n"
[[ "$COMPONENTS" == *"plymouth"* ]] && SUMMARY+=" ✦ Plymouth boot splash\n"
[[ "$DE" != "none" && "$DE" != "" ]] && SUMMARY+=" ✦ Desktop environment: $DE\n"
[[ "$SHELL_RC" == "dotfiles" ]] && SUMMARY+=" ✦ Shell rc files → /etc/skel (dotfiles)\n" \
|| SUMMARY+=" ✦ Shell rc files → /etc/skel (system defaults)\n"
if [[ -n "$SELECTED_APPS" ]]; then
SUMMARY+="\n Applications:\n"
[[ "$SELECTED_APPS" == *"ollama"* ]] && SUMMARY+=" ✦ Ollama\n"
[[ "$SELECTED_APPS" == *"llama-cpp"* ]] && SUMMARY+=" ✦ llama.cpp\n"
[[ "$SELECTED_APPS" == *"open-webui"* ]] && SUMMARY+=" ✦ Open WebUI\n"
[[ "$SELECTED_APPS" == *"claude"* ]] && SUMMARY+=" ✦ Claude Code\n"
[[ "$SELECTED_APPS" == *"networking-cli"* ]] && SUMMARY+=" ✦ Networking CLI (nmap, nethogs, mitmproxy, httpie)\n"
[[ "$SELECTED_APPS" == *"disk-recovery"* ]] && SUMMARY+=" ✦ Disk Recovery (ddrescue, f3)\n"
[[ "$SELECTED_APPS" == *"himalaya"* ]] && SUMMARY+=" ✦ Himalaya\n"
[[ "$SELECTED_APPS" == *"gnuplot"* ]] && SUMMARY+=" ✦ Gnuplot\n"
[[ "$SELECTED_APPS" == *"blender-povray"* ]] && SUMMARY+=" ✦ Blender + POV-Ray\n"
[[ "$SELECTED_APPS" == *"toot"* ]] && SUMMARY+=" ✦ toot\n"
[[ "$SELECTED_APPS" == *"db-clients"* ]] && SUMMARY+=" ✦ DB Clients (pgcli, mycli)\n"
[[ "$SELECTED_APPS" == *"mysql"* ]] && SUMMARY+=" ✦ MySQL / MariaDB\n"
[[ "$SELECTED_APPS" == *"productivity"* ]] && SUMMARY+=" ✦ Productivity (taskwarrior, watson, jrnl)\n"
[[ "$SELECTED_APPS" == *"yt-dlp"* ]] && SUMMARY+=" ✦ yt-dlp\n"
[[ "$SELECTED_APPS" == *"sox"* ]] && SUMMARY+=" ✦ SoX\n"
[[ "$SELECTED_APPS" == *"imagemagick"* ]] && SUMMARY+=" ✦ ImageMagick\n"
[[ "$SELECTED_APPS" == *"ffmpeg"* ]] && SUMMARY+=" ✦ FFmpeg extras\n"
[[ "$SELECTED_APPS" == *"localtunnel"* ]] && SUMMARY+=" ✦ LocalTunnel\n"
[[ "$SELECTED_APPS" == *"butter"* ]] && SUMMARY+=" ✦ butter (btrfs backup)\n"
[[ "$SELECTED_APPS" == *"tlp"* ]] && SUMMARY+=" ✦ TLP\n"
[[ "$SELECTED_APPS" == *"steam"* ]] && SUMMARY+=" ✦ Steam\n"
[[ "$SELECTED_APPS" == *"vesktop"* ]] && SUMMARY+=" ✦ Vesktop + Vencord theme\n"
[[ "$SELECTED_APPS" == *"spotify"* ]] && SUMMARY+=" ✦ Spotify + Spicetify\n"
[[ "$SELECTED_APPS" == *"prism"* ]] && SUMMARY+=" ✦ PrismLauncher\n"
[[ "$SELECTED_APPS" == *"vintagestory"* ]] && SUMMARY+=" ✦ Vintage Story\n"
[[ "$SELECTED_APPS" == *"openarena"* ]] && SUMMARY+=" ✦ OpenArena\n"
[[ "$SELECTED_APPS" == *"tetris"* ]] && SUMMARY+=" ✦ Tetris CLI (bastet · vitetris)\n"
[[ "$SELECTED_APPS" == *"doom"* ]] && SUMMARY+=" ✦ Doom\n"
[[ "$SELECTED_APPS" == *"sauerbraten"* ]] && SUMMARY+=" ✦ Sauerbraten\n"
[[ "$SELECTED_APPS" == *"stuntrally"* ]] && SUMMARY+=" ✦ Stunt Rally\n"
[[ "$SELECTED_APPS" == *"localsend"* ]] && SUMMARY+=" ✦ LocalSend\n"
[[ "$SELECTED_APPS" == *"croc"* ]] && SUMMARY+=" ✦ croc\n"
[[ "$SELECTED_APPS" == *"opendeck"* ]] && SUMMARY+=" ✦ OpenDeck + ydotool\n"
[[ "$SELECTED_APPS" == *"onlyoffice"* ]] && SUMMARY+=" ✦ OnlyOffice\n"
[[ "$SELECTED_APPS" == *"xournal"* ]] && SUMMARY+=" ✦ Xournal++\n"
[[ "$SELECTED_APPS" == *"gimp"* ]] && SUMMARY+=" ✦ GIMP\n"
[[ "$SELECTED_APPS" == *"inkscape"* ]] && SUMMARY+=" ✦ Inkscape\n"
[[ "$SELECTED_APPS" == *"krita"* ]] && SUMMARY+=" ✦ Krita\n"
[[ "$SELECTED_APPS" == *"ardour"* ]] && SUMMARY+=" ✦ Ardour\n"
[[ "$SELECTED_APPS" == *"audacity"* ]] && SUMMARY+=" ✦ Audacity\n"
[[ "$SELECTED_APPS" == *"lmms"* ]] && SUMMARY+=" ✦ LMMS\n"
[[ "$SELECTED_APPS" == *"mixxx"* ]] && SUMMARY+=" ✦ Mixxx\n"
[[ "$SELECTED_APPS" == *"cecilia"* ]] && SUMMARY+=" ✦ Cecilia\n"
[[ "$SELECTED_APPS" == *"kdenlive"* ]] && SUMMARY+=" ✦ Kdenlive\n"
[[ "$SELECTED_APPS" == *"openshot"* ]] && SUMMARY+=" ✦ OpenShot\n"
[[ "$SELECTED_APPS" == *"shotcut"* ]] && SUMMARY+=" ✦ Shotcut\n"
[[ "$SELECTED_APPS" == *"anti-malware"* ]] && SUMMARY+=" ✦ Anti-Malware (ClamAV, rkhunter, chkrootkit)\n"
[[ "$SELECTED_APPS" == *"timeshift"* ]] && SUMMARY+=" ✦ Timeshift\n"
[[ "$SELECTED_APPS" == *"wireshark"* ]] && SUMMARY+=" ✦ Wireshark\n"
[[ "$SELECTED_APPS" == *"k8s"* ]] && SUMMARY+=" ✦ Kubernetes tools\n"
[[ "$SELECTED_APPS" == *"docker"* ]] && SUMMARY+=" ✦ Docker + Compose\n"
[[ "$SELECTED_APPS" == *"podman"* ]] && SUMMARY+=" ✦ Podman (rootless) + Buildah\n"
[[ "$SELECTED_APPS" == *"cockpit"* ]] && SUMMARY+=" ✦ Cockpit web UI\n"
[[ "$SELECTED_APPS" == *"ssh-server"* ]] && SUMMARY+=" ✦ SSH server (openssh, key auth)\n"
[[ "$SELECTED_APPS" == *"freeipa-client"* ]] && SUMMARY+=" ✦ FreeIPA Client\n"
[[ "$SELECTED_APPS" == *"freeipa-server"* ]] && SUMMARY+=" ✦ FreeIPA Server\n"
[[ "$SELECTED_APPS" == *"freeipa-image"* ]] && SUMMARY+=" ✦ FreeIPA Image Builder\n"
[[ "$SELECTED_APPS" == *"python"* ]] && SUMMARY+=" ✦ Python tools\n"
[[ "$SELECTED_APPS" == *"zfs"* ]] && SUMMARY+=" ✦ ZFS\n"
[[ "$SELECTED_APPS" == *"wprs"* ]] && SUMMARY+=" ✦ WPRS\n"
[[ "$SELECTED_APPS" == *"chromium"* ]] && SUMMARY+=" ✦ Chromium\n"
[[ "$SELECTED_APPS" == *"firefox-browser"* ]] && SUMMARY+=" ✦ Firefox\n"
[[ "$SELECTED_APPS" == *"zen-browser"* ]] && SUMMARY+=" ✦ Zen Browser\n"
[[ "$SELECTED_APPS" == *"nyxt"* ]] && SUMMARY+=" ✦ Nyxt\n"
[[ "$SELECTED_APPS" == *"librewolf"* ]] && SUMMARY+=" ✦ LibreWolf\n"
[[ "$SELECTED_APPS" == *"min-browser"* ]] && SUMMARY+=" ✦ Min Browser\n"
[[ "$SELECTED_APPS" == *"vscodium"* ]] && SUMMARY+=" ✦ VSCodium\n"
[[ "$SELECTED_APPS" == *"zed-ide"* ]] && SUMMARY+=" ✦ Zed IDE\n"
[[ "$SELECTED_APPS" == *"geany"* ]] && SUMMARY+=" ✦ Geany\n"
[[ "$SELECTED_APPS" == *"codeblocks"* ]] && SUMMARY+=" ✦ Code::Blocks\n"
[[ "$SELECTED_APPS" == *"kate"* ]] && SUMMARY+=" ✦ Kate\n"
[[ "$SELECTED_APPS" == *"rdp-client"* ]] && SUMMARY+=" ✦ RDP Client (Remmina + FreeRDP)\n"
[[ "$SELECTED_APPS" == *"lamco-rdp-server"* ]] && SUMMARY+=" ✦ Lamco RDP Server (native Wayland)\n"
[[ "$SELECTED_APPS" == *"qemu"* ]] && SUMMARY+=" ✦ QEMU/KVM + virt-manager\n"
fi
# Size the confirmation dialog to the terminal, capped at 24 rows.
_CONF_H=$(( TERM_H - 2 < 24 ? TERM_H - 2 : 24 ))
dialog --backtitle "$BACKTITLE" \
--title " Confirm Installation " \
--yesno "\n Components to install:\n\n${SUMMARY}\n Log: $LOG\n\n Proceed?" \
"$_CONF_H" 62 || { clear; echo "Aborted."; exit 0; }
fi
# Pre-count all selected steps before installation starts so run_module() can
# display [N/TOTAL] with an accurate denominator from the very first module.
count_steps "$COMPONENTS" "$DE" "$SELECTED_APPS"
# ── Installation: base components ─────────────────────────────────────────────
# Each guard uses glob matching against the space-separated COMPONENTS string.
# Order matters: package managers must be installed before packages that need yay/rust.
[[ "$COMPONENTS" == *"pkg"* ]] && run_module "Package Managers" "$MODULES/package-managers.sh"
[[ "$COMPONENTS" == *"core"* ]] && run_module "Core Packages" "$MODULES/core-packages.sh"
[[ "$COMPONENTS" == *"svc"* ]] && run_module "Core Services" "$MODULES/core.sh"
[[ "$COMPONENTS" == *"shell"* ]] && run_module "Shell Setup" "$MODULES/shell-setup.sh"
[[ "$COMPONENTS" == *"plymouth"* ]] && run_module "Plymouth" "$MODULES/optional-Modules/plymouth.sh"
# Route the single selected DE value to its corresponding install script.
# "none" is the skip sentinel — no case branch matches it intentionally.
if [[ "$DE" != "none" ]]; then
case "$DE" in
hyprlua) run_module "HyprLua" "$MODULES/Desktop-Environments/hyprlua.sh" ;;
niri) run_module "Niri" "$MODULES/Desktop-Environments/niri.sh" ;;
hyprland) run_module "Hyprland" "$MODULES/Desktop-Environments/hyprland.sh" ;;
sway) run_module "Sway" "$MODULES/Desktop-Environments/sway.sh" ;;
kde-plasma) run_module "KDE Plasma" "$MODULES/Desktop-Environments/kde-plasma.sh" ;;
gnome) run_module "GNOME" "$MODULES/Desktop-Environments/gnome.sh" ;;
cosmic) run_module "COSMIC" "$MODULES/Desktop-Environments/cosmic.sh" ;;
xfce) run_module "XFCE" "$MODULES/Desktop-Environments/xfce.sh" ;;
lxqt) run_module "LXQt" "$MODULES/Desktop-Environments/lxqt.sh" ;;
esac
fi
# ── Installation: applications ────────────────────────────────────────────────
# Same guard pattern as base components. Each line is independent; a missing
# module script will be caught by run_module()'s error handling rather than
# silently skipped — 'bash "$script"' will exit non-zero if the file is absent.
[[ "$SELECTED_APPS" == *"ollama"* ]] && run_module "Ollama" "$APPS/ollama.sh"
[[ "$SELECTED_APPS" == *"llama-cpp"* ]] && run_module "llama.cpp" "$APPS/llama-cpp.sh"
[[ "$SELECTED_APPS" == *"open-webui"* ]] && run_module "Open WebUI" "$APPS/open-webui.sh"
[[ "$SELECTED_APPS" == *"claude"* ]] && run_module "Claude Code" "$APPS/claude.sh"
[[ "$SELECTED_APPS" == *"networking-cli"* ]] && run_module "Networking CLI" "$APPS/networking-cli.sh"
[[ "$SELECTED_APPS" == *"disk-recovery"* ]] && run_module "Disk Recovery" "$APPS/disk-recovery.sh"
[[ "$SELECTED_APPS" == *"himalaya"* ]] && run_module "Himalaya" "$APPS/himalaya.sh"
[[ "$SELECTED_APPS" == *"mail-notmuch"* ]] && run_module "Mail (notmuch)" "$APPS/mail-notmuch.sh"
[[ "$SELECTED_APPS" == *"caldav-sync"* ]] && run_module "CalDAV Sync" "$APPS/caldav-sync.sh"
[[ "$SELECTED_APPS" == *"gnuplot"* ]] && run_module "Gnuplot" "$APPS/gnuplot.sh"
[[ "$SELECTED_APPS" == *"blender-povray"* ]] && run_module "Blender + POV-Ray" "$APPS/blender-povray.sh"
[[ "$SELECTED_APPS" == *"toot"* ]] && run_module "toot" "$APPS/toot.sh"
[[ "$SELECTED_APPS" == *"db-clients"* ]] && run_module "DB Clients" "$APPS/db-clients.sh"
[[ "$SELECTED_APPS" == *"mysql"* ]] && run_module "MySQL / MariaDB" "$APPS/mysql.sh"
[[ "$SELECTED_APPS" == *"productivity"* ]] && run_module "Productivity" "$APPS/productivity.sh"
[[ "$SELECTED_APPS" == *"yt-dlp"* ]] && run_module "yt-dlp" "$APPS/yt-dlp.sh"
[[ "$SELECTED_APPS" == *"sox"* ]] && run_module "SoX" "$APPS/sox.sh"
[[ "$SELECTED_APPS" == *"imagemagick"* ]] && run_module "ImageMagick" "$APPS/imagemagick.sh"
[[ "$SELECTED_APPS" == *"ffmpeg"* ]] && run_module "FFmpeg extras" "$APPS/ffmpeg.sh"
[[ "$SELECTED_APPS" == *"localtunnel"* ]] && run_module "LocalTunnel" "$APPS/localtunnel.sh"
[[ "$SELECTED_APPS" == *"butter"* ]] && run_module "butter" "$APPS/butter.sh"
[[ "$SELECTED_APPS" == *"tlp"* ]] && run_module "TLP" "$APPS/tlp.sh"
[[ "$SELECTED_APPS" == *"steam"* ]] && run_module "Steam" "$APPS/steam.sh"
[[ "$SELECTED_APPS" == *"vesktop"* ]] && run_module "Vesktop" "$APPS/vesktop.sh"
[[ "$SELECTED_APPS" == *"spotify"* ]] && run_module "Spotify" "$APPS/spotify.sh"
[[ "$SELECTED_APPS" == *"prism"* ]] && run_module "PrismLauncher" "$APPS/prismlauncher.sh"
[[ "$SELECTED_APPS" == *"vintagestory"* ]] && run_module "Vintage Story" "$APPS/vintagestory.sh"
[[ "$SELECTED_APPS" == *"openarena"* ]] && run_module "OpenArena" "$APPS/openarena.sh"
[[ "$SELECTED_APPS" == *"tetris"* ]] && run_module "Tetris CLI" "$APPS/tetris.sh"
[[ "$SELECTED_APPS" == *"doom"* ]] && run_module "Doom" "$APPS/doom.sh"
[[ "$SELECTED_APPS" == *"sauerbraten"* ]] && run_module "Sauerbraten" "$APPS/sauerbraten.sh"
[[ "$SELECTED_APPS" == *"stuntrally"* ]] && run_module "Stunt Rally" "$APPS/stuntrally.sh"
[[ "$SELECTED_APPS" == *"localsend"* ]] && run_module "LocalSend" "$APPS/localsend.sh"
[[ "$SELECTED_APPS" == *"croc"* ]] && run_module "croc" "$APPS/croc.sh"
[[ "$SELECTED_APPS" == *"opendeck"* ]] && run_module "OpenDeck" "$APPS/opendeck.sh"
[[ "$SELECTED_APPS" == *"onlyoffice"* ]] && run_module "OnlyOffice" "$APPS/onlyoffice.sh"
[[ "$SELECTED_APPS" == *"xournal"* ]] && run_module "Xournal++" "$APPS/xournal.sh"
[[ "$SELECTED_APPS" == *"gimp"* ]] && run_module "GIMP" "$APPS/gimp.sh"
[[ "$SELECTED_APPS" == *"inkscape"* ]] && run_module "Inkscape" "$APPS/inkscape.sh"
[[ "$SELECTED_APPS" == *"krita"* ]] && run_module "Krita" "$APPS/krita.sh"
[[ "$SELECTED_APPS" == *"ardour"* ]] && run_module "Ardour" "$APPS/ardour.sh"
[[ "$SELECTED_APPS" == *"audacity"* ]] && run_module "Audacity" "$APPS/audacity.sh"
[[ "$SELECTED_APPS" == *"lmms"* ]] && run_module "LMMS" "$APPS/lmms.sh"
[[ "$SELECTED_APPS" == *"mixxx"* ]] && run_module "Mixxx" "$APPS/mixxx.sh"
[[ "$SELECTED_APPS" == *"cecilia"* ]] && run_module "Cecilia" "$APPS/cecilia.sh"
[[ "$SELECTED_APPS" == *"kdenlive"* ]] && run_module "Kdenlive" "$APPS/kdenlive.sh"
[[ "$SELECTED_APPS" == *"openshot"* ]] && run_module "OpenShot" "$APPS/openshot.sh"
[[ "$SELECTED_APPS" == *"shotcut"* ]] && run_module "Shotcut" "$APPS/shotcut.sh"
[[ "$SELECTED_APPS" == *"anti-malware"* ]] && run_module "Anti-Malware" "$APPS/anti-malware.sh"
[[ "$SELECTED_APPS" == *"timeshift"* ]] && run_module "Timeshift" "$APPS/timeshift.sh"
[[ "$SELECTED_APPS" == *"wireshark"* ]] && run_module "Wireshark" "$APPS/wireshark.sh"
[[ "$SELECTED_APPS" == *"k8s"* ]] && run_module "Kubernetes Tools" "$APPS/k8s.sh"
[[ "$SELECTED_APPS" == *"docker"* ]] && run_module "Docker" "$APPS/docker.sh"
[[ "$SELECTED_APPS" == *"podman"* ]] && run_module "Podman" "$APPS/podman.sh"
[[ "$SELECTED_APPS" == *"cockpit"* ]] && run_module "Cockpit" "$APPS/cockpit.sh"
[[ "$SELECTED_APPS" == *"ssh-server"* ]] && run_module "SSH Server" "$APPS/ssh-server.sh"
[[ "$SELECTED_APPS" == *"freeipa-client"* ]] && run_module "FreeIPA Client" "$APPS/freeipa-client.sh"
[[ "$SELECTED_APPS" == *"freeipa-server"* ]] && run_module "FreeIPA Server" "$APPS/freeipa-server.sh"
[[ "$SELECTED_APPS" == *"freeipa-image"* ]] && run_module "FreeIPA Image" "$APPS/freeipa-image-builder.sh"
[[ "$SELECTED_APPS" == *"python"* ]] && run_module "Python Tools" "$MODULES/optional-Modules/python.sh"
[[ "$SELECTED_APPS" == *"zfs"* ]] && run_module "ZFS" "$MODULES/optional-Modules/zfs.sh"
[[ "$SELECTED_APPS" == *"wprs"* ]] && run_module "WPRS" "$MODULES/optional-Modules/wprs.sh"
[[ "$SELECTED_APPS" == *"chromium"* ]] && run_module "Chromium" "$APPS/chromium.sh"
[[ "$SELECTED_APPS" == *"firefox-browser"* ]] && run_module "Firefox" "$APPS/firefox.sh"
[[ "$SELECTED_APPS" == *"zen-browser"* ]] && run_module "Zen Browser" "$APPS/zen-browser.sh"
[[ "$SELECTED_APPS" == *"nyxt"* ]] && run_module "Nyxt" "$APPS/nyxt.sh"
[[ "$SELECTED_APPS" == *"librewolf"* ]] && run_module "LibreWolf" "$APPS/librewolf.sh"
[[ "$SELECTED_APPS" == *"min-browser"* ]] && run_module "Min Browser" "$APPS/min-browser.sh"
[[ "$SELECTED_APPS" == *"vscodium"* ]] && run_module "VSCodium" "$APPS/vscodium.sh"
[[ "$SELECTED_APPS" == *"zed-ide"* ]] && run_module "Zed IDE" "$APPS/zed.sh"
[[ "$SELECTED_APPS" == *"geany"* ]] && run_module "Geany" "$APPS/geany.sh"
[[ "$SELECTED_APPS" == *"codeblocks"* ]] && run_module "Code::Blocks" "$APPS/codeblocks.sh"
[[ "$SELECTED_APPS" == *"kate"* ]] && run_module "Kate" "$APPS/kate.sh"
[[ "$SELECTED_APPS" == *"rdp-client"* ]] && run_module "RDP Client" "$APPS/rdp-client.sh"
[[ "$SELECTED_APPS" == *"lamco-rdp-server"* ]] && run_module "Lamco RDP Server" "$APPS/lamco-rdp-server.sh"
[[ "$SELECTED_APPS" == *"qemu"* ]] && run_module "QEMU/KVM" "$APPS/qemu.sh"
# ── Colorway (final step) ─────────────────────────────────────────────────────
# Read defaults from repo colors.conf for pre-population.
# Running after all modules ensures apply-theme.sh can re-process configs
# that were just symlinked or written by the DE/app modules.
declare -A _cdef
if [[ -f "$DOTFILES_DIR/colors.conf" ]]; then
# Parse 'KEY=VALUE' lines, skipping comments and blank lines.
# k="${k%%[[:space:]]*}" strips any trailing whitespace from the key.
# v="${v%%#*}" strips inline comments; v="${v//[[:space:]]/}" removes all spaces;
# v="${v^^}" uppercases the hex value so comparisons are case-insensitive.
while IFS='=' read -r k v; do
k="${k%%[[:space:]]*}"
[[ "$k" =~ ^[[:space:]]*# || -z "$k" ]] && continue
v="${v%%#*}"; v="${v//[[:space:]]/}"; v="${v^^}"
_cdef[$k]="$v"
done < "$DOTFILES_DIR/colors.conf"
fi
# Fall back to hard-coded defaults if colors.conf is absent or missing a key.
DEF_TEXT="${_cdef[COLOR_TEXT]:-D6ABAB}"
DEF_BG="${_cdef[COLOR_BG]:-1A1A1A}"
DEF_HIGHLIGHT="${_cdef[COLOR_HIGHLIGHT]:-E40046}"
DEF_DARK="${_cdef[COLOR_DARK]:-5018DD}"
DEF_RED="${_cdef[COLOR_RED]:-F50505}"
_write_colors_conf() {
# Write a normalized colors.conf to a temporary path.
# ${t^^} uppercases each hex value so apply-theme.sh always sees consistent casing.
# The file is written to TMP_D so it never clobbers the repo's own colors.conf.
local out="$1" t="$2" b="$3" h="$4" d="$5" r="$6"
printf 'COLOR_TEXT=%s\nCOLOR_BG=%s\nCOLOR_HIGHLIGHT=%s\nCOLOR_DARK=%s\nCOLOR_RED=%s\n' \
"${t^^}" "${b^^}" "${h^^}" "${d^^}" "${r^^}" > "$out"
}
if $ANSWERFILE_MODE; then
# Apply colors from answerfile if any are set.
# The concatenated string test is a concise way to check if at least one
# color field is non-empty without needing five separate [[ -n ]] guards.
if [[ -n "$AF_COLOR_TEXT$AF_COLOR_BG$AF_COLOR_HIGHLIGHT$AF_COLOR_DARK$AF_COLOR_RED" ]]; then
TMP_COLORS="$TMP_D/colors.conf"
# Per-field fallback: if the answerfile omits a color, use the repo default
# so apply-theme.sh always receives a complete, five-color config file.
_write_colors_conf "$TMP_COLORS" \
"${AF_COLOR_TEXT:-$DEF_TEXT}" \
"${AF_COLOR_BG:-$DEF_BG}" \
"${AF_COLOR_HIGHLIGHT:-$DEF_HIGHLIGHT}" \
"${AF_COLOR_DARK:-$DEF_DARK}" \
"${AF_COLOR_RED:-$DEF_RED}"
printf "Applying colorway from answerfile...\n" | tee -a "$LOG"
# '|| true' suppresses a non-zero exit from apply-theme.sh so a colorway
# failure never aborts an otherwise successful unattended install.
bash "$DOTFILES_DIR/apply-theme.sh" "$TMP_COLORS" 2>&1 | tee -a "$LOG" || true
fi
else
# Interactive: show color form dialog
# dialog --form: each field is specified as label row col default frow fcol flen max.
# The fd swap (3>&1 1>&2 2>&3) captures the form output (one value per line) via $().
# Esc / Cancel sets COLORWAY_RAW="" so the colorway step is simply skipped.
COLORWAY_RAW=$(dialog --backtitle "$BACKTITLE" \
--title " Colorway (optional) " \
--form "\n Customize theme colors — bare 6-digit hex, no #.\n Leave unchanged to skip colorway setup.\n" \
16 62 5 \
"COLOR_TEXT " 1 1 "$DEF_TEXT" 1 18 10 7 \
"COLOR_BG " 2 1 "$DEF_BG" 2 18 10 7 \
"COLOR_HIGHLIGHT " 3 1 "$DEF_HIGHLIGHT" 3 18 10 7 \
"COLOR_DARK " 4 1 "$DEF_DARK" 4 18 10 7 \
"COLOR_RED " 5 1 "$DEF_RED" 5 18 10 7 \
3>&1 1>&2 2>&3) || COLORWAY_RAW=""
if [[ -n "$COLORWAY_RAW" ]]; then
# mapfile reads the newline-separated form output into an array.
# _cv[0] = COLOR_TEXT line, _cv[1] = COLOR_BG, ... through _cv[4].
mapfile -t _cv <<< "$COLORWAY_RAW"
N_TEXT="${_cv[0]:-$DEF_TEXT}"
N_BG="${_cv[1]:-$DEF_BG}"
N_HIGHLIGHT="${_cv[2]:-$DEF_HIGHLIGHT}"
N_DARK="${_cv[3]:-$DEF_DARK}"
N_RED="${_cv[4]:-$DEF_RED}"
# Only invoke apply-theme.sh if at least one color actually changed.
# ${VAR^^} uppercases before comparison to treat 'e40046' == 'E40046'.
# This avoids a redundant theme rebuild when the user just pressed Enter
# on every field without changing anything.
if [[ "${N_TEXT^^}" != "$DEF_TEXT" || \
"${N_BG^^}" != "$DEF_BG" || \
"${N_HIGHLIGHT^^}" != "$DEF_HIGHLIGHT" || \
"${N_DARK^^}" != "$DEF_DARK" || \
"${N_RED^^}" != "$DEF_RED" ]]; then
TMP_COLORS="$TMP_D/colors.conf"
_write_colors_conf "$TMP_COLORS" "$N_TEXT" "$N_BG" "$N_HIGHLIGHT" "$N_DARK" "$N_RED"
clear
printf "\n\033[1;35m Applying colorway...\033[0m\n\n"
bash "$DOTFILES_DIR/apply-theme.sh" "$TMP_COLORS" 2>&1 | tee -a "$LOG" || true
fi
fi
fi
# ── Sync user config to /etc/skel ─────────────────────────────────────────────
# /etc/skel is the skeleton directory Linux copies to new user home dirs on creation.
# Propagating the post-install config here means any new user added to this machine
# automatically gets the full dotfiles environment without a manual copy step.
if [[ -d "$HOME/.config" ]]; then
printf "\n Syncing ~/.config to /etc/skel...\n"
sudo mkdir -p /etc/skel/.config
# '.' suffix on the source copies directory contents rather than the directory
# itself, making the merge non-destructive if /etc/skel/.config already exists.
sudo cp -r "$HOME/.config/." /etc/skel/.config/
fi
[[ -d "$HOME/.themes" ]] && { sudo mkdir -p /etc/skel/.themes; sudo cp -r "$HOME/.themes/." /etc/skel/.themes/; }
# Copy shell rc files to skel only if the user opted in to the dotfiles configs.
if [[ "$SHELL_RC" == "dotfiles" ]]; then
[[ -f "$HOME/.zshrc" ]] && sudo cp "$HOME/.zshrc" /etc/skel/.zshrc
[[ -f "$HOME/.bashrc" ]] && sudo cp "$HOME/.bashrc" /etc/skel/.bashrc
[[ -f "$HOME/.vimrc" ]] && sudo cp "$HOME/.vimrc" /etc/skel/.vimrc
fi
# ── Done ──────────────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
printf "\nDone. Log: %s\n" "$LOG"
else
dialog --backtitle "$BACKTITLE" \
--title " Done " \
--msgbox "\n All selected components installed.\n\n Log: $LOG\n\n A reboot may be required for all changes to take effect.\n" 12 58
clear
printf "\n Done. Log: %s\n\n" "$LOG"
fi