diff --git a/debianito.sh b/debianito.sh index 718ef2b..4b884d6 100644 --- a/debianito.sh +++ b/debianito.sh @@ -16,33 +16,35 @@ TUI_ALTO_LISTA=10 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MODULES_DIR="${SCRIPT_DIR}/modules" +# Boot profiling: DEBIANITO_PROFILE=1 stamps each boot +# step to stderr for before/after comparison. +_boot_mark() { + [ "${DEBIANITO_PROFILE:-0}" = "1" ] || return 0 + printf '[%s] %s\n' "$(date +%s%N)" "$*" >&2 +} + +_boot_mark "boot: start (before module sources)" + +# Eager core only: utils.sh holds every boot function +# and TUI helper; hw_detect.sh declares the global +# device arrays (PCI_NET_DEVS, ...) that lazily-loaded +# modules read under `set -u`. All other modules are +# sourced on demand via _load_module() in main_menu(). source "${MODULES_DIR}/utils.sh" source "${MODULES_DIR}/hw_detect.sh" -[ -f "${MODULES_DIR}/sysinfo.sh" ] && source "${MODULES_DIR}/sysinfo.sh" -source "${MODULES_DIR}/sudo_config.sh" -source "${MODULES_DIR}/repos/repo_detect.sh" -source "${MODULES_DIR}/repos.sh" -[ -f "${MODULES_DIR}/firmware.sh" ] && source "${MODULES_DIR}/firmware.sh" -[ -f "${MODULES_DIR}/bluetooth.sh" ] && source "${MODULES_DIR}/bluetooth.sh" -[ -f "${MODULES_DIR}/gpu.sh" ] && source "${MODULES_DIR}/gpu.sh" -[ -f "${MODULES_DIR}/kernel.sh" ] && source "${MODULES_DIR}/kernel.sh" -[ -f "${MODULES_DIR}/gaming.sh" ] && source "${MODULES_DIR}/gaming.sh" -[ -f "${MODULES_DIR}/extras.sh" ] && source "${MODULES_DIR}/extras.sh" -[ -f "${MODULES_DIR}/zram.sh" ] && source "${MODULES_DIR}/zram.sh" -[ -f "${MODULES_DIR}/extras/java.sh" ] && source "${MODULES_DIR}/extras/java.sh" -[ -f "${MODULES_DIR}/rescue.sh" ] && source "${MODULES_DIR}/rescue.sh" -[ -f "${MODULES_DIR}/swap.sh" ] && source "${MODULES_DIR}/swap.sh" -[ -f "${MODULES_DIR}/desktop_display.sh" ] && source "${MODULES_DIR}/desktop_display.sh" -[ -f "${MODULES_DIR}/system/system_prefs.sh" ] && source "${MODULES_DIR}/system/system_prefs.sh" -[ -f "${MODULES_DIR}/system/audio.sh" ] && source "${MODULES_DIR}/system/audio.sh" # ── Bullseye-specific modules (loaded only on Debian 11) ── +# Eager-conditional: the menu dispatch branches on +# `type install_*_bullseye`, so these must be defined +# before main_menu() runs. if [ -d "${MODULES_DIR}/bullseye" ]; then [ -f "${MODULES_DIR}/bullseye/legacy.sh" ] && source "${MODULES_DIR}/bullseye/legacy.sh" [ -f "${MODULES_DIR}/bullseye/repos.sh" ] && source "${MODULES_DIR}/bullseye/repos.sh" [ -f "${MODULES_DIR}/bullseye/extras.sh" ] && source "${MODULES_DIR}/bullseye/extras.sh" fi +_boot_mark "boot: core modules loaded" + # ── Interrupt safety: restore repository state on Ctrl+C / TERM ── _on_interrupt() { echo -e "${RED}[!] Interrupted. Restoring repository state if possible...${NC}" @@ -94,9 +96,16 @@ main_menu() { clear case "$choice" in - 1) _show_sysinfo ;; - 2) config_sudo || true ;; + 1) + _load_module sysinfo || continue + _show_sysinfo + ;; + 2) + _load_module sudo_config || continue + config_sudo || true + ;; 3) + _load_module system_prefs || continue _system_preferences_menu STATE_REFRESHED=true ;; @@ -104,19 +113,26 @@ main_menu() { if [ "$DEBIAN_VERSION" = "11" ] && type configure_repos_bullseye &>/dev/null; then configure_repos_bullseye || true else + _load_module repos || continue configure_repos || true fi STATE_REFRESHED=true ;; 5) if [ "$DEBIAN_VERSION" = "11" ] && type install_firmware_bullseye &>/dev/null; then + # Bullseye flow calls _handle_wireless + # from firmware.sh: load the module + # (cascades repo_detect, bluetooth, repos). + _load_module firmware || continue install_firmware_bullseye || true else + _load_module firmware || continue install_firmware || true fi STATE_REFRESHED=true ;; 6) + _load_module gpu || continue local gpu_sub gpu_sub=$(_menu "Graphics Drivers" "" 12 50 2 \ "1" "Radeon/Intel Mesa" \ @@ -130,38 +146,54 @@ main_menu() { STATE_REFRESHED=true ;; 7) + _load_module kernel || continue show_kernel_menu || true STATE_REFRESHED=true ;; 8) if [ "$DEBIAN_VERSION" = "11" ] && type install_gaming_bullseye &>/dev/null; then + # Bullseye flow calls the gaming bundle + # (ensure_contrib_repo, install_steam, + # install_mangohud, ...) and java.sh: + # load the bundle (cascades repo_detect, + # java, repos). + _load_module gaming || continue install_gaming_bullseye || true else + _load_module gaming || continue install_gaming || true fi STATE_REFRESHED=true ;; 9) + _load_module zram || continue zram_menu || true STATE_REFRESHED=true ;; 10) + _load_module swap || continue manage_swap || true STATE_REFRESHED=true ;; 11) if [ "$DEBIAN_VERSION" = "11" ] && type install_extras_bullseye &>/dev/null; then + # Bullseye flow calls _install_dev_java + # from extras/java.sh: load it explicitly. + _load_module java || continue install_extras_bullseye || true else + _load_module extras || continue install_extras || true fi STATE_REFRESHED=true ;; 12) + _load_module rescue || continue rescue_boot || true STATE_REFRESHED=true ;; 13) + _load_module desktop_display || continue manage_desktop_display || true STATE_REFRESHED=true ;; @@ -176,6 +208,7 @@ main_menu() { done } +_boot_mark "boot: pre-flight checks" check_root check_sudo if ! command -v whiptail >/dev/null 2>&1; then @@ -187,27 +220,12 @@ if ! command -v whiptail >/dev/null 2>&1; then echo -e "${RED} The TUI menu requires it; install manually and re-run.${NC}" >&2 fi fi -if ! _check_network; then - echo -e "${YELLOW}──────────────────────────────────────────${NC}" - echo -e "${YELLOW} No internet connectivity detected.${NC}" - echo -e "${YELLOW} Package installation will fail without network.${NC}" - echo -e "${YELLOW} You can use: System Info, User Privileges, and${NC}" - echo -e "${YELLOW} other offline features.${NC}" - echo -e "${YELLOW}──────────────────────────────────────────${NC}" -fi -_ensure_time_synced +# Hardware detection is deferred on demand via +# _ensure_state_detected(); only the OS version is +# eager because the menu dispatch branches on it. +_boot_mark "boot: os detection" detect_debian_version -detect_cpu_ram -detect_kernel -_init_lspci_cache -detect_gpu -detect_network -detect_displayserver -detect_storage -detect_desktop_environment -_configure_lightdm -detect_audio_server # ── Bullseye-specific init (archive phase) ── if [ "$DEBIAN_VERSION" = "11" ] && type check_bullseye_archive_phase &>/dev/null; then @@ -219,4 +237,5 @@ if ! command -v whiptail >/dev/null 2>&1; then exit 1 fi +_boot_mark "boot: entering main menu" main_menu diff --git a/docs/firmware.md b/docs/firmware.md index c14a947..f511b3e 100644 --- a/docs/firmware.md +++ b/docs/firmware.md @@ -43,7 +43,7 @@ The installation process follows a strict atomic pipeline defined in `install_fi Broadcom chipsets require proprietary handling because no open-source driver covers most `14e4:*` devices on modern kernels. The current implementation in `firmware.sh:_handle_wireless` uses a **single-path DKMS flow** (`broadcom-sta-dkms` + `wl` module) — not a 3-tier fallback: -1. **Device detection** — Iterates `PCI_NET_DEVS` (parsed from `lspci -nn` at startup) and extracts the Broadcom ID `14e4:XXXX`. Non-Broadcom devices are skipped. +1. **Device detection** — Iterates `PCI_NET_DEVS` (parsed from `lspci -nn` by the on-demand detection battery) and extracts the Broadcom ID `14e4:XXXX`. Non-Broadcom devices are skipped. 2. **Dependency guard** — Verifies `linux-headers-amd64` and `dkms` are available via `apt-cache show`. If missing, shows: `"linux-headers-amd64 or dkms are not available in your repositories."` 3. **User confirmation** — `whiptail --yesno "Install broadcom-sta-dkms, dkms, and wireless-tools?"` 4. **Step-by-step install** (allows partial failure without aborting the whole module): diff --git a/docs/kernel.md b/docs/kernel.md index 0897ac3..f0e0b39 100644 --- a/docs/kernel.md +++ b/docs/kernel.md @@ -112,7 +112,7 @@ Key details: | Mechanism | How It Works | | ----------- | -------------- | | **Pre-flight `is_backports_enabled`** | `utils.sh:is_backports_enabled` greps `/etc/apt/sources.list` and `/etc/apt/sources.list.d/*.sources` / `*.list` for `trixie-backports`. Prevents `apt -t` from failing with `E: Release not found`. | -| **NVIDIA RT warning** | `utils.sh:GPU_TYPE` is set at startup by `detect_gpu` (lspci). RT kernels change scheduling semantics; some `nvidia.ko` builds reject `PREEMPT_RT`. | +| **NVIDIA RT warning** | `utils.sh:GPU_TYPE` is set on demand by `detect_gpu` (lspci, via `_ensure_state_detected`). RT kernels change scheduling semantics; some `nvidia.ko` builds reject `PREEMPT_RT`. | | **Atomic image+headers** | Both packages in one `apt` call. If headers are missing, `dkms` will fail at boot — so they are never installed separately. | | **Fallback preservation** | `apt` never removes the running kernel. `/boot` retains `vmlinuz-*` and `initrd.img-*` for both. GRUB keeps both entries; if the new kernel panics, select the old one. | | **Version-aware messaging** | The confirmation dialog always shows the exact version string (`apt-cache madison` / `apt-cache show`) so users know they are not reinstalling the same package. | diff --git a/docs/system_info.md b/docs/system_info.md index 516a9b7..fc86a06 100644 --- a/docs/system_info.md +++ b/docs/system_info.md @@ -2,9 +2,9 @@ ### 1. What Does This Component Do? -This component serves as the **System Abstraction Layer** and diagnostic engine of the Debianito script. It is not merely a display utility; it acts as the foundational state initializer that runs prior to the main menu loop (`main_menu`). Its primary function is to perform pre-flight hardware enumeration, OS validation, and environment checks in "cold" mode (before any configuration changes are made). +This component serves as the **System Abstraction Layer** and diagnostic engine of the Debianito script. It is not merely a display utility; it acts as the foundational state initializer for the other menu options. Its primary function is to perform pre-flight hardware enumeration, OS validation, and environment checks in "cold" mode (before any configuration changes are made). -**Pre-flight initialization** (`debianito.sh` startup sequence): +**Initialization** (boot checks in `debianito.sh`, hardware state on demand via `_ensure_state_detected`): 1. **Dependency auto-install**: The script checks for `whiptail` and `lsb-release`, installing them automatically if missing — ensuring the TUI and version detection work on minimal systems. 2. **`lspci` guard and cache**: `command -v lspci &>/dev/null` validates availability before execution. If present, the full `lspci -nn` output is captured once into the global variable `LSPCI_OUTPUT`. All subsequent GPU, Ethernet, and network detection reads from this cache — avoiding redundant `lspci` invocations and ensuring consistent data throughout the session. @@ -22,7 +22,7 @@ The following table details the native Linux tools and file descriptors utilized | **OS Version** | `lsb_release -cs`, `/etc/os-release` | Parses `VERSION_CODENAME` to determine Debian release (Bullseye, Bookworm, Trixie). Critical for selecting correct repository backports. | | **CPU Info** | `/proc/cpuinfo` | Reads `model name` and counts cores/threads. Provides cosmetic summary without needing heavy tools like `lscpu`. | | **Memory** | `/proc/meminfo` | Extracts `MemTotal` to calculate RAM in GB. Used for compatibility warnings with specific software packages. | -| **GPU Detection** | `command -v lspci` guard → cached `LSPCI_OUTPUT` | `lspci -nn` output is cached in the global variable `LSPCI_OUTPUT` at startup. All subsequent GPU detection (PCI/USB buses, Ethernet, network) reads from this cache instead of re-running `lspci`. Identifies VGA/3D controllers via PCI IDs (`10de` for NVIDIA). Checks driver versions via `dpkg` if `nvidia-smi` fails. | +| **GPU Detection** | `command -v lspci` guard → cached `LSPCI_OUTPUT` | `lspci -nn` output is cached in the global variable `LSPCI_OUTPUT` on first hardware access (`_ensure_state_detected`). All subsequent GPU detection (PCI/USB buses, Ethernet, network) reads from this cache instead of re-running `lspci`. Identifies VGA/3D controllers via PCI IDs (`10de` for NVIDIA). Checks driver versions via `dpkg` if `nvidia-smi` fails. | | **Network (Eth)** | `ip -o link show` | Enumerates Ethernet interfaces, state (UP/DOWN), and IP addresses using the `iproute2` suite. | | **Network (Wi-Fi)** | `iwgetid`, `lspci` | Identifies wireless chipsets via PCI and retrieves SSID/Connection status for network diagnostics. | | **Storage** | `lsblk -d -o NAME,SIZE,ROTA` | Distinguishes between NVMe (`nvme`), SSD (RoT=0), and HDD (RoT=1) to provide storage topology summary. | diff --git a/docs/system_prefs.md b/docs/system_prefs.md index 983111e..d38ad9f 100644 --- a/docs/system_prefs.md +++ b/docs/system_prefs.md @@ -47,7 +47,7 @@ Timezone: America/Santiago - Ensures `systemd-timesyncd` is installed, enabled and restarted - Waits 2 seconds and checks `timedatectl show --property=NTPSynchronized --value` for `yes` -**Why it matters:** An incorrect clock breaks APT GPG verification (`Release file is not valid yet`) and TLS certificates. The script runs a similar check automatically at startup (`_ensure_time_synced` in `debianito.sh` line 108), but this menu allows the user to fix timezone manually without re-running the whole script. +**Why it matters:** An incorrect clock breaks APT GPG verification (`Release file is not valid yet`) and TLS certificates. The script runs a similar check automatically before the first `apt update` (`_ensure_time_synced` is called from `_ensure_apt_updated` in `utils.sh`), but this menu allows the user to fix timezone manually without re-running the whole script. --- @@ -191,7 +191,7 @@ Each package is installed via `_run_install ` which confirms the version vi - **Firmware (`firmware.sh`)** — Bluetooth firmware packages (`firmware-iwlwifi`, etc.) are separate from the Bluetooth *stack* (`bluez`). This menu complements firmware by ensuring the audio side of Bluetooth (PipeWire codecs) is ready. - **Desktop & Display (`desktop_display.sh`)** — XFCE/LXDE installations often need audio configured afterwards. Running System Preferences → Audio after a new desktop ensures the correct mixer is available. - **Gaming (`gaming.sh`)** — Many games require PipeWire/PulseAudio for voice chat. The gaming module assumes audio is already functional. -- **Time sync (`utils.sh:_ensure_time_synced`)** — Also called automatically at script startup. This menu is the manual override. +- **Time sync (`utils.sh:_ensure_time_synced`)** — Also called automatically before the first `apt update`. This menu is the manual override. --- diff --git a/modules/bullseye/legacy.sh b/modules/bullseye/legacy.sh index cebefbd..8b6001b 100644 --- a/modules/bullseye/legacy.sh +++ b/modules/bullseye/legacy.sh @@ -131,6 +131,10 @@ install_nvidia_bullseye() { # 32-bit Mesa/NVIDIA + gamemode + mangohud + goverlay + lutris # --------------------------------------------------------------------------- install_gaming_bullseye() { + # Hardware state is detected on demand; GPU_TYPE is + # consulted below for the 32-bit driver choice. + _ensure_state_detected + echo -e "${YELLOW}Gaming setup (Bullseye)...${NC}" local choices diff --git a/modules/desktop_display.sh b/modules/desktop_display.sh index 01df0dd..2782730 100644 --- a/modules/desktop_display.sh +++ b/modules/desktop_display.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash manage_desktop_display() { + # Hardware state is detected on demand; HAS_NVIDIA + # (GDM3 Wayland option) is populated by the battery. + _ensure_state_detected + while true; do local choice choice=$(_menu "Desktop & Display" \ diff --git a/modules/firmware.sh b/modules/firmware.sh index cd0531a..ca27121 100644 --- a/modules/firmware.sh +++ b/modules/firmware.sh @@ -346,6 +346,10 @@ _ensure_nonfree_repo() { # ── Main entry point ── install_firmware() { + # Hardware state is detected on demand; the device + # arrays and DESKTOP_ENV/AUDIO_SERVER are read below. + _ensure_state_detected + echo -e "${YELLOW}Base firmware check...${NC}" if ! _ensure_nonfree_repo; then diff --git a/modules/gaming.sh b/modules/gaming.sh index 0cc5f27..731585b 100644 --- a/modules/gaming.sh +++ b/modules/gaming.sh @@ -69,6 +69,10 @@ ensure_contrib_repo() { } install_gaming() { + # Hardware state is detected on demand; GPU_TYPE is + # consulted below for the NVIDIA hints. + _ensure_state_detected + echo -e "${YELLOW}Gaming setup...${NC}" # 1. Single checklist with ALL options (including i386 toggle) diff --git a/modules/gaming/heroic.sh b/modules/gaming/heroic.sh index 3ecf918..3b32057 100644 --- a/modules/gaming/heroic.sh +++ b/modules/gaming/heroic.sh @@ -4,12 +4,18 @@ install_heroic() { local heroic_deb heroic_deb=$(mktemp "${TMPDIR:-/tmp}/heroic-XXXXXX.deb") || return 1 + # Remove the temp file on every exit path (RETURN + # traps are not inherited by called functions). + trap 'rm -f "$heroic_deb"' RETURN local ua="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" _run_cmd "Heroic" "sudo apt install -y curl jq" "Installing dependencies..." local json - json=$(curl -s --connect-timeout 10 -H "User-Agent: $ua" \ + # -f: HTTP >= 400 must fail; --max-time bounds the + # whole transfer (connect-timeout only covers the + # connection phase). + json=$(curl -fsS --connect-timeout 10 --max-time 30 -H "User-Agent: $ua" \ "https://api.github.com/repos/Heroic-Games-Launcher/HeroicGamesLauncher/releases/latest") || { _msg "Heroic Error" "Could not fetch release data from GitHub API." 8 60 return 1 @@ -28,16 +34,23 @@ install_heroic() { # Strict URL validation before interpolating into a shell command (_run_cmd uses bash -c) [[ "$deb_url" =~ ^https://[A-Za-z0-9./_-]+\.deb$ ]] || { _msg "Error" "Invalid download URL: $deb_url"; return 1; } - _run_cmd "Heroic" "curl -fsSL -H 'User-Agent: $ua' -o '$heroic_deb' '$deb_url'" "Downloading Heroic..." + _run_cmd "Heroic" "curl -fsSL --max-time 300 -H 'User-Agent: $ua' -o '$heroic_deb' '$deb_url'" "Downloading Heroic..." - if ! dpkg-deb --info "$heroic_deb" >/dev/null 2>&1; then + # --info reads only the control archive; --contents + # also walks the data archive, catching deb files + # truncated after the control section. + if ! dpkg-deb --info "$heroic_deb" >/dev/null 2>&1 || \ + ! dpkg-deb --contents "$heroic_deb" >/dev/null 2>&1; then _msg "Heroic Error" "Downloaded .deb is corrupted or truncated.\n\nRemoving file." 10 60 rm -f "$heroic_deb" return 1 fi echo -e "${GREEN}Package integrity verified.${NC}" - _run_cmd "Heroic" "sudo apt install -y '$heroic_deb'" "Installing Heroic..." + if ! _run_cmd "Heroic" "sudo apt install -y '$heroic_deb'" "Installing Heroic..."; then + rm -f "$heroic_deb" + return 1 + fi rm -f "$heroic_deb" echo -e "${GREEN}Heroic Games Launcher installed.${NC}" } diff --git a/modules/gaming/tools.sh b/modules/gaming/tools.sh index eed8c4b..86d7583 100644 --- a/modules/gaming/tools.sh +++ b/modules/gaming/tools.sh @@ -38,12 +38,16 @@ install_openrgb() { local deb_path deb_path=$(mktemp "${TMPDIR:-/tmp}/openrgb-XXXXXX.deb") || return 1 + # Remove the temp file on every exit path. + trap 'rm -f "$deb_path"' RETURN local ua="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" _run_cmd "OpenRGB" "sudo apt install -y curl jq" "Installing dependencies..." local json="" - json=$(curl -s --connect-timeout 10 \ + # -f: HTTP >= 400 must fail; --max-time bounds the + # whole transfer. + json=$(curl -fsS --connect-timeout 10 --max-time 30 \ "https://codeberg.org/api/v1/repos/OpenRGB/OpenRGB/releases?limit=1") || { echo -e "${RED}Could not fetch OpenRGB releases from Codeberg API.${NC}" return 1 @@ -66,7 +70,7 @@ install_openrgb() { # Strict URL validation before interpolating into a shell command (_run_cmd uses bash -c) [[ "$deb_url" =~ ^https://[A-Za-z0-9./_-]+\.deb$ ]] || { _msg "Error" "Invalid download URL: $deb_url"; return 1; } - _run_cmd "OpenRGB" "curl -fsSL -o '${deb_path}' -A '${ua}' '${deb_url}'" "Downloading OpenRGB..." + _run_cmd "OpenRGB" "curl -fsSL --max-time 300 -o '${deb_path}' -A '${ua}' '${deb_url}'" "Downloading OpenRGB..." if [ -n "$sha256" ]; then if ! echo "$sha256 $deb_path" | sha256sum -c --strict; then @@ -76,7 +80,11 @@ install_openrgb() { fi echo -e "${GREEN}SHA256 verified.${NC}" else - if ! dpkg-deb --info "$deb_path" >/dev/null 2>&1; then + # --contents also walks the data archive, + # catching deb files truncated after the + # control section. + if ! dpkg-deb --info "$deb_path" >/dev/null 2>&1 || \ + ! dpkg-deb --contents "$deb_path" >/dev/null 2>&1; then echo -e "${RED}Downloaded .deb is corrupted. Removing.${NC}" rm -f "$deb_path" return 1 @@ -84,7 +92,14 @@ install_openrgb() { echo -e "${YELLOW}No SHA256 in API, validated via dpkg-deb.${NC}" fi - sudo apt install -y "$deb_path" + # The post-install steps (i2c group, udev rules) + # must only run if the package was actually + # installed; apt can fail on missing dependencies. + if ! sudo apt install -y "$deb_path"; then + rm -f "$deb_path" + echo -e "${RED}OpenRGB installation failed (dependency or dpkg error).${NC}" + return 1 + fi rm -f "$deb_path" sudo modprobe i2c-dev diff --git a/modules/gpu.sh b/modules/gpu.sh index eb04de8..d5cc126 100644 --- a/modules/gpu.sh +++ b/modules/gpu.sh @@ -28,6 +28,10 @@ _warn_nvidia_gnome_wayland() { } _install_amd_intel_stack() { + # Hardware state is detected on demand; GPU_TYPE and + # LSPCI_OUTPUT must be populated before planning. + _ensure_state_detected + if [ "$GPU_TYPE" = "unknown" ] || [ -z "$GPU_TYPE" ]; then local mesa_pkgs=(mesa-vulkan-drivers libgl1-mesa-dri libglx-mesa0 libegl-mesa0 mesa-va-drivers) local ref_ver @@ -174,6 +178,10 @@ _apply_amd_gcn_grub_fix() { } _install_nvidia_stack() { + # Hardware state is detected on demand; HAS_NVIDIA and + # LSPCI_OUTPUT must be populated before planning. + _ensure_state_detected + if ! $HAS_NVIDIA; then _msg "NVIDIA Not Found" "No NVIDIA GPU was detected.\n\nPlease check your hardware and try again." 10 60 return diff --git a/modules/kernel.sh b/modules/kernel.sh index 4f72c31..c4c44b5 100644 --- a/modules/kernel.sh +++ b/modules/kernel.sh @@ -33,6 +33,10 @@ show_kernel_menu() { } _install_kernel_package() { + # Hardware state is detected on demand; GPU_TYPE drives + # the NVIDIA DKMS/RT warnings below. + _ensure_state_detected + local pkg_base="$1" local flavor="$2" local bpo_flag="$3" diff --git a/modules/repos/migrate.sh b/modules/repos/migrate.sh index 5dfdf82..01c596d 100644 --- a/modules/repos/migrate.sh +++ b/modules/repos/migrate.sh @@ -13,7 +13,13 @@ _persistent_backup_repos() { [ -f /etc/apt/sources.list ] && files+=("/etc/apt/sources.list") [ -d /etc/apt/sources.list.d ] && files+=("/etc/apt/sources.list.d") [ ${#files[@]} -eq 0 ] && return 1 - sudo tar czf "$_MIGRATE_BACKUP" "${files[@]}" 2>/dev/null + # The rollback promise depends on this archive: + # a failed backup must abort before anything is + # deleted or rewritten. + if ! sudo tar czf "$_MIGRATE_BACKUP" "${files[@]}" 2>/dev/null; then + rm -f "$_MIGRATE_BACKUP" + return 1 + fi echo "Backup: $_MIGRATE_BACKUP" } @@ -23,7 +29,13 @@ _restore_backup() { return 1 fi echo -e "${YELLOW}Restoring repository backup...${NC}" - sudo tar xzf "$_MIGRATE_BACKUP" -C / 2>/dev/null + if ! sudo tar xzf "$_MIGRATE_BACKUP" -C / 2>/dev/null; then + _msg_red "Restore Failed" \ + "Extraction of $_MIGRATE_BACKUP failed.\n\ +/etc/apt sources may be missing or inconsistent.\n\n\ +Restore manually:\n sudo tar xzf $_MIGRATE_BACKUP -C /" 12 70 + return 1 + fi echo -e "${GREEN}Backup restored from $_MIGRATE_BACKUP${NC}" } @@ -64,7 +76,14 @@ _write_deb822_branch() { fi sudo mkdir -p /etc/apt/sources.list.d - echo -e "$main_content" | sudo tee "$main_file" >/dev/null + # Stage to a temp file and move atomically: an + # interrupted write never leaves a partial sources. + if ! echo -e "$main_content" | sudo tee "${main_file}.tmp" >/dev/null; then + return 1 + fi + if ! sudo mv "${main_file}.tmp" "$main_file"; then + return 1 + fi echo "Wrote $main_file" } @@ -80,7 +99,14 @@ _write_classic_branch() { main_content+="deb https://security.debian.org/debian-security ${target}-security main contrib non-free non-free-firmware\n" fi - echo -e "$main_content" | sudo tee "$main_file" >/dev/null + # Stage to a temp file and move atomically: an + # interrupted write never leaves a partial sources. + if ! echo -e "$main_content" | sudo tee "${main_file}.tmp" >/dev/null; then + return 1 + fi + if ! sudo mv "${main_file}.tmp" "$main_file"; then + return 1 + fi echo "Wrote $main_file" } @@ -93,7 +119,8 @@ Risks include:\n\ • NVIDIA / DKMS drivers may break\n\ • System may fail to boot after reboot\n\ • Some packages may be removed or replaced\n\ - • SID (unstable) receives NO security updates\n\n\ + • SID (unstable) has no release cycle; security\n\ + fixes arrive via package uploads, not DSAs\n\n\ A full persistent backup will be saved to /var/backups/\n\ so you can restore if things go wrong." 16 70 @@ -154,16 +181,20 @@ so you can restore if things go wrong." 16 70 [ -f /etc/apt/sources.list.d/debian-backports.sources ] && sudo rm -f /etc/apt/sources.list.d/debian-backports.sources [ -f /etc/apt/sources.list.d/debian-backports.list ] && sudo rm -f /etc/apt/sources.list.d/debian-backports.list - # 4c. Remove any old classic source files to avoid conflicts - [ -f /etc/apt/sources.list ] && sudo rm -f /etc/apt/sources.list - - # 4d. Write new sources + # 4c. Write new sources if ! _write_branch_sources "$target"; then echo -e "${RED}[-]${NC} Failed to write new sources. Restoring backup..." _restore_backup || true return 1 fi + # 4d. Remove the old classic file AFTER the new + # sources are live: an interrupt between steps + # leaves both files (recoverable), not none. + # Only needed on the deb822 path; the classic + # writer replaced sources.list atomically. + [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list ] && sudo rm -f /etc/apt/sources.list + # 4e. SID guardrails: install bug alerts before upgrade if [ "$target" = "sid" ]; then echo -e "${YELLOW}Installing apt-listbugs and apt-listchanges (SID guardrails)...${NC}" @@ -175,19 +206,40 @@ so you can restore if things go wrong." 16 70 echo -e "${YELLOW}Running apt update...${NC}" if ! sudo apt update; then echo -e "${RED}apt update failed. Restoring backup...${NC}" - _restore_backup - _msg_red "Migration Failed" \ - "apt update failed. Backup has been restored from:\n\ + if _restore_backup; then + _msg_red "Migration Failed" \ + "apt update failed. Backup has been restored from:\n\ $_MIGRATE_BACKUP\n\n\ Your system should be back to its previous state.\n\ Run 'sudo apt update' manually to verify." 12 70 - return + else + _msg_red "Migration Failed" \ + "apt update failed AND the backup could not be\n\ +restored from $_MIGRATE_BACKUP.\n\n\ +Restore it manually:\n sudo tar xzf $_MIGRATE_BACKUP -C /\n\ +then run: sudo apt update" 12 70 + fi + return 1 fi # 4g. Full upgrade - _run_cmd "Upgrade" "sudo apt upgrade -y" "Upgrading packages..." - _run_cmd "Full-Upgrade" "sudo apt full-upgrade -y" "Running full-upgrade..." - _run_cmd "Autoremove" "sudo apt autoremove -y" "Removing obsolete packages..." + # A failed upgrade leaves packages half-installed. + # Restoring only the sources would be worse (stable + # sources + testing packages), so stop with an + # honest report and manual-recovery instructions. + if ! _run_cmd "Upgrade" "sudo apt upgrade -y" "Upgrading packages..."; then + _msg_red "Migration Incomplete" \ + "Package upgrade failed. The system is partially\n\ +upgraded to ${target}. Do NOT reboot yet.\n\n\ +Repair with:\n sudo apt --fix-broken install\n sudo dpkg --configure -a\n\n\ +To roll back the branch manually, restore:\n sudo tar xzf $_MIGRATE_BACKUP -C /" 14 70 + return 1 + fi + if ! _run_cmd "Full-Upgrade" "sudo apt full-upgrade -y" "Running full-upgrade..."; then + _msg_red "Migration Incomplete" "full-upgrade failed. Repair with:\n sudo apt --fix-broken install\nBackup: $_MIGRATE_BACKUP" 10 70 + return 1 + fi + _run_cmd "Autoremove" "sudo apt autoremove -y" "Removing obsolete packages..." || true # 4h. Re-run detection to reflect new branch echo -e "${YELLOW}Re-running system detection for new branch...${NC}" diff --git a/modules/repos/repo_detect.sh b/modules/repos/repo_detect.sh index 0b4350b..3cea03c 100644 --- a/modules/repos/repo_detect.sh +++ b/modules/repos/repo_detect.sh @@ -5,12 +5,13 @@ # Any active (non-commented) source of any format, in any file? # Returns: 0 if at least one active source exists, 1 otherwise has_active_deb_sources() { + local apt_dir="${APT_DIR:-/etc/apt}" local f - for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list; do + for f in "$apt_dir/sources.list" "$apt_dir/sources.list.d/"*.list; do [ -f "$f" ] || continue grep -qE '^[^#]*\bdeb\b' "$f" 2>/dev/null && return 0 done - for f in /etc/apt/sources.list.d/*.sources; do + for f in "$apt_dir/sources.list.d/"*.sources; do [ -f "$f" ] || continue grep -qE '^Types:.*\bdeb\b' "$f" 2>/dev/null && \ grep -qE '^URIs:' "$f" 2>/dev/null && return 0 @@ -22,10 +23,11 @@ has_active_deb_sources() { # DEB822 is only valid on Debian 13 (Trixie); Debian 11/12 are classic-only. # Returns: "deb822", "classic", or "none" detect_repo_format() { - if [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list.d/debian.sources ] && \ - grep -qE '^Types:.*\bdeb\b' /etc/apt/sources.list.d/debian.sources 2>/dev/null; then + local apt_dir="${APT_DIR:-/etc/apt}" + if [ "$DEBIAN_VERSION" = "13" ] && [ -f "$apt_dir/sources.list.d/debian.sources" ] && \ + grep -qE '^Types:.*\bdeb\b' "$apt_dir/sources.list.d/debian.sources" 2>/dev/null; then echo "deb822" - elif [ -f /etc/apt/sources.list ] && grep -qE '^[^#]*\bdeb\b' /etc/apt/sources.list 2>/dev/null; then + elif [ -f "$apt_dir/sources.list" ] && grep -qE '^[^#]*\bdeb\b' "$apt_dir/sources.list" 2>/dev/null; then echo "classic" else echo "none" @@ -35,17 +37,18 @@ detect_repo_format() { # Detect the components currently active in the main repo file # Returns: components list (e.g. "main contrib non-free non-free-firmware") detect_active_components() { - if [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list.d/debian.sources ]; then + local apt_dir="${APT_DIR:-/etc/apt}" + if [ "$DEBIAN_VERSION" = "13" ] && [ -f "$apt_dir/sources.list.d/debian.sources" ]; then local comps - comps=$(grep "^Components:" /etc/apt/sources.list.d/debian.sources 2>/dev/null | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//') + comps=$(grep "^Components:" "$apt_dir/sources.list.d/debian.sources" 2>/dev/null | head -1 | cut -d: -f2- | sed 's/^[[:space:]]*//') if [ -n "$comps" ]; then echo "$comps" return fi fi - if [ -f /etc/apt/sources.list ]; then + if [ -f "$apt_dir/sources.list" ]; then local comps - comps=$(grep "^[^#]*deb .* main" /etc/apt/sources.list 2>/dev/null | head -1 | sed 's/.*main\s*//') + comps=$(grep "^[^#]*deb .* main" "$apt_dir/sources.list" 2>/dev/null | head -1 | sed 's/.*main\s*//') if [ -n "$comps" ]; then echo "main $comps" return @@ -61,17 +64,18 @@ detect_active_components() { # Check whether backports are currently enabled (any format, any file) # Returns: 0 if enabled, 1 otherwise detect_backports_status() { + local apt_dir="${APT_DIR:-/etc/apt}" local codename="$1" - if [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list.d/debian.sources ]; then - grep -qE "^Suites:.*${codename}-backports" /etc/apt/sources.list.d/debian.sources 2>/dev/null && return 0 + if [ "$DEBIAN_VERSION" = "13" ] && [ -f "$apt_dir/sources.list.d/debian.sources" ]; then + grep -qE "^Suites:.*${codename}-backports" "$apt_dir/sources.list.d/debian.sources" 2>/dev/null && return 0 fi - if [ -f /etc/apt/sources.list ]; then - grep -qE "^[^#]*${codename}-backports" /etc/apt/sources.list 2>/dev/null && return 0 + if [ -f "$apt_dir/sources.list" ]; then + grep -qE "^[^#]*${codename}-backports" "$apt_dir/sources.list" 2>/dev/null && return 0 fi - if [ -d /etc/apt/sources.list.d ]; then - grep -qrE "^Suites:.*${codename}-backports" /etc/apt/sources.list.d/*.sources 2>/dev/null && return 0 - grep -qrE "^[^#]*${codename}-backports" /etc/apt/sources.list.d/*.list 2>/dev/null && return 0 + if [ -d "$apt_dir/sources.list.d" ]; then + grep -qrE "^Suites:.*${codename}-backports" "$apt_dir/sources.list.d/"*.sources 2>/dev/null && return 0 + grep -qrE "^[^#]*${codename}-backports" "$apt_dir/sources.list.d/"*.list 2>/dev/null && return 0 fi return 1 @@ -84,24 +88,25 @@ detect_backports_status() { # "embedded-classic" (inside sources.list), # "none" detect_backports_location() { + local apt_dir="${APT_DIR:-/etc/apt}" local codename="$1" - if [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list.d/debian-backports.sources ] && \ - grep -qE "^Suites:.*${codename}-backports" /etc/apt/sources.list.d/debian-backports.sources 2>/dev/null; then + if [ "$DEBIAN_VERSION" = "13" ] && [ -f "$apt_dir/sources.list.d/debian-backports.sources" ] && \ + grep -qE "^Suites:.*${codename}-backports" "$apt_dir/sources.list.d/debian-backports.sources" 2>/dev/null; then echo "standalone-deb822" - elif [ -f /etc/apt/sources.list.d/debian-backports.list ] && \ - grep -qE "^[^#]*${codename}-backports" /etc/apt/sources.list.d/debian-backports.list 2>/dev/null; then + elif [ -f "$apt_dir/sources.list.d/debian-backports.list" ] && \ + grep -qE "^[^#]*${codename}-backports" "$apt_dir/sources.list.d/debian-backports.list" 2>/dev/null; then echo "standalone-classic" - elif [ "$DEBIAN_VERSION" = "13" ] && [ -f /etc/apt/sources.list.d/debian.sources ] && \ - grep -qE "^Suites:.*${codename}-backports" /etc/apt/sources.list.d/debian.sources 2>/dev/null; then + elif [ "$DEBIAN_VERSION" = "13" ] && [ -f "$apt_dir/sources.list.d/debian.sources" ] && \ + grep -qE "^Suites:.*${codename}-backports" "$apt_dir/sources.list.d/debian.sources" 2>/dev/null; then echo "embedded-deb822" - elif [ -f /etc/apt/sources.list ] && \ - grep -qE "^[^#]*${codename}-backports" /etc/apt/sources.list 2>/dev/null; then + elif [ -f "$apt_dir/sources.list" ] && \ + grep -qE "^[^#]*${codename}-backports" "$apt_dir/sources.list" 2>/dev/null; then echo "embedded-classic" - elif [ -d /etc/apt/sources.list.d ]; then - if grep -qrE "^Suites:.*${codename}-backports" /etc/apt/sources.list.d/*.sources 2>/dev/null; then + elif [ -d "$apt_dir/sources.list.d" ]; then + if grep -qrE "^Suites:.*${codename}-backports" "$apt_dir/sources.list.d/"*.sources 2>/dev/null; then echo "embedded-deb822" - elif grep -qrE "^[^#]*${codename}-backports" /etc/apt/sources.list.d/*.list 2>/dev/null; then + elif grep -qrE "^[^#]*${codename}-backports" "$apt_dir/sources.list.d/"*.list 2>/dev/null; then echo "embedded-classic" else echo "none" diff --git a/modules/swap.sh b/modules/swap.sh index 6611811..bfefe77 100644 --- a/modules/swap.sh +++ b/modules/swap.sh @@ -154,6 +154,10 @@ _swap_set_swappiness() { # ── Entry point ── manage_swap() { + # Hardware state is detected on demand; RAM_GB + # drives the size recommendation. + _ensure_state_detected + exec 9>"$SWAP_LOCK" flock -n 9 || { _msg "Busy" "Another swap operation is already running." 8 55 diff --git a/modules/sysinfo.sh b/modules/sysinfo.sh index 3871261..bcbe79f 100644 --- a/modules/sysinfo.sh +++ b/modules/sysinfo.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash _show_sysinfo() { + # Hardware state is detected on demand; populate it + # before reading any DETECT_* global. + _ensure_state_detected + local msg="" # ── OS Block ── diff --git a/modules/utils.sh b/modules/utils.sh index 8ad6742..d51d94b 100644 --- a/modules/utils.sh +++ b/modules/utils.sh @@ -28,6 +28,75 @@ APT_UPDATED=0 # Cached output of `lspci -nn` for the whole session (populated once via _init_lspci_cache) LSPCI_OUTPUT="" +# Deferred hardware state: the detection battery runs on +# first need (_ensure_state_detected) instead of at boot. +STATE_DETECTED=0 + +# Network check result cache: 0 = online, 1 = offline, +# empty = not checked yet (checked on first use). +_NET_STATUS="" + +# Lazy module registry (presence of a key = loaded). +# Populated by _load_module() so every module is +# sourced at most once per session. +declare -A _LOADED_MODULES=() + +# ── Lazy module loading ────────────────────────── +# Sources a module on first use and remembers it in +# _LOADED_MODULES. Call at the top of a main_menu() +# case branch, before calling into the module: +# _load_module || continue +# Cross-module dependencies (functions called at +# runtime that live in another module) are declared +# in the deps case below, so a module never calls +# an unloaded function. +_load_module() { + # Defensive: an empty name would produce a bad + # array subscript under `set -u`. + local name="${1:-}" + [ -n "$name" ] || return 1 + + # Already loaded in this session? + [ -n "${_LOADED_MODULES[$name]+x}" ] && return 0 + + # Module file path. Most modules live directly in + # modules/; a few have a dedicated subdirectory. + local path + case "$name" in + system_prefs) path="${MODULES_DIR}/system/system_prefs.sh" ;; + audio) path="${MODULES_DIR}/system/audio.sh" ;; + java) path="${MODULES_DIR}/extras/java.sh" ;; + repo_detect) path="${MODULES_DIR}/repos/repo_detect.sh" ;; + *) path="${MODULES_DIR}/${name}.sh" ;; + esac + + # Cross-module dependencies, loaded first. + local -a deps=() + case "$name" in + system_prefs) deps=(audio) ;; + repos) deps=(repo_detect) ;; + firmware) deps=(repo_detect bluetooth repos) ;; + gaming) deps=(repo_detect java repos) ;; + extras) deps=(java) ;; + desktop_display) deps=(repos) ;; + esac + + local dep + for dep in "${deps[@]}"; do + _load_module "$dep" || return 1 + done + + if [ ! -f "$path" ]; then + echo -e "${RED}[-] Module not found: ${path}${NC}" >&2 + return 1 + fi + + # shellcheck disable=SC1090 + source "$path" + _LOADED_MODULES["$name"]=1 + return 0 +} + # -------------------------- # Pre-flight checks # -------------------------- @@ -487,21 +556,22 @@ is_backports_enabled() { local c_pattern="^[^#]*${codename}-backports[[:space:]]+" local d_pattern="Suites:.*${codename}-backports" + local apt_dir="${APT_DIR:-/etc/apt}" # Classic embedded (sources.list) - if [ -f /etc/apt/sources.list ] && grep -Eq "$c_pattern" /etc/apt/sources.list 2>/dev/null; then + if [ -f "$apt_dir/sources.list" ] && grep -Eq "$c_pattern" "$apt_dir/sources.list" 2>/dev/null; then echo true return fi # Classic standalone (any .list file in sources.list.d) - if [ -d /etc/apt/sources.list.d ] && grep -qrE "$c_pattern" /etc/apt/sources.list.d/*.list 2>/dev/null; then + if [ -d "$apt_dir/sources.list.d" ] && grep -qrE "$c_pattern" "$apt_dir/sources.list.d/"*.list 2>/dev/null; then echo true return fi # Deb822 any .sources file - if [ -d /etc/apt/sources.list.d ] && grep -qr "$d_pattern" /etc/apt/sources.list.d/*.sources 2>/dev/null; then + if [ -d "$apt_dir/sources.list.d" ] && grep -qr "$d_pattern" "$apt_dir/sources.list.d/"*.sources 2>/dev/null; then echo true return fi @@ -738,19 +808,29 @@ _detect_lang_pkg() { _check_network() { local target="${1:-deb.debian.org}" - if command -v ping &>/dev/null; then - ping -c 1 -W 3 "$target" &>/dev/null && return 0 - fi + # Single bounded probe (worst case 3 s; the old + # ping/wget/curl cascade could block up to 13 s). + [[ "$target" =~ ^[a-zA-Z0-9.-]+$ ]] || return 1 + timeout 3 bash -c "/dev/null +} - if command -v wget &>/dev/null; then - wget -q --timeout=5 --spider "http://${target}" &>/dev/null && return 0 +# ── On-demand network check (non-blocking boot) ── +# The connectivity banner moved from boot to the first +# apt operation. _NET_STATUS caches the result so the +# probe runs at most once per session. +_require_network() { + if [ -z "$_NET_STATUS" ]; then + if _check_network; then + _NET_STATUS=0 + else + _NET_STATUS=1 + echo -e "${YELLOW}──────────────────────────────────────────${NC}" + echo -e "${YELLOW} No internet connectivity detected.${NC}" + echo -e "${YELLOW} Package installation will fail without network.${NC}" + echo -e "${YELLOW}──────────────────────────────────────────${NC}" + fi fi - - if command -v curl &>/dev/null; then - curl -s --connect-timeout 5 -o /dev/null "http://${target}" &>/dev/null && return 0 - fi - - return 1 + return "$_NET_STATUS" } # ---------------------------------- @@ -763,6 +843,15 @@ _ensure_apt_updated() { echo -e "${GREEN}[+]${NC} APT package lists already refreshed this session." return 0 fi + + # Advisory: probe the network once (cached in + # _NET_STATUS) before attempting an apt operation. + _require_network || true + + # A correct clock is required for HTTPS certificate + # validation; only runs when apt is actually used. + _ensure_time_synced + echo -e "${GREEN}[+]${NC} Refreshing APT package lists..." if sudo apt-get update; then APT_UPDATED=1 @@ -772,41 +861,33 @@ _ensure_apt_updated() { return 1 } -# ---------------------------------- -# LightDM configuration -# ---------------------------------- -_configure_lightdm() { - command -v lightdm &>/dev/null || return 0 - - if _confirm "LightDM" "Configure LightDM to show the user list on the login screen?\n\nThis disables greeter-hide-users."; then - if ! is_installed lightdm-gtk-greeter-settings; then - echo -e "${YELLOW}Installing lightdm-gtk-greeter-settings...${NC}" - if ! sudo DEBIAN_FRONTEND=noninteractive apt install -y lightdm-gtk-greeter-settings; then - echo -e "${YELLOW}lightdm-gtk-greeter-settings could not be installed — LightDM configuration skipped.${NC}" - return 0 - fi - fi - - local conf_dir="/etc/lightdm/lightdm.conf.d" - local conf_file="${conf_dir}/99-show-users.conf" - - if [ -f "$conf_file" ] && grep -q '^greeter-hide-users=false' "$conf_file"; then - return - fi - - sudo mkdir -p "$conf_dir" - printf '[Seat:*]\ngreeter-hide-users=false\n' | sudo tee "$conf_file" >/dev/null - echo -e "${GREEN}LightDM configured to show user list.${NC}" - fi +# ── Deferred hardware state (lazy pre-flight) ── +# The detection battery runs on first need instead of at +# boot. Call _ensure_state_detected() before reading any +# DETECT_* global (LSPCI_OUTPUT, GPU_*, ETH_NAMES, +# DESKTOP_ENV, AUDIO_SERVER, ...). +_run_detection_battery() { + _init_lspci_cache + detect_gpu + detect_network + detect_displayserver + detect_storage + detect_desktop_environment + detect_audio_server + detect_cpu_ram + detect_kernel +} +_ensure_state_detected() { + [ "$STATE_DETECTED" -eq 1 ] && return 0 + _run_detection_battery + STATE_DETECTED=1 } # ── Lazy system state refresh ── refresh_system_state() { detect_debian_version - detect_gpu - detect_cpu_ram - detect_network - detect_desktop_environment - detect_displayserver - detect_audio_server + # Forced re-run: a menu action may have changed the + # hardware or repo state since the first detection. + _run_detection_battery + STATE_DETECTED=1 } diff --git a/modules/zram.sh b/modules/zram.sh index ac12fe0..2faad92 100644 --- a/modules/zram.sh +++ b/modules/zram.sh @@ -2,6 +2,10 @@ # zram.sh — ZRAM submenu: view, create/reconfigure, remove zram_menu() { + # Hardware state is detected on demand; RAM_KB + # and RAM_SUMMARY are populated by the battery. + _ensure_state_detected + while true; do local choice choice=$(_menu "ZRAM Configuration" \ diff --git a/tests/repo_detect.bats b/tests/repo_detect.bats new file mode 100644 index 0000000..87f0cd7 --- /dev/null +++ b/tests/repo_detect.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# repo_detect.bats — Offline tests for modules/repos/repo_detect.sh +# and is_backports_enabled() from modules/utils.sh. +# +# No `set -euo pipefail` here: BATS controls error handling +# per test. +# +# The modules under test read ${APT_DIR:-/etc/apt} (the APT_DIR +# indirection added for testability). mock_reset() points APT_DIR +# at a fresh empty $MOCK_APT_ROOT per test, and every test builds +# its own fixture tree inside it — the real /etc/apt is never +# read, so the suite is 100% offline and hermetic. +# +# Every function under test answers via stdout (echo +# true/false/deb822/...), so `run` + `$output` is the natural +# assertion style. + +load test_helper.bash + +setup() { + mock_reset + load_utils +} + +teardown() { + if [ -n "${MOCK_SUDO_LOG:-}" ]; then + rm -f "$MOCK_SUDO_LOG" + fi + if [ -n "${MOCK_APT_ROOT:-}" ]; then + rm -rf "$MOCK_APT_ROOT" + fi +} + +# ── Fixture helper ─────────────────────────────────── +# Write one fixture file inside the mock apt root, with one +# content line per argument: +# _fixt_file sources.list "deb ... bookworm main" ... +# _fixt_file sources.list.d/debian.sources "Types: deb" ... +_fixt_file() { + local rel="$1" + shift + mkdir -p "${MOCK_APT_ROOT}/$(dirname "$rel")" + printf '%s\n' "$@" >"${MOCK_APT_ROOT}/${rel}" +} + +# ── is_backports_enabled (modules/utils.sh) ────────── +@test "is_backports_enabled returns true with backports in sources.list" { + _fixt_file sources.list \ + "deb http://deb.debian.org/debian bookworm main" \ + "deb http://deb.debian.org/debian bookworm-updates main" \ + "deb http://deb.debian.org/debian bookworm-backports main" + run is_backports_enabled + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "is_backports_enabled returns false without backports" { + _fixt_file sources.list \ + "deb http://deb.debian.org/debian bookworm main" \ + "deb http://deb.debian.org/debian bookworm-updates main" + run is_backports_enabled + [ "$status" -eq 0 ] + [ "$output" = "false" ] +} + +@test "is_backports_enabled returns true with a standalone backports .list file" { + _fixt_file sources.list.d/debian-backports.list \ + "deb http://deb.debian.org/debian bookworm-backports main" + run is_backports_enabled + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +@test "is_backports_enabled returns true with a deb822 backports suite" { + _fixt_file sources.list.d/debian-backports.sources \ + "Types: deb" \ + "URIs: http://deb.debian.org/debian" \ + "Suites: bookworm-backports" \ + "Components: main" \ + "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" + run is_backports_enabled + [ "$status" -eq 0 ] + [ "$output" = "true" ] +} + +# ── detect_repo_format ─────────────────────────────── +@test "detect_repo_format returns deb822 for a debian.sources file" { + # DEB822 is only valid on Debian 13 (Trixie). + DEBIAN_VERSION="13" + _fixt_file sources.list.d/debian.sources \ + "Types: deb" \ + "URIs: http://deb.debian.org/debian" \ + "Suites: bookworm bookworm-updates" \ + "Components: main contrib non-free non-free-firmware" \ + "Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg" + run detect_repo_format + [ "$status" -eq 0 ] + [ "$output" = "deb822" ] +} + +@test "detect_repo_format returns classic for a classic sources.list" { + _fixt_file sources.list \ + "deb http://deb.debian.org/debian bookworm main" \ + "deb http://deb.debian.org/debian bookworm-updates main" + run detect_repo_format + [ "$status" -eq 0 ] + [ "$output" = "classic" ] +} + +@test "detect_repo_format returns none for an empty apt root" { + run detect_repo_format + [ "$status" -eq 0 ] + [ "$output" = "none" ] +} diff --git a/tests/test_helper.bash b/tests/test_helper.bash new file mode 100644 index 0000000..50db187 --- /dev/null +++ b/tests/test_helper.bash @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# test_helper.bash — Setup and offline system mocks for the +# debianito BATS test suite. +# +# Layout +# ------ +# load_utils() Safe loading of the modules under test. +# _mock_*() Mock implementations (one per command). +# _install_mocks() Serializes the _mock_* functions into +# standalone executables under $MOCK_BIN_DIR. +# mock_reset() Per-test fixture reset + PATH setup. +# +# Why PATH-based mock scripts instead of plain shell functions? +# ------------------------------------------------------------ +# _init_lspci_cache() runs `timeout 2 lspci -nn` and +# detect_network() runs `timeout 2 ip -o link show`, +# `timeout 2 ip -4 -o addr show ` and +# `timeout 2 iwgetid -r `. timeout(1) execs its +# target as a CHILD PROCESS, and a child process cannot see +# the shell functions of its parent. The _mock_* functions +# are therefore written to disk as real executables +# (self-contained: shebang + `declare -f` output + dispatch +# line) and $MOCK_BIN_DIR is prepended to PATH, so both +# direct calls and timeout-wrapped calls hit the mocks. +# +# Everything here is 100% offline: no network, no apt +# update, and no real package is installed or removed. +# +# shellcheck disable=SC2148 + +# ── Locations ────────────────────────────────────────────────── +[ -n "${BATS_TEST_DIRNAME:-}" ] || { + echo "test_helper.bash must be loaded from a BATS test file" >&2 + return 1 2>/dev/null || exit 1 +} + +TEST_PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" +export TEST_PROJECT_ROOT + +# Holds the generated mock executables (created once per +# load; the OS cleans /tmp, so no per-test cleanup needed). +MOCK_BIN_DIR="$(mktemp -d "${TMPDIR:-/tmp}/debianito-mocks.XXXXXX")" +export MOCK_BIN_DIR + +# ── Safe module loading ──────────────────────────────────────── +# Contract (verified against the current sources): +# * modules/utils.sh and modules/repos/repo_detect.sh +# contain ONLY function definitions and variable +# assignments — no top-level command execution — so +# sourcing them is side-effect free. +# * debianito.sh itself must NEVER be sourced from tests: +# it executes the module chain and the menu at top +# level. +load_utils() { + local f + for f in modules/utils.sh modules/repos/repo_detect.sh; do + if [ ! -f "${TEST_PROJECT_ROOT}/${f}" ]; then + echo "load_utils: missing ${TEST_PROJECT_ROOT}/${f}" >&2 + return 1 + fi + done + + # shellcheck source=modules/utils.sh + source "${TEST_PROJECT_ROOT}/modules/utils.sh" + # shellcheck source=modules/repos/repo_detect.sh + source "${TEST_PROJECT_ROOT}/modules/repos/repo_detect.sh" + + # Note: sourcing from inside a function makes the + # top-level `declare -a ETH_NAMES=()` & co. of utils.sh + # function-local; they vanish when load_utils() returns. + # That is harmless — the functions under test assign + # those arrays as globals at call time (e.g. the BH-013 + # reset at the top of detect_network()), so their + # behaviour is unchanged. + : +} + +# ── Globals that debianito.sh normally defines ─────────────── +# The modules consume them at call time only. Colour codes +# are kept EMPTY on purpose so output assertions stay clean. +export RED="" GREEN="" YELLOW="" CYAN="" NC="" +export DEBIAN_CODENAME="bookworm" +export DEBIAN_VERSION="12" +export MODULES_DIR="${TEST_PROJECT_ROOT}/modules" + +# ════════════════════════════════════════════════════════════ +# Mock implementations +# +# Each mock reads its canned data from MOCK_* environment +# variables, so a test injects predictable output with a +# plain assignment (the vars are exported by mock_reset, and +# assigning to an exported var keeps the export attribute): +# +# MOCK_DPKG_INSTALLED space-separated "installed" packages +# MOCK_DPKG_VERSION version reported for installed pkgs +# MOCK_APT_CACHE_POLICY_OUTPUT `apt-cache policy ` output +# MOCK_APT_CACHE_MADISON_OUTPUT `apt-cache madison ` output +# MOCK_APT_CACHE_SEARCH_OUTPUT `apt-cache search ` output +# MOCK_LSPCI_OUTPUT `lspci -nn` output +# MOCK_LSUSB_OUTPUT `lsusb` output +# MOCK_IP_LINK_OUTPUT `ip -o link show` output +# MOCK_IP_ADDR_OUTPUT "iface cidr" lines for +# `ip -4 -o addr show ` +# MOCK_SSID_OUTPUT `iwgetid -r ` output +# MOCK_TIMEZONE_OUTPUT `timedatectl show -p Timezone --value` +# MOCK_NTP_SYNC_OUTPUT `timedatectl show --property=NTPSynchronized --value` +# MOCK_WHIPTAIL_OUTPUT / _RC whiptail stdout / exit code +# MOCK_SUDO_DRY_RUN 1 = log-only sudo (see _mock_sudo) +# MOCK_SUDO_LOG log file for dry-run sudo +# MOCK_APT_ROOT per-test fixture mirror of /etc/apt, +# exported to the code under test as +# APT_DIR (see the APT_DIR +# indirection in utils.sh and +# repos/repo_detect.sh) +# ════════════════════════════════════════════════════════════ + +# ── dpkg(1) ─────────────────────────────────────────────────── +# `dpkg -l ` prints an "ii" status line and exits 0 +# when is listed in $MOCK_DPKG_INSTALLED; prints +# nothing and exits 1 otherwise (real dpkg -l reports +# "no packages found" with status 1). is_installed() +# greps for a leading "ii", so the line prefix is what +# matters — both are implemented faithfully anyway. +_mock_dpkg() { + local pkg="${@: -1}" # last argument + local installed + for installed in ${MOCK_DPKG_INSTALLED:-}; do + if [ "$installed" = "$pkg" ]; then + printf 'ii %s %s all mock package\n' \ + "$pkg" "${MOCK_DPKG_VERSION:-1.0.0-mock}" + return 0 + fi + done + return 1 +} + +# ── apt-cache(8) ────────────────────────────────────────────── +# Canned output per subcommand. The policy fixture needs at +# least 3 lines: _get_pkg_version() parses field 2 of line +# 3 (the "Candidate:" line in real output). +_mock_apt_cache() { + local sub="${1:-}" + [ $# -gt 0 ] && shift + case "$sub" in + policy) + [ -n "${MOCK_APT_CACHE_POLICY_OUTPUT:-}" ] && + printf '%s\n' "${MOCK_APT_CACHE_POLICY_OUTPUT}" + ;; + madison) + [ -n "${MOCK_APT_CACHE_MADISON_OUTPUT:-}" ] && + printf '%s\n' "${MOCK_APT_CACHE_MADISON_OUTPUT}" + ;; + search) + [ -n "${MOCK_APT_CACHE_SEARCH_OUTPUT:-}" ] && + printf '%s\n' "${MOCK_APT_CACHE_SEARCH_OUTPUT}" + ;; + esac + return 0 +} + +# ── lspci(8) ────────────────────────────────────────────────── +# Prints $MOCK_LSPCI_OUTPUT verbatim; arguments (-nn) are +# ignored. Served through the PATH wrapper because +# _init_lspci_cache() captures `timeout 2 lspci -nn`. +_mock_lspci() { + printf '%s' "${MOCK_LSPCI_OUTPUT:-}" + return 0 +} + +# ── lsusb(8) ────────────────────────────────────────────────── +# Prints $MOCK_LSUSB_OUTPUT verbatim. detect_network() +# uses it as the USB-WiFi fallback (layer 4) when no PCI +# wireless device exists. +_mock_lsusb() { + printf '%s' "${MOCK_LSUSB_OUTPUT:-}" + return 0 +} + +# ── ip(8) ───────────────────────────────────────────────────── +# Just enough for detect_network(): +# ip -o link show → one line per interface +# ($MOCK_IP_LINK_OUTPUT) +# ip -4 -o addr show → a full "ip -o" line whose +# 4th whitespace field is +# the IPv4 CIDR of , +# looked up from the +# "iface cidr" lines of +# $MOCK_IP_ADDR_OUTPUT +_mock_ip() { + case "$1 $2" in + "-o link") + # Real `ip` terminates its output with a newline; + # `while read` loops (detect_network) drop the last + # line of an unterminated stream, so the mock must + # terminate it too. + if [ -n "${MOCK_IP_LINK_OUTPUT:-}" ]; then + printf '%s\n' "${MOCK_IP_LINK_OUTPUT:-}" + fi + ;; + "-4 -o") + local iface="${5:-}" name cidr + [ -z "$iface" ] && return 0 + while IFS=' ' read -r name cidr; do + if [ "$name" = "$iface" ]; then + printf '2: %s inet %s brd 192.168.1.255 scope global dynamic %s\n' \ + "$iface" "$cidr" "$iface" + return 0 + fi + done <>"${MOCK_SUDO_LOG:-/tmp/debianito-sudo-mock.log}" + return 0 + fi + while [ $# -gt 0 ]; do + case "$1" in + -*) shift ;; + *) break ;; + esac + done + [ $# -eq 0 ] && return 0 # e.g. `sudo -v` + env "$@" +} + +# ── whiptail(1) ─────────────────────────────────────────────── +# Prints $MOCK_WHIPTAIL_OUTPUT on stdout and exits with +# $MOCK_WHIPTAIL_RC (default 0 = "Yes" for confirm +# dialogs). Not used by the current tests; future-proofs +# the _confirm / _menu / _checklist helpers. +_mock_whiptail() { + printf '%s' "${MOCK_WHIPTAIL_OUTPUT:-}" + return "${MOCK_WHIPTAIL_RC:-0}" +} + +# ── Mock installation ───────────────────────────────────────── +# Serializes every _mock_* function into a standalone +# executable under $MOCK_BIN_DIR (shebang + `declare -f` +# output + dispatch line). See the header comment for why +# PATH scripts are required. +_install_mocks() { + local name func + for name in dpkg apt-cache lspci lsusb ip iwgetid timedatectl sudo whiptail; do + func="_mock_${name//-/_}" # apt-cache → _mock_apt_cache + if ! declare -f "$func" >/dev/null; then + echo "test_helper.bash: mock function ${func} is not defined" >&2 + return 1 + fi + { + printf '#!/usr/bin/env bash\n' + declare -f "$func" + printf '%s "$@"\n' "$func" + } >"${MOCK_BIN_DIR}/${name}" + chmod +x "${MOCK_BIN_DIR}/${name}" + done +} + +# ── Per-test reset ──────────────────────────────────────────── +# Restores every fixture to a predictable empty state and +# puts the mock bin dir first on PATH. Call from setup() in +# each .bats file: BATS runs every test in a fresh subshell, +# so PATH changes never leak between tests. +mock_reset() { + # dpkg(1) + export MOCK_DPKG_INSTALLED="" + export MOCK_DPKG_VERSION="1.0.0-mock" + + # apt-cache(8) + export MOCK_APT_CACHE_POLICY_OUTPUT="" + export MOCK_APT_CACHE_MADISON_OUTPUT="" + export MOCK_APT_CACHE_SEARCH_OUTPUT="" + + # hardware + export MOCK_LSPCI_OUTPUT="" + export MOCK_LSUSB_OUTPUT="" + export MOCK_IP_LINK_OUTPUT="" + export MOCK_IP_ADDR_OUTPUT="" + export MOCK_SSID_OUTPUT="" + + # systemd + export MOCK_TIMEZONE_OUTPUT="UTC" + export MOCK_NTP_SYNC_OUTPUT="no" + + # dialogs + export MOCK_WHIPTAIL_OUTPUT="" + export MOCK_WHIPTAIL_RC="0" + + # sudo(8) + export MOCK_SUDO_DRY_RUN="0" + export MOCK_SUDO_LOG="$(mktemp)" + + # /etc/apt(5) fixture mirror. The modules under test read + # ${APT_DIR:-/etc/apt} (APT_DIR indirection added for + # testability), so tests build fixture trees inside + # $MOCK_APT_ROOT instead of touching the real /etc/apt. + # Fresh per test: fixtures never leak between tests. + export MOCK_APT_ROOT="$(mktemp -d)" + export APT_DIR="${MOCK_APT_ROOT}" + + export PATH="${MOCK_BIN_DIR}:${PATH}" +} + +# Generate the mock executables once, when this helper is +# loaded. +_install_mocks diff --git a/tests/utils.bats b/tests/utils.bats new file mode 100644 index 0000000..7f5fec6 --- /dev/null +++ b/tests/utils.bats @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# utils.bats — Offline tests for modules/utils.sh. +# +# No `set -euo pipefail` here: BATS controls error handling +# per test — any failing command in a test body fails the +# test, and `run` already isolates the commands under test. +# +# `run` executes its command in a SUBSHELL, so global side +# effects would be lost. The state-mutating functions +# (_init_lspci_cache, detect_network) are therefore called +# DIRECTLY and the globals they set (LSPCI_OUTPUT, the +# ETH_* / WIFI_* arrays) are asserted afterwards. + +load test_helper.bash + +setup() { + mock_reset + load_utils +} + +teardown() { + if [ -n "${MOCK_SUDO_LOG:-}" ]; then + rm -f "$MOCK_SUDO_LOG" + fi + if [ -n "${MOCK_APT_ROOT:-}" ]; then + rm -rf "$MOCK_APT_ROOT" + fi +} + +# ── Shared fixtures ───────────────────────────────────── +# Hardware view for the detect_network tests: one Intel +# Ethernet controller, one Intel Wi-Fi 6 controller, and +# `ip -o link show` output with lo (UNKNOWN), eth0 (UP) +# and wlan0 (UP, with IPv4 address and SSID). +_setup_detect_network_fixtures() { + MOCK_LSPCI_OUTPUT="00:19.0 Ethernet controller [0200]: Intel Corporation Ethernet Connection (7) I219-LM [8086:1533] (rev 10) +01:00.0 Network controller [0280]: Intel Corporation Wi-Fi 6 AX200 [8086:2725] (rev 1a)" + MOCK_IP_LINK_OUTPUT="1: lo: mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000 +2: eth0: mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000 +3: wlan0: mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000" + MOCK_IP_ADDR_OUTPUT="eth0 192.168.1.10/24 +wlan0 10.0.0.5/24" + MOCK_SSID_OUTPUT="CasaWiFi" + + # Populate the global cache from the lspci mock + # (exercises the timeout-wrapped PATH mock chain). + LSPCI_OUTPUT="" + _init_lspci_cache +} + +# ── is_installed ──────────────────────────────────────── +@test "is_installed returns 0 for an installed package" { + MOCK_DPKG_INSTALLED="curl wget" + run is_installed curl + [ "$status" -eq 0 ] +} + +@test "is_installed returns 1 for a missing package" { + MOCK_DPKG_INSTALLED="curl wget" + run is_installed vim + [ "$status" -eq 1 ] +} + +# ── _get_pkg_version ──────────────────────────────────── +@test "_get_pkg_version parses the candidate from policy line 3" { + MOCK_APT_CACHE_POLICY_OUTPUT="curl: + Installed: 1.2.3-1 + Candidate: 1.2.3" + run _get_pkg_version curl + [ "$status" -eq 0 ] + [ "$output" = "1.2.3" ] +} + +# ── _get_backports_version ────────────────────────────── +@test "_get_backports_version returns the backports version" { + MOCK_APT_CACHE_MADISON_OUTPUT=" curl | 1.2.3-1 | https://deb.debian.org/debian bookworm/main amd64 Packages + curl | 1.2.3-1~bpo12+1 | https://deb.debian.org/debian bookworm-backports/main amd64 Packages" + run _get_backports_version curl bookworm + [ "$status" -eq 0 ] + [ "$output" = "1.2.3-1~bpo12+1" ] +} + +@test "_get_backports_version defaults the codename to DEBIAN_CODENAME" { + MOCK_APT_CACHE_MADISON_OUTPUT=" curl | 1.2.3-1~bpo12+1 | https://deb.debian.org/debian bookworm-backports/main amd64 Packages" + run _get_backports_version curl + [ "$output" = "1.2.3-1~bpo12+1" ] +} + +@test "_get_backports_version prints nothing without a backports entry" { + MOCK_APT_CACHE_MADISON_OUTPUT=" curl | 1.2.3-1 | https://deb.debian.org/debian bookworm/main amd64 Packages" + run _get_backports_version curl bookworm + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# ── _state ────────────────────────────────────────────── +@test "_state prints ON for an installed package" { + MOCK_DPKG_INSTALLED="curl" + run _state curl + [ "$output" = "ON" ] +} + +@test "_state prints OFF for a missing package" { + MOCK_DPKG_INSTALLED="" + run _state curl + [ "$output" = "OFF" ] +} + +# ── detect_network ────────────────────────────────────── +@test "detect_network fills ETH_NAMES and WIFI_NAMES from mocked hardware" { + _setup_detect_network_fixtures + detect_network + + # Ethernet (PCI) interface + [ "${#ETH_NAMES[@]}" -eq 1 ] + [ "${ETH_NAMES[0]}" = "eth0" ] + [ "${ETH_STATES[0]}" = "UP" ] + [ "${ETH_IPS[0]}" = "192.168.1.10/24" ] + [ "$ETH_DESC" = "Intel Corporation Ethernet Connection (7) I219-LM" ] + + # WiFi (PCI) interface + [ "${#WIFI_NAMES[@]}" -eq 1 ] + [ "${WIFI_NAMES[0]}" = "wlan0" ] + [ "${WIFI_STATES[0]}" = "UP" ] + [ "${WIFI_IPS[0]}" = "10.0.0.5/24" ] + [ "${WIFI_SSIDS[0]}" = "CasaWiFi" ] + [ "$WIFI_DESC" = "Intel Corporation Wi-Fi 6 AX200" ] +} + +@test "detect_network does not duplicate arrays when called twice (BH-013)" { + _setup_detect_network_fixtures + + detect_network + local eth_count=${#ETH_NAMES[@]} + local wifi_count=${#WIFI_NAMES[@]} + + detect_network + + # BH-013: the arrays must be reset and refilled, never appended to. + [ "${#ETH_NAMES[@]}" -eq "$eth_count" ] + [ "${#WIFI_NAMES[@]}" -eq "$wifi_count" ] + [ "${#ETH_NAMES[@]}" -eq 1 ] + [ "${#WIFI_NAMES[@]}" -eq 1 ] + [ "${ETH_NAMES[0]}" = "eth0" ] + [ "${WIFI_NAMES[0]}" = "wlan0" ] +}