Dotfiles/setup/tui-install.sh

904 lines
55 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 ))
# 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.
# BEGIN GENERATED MODULES: module-counters
[[ "$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" == *"mail-notmuch"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"caldav-sync"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"ssh-server"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"wireshark"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"anti-malware"* ]] && 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" == *"python"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"k8s"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"docker"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"podman"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"cockpit"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"tlp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"butter"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"localsend"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"croc"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"opendeck"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"localtunnel"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"timeshift"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"zfs"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"wprs"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"plymouth"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"plymouth-custom"* ]] && 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" == *"onlyoffice"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"xournal"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"rnote"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"obsidian"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"tangent-notes"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"ffmpeg"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"sox"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"imagemagick"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"yt-dlp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"gimp"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"inkscape"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"krita"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"kdenlive"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"openshot"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"shotcut"* ]] && 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" == *"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" == *"freeipa-client"* ]] && TOTAL=$(( TOTAL + 1 ))
[[ "$a" == *"freeipa-server"* ]] && TOTAL=$(( TOTAL + 1 ))
# END GENERATED MODULES: module-counters
}
# ── 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 \
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 ))
# BEGIN GENERATED MODULES: module-checklist
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 and API server" off \
"llama-cpp" "llama-cpp standalone LLM inference CLI and server" off \
"open-webui" "open-webui browser UI for Ollama and LLM backends" off \
"claude" "claude Anthropic Claude Code CLI via npm" off \
"networking-cli" "networking-cli nmap, nethogs, mitmproxy, httpie" off \
"disk-recovery" "disk-recovery ddrescue and f3 disk recovery tools" off \
"himalaya" "himalaya terminal email client (AUR)" off \
"mail-notmuch" "mail-notmuch isync, msmtp, notmuch, alot mail stack" off \
"caldav-sync" "caldav-sync vdirsyncer and khal CalDAV calendar sync" off \
"ssh-server" "ssh-server openssh with key-auth and systemd unit enabled" off \
"wireshark" "wireshark network packet analyser GUI" off \
"anti-malware" "anti-malware ClamAV, rkhunter, chkrootkit" off \
"gnuplot" "gnuplot scientific plotting tool" off \
"blender-povray" "blender-povray 3D modelling and ray-tracing (Blender + POV-Ray)" off \
"toot" "toot Mastodon CLI client (AUR)" off \
"db-clients" "db-clients pgcli and mycli interactive database CLIs" off \
"mysql" "mysql MariaDB server with initial setup" off \
"productivity" "productivity taskwarrior, watson, jrnl — task management and time tracking" off \
"python" "python pyright, pipx, pynvim Python tooling" on \
"k8s" "k8s kubectl and podman-desktop Kubernetes tools" off \
"docker" "docker docker and docker-compose" off \
"podman" "podman rootless containers with buildah" off \
"cockpit" "cockpit web UI for machines and containers" off \
"tlp" "tlp laptop battery optimisation" off \
"butter" "butter btrfs snapshot backup manager (AUR)" off \
"localsend" "localsend LAN file transfer, AirDrop-like (AUR)" off \
"croc" "croc cross-platform encrypted file transfer" off \
"opendeck" "opendeck Stream Deck controller — ydotool + OpenDeck (Flatpak)" off \
"localtunnel" "localtunnel expose localhost over a public URL" off \
"timeshift" "timeshift system snapshot and backup with autosnap" off \
"zfs" "zfs zfs-dkms kernel module" off \
"wprs" "wprs Wayland proxy for remote sessions (wprs-git, AUR)" off \
"plymouth" "plymouth boot splash — bundled skull logo and spinner" on \
"plymouth-custom" "plymouth-custom boot splash with a user-supplied image" off \
"steam" "steam Steam gaming platform" off \
"vesktop" "vesktop Discord client with Vencord theme" off \
"spotify" "spotify Spotify launcher with Spicetify theming" off \
"prism" "prism PrismLauncher Minecraft launcher (Flatpak)" off \
"vintagestory" "vintagestory Vintage Story survival game (AUR)" off \
"openarena" "openarena open-source Quake III Arena" off \
"tetris" "tetris bastet and vitetris terminal Tetris" off \
"doom" "doom Chocolate Doom with Freedoom data" off \
"sauerbraten" "sauerbraten Sauerbraten open-source FPS (Cube 2)" off \
"stuntrally" "stuntrally Stunt Rally racing game (Flatpak)" off \
"onlyoffice" "onlyoffice office suite — Docs, Sheets, Slides (AUR)" on \
"xournal" "xournal note-taking and PDF annotator" off \
"rnote" "rnote handwriting and note-taking with stylus support (Flatpak)" off \
"obsidian" "obsidian knowledge base and Markdown note-taking (Flatpak)" off \
"tangent-notes" "tangent-notes networked Markdown note-taking (Flatpak)" off \
"ffmpeg" "ffmpeg GStreamer codecs and ffmpegthumbnailer" off \
"sox" "sox command-line audio processing toolkit" off \
"imagemagick" "imagemagick image manipulation suite" off \
"yt-dlp" "yt-dlp YouTube and media downloader" off \
"gimp" "gimp GNU Image Manipulation Program" off \
"inkscape" "inkscape vector graphics editor" off \
"krita" "krita digital painting and illustration" off \
"kdenlive" "kdenlive KDE non-linear video editor" off \
"openshot" "openshot cross-platform video editor" off \
"shotcut" "shotcut cross-platform video editor" off \
"ardour" "ardour professional DAW" off \
"audacity" "audacity multi-track audio editor" off \
"lmms" "lmms Linux MultiMedia Studio music production" off \
"mixxx" "mixxx DJ mixing software" off \
"cecilia" "cecilia audio synthesis and signal processing (AUR)" off \
"chromium" "chromium open-source Chromium browser (official)" off \
"firefox-browser" "firefox-browser Mozilla Firefox (official)" on \
"zen-browser" "zen-browser privacy-focused Firefox fork (AUR)" off \
"nyxt" "nyxt keyboard-driven hackable browser (AUR)" off \
"librewolf" "librewolf hardened Firefox fork (AUR)" off \
"min-browser" "min-browser minimal Electron browser (AUR)" off \
"vscodium" "vscodium telemetry-free VS Code build (AUR)" off \
"zed-ide" "zed-ide high-performance Rust IDE (official)" off \
"geany" "geany lightweight IDE with plugins (official)" off \
"codeblocks" "codeblocks C/C++ IDE (official)" off \
"kate" "kate KDE advanced text editor (official)" off \
"rdp-client" "rdp-client Remmina with FreeRDP and VNC plugins" off \
"lamco-rdp-server" "lamco-rdp-server native Wayland RDP server (AUR, Rust)" off \
"qemu" "qemu full QEMU/KVM stack with virt-manager" off \
"freeipa-client" "freeipa-client sssd and ipa-client-install with auto-enrollment" off \
"freeipa-server" "freeipa-server interactive FreeIPA server setup with client generator" off \
3>&1 1>&2 2>&3) || SELECTED_APPS=""
# END GENERATED MODULES: module-checklist
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"
[[ "$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"
# BEGIN GENERATED MODULES: module-summary
[[ "$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\n"
[[ "$SELECTED_APPS" == *"networking-cli"* ]] && SUMMARY+=" ✦ networking-cli\n"
[[ "$SELECTED_APPS" == *"disk-recovery"* ]] && SUMMARY+=" ✦ disk-recovery\n"
[[ "$SELECTED_APPS" == *"himalaya"* ]] && SUMMARY+=" ✦ himalaya\n"
[[ "$SELECTED_APPS" == *"mail-notmuch"* ]] && SUMMARY+=" ✦ mail-notmuch\n"
[[ "$SELECTED_APPS" == *"caldav-sync"* ]] && SUMMARY+=" ✦ caldav-sync\n"
[[ "$SELECTED_APPS" == *"ssh-server"* ]] && SUMMARY+=" ✦ ssh-server\n"
[[ "$SELECTED_APPS" == *"wireshark"* ]] && SUMMARY+=" ✦ wireshark\n"
[[ "$SELECTED_APPS" == *"anti-malware"* ]] && SUMMARY+=" ✦ anti-malware\n"
[[ "$SELECTED_APPS" == *"gnuplot"* ]] && SUMMARY+=" ✦ gnuplot\n"
[[ "$SELECTED_APPS" == *"blender-povray"* ]] && SUMMARY+=" ✦ blender-povray\n"
[[ "$SELECTED_APPS" == *"toot"* ]] && SUMMARY+=" ✦ toot\n"
[[ "$SELECTED_APPS" == *"db-clients"* ]] && SUMMARY+=" ✦ db-clients\n"
[[ "$SELECTED_APPS" == *"mysql"* ]] && SUMMARY+=" ✦ mysql\n"
[[ "$SELECTED_APPS" == *"productivity"* ]] && SUMMARY+=" ✦ productivity\n"
[[ "$SELECTED_APPS" == *"python"* ]] && SUMMARY+=" ✦ python\n"
[[ "$SELECTED_APPS" == *"k8s"* ]] && SUMMARY+=" ✦ k8s\n"
[[ "$SELECTED_APPS" == *"docker"* ]] && SUMMARY+=" ✦ docker\n"
[[ "$SELECTED_APPS" == *"podman"* ]] && SUMMARY+=" ✦ podman\n"
[[ "$SELECTED_APPS" == *"cockpit"* ]] && SUMMARY+=" ✦ cockpit\n"
[[ "$SELECTED_APPS" == *"tlp"* ]] && SUMMARY+=" ✦ tlp\n"
[[ "$SELECTED_APPS" == *"butter"* ]] && SUMMARY+=" ✦ butter\n"
[[ "$SELECTED_APPS" == *"localsend"* ]] && SUMMARY+=" ✦ localsend\n"
[[ "$SELECTED_APPS" == *"croc"* ]] && SUMMARY+=" ✦ croc\n"
[[ "$SELECTED_APPS" == *"opendeck"* ]] && SUMMARY+=" ✦ opendeck\n"
[[ "$SELECTED_APPS" == *"localtunnel"* ]] && SUMMARY+=" ✦ localtunnel\n"
[[ "$SELECTED_APPS" == *"timeshift"* ]] && SUMMARY+=" ✦ timeshift\n"
[[ "$SELECTED_APPS" == *"zfs"* ]] && SUMMARY+=" ✦ zfs\n"
[[ "$SELECTED_APPS" == *"wprs"* ]] && SUMMARY+=" ✦ wprs\n"
[[ "$SELECTED_APPS" == *"plymouth"* ]] && SUMMARY+=" ✦ plymouth\n"
[[ "$SELECTED_APPS" == *"plymouth-custom"* ]] && SUMMARY+=" ✦ plymouth-custom\n"
[[ "$SELECTED_APPS" == *"steam"* ]] && SUMMARY+=" ✦ steam\n"
[[ "$SELECTED_APPS" == *"vesktop"* ]] && SUMMARY+=" ✦ vesktop\n"
[[ "$SELECTED_APPS" == *"spotify"* ]] && SUMMARY+=" ✦ spotify\n"
[[ "$SELECTED_APPS" == *"prism"* ]] && SUMMARY+=" ✦ prism\n"
[[ "$SELECTED_APPS" == *"vintagestory"* ]] && SUMMARY+=" ✦ vintagestory\n"
[[ "$SELECTED_APPS" == *"openarena"* ]] && SUMMARY+=" ✦ openarena\n"
[[ "$SELECTED_APPS" == *"tetris"* ]] && SUMMARY+=" ✦ tetris\n"
[[ "$SELECTED_APPS" == *"doom"* ]] && SUMMARY+=" ✦ doom\n"
[[ "$SELECTED_APPS" == *"sauerbraten"* ]] && SUMMARY+=" ✦ sauerbraten\n"
[[ "$SELECTED_APPS" == *"stuntrally"* ]] && SUMMARY+=" ✦ stuntrally\n"
[[ "$SELECTED_APPS" == *"onlyoffice"* ]] && SUMMARY+=" ✦ onlyoffice\n"
[[ "$SELECTED_APPS" == *"xournal"* ]] && SUMMARY+=" ✦ xournal\n"
[[ "$SELECTED_APPS" == *"rnote"* ]] && SUMMARY+=" ✦ rnote\n"
[[ "$SELECTED_APPS" == *"obsidian"* ]] && SUMMARY+=" ✦ obsidian\n"
[[ "$SELECTED_APPS" == *"tangent-notes"* ]] && SUMMARY+=" ✦ tangent-notes\n"
[[ "$SELECTED_APPS" == *"ffmpeg"* ]] && SUMMARY+=" ✦ ffmpeg\n"
[[ "$SELECTED_APPS" == *"sox"* ]] && SUMMARY+=" ✦ sox\n"
[[ "$SELECTED_APPS" == *"imagemagick"* ]] && SUMMARY+=" ✦ imagemagick\n"
[[ "$SELECTED_APPS" == *"yt-dlp"* ]] && SUMMARY+=" ✦ yt-dlp\n"
[[ "$SELECTED_APPS" == *"gimp"* ]] && SUMMARY+=" ✦ gimp\n"
[[ "$SELECTED_APPS" == *"inkscape"* ]] && SUMMARY+=" ✦ inkscape\n"
[[ "$SELECTED_APPS" == *"krita"* ]] && SUMMARY+=" ✦ krita\n"
[[ "$SELECTED_APPS" == *"kdenlive"* ]] && SUMMARY+=" ✦ kdenlive\n"
[[ "$SELECTED_APPS" == *"openshot"* ]] && SUMMARY+=" ✦ openshot\n"
[[ "$SELECTED_APPS" == *"shotcut"* ]] && SUMMARY+=" ✦ shotcut\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" == *"chromium"* ]] && SUMMARY+=" ✦ chromium\n"
[[ "$SELECTED_APPS" == *"firefox-browser"* ]] && SUMMARY+=" ✦ firefox-browser\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+=" ✦ codeblocks\n"
[[ "$SELECTED_APPS" == *"kate"* ]] && SUMMARY+=" ✦ kate\n"
[[ "$SELECTED_APPS" == *"rdp-client"* ]] && SUMMARY+=" ✦ rdp-client\n"
[[ "$SELECTED_APPS" == *"lamco-rdp-server"* ]] && SUMMARY+=" ✦ lamco-rdp-server\n"
[[ "$SELECTED_APPS" == *"qemu"* ]] && SUMMARY+=" ✦ qemu\n"
[[ "$SELECTED_APPS" == *"freeipa-client"* ]] && SUMMARY+=" ✦ freeipa-client\n"
[[ "$SELECTED_APPS" == *"freeipa-server"* ]] && SUMMARY+=" ✦ freeipa-server\n"
# END GENERATED MODULES: module-summary
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"
# 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.
# BEGIN GENERATED MODULES: module-conflicts
if [[ "$SELECTED_APPS" == *"plymouth"* && "$SELECTED_APPS" == *"plymouth-custom"* ]]; then
warn "plymouth and plymouth-custom are mutually exclusive — skipping plymouth-custom"
SELECTED_APPS="${SELECTED_APPS/plymouth-custom/}"
fi
if [[ "$SELECTED_APPS" == *"plymouth-custom"* && "$SELECTED_APPS" == *"plymouth"* ]]; then
warn "plymouth-custom and plymouth are mutually exclusive — skipping plymouth"
SELECTED_APPS="${SELECTED_APPS/plymouth/}"
fi
# END GENERATED MODULES: module-conflicts
# BEGIN GENERATED MODULES: module-dispatch
[[ "$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" "$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" == *"ssh-server"* ]] && run_module "ssh-server" "$APPS/ssh-server.sh"
[[ "$SELECTED_APPS" == *"wireshark"* ]] && run_module "wireshark" "$APPS/wireshark.sh"
[[ "$SELECTED_APPS" == *"anti-malware"* ]] && run_module "anti-malware" "$APPS/anti-malware.sh"
[[ "$SELECTED_APPS" == *"gnuplot"* ]] && run_module "gnuplot" "$APPS/gnuplot.sh"
[[ "$SELECTED_APPS" == *"blender-povray"* ]] && run_module "blender-povray" "$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" "$APPS/mysql.sh"
[[ "$SELECTED_APPS" == *"productivity"* ]] && run_module "productivity" "$APPS/productivity.sh"
[[ "$SELECTED_APPS" == *"python"* ]] && run_module "python" "$APPS/python.sh"
[[ "$SELECTED_APPS" == *"k8s"* ]] && run_module "k8s" "$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" == *"tlp"* ]] && run_module "tlp" "$APPS/tlp.sh"
[[ "$SELECTED_APPS" == *"butter"* ]] && run_module "butter" "$APPS/butter.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" == *"localtunnel"* ]] && run_module "localtunnel" "$APPS/localtunnel.sh"
[[ "$SELECTED_APPS" == *"timeshift"* ]] && run_module "timeshift" "$APPS/timeshift.sh"
[[ "$SELECTED_APPS" == *"zfs"* ]] && run_module "zfs" "$APPS/zfs.sh"
[[ "$SELECTED_APPS" == *"wprs"* ]] && run_module "wprs" "$APPS/wprs.sh"
[[ "$SELECTED_APPS" == *"plymouth"* ]] && run_module "plymouth" "$APPS/plymouth.sh"
[[ "$SELECTED_APPS" == *"plymouth-custom"* ]] && run_module "plymouth-custom" "$APPS/plymouth-custom.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 "prism" "$APPS/prism.sh"
[[ "$SELECTED_APPS" == *"vintagestory"* ]] && run_module "vintagestory" "$APPS/vintagestory.sh"
[[ "$SELECTED_APPS" == *"openarena"* ]] && run_module "openarena" "$APPS/openarena.sh"
[[ "$SELECTED_APPS" == *"tetris"* ]] && run_module "tetris" "$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 "stuntrally" "$APPS/stuntrally.sh"
[[ "$SELECTED_APPS" == *"onlyoffice"* ]] && run_module "onlyoffice" "$APPS/onlyoffice.sh"
[[ "$SELECTED_APPS" == *"xournal"* ]] && run_module "xournal" "$APPS/xournal.sh"
[[ "$SELECTED_APPS" == *"rnote"* ]] && run_module "rnote" "$APPS/rnote.sh"
[[ "$SELECTED_APPS" == *"obsidian"* ]] && run_module "obsidian" "$APPS/obsidian.sh"
[[ "$SELECTED_APPS" == *"tangent-notes"* ]] && run_module "tangent-notes" "$APPS/tangent-notes.sh"
[[ "$SELECTED_APPS" == *"ffmpeg"* ]] && run_module "ffmpeg" "$APPS/ffmpeg.sh"
[[ "$SELECTED_APPS" == *"sox"* ]] && run_module "sox" "$APPS/sox.sh"
[[ "$SELECTED_APPS" == *"imagemagick"* ]] && run_module "imagemagick" "$APPS/imagemagick.sh"
[[ "$SELECTED_APPS" == *"yt-dlp"* ]] && run_module "yt-dlp" "$APPS/yt-dlp.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" == *"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" == *"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" == *"chromium"* ]] && run_module "chromium" "$APPS/chromium.sh"
[[ "$SELECTED_APPS" == *"firefox-browser"* ]] && run_module "firefox-browser" "$APPS/firefox-browser.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-ide.sh"
[[ "$SELECTED_APPS" == *"geany"* ]] && run_module "geany" "$APPS/geany.sh"
[[ "$SELECTED_APPS" == *"codeblocks"* ]] && run_module "codeblocks" "$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" "$APPS/qemu.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"
# END GENERATED MODULES: module-dispatch
# ── 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