Compare commits

..
68 Commits
Author SHA1 Message Date
yuri 482bd797d0 added papirus-icon-theme 2026-11-13 17:31:58 +01:00
yuri f08abda392 Merge pull request 'Always-packages: install the multimedia dnf group' (#17) from feat/always-multimedia-group into main 2026-11-13 16:27:26 +01:00
Pathfinder 6733cde613 Always-packages: install the multimedia dnf group
Adds '@multimedia' as an ALWAYS_PACKAGES entry and teaches the always-loop
to route '@group' lines through install_group, so a group is checked for
existence first and a bad name prints the system's real group list instead
of dnf's 'No match for argument: @multimedia'.

Group install failures warn in red and continue, like every other
always-package line.
2026-11-13 16:26:04 +01:00
yuri 64fff4f200 added packages for building Hermes Desktop 2026-11-13 16:19:07 +01:00
yuri 17d09d985e added extra mandatory packages during install 2026-11-13 16:12:46 +01:00
yuri fec520be9d Merge pull request 'NVIDIA driver module: detect the GPU, install the right branch' (#16) from feat/nvidia-driver-module into main 2026-11-13 12:39:29 +01:00
Pathfinder 065a8cd0ae Add GPU-detected NVIDIA driver module
modules/user/nvidia.sh detects the card via lspci and installs the branch
that matches it: current (Turing+), 580xx (Volta/Pascal/Maxwell), 470xx
(Kepler), 390xx (Fermi); pre-Fermi is refused with an explanation.

- no prompt for a detected GPU (--nvidia=no is the opt-out, wired in setup.sh)
- Secure Boot gate: with enforcement on, the akmods key is generated and
  imported and the module stops BEFORE installing the driver, so nobody gets
  the documented unsigned-module black screen; off/setup-mode skips signing
  entirely (a MOK import is never processed when enforcement is off)
- bounded wait for the kmod build, so a reboot cannot land mid-build
- dnf mark user on the akmod; VA-API/vulkan offered as an opt-in extra
- never sets nvidia-drm.modeset=1 (RPM Fusion enables KMS; the flag conflicts
  with Fedora's simpledrm patch)
- branch chosen from the chip family in the PCI name, not the device-id
  ranges, which overlap between families

Tested with a stub harness (38 assertions, 14 scenarios): all four branch
mappings, multi-GPU warning, Optimus, pre-Fermi refusal, SB on/off/setup,
unparseable name fallback, idempotent re-run, build timeout.
2026-11-13 12:18:23 +01:00
yuri e6698cdb44 Merge pull request 'PeaZip: Qt6 default (GTK3 build is upstream alpha)' (#15) from fix/peazip-qt6-default into main 2026-11-11 16:27:09 +01:00
Pathfinder 88bd9d6a20 PeaZip: Qt6 everywhere except GNOME/XFCE (GTK3 build is upstream alpha)
YuKu tested gtk3 on niri: broken UI until a manual swap to qt6 fixed
it. Upstream's own changelog: the GTK3 package is 'for test purpose
only' (Lazarus GTK3 widgetset is alpha) and Qt6 supersedes Qt5.

New mapping: gtk3 ONLY for genuine GTK stacks (gnome, xfce); qt6 for
everything else - plasma, hyprland, niri, mangowc, chadwm (Qt6 handles
Wayland and X11 cleanly).

Harness: full DE/TWM matrix passes (7/7); existing qt5 on niri now
swaps to qt6 (not gtk3); a machine already on qt6 (YuKu's laptop
after the manual swap) is a clean no-op.
2026-11-11 16:14:53 +01:00
yuri d1031277bf Merge pull request 'Repo guards (repolist) + PeaZip flavor guard' (#14) from fix/repo-and-peazip-guards into main 2026-11-11 15:48:11 +01:00
Pathfinder ee10829452 Cosmetic: ANSI line wipe; hide (0 packages) on idempotent re-runs
Two nits from YuKu's laptop paste: the 100-space line wipe left a
visible gap before short summaries (now ANSI EL: \r\033[K, no
residue), and '(0 packages)' printed when nothing new was installed
(idempotent re-run / theme deps all present) — the count parenthetical
is now omitted when zero.
2026-11-11 15:46:45 +01:00
Pathfinder 1586e10df1 SilentSDDM install: quiet like everything else
The theme's own install.sh runs dnf for its dependencies and its
output flooded the screen raw (laptop-proven: the last wall of text
left in the run). Now wrapped in ui_quiet_run: live block line with
the current dependency, summary count after, full output in the
setup log, red + tail on failure. Unattended/piped runs keep plain
output. pipefail still suspended around the yes| pipe (inherited at
subshell fork).
2026-11-11 15:42:49 +01:00
Pathfinder 4e0dad3483 PeaZip: swap to the wanted flavor when an incompatible one is detected
Per YuKu: instead of skipping when an older flavor is present, SWAP it
to the environment-appropriate one (dnf swap -y old new). Atomic —
if the swap fails, the existing flavor stays and the run continues
with a warning (the safe outcome). Right flavor already present ->
no-op; fresh machine -> plain install.

Verified: qt5+niri -> swap to gtk3 (your laptop scenario); qt6+hyprland
-> no-op; forced swap failure -> warn + keep qt5; fresh -> gtk3.
2026-11-11 15:38:57 +01:00
Pathfinder b79735d82e Repo guards check repolist, not just RPMs; PeaZip skips when any flavor exists
Laptop run (YuKu, existing machine) exposed two fresh-install blind
spots:

1. Terra add failed on a machine that already HAD the repo but not a
   queryable terra-release RPM — bootstrapping --repofrompath over an
   existing repo id collides. New repo_enabled() checks dnf repolist
   (the source of truth); add_terra_repo and add_rpmfusion_repo now
   skip when the repo is configured OR the release RPM present.

2. PeaZip tailoring tried gtk3 over an existing peazip-qt5: all UI
   flavors ship /usr/bin/peazip and file-conflict, transaction died.
   Any installed flavor (qt5/qt6/gtk3/gtk2) now wins and the section
   skips with the flavor named.

Harness: laptop sim (repo configured, peazip-qt5 present) -> zero
bootstrap calls, PeaZip skipped; fresh machine -> terra bootstraps +
gtk3 installs; re-run -> skip via repolist; peazip-qt6 + hyprland ->
skip; unattended/piped green.
2026-11-11 15:35:15 +01:00
yuri 8fb78651c9 Merge pull request 'Post-install tailoring (PeaZip variants, starship+preset, RobotoMono, services)' (#13) from feat/post-install-tailoring into main 2026-11-02 17:50:02 +01:00
Pathfinder c9a127a07e Advanced-menu installs go quiet: fix stdin theft + silent wiring
YuKu on the VM: menu installs were not quiet — the ONE place dnf's
full output still scrolled.

Root cause: the per-item install loop read picks with
'while read ... <<< "$sel"' — a herestring REPLACES stdin for its
whole body, so ui_quiet_run's tty gate saw 'piped' inside the loop
and every menu install ran in plain mode. Picks are now read into an
array first (herestring fills ONLY that); installs run in a for-loop
that never touches stdin.

Also silenced the remaining raw output in the menu path:
- Chrome repo wiring -> install_optional (quiet) + setopt silenced,
  direct-rpm fallback via ui_quiet_run
- Brave addrepo -> ui_quiet_run
- flatpak installs -> ui_quiet_run; ui_quiet_run now also matches
  flatpak's 'Installing...' progress (Unicode ellipsis) for the live
  line and count, sanitized to printable ASCII (tr -cd) so a
  non-UTF8 console never sees garbage bytes.

Pty-verified: live block lines + green summaries for chrome/gimp/
vscodium picks, flatpak progress only in the log, zero raw dnf or
flatpak text on screen.
2026-11-02 17:40:42 +01:00
Pathfinder 86358466e1 SilentSDDM theme, workstation-repos, Editors/Graphics/DevTools menus, flatpak support
Ported from the old fedora-full script:

- SilentSDDM SDDM theme in the tailoring section, gated on sddm
  being installed (skipped on gdm setups), idempotent via the
  ~/git/SilentSDDM checkout (pull --rebase when present), pipefail
  suspended around 'yes | ./install.sh' for the SIGPIPE trap.
- fedora-workstation-repositories added in base alongside the other
  repos (it ships the google-chrome repo the Browsers option enables).
- Advanced menu: Editors (VSCodium flatpak), Graphics and Multimedia
  (gimp/inkscape/obs/lollypop via dnf, spotify via flatpak), Development
  Tools (single option: @development-tools wget cmake ninja-build
  pkgconf-pkg-config). All names verified on F44.
- install_category_pkgs splits flatpak:<id> entries to Flathub so mixed
  picks cannot fail each other's transactions.

Harness: sddm->theme installs, gnome->skipped, vscodium+spotify via
flatpak, lollypop via dnf in the same multi-pick, devtools as one
transaction, all five categories listed, unattended+piped green.
2026-11-02 17:25:16 +01:00
Pathfinder 35c1939f28 Tailoring: wire starship into .bashrc only when dotfiles are absent
Per YuKu: after the starship install, append
  export STARSHIP_CONFIG=$HOME/.config/starship.toml
  eval "$(starship init bash)"
to ~/.bashrc — but ONLY when fedora-dots was NOT cloned (on dots
machines .bashrc belongs to the dots). Also gated on starship actually
being present (no dead eval in every shell) and idempotent (grep
guard). Harness: appended once with dots=no, never with dots=yes
(clean-home re-test; the first test was invalid because rm -rf home/*
left the previous .bashrc — the known dotfile-cleanup pitfall), and
not duplicated on re-run.
2026-11-02 16:58:34 +01:00
Pathfinder a8796467df Post-install tailoring section (ported from starburst minimal-fedora)
Section 8, after the advanced menu (summary renumbered to 9):

- PeaZip variant by environment: peazip-common + peazip-qt6 for
  plasma/hyprland, peazip-common + peazip-gtk3 otherwise (all verified
  on F44; peazip-qt6-10.9.0-1.fc44). Resilient: warns red, continues.
- Starship prompt via the official installer (command -v guarded), then
  the tokyo-night preset ONLY when no starship.toml exists yet —
  dotfiles checkouts always win over the preset.
- RobotoMono Nerd Font via the existing install_nerd_font helper
  (modules keep installing JetBrainsMono; both are small).
- Services: fstrim.timer, chronyd, NetworkManager globally-managed
  config (write + restart + 15s settle ONLY when the conf is absent —
  no needless NM restarts on re-runs, and no SSH self-drop when the
  config already exists), pipewire user units best-effort.

Polkit agent deliberately NOT ported: solved properly by the
polkit-kde user unit (VM-proven), the old polkit-kde-agent-1 line is
superseded.
2026-11-02 16:45:21 +01:00
yuri 3590c1b477 Merge pull request 'Always-packages: p7zip->7zip (retired in F44) + resilient loop' (#12) from fix/always-packages-p7zip-resilience into main 2026-11-02 16:31:09 +01:00
Pathfinder d4f04ac486 Always-packages: drop X11-only tools (numlockx, arandr)
Both are inert on the niri/Wayland target (numlockx needs X11; arandr
drives RandR). numlock at the greeter is SDDM's own setting.
2026-11-02 16:21:59 +01:00
Pathfinder a5e4facf42 Live progress line: clamp to terminal width
YuKu's promised screenshot revealed the real section-6 chaos: the live
line 'block + msg + current package' is longer than 80 columns, so
every redraw WRAPPED to a new line instead of overwriting — the screen
filled with duplicate partial progress lines.

The line is now composed once and clamped to $(tput cols) - 2 with an
ASCII '..' tail. 80-col pty run: zero wrapped live lines (the only
>82-char output left is plain-mode dnf, unchanged by design).
2026-11-02 16:19:52 +01:00
Pathfinder 835b6b0040 Always-packages: p7zip -> 7zip (retired in F44) + resilient loop
YuKu's expanded always-section died at section 6: p7zip was RETIRED
from Fedora 44 (replaced by the official '7zip' package) -> the
compression line's transaction failed -> set -e killed the whole
install after everything before it had already succeeded.

Two changes:
- p7zip -> 7zip in the Compression line (unace/unrar verified fine:
  they live in RPM Fusion, which base enables).
- The always-loop now warns in red per failing category line and
  continues — same resilience contract as the advanced menu. One
  missing package can no longer abort a 30-minute install at the
  finish line.

Harness: healthy run installs everything incl. 7zip/mpv/wget; a
deliberately broken compression line produces the red Failed block,
'finished with failures' summary, exit 0, and later categories
(mpv, wget...) still install.
2026-11-02 16:16:37 +01:00
yuri cb60610942 Update setup.sh
added more packages to 6 Always-installed packages
2026-11-02 13:54:48 +01:00
yuri ea50f64ef2 Merge pull request 'Brave via dnf5 addrepo + per-item installs in advanced menu' (#11) from fix/brave-addrepo-per-item into main
Reviewed-on: #11
2026-11-02 13:05:07 +01:00
Pathfinder 82b35e4bdb Browsers: dnf5 addrepo for Brave; per-item installs in the menu
Two bugs read straight out of YuKu's VM screenshot:

1. 'Unknown argument "add" for command "config-manager"' — dnf5 has
   no 'add' subcommand, so the Brave repo never landed and
   brave-browser had no match. Now uses 'config-manager addrepo
   --from-repofile=' per Brave's own Fedora 41+ docs; skips when
   /etc/yum.repos.d/brave-browser.repo exists. The .repo carries the
   gpgkey, so the rpm --import dance is gone.

2. 'Installation failed: chromium brave-browser' — multi-select put
   all picks in ONE dnf transaction, so brave's no-match killed
   chromium too. Picks now install PER ITEM: one failure red-warns
   with the item's own name and the remaining picks still install.
2026-10-27 22:58:01 +01:00
yuri 85ba9ddb51 Merge pull request 'Breathing-block status indicator (blue/green/red)' (#10) from feat/breathing-block into main 2026-10-27 22:50:24 +01:00
Pathfinder 0630acf020 Console pulse: one-third speed (150ms frames), 256-ramp untouched
YuKu: 'slow the flicker a bit (half or one-third speed)'. The 16-colour
blue<->grey pulse now ticks at 150ms (600ms full cycle, calm); the
256-colour terminal breathe keeps its 50ms deep ramp. Per-depth tick
variable; same in the vendored helper.

Rate proven by pty trace: 8 frames in a 1.2s transaction = 150ms
exactly. (Frame-counting via grep -c undercounts — \r overwrites
collapse frames onto one line; count matches, not lines.)
2026-10-27 22:48:50 +01:00
Pathfinder 4a53155c7e TTY pulse: constant layout, blue <-> dark grey (no more block-off)
The on/off pulse shifted the whole line by one character during the
dark frames (off-frame printed one space fewer than the block frame).
Per YuKu: keep the block ALWAYS present and alternate its colour
instead — blue (44) <-> dark grey (100, bright black). Layout is
constant, pulse is colour-based, nothing moves but the block itself.

Also: disable_selinux now mkdir -p's /etc/selinux before writing a
fresh config (harness caught a die on a missing parent dir).
2026-10-27 22:45:43 +01:00
Pathfinder 71780291a3 TTY: on/off block pulse on the Linux console (shade fusion defeated)
YuKu tests on the bare Linux VT (not a terminal emulator): my 16-colour
fallback alternated blue (44) / bright blue (104), which render nearly
identically on VGA consoles -> fused into 'solid blue'. If TERM
misreports 256, 48;5;N codes are ignored outright.

Fixes:
- TERM linux*/console* forces the 16-colour path regardless of tput.
- 16-colour mode now PULSES THE BLOCK ON/OFF (2 frames blue block,
  2 frames no block, ~2.5Hz) instead of shade alternation — visible
  on any console hardware, no colour fidelity required.
- 256-colour terminals keep the deep 17->45 breathe ramp.

Verified: TERM=linux pty shows block/no-block alternation with zero
48;5 codes; TERM=xterm-256color keeps the 8-shade ramp; unattended +
piped regressions green.
2026-10-27 22:41:48 +01:00
Pathfinder 3d21a1c427 Breathing: deep amplitude so the pulse survives flicker fusion
At 20fps the previous narrow ramp (shades 17-33) flicker-fused into an
apparent SOLID block on the VM. Amplitude now spans dark navy (17) to
bright blue (45) in 12 steps — every frame is visibly different, cycle
is 0.6s. Speed unchanged.
2026-10-27 22:38:02 +01:00
Pathfinder 35906d4df0 Breathing pulse: 3x faster (0.15s -> 0.05s per frame)
YuKu: 'the breathing is super slow'. Frame interval down from 150ms
to 50ms (~7fps -> ~20fps pulse). Same change in the vendored
lib/ui-block.sh helper for consistency.
2026-10-27 22:35:35 +01:00
Pathfinder 9c0b716758 Status blocks replace the spinner: blue breathing / green / red
ui_quiet_run now paints a TTY-safe status BLOCK: a background-coloured
SPACE (never a glyph, no font/locale dependency — the braille lesson).

  busy : blue block breathing through a 256-colour ramp (48;5;17..33)
         with the live dnf action after it
  ok   : solid green block + package count
  fail : solid RED block (spec: YuKu changed the red X to a red block)
         followed by the log tail

Colour-depth aware: 256-colour ramp -> 16-colour blue/bright-blue ->
plain ASCII spinner when the terminal reports no colour. LANG=C pty
run shows zero non-ASCII bytes.

Also vendors lib/ui-block.sh (the standalone helper from the Telegram
session prototype; fail changed to red block) and installs the
ui_block_close EXIT trap — with a return 0, because an EXIT trap's
status becomes the script's exit code (harness caught every-successful-
run exiting 1 through that path).
2026-10-27 22:32:58 +01:00
yuri 49ea36e089 Merge pull request 'Install cfg alias on every machine (~/.bash_aliases)' (#9) from feat/cfg-alias-install into main 2026-10-27 20:20:08 +01:00
Pathfinder 8595e3d721 dotfiles module: install the cfg alias via ~/.bash_aliases (VM: command not found)
The alias only existed where it was typed by hand; a fresh VM with
dots cloned had no cfg command. The module now always ensures:
- ~/.bash_aliases holds the cfg alias (created if absent)
- ~/.bashrc sources ~/.bash_aliases (appended if absent)
Runs before the already-installed skip so existing machines get it
too. Idempotent both ways (harness-proven: single alias line, single
source line after two runs; verified working in an interactive shell:
'type cfg' resolves and 'cfg status' runs).

~/.bash_aliases is the right file to TRACK in fedora-dots next: it
will then sync further aliases across machines.
2026-10-27 20:13:29 +01:00
yuri 1869743d4a Merge pull request 'Quiet dnf + live ASCII progress (recovered from orphaned PR #7)' (#8) from feat/quiet-dnf-recovered into main 2026-10-27 19:49:51 +01:00
yuri 3e23ce1210 Merge pull request 'gum TUI with plain-text fallback' (#6) from feat/gum-tui into main 2026-10-27 19:49:14 +01:00
Pathfinder 4cf680b854 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.
2026-10-27 19:42:30 +01:00
Pathfinder 0050f3a4a8 Prime sudo up front (sudo -v) — password prompt no longer lands mid-line
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.
2026-10-27 19:37:20 +01:00
Pathfinder a54f14018e 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.
2026-10-27 19:31:22 +01:00
Pathfinder 9dd1ecada8 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: <pkgs> (N packages)' line. Full output is appended
to $SETUP_DNF_LOG (/tmp/fedora-setup-dnf-<uid>.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.
2026-10-27 19:20:31 +01:00
Pathfinder c6e41cbf1e gum TUI with plain-text fallback (lib/ui.sh)
Interactive install gets styled menus, confirms, multi-select and a
spinner around the system update when gum is available (dnf install
gum, installed optionally in the base section); every prompt falls
back to the classic numbered/yn prompts otherwise, and --yes runs
stay 100% non-interactive.

Call sites moved to ui_choose/ui_confirm/ui_choose_multi/ui_run:
DE + TWM menus (defaults preserved: none/none), SELinux + dotfiles
questions, advanced-menu category + multi-select app picker, reboot
prompt. Dead ask_choice/ask_yes_no removed.

Two bugs caught by pty-based harness testing before shipping:
- stdout is a pipe inside $(ui_...), so have_gum must test stdin +
  /dev/tty, never -t 1 (gum would never have fired on a real term).
- fallback display output must go to >&2 — a stray newline on stdout
  made choice='\nno' and broke every case match.
2026-10-27 19:09:58 +01:00
yuri 2b58c4f788 Merge pull request 'libvirt group: gate on daemon/virt-manager, not the metapkg' (#5) from fix/libvirt-group-gate into main 2026-10-27 15:58:59 +01:00
Pathfinder f069026a7c libvirt group: check at summary time (final state) + OR-semantics gate
Fresh-VM timeline explained: on run 1 virt-manager installs AFTER the
niri module's prereq check ran (menu dispatch order), so the group add
was evaluated against a package state that did not include it yet. A
second run 'fixed' it — matching the observed behavior.

- New ensure_libvirt_group runs in the Summary section, seeing the FINAL
  package state; menu/module-time check kept as an early pass.
- pkg_installed is AND-semantics (all must be installed); the gate needs
  OR. New pkg_installed_any helper; both gates switched to it.
- Also fixed a set -e trap: '$done_any && ok' returned 1 (killing the
  script) when no optional tools were picked — replaced with an if.
All four gate cases harness-proven: virt-only, menu-pick, no-virt,
already-in-group.
2026-10-27 15:50:30 +01:00
Pathfinder df56379556 libvirt group gate: virt-manager alone justifies membership
Fresh-VM repro: user in yuri+wheel with virt-manager installed. Gate was
'pkg_installed libvirt', but virt-manager does NOT require the 'libvirt'
metapkg (verified vs F44 package page: requires libvirt-glib etc., client
side only) — so the usermod never fired.

Gate now: libvirt OR libvirt-daemon OR libvirt-daemon-driver-qemu OR
virt-manager packages, or an existing /usr/sbin/libvirtd binary.
2026-10-27 15:32:41 +01:00
yuri 847e76614a Merge pull request 'Polkit agent + libvirt group (fixes virt-manager connection failure)' (#4) from fix/polkit-agent-libvirt into main 2026-10-27 15:28:59 +01:00
Pathfinder c552b30112 Advanced menu libvirt wiring: use SUDO_USER fallback like session prereqs
usermod used $USER directly; under sudo $USER can be root. All other
user-scoped calls already use ${SUDO_USER:-$USER} — aligned. No hardcoded
usernames anywhere in the repo (verified by grep; only the dotfiles repo
URL, which is overridable via DOTFILES_REPO).
2026-10-27 15:19:08 +01:00
Pathfinder e99c60a073 Session prereqs: libvirt group membership when libvirt is installed
The error CHANGED on the VM: 'no polkit agent' -> 'access denied by
policy' — the agent unit works; polkit now denies because the user is
not in the libvirt group (fresh run without picking QEMU from the menu,
or no re-login after the pick).

install_session_prereqs now adds the user to the libvirt group whenever
libvirt is installed (idempotent via id -nG check), decoupled from the
advanced-menu choice. Applies after re-login.
2026-10-27 15:14:22 +01:00
Pathfinder 0814c5bde7 polkit agent via user systemd unit, not XDG autostart
Second VM failure: Fedora's polkit-kde .desktop carries
X-systemd-skip=true (and OnlyShowIn on some versions), so niri's
xdg-desktop-autostart.target skips the agent regardless — package
installed, agent dead, same error.

Stop fighting .desktop semantics: install a polkit-agent.service USER
unit (WantedBy=graphical-session.target, which niri/Hyprland/Mango all
bring up) with the Fedora kf6 binary path, pre-linked into
graphical-session.target.wants. Idempotent on both file and symlink.
2026-10-27 14:51:44 +01:00
Pathfinder 6a9941771a polkit-kde agent never starts in niri: strip OnlyShowIn=KDE
The VM error persisted with polkit-kde installed: the package's autostart
.desktop carries OnlyShowIn=KDE;, so XDG autostart (which niri honors via
xdg-desktop-autostart.target) skips the agent in a niri session. Package
present, agent never running — exactly the observed behavior.

install_session_prereqs now rewrites the .desktop without the OnlyShowIn
line in place (idempotent: only rewrites when the line is present).
2026-10-27 14:37:41 +01:00
Pathfinder dbf75d1239 qemu/libvirt belongs to the advanced menu, not the niri module
A clean-snapshot run showed qemu/virt-manager installed without choosing
it: PR #4 had embedded the install in the niri module. Removed from there
(zero virt packages in a plain niri run, harness-verified) and moved into
install_category_pkgs Virtualisation: picking qemu/virt-manager there now
also enables libvirtd and adds the user to the libvirt group.
2026-10-27 14:19:26 +01:00
Pathfinder eacceee296 polkit agent: polkit-gnome is orphaned/EPEL-only on F44; use polkit-kde
VM run failed: 'No match for argument: polkit-gnome'. polkit-gnome is
orphaned in Fedora (EPEL9 only). polkit-kde 6.6.4 is in F44 and ships its
agent's /etc/xdg/autostart .desktop itself, so the manual symlink wiring
is gone too — installing the package is the whole fix.
2026-10-27 14:07:49 +01:00
Pathfinder 8ca045de4a Polkit agent for compositor sessions; libvirt group for virt-manager
virt-manager on niri/Noctalia failed with 'authentication unavailable:
no polkit agent available to authenticate action org.libvirt.unix.manage'
- bare compositors ship no polkit agent, unlike GNOME/KDE.

Two fixes:
- install_session_prereqs installs polkit-gnome and wires its agent
  .desktop into /etc/xdg/autostart (paths overridable for testing).
- niri module optionally installs the qemu/libvirt stack, enables
  libvirtd, and adds the user to the libvirt group: members manage
  qemu:///system with no polkit prompt at all (the real fix; applies
  after re-login).
2026-10-27 14:01:31 +01:00
yuri fceeb57254 Merge pull request 'Package sections: always-on + advanced menu' (#3) from feat/package-sections into main 2026-10-27 13:57:39 +01:00
Pathfinder af4504fdbe Advanced menu: fix single-argument dnf call; failures warn instead of aborting
Root cause of the VM failures (both qemu-kvm AND VirtualBox showed 'No
match for argument: <entire list>'): picks were resolved into ONE quoted
string and passed to dnf as a single argument, so dnf searched for a
package literally named 'qemu-kvm libvirt virt-install ...'. Picks are
now word-split into separate package arguments.

Also, a failing install no longer kills the script (set -e abort): the
menu prints a red 'Installation failed' warning, notes the system is
unchanged, and drops back to the menu so another choice can be made.
Proven in harness: VBox failure (no match) followed by successful qemu
install in the same session, exit 0.
2026-10-27 13:39:28 +01:00
Pathfinder 5b7ed5ede9 Package sections: always-on categories + advanced menu installer
Section 6 'Always-installed packages': ALWAYS_PACKAGES array, one line per
category (first: Terminals with ghostty + ghostty-bash-completion). Every
entry installs on every run, no questions. Add a category by adding a line.

Section 7 'Advanced optional tools': menu-driven picker, data-driven —
ADV_CATEGORIES + ADV_<Category> arrays ('id:Label:pkgs...'). Ships
Virtualisation (qemu/kvm stack, VirtualBox) and Browsers (firefox,
chromium, google-chrome, brave; the latter two wire up their repos
automatically). Multi-select with comma-separated picks, invalid picks
warned and skipped, 'x' backs out, menu is skipped under --yes so
unattended runs never hang.

Bugs found by harness testing and fixed before shipping:
- ${!var} on an array name yields only element[0]: Browsers listed one
  entry. Now uses a nameref (declare -n).
- var assignment was lost in an edit: unbound-variable crash on the menu.
- --snapper=no was overwritten by the interactive default; explicit flags
  now always win.
2026-10-27 13:04:15 +01:00
yuri 525537c929 Merge pull request 'Dotfiles integration: modules/user/dotfiles.sh' (#2) from feat/dotfiles-integration into main 2026-10-27 12:49:54 +01:00
Pathfinder 3776dcb5a4 Snapper: no question on btrfs; vendor the SysGuides scripts
- On btrfs, snapshots + zstd compression now just happen (no prompt);
  --snapper=no remains the explicit opt-out.
- Vendor scripts/snapper-*.sh + config/snapper.actions from
  SysGuides/sysguides-snapper-fedora (MIT, upstream 0a28eb4) into
  vendor/snapper/ and install from there — no runtime clone of an
  external repo, and no dependency on exec bits (fixes the
  './install.sh: Permission denied' failure seen on the test VM).
- grub-btrfs still cloned at runtime from Antynea/grub-btrfs with the
  Fedora config applied; everything else is local now.
2026-10-27 12:37:18 +01:00
Pathfinder 47991727ff Add dotfiles integration module (fedora-dots)
New modules/user/dotfiles.sh behind a menu question ('Clone personal
dotfiles?', default no) and --dotfiles=yes|no flag.

Does a --bare clone of yuri/fedora-dots into ~/.cfg.git, checks the repo
out into $HOME with status.showUntrackedFiles=no, and handles an empty
repo gracefully (configures the alias, skips checkout). Existing
~/.cfg.git is left untouched (idempotent).

Fix proven in the harness: the module originally used a plain git clone,
which creates a working copy and breaks the git-dir alias — the same
failure YuKu hit manually; --bare is required.
Dispatch runs the dotfiles module before DE/TWM so configs are in place
when sessions are installed.
2026-10-27 12:25:13 +01:00
Pathfinder 80d9dccb61 niri module: noctalia now in Fedora repos, drop Terra dependency
noctalia 5.3.0 is in the Fedora 44 updates repo (confirmed on
packages.fedoraproject.org and by a fresh-install dnf install noctalia).
The niri module no longer calls add_terra_repo; Terra stays in the base
section and in the mango module, which is the only consumer left
(mangowc). README + comments updated.
2026-10-27 11:43:48 +01:00
Pathfinder aadead9f38 System settings: optional SELinux disable; btrfs snapper + zstd compression
New section between base and DE menus:
- 'Disable SELinux permanently?' — default no; writes SELINUX=disabled with a
  backup, warns about the security tradeoff.
- When / is btrfs: 'snapper + grub-btrfs + compression?' — default yes. Shows
  subvolume list + fstab, appends ,compress=zstd:1 to btrfs fstab entries that
  lack a compress= option (idempotent, fstab backed up), then runs the
  SysGuides sysguides-snapper-fedora installer (snapper root+home, grub-btrfs,
  DNF5 pre/post snapshot actions).
- Flags for unattended runs: --selinux=keep|disabled, --snapper=yes|no.
- Fix found by the harness: the flag initializers sat after the arg loop and
  wiped parsed values; --snapper=no was silently ignored. Initializers moved
  before the loop.
README + section renumbering updated.
2026-10-27 11:37:51 +01:00
Pathfinder 632a0267dd Hyprland: COPR fallback chain (solopasha has no F44 chroot)
On F44, 'dnf copr enable solopasha/hyprland' fails with 'Chroot not found':
verified via the Copr API, solopasha is rawhide-only right now. lionheartp/
Hyprland (solopasha fork, also ships noctalia packages) and hermitfeather/
hyprland both have fedora-44 chroots.

The module now tries lionheartp, hermitfeather, then solopasha, and aborts
with an actionable message if none has a chroot for this release. README
updated to name the chain, not a single repo.
2026-10-27 11:19:51 +01:00
Pathfinder 0939b228b6 Terra in base section; fix Hyprland via solopasha COPR
- Terra repo is added right after RPM Fusion in the base setup (it carries
  noctalia + mangowc). Modules keep calling add_terra_repo so they still
  work standalone; it is a no-op when terra-release is present.
- Hyprland was retired from the Fedora repos after F42 (dependency churn);
  the module could never resolve 'hyprland' on F44. It now enables the
  solopasha/hyprland COPR first — the de facto standard for F43/44 — and
  treats the accessory packages (hyprpaper/hyprlock/waybar/mako/wofi/
  xwayland-satellite) as optional so a name drift warns instead of aborting.
- README updated.
2026-10-27 11:14:21 +01:00
Pathfinder 1cd8d26d01 XFCE on SDDM; menu defaults to none for DE and TWM
- XFCE module installs/enables sddm instead of lightdm; one greeter across
  every session choice, no lightdm left anywhere.
- Interactive menus now default to 'none' for both DE and TWM (press Enter
  for no DE / no TWM). none+none is still refused by the existing guard.
- README and session.sh comments updated to match.
2026-10-27 11:06:50 +01:00
Pathfinder c443e4edf3 Restore restructured setup.sh (main was broken by 'corrections' merge)
The corrections commit replaced the lib/+modules/ entrypoint with the
older single-file version, reintroducing the DEBUG_MODE unbound-variable
crash, the unguarded clear, and TWM dispatch pointing at nonexistent
root-level scripts. lib/ and modules/ were orphaned.

Restores the tested restructured setup.sh + the SC2034 fix in lib/common.sh.
2026-10-27 10:30:17 +01:00
yuri 047eeebcc2 Merge branch 'feat/lib-modules' 2026-10-27 10:27:05 +01:00
Pathfinder 234bf8214e Restructure for bare Fedora: lib/ + modules/, DE selection restored
Splits setup.sh into lib/common.sh, lib/session.sh and seven modules
(3 DE, 4 TWM). Targets a bare Fedora 44+ install.

Fixes carried over from the first build:
- DEBUG_MODE was unbound under set -u, aborting the script at the version banner
- mangled 'echohttps://...' line left over from the starburst split
- set -e was missing entirely; --debug flag parsing restored
- unguarded 'clear' aborted the run without TERM
- $PRETTY_NAME unguarded under set -u
- modules were sourced, leaking shell options into the parent
- Flathub added twice (system-wide + per-user)
- no dnf5-plugins check before using 'dnf config-manager'
- no Fedora release gate
- /usr/share/xsessions assumed to exist when writing session entries
2026-10-27 10:15:04 +01:00
yuri b9c86feba5 corrections 2026-10-27 10:13:22 +01:00
22 changed files with 2956 additions and 234 deletions
+192 -1
View File
@@ -1,3 +1,194 @@
# Fedora Unified Config # Fedora Unified Config
The goal for this repo is to create a unified configuration for Fedora 44 and newer. Unified configuration for **bare/minimal Fedora 44 and newer**. Pick a desktop
environment and optionally a tiling window manager; the script wires up repos,
packages, the greeter and the session entry.
This repo is the Fedora-only successor to `starburst`. Everything multi-distro
(Arch, Debian, Ubuntu branches, OS guessing, per-distro package helpers) is gone
on purpose.
## Usage
```bash
git clone https://git.ku-it.org/yuri/fedora.git
cd fedora
./setup.sh # interactive: choose DE, then TWM
./setup.sh --debug # same, but pause after each section
```
Non-interactive:
```bash
./setup.sh --yes --de=none --twm=niri
./setup.sh --yes --de=gnome --twm=none
```
Run as your **normal user**, not root. `sudo` is used where needed.
## Layout
```
setup.sh entrypoint: args, preconditions, base setup, menus, dispatch
lib/common.sh colours, logging, dnf helpers, rpmfusion/terra, nerd fonts
lib/session.sh display-manager detection/switching, session entries
modules/de/{gnome,plasma,xfce}.sh
modules/twm/{niri,hyprland,mango,chadwm}.sh
modules/user/dotfiles.sh bare-repo dotfiles checkout
modules/user/nvidia.sh GPU-detected NVIDIA driver install
```
Modules are **separate processes**, not sourced into `setup.sh`. That means a
module's own `set -e` cannot leak into the caller, and every module can be run
on its own for testing:
```bash
DE=none TWM=niri DEBUG_MODE=false bash modules/twm/niri.sh
```
Each module pulls in `lib/` by relative path, so it works both ways.
## Selection matrix
| DE | TWM | Result |
|---|---|---|
| any | any | DE provides the session; TWM is added alongside it |
| `none` | any | TWM-only machine, SDDM installed as the greeter |
| any | `none` | plain desktop, no tiling WM |
| `none` | `none` | **refused** — that is not a graphical system |
### Desktop environments
| DE | dnf group (tried in order) | native greeter |
|---|---|---|
| `gnome` | `gnome-desktop`, `gnome-desktop-environment`, `workstation-product-environment` | gdm |
| `plasma` | `kde-desktop`, `kde-desktop-environment` | sddm |
| `xfce` | `xfce-desktop`, `xfce-desktop-environment` | sddm |
### Tiling window managers
| TWM | Source | Session type | Notes |
|---|---|---|---|
| `niri` | Fedora repos | Wayland | noctalia is packaged since F44; ships its own session entry |
| `hyprland` | **COPR** (lionheartp, then hermitfeather, then solopasha) | Wayland | Retired from Fedora repos after F42; first COPR with a chroot for your release wins |
| `mango` | **Terra repo only** | Wayland | Session entry written if the package lacks one |
| `chadwm` | **built from source** | **X11** | dwm fork; not packaged anywhere for Fedora |
`chadwm` is the only X11 entry in the menu. The other three are Wayland
compositors — do not expect the same config story from it.
## GPU drivers (NVIDIA)
`modules/user/nvidia.sh` runs on **detected hardware**, no question asked: the
card in the machine decides what is installed. Opt out with `--nvidia=no`
(e.g. to stay on nouveau).
Branch selection comes from the chip family in the name `lspci` prints, because
the numeric device-id ranges overlap between families and a wrong guess installs
a module the card cannot use:
| Chip family in the name | Driver branch | Packages |
|---|---|---|
| `GB`/`AD`/`GA`/`TU` (Turing and newer) | current | `akmod-nvidia` + `xorg-x11-drv-nvidia-cuda` |
| `GV`/`GP`/`GM` (Volta, Pascal, Maxwell) | 580xx | `akmod-nvidia-580xx` + `xorg-x11-drv-nvidia-580xx{,-cuda}` |
| `GK` (Kepler) | 470xx | `akmod-nvidia-470xx` + `xorg-x11-drv-nvidia-470xx{,-cuda}` |
| `GF` (Fermi) | 390xx | `akmod-nvidia-390xx` + `xorg-x11-drv-nvidia-390xx{,-cuda}` |
| pre-Fermi (`G92`, `GT200`, ...) | none | refused: 340xx is EOL and needs a longterm kernel |
If the name cannot be parsed, the module asks for the branch instead of guessing.
**Secure Boot.** With enforcement on, the akmods key is generated and imported,
and the module then *stops before installing the driver* — installing an
unsigned module while Secure Boot enforces is the documented way to get a black
screen, because the driver package blacklists nouveau as it lands. Enrol the key
in MokManager at the next boot, then run the setup again. With Secure Boot off
or in **Setup mode**, no signing happens at all: shim skips MokManager when
enforcement is off, so an import request would just sit in the efivars.
After the transaction the module waits (bounded, 5 min) for the akmod to build
so you cannot reboot into a driverless boot, runs `dnf mark user` on the akmod
(so `dnf autoremove` cannot take it), and offers VA-API decode + vulkan as an
opt-in extra. Nothing here sets `nvidia-drm.modeset=1`: RPM Fusion's kernel
package already enables KMS, and the flag now conflicts with Fedora's simpledrm
early-boot patch.
## Display manager policy
* A **DE module** leaves the DE's own greeter alone (GNOME→gdm, Plasma→sddm,
XFCE→sddm). It never fights a desktop over its own login screen.
* A **TWM module** enforces **SDDM**, because it is the only greeter that lists
Wayland compositors *and* X11 sessions in one menu.
* Either way `graphical.target` becomes the default.
## System settings
Between base setup and the DE menu, the script asks two questions:
1. **Disable SELinux permanently?** — default **no**. Writes `SELINUX=disabled`
to `/etc/selinux/config` (backup kept); a reboot applies it. Recommended
answer is no.
2. **Snapper + grub-btrfs + zstd compression** — only asked when `/` is btrfs,
default **yes**. Runs, in order:
- `btrfs subvolume list /` and the fstab btrfs entries are shown
- every fstab btrfs entry with a `subvol=` but no `compress=` gets
`,compress=zstd:1` appended (fstab backed up first; re-runs cannot stack
duplicate options)
- the [SysGuides installer](https://github.com/SysGuides/sysguides-snapper-fedora)
sets up snapper (root + home), grub-btrfs, and DNF5 pre/post snapshot hooks,
so every `dnf` transaction is snapshotted and `snapper undochange` works.
Unattended equivalents: `--selinux=keep|disabled` and `--snapper=yes|no`.
## Design rules
1. **Fail loudly, early.** `set -e` is on unless `--debug`. Unsupported distro,
release below 44, `DE=none` + `TWM=none`, and a missing module all abort with
an explanation instead of half-installing.
2. **Be idempotent.** Re-running must be safe. Repos are checked with `rpm -q`
before being added, the Nerd Font is skipped if already present, and session
entries are not overwritten.
3. **Prefer official repos, then Terra, then a source build.** That order is why
only `mango` and `chadwm` need special handling.
4. **No silent optional failures.** `install_optional` warns and continues for
nice-to-haves; everything load-bearing uses `install_packages` and aborts.
5. **Guard anything environment-dependent.** `clear` needs `|| true` (no `TERM`
under cron/pipes), `os-release` fields are defaulted, and
`/usr/share/xsessions` is created before writing into it.
## Testing
The script was verified on a non-Fedora host with a stateful stub harness
(fake `sudo`/`dnf`/`rpm`/`systemctl`/`git`/`make`/`curl`, a fake `os-release`,
and sandboxed `/usr` + `$HOME`). Covered: every DE×(TWM|none) combination, the
refusal paths, `--help`/bad args, interactive menus, `--debug` abort, and
re-running for idempotency.
`OS_RELEASE_FILE` is overridable purely so the preconditions can be tested:
```bash
OS_RELEASE_FILE=/tmp/fake-os-release ./setup.sh --yes --de=xfce --twm=chadwm
```
## Known limitations
* `mangowc` comes from **Terra** (added in the base section) and `hyprland`
from a **COPR** — third-party sources whose layout is not guaranteed across
Fedora releases; `mango` aborts with build-from-source instructions if the
package is missing. `noctalia` has been in the Fedora repos since F44, so
the niri module needs no third-party repo.
* Package *names* are not verified by installation in this repo's test harness —
only control flow is. First real run on a fresh F44 box is the actual proof.
* `gnome-tweaks`/`gnome-extensions-app`, `wofi`, `waybar`, `hyprlock` and friends
are installed with `install_optional`, so a rename will warn rather than fail.
## Roadmap
- [ ] Profile files (`--profile=laptop`) instead of individual flags
- [ ] Optional dotfiles layer (noctalia/hyprland/niri configs)
- [ ] `--dry-run` that prints the dnf transaction without applying it
- [ ] Pre-flight report: what is already installed, what would change
## License
See `LICENSE`.
+494
View File
@@ -0,0 +1,494 @@
# 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
# 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)
# ---- status BLOCK (TTY-safe: a background-coloured SPACE, never a glyph) --
# busy: blue block that breathes (bg-colour ramp) + current dnf action
# ok : solid green block + package count
# fail: solid RED block (spec change from the X, per YuKu)
# Colour-depth aware: 256 -> ramp, 16 -> blue/bright-blue, none -> ASCII.
local depth=0 nc
nc=$(tput colors 2>/dev/null || echo 0)
case "$nc" in ''|*[!0-9]*) nc=0 ;; esac
if (( nc >= 256 )); then depth=256
elif (( nc >= 8 )); then depth=16
fi
# The bare Linux VT is 16-color VGA: 48;5;N codes are ignored there, and
# its blue/bright-blue backgrounds render nearly identically (VM-proven:
# "solid blue, no breathing" on the console). Cap to the 16-color path.
case "${TERM:-}" in linux*|console*) depth=16 ;; esac
# Per-depth frame interval: the console colour-pulse runs slower (a fast
# blue/grey flip reads as flicker — YuKu asked for half-to-third speed);
# the 256-colour breathe ramp keeps its 50ms.
local tick=0.05
(( depth == 16 )) && tick=0.15
local -a bg=()
case "$depth" in
# Deep-amplitude breathe: dark navy (17) to BRIGHT blue (45). Adjacent
# shades must differ enough to survive flicker fusion — a subtle ramp
# at 20fps reads as a solid block (VM-proven).
256) bg=( "48;5;17" "48;5;19" "48;5;21" "48;5;27" "48;5;33" "48;5;39" "48;5;45" "48;5;39" "48;5;33" "48;5;27" "48;5;21" "48;5;19" ) ;;
# Colour pulse with CONSTANT layout: blue <-> dark grey (bright black).
# Never remove the block on a frame — a missing block shifted the
# whole line by one char on the console (VM-proven).
16) bg=( "44" "44" "100" "100" ) ;;
esac
local -a ch=( "|" "/" "-" "\\" )
"$@" >>"$log" 2>&1 &
local pid=$!
local f=0 cur
while kill -0 "$pid" 2>/dev/null; do
# Match dnf ('Installing: pkg') AND flatpak ('Installing... id', a
# Unicode ellipsis) progress lines; sanitize to printable ASCII so a
# non-UTF8 console never sees garbage bytes in the live line.
cur=$(sed -n "$((before+1)),\$p" "$log" 2>/dev/null \
| grep -aE '^Installing|^Upgrading|^Downgrading|^Removing|^Verifying|^Preparing|^Cleanup' \
| tail -n 1 \
| tr -cd '\11\12\40-\176' || true)
# Compose ONCE, then clamp to the terminal width: a live line longer
# than the screen wraps on every redraw and floods the screen with
# duplicate partial lines (VM-proven on the 80-col TTY).
local cols line
cols=$(tput cols 2>/dev/null)
[[ "$cols" =~ ^[0-9]+$ ]] || cols=80
if [[ -n "$cur" ]]; then
line="$msg: $cur"
else
line="$msg"
fi
if (( ${#line} > cols - 2 )); then
line="${line:0:cols-4}.."
fi
if (( depth >= 16 )); then
local code="${bg[$(( f % ${#bg[@]} ))]}"
printf '\r\033[%sm \033[0m %s' "$code" "$line" >&2
else
printf '\r %s %s' "${ch[$(( f % ${#ch[@]} ))]}" "$line" >&2
fi
f=$(( f + 1 ))
sleep "$tick"
done
local rc=0
wait "$pid" || rc=$?
# ANSI erase-line: wipes the live line without leaving trailing spaces
# (a 100-space wipe left a visible gap when the summary was shorter)
printf '\r\033[K' >&2
if (( rc == 0 )); then
n=$(sed -n "$((before+1)),\$p" "$log" 2>/dev/null | grep -ac '^Installing' || true)
local count_txt=""
if (( n > 0 )); then
local plural="s"
(( n == 1 )) && plural=""
count_txt=" (${n} package${plural})"
fi
# n == 0 means nothing new was installed (idempotent re-run /
# deps already present): print the message without a count.
if (( depth == 256 )); then
printf '\033[48;5;34m \033[0m %s%s\n' "$msg" "$count_txt" >&2
elif (( depth == 16 )); then
printf '\033[42m \033[0m %s%s\n' "$msg" "$count_txt" >&2
else
ok "$msg${count_txt}"
fi
return 0
else
if (( depth == 256 )); then
printf '\033[48;5;196m \033[0m %s\n' "$msg" >&2
elif (( depth == 16 )); then
printf '\033[41m \033[0m %s\n' "$msg" >&2
else
color_red " Failed: $msg"
fi
color_red " (dnf reported a transaction failure)"
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
#------------------------------------------
# True when a repo id is CONFIGURED, whatever installed it. This is the
# reliable source of truth: a machine can have the repo without the
# -release RPM (hand-added .repo file) — and bootstrapping over an existing
# repo id via --repofrompath collides and fails (laptop-proven).
repo_enabled() {
dnf repolist --all 2>/dev/null | awk '{print $1}' | grep -qx "$1"
}
add_terra_repo() {
if pkg_installed terra-release || repo_enabled terra; then
ok "Terra repository already present."
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 \
|| { repo_enabled rpmfusion-free && repo_enabled rpmfusion-nonfree; }; then
ok "RPM Fusion already present."
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
# belt-and-suspenders: on real Fedora /etc/selinux always exists, but
# do not die if it somehow does not
sudo mkdir -p "$(dirname "$cfg")"
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"
}
+145
View File
@@ -0,0 +1,145 @@
# shellcheck shell=bash
#------------------------------------------
# lib/session.sh — display manager and graphical session wiring
#
# DM policy:
# * A DE module leaves its native DM alone (GNOME→gdm, Plasma→sddm, XFCE→sddm).
# * A TWM module enforces SDDM, because SDDM is the only DM that presents both
# Wayland compositors and X11 sessions in one menu. One greeter, no surprises.
#------------------------------------------
if [[ -n "${_SB_SESSION_LOADED:-}" ]]; then
return 0
fi
_SB_SESSION_LOADED=1
# Name of the active display manager ("sddm", "gdm", "lightdm"), or "" if none.
detect_display_manager() {
local link=/etc/systemd/system/display-manager.service
if [[ -L "$link" ]]; then
basename "$(readlink "$link")" | sed 's/\.service$//'
else
echo ""
fi
}
enable_graphical_target() {
sudo systemctl set-default graphical.target
}
# Make $1 the display manager, disabling whatever is active now.
switch_display_manager() {
local target="$1"
local current
current=$(detect_display_manager)
if [[ "$current" == "$target" ]]; then
ok "Display manager already $target."
else
if [[ -n "$current" ]]; then
info "Disabling current display manager: $current"
# disable may fail if the unit vanished; that is not fatal
sudo systemctl disable "$current" 2>/dev/null || true
fi
info "Enabling display manager: $target"
sudo systemctl enable "$target"
fi
enable_graphical_target
}
# Register a Wayland session so the DM can list it.
# $1 = identifier, $2 = display name, $3 = command
write_wayland_session() {
local id="$1" name="$2" exec_cmd="$3"
local file="/usr/share/wayland-sessions/${id}.desktop"
if [[ -f "$file" ]]; then
ok "Wayland session already registered: $id"
return 0
fi
info "Registering Wayland session: $id"
sudo mkdir -p "$(dirname "$file")"
printf '[Desktop Entry]\nName=%s\nComment=%s\nExec=%s\nType=Application\nDesktopNames=%s\n' \
"$name" "$name session" "$exec_cmd" "$id" | sudo tee "$file" >/dev/null
sudo chmod 644 "$file"
}
# Register an X11 session so the DM can list it.
write_x11_session() {
local id="$1" name="$2" exec_cmd="$3"
local file="/usr/share/xsessions/${id}.desktop"
if [[ -f "$file" ]]; then
ok "X11 session already registered: $id"
return 0
fi
info "Registering X11 session: $id"
# A bare Fedora has no X session yet, so the directory may not exist.
sudo mkdir -p "$(dirname "$file")"
printf '[Desktop Entry]\nName=%s\nComment=%s\nExec=%s\nType=Application\n' \
"$name" "$name session" "$exec_cmd" | sudo tee "$file" >/dev/null
sudo chmod 644 "$file"
}
# Common login-time plumbing every Wayland compositor wants on a bare install.
install_session_prereqs() {
# polkit-kde ships the agent (polkit-gnome is orphaned, EPEL-only).
# BUT Fedora's autostart .desktop carries X-systemd-skip=true (and a
# OnlyShowIn on some versions): niri starts apps via systemd's
# xdg-desktop-autostart.target, which honors those flags -> agent never
# starts in a niri session (VM-proven twice). Fighting .desktop semantics
# is fragile, so we ship our own user unit bound to
# graphical-session.target, which niri/Hyprland/MangoWC all bring up.
install_packages \
xdg-desktop-portal \
polkit \
polkit-kde \
pipewire \
pipewire-pulseaudio \
wireplumber
local unit_dir="/etc/systemd/user"
if [[ ! -f "$unit_dir/polkit-agent.service" ]]; then
info "Installing polkit-agent user unit (compositor-safe)..."
sudo mkdir -p "$unit_dir"
sudo tee "$unit_dir/polkit-agent.service" >/dev/null <<'UNIT'
[Unit]
Description=PolicyKit Authentication Agent (for bare compositors)
PartOf=graphical-session.target
[Service]
# polkit-kde's binary on Fedora (kf6 path). Restart in case the agent dies
# before the session settles.
ExecStart=/usr/libexec/kf6/polkit-kde-authentication-agent-1
Restart=on-failure
RestartSec=2
[Install]
WantedBy=graphical-session.target
UNIT
fi
# Enable per-user: preset-all would also do, but be explicit. This is a
# global unit dir; each user links it at first login via the preset.
if [[ ! -f /etc/systemd/user/graphical-session.target.wants/polkit-agent.service ]]; then
sudo mkdir -p /etc/systemd/user/graphical-session.target.wants
sudo ln -sf ../polkit-agent.service \
/etc/systemd/user/graphical-session.target.wants/polkit-agent.service
fi
# libvirt group: members manage qemu:///system without a polkit prompt.
# Without this, virt-manager dies on "access denied by policy" even with
# a working agent (VM-proven). Idempotent; applies after re-login.
#
# Gate on the DAEMON, not the 'libvirt' metapkg name: virt-manager only
# requires libvirt-glib client-side, so on a fresh VM libvirt can be
# functionally present via libvirt-daemon* without rpm -q libvirt passing
# (VM-proven: user stuck in yuri+wheel with virt-manager installed).
if pkg_installed_any libvirt libvirt-daemon libvirt-daemon-driver-qemu virt-manager \
|| [[ -x /usr/sbin/libvirtd ]]; then
local real_user="${SUDO_USER:-$USER}"
if ! id -nG "$real_user" 2>/dev/null | grep -qw libvirt; then
info "Adding $real_user to the libvirt group (re-login to apply)..."
sudo usermod -aG libvirt "$real_user"
fi
fi
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# ui-block.sh - TTY-safe status "block" indicator (no special fonts)
#
# busy : blue block that "breathes" (background colour pulses)
# ok : solid green block
# fail : solid red block (changed from red X per YuKu's spec)
#
# Why this works on a bare TTY: the "block" is a BACKGROUND-COLOURED SPACE,
# not a U+2588 glyph. No fonts, no UTF-8, no locale dependency.
#
# Draws on /dev/tty, never stdout: ui_choose/ui_confirm capture stdout with $(),
# so a status line on stdout would corrupt every captured answer.
_UB_FD=""
_UB_PID=""
_UB_MSG=""
_ub_tty() {
if [[ -z "$_UB_FD" ]]; then
# probe first: a bare `exec {fd}>/dev/tty` failure still prints its error
{ : >/dev/tty; } 2>/dev/null || { _UB_FD=""; return 1; }
exec {_UB_FD}>/dev/tty 2>/dev/null || { _UB_FD=""; return 1; }
fi
[[ -n "$_UB_FD" ]]
}
_ub_depth() {
local n
n=$(tput colors 2>/dev/null || echo 0)
case "$n" in ''|*[!0-9]*) n=0 ;; esac
if [[ "$n" -ge 256 ]]; then echo 256
elif [[ "$n" -ge 8 ]]; then echo 16
else echo 0; fi
# bare Linux VT: cap regardless of what tput reports
case "${TERM:-}" in linux*|console*) echo 16 ;; esac
}
# paint one background-coloured block (a SPACE) + message
_ub_paint() {
printf '\r\033[%sm \033[0m %s' "$1" "$2" >&"$_UB_FD"
}
_ub_busy_loop() {
local depth pal=() i tick=0.05
depth=$(_ub_depth)
[[ "$depth" -eq 16 ]] && tick=0.15 # console pulse: slow, not flicker
case "$depth" in
256) pal=(17 19 21 27 33 39 45 39 33 27 21 19) ;; # deep breathe: dark navy to bright blue
16) pal=(44 44 100 100) ;; # blue <-> dark grey, constant layout
*) pal=("|" "/" "-" "\\") ;; # last-resort spinner
esac
while :; do
for i in "${pal[@]}"; do
if [[ "$depth" -eq 256 ]]; then
_ub_paint "48;5;$i" "$_UB_MSG" # 256-colour cube background
elif [[ "$depth" -eq 16 ]]; then
_ub_paint "$i" "$_UB_MSG" # blue <-> dark grey pulse
else
printf '\r[%s] %s' "$i" "$_UB_MSG" >&"$_UB_FD"
fi
sleep "$tick" || return 0
done
done
}
_ub_stop() {
if [[ -n "$_UB_PID" ]]; then
kill "$_UB_PID" 2>/dev/null
wait "$_UB_PID" 2>/dev/null
_UB_PID=""
fi
}
ui_block_start() {
_UB_MSG="${1:-Working}"
_ub_tty || return 0 # no tty: silent no-op
_ub_stop
_ub_busy_loop &
_UB_PID=$!
}
ui_block_ok() {
local msg="${1:-Done}" d
_ub_tty || return 0
_ub_stop
d=$(_ub_depth)
case "$d" in
256) _ub_paint "48;5;34" "$msg" ;;
16) _ub_paint "42" "$msg" ;;
*) printf '\r[OK] %s' "$msg" >&"$_UB_FD" ;;
esac
printf '\033[0m\n' >&"$_UB_FD"
}
ui_block_fail() {
local msg="${1:-Failed}" d
_ub_tty || return 0
_ub_stop
d=$(_ub_depth)
case "$d" in
256) printf '\r\033[48;5;196m \033[0m %s' "$msg" >&"$_UB_FD" ;;
16) printf '\r\033[41m \033[0m %s' "$msg" >&"$_UB_FD" ;;
*) printf '\r[!] %s' "$msg" >&"$_UB_FD" ;;
esac
printf '\n' >&"$_UB_FD"
}
ui_block_close() {
_ub_stop
if [[ -n "$_UB_FD" ]]; then
exec {_UB_FD}>&- 2>/dev/null
_UB_FD=""
fi
return 0 # an EXIT trap's status becomes the script's exit code — never fail here
}
# ALWAYS install a cleanup trap in the sourcing script:
# trap 'ui_block_close' EXIT INT TERM
+170
View File
@@ -0,0 +1,170 @@
# shellcheck shell=bash
#------------------------------------------
# lib/ui.sh — gum-based TUI with plain-text fallback
#
# Every ui_* helper has three behaviors:
# 1. ASSUME_YES (unattended) -> returns the default, no UI at all
# 2. gum available + a TTY -> styled TUI (menus, confirms, spinners)
# 3. otherwise -> the classic numbered prompts
#
# The wrappers print the ANSWER on stdout (like the old ask_* did), so call
# sites never change based on which mode is active.
#------------------------------------------
if [[ -n "${_SB_UI_LOADED:-}" ]]; then
return 0
fi
_SB_UI_LOADED=1
# have_gum lives in lib/common.sh (install_packages needs it too).
# Single choice from a list.
# ui_choose "<header>" "<prompt>" "<default>" item1 item2 ...
# prints the chosen item verbatim; prints the default when unattended.
ui_choose() {
local header="$1" prompt="$2" default="$3"
shift 3
local items=("$@")
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
if have_gum; then
# gum choose returns 130 on Esc/Ctrl-C; treat as default rather than
# killing the whole install.
local sel
sel=$(gum choose --header "$header"$'\n'"$prompt" "${items[@]}") || sel="$default"
echo "${sel:-$default}"
return 0
fi
# classic fallback: numbered list. ALL display goes to stderr — stdout is
# captured by the caller ($(ui_choose ...)) and must carry ONLY the answer
# (a stray printf newline once made choice='\nno' and broke every case match).
printf "\n" >&2
color_cyan "$header" >&2
local i=1 it
for it in "${items[@]}"; do
echo " $i) $it" >&2
i=$((i+1))
done
local n=${#items[@]} answer
while true; do
if ! read -t 90 -rp "$prompt [1-$n] (default: shown above): " answer; then
printf "\n" >&2
answer=""
fi
# empty/timeout -> the default item (its position in the list)
if [[ -z "$answer" ]]; then
local k=0
for it in "${items[@]}"; do
k=$((k+1))
[[ "$it" == "$default" ]] && { echo "$it"; return 0; }
done
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
fi
err "Invalid choice: $answer"
done
}
# Yes/no. ui_confirm "<prompt>" "<yes|no default>" -> prints yes/no
ui_confirm() {
local prompt="$1" default="${2:-no}"
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
if have_gum; then
if gum confirm "$prompt"; then
echo "yes"
else
echo "no"
fi
return 0
fi
printf "\n" >&2
read -t 60 -rp "$prompt [y/n] (default: $default): " answer || printf "\n" >&2
answer="${answer,,}"
[[ -z "$answer" ]] && answer="$default"
case "$answer" in
y|yes) echo "yes" ;;
*) echo "no" ;;
esac
}
# Multi-select from a list (one per line on stdout; empty = none).
# ui_choose_multi "<header>" "<prompt>" item1 item2 ...
ui_choose_multi() {
local header="$1" prompt="$2"
shift 2
local items=("$@")
if [[ "$ASSUME_YES" == "true" ]]; then
return 0
fi
if have_gum; then
gum choose --no-limit --header "$header"$'\n'"$prompt" "${items[@]}" || true
return 0
fi
printf "\n" >&2
color_cyan "$header" >&2
local i=1 it
for it in "${items[@]}"; do
echo " $i) $it" >&2
i=$((i+1))
done
echo " x) None / back" >&2
local sel picks=() pick
read -t 90 -rp "Pick one or more (comma-separate, default: none): " sel || printf "\n"
[[ "${sel,,}" == "x" || -z "$sel" ]] && return 0
IFS=',' read -ra picks <<< "$sel"
for pick in "${picks[@]}"; do
pick="${pick// /}"
[[ "$pick" =~ ^[0-9]+$ ]] || continue
(( pick >= 1 && pick <= ${#items[@]} )) && echo "${items[$((pick-1))]}"
done
}
# Spinner around a long command. Falls back to running it plainly.
# ui_run "Updating system..." sudo dnf update -y
ui_run() {
local msg="$1"
shift
if have_gum; then
if gum spin --title "$msg" --show-output -- "$@"; then
ok "$msg"
else
local rc=$?
color_red " Failed: $msg"
return "$rc"
fi
return 0
fi
info "$msg"
"$@"
local rc=$?
if (( rc == 0 )); then
ok "$msg"
else
color_red " Failed: $msg"
fi
return "$rc"
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Desktop environment: GNOME"
rule
if pkg_installed gnome-shell; then
ok "GNOME already installed."
else
info "Installing the GNOME desktop..."
# Group names have shifted across releases; take whichever exists.
install_group_any gnome-desktop gnome-desktop-environment workstation-product-environment
install_optional gnome-tweaks gnome-extensions-app
fi
# GNOME ships and enables gdm itself. We deliberately leave it alone: a DE module
# never fights the DE over its own greeter. A TWM module switched on top will
# move the system to SDDM instead.
info "Current display manager: $(detect_display_manager)"
ok "GNOME setup complete."
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Desktop environment: KDE Plasma"
rule
if pkg_installed plasma-workspace; then
ok "Plasma already installed."
else
info "Installing the KDE Plasma desktop..."
install_group_any kde-desktop kde-desktop-environment
fi
# Plasma's native greeter is SDDM; make sure it is actually the active one.
install_packages sddm
switch_display_manager sddm
ok "Plasma setup complete."
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Desktop environment: XFCE"
rule
if pkg_installed xfce4-session; then
ok "XFCE already installed."
else
info "Installing the XFCE desktop..."
install_group_any xfce-desktop xfce-desktop-environment
install_optional "@xfce-extra-plugins"
fi
# XFCE is X11. It needs an X server present even when it is only the session
# provider for chadwm.
install_packages xorg-x11-server-Xorg xorg-x11-xinit
# XFCE runs fine under SDDM too — unify on SDDM so every session has one greeter
if ! pkg_installed sddm; then
install_packages sddm
fi
switch_display_manager sddm
install_nerd_font JetBrainsMono
install_packages alacritty dconf
# Point XFCE's terminal preference at alacritty, seeding the config directly so
# it applies on first login rather than needing a session restart.
XFCONF_DIR="$HOME/.config/xfce4/xfconf/xfce-perchannel-xml"
mkdir -p "$XFCONF_DIR"
info "Seeding XFCE session defaults..."
cat > "$XFCONF_DIR/xfce4-session.xml" <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-session" version="1.0">
<property name="general" type="empty">
<property name="TerminalEmulator" type="string" value="alacritty"/>
</property>
</channel>
EOF
ok "XFCE setup complete."
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Tiling window manager: chadwm"
rule
# chadwm is an X11 dwm fork and is not packaged for Fedora anywhere. It is
# built from source. Note this is the only non-Wayland option in the menu.
info "Installing chadwm build dependencies..."
install_packages \
gcc make git \
libX11-devel libXft-devel libXinerama-devel imlib2-devel \
dash xsetroot feh picom rofi alacritty \
xorg-x11-server-Xorg xorg-x11-xinit
CHAD_DIR="$HOME/.config/chadwm"
if [[ ! -d "$CHAD_DIR/.git" ]]; then
info "Cloning chadwm into $CHAD_DIR..."
git clone --depth 1 https://github.com/siduck/chadwm "$CHAD_DIR"
else
ok "chadwm source already present at $CHAD_DIR"
fi
if [[ ! -x /usr/local/bin/chadwm ]]; then
info "Building and installing chadwm..."
# make install needs root to write /usr/local/bin
sudo make -C "$CHAD_DIR/chadwm" install
else
ok "chadwm binary already installed."
fi
# Session wrapper: dwm forks do not do autostart themselves, so set up the
# wallpaper/compositor here instead of leaving the user with an empty desktop.
info "Installing session wrapper /usr/local/bin/chadwm-session..."
sudo tee /usr/local/bin/chadwm-session >/dev/null <<'EOF'
#!/bin/bash
# chadwm session: compositor + wallpaper + status, then hand over to chadwm.
pgrep -x picom >/dev/null || picom --daemon &
if [ -d "$HOME/Pictures" ]; then
feh --bg-fill "$(find "$HOME/Pictures" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.png' \) | head -n1)" 2>/dev/null || true
fi
xsetroot -cursor_name left_ptr 2>/dev/null || true
exec chadwm
EOF
sudo chmod 755 /usr/local/bin/chadwm-session
write_x11_session chadwm "chadwm" "/usr/local/bin/chadwm-session"
install_nerd_font JetBrainsMono
install_packages sddm
switch_display_manager sddm
ok "chadwm setup complete. Log out and pick chadwm in SDDM."
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Tiling window manager: Hyprland"
rule
# hyprland was retired from the Fedora repos after F42 (dependency churn), so it
# comes from a COPR. Which COPR has a fedora-44 chroot changes over time —
# solopasha (the usual recommendation) was rawhide-only when this was written.
# Try candidates in order; first one that enables wins. If none works, abort
# with an actionable message instead of a cryptic "chroot not found".
if ! pkg_installed hyprland; then
enabled=""
for copr in lionheartp/Hyprland hermitfeather/hyprland solopasha/hyprland; do
info "Enabling copr: $copr..."
if sudo dnf copr enable -y "$copr" >/dev/null 2>&1; then
enabled="$copr"
ok "Copr enabled: $copr"
break
fi
warn "Copr $copr has no chroot for this Fedora release; trying next..."
done
if [[ -z "$enabled" ]]; then
err "None of the Hyprland COPRs have a chroot for Fedora $(rpm -E %fedora)."
err "Check https://copr.fedorainfracloud.org/coprs/ for an up-to-date one, or:"
err " sudo dnf copr enable -y <owner>/<project> fedora-rawhide-x86_64 # last resort"
exit 1
fi
fi
info "Installing Hyprland and its ecosystem..."
# COPR package sets differ; treat everything beyond the core compositor as
# optional so a name drift warns instead of killing the transaction.
install_packages hyprland xdg-desktop-portal-hyprland alacritty
install_optional hyprpaper hypridle hyprlock waybar mako wofi xwayland-satellite
install_session_prereqs
install_nerd_font JetBrainsMono
# The Fedora hyprland package ships hyprland.desktop in wayland-sessions.
install_packages sddm
switch_display_manager sddm
ok "Hyprland setup complete. Log out and pick Hyprland in SDDM."
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Tiling window manager: MangoWC"
rule
# MangoWC is not in the Fedora repos; it is packaged in Terra.
add_terra_repo
info "Installing MangoWC..."
if ! sudo dnf install -y mangowc; then
err "mangowc is not available from Terra on this release."
err "Alternatives: copr dennemann/MangoWC, or build from source:"
err " https://github.com/GravityShark/mangowc"
exit 1
fi
install_packages fuzzel alacritty
install_optional xdg-desktop-portal-wlr
install_optional xwayland-satellite
install_session_prereqs
install_nerd_font JetBrainsMono
# MangoWC's session entry is not guaranteed by the package; write one if absent.
write_wayland_session mangowc "MangoWC" "mangowc"
install_packages sddm
switch_display_manager sddm
ok "MangoWC setup complete. Log out and pick MangoWC in SDDM."
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/session.sh
source "$HERE/../../lib/session.sh"
if is_debug; then set +e; else set -e; fi
step "Tiling window manager: Niri"
rule
# Noctalia is in the Fedora repos since F44 (noctalia 5.3.0 in updates) —
# no third-party repo needed for this module. (Terra stays in the base
# section; mangowc still requires it.)
info "Installing Niri + Noctalia..."
install_packages niri noctalia fuzzel alacritty
# niri is Wayland-only; this lets X11 apps run inside it
install_optional xwayland-satellite
install_optional xdg-desktop-portal-wlr
install_session_prereqs
install_nerd_font JetBrainsMono
# niri ships /usr/share/wayland-sessions/niri.desktop itself, so no entry needed.
install_packages sddm
switch_display_manager sddm
ok "Niri setup complete. Log out and pick Niri in SDDM."
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
if is_debug; then set +e; else set -e; fi
DOTFILES_REPO="${DOTFILES_REPO:-https://git.ku-it.org/yuri/fedora-dots.git}"
CFG_DIR="$HOME/.cfg.git"
step "Personal dotfiles: fedora-dots"
rule
if [[ ! -d "$HOME/.config" ]]; then
mkdir -p "$HOME/.config"
fi
# --- Install the cfg command, ALWAYS (even if the repo exists already) ---
# The alias lives in ~/.bash_aliases (user-owned, trackable in fedora-dots),
# and ~/.bashrc sources that file. Without this, `cfg` exists only on the
# machine where it was typed by hand (VM-proven: "cfg pull: command not found").
ALIASES_FILE="$HOME/.bash_aliases"
BASHRC="$HOME/.bashrc"
if ! grep -q "alias cfg=" "$ALIASES_FILE" 2>/dev/null; then
info "Installing cfg alias into ~/.bash_aliases..."
cat >> "$ALIASES_FILE" <<'EOF'
# cfg: manage the bare dotfiles repo (~/.cfg.git, work tree $HOME)
alias cfg='git --git-dir=$HOME/.cfg.git --work-tree=$HOME'
EOF
fi
if ! grep -q "bash_aliases" "$BASHRC" 2>/dev/null; then
info "Making ~/.bashrc load ~/.bash_aliases..."
touch "$BASHRC"
cat >> "$BASHRC" <<'EOF'
# user aliases
if [ -f ~/.bash_aliases ]; then . ~/.bash_aliases; fi
EOF
fi
if [[ -d "$CFG_DIR" ]]; then
ok "Dotfiles already set up ($CFG_DIR exists)."
ok "cfg alias ensured. Open a new shell or run: source ~/.bashrc"
exit 0
fi
# A bare repo is the storage; $HOME is the work tree. Checkout happens through
# the alias form, never a plain `git checkout` inside $HOME.
info "Cloning dotfiles (bare) into $CFG_DIR..."
# --bare is what makes ~/.cfg.git the storage; the plain clone YuKu first ran
# created a working copy instead and broke the alias (proven the hard way).
git clone --quiet --bare "$DOTFILES_REPO" "$CFG_DIR"
cfg() {
git --git-dir="$CFG_DIR" --work-tree="$HOME" "$@"
}
# Empty repo (no commits yet): nothing to check out, but leave everything
# configured so the first `cfg add` on this machine just works.
if ! git --git-dir="$CFG_DIR" rev-parse --verify -q HEAD >/dev/null; then
warn "Dotfiles repo has no commits yet — checkout skipped."
warn "Add folders on your main machine first; here you can start fresh."
cfg config status.showUntrackedFiles no
ok "cfg alias ready: git --git-dir=$CFG_DIR --work-tree=$HOME"
exit 0
fi
info "Checking dotfiles out into \$HOME..."
# -f on purpose: a fresh machine HAS stock files (e.g. .bashrc); the repo's
# versions win on first setup. Untracked files are never touched.
cfg checkout -f
cfg config status.showUntrackedFiles no
ok "Dotfiles checked out. Tracked paths:"
git --git-dir="$CFG_DIR" ls-tree -r --name-only HEAD | cut -d/ -f1-2 | sort -u | sed 's/^/ /'
info "Suggested shell alias for managing them:"
info " alias cfg='git --git-dir=$CFG_DIR --work-tree=\$HOME'"
+354
View File
@@ -0,0 +1,354 @@
#!/bin/bash
#
# modules/user/nvidia.sh — install the NVIDIA driver branch that matches the
# GPU that is actually in the machine.
#
# Runs on its own:
# bash modules/user/nvidia.sh
# LSPCI_BIN=/tmp/fake-lspci bash modules/user/nvidia.sh
#
# Behaviour:
# * no NVIDIA GPU -> prints one line and exits 0
# * GPU found, SB off/setup -> installs, no signing, no MOK enrolment
# * GPU found, SB enabled -> generates + imports the akmods key, then STOPS
# and asks for a reboot. The driver is installed
# on the next run, once the key is enrolled.
# (Installing before enrolment is the documented
# way to get a black screen: RPM Fusion blacklists
# nouveau the moment the driver package lands.)
# * already installed -> skips the transaction, keeps `dnf mark user`
#
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../../lib/common.sh
source "$HERE/../../lib/common.sh"
# shellcheck source=../../lib/ui.sh
source "$HERE/../../lib/ui.sh"
if is_debug; then set +e; else set -e; fi
# Overridable so the detection logic can be tested on a host with no NVIDIA
# hardware (same pattern as OS_RELEASE_FILE / FSTAB_FILE in lib/common.sh).
LSPCI_BIN="${LSPCI_BIN:-lspci}"
MOKUTIL_BIN="${MOKUTIL_BIN:-mokutil}"
EFIVARS_DIR="${EFIVARS_DIR:-/sys/firmware/efi/efivars}"
AKMODS_CERT="${AKMODS_CERT:-/etc/pki/akmods/certs/public_key.der}"
AKMOD_COMPILE_TIMEOUT="${AKMOD_COMPILE_TIMEOUT:-300}"
step "NVIDIA driver"
rule
#--------------------------------------------------
# Detection
#--------------------------------------------------
# Prints: "<branch>|<model>|<pciid>" or nothing at all when there is no card.
#
# Branch is derived from the chip family in the name lspci prints (hwdata always
# has it): GB/AD/GA/TU -> current, GV/GP/GM -> 580xx, GK -> 470xx, GF -> 390xx.
# The numeric device id is NOT used for the decision: the id ranges overlap
# between families, and a wrong guess installs a module the card cannot use.
detect_nvidia_gpu() {
local line model arch id
line=$("$LSPCI_BIN" -nn 2>/dev/null \
| grep -iE '(VGA compatible|3D|Display) controller' \
| grep -iE 'nvidia|\[10de:' \
| head -n1) || true
[[ -z "$line" ]] && return 1
id=$(printf '%s' "$line" \
| grep -oE '\[10de:[0-9a-fA-F]{4}\]' | head -n1 | tr -d '[]') || true
# "01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102
# [GeForce RTX 3080 Ti] [10de:2208] (rev a1)" -> "GA102 [GeForce RTX 3080 Ti]"
model=$(printf '%s' "$line" \
| sed -n 's/.*NVIDIA Corporation \(.*\)\[10de:[0-9a-fA-F]\{4\}\].*/\1/p' \
| sed -e 's/[[:space:]]*$//') || true
[[ -z "$model" ]] && model="unknown NVIDIA device"
arch=$(printf '%s' "$model" | grep -oE '(GB|AD|GA|TU|GV|GP|GM|GK|GF)[0-9]{3}[A-Z]?' \
| head -n1 | grep -oE '^(GB|AD|GA|TU|GV|GP|GM|GK|GF)') || true
local branch="unknown"
case "$arch" in
GB|AD|GA|TU) branch="current" ;; # Turing and newer: open kernel module
GV|GP|GM) branch="580xx" ;; # Volta / Pascal / Maxwell (last branch)
GK) branch="470xx" ;; # Kepler, Xorg only
GF) branch="390xx" ;; # Fermi, Xorg only, EOL upstream
"")
# Pre-Fermi naming (G80/G92/GT200/GT215...) has no architecture
# prefix; those cards need the EOL 340xx branch plus a longterm
# kernel, which this script deliberately does not automate.
if printf '%s' "$model" | grep -qE '(^|[^A-Za-z])(G[0-9]{2,3}|GT[0-9]{3}|NV[0-9]{2})'; then
branch="340xx"
else
branch="unknown"
fi
;;
esac
printf '%s|%s|%s\n' "$branch" "$model" "${id:-10de:unknown}"
}
gpu_info=""
if ! command -v "$LSPCI_BIN" >/dev/null 2>&1; then
info "pciutils missing (no lspci). Installing..."
install_optional pciutils
fi
if command -v "$LSPCI_BIN" >/dev/null 2>&1; then
gpu_info=$(detect_nvidia_gpu) || true
fi
if [[ -z "$gpu_info" ]]; then
info "No NVIDIA GPU detected. Nothing to do."
exit 0
fi
BRANCH="${gpu_info%%|*}"
rest="${gpu_info#*|}"
MODEL="${rest%%|*}"
PCIID="${rest#*|}"
ok "GPU: $MODEL ($PCIID)"
# Multiple cards of different generations are not something one dnf transaction
# can serve correctly: the packages are mutually exclusive.
nv_count=$("$LSPCI_BIN" -nn 2>/dev/null \
| grep -icE '(VGA compatible|3D|Display) controller.*(nvidia|\[10de:)') || true
if [[ "${nv_count:-0}" -gt 1 ]]; then
warn "Found $nv_count NVIDIA display devices. Only the first one determines"
warn "the driver branch; mixed generations on one machine need manual work."
fi
# A 3D controller (rather than a VGA/Display controller) is the Optimus layout.
if printf '%s' "$("$LSPCI_BIN" -nn 2>/dev/null | grep -i '10de:' | head -n1)" \
| grep -q '3D controller'; then
info "Hybrid (Optimus) layout: Fedora handles the iGPU/dGPU split automatically."
fi
#--------------------------------------------------
# Branch -> packages
#--------------------------------------------------
if [[ "$BRANCH" == "unknown" ]]; then
warn "Could not identify the GPU generation from '$MODEL'."
choice=$(ui_choose "NVIDIA driver branch" "Which driver branch does this card need?" \
"current (Turing / Ampere / Ada / Blackwell)" \
"current (Turing / Ampere / Ada / Blackwell)" \
"580xx (Maxwell / Pascal / Volta)" \
"470xx (Kepler)" \
"390xx (Fermi)")
case "$choice" in
580xx*) BRANCH="580xx" ;;
470xx*) BRANCH="470xx" ;;
390xx*) BRANCH="390xx" ;;
*) BRANCH="current" ;;
esac
fi
case "$BRANCH" in
current)
PKGS=(akmod-nvidia xorg-x11-drv-nvidia-cuda)
MARK="akmod-nvidia"
BRANCH_LABEL="current (Turing/Ampere/Ada/Blackwell, open kernel module)"
;;
580xx)
PKGS=(akmod-nvidia-580xx xorg-x11-drv-nvidia-580xx xorg-x11-drv-nvidia-580xx-cuda)
MARK="akmod-nvidia-580xx"
BRANCH_LABEL="580xx (Maxwell/Pascal/Volta, last branch for those cards)"
;;
470xx)
PKGS=(akmod-nvidia-470xx xorg-x11-drv-nvidia-470xx xorg-x11-drv-nvidia-470xx-cuda)
MARK="akmod-nvidia-470xx"
BRANCH_LABEL="470xx (Kepler, Xorg only)"
;;
390xx)
PKGS=(akmod-nvidia-390xx xorg-x11-drv-nvidia-390xx xorg-x11-drv-nvidia-390xx-cuda)
MARK="akmod-nvidia-390xx"
BRANCH_LABEL="390xx (Fermi, EOL upstream, Xorg only)"
;;
340xx)
warn "This card predates Fermi. Driver 340xx is end-of-life and needs a"
warn "longterm kernel from a COPR, which this script does not automate."
warn "See https://rpmfusion.org/Howto/NVIDIA and install it by hand."
exit 0
;;
esac
info "Driver branch: $BRANCH_LABEL"
if [[ "$BRANCH" != "current" ]]; then
warn "This branch has no Wayland support. Plan on an X11 session."
fi
#--------------------------------------------------
# Secure Boot gate
#--------------------------------------------------
# enabled -> sign and enrol, then stop for a reboot
# disabled / setup mode -> no signing at all (an import request would never be
# processed: shim skips MokManager when enforcement is
# off, so the request just sits in the efivars)
sb_state() {
local out sb_var pk_var
if command -v "$MOKUTIL_BIN" >/dev/null 2>&1; then
out=$("$MOKUTIL_BIN" --sb-state 2>/dev/null) || true
case "$out" in
*"SecureBoot enabled"*) echo "enabled"; return 0 ;;
*"Setup mode"*) echo "setup"; return 0 ;;
*"SecureBoot disabled"*) echo "disabled"; return 0 ;;
esac
fi
# Fallback: read the efivars directly (byte 5 of the variable data).
if [[ -d "$EFIVARS_DIR" ]]; then
sb_var=$(ls "$EFIVARS_DIR"/SecureBoot-* 2>/dev/null | head -n1) || true
pk_var=$(ls "$EFIVARS_DIR"/PK-* 2>/dev/null | head -n1) || true
if [[ -n "$sb_var" ]]; then
if [[ "$(od -An -t u1 -j 4 -N 1 "$sb_var" 2>/dev/null | tr -d '[:space:]')" == "1" ]]; then
echo "enabled"; return 0
fi
# No Platform Key enrolled = Setup mode: nothing can be enforced.
[[ -z "$pk_var" ]] && { echo "setup"; return 0; }
echo "disabled"; return 0
fi
fi
echo "unknown"
}
SB_STATE=$(sb_state)
case "$SB_STATE" in
enabled)
info "Secure Boot is enabled: the module has to be signed and the key enrolled."
install_packages akmods
if [[ ! -f "$AKMODS_CERT" ]]; then
info "Generating the akmods signing key..."
sudo kmodgenca -a
else
ok "akmods signing key present."
fi
if sudo "$MOKUTIL_BIN" --test-key "$AKMODS_CERT" 2>&1 | grep -qi 'not enrolled'; then
warn "The akmods key is not enrolled yet. Importing it now."
sudo "$MOKUTIL_BIN" --import "$AKMODS_CERT"
printf "\n"
color_yellow "------------------------------------------------------------"
color_yellow " ACTION REQUIRED: enrolment is pending"
color_yellow "------------------------------------------------------------"
info "The driver is deliberately NOT installed yet: with Secure Boot"
info "enforcing, an unsigned module leaves you with a black screen."
printf "\n"
info "1. Reboot now."
info "2. At the boot screen press a key during the countdown."
info "3. Enroll MOK -> View key 0 (check the fingerprint) -> Continue"
info " -> Yes -> type the password you just set. The keyboard there"
info " is QWERTY, so use a short, symbol-free password."
info "4. Boot Fedora and run this setup again: it will install the"
info " driver now that the key is trusted."
printf "\n"
warn "If you miss the countdown the request is dropped silently and"
warn "you have to import again."
exit 0
fi
ok "akmods key already enrolled."
;;
setup)
ok "Secure Boot is off (platform is in Setup mode): no signing needed."
warn "Setup mode is a temporary state: a firmware update or 'Restore"
warn "Factory Keys' turns enforcement on, and this unsigned module then"
warn "stops loading at the next boot. Enrol the key before you need it:"
warn " sudo dnf install akmods && sudo kmodgenca -a"
warn " sudo mokutil --import /etc/pki/akmods/certs/public_key.der"
;;
disabled)
ok "Secure Boot is disabled: no signing needed."
warn "Keep it that way deliberately: a firmware update or 'Restore Factory"
warn "Keys' can enable enforcement, and the unsigned module then fails to"
warn "load at the next boot."
;;
*)
warn "Could not determine the Secure Boot state. Assuming it is off."
warn "If Secure Boot actually is on, the driver will not load: check"
warn "'mokutil --sb-state' and enrol the akmods key first."
;;
esac
#--------------------------------------------------
# Install
#--------------------------------------------------
if pkg_installed_any "${PKGS[@]}"; then
ok "NVIDIA driver packages already installed."
else
info "Installing: ${PKGS[*]}"
if ! install_packages "${PKGS[@]}"; then
err "Driver installation failed for branch $BRANCH."
err "Check that RPM Fusion nonfree is enabled: dnf repolist | grep rpmfusion"
exit 1
fi
fi
# An akmod is removable as an "unused dependency" unless it is marked user
# installed; `dnf autoremove` has taken the driver (and its compiler) away on
# people before.
if ! sudo dnf mark user "$MARK" >/dev/null 2>&1; then
warn "Could not mark $MARK as user-installed (dnf autoremove may remove it)."
fi
#--------------------------------------------------
# Wait for the module build
#--------------------------------------------------
# dnf returns before the kmod exists. Rebooting in that window gives a boot with
# no driver at all, because nouveau is already blacklisted by the driver package.
NEWEST_KERNEL=$(rpm -q --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' kernel-core 2>/dev/null \
| sort -V | tail -n1) || true
info "Waiting for the akmod build (up to ${AKMOD_COMPILE_TIMEOUT}s)..."
built=false
deadline=$(( SECONDS + AKMOD_COMPILE_TIMEOUT ))
while (( SECONDS < deadline )); do
if [[ -n "$NEWEST_KERNEL" ]] && modinfo -k "$NEWEST_KERNEL" -F version nvidia >/dev/null 2>&1; then
built=true; break
fi
if [[ -z "$NEWEST_KERNEL" ]] && modinfo -F version nvidia >/dev/null 2>&1; then
built=true; break
fi
sleep 5
done
if [[ "$built" == "true" ]]; then
if [[ -n "$NEWEST_KERNEL" ]]; then
VER=$(modinfo -k "$NEWEST_KERNEL" -F version nvidia 2>/dev/null) || true
else
VER=$(modinfo -F version nvidia 2>/dev/null) || true
fi
ok "Kernel module built: nvidia ${VER:-unknown}"
if [[ -n "$NEWEST_KERNEL" ]] && ! [[ "$NEWEST_KERNEL" == "$(uname -r)" ]]; then
warn "Built for $NEWEST_KERNEL but you are running $(uname -r): reboot."
fi
else
warn "The module is not built yet after ${AKMOD_COMPILE_TIMEOUT}s."
warn "Do NOT reboot until it is: the next boot would have no driver."
warn "Check the build log, then retry:"
warn " ls -l /var/cache/akmods/nvidia/"
warn " sudo akmods --force"
fi
#--------------------------------------------------
# Optional extras
#--------------------------------------------------
printf "\n"
extras=$(ui_confirm "Install the optional graphics extras (VA-API decode, vulkan)?" "no")
if [[ "$extras" == "yes" ]]; then
install_optional libva-nvidia-driver libva-utils vulkan
fi
#--------------------------------------------------
# What to check after the reboot
#--------------------------------------------------
printf "\n"
info "After the reboot, verify with:"
info " nvidia-smi"
info " cat /sys/module/nvidia_drm/parameters/modeset # expect Y"
info " lsmod | grep -Ei 'nouveau|nova_core' # expect nothing"
warn "A reboot is required before the driver is active."
Regular → Executable
+843 -230
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
# Vendored: SysGuides snapper scripts for Fedora
Source: https://github.com/SysGuides/sysguides-snapper-fedora (MIT, Madhu Desai / SysGuides)
Vendored (2026-10-27, upstream commit 0a28eb4) so setup does not depend on an
external repo at runtime. `lib/common.sh:setup_snapper` installs:
- scripts/snapper-*.sh -> /usr/local/bin/ (DNF5 pre/post snapshot actions)
- config/snapper.actions -> /etc/dnf/libdnf5-plugins/actions.d/
To update: re-fetch from upstream, diff, commit here.
+26
View File
@@ -0,0 +1,26 @@
# snapper.actions
# ----------------
# Snapper integration with libdnf5 (DNF5 transactions)
#
# This file defines hooks that:
# - Create PRE snapshots before transactions
# - Capture GUI package details
# - Create POST snapshots after transactions
# - Apply WAL checkpoint fix for consistency
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
# Create PRE snapshot
pre_transaction::::/usr/local/bin/snapper-pre.sh ${pid}
# Capture GUI package info (incoming packages)
pre_transaction:*:in::/usr/local/bin/snapper-gui-pkg.sh ${pid} ${pkg.action} ${pkg.name}
# Capture GUI package info (outgoing packages)
pre_transaction:*:out::/usr/local/bin/snapper-gui-pkg.sh ${pid} ${pkg.action} ${pkg.name}
# Create POST snapshot (with WAL fix)
post_transaction::::/usr/local/bin/snapper-post.sh ${pid}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# snapper-desc.sh
# ----------------
# Determines a human-readable description for the transaction.
#
# This script inspects the process that triggered the DNF5 transaction.
#
# Behavior:
# - If the transaction originates from GUI tools (PackageKit / dnf5daemon),
# it returns "GUI"
# - Otherwise, it returns the actual command (e.g., "dnf install vim")
#
# This description is later used by Snapper for snapshot naming.
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
PID="$1"
# Get the full command of the originating process
cmd=$(ps -o command --no-headers -p "$PID" 2>/dev/null || echo "Unknown Task")
case "$cmd" in
*/dnf5daemon* | */packagekitd*)
# GUI-based transaction
echo "GUI"
;;
*)
# CLI or unknown source
echo "$cmd"
;;
esac
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# snapper-gui-pkg.sh
# ------------------
# Captures the first package involved in a GUI-based transaction.
#
# This script:
# - Runs during DNF5 transaction events (pre_transaction hooks)
# - Detects if the transaction originated from a GUI
# - Records the first package name and action (install/remove)
#
# This allows snapshots to have meaningful descriptions like:
# "GUI install firefox"
#
# Only the first package is recorded to keep descriptions concise.
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
PID="$1"
ACTION="$2"
NAME="$3"
STATE_DIR="/run/snapper-actions"
DESC_FILE="$STATE_DIR/snapper_desc_${PID}"
PKG_FILE="$STATE_DIR/snapper_gui_${PID}"
# Read previously stored description (set in PRE phase)
desc=$(cat "$DESC_FILE" 2>/dev/null || echo "")
# Only proceed if this is a GUI transaction
[[ "$desc" != "GUI" ]] && exit 0
# Only capture the first package to avoid overwriting
[[ -f "$PKG_FILE" ]] && exit 0
# Determine action type and store description
case "$ACTION" in
I|U|D|R)
# Install / upgrade / downgrade / reinstall
echo "GUI install ${NAME}" > "$PKG_FILE"
;;
E|O)
# Erase / obsolete
echo "GUI remove ${NAME}" > "$PKG_FILE"
;;
esac
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# snapper-post.sh
# ----------------
# Creates a POST snapshot after a DNF5 transaction completes.
#
# This script:
# - Retrieves stored PRE snapshot and description
# - Improves description using GUI package info (if available)
# - Applies SQLite WAL checkpoint fix for libdnf5
# - Creates the POST snapshot linked to the PRE snapshot
# - Cleans up temporary state files
#
# The WAL checkpoint is necessary on Fedora 44+ to reduce
# inconsistencies between filesystem state and RPM database.
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
PID="$1"
STATE_DIR="/run/snapper-actions"
DESC_FILE="$STATE_DIR/snapper_desc_${PID}"
PRE_FILE="$STATE_DIR/snapper_pre_${PID}"
GUI_FILE="$STATE_DIR/snapper_gui_${PID}"
# Load stored state (if available)
desc=$(cat "$DESC_FILE" 2>/dev/null || echo "")
pre=$(cat "$PRE_FILE" 2>/dev/null || echo "")
gui_pkg=$(cat "$GUI_FILE" 2>/dev/null || echo "")
# If no PRE snapshot exists, nothing to do
[[ -z "$pre" ]] && exit 0
# If GUI package info exists, improve snapshot description
if [[ -n "$gui_pkg" ]]; then
desc="$gui_pkg"
snapper -c root modify -d "$desc" "$pre" || true
fi
# Apply WAL checkpoint (best-effort; do not fail if it doesn't succeed)
/usr/local/bin/snapper-wal-checkpoint.sh || true
# Create POST snapshot linked to PRE snapshot
snapper -c root create -c number -t post \
--pre-number "$pre" -d "$desc"
# Clean up temporary state files
rm -f "$DESC_FILE" "$PRE_FILE" "$GUI_FILE"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -e
# snapper-pre.sh
# ----------------
# Creates a PRE snapshot before a DNF5 transaction begins.
#
# This script:
# - Generates a description using snapper-desc.sh
# - Stores the description and snapshot number in /run for later use
#
# The stored data is used by the POST script to:
# - Pair snapshots correctly
# - Improve snapshot descriptions (especially for GUI installs)
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
PID="$1"
STATE_DIR="/run/snapper-actions"
# Ensure runtime directory exists with secure permissions
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
# On a fresh setup, DNF5 may fail with "filesystem error: cannot copy: packages.toml"
# if this directory is missing, leaving an orphaned PRE snapshot with no POST.
# This block ensures the directory exists before the transaction proceeds.
if [[ ! -d /usr/lib/sysimage/libdnf5 ]]; then
mkdir -p /usr/lib/sysimage/libdnf5
restorecon -q /usr/lib/sysimage/libdnf5 2>/dev/null || true
fi
# Get transaction description (GUI or CLI command)
desc=$(/usr/local/bin/snapper-desc.sh "$PID")
# Store description for use in POST phase
echo "$desc" > "$STATE_DIR/snapper_desc_${PID}"
# Create PRE snapshot and store snapshot number
pre=$(snapper -c root create -c number -t pre -p -d "$desc") || exit 1
echo "$pre" > "$STATE_DIR/snapper_pre_${PID}"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# snapper-wal-checkpoint.sh
# --------------------------
# Forces an SQLite WAL checkpoint for the RPM database used by libdnf5.
#
# Fedora 44 introduced DNF5 (libdnf5), which uses SQLite with WAL mode.
# This can lead to inconsistencies during snapper undochange, where:
# - Files are reverted correctly
# - But the RPM database still reports packages as installed
#
# This script attempts to flush WAL data to the main database file
# before the POST snapshot is created.
#
# The operation is best-effort:
# - Retries multiple times if the database is busy
# - Fails silently if unable to complete
#
# Project: sysguides-snapper-fedora
# Author: Madhu Desai (SysGuides)
# Website: https://sysguides.com
# GitHub: https://github.com/SysGuides/sysguides-snapper-fedora
python3 - <<'EOF'
import sqlite3
import time
import sys
DB = "/usr/lib/sysimage/rpm/rpmdb.sqlite"
# Try up to 10 times to checkpoint WAL (single process, no respawn overhead)
for i in range(10):
try:
conn = sqlite3.connect(DB, timeout=3)
conn.execute("PRAGMA busy_timeout=3000")
result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
conn.close()
# result[1] = number of remaining frames in WAL (0 = fully flushed)
if result and result[1] == 0:
sys.exit(0)
except sqlite3.OperationalError:
pass
time.sleep(0.5)
# Best-effort fallback
sys.exit(1)
EOF