# shellcheck shell=bash #------------------------------------------ # lib/common.sh — shared helpers # # Sourced by setup.sh and by every module. Modules source it directly, so a # module can be run standalone for testing: # DE=none TWM=niri bash modules/twm/niri.sh #------------------------------------------ # Guard against double-sourcing (setup.sh and a module can both pull it in) if [[ -n "${_SB_COMMON_LOADED:-}" ]]; then return 0 fi _SB_COMMON_LOADED=1 #------------------------------------------ # Colors (printf-safe, no tput) #------------------------------------------ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' PURPLE='\033[0;35m' BLUE='\033[0;34m' GRAY='\033[0;37m' BOLD='\033[1m' RESET='\033[0m' # Disable colors if output is not a terminal # (PURPLE/BLUE/BOLD defined for future use; shellcheck has no way to know that) # shellcheck disable=SC2034 if [ ! -t 1 ]; then RED='' GREEN='' YELLOW='' CYAN='' PURPLE='' BLUE='' GRAY='' BOLD='' RESET='' fi color_yellow() { printf "%b%s%b\n" "$YELLOW" "$*" "$RESET"; } color_cyan() { printf "%b%s%b\n" "$CYAN" "$*" "$RESET"; } color_red() { printf "%b%s%b\n" "$RED" "$*" "$RESET"; } color_green() { printf "%b%s%b\n" "$GREEN" "$*" "$RESET"; } color_gray() { printf "%b%s%b\n" "$GRAY" "$*" "$RESET"; } #------------------------------------------ # Log levels #------------------------------------------ info() { color_cyan " $*"; } ok() { color_green " $*"; } warn() { color_yellow " $*"; } err() { color_red " $*" >&2; } step() { printf "\n"; color_yellow "$*"; } rule() { printf "%s\n" "----------------------------------------------------------------"; } #------------------------------------------ # Debug pausing #------------------------------------------ is_debug() { [[ "${DEBUG_MODE:-false}" == "true" ]]; } pause_if_debug() { if is_debug; then printf "\n" color_yellow "----------------------------------------------------------------" read -rp "Section complete. Press [Enter] to continue, or [q] to abort: " debug_choice if [[ "${debug_choice,,}" == "q" ]]; then color_red "Script aborted by user." exit 1 fi color_yellow "----------------------------------------------------------------" printf "\n" fi } #------------------------------------------ # Package helpers (dnf5) #------------------------------------------ # True when gum is installed AND we are interactive (stdin tty + /dev/tty # writable). stdout must NOT be checked: it is a pipe inside $(...). # Unattended runs never get the TUI (see ui_quiet_run). have_gum() { command -v gum >/dev/null 2>&1 || return 1 [[ "${ASSUME_YES:-false}" == "true" ]] && return 1 [ -t 0 ] || return 1 [ -w /dev/tty ] || return 1 return 0 } # Quiet dnf transaction: spinner while it runs, one ok-line after, full output # appended to $SETUP_DNF_LOG and printed (tail) only on failure. Unattended or # no-gum runs execute plainly with normal output (logs matter there). ui_quiet_run() { local msg="$1" shift local log="${SETUP_DNF_LOG:-/tmp/fedora-setup-dnf.log}" local before n if ! have_gum; then "$@" return $? fi before=$(wc -l < "$log" 2>/dev/null || echo 0) if LOGFILE="$log" gum spin --title "$msg" -- \ bash -c 'exec "$@" >>"$LOGFILE" 2>&1' _ "$@"; then n=$(sed -n "$((before+1)),\$p" "$log" 2>/dev/null | grep -c '^Installing:' || true) local plural="s" (( n == 1 )) && plural="" ok "$msg (${n} package${plural})" return 0 else local rc=$? color_red " Failed: $msg" color_red " Last lines from $log:" sed -n "$((before+1)),\$p" "$log" 2>/dev/null | tail -n 20 | sed 's/^/ /' >&2 return "$rc" fi } install_packages() { # -y : no prompts # weak deps on : keep default behaviour, stated explicitly so it is greppable ui_quiet_run "Installing: $*" \ sudo dnf install -y --setopt=install_weak_deps=True "$@" } # Same, but a missing/renamed package warns instead of aborting the whole run. # Use for anything that is a nice-to-have rather than load-bearing. install_optional() { ui_quiet_run "Installing (optional): $*" \ sudo dnf install -y --setopt=install_weak_deps=True "$@" \ || warn "Optional packages not installed: $*" } pkg_installed() { local p for p in "$@"; do rpm -q "$p" &>/dev/null || return 1 done return 0 } # True if ANY of the listed packages is installed (pkg_installed is AND). pkg_installed_any() { local p for p in "$@"; do if rpm -q "$p" &>/dev/null; then return 0 fi done return 1 } remove_packages() { local packages_to_remove=() for pkg in "$@"; do if pkg_installed "$pkg"; then packages_to_remove+=("$pkg") else color_gray " Package '$pkg' not installed. Skipping removal." fi done if [ ${#packages_to_remove[@]} -gt 0 ]; then ui_quiet_run "Removing: ${packages_to_remove[*]}" \ sudo dnf remove -y "${packages_to_remove[@]}" fi } # dnf comps group. Fail loudly with the real group list rather than letting # dnf's generic "no match" scroll past. install_group() { local group="$1" if ! dnf group info "$group" >/dev/null 2>&1; then err "No such dnf group: @$group" err "Available groups on this system:" dnf group list --hidden 2>/dev/null | sed 's/^/ /' >&2 || true return 1 fi ui_quiet_run "Installing group: @$group" sudo dnf group install -y "$group" } # Try each candidate group/environment in turn; first one that exists wins. install_group_any() { local g for g in "$@"; do if dnf group info "$g" >/dev/null 2>&1; then info "Using dnf group: @$g" ui_quiet_run "Installing group: @$g" sudo dnf group install -y "$g" return 0 fi done err "None of these dnf groups exist: $*" dnf group list --hidden 2>/dev/null | sed 's/^/ /' >&2 || true return 1 } #------------------------------------------ # Third-party repos #------------------------------------------ add_terra_repo() { if pkg_installed terra-release; then ok "Terra repository already installed." return 0 fi info "Adding Terra repository (Fyra Labs)..." # Terra's own instructions use --nogpgcheck for the bootstrap package; # once terra-release is in place the repo itself is signature-checked. sudo dnf install -y --nogpgcheck \ --repofrompath 'terra,https://repos.fyralabs.com/terra$releasever' \ terra-release } add_rpmfusion_repo() { local rel rel=$(rpm -E %fedora) if pkg_installed rpmfusion-free-release rpmfusion-nonfree-release; then ok "RPM Fusion already installed." return 0 fi info "Enabling RPM Fusion (free + nonfree) for Fedora ${rel}..." sudo dnf install -y \ "https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${rel}.noarch.rpm" \ "https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-${rel}.noarch.rpm" } #------------------------------------------ # Fonts #------------------------------------------ # Nerd Fonts are not in the Fedora repos, so pull the release zip from GitHub # into the user's font dir. Idempotent: skipped if the family is already there. install_nerd_font() { local family="${1:-JetBrainsMono}" local dest="${HOME}/.local/share/fonts/NerdFonts-${family}" local url="https://github.com/ryanoasis/nerd-fonts/releases/latest/download/${family}.zip" if [[ -d "$dest" ]] && compgen -G "$dest/*.ttf" >/dev/null; then ok "Nerd Font ${family} already installed." return 0 fi info "Installing Nerd Font ${family}..." local tmp tmp=$(mktemp -d) if ! curl -fsSL -o "$tmp/${family}.zip" "$url"; then warn "Could not download ${family} Nerd Font. Skipping." rm -rf "$tmp" return 0 fi if ! command -v unzip >/dev/null 2>&1; then install_packages unzip fi mkdir -p "$dest" unzip -qo "$tmp/${family}.zip" -d "$dest" rm -rf "$tmp" fc-cache -f "$dest" >/dev/null 2>&1 || true ok "Nerd Font ${family} installed to $dest" } #------------------------------------------ # System: SELinux, Btrfs, snapper #------------------------------------------ # Permanently disable SELinux. Destructive-ish: warn loudly, and remind the # user a reboot is needed for the change to take effect. disable_selinux() { # FSTAB/SELINUX file overridable for testing on non-Fedora hosts local cfg="${SELINUX_CONFIG:-/etc/selinux/config}" if [[ -f "$cfg" ]] && grep -q '^SELINUX=disabled' "$cfg"; then ok "SELinux already disabled in $cfg." return 0 fi warn "Disabling SELinux permanently. This removes a significant layer of" warn "system protection. Only do this if you specifically need it." if [[ -f "$cfg" ]]; then sudo cp "$cfg" "${cfg}.bkp.$(date +%F)" sudo sed -i 's/^SELINUX=.*/SELINUX=disabled/' "$cfg" else printf 'SELINUX=disabled\nSELINUXTYPE=targeted\n' | sudo tee "$cfg" >/dev/null fi ok "SELINUX=disabled written to $cfg (backup saved). Reboot to apply." } # Show the btrfs layout, then make sure every btrfs fstab entry compresses # with zstd. The sed variant from the SysGuides guide appends ,compress=zstd:1 # after the subvol= option; we guard it so re-runs cannot stack duplicates. ensure_btrfs_compression() { local fstab="${FSTAB_FILE:-/etc/fstab}" info "Current btrfs subvolumes on /:" sudo btrfs subvolume list / 2>/dev/null | sed 's/^/ /' || warn "btrfs subvolume list failed (not btrfs?)" info "fstab btrfs entries:" grep ' btrfs ' "$fstab" | sed 's/^/ /' || true local fixed=0 line # Only touch btrfs lines that have a subvol= option and no compress= yet. while IFS= read -r line; do if grep -q ' btrfs ' <<<"$line" && grep -q 'subvol=' <<<"$line" && ! grep -q 'compress=' <<<"$line"; then fixed=$((fixed+1)) fi done < "$fstab" if (( fixed == 0 )); then ok "All btrfs fstab entries already carry a compress= option (or none exist)." return 0 fi info "Adding compress=zstd:1 to ${fixed} btrfs fstab entrie(s)..." # Backup once per run, then apply the SysGuides sed to every qualifying line. sudo cp "$fstab" "$fstab.bkp.$(date +%F-%H%M)" sudo sed -i '/ btrfs / s/subvol=[^ ,]*/&,compress=zstd:1/' "$fstab" ok "fstab updated. Remount or reboot for compression to take effect:" info " sudo mount -o remount / && sudo mount -o remount /home" } # Snapshot setup using the vendored SysGuides scripts (vendor/snapper/). # Original: https://github.com/SysGuides/sysguides-snapper-fedora (MIT). # Vendored so we do not depend on that repo at runtime; upstream changes can # be pulled deliberately, not accidentally. # Exec bits are not relied on: scripts are invoked via `bash`. setup_snapper() { local vendor="$SCRIPT_DIR/vendor/snapper" if pkg_installed snapper && [[ -d /.snapshots ]]; then ok "Snapper already configured (config for / exists)." return 0 fi if ! findmnt -n -o FSTYPE / 2>/dev/null | grep -q btrfs; then warn "Root filesystem is not btrfs; skipping snapshot setup." return 0 fi if [[ ! -f "$vendor/scripts/snapper-pre.sh" ]]; then err "Vendored snapper scripts missing at $vendor — repo checkout incomplete?" return 1 fi info "Installing snapper + grub-btrfs packages..." install_packages snapper libdnf5-plugin-actions btrfs-assistant inotify-tools make git info "Creating snapper configs (root + home)..." [[ -d /.snapshots ]] || sudo snapper -c root create-config / [[ -d /home/.snapshots ]] || sudo snapper -c home create-config /home sudo restorecon -RF /.snapshots /home/.snapshots 2>/dev/null || true local real_user="${SUDO_USER:-$USER}" sudo snapper -c root set-config ALLOW_USERS="$real_user" SYNC_ACL=yes sudo snapper -c home set-config ALLOW_USERS="$real_user" SYNC_ACL=yes # no home timeline: snapshots of your own files on every dnf run are noise sudo snapper -c home set-config TIMELINE_CREATE=no info "Excluding .snapshots from updatedb..." if grep -q '^PRUNENAMES' /etc/updatedb.conf 2>/dev/null; then grep -q '\.snapshots' /etc/updatedb.conf || \ sudo sed -i 's|^PRUNENAMES *= *"|PRUNENAMES = ".snapshots |' /etc/updatedb.conf else printf 'PRUNENAMES = ".snapshots"\n' | sudo tee -a /etc/updatedb.conf >/dev/null fi info "Installing grub-btrfs (from source, Antynea/grub-btrfs)..." local tmp tmp=$(mktemp -d) git clone --quiet --depth 1 https://github.com/Antynea/grub-btrfs "$tmp/grub-btrfs" sed -i \ -e 's|^#GRUB_BTRFS_SNAPSHOT_KERNEL_PARAMETERS=.*|GRUB_BTRFS_SNAPSHOT_KERNEL_PARAMETERS="rd.live.overlay.overlayfs=1"|' \ -e 's|^#GRUB_BTRFS_GRUB_DIRNAME=.*|GRUB_BTRFS_GRUB_DIRNAME="/boot/grub2"|' \ -e 's|^#GRUB_BTRFS_MKCONFIG=.*|GRUB_BTRFS_MKCONFIG=/usr/bin/grub2-mkconfig|' \ -e 's|^#GRUB_BTRFS_SCRIPT_CHECK=.*|GRUB_BTRFS_SCRIPT_CHECK=grub2-script-check|' \ "$tmp/grub-btrfs/config" sudo make -C "$tmp/grub-btrfs" install sudo systemctl enable --now grub-btrfsd.service sudo grub2-mkconfig -o /boot/grub2/grub.cfg rm -rf "$tmp" info "Installing DNF5 snapper integration (vendored scripts)..." sudo install -m 755 "$vendor"/scripts/* /usr/local/bin/ sudo restorecon -v /usr/local/bin/snapper-*.sh 2>/dev/null || true sudo mkdir -p /etc/dnf/libdnf5-plugins/actions.d/ sudo install -m 644 "$vendor/config/snapper.actions" /etc/dnf/libdnf5-plugins/actions.d/ info "Enabling snapper timers..." sudo systemctl enable --now snapper-timeline.timer snapper-cleanup.timer ok "Snapper + grub-btrfs installed. Every dnf transaction now gets a pre/post snapshot." info "Try it: snapper ls | snapper status 1..2 | sudo snapper undochange 1..2" }