Dotfiles/setup/tui-install.sh

973 lines
58 KiB
Bash
Executable File

#!/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
# ── Cyberqueer CLI palette ────────────────────────────────────────────────────
# Plain ANSI escape codes used by the prompt helpers below. These keep the
# magenta/cyan "cyberqueer" look without depending on the `dialog` binary, so
# the installer runs on a bare console with nothing but bash + coreutils.
# tput is not used here because it may be unavailable on a minimal TTY.
C_RESET=$'\033[0m'
C_TITLE=$'\033[1;35m' # bold magenta — section headers
C_ACCENT=$'\033[36m' # cyan — prompts and selected marks
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
# ── Plain-CLI prompt helpers ──────────────────────────────────────────────────
# These reimplement the subset of `dialog` widgets the installer needs using
# only `read`. Widgets that return a value (input/menu/checklist/form) print
# their UI to stderr and the chosen value(s) to stdout, mirroring dialog's
# `3>&1 1>&2 2>&3` fd-swap convention so existing `$(...)` capture sites work
# unchanged. All interactive reads come from /dev/tty so the helpers keep
# working even when stdin is redirected.
ui_header() {
# Styled section header printed to the given fd (default stdout).
printf '\n%s──── %s ────%s\n\n' "$C_TITLE" "$1" "$C_RESET"
}
ui_msgbox() {
# Informational box: print title + body, wait for Enter to acknowledge.
local title="$1" body="$2"
ui_header "$title"
printf '%b\n' "$body"
printf '\n%s Press Enter to continue…%s' "$C_DIM" "$C_RESET"
read -r _ </dev/tty || true
printf '\n'
}
ui_yesno() {
# Confirmation prompt. Returns 0 for yes, 1 for no.
# $3 sets the default applied on a bare Enter ("yes" or "no").
local title="$1" body="$2" def="${3:-yes}" ans hint="[Y/n]"
[[ "$def" == no ]] && hint="[y/N]"
ui_header "$title"
printf '%b\n' "$body"
while true; do
printf '\n%s %s %s%s ' "$C_ACCENT" "Confirm?" "$hint" "$C_RESET"
read -r ans </dev/tty || ans=""
ans="${ans,,}"; [[ -z "$ans" ]] && ans="$def"
case "$ans" in
y|yes) return 0 ;;
n|no) return 1 ;;
esac
done
}
ui_input() {
# Single-line text entry. Echoes the entered value (or default) to stdout.
local title="$1" prompt="$2" def="${3:-}" val
{
ui_header "$title"
printf '%b\n' "$prompt"
[[ -n "$def" ]] && printf '%s (default: %s)%s\n' "$C_DIM" "$def" "$C_RESET"
printf '%s > %s' "$C_ACCENT" "$C_RESET"
} >&2
read -r val </dev/tty || val=""
[[ -z "$val" ]] && val="$def"
printf '%s' "$val"
}
ui_menu() {
# Single-choice list. Args: title prompt tag1 desc1 tag2 desc2 …
# Echoes the chosen tag to stdout; returns 1 (no selection) if the user
# enters 'q' or a blank line, so callers can fall back to a default.
local title="$1" prompt="$2"; shift 2
local -a tags=() descs=()
while [[ $# -gt 0 ]]; do tags+=("$1"); descs+=("$2"); shift 2; done
local i choice
while true; do
{
ui_header "$title"
printf '%b\n\n' "$prompt"
for i in "${!tags[@]}"; do
printf ' %s%2d%s) %s%-12s%s %s\n' \
"$C_ACCENT" $((i + 1)) "$C_RESET" "$C_BOLD" "${tags[$i]}" "$C_RESET" "${descs[$i]}"
done
printf '\n%s Enter number (q to skip): %s' "$C_ACCENT" "$C_RESET"
} >&2
read -r choice </dev/tty || choice=""
[[ "$choice" == q || -z "$choice" ]] && return 1
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#tags[@]} )); then
printf '%s' "${tags[$((choice - 1))]}"
return 0
fi
done
}
ui_checklist() {
# Multi-select list. Args: title prompt tag1 desc1 state1 tag2 desc2 state2 …
# state is "on"/"off" for the initial selection. Long lists are paginated to
# the terminal height so they never scroll off-screen: the screen is cleared
# and redrawn each round, with n/p moving between pages. The user toggles
# items by number and/or range (e.g. "1 3 5-8") — numbering is global, so any
# item can be toggled from any page — then presses Enter to confirm. Entering
# 'q' aborts (returns 1). The space-separated selected tags go to stdout.
local title="$1" prompt="$2"; shift 2
local -a tags=() descs=() state=()
while [[ $# -gt 0 ]]; do tags+=("$1"); descs+=("$2"); state+=("$3"); shift 3; done
local n=${#tags[@]} i mark line tok lo hi j
# Reserve rows for the header, prompt and footer; the remainder hold items.
# tput may be absent on a bare TTY, so fall back to a safe 24-row assumption.
local rows per pg=0 pages start end
rows=$(tput lines 2>/dev/null || echo 24)
[[ "$rows" =~ ^[0-9]+$ ]] || rows=24
per=$(( rows - 9 )); (( per < 1 )) && per=1
pages=$(( (n + per - 1) / per )); (( pages < 1 )) && pages=1
while true; do
(( pg < 0 )) && pg=0
(( pg >= pages )) && pg=$(( pages - 1 ))
start=$(( pg * per )); end=$(( start + per )); (( end > n )) && end=$n
{
# \033[2J\033[H clears the screen and homes the cursor so each page
# paints in place rather than scrolling — a stable, scrollable view.
printf '\033[2J\033[H'
ui_header "$title"
printf '%b' "$prompt"
(( pages > 1 )) && printf ' %s(page %d/%d)%s' "$C_DIM" $((pg + 1)) "$pages" "$C_RESET"
printf '\n\n'
for (( i = start; i < end; i++ )); do
if [[ "${state[$i]}" == on ]]; then mark="${C_ACCENT}[x]${C_RESET}"; else mark='[ ]'; fi
printf ' %s %s%3d%s %s\n' "$mark" "$C_ACCENT" $((i + 1)) "$C_RESET" "${descs[$i]}"
done
if (( pages > 1 )); then
printf '\n%s number/range toggles (1 3 5-8) · n/p next/prev page · Enter confirms · q aborts%s\n' "$C_DIM" "$C_RESET"
else
printf '\n%s Toggle items by number/range (e.g. 1 3 5-8); Enter confirms, q aborts.%s\n' "$C_DIM" "$C_RESET"
fi
printf '%s > %s' "$C_ACCENT" "$C_RESET"
} >&2
read -r line </dev/tty || line=""
case "$line" in
q) return 1 ;;
"") break ;;
n|N) pg=$(( pg + 1 )); continue ;;
p|P) pg=$(( pg - 1 )); continue ;;
esac
for tok in $line; do
if [[ "$tok" =~ ^([0-9]+)-([0-9]+)$ ]]; then
lo=${BASH_REMATCH[1]}; hi=${BASH_REMATCH[2]}
elif [[ "$tok" =~ ^[0-9]+$ ]]; then
lo=$tok; hi=$tok
else
continue
fi
for (( j = lo; j <= hi; j++ )); do
(( j >= 1 && j <= n )) || continue
if [[ "${state[$((j - 1))]}" == on ]]; then state[$((j - 1))]=off; else state[$((j - 1))]=on; fi
done
done
done
local out=""
for i in "${!tags[@]}"; do
[[ "${state[$i]}" == on ]] && out+="${tags[$i]} "
done
printf '%s' "${out% }"
}
ui_form() {
# Sequential field entry. Args: title prompt label1 default1 label2 default2 …
# Prints one entered value per line to stdout (blank field → its default),
# matching the newline-separated output of `dialog --form`.
local title="$1" prompt="$2"; shift 2
{
ui_header "$title"
printf '%b\n' "$prompt"
} >&2
local label def v out=""
while [[ $# -gt 0 ]]; do
label="$1"; def="$2"; shift 2
printf '%s %s%s%s [%s]: ' "$C_ACCENT" "$C_BOLD" "$label" "$C_RESET$C_ACCENT" "$def" >&2
printf '%s' "$C_RESET" >&2
read -r v </dev/tty || v=""
[[ -z "$v" ]] && v="$def"
out+="$v"$'\n'
done
printf '%s' "$out"
}
# ── 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_jq() {
# jq is only needed in answerfile mode, so we install it on demand rather
# than making it 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 screen 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
}
warn() {
# Non-fatal notice: in answerfile mode just log it; interactively show a
# message box that blocks until the user acknowledges with Enter.
if $ANSWERFILE_MODE; then
printf "\n Warning: %s\n" "$1" | tee -a "$LOG"
else
ui_msgbox " Module Conflict " " $1"
fi
}
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 selection screen 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.
# ui_yesno returns 0 for Yes and 1 for No; the '|| { ...; exit 1; }'
# fires on No, aborting the install.
ui_yesno " Module Failed " "$label exited with code $rc.\n\nContinue anyway?" no \
|| { 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 word-boundary check per app, one increment per match.
# Padding $a with spaces and matching " tag " prevents partial-name false matches
# (e.g. "plymouth" must not match against "plymouth-custom").
# 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_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.
# 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")
}
# ── 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."
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.
ui_msgbox " No Network Detected " " 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 Reconnect, then press Enter."
if ! ping -c1 -W3 archlinux.org &>/dev/null; then
ui_yesno " Still Offline " " Still no internet connection.\n\n Packages cannot be downloaded without network access.\n\n Continue anyway?" no \
|| { 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.
# ':' is the no-op command so the redirection has an explicit target.
: > "$LOG"
printf "Dotfiles install: %s\nDotfiles dir: %s\n" "$(date)" "$DOTFILES_DIR" >> "$LOG"
# ── Welcome ───────────────────────────────────────────────────────────────────
if ! $ANSWERFILE_MODE; then
ui_msgbox " Welcome " "\
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"
fi
# Note: the hostname is set by the base installer (archbaseos-guided-install.sh),
# so this script intentionally does not prompt for or change it.
# ── Component selection ───────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
COMPONENTS="$AF_COMPONENTS"
else
# ui_checklist takes tag/desc/state triplets and echoes the selected tags.
# All four components are pre-selected ("on") because they form the expected base.
# Entering 'q' returns exit code 1; the '|| { ...; exit 0; }' treats that as a clean abort.
COMPONENTS=$(ui_checklist " Select Components " "Optional base components — all enabled by default:" \
"pkg" "pkg Package managers yay · nvm · rust" on \
"core" "core Core packages 100+ base system packages" on \
"svc" "svc Core services NetworkManager · cronie · fail2ban" on \
"shell" "shell Shell setup zsh · nvim · yazi · micro · starship" on) \
|| { clear; echo "Aborted."; exit 0; }
fi
# ── DE selection ──────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
DE="$AF_DE"
else
# ui_menu is a single-choice list; it outputs the selected tag.
# 'q'/blank returns exit code 1 — '|| DE="none"' defaults to skipping the DE,
# preserving whatever DE the user already has installed.
DE=$(ui_menu " Desktop Environment " "Select a desktop environment · q / none to skip:" \
"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") || DE="none"
fi
# ── Apps selection ────────────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
SELECTED_APPS="$AF_APPS"
else
# BEGIN GENERATED MODULES: module-checklist
SELECTED_APPS=$(ui_checklist " Applications " "Optional applications — installed after base components:" \
"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 \
) || SELECTED_APPS=""
# END GENERATED MODULES: module-checklist
fi
# ── Shell RC preference ───────────────────────────────────────────────────────
if $ANSWERFILE_MODE; then
SHELL_RC="$AF_SHELL_RC"
else
SHELL_RC=$(ui_menu " Shell Config for New Users " \
"Should new users on this machine inherit the dotfiles' rc files?\n (Controls what gets copied to /etc/skel)" \
"dotfiles" "Use the_miro's .zshrc / .bashrc / .vimrc from dotfiles" \
"defaults" "Skip — use system defaults for new users") || 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=""
[[ "$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
ui_yesno " Confirm Installation " " Components to install:\n\n${SUMMARY}\n Log: $LOG" \
|| { 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 — deselecting 'plymouth-custom'"
SELECTED_APPS=" $SELECTED_APPS "
SELECTED_APPS="${SELECTED_APPS/ plymouth-custom / }"
SELECTED_APPS="${SELECTED_APPS# }"
SELECTED_APPS="${SELECTED_APPS% }"
fi
if [[ " $SELECTED_APPS " == *" plymouth-custom "* && " $SELECTED_APPS " == *" plymouth "* ]]; then
warn "'plymouth-custom' and 'plymouth' are mutually exclusive — deselecting 'plymouth'"
SELECTED_APPS=" $SELECTED_APPS "
SELECTED_APPS="${SELECTED_APPS/ plymouth / }"
SELECTED_APPS="${SELECTED_APPS# }"
SELECTED_APPS="${SELECTED_APPS% }"
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: prompt for each color field in turn.
# ui_form emits one value per line (blank → default), captured via $().
# Pressing Enter on every field leaves COLORWAY_RAW at the defaults, and the
# comparison below then skips the colorway step entirely.
COLORWAY_RAW=$(ui_form " Colorway (optional) " \
" Customize theme colors — bare 6-digit hex, no #.\n Leave each field unchanged to skip colorway setup." \
"COLOR_TEXT" "$DEF_TEXT" \
"COLOR_BG" "$DEF_BG" \
"COLOR_HIGHLIGHT" "$DEF_HIGHLIGHT" \
"COLOR_DARK" "$DEF_DARK" \
"COLOR_RED" "$DEF_RED") || 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
ui_msgbox " Done " " All selected components installed.\n\n Log: $LOG\n\n A reboot may be required for all changes to take effect."
clear
printf "\n Done. Log: %s\n\n" "$LOG"
fi