From 9dd1ecada83c0ba8bc349af5733d5972f1efbca9 Mon Sep 17 00:00:00 2001 From: Pathfinder Date: Tue, 27 Oct 2026 19:20:31 +0100 Subject: [PATCH 1/4] Quiet dnf transactions: spinner + one-line summary, log on failure ui_quiet_run wraps every install_packages/install_optional/group install/remove and the system update: while gum is available and the run is interactive, dnf output is hidden behind a spinner, then a single 'Installing: (N packages)' line. Full output is appended to $SETUP_DNF_LOG (/tmp/fedora-setup-dnf-.log); on failure the last 20 lines print in red so nothing is lost. Unattended and no-gum runs keep plain full output (logs/cron contract). Also: fallback ui_choose accepts 'x' to exit (muscle memory from the old menu), fixed a stray stdout newline leaking into captured answers (same class as the earlier '\nno' bug), fixed pluralization. Harness: piped fallback, pty gum run incl. a failing VirtualBox pick (red failure + log tail, menu survives, subsequent qemu install ok), unattended regression. --- lib/common.sh | 56 +++++++++++++++++++++++++++++++++++++++++++++------ lib/ui.sh | 18 +++++++---------- setup.sh | 8 ++++++-- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 5b22c1d..adb408d 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -71,16 +71,60 @@ 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 + + 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 - 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 +158,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 +173,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 +182,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..d04e092 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. @@ -185,8 +189,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 #------------------------------------------ From a54f14018e2c408c22dc32e7f443b0f3f4c37b59 Mon Sep 17 00:00:00 2001 From: Pathfinder Date: Tue, 27 Oct 2026 19:31:22 +0100 Subject: [PATCH 2/4] Live progress line instead of gum spin (VM: frozen block, 'looks hung') gum spin rendered static on the real VM during long transactions. ui_quiet_run now draws its own line: braille spinner frame + the package dnf is currently working on (from the transaction log), at 150ms ticks. Works without gum (pure bash), gated on interactive terminal, not on gum. set -e + pipefail bug found via pty trace: the first tick races the child's first log line, grep exits 1 (no match) and pipefail turns the whole substitution nonzero, killing the script silently at the first frame. || true on the substitution pipeline fixes it. --- lib/common.sh | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index adb408d..554fac8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -91,21 +91,49 @@ ui_quiet_run() { local log="${SETUP_DNF_LOG:-/tmp/fedora-setup-dnf.log}" local before n - if ! have_gum; then + # 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) - if LOGFILE="$log" gum spin --title "$msg" -- \ - bash -c 'exec "$@" >>"$LOGFILE" 2>&1' _ "$@"; then + + # 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=$! + 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 - 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 From 0050f3a4a8f8c8c9d1ece251083aa6b8a7369b37 Mon Sep 17 00:00:00 2001 From: Pathfinder Date: Tue, 27 Oct 2026 19:37:20 +0100 Subject: [PATCH 3/4] =?UTF-8?q?Prime=20sudo=20up=20front=20(sudo=20-v)=20?= =?UTF-8?q?=E2=80=94=20password=20prompt=20no=20longer=20lands=20mid-line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First sudo call of the session printed its password prompt straight into the live progress line: 'Installing (optional): gum[sudo] password for yuri:' (VM screenshot). sudo -v asks once, on a clean screen, right after the banner; the timestamp refreshes on every later sudo use, so one prompt covers the whole run. --- setup.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.sh b/setup.sh index d04e092..f55857c 100755 --- a/setup.sh +++ b/setup.sh @@ -103,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:" From 4cf680b854c1cfcddef8eb59bd83561661cbcbcc Mon Sep 17 00:00:00 2001 From: Pathfinder Date: Tue, 27 Oct 2026 19:42:30 +0100 Subject: [PATCH 4/4] ASCII-only spinner frames and output (VM locale rendered braille as garbage) The braille spinner frames and the em-dash separator are multibyte UTF-8; on the VM session they rendered as changing random letters, not a spinner. Frames are now the classic | / - \ cycle, the separator is ':', and the two user-facing info lines that carried em-dashes are ASCII too. Verified under LANG=C LC_ALL=C in a pty: zero non-ASCII bytes in the entire output. --- lib/common.sh | 7 +++++-- setup.sh | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 554fac8..df27987 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -107,14 +107,17 @@ ui_quiet_run() { # animates independently of gum and shows real progress, not just motion. "$@" >>"$log" 2>&1 & local pid=$! - local frames=( '⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏' ) + # 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 + printf '\r %s %s: %s' "${frames[$f]}" "$msg" "$cur" >&2 else printf '\r %s %s' "${frames[$f]}" "$msg" >&2 fi diff --git a/setup.sh b/setup.sh index f55857c..d35e86f 100755 --- a/setup.sh +++ b/setup.sh @@ -220,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." @@ -259,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)")