diff --git a/lib/common.sh b/lib/common.sh index 5b22c1d..df27987 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -71,16 +71,91 @@ pause_if_debug() { #------------------------------------------ # 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 + + # Live-quiet mode needs only an interactive terminal (stdin tty + writable + # /dev/tty), NOT gum — the progress line is pure bash. Unattended runs and + # pipes keep plain full output (logs/cron contract). + if [[ "${ASSUME_YES:-false}" == "true" ]] || [ ! -t 0 ] || [ ! -w /dev/tty ]; then + "$@" + return $? + fi + + before=$(wc -l < "$log" 2>/dev/null || echo 0) + + # Child writes to the log; we draw a LIVE line: spinner frame + the package + # dnf is currently working on. gum spin was tried here first but rendered + # as a frozen block on the real VM ("looks like it hangs") — this variant + # animates independently of gum and shows real progress, not just motion. + "$@" >>"$log" 2>&1 & + local pid=$! + # ASCII frames ONLY: braille/em-dash are multibyte UTF-8 and rendered as + # garbage letters when the session locale is not UTF-8 (VM-proven: the + # spinner appeared as changing random chars, not as a spinner). + local frames=( '|' '/' '-' '\\' ) + local f=0 cur + while kill -0 "$pid" 2>/dev/null; do + cur=$(sed -n "$((before+1)),\$p" "$log" 2>/dev/null \ + | grep -E '^(Installing|Upgrading|Downgrading|Removing|Verifying|Preparing|Cleanup):' \ + | tail -n 1 || true) + if [[ -n "$cur" ]]; then + printf '\r %s %s: %s' "${frames[$f]}" "$msg" "$cur" >&2 + else + printf '\r %s %s' "${frames[$f]}" "$msg" >&2 + fi + f=$(( (f + 1) % ${#frames[@]} )) + sleep 0.15 + done + + local rc=0 + wait "$pid" || rc=$? + # wipe the progress line before printing the result + printf '\r%*s\r' 100 '' >&2 + + if (( rc == 0 )); 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 + 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 - sudo dnf install -y --setopt=install_weak_deps=True "$@" + 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() { - sudo dnf install -y --setopt=install_weak_deps=True "$@" \ + ui_quiet_run "Installing (optional): $*" \ + sudo dnf install -y --setopt=install_weak_deps=True "$@" \ || warn "Optional packages not installed: $*" } @@ -114,8 +189,8 @@ remove_packages() { done if [ ${#packages_to_remove[@]} -gt 0 ]; then - info "Removing packages: ${packages_to_remove[*]}" - sudo dnf remove -y "${packages_to_remove[@]}" + ui_quiet_run "Removing: ${packages_to_remove[*]}" \ + sudo dnf remove -y "${packages_to_remove[@]}" fi } @@ -129,7 +204,7 @@ install_group() { dnf group list --hidden 2>/dev/null | sed 's/^/ /' >&2 || true return 1 fi - sudo dnf group install -y "$group" + ui_quiet_run "Installing group: @$group" sudo dnf group install -y "$group" } # Try each candidate group/environment in turn; first one that exists wins. @@ -138,7 +213,7 @@ install_group_any() { for g in "$@"; do if dnf group info "$g" >/dev/null 2>&1; then info "Using dnf group: @$g" - sudo dnf group install -y "$g" + ui_quiet_run "Installing group: @$g" sudo dnf group install -y "$g" return 0 fi done diff --git a/lib/ui.sh b/lib/ui.sh index 57b7f50..d78765f 100644 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -16,16 +16,7 @@ if [[ -n "${_SB_UI_LOADED:-}" ]]; then fi _SB_UI_LOADED=1 -have_gum() { - command -v gum >/dev/null 2>&1 || return 1 - # stdin must be interactive — it is inherited by $(...) subshells, so - # menus still get the real terminal. stdout must NOT be checked: it is - # a pipe by definition inside $(ui_choose ...). gum draws its UI on - # /dev/tty and prints only the answer to stdout (which we capture). - [ -t 0 ] || return 1 - [ -w /dev/tty ] || return 1 - return 0 -} +# have_gum lives in lib/common.sh (install_packages needs it too). # Single choice from a list. # ui_choose "
" "" "" item1 item2 ... @@ -62,7 +53,8 @@ ui_choose() { local n=${#items[@]} answer while true; do if ! read -t 90 -rp "$prompt [1-$n] (default: shown above): " answer; then - printf "\n"; answer="" + printf "\n" >&2 + answer="" fi # empty/timeout -> the default item (its position in the list) if [[ -z "$answer" ]]; then @@ -74,6 +66,10 @@ ui_choose() { echo "${items[-1]}" return 0 fi + if [[ "${answer,,}" == "x" ]]; then + echo "$default" + return 0 + fi if [[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= n )); then echo "${items[$((answer-1))]}" return 0 diff --git a/setup.sh b/setup.sh index d5ea316..d35e86f 100755 --- a/setup.sh +++ b/setup.sh @@ -13,6 +13,10 @@ source "$SCRIPT_DIR/lib/session.sh" # shellcheck source=lib/ui.sh source "$SCRIPT_DIR/lib/ui.sh" +# Full dnf output from quiet transactions lands here (shown on failure). +export SETUP_DNF_LOG="${SETUP_DNF_LOG:-/tmp/fedora-setup-dnf-$(id -u).log}" +: >> "$SETUP_DNF_LOG" + #------------------------------------------ # Defaults + argument parsing # DEBUG_MODE must be initialised before anything reads it under set -u. @@ -99,6 +103,14 @@ if [[ ${EUID} -eq 0 ]]; then exit 1 fi +# Ask for the sudo password ONCE, up front, on a clean screen. Otherwise the +# first sudo call mid-run prints its prompt straight into the live progress +# line ("Installing (optional): gum[sudo] password for yuri:" — VM-proven). +# The timestamp refreshes on every subsequent sudo use, so one prompt covers +# the whole run. +info "Requesting sudo credentials (asked once for the whole run)..." +sudo -v + if [[ "$ASSUME_YES" == "true" && ( -z "$DE" || -z "$TWM" ) ]]; then err "--yes requires explicit --de and --twm, so nothing is guessed on an" err "unattended run. Example:" @@ -185,8 +197,8 @@ fi info "Ensuring Flathub is available (system-wide)..." sudo flatpak remote-add --if-not-exists --system flathub https://dl.flathub.org/repo/flathub.flatpakrepo -# Update the system (spinner when gum is present; output shown on failure) -ui_run "Updating the system" sudo dnf update -y +# Update the system (spinner + one-line summary; full output in $SETUP_DNF_LOG) +ui_quiet_run "Updating the system" sudo dnf update -y pause_if_debug #------------------------------------------ @@ -208,7 +220,7 @@ if [[ -z "$SELINUX_OPT" ]]; then [[ "$choice" == "yes" ]] && SELINUX_DISABLED=true printf "\n" if findmnt -n -o FSTYPE / 2>/dev/null | grep -q btrfs; then - ok "Root filesystem is btrfs — snapshots + zstd compression will be set up." + ok "Root filesystem is btrfs: snapshots + zstd compression will be set up." BTRFS_SNAPPER=true else info "Root filesystem is not btrfs; skipping snapshot setup." @@ -247,7 +259,7 @@ pause_if_debug if [[ -z "$DE" ]]; then step "4 Desktop environment" rule - color_gray " Bare Fedora install — picking a DE is what gives you a graphical login." + color_gray "Bare Fedora install: picking a DE is what gives you a graphical login." choice=$(ui_choose "Desktop environment" "Select desktop environment:" \ "None (TWM only, SDDM via TWM module)" \ "GNOME (gdm)" "Plasma (sddm)" "XFCE (sddm)" "None (TWM only, SDDM via TWM module)")