diff --git a/README.md b/README.md index 0c77eb7..9661485 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@
+Gentoo logo + # Fiw-Gentoo-Dots **My Gentoo setup, ready to make home feel like home again.** @@ -29,7 +31,7 @@ anything is applied. - **Keep your edits:** conflict prompts, backups and `.new` proposals on updates. **Work in progress:** configuration restoration and the TUI are ready for review. -Package installation needs a supervised first run on a fresh Gentoo system. +Package and boot execution need a supervised first run on a fresh Gentoo system. The existing installation has not been changed by creating this repo. ## Start @@ -41,10 +43,12 @@ cd Fiw-Gentoo-Dots ``` The Bubble Tea interface offers a preset, package sections, individual app -configs, kernel/bootloader choices, extras, and a final preview. Enter at the -preview saves a selection without applying it. `a` restores configs; `i` -starts the package installation workflow with sudo. Go 1.24+ builds the TUI; -Python 3.11+ runs the backend. Go dependencies are recorded in go.mod/go.sum. +configs, kernel/bootloader choices, individual Flatpaks, optional services and +a final preview. Enter saves without applying. `r` runs the full restore; +`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks, +`v` enables selected services and `b` deploys the selected bootloader. +Go 1.24+ builds the TUI; Python 3.11+ runs the backend. Go dependencies are +recorded in go.mod/go.sum. Saved selections are reused on subsequent launches. For a preview without building the TUI: @@ -85,9 +89,11 @@ CachyOS 7.2.7-1 patch. Refresh it explicitly when updating the snapshot. | [tidewm](packages/tidewm.list) | Optional compositor and supporting utilities | Filelight, NVIDIA drivers and Btrfs tools are separate extras. Ext4 does not -require Btrfs tools. LocalSend is an optional Flatpak in the apps section. +require Btrfs tools. LocalSend and Sober are individually optional Flatpaks +in the TUI, grouped with apps and gaming respectively. Limine and GRUB are optional alternatives; retaining the current loader is -the default. Kernel choice is independent of bootloader choice. +the default. Kernel choice is independent of bootloader choice. Deployment +previews the target ESP and firmware choices; see [boot setup](docs/boot.md). ## Configs @@ -136,6 +142,10 @@ python3 -m unittest discover -s tests go test ./... ``` +Missing selected overlays are staged before package resolution. Optional +service presets enable audio, networking, Bluetooth and power profiles for +the next boot/login. See [setup](docs/setup.md) and the [helper list](docs/scripts.md). + Review captures before committing. `local/` and built binaries are ignored. This repo starts with fresh history. Third-party theme/cursor assets retain their licence notices; overlay ebuilds retain their original notices. diff --git a/assets/images/Arch.png b/assets/images/Arch.png new file mode 100644 index 0000000..b39010d Binary files /dev/null and b/assets/images/Arch.png differ diff --git a/assets/images/Gentoo.png b/assets/images/Gentoo.png new file mode 100644 index 0000000..4cd820a Binary files /dev/null and b/assets/images/Gentoo.png differ diff --git a/cmd/dots/main.go b/cmd/dots/main.go index a8dd413..9ba9bc8 100644 --- a/cmd/dots/main.go +++ b/cmd/dots/main.go @@ -22,17 +22,27 @@ type selection struct { Extras []string `json:"extras"` Firefox bool `json:"firefox_privacy"` Spotify bool `json:"spotify_custom"` + Flatpaks []string `json:"flatpaks"` + Services []string `json:"services"` } type config struct { Label string `json:"label"` Group string `json:"group"` } +type setupOption struct { + Label string `json:"label"` + Group string `json:"group"` + Scope string `json:"scope"` +} + type catalog struct { - Groups []string `json:"groups"` - Configs map[string]config `json:"configs"` - Extras map[string]string `json:"extras"` - Stock selection `json:"stock"` - Ryzen selection `json:"ryzen"` + Groups []string `json:"groups"` + Configs map[string]config `json:"configs"` + Extras map[string]string `json:"extras"` + Flatpaks map[string]setupOption `json:"flatpaks"` + Services map[string]setupOption `json:"services"` + Stock selection `json:"stock"` + Ryzen selection `json:"ryzen"` } type row struct { id, label string @@ -102,6 +112,21 @@ func (m *model) prepare() { m.rows = append(m.rows, row{id, m.catalog.Extras[id], contains(m.selection.Extras, id)}) } m.rows = append(m.rows, row{"autostart", "Optional app autostart", contains(m.selection.Configs, "autostart")}, row{"firefox", "Optional Firefox-Privacy setup", m.selection.Firefox}, row{"spotify", "Optional Spotify customization script (stock by default)", m.selection.Spotify}) + case 4: + flatpaks := make([]string, 0, len(m.catalog.Flatpaks)) + for id := range m.catalog.Flatpaks { + flatpaks = append(flatpaks, id) + } + sort.Strings(flatpaks) + for _, id := range flatpaks { + option := m.catalog.Flatpaks[id] + m.rows = append(m.rows, row{"flatpak:" + id, "Flatpak: " + option.Label + " (" + option.Group + ")", contains(m.selection.Flatpaks, id)}) + } + for _, id := range []string{"audio", "network", "bluetooth", "power"} { + if option, ok := m.catalog.Services[id]; ok { + m.rows = append(m.rows, row{"service:" + id, option.Label, contains(m.selection.Services, id)}) + } + } } } func (m *model) remember() { @@ -155,6 +180,19 @@ func (m *model) remember() { } } m.selection.Configs = configNames + case 4: + m.selection.Flatpaks, m.selection.Services = []string{}, []string{} + for _, r := range m.rows { + if !r.checked { + continue + } + if strings.HasPrefix(r.id, "flatpak:") { + m.selection.Flatpaks = append(m.selection.Flatpaks, strings.TrimPrefix(r.id, "flatpak:")) + } + if strings.HasPrefix(r.id, "service:") { + m.selection.Services = append(m.selection.Services, strings.TrimPrefix(r.id, "service:")) + } + } } } func (m model) makePreview() tea.Cmd { @@ -195,7 +233,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.loading { return m, nil } - if m.stage == 4 { + if m.stage == 5 { switch key { case "up", "k": if m.scroll > 0 { @@ -208,7 +246,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "esc": m.stage-- m.prepare() - case "enter", "s", "a", "i": + case "enter", "s", "a", "i", "f", "v", "b", "r": if m.errorText == "" { m.done = true m.action = key @@ -267,7 +305,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.remember() m.stage++ - if m.stage == 4 { + if m.stage == 5 { m.loading = true m.errorText = "" return m, m.makePreview() @@ -287,13 +325,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() string { const purple = "\033[38;2;157;143;217m" const reset = "\033[0m" - titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Final preview"} + titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview"} text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n" budget := m.height - 10 if budget < 5 { budget = 5 } - if m.stage == 4 { + if m.stage == 5 { if m.loading { return text + " Preparing preview…\n" } @@ -305,7 +343,7 @@ func (m model) View() string { for _, line := range lines[m.scroll:end] { text += " " + line + "\n" } - text += "\n ↑/↓ scroll · Esc back · Enter save plan · a restore configs · i install packages\n q cancel\n" + text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · q cancel\n" if m.errorText != "" { text += " " + m.errorText + "\n" } @@ -355,6 +393,14 @@ func main() { os.Exit(1) } m := model{repo: repo, catalog: c, selection: c.Stock, height: 28} + savedPath := filepath.Join(repo, "local", "selection.json") + if saved, readErr := os.ReadFile(savedPath); readErr == nil { + var previous selection + if json.Unmarshal(saved, &previous) == nil && (previous.Profile == "stock" || previous.Profile == "fiw-ryzen") { + m.selection = previous + m.stage = 1 + } + } m.prepare() result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() if err != nil { @@ -377,27 +423,75 @@ func main() { } fmt.Println(final.preview) fmt.Println("\nSaved selection:", path) - args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path} + run := func(action string, root bool) error { + args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action} + program := "python3" + if root { + program = "sudo" + args = append([]string{"python3"}, args...) + } + cmd := exec.Command(program, args...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + return cmd.Run() + } + services := func() error { + system, user := false, false + for _, id := range final.selection.Services { + if c.Services[id].Scope == "system" { + system = true + } + if c.Services[id].Scope == "user" { + user = true + } + } + if system { + if err := run("--enable-services", true); err != nil { + return err + } + } + if user { + return run("--enable-services", false) + } + if !system { + fmt.Println("No services selected.") + } + return nil + } + var actionErr error switch final.action { case "a": - args = append(args, "--apply-configs") + actionErr = run("--apply-configs", false) case "i": - args = append([]string{"python3"}, args...) - args = append(args, "--install-packages") + actionErr = run("--install-packages", true) + case "f": + actionErr = run("--install-flatpaks", false) + case "v": + actionErr = services() + case "b": + actionErr = run("--deploy-bootloader", true) + case "r": + actionErr = run("--check-configs", false) + if actionErr == nil && (len(final.selection.Groups) > 0 || len(final.selection.Extras) > 0 || final.selection.Bootloader != "keep" || len(final.selection.Flatpaks) > 0 || len(final.selection.Services) > 0) { + actionErr = run("--install-packages", true) + } + if actionErr == nil && len(final.selection.Flatpaks) > 0 { + actionErr = run("--install-flatpaks", false) + } + if actionErr == nil && len(final.selection.Services) > 0 { + actionErr = services() + } + if actionErr == nil && len(final.selection.Configs) > 0 { + actionErr = run("--apply-configs", false) + } + if actionErr == nil && final.selection.Bootloader != "keep" { + actionErr = run("--deploy-bootloader", true) + } default: - fmt.Println("Review the setup notes in docs/setup.md before applying.") + fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.") return } - program := "python3" - if final.action == "i" { - program = "sudo" - } - cmd := exec.Command(program, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) + if actionErr != nil { + fmt.Fprintln(os.Stderr, actionErr) os.Exit(1) } } diff --git a/cmd/dots/main_test.go b/cmd/dots/main_test.go index 9f7752c..50baff2 100644 --- a/cmd/dots/main_test.go +++ b/cmd/dots/main_test.go @@ -50,3 +50,19 @@ func TestConfigsSelectableWithoutPackageGroups(t *testing.T) { t.Fatal("configs were hidden or deselected by package choices") } } + +func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) { + m := model{stage: 4, catalog: catalog{ + Flatpaks: map[string]setupOption{"org.vinegarhq.Sober": {Label: "Sober", Group: "gaming"}}, + Services: map[string]setupOption{"audio": {Label: "Audio", Scope: "user"}}, + }, selection: selection{}} + m.prepare() + for i := range m.rows { + m.rows[i].checked = true + } + next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result := next.(model) + if result.stage != 5 || command == nil || !contains(result.selection.Flatpaks, "org.vinegarhq.Sober") || !contains(result.selection.Services, "audio") { + t.Fatalf("setup selections lost before preview: %+v", result.selection) + } +} diff --git a/docs/assets.md b/docs/assets.md index fc02366..bd34171 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -21,3 +21,13 @@ theme metadata and colour-scheme notices. It still scans those files for other private-data patterns. A changed fingerprint requires review before updating tools/public-attribution.json. The fingerprinted upstream kernel patch similarly retains public source/test data rather than being rewritten. + +## Distribution logos + +The user-supplied Gentoo and Arch images are kept in `assets/images`. The +Gentoo image is used in the README; the Arch image is retained as supplied. +These distribution marks are separate from this project's original MIT code. +Upstream references: [Gentoo artwork](https://www.gentoo.org/inside-gentoo/artwork/) +and [name/logo guidelines](https://www.gentoo.org/inside-gentoo/foundation/name-logo-guidelines.html); +[Arch artwork](https://archlinux.org/art/) and +[trademark policy](https://terms.archlinux.org/docs/trademark-policy/). diff --git a/docs/boot.md b/docs/boot.md index 16ee0f1..905aafa 100644 --- a/docs/boot.md +++ b/docs/boot.md @@ -1,20 +1,84 @@ -# Optional bootloader choices +# Optional bootloader deployment -`keep` is the default. `limine` and `grub` select separate package lists. -The draft does not install firmware entries or replace the active bootloader. -Target-specific deployment still needs implementation and a reviewed target -ESP/root layout. Selecting a kernel does not select a bootloader. +`keep` is the default. Limine and GRUB have separate package choices; kernel +selection remains independent. Selecting a loader does not itself deploy it. +Use the final preview's **b** action or include it in **r** full restore. -GRUB integration must use the target system's installkernel configuration -and generate that system's grub.cfg. Limine integration must derive root and -ESP identifiers on the target and produce the selected custom/generic entries. -Fiw's current dual-boot paths and UUIDs do not belong in generic templates. +## Preview and apply -The retained `optional/fiw-limine-splice` refreshes the Gentoo block in an -existing Limine configuration and preserves other entries. It is specific -to the current marker/snippet design and is not installed by the default -rice preset or by the GRUB variant. +```sh +./install --selection local/selection.json --boot-plan +sudo ./install --selection local/selection.json --deploy-bootloader +# Specify a mounted ESP when automatic discovery does not find yours: +sudo ./install --selection local/selection.json --deploy-bootloader --esp /boot/efi +``` -`optional/fiw-cachy-patch` supports the custom kernel, not a bootloader. It -generates a CachyOS patch and updates the version guard/name. It requires -root and matching upstream source releases; review before running. +Discovery checks mounted `/efi`, `/boot/efi` and `/boot` candidates for the GPT +ESP type and FAT filesystem. The plan shows the selected partition, root +filesystem, kernel arguments, installed kernel/initramfs candidates and +proposed loader config or commands. UUIDs are derived on the target and kept +in its local state; they are not shipped in the repo. + +Firmware registration has three choices: + +| Choice | Effect | +|---|---| +| keep | Leave current firmware entries and BootOrder unchanged | +| add | Create an entry without adding it to BootOrder | +| default | Register the selected loader first in BootOrder | + +The final deployment confirmation follows the target and firmware preview. +Existing matching firmware entries are reused when their partition and EFI +path match. Other entries are retained. No reboot is performed. + +## Limine + +The loader goes to `EFI/Fiw-Gentoo/BOOTX64.EFI`, with `limine.conf` beside it. +Versioned kernels and initramfs files are copied below that directory so +Limine can read them from the ESP. Custom Fiw/Cachy kernels appear first, +followed by generic kernels, including an installed binary fallback. + +A marked block contains the generated Gentoo entries. Entries outside that +block are preserved. Locally edited managed entries receive a `.new` proposal +instead of being overwritten. ESP free space is checked before copying kernel +artifacts. The protocol and paths follow the upstream +[Limine configuration reference](https://github.com/limine-bootloader/limine/blob/v12.x/CONFIG.md). + +## GRUB + +`grub-install` uses `--target=x86_64-efi`, a dedicated `Fiw-Gentoo` EFI ID, +`/boot/fiw-gentoo` as its boot directory and `--no-nvram`. Firmware changes are +handled separately by the selected registration choice. + +The generated menu lives in `/boot/fiw-gentoo/grub/grub.cfg`. A temporary output +is generated before replacing it. If the previous menu was edited locally, +the new menu becomes a `.new` proposal. Existing `/etc/default/grub` settings +are used by `grub-mkconfig`. + +## Kernel updates and backups + +Deployment installs a standalone refresh helper at +`/usr/local/libexec/fiw-dots-boot`, a systemd kernel-install hook and a +traditional Gentoo installkernel post-install hook. The active installkernel +configuration uses the compat layout and Dracut. Missing initramfs files are +generated when needed. Each refresh verifies the ESP is still mounted and +updates only the selected loader's managed menu. + +The systemd remove hook excludes the removed kernel from its generated menu. +The legacy post-install hook refreshes after additions. Deployment saves +local boot state under `/etc/fiw-gentoo-dots/boot.json`; backup files and reports +live under `/var/lib/Fiw-Gentoo-Dots`. No existing disk partition is formatted. + +Automatic deployment currently supports amd64 UEFI, GPT ESPs, plain ext4/Btrfs +roots and unsigned boot. Encrypted, UKI-only, BIOS and signing workflows can +keep their existing loader. Secure Boot being active stops unsigned deployment +before changes. Full boot execution on a fresh installation remains unverified. + +## Retained helpers + +`optional/fiw-limine-splice` is the earlier helper for Fiw's marker/snippet +layout. It is retained for manual use and is not installed by this deployment +or by the GRUB variant. The new refresh helper manages its own separate config. + +`optional/fiw-cachy-patch` refreshes the custom kernel patch; it does not select +a bootloader. See the [helper list](scripts.md) before using legacy helpers. diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..ac71254 --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,29 @@ +# Helpers and what they do + +| Helper | Purpose | How it is used | +|---|---|---| +| `install` | Builds/opens the Bubble Tea chooser, or forwards CLI options to the Python backend | Main entry point | +| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI | +| `lib/setup.py` | Stages missing overlays, installs selected user Flatpaks and enables selected service units | Called by the backend | +| `lib/boot.py` | Detects the target ESP/root, previews/deploys Limine or GRUB, refreshes kernel menus | Boot action; copied as `fiw-dots-boot` on deployment | +| `fiw-shot` | Captures a region or monitor using Spectacle, copies it to the clipboard and offers Save/Edit through a notification | Installed with KDE shortcuts; `region` or `screen` argument | +| `tools/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff | +| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts | +| `tools/check-private.py` | Scans publishable files for personal paths, identifiers and credentials; keeps fingerprinted upstream attribution | Run before committing | +| `optional/fiw-cachy-patch` | Downloads matching vanilla/CachyOS kernel sources, generates a patch and adjusts the installed guard/name config | Explicit manual root operation; not part of ordinary restoration | +| `optional/fiw-limine-splice` | Replaces the old Gentoo snippet block in an existing Limine menu and orders its entries | Explicit manual use with an ESP argument; specific to the earlier snippet design | +| `optional/spotify/spotify-patch` | Runs the retained SpotX/Spicetify customization workflow for the Gentoo Spotify client | Explicit optional user operation; stock Spotify is the default | +| `optional/spotify/spicetify-snapshotfix` | Copies missing Marketplace fragments into the captured Spotify UI snapshot format | Called by the optional customization workflow | + +The kernel refresh helper installed on a target runs with `--refresh`. Its +kernel hooks are created only when boot deployment is explicitly confirmed; +they are not enabled by the default `keep` choice. + +The older Cachy patch helper predates the snapshot guard shipped by this +repo. It does not update the repo's `snapshot.json`, checksum or version mask; +review/update those together when preparing a new supported kernel snapshot. +It is retained as a reference, not run automatically. + +For the optional Spotify scripts, see [the setup and risk note](spotify.md). +Unofficial Spotify modifications are optional and used at your own risk. +This project does not endorse bypassing paid features. diff --git a/docs/setup.md b/docs/setup.md index 8a8c7dc..e69dbba 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,83 +1,131 @@ -# Setup and current draft boundaries +# Restore the setup Start with an installed amd64 Gentoo system using systemd and a multilib -Plasma profile. This repo does not partition disks or install the stage3. -Plasma Login Manager currently requires systemd. It is the default greeter -when installing the KDE section. +Plasma profile, with Git and Python 3.11+ available. Go 1.24+ builds the TUI. +This repo does not partition disks or install a stage3. -1. Run `./install`, make selections and review the final preview. -2. Save the selection. It lives in the ignored `local/selection.json`. -3. Export for inspection if desired: - `./install --selection local/selection.json --export local/stage`. -4. Install packages via the TUI or - `sudo ./install --selection local/selection.json --install-packages`. - Resolution happens in a temporary Portage config before live files change. - Missing binaries prompt compile/skip; final installation requires confirmation. -5. Apply user configs as the target user: - `./install --selection local/selection.json --apply-configs`. - Log out of Plasma and use a TTY for KDE configs; running KDE can overwrite - edited config files. Portable app configs can be restored separately. +## Terminal interface -For updates, review the Git changes, then use the saved selection with -`--apply-configs --update`. There is no automatic git pull or upstream upgrade. -Backups and reports live under `~/.local/state/Fiw-Gentoo-Dots`. Package reports -and system backups live under `/var/lib/Fiw-Gentoo-Dots`. +Run `./install`. Choose a preset, package sections, individual configs, +kernel/bootloader options, Flatpaks and optional services. Previous selections +are loaded from the ignored `local/selection.json`; press Esc to return to +preset selection when you want to change profiles. -## Explicit setup steps +At the final preview: -Root package installation enables `plasmalogin.service` for selected KDE -installs without restarting the running greeter. After the desired packages -are installed, enable the services you use: +| Key | Action | +|---|---| +| Enter / s | Save the selection without applying it | +| r | Full restore: packages, Flatpaks, services, configs, selected bootloader | +| i | Install Portage packages | +| f | Install selected user Flatpaks | +| v | Enable selected services | +| a | Restore selected user configs | +| b | Preview and deploy the selected bootloader | + +Full restore checks the KDE session before making package changes. Log out of +Plasma and run from a TTY when KDE configs are selected. Each operation also +has a command for resuming a saved selection: ```sh -sudo systemctl enable NetworkManager bluetooth power-profiles-daemon -systemctl --user enable pipewire.socket pipewire-pulse.socket wireplumber.service +./install --selection local/selection.json --plan +sudo ./install --selection local/selection.json --install-packages +./install --selection local/selection.json --install-flatpaks +sudo ./install --selection local/selection.json --enable-services +./install --selection local/selection.json --enable-services +./install --selection local/selection.json --apply-configs +sudo ./install --selection local/selection.json --deploy-bootloader ``` -Docker stays disabled until needed. The installer does not reboot or power off. +The root service command handles system services; the regular-user command +handles PipeWire units. User Flatpaks and user configs are never applied as root. -Flatpak apps are listed separately. Add Flathub if absent, then install only -the selected categories' apps explicitly, for example: +## Packages and repositories + +Existing GURU and steam-overlay checkouts are reused. Missing overlays needed +by the selected package sections are cloned into temporary storage before +Portage resolves the plan. Their identity is checked, and they are adopted +under `/var/db/repos` only after confirmation. Existing repository checkouts +are not automatically synced or replaced. + +Additional source builds prompt **compile or skip**, with compilation +recommended. Skipping a dependency also skips requested packages needing it. +A package report records requested, skipped and missing packages. There is a +final package confirmation before live Portage configuration changes. + +Read-only resolution and an export are available separately: ```sh -flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo -flatpak install --user flathub org.localsend.localsend_app -flatpak install --user flathub org.vinegarhq.Sober +./install --selection local/selection.json --check-packages +./install --selection local/selection.json --export local/stage ``` -The independently selectable `fonts` config includes the four used styles of -JetBrainsMono Nerd Font Mono from the official Nerd Fonts v3.5.1 release, -with its OFL licence. It works with KDE and portable apps alike. The entire -personal font collection is not included. Neovim uses its built-in theme as -a fallback when Catppuccin is not installed. Vesktop's selected Midnight -theme CSS is captured with a fixed upstream snapshot and its MIT licence. +The check may download a missing overlay into its temporary root; it does not +register that checkout or change the live Portage configuration. The export +contains proposed files and a plan, not a ready-to-merge repository checkout. + +Plasma Login Manager is enabled for selected KDE installs. The installer does +not restart the running greeter, reboot or power off. + +## Flatpaks and services + +LocalSend (`fiw-apps`) and Sober (`gaming`) have individual Flatpak checkboxes. +Both start unchecked. Selecting them ensures Flatpak is in the package plan; +the separate Flatpak action adds Flathub for the user if absent and installs +only missing selected apps. Existing user installations are kept. Failures +and skips are reported. + +Audio, NetworkManager, Bluetooth and power profiles have independent service +checkboxes, also initially unchecked. Their required packages are added when +needed. Enabling a service schedules it for the next boot or user login; the +workflow does not start, stop or restart services. Missing units are reported. +Docker remains a separate choice outside this service preset. + +## Configs and updates + +Every config is optional and independent of package installation groups. +The separate fonts config includes four JetBrainsMono Nerd Font Mono styles. +Vesktop's Midnight CSS is included with a fixed upstream snapshot. See the +[KDE styling reference](kde-style.md) and [shortcut list](shortcuts.md). + +Review Git changes before pulling updates, then restore your saved selection: + +```sh +./install --selection local/selection.json --apply-configs --update +``` + +Locally edited configs remain intact; proposed changes are written to `.new` +files. Replacements receive backups. Root Portage file conflicts ask before +replacement and receive backups too. Reports and user backups live under +`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under +`/var/lib/Fiw-Gentoo-Dots`. ## Fiw tools -The `fiw-tools` section includes FiwNode's x86-64 binary release and the -released Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. -Apdatifier installs QML/scripts without compilation; any dependencies needing -source builds still go through the normal compile/skip prompt. +The `fiw-tools` section includes FiwNode's binary release and the released +Apdatifier Gentoo widget, alongside OpenDeck and Music Presence. Apdatifier +installs QML/scripts without compilation; source-only dependencies still +require the normal compile/skip choice. -FiwNode uses its default configuration. The installer does not copy sound -libraries, microphone/output selections or personal app state. Older copies -in `~/.local/bin` can shadow the Portage-installed binaries in `/usr/bin`; -remove those manually when ready to switch. The daemon starts on demand. +FiwNode uses its default config. Sound libraries and audio device selections +stay local. Copies in `~/.local/bin` can shadow `/usr/bin`; manage old copies +manually when switching to the package. Its daemon starts on demand. -Apdatifier is available through Plasma's **Add Widgets** menu. Installing it -does not alter panels. An existing user-installed widget can override the -system copy; use `kpackagetool6` to manage it. Its preferences remain local. +Add Apdatifier through Plasma's **Add Widgets** menu. Installation does not +alter panels. An existing user-installed widget can override the system copy; +manage it with `kpackagetool6`. Its preferences stay local. -## Remaining work before declaring a fresh install verified +## Current scope -- Run both presets on a fresh Gentoo test installation. Root package execution - has not been exercised on the working desktop. -- Implement target-specific Limine/GRUB deployment after reviewing the target - ESP and boot layout. Current variants select packages and provide setup notes. -- Refresh the optional TideWM live ebuild branch/dependency recipe against the - upstream development version before building it on a new machine. -- Repository sync is an explicit prerequisite when GURU or steam-overlay is - absent; the draft aborts cleanly on resolution failure rather than guessing. +Automatic boot deployment supports amd64 UEFI, a mounted GPT ESP, plain ext4 +or Btrfs roots, and unsigned boot. See [boot setup](boot.md) for previews, +firmware choices and kernel update integration. Other layouts can keep their +existing loader. -Do not publish the private original fiw-gentoo repository. It is the working -archive and has different history and host-specific files. +The optional TideWM live recipe still needs a refresh against its upstream +development branch. End-to-end package and boot execution on a fresh Gentoo +installation remains unverified; development checks used temporary homes, +staged Portage configuration and mocked system commands. + +Do not publish the private original fiw-gentoo archive. It has different +history and host-specific files. diff --git a/lib/boot.py b/lib/boot.py new file mode 100644 index 0000000..b5ee5b8 --- /dev/null +++ b/lib/boot.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Preview and deploy a separate amd64 UEFI Limine or GRUB installation.""" +import argparse +import hashlib +import json +import os +import platform +import re +import shlex +import shutil +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +STATE = Path('/etc/fiw-gentoo-dots/boot.json') +MANAGED = Path('/var/lib/Fiw-Gentoo-Dots') +BEGIN = '# >>> Fiw-Gentoo-Dots kernels' +END = '# <<< Fiw-Gentoo-Dots kernels' +EFI_DIRECTORY = 'EFI/Fiw-Gentoo' +GRUB_DIRECTORY = Path('/boot/fiw-gentoo') + + +def mount_info(path, exact=False): + result = subprocess.run(['findmnt', '--json', '--mountpoint' if exact else '--target', + str(path), '--output', 'TARGET,SOURCE,FSTYPE,UUID,OPTIONS'], + text=True, capture_output=True) + if result.returncode: + raise RuntimeError('Not a mounted filesystem: ' + str(path)) + data = json.loads(result.stdout)['filesystems'][0] + return data + + +def discover_esp(path=None): + candidates = [Path(path)] if path else [Path('/efi'), Path('/boot/efi'), Path('/boot')] + for candidate in candidates: + try: + info = mount_info(candidate, exact=True) + except RuntimeError: + continue + if info['fstype'] not in ('vfat', 'fat', 'fat32'): + continue + source = re.sub(r'\[.*\]$', '', info['source']) + device = Path(source).resolve() + partition = Path('/sys/class/block') / device.name / 'partition' + if not partition.is_file(): + continue + # Require the GPT ESP type, not an arbitrary mounted FAT data partition. + result = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTTYPE', str(device)], + text=True, capture_output=True) + esp_type = 'c12a7328f81f11d2ba4b00a0c93ec93b' # GPT ESP type, without separators + if result.returncode or result.stdout.strip().lower().replace('-', '') != esp_type: + continue + parent = (Path('/sys/class/block') / device.name).resolve().parent.name + identifier = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'PARTUUID', str(device)], text=True, capture_output=True) + return {'partuuid': identifier.stdout.strip().lower(), 'path': str(candidate.resolve()), 'device': str(device), + 'disk': '/dev/' + parent, 'partition': int(partition.read_text())} + raise RuntimeError('Select a mounted GPT EFI System Partition with --esp. No ESP was found.') + + +def kernel_images(boot=Path('/boot'), exclude=None): + found = {} + for pattern, prefix in [('vmlinuz-*', 'vmlinuz-'), ('kernel-*', 'kernel-')]: + for path in boot.glob(pattern): + version = path.name[len(prefix):] + if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude: + initrds = [boot / ('initramfs-' + version + '.img'), boot / ('initrd-' + version), + boot / ('initrd.img-' + version)] + found.setdefault(version, {'version': version, 'kernel': str(path), + 'initrd': next((str(p) for p in initrds if p.is_file()), None)}) + # systemd BLS installations store a linux/initrd pair below their entry ID. + for path in boot.glob('*/*/linux'): + version = path.parent.name + if path.is_file() and re.fullmatch(r'[A-Za-z0-9_.+-]+', version) and version != exclude: + initrd = path.parent / 'initrd' + found.setdefault(version, {'version': version, 'kernel': str(path), + 'initrd': str(initrd) if initrd.is_file() else None}) + for path in Path('/usr/lib/modules').glob('*/vmlinuz'): + version = path.parent.name + if boot == Path('/boot') and path.is_file() and version != exclude and re.fullmatch(r'[A-Za-z0-9_.+-]+', version): + initrd = boot / ('initramfs-' + version + '.img') + found.setdefault(version, {'version': version, 'kernel': str(path), + 'initrd': str(initrd) if initrd.is_file() else None}) + versions = subprocess.run(['sort', '-V'], input='\n'.join(found), text=True, + capture_output=True, check=True).stdout.splitlines()[::-1] + versions.sort(key=lambda v: not any(word in v.lower() for word in ('fiw', 'cachy'))) + return [found[v] for v in versions] + + +def kernel_cmdline(root): + config = Path('/etc/kernel/cmdline') + raw = config.read_text().strip() if config.is_file() else Path('/proc/cmdline').read_text().strip() + tokens = shlex.split(raw) + tokens = [token for token in tokens if not token.startswith(('BOOT_IMAGE=', 'initrd=', 'root='))] + if not root.get('uuid'): + raise RuntimeError('Cannot identify the target root filesystem UUID.') + tokens.insert(0, 'root=UUID=' + root['uuid']) + if root['fstype'] == 'btrfs' and not any(t.startswith('rootflags=') for t in tokens): + flags = [flag for flag in root.get('options', '').split(',') if flag.startswith('subvol=')] + if flags: + tokens.append('rootflags=' + flags[0]) + text = ' '.join(tokens) + if any(char in text for char in ('\n', '\r', '\0')): + raise RuntimeError('Invalid kernel command line.') + return text + + +def secure_boot(): + files = list(Path('/sys/firmware/efi/efivars').glob('SecureBoot-*')) + return bool(files and len(files[0].read_bytes()) > 4 and files[0].read_bytes()[4] == 1) + + +def limine_loader(): + for path in [Path('/usr/share/limine/BOOTX64.EFI'), Path('/usr/share/limine/limine-uefi-cd.bin')]: + # A CD image is not an EFI executable; only actual PE EFI files qualify. + if path.is_file() and path.open('rb').read(2) == b'MZ': + return path + for path in Path('/usr/share/limine').rglob('BOOTX64.EFI'): + if path.is_file(): + return path + raise RuntimeError('Limine BOOTX64.EFI is missing; install sys-boot/limine with uefi-x86-64.') + + +def render_limine(images, cmdline): + lines = [BEGIN] + for image in images: + version = image['version'] + label = 'custom' if any(word in version.lower() for word in ('fiw', 'cachy')) else 'generic' + lines += [f'/Gentoo {version} ({label})', ' protocol: linux', + f' path: boot():/{EFI_DIRECTORY}/kernels/{version}/vmlinuz', + f' module_path: boot():/{EFI_DIRECTORY}/kernels/{version}/initramfs.img', + ' cmdline: ' + cmdline, ''] + return '\n'.join(lines + [END]) + '\n' + + +def managed_block(text): + if text.count(BEGIN) != text.count(END) or text.count(BEGIN) > 1: + raise RuntimeError('Malformed managed Limine block; preserving the config.') + if BEGIN not in text: + return None + start = text.index(BEGIN) + end = text.index(END, start) + len(END) + return text[start:end] + '\n' + + +def merge_limine(text, block): + old = managed_block(text) + if old is None: + return text.rstrip() + '\n\n' + block if text.strip() else 'timeout: 5\n\n' + block + return text.replace(old.rstrip('\n'), block.rstrip('\n'), 1) + + +def file_hash(path): + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def block_hash(block): + return hashlib.sha256(block.encode()).hexdigest() if block else None + + +def install_config(old): + values = {'layout': 'compat', 'initrd_generator': 'dracut', 'uki_generator': 'none'} + lines, seen = [], set() + for line in old.splitlines(): + key = line.split('=', 1)[0].strip() + if key in values: + if key not in seen: + lines.append(key + '=' + values[key]) + seen.add(key) + else: + lines.append(line) + lines += [key + '=' + value for key, value in values.items() if key not in seen] + return '\n'.join(lines) + '\n' + + +def plan(mode, esp=None, exclude=None): + if mode == 'keep': + return {'mode': 'keep', 'changes': []} + if mode not in ('limine', 'grub'): + raise ValueError('Unknown bootloader') + if platform.machine() not in ('x86_64', 'amd64') or not Path('/sys/firmware/efi').is_dir(): + raise RuntimeError('Automatic boot deployment supports amd64 UEFI installations.') + root = mount_info('/') + if root['fstype'] not in ('ext4', 'btrfs') or root['source'].startswith('/dev/mapper/'): + raise RuntimeError('Automatic boot deployment currently supports plain ext4/Btrfs roots. Keep the existing loader for encrypted or other layouts.') + source = re.sub(r'\[.*\]$', '', root['source']) + device_type = subprocess.run(['lsblk', '--noheadings', '--nodeps', '--output', 'TYPE', source], text=True, capture_output=True) + if device_type.returncode or device_type.stdout.strip() not in ('part', 'disk'): + raise RuntimeError('Automatic boot deployment needs a plain disk/partition root; keep the existing loader for this storage layout.') + target = discover_esp(esp) + images = kernel_images(exclude=exclude) + if not images: + raise RuntimeError('No installed kernel/initramfs candidates found under /boot. Install a distribution kernel first.') + text = kernel_cmdline(root) + result = {'mode': mode, 'esp': target, 'root_filesystem': root['fstype'], + 'cmdline': text, 'images': images, 'secure_boot': secure_boot(), + 'firmware': 'preserve current entries/order unless explicitly selected', + 'kernel_refresh_hook': '/etc/kernel/install.d/96-fiw-dots-boot.install'} + if mode == 'limine': + result['loader'] = str(limine_loader()) + result['config'] = str(Path(target['path']) / EFI_DIRECTORY / 'limine.conf') + result['proposed_entries'] = render_limine(images, text) + else: + result['config'] = str(GRUB_DIRECTORY / 'grub/grub.cfg') + result['commands'] = [['grub-install', '--target=x86_64-efi', '--efi-directory=' + target['path'], + '--boot-directory=' + str(GRUB_DIRECTORY), '--bootloader-id=Fiw-Gentoo', '--no-nvram'], + ['grub-mkconfig', '-o', result['config']]] + prior = Path('/etc/kernel/install.conf').read_text() if Path('/etc/kernel/install.conf').is_file() else '' + result['installkernel_config'] = install_config(prior) + for image in images: + if not image['initrd']: + image['generate_initrd'] = '/boot/initramfs-' + image['version'] + '.img' + return result + + +def backup(path, root): + if path.exists() or path.is_symlink(): + target = root / str(path.absolute()).lstrip('/') + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target, follow_symlinks=False) + + +def write(path, content, backups, mode=0o644): + backup(path, backups) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name('.' + path.name + '.fiw-dots-tmp') + tmp.write_text(content) + tmp.chmod(mode) + tmp.replace(path) + + +def refresh(saved, backups, exclude=None): + # Recheck the mount every time; never write into an unmounted ESP directory. + target = discover_esp(Path(saved['esp'])) + images = kernel_images(exclude=exclude) + if not images: + raise RuntimeError('No remaining kernels; preserving the boot menu.') + for image in images: + if not image['initrd']: + if not shutil.which('dracut'): + raise RuntimeError('Missing initramfs for ' + image['version'] + '; install sys-kernel/dracut.') + path = Path('/boot/initramfs-' + image['version'] + '.img') + subprocess.run(['dracut', '--force', str(path), image['version']], check=True) + image['initrd'] = str(path) + if saved['mode'] == 'grub': + config = GRUB_DIRECTORY / 'grub/grub.cfg' + old_hash = file_hash(config) + output = config + if old_hash and old_hash != saved.get('config_hash'): + output = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')) + else: + backup(config, backups) + output.parent.mkdir(parents=True, exist_ok=True) + for image in images: + source = Path(image['kernel']) + if source.parent != Path('/boot'): + dest = Path('/boot') / ('kernel-' + image['version']) + if file_hash(dest) != file_hash(source): + backup(dest, backups) + shutil.copy2(source, dest) + source = Path(image['initrd']) + dest = Path('/boot') / ('initramfs-' + image['version'] + '.img') + if source != dest and not dest.exists(): + shutil.copy2(source, dest) + scratch = output.with_name('.' + output.name + '.tmp') + try: + subprocess.run(['grub-mkconfig', '-o', str(scratch)], check=True) + scratch.replace(output) + finally: + if scratch.exists(): + scratch.unlink() + if output != config: + print('Preserved edited GRUB config; review ' + str(output)) + else: + saved['config_hash'] = file_hash(config) + return saved + folder = Path(target['path']) / EFI_DIRECTORY + required = sum(Path(source).stat().st_size for image in images + for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')] + if file_hash(folder / 'kernels' / image['version'] / name) != file_hash(Path(source))) + if shutil.disk_usage(target['path']).free < required + 16 * 1024 * 1024: + raise RuntimeError('Not enough free space on the ESP for the selected kernels and initramfs files.') + for image in images: + directory = folder / 'kernels' / image['version'] + directory.mkdir(parents=True, exist_ok=True) + for source, name in [(image['kernel'], 'vmlinuz'), (image['initrd'], 'initramfs.img')]: + dest = directory / name + if file_hash(dest) != file_hash(Path(source)): + backup(dest, backups) + scratch = dest.with_name('.' + name + '.tmp') + shutil.copy2(source, scratch) + scratch.replace(dest) + config = folder / 'limine.conf' + old = config.read_text() if config.is_file() else '' + prior = managed_block(old) + block = render_limine(images, saved['cmdline']) + output = merge_limine(old, block) + if prior and block_hash(prior) != saved.get('block_hash'): + proposal = config.with_name(config.name + '.new.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')) + write(proposal, output, backups) + print('Preserved edited Limine entries; review ' + str(proposal)) + elif output != old: + write(config, output, backups) + saved['block_hash'] = block_hash(block) + return saved + + +def confirm(prompt, choices, default): + if not sys.stdin.isatty(): + raise RuntimeError('Interactive confirmation required: ' + prompt) + while True: + value = input(prompt + ' [' + '/'.join(choices) + '] (default ' + default + '): ').strip().lower() or default + if value in choices: + return value + + +def firmware_commands(target, mode, loader_path, choice, listing): + if choice == 'keep': + return [] + label = 'Fiw-Gentoo-' + mode + matches = [] + for line in listing.splitlines(): + match = re.match(r'^Boot([0-9A-Fa-f]{4})\*?\s+' + re.escape(label) + r'\s', line) + if match and target.get('partuuid') and target['partuuid'] in line.lower() and loader_path.lower().replace('/', '\\') in line.lower(): + matches.append(match.group(1)) + if matches: + if choice == 'add': + return [] + order = re.search(r'^BootOrder:\s*(.*)$', listing, re.M) + sequence = [matches[0]] + [item for item in (order.group(1).split(',') if order else []) + if item.upper() != matches[0].upper()] + return [['efibootmgr', '--bootorder', ','.join(sequence)]] + return [['efibootmgr', '--create' if choice == 'default' else '--create-only', + '--disk', target['disk'], '--part', str(target['partition']), + '--label', label, '--loader', loader_path.replace('/', '\\')]] + + +def deploy(mode, esp=None, ask=confirm): + if mode == 'keep': + print('Keeping the current bootloader.') + return + if os.geteuid() != 0: + raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection .') + proposed = plan(mode, esp) + if proposed['secure_boot']: + raise RuntimeError('Secure Boot is active. This unsigned deployment requires a separately configured signing setup.') + for command in (['grub-install', 'grub-mkconfig'] if mode == 'grub' else []): + if not shutil.which(command): + raise RuntimeError('Missing command: ' + command) + if any(not image['initrd'] for image in proposed['images']) and not shutil.which('dracut'): + raise RuntimeError('Missing initramfs generator: dracut') + print(json.dumps(proposed, indent=2)) + firmware = ask('UEFI entry: preserve current order, add a menu entry, or make this loader default?', + ['keep', 'add', 'default'], 'keep') + if firmware != 'keep' and not shutil.which('efibootmgr'): + raise RuntimeError('Install sys-boot/efibootmgr before registering a firmware entry.') + loader_path = '/EFI/Fiw-Gentoo/' + ('BOOTX64.EFI' if mode == 'limine' else 'grubx64.efi') + listing = subprocess.run(['efibootmgr', '--verbose'], text=True, capture_output=True, check=True).stdout if firmware != 'keep' else '' + commands = firmware_commands(proposed['esp'], mode, loader_path, firmware, listing) + if commands: + print('Proposed firmware commands: ' + json.dumps(commands)) + if ask('Deploy the previewed bootloader and kernel refresh hook?', ['deploy', 'cancel'], 'cancel') != 'deploy': + return + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + backups = MANAGED / 'backups' / ('boot-' + stamp) + folder = Path(proposed['esp']['path']) / EFI_DIRECTORY + if folder.exists(): + shutil.copytree(folder, backups / 'efi-directory', symlinks=True) + if GRUB_DIRECTORY.exists() and mode == 'grub': + shutil.copytree(GRUB_DIRECTORY, backups / 'grub-directory', symlinks=True) + prior = json.loads(STATE.read_text()) if STATE.is_file() else {} + saved = {'mode': mode, 'esp': proposed['esp']['path'], 'cmdline': proposed['cmdline']} + if prior.get('mode') == mode and prior.get('esp') == saved['esp']: + saved.update({key: prior[key] for key in ('config_hash', 'block_hash') if key in prior}) + folder.mkdir(parents=True, exist_ok=True) + if mode == 'limine': + loader = folder / 'BOOTX64.EFI' + backup(loader, backups) + shutil.copy2(proposed['loader'], loader) + loader_path = '/EFI/Fiw-Gentoo/BOOTX64.EFI' + else: + subprocess.run(proposed['commands'][0], check=True) + loader_path = '/EFI/Fiw-Gentoo/grubx64.efi' + saved = refresh(saved, backups) + # Native installkernel provides versioned /boot kernel/initrd files; our + # final hook refreshes only the chosen loader's own configuration. + write(Path('/etc/kernel/install.conf'), proposed['installkernel_config'], backups) + script = Path('/usr/local/libexec/fiw-dots-boot') + backup(script, backups) + script.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(Path(__file__), script) + script.chmod(0o755) + hook = '#!/bin/sh\ncase "$1" in\n add) exec /usr/local/libexec/fiw-dots-boot --refresh ;;\n remove) exec /usr/local/libexec/fiw-dots-boot --refresh --exclude-version "$2" ;;\nesac\n' + write(Path('/etc/kernel/install.d/96-fiw-dots-boot.install'), hook, backups, 0o755) + # Traditional Gentoo installkernel uses postinst.d rather than install.d. + legacy = '#!/bin/sh\nexec /usr/local/libexec/fiw-dots-boot --refresh\n' + write(Path('/etc/kernel/postinst.d/96-fiw-dots-boot'), legacy, backups, 0o755) + write(STATE, json.dumps(saved, indent=2) + '\n', backups, 0o600) + for command in commands: + subprocess.run(command, check=True) + report = MANAGED / ('boot-' + stamp + '.json') + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware, + 'backups': str(backups)}, indent=2) + '\n') + print('Boot deployment finished. Report: ' + str(report)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--refresh', action='store_true') + parser.add_argument('--exclude-version') + args = parser.parse_args() + if not args.refresh: + parser.error('Use ./install --boot-plan or --deploy-bootloader for initial setup.') + if os.geteuid() != 0: + raise RuntimeError('Kernel refresh requires root.') + if not STATE.is_file(): + return + saved = json.loads(STATE.read_text()) + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + backups = MANAGED / 'backups' / ('boot-refresh-' + stamp) + updated = refresh(saved, backups, args.exclude_version) + write(STATE, json.dumps(updated, indent=2) + '\n', backups, 0o600) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error: + print('Boot refresh: ' + str(error), file=sys.stderr) + sys.exit(1) diff --git a/lib/rice.py b/lib/rice.py index d278adf..9075202 100644 --- a/lib/rice.py +++ b/lib/rice.py @@ -14,7 +14,11 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / 'tools')) +sys.path.insert(0, str(REPO / 'lib')) from capture import sections +from setup import (FLATPAKS, SERVICES, required_repositories, + existing_repository, repository_config, stage_repositories, + publish_repositories, install_flatpaks, enable_services) GROUPS = ['system', 'kde', 'fiw-apps', 'cli', 'dev', 'gaming', 'fiw-tools', 'tidewm'] EXTRAS = {'filelight': 'Filelight', 'btrfs': 'Btrfs tools', 'nvidia': 'NVIDIA drivers'} @@ -29,8 +33,14 @@ def load_selection(path=None, profile='stock'): selection = json.loads((Path(path) if path else REPO / 'presets' / (profile + '.json')).read_text()) if selection.get('profile') not in ('stock', 'fiw-ryzen'): raise ValueError('Unknown preset') - for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS)]: - if any(x not in allowed for x in selection.get(field, [])): + for field in ('flatpaks', 'services'): + if selection.get(field) is None: + selection[field] = [] + for field, allowed in [('groups', GROUPS), ('configs', CATALOG), ('extras', EXTRAS), + ('flatpaks', FLATPAKS), ('services', SERVICES)]: + if not isinstance(selection.get(field, []), list): + raise ValueError('Selection must be a list: ' + field) + if any(not isinstance(x, str) or x not in allowed for x in selection.get(field, [])): raise ValueError('Unknown selection in ' + field) if selection.get('kernel') not in ('binary', 'custom'): raise ValueError('Unknown kernel choice') @@ -54,6 +64,17 @@ def package_map(selection): result[extra] = atoms(REPO / 'packages/optional' / (extra + '.list')) if selection['bootloader'] != 'keep': result['boot-' + selection['bootloader']] = atoms(REPO / 'packages/optional' / (selection['bootloader'] + '.list')) + support = [] + if selection.get('flatpaks'): + support.append('sys-apps/flatpak') + for key in selection.get('services', []): + support += SERVICES[key]['packages'] + if selection['bootloader'] != 'keep': + support += ['sys-kernel/installkernel', 'sys-kernel/dracut', 'sys-boot/efibootmgr'] + selected = {atom.split('::')[0] for packages in result.values() for atom in packages} + support = [atom for atom in dict.fromkeys(support) if atom not in selected] + if support: + result['setup'] = support return result @@ -153,11 +174,16 @@ def active_plasma(home): return result.returncode == 0 +def check_configs(selection, home): + entries = list(config_entries(selection)) + if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home.resolve()): + raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.') + + def apply_configs(selection, home, update=False, conflict='ask'): home = home.resolve() entries = list(config_entries(selection)) - if any(CATALOG[name].get('kde') for name, _, _, _ in entries) and active_plasma(home): - raise RuntimeError('Log out of Plasma and apply from a TTY to prevent KDE overwriting restored settings. Portable app configs can be applied separately.') + check_configs(selection, home) state_dir = home / '.local/state/Fiw-Gentoo-Dots' index_path = state_dir / 'managed.json' index = json.loads(index_path.read_text()) if index_path.is_file() else {} @@ -255,8 +281,9 @@ def root_files(selection, repo_location=None): result['etc/portage/fiw-dots.conf'] += '\n# Persist the selected binary/source policy for later emerge operations.\n' result['etc/portage/fiw-dots.conf'] += 'EMERGE_DEFAULT_OPTS="${EMERGE_DEFAULT_OPTS} --getbinpkg ' + ' '.join('--usepkg-exclude=' + atom for atom in source_policy(selection)) + '"\n' result['etc/portage/repos.conf/fiw-dots.conf'] = '[fiw-dots]\nlocation = ' + str(repo_location or REPO / 'overlay') + '\npriority = 40\nauto-sync = no\n' - for name, uri in [('guru', 'https://github.com/gentoo-mirror/guru.git'), ('steam-overlay', 'https://github.com/anyc/steam-overlay.git')]: - result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = '[' + name + ']\nlocation = /var/db/repos/' + name + '\nsync-type = git\nsync-uri = ' + uri + '\nsync-depth = 1\npriority = 10\n' + for name in required_repositories(selection): + if not existing_repository(name): + result['etc/portage/repos.conf/fiw-dots-' + name + '.conf'] = repository_config(name, '/var/db/repos/' + name) arch = 'x86-64-v3' if selection['profile'] == 'fiw-ryzen' else 'x86-64' result['etc/portage/binrepos.conf/fiw-dots.conf'] = '[gentoo]\npriority = 1\nsync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/' + arch + '/\nlocation = /var/cache/binhost/gentoo\nverify-signature = true\n' if selection['kernel'] == 'custom' and 'system' in selection['groups']: @@ -265,6 +292,14 @@ def root_files(selection, repo_location=None): result['etc/portage/package.accept_keywords/fiw-dots-kernel'] = 'sys-kernel/gentoo-kernel ~amd64\nsys-kernel/gentoo-kernel-bin ~amd64\nvirtual/dist-kernel ~amd64\n' result['etc/portage/env/sys-kernel/gentoo-kernel'] = (REPO / 'optional/kernel/guard').read_text() result['etc/portage/package.mask/fiw-dots-custom-kernel'] = '# Tested custom-kernel snapshot; update snapshot and patch explicitly.\n>sys-kernel/gentoo-kernel-7.2.8\n' + if selection['bootloader'] != 'keep': + result['etc/portage/package.use/fiw-dots-boot'] = ('sys-kernel/installkernel systemd dracut -grub -uki -ukify -ugrd -efistub -refind -systemd-boot\n') + if selection['bootloader'] == 'grub': + result['etc/portage/env/fiw-dots-grub.conf'] = 'GRUB_PLATFORMS="efi-64"\n' + result['etc/portage/package.env/fiw-dots-grub'] = 'sys-boot/grub fiw-dots-grub.conf\n' + else: + result['etc/portage/package.accept_keywords/fiw-dots-limine'] = 'sys-boot/limine ~amd64\n' + result['etc/portage/package.use/fiw-dots-limine'] = 'sys-boot/limine uefi-x86-64 -bios -bios-cd -bios-pxe -uefi-cd -uefi-ia32 -uefi-aarch64 -uefi-riscv64 -uefi-loongarch64\n' if 'tidewm' in selection['groups']: result['etc/portage/package.accept_keywords/fiw-dots-tidewm'] = 'gui-wm/tidewm::fiw-dots **\n' return result @@ -275,7 +310,11 @@ def preview(selection): lines = ["Fiw-Gentoo-Dots — " + selection['name'], ''] for group, values in packages.items(): lines += [group + ' (' + str(len(values)) + ')', ' ' + ', '.join(values)] - lines += ['', 'Compile deliberately: ' + ', '.join(source_policy(selection)), + lines += ['', 'Repositories: ' + (', '.join(required_repositories(selection)) or 'Gentoo + fiw-dots only'), + 'Missing selected overlays are staged before resolution and adopted after confirmation.', + 'Flatpaks (user): ' + (', '.join(selection.get('flatpaks', [])) or 'none'), + 'Optional services: ' + (', '.join(selection.get('services', [])) or 'none'), + 'Compile deliberately: ' + ', '.join(source_policy(selection)), 'Other packages: prefer binaries; ask compile/skip for additional source builds.', 'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''), 'Bootloader: ' + selection['bootloader'], '', 'Selected configs:'] @@ -286,7 +325,7 @@ def preview(selection): 'Existing config conflicts: ask; updates preserve edits as .new.'] if selection['bootloader'] != 'keep': - lines.append('Bootloader package selected; ESP/firmware deployment is a separate explicit setup step (docs/boot.md).') + lines.append('Boot deployment: target ESP and boot files are previewed before applying; firmware changes are separately selectable (docs/boot.md).') if selection.get('firefox_privacy'): lines.append('Optional manual setup: Firefox-Privacy (docs/firefox.md).') if selection.get('spotify_custom'): @@ -354,6 +393,7 @@ def install_packages(selection): profile.unlink() profile.symlink_to(Path('/etc/portage/make.profile').resolve()) export(selection, stage) + fetched = stage_repositories(selection, stage) make_conf = stage / 'etc/portage/make.conf' make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n') # Repository checkout is local and read-only for resolution. No live @@ -392,6 +432,7 @@ def install_packages(selection): return if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply': return + publish_repositories(fetched) # Back up each existing managed destination before replacing it. stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') backup_root = Path('/var/lib/Fiw-Gentoo-Dots/backups') / stamp @@ -456,7 +497,7 @@ def install_packages(selection): raise RuntimeError('Some packages did not install; consult the package report and emerge output.') if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing: subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True) - print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Flatpaks and boot deployment are explicit follow-up steps; see docs/setup.md.') + print('Portage installation finished. Plasma Login Manager is enabled for selected KDE installs; no running greeter was restarted. Use the selected Flatpak, service and boot actions or the full restore workflow to finish setup.') def check_packages(selection): @@ -470,6 +511,7 @@ def check_packages(selection): profile.unlink() profile.symlink_to(Path('/etc/portage/make.profile').resolve()) export(selection, stage) + fetched = stage_repositories(selection, stage) make_conf = stage / 'etc/portage/make.conf' make_conf.write_text(make_conf.read_text() + '\nsource "' + str(stage / 'etc/portage/fiw-dots.conf') + '"\n') result = resolve(stage, selection, requested) @@ -489,18 +531,28 @@ def main(): parser.add_argument('--catalog', action='store_true') parser.add_argument('--export', type=Path) parser.add_argument('--apply-configs', action='store_true') + parser.add_argument('--check-configs', action='store_true') parser.add_argument('--install-packages', action='store_true') parser.add_argument('--check-packages', action='store_true') + parser.add_argument('--install-flatpaks', action='store_true') + parser.add_argument('--enable-services', action='store_true') + parser.add_argument('--deploy-bootloader', action='store_true') + parser.add_argument('--boot-plan', action='store_true') + parser.add_argument('--esp', type=Path) parser.add_argument('--update', action='store_true') parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask') args = parser.parse_args() if args.catalog: print(json.dumps({'groups': GROUPS, 'configs': CATALOG, 'extras': EXTRAS, + 'flatpaks': FLATPAKS, 'services': SERVICES, 'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')})) return selection = load_selection(args.selection, args.profile) if args.export: export(selection, args.export) + elif args.check_configs: + check_configs(selection, args.home) + print('Config restoration preflight passed.') elif args.apply_configs: if os.geteuid() == 0: raise RuntimeError('Apply user configs as the target user, not as root.') @@ -509,6 +561,16 @@ def main(): install_packages(selection) elif args.check_packages: check_packages(selection) + elif args.install_flatpaks: + install_flatpaks(selection, ask) + elif args.enable_services: + enable_services(selection, ask) + elif args.deploy_bootloader or args.boot_plan: + from boot import deploy, plan + if args.boot_plan: + print(json.dumps(plan(selection['bootloader'], args.esp), indent=2)) + else: + deploy(selection['bootloader'], args.esp, ask) else: print(preview(selection)) diff --git a/lib/setup.py b/lib/setup.py new file mode 100644 index 0000000..990db99 --- /dev/null +++ b/lib/setup.py @@ -0,0 +1,174 @@ +"""Optional repositories, Flatpaks and services for the restoration workflow.""" +import json +import os +import re +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +REPOSITORIES = { + 'guru': 'https://github.com/gentoo-mirror/guru.git', + 'steam-overlay': 'https://github.com/anyc/steam-overlay.git', +} +FLATPAKS = {} +LABELS = {'org.localsend.localsend_app': 'LocalSend', 'org.vinegarhq.Sober': 'Sober'} +for list_file in sorted((Path(__file__).resolve().parents[1] / 'flatpaks').glob('*.list')): + for line in list_file.read_text().splitlines(): + app = line.strip() + if not app or app.startswith('#'): + continue + if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]*', app) or app.count('.') < 2: + raise ValueError('Invalid Flatpak ID in ' + list_file.name) + FLATPAKS[app] = {'label': LABELS.get(app, app), 'group': list_file.stem} +SERVICES = { + 'audio': {'label': 'PipeWire audio (user)', 'scope': 'user', + 'units': ['pipewire.socket', 'pipewire-pulse.socket', 'wireplumber.service'], + 'packages': ['media-video/pipewire', 'media-video/wireplumber']}, + 'network': {'label': 'NetworkManager (system)', 'scope': 'system', + 'units': ['NetworkManager.service'], 'packages': ['net-misc/networkmanager']}, + 'bluetooth': {'label': 'Bluetooth (system)', 'scope': 'system', + 'units': ['bluetooth.service'], 'packages': ['net-wireless/bluez']}, + 'power': {'label': 'Power profiles (system)', 'scope': 'system', + 'units': ['power-profiles-daemon.service'], 'packages': ['sys-power/power-profiles-daemon']}, +} + + +def required_repositories(selection): + names = [] + if any(g in selection['groups'] for g in ('fiw-apps', 'gaming')): + names.append('guru') + if 'gaming' in selection['groups']: + names.append('steam-overlay') + return names + + +def existing_repository(name): + if not shutil.which('portageq'): + return None + result = subprocess.run(['portageq', 'get_repo_path', '/', name], text=True, + capture_output=True) + if result.returncode or not result.stdout.strip(): + return None + path = Path(result.stdout.strip()) + if (path / 'profiles/repo_name').is_file(): + return path + return None + + +def repository_config(name, location): + return (f'[{name}]\nlocation = {location}\nsync-type = git\n' + f'sync-uri = {REPOSITORIES[name]}\nsync-depth = 1\npriority = 10\n') + + +def stage_repositories(selection, stage): + """Fetch missing selected repositories into the temporary resolution root.""" + fetched = {} + for name in required_repositories(selection): + if existing_repository(name): + continue + if not shutil.which('git'): + raise RuntimeError('Repository preparation needs Git. Install dev-vcs/git first.') + path = stage / 'var/db/repos' / name + path.parent.mkdir(parents=True, exist_ok=True) + print('Preparing missing repository in temporary storage: ' + name, flush=True) + subprocess.run(['git', 'clone', '--depth=1', REPOSITORIES[name], str(path)], check=True) + if (path / 'profiles/repo_name').read_text().strip() != name: + raise RuntimeError('Unexpected repository identity for ' + name) + config = stage / 'etc/portage/repos.conf' / ('fiw-dots-' + name + '.conf') + config.parent.mkdir(parents=True, exist_ok=True) + config.write_text(repository_config(name, path)) + fetched[name] = path + return fetched + + +def publish_repositories(fetched): + """Adopt verified checkouts only after the installation confirmation.""" + for name, source in fetched.items(): + target = Path('/var/db/repos') / name + if target.exists(): + raise RuntimeError('Repository destination already exists; preserving it: ' + str(target)) + target.parent.mkdir(parents=True, exist_ok=True) + scratch = target.with_name('.fiw-dots-' + name + '-' + str(os.getpid())) + try: + shutil.copytree(source, scratch, symlinks=True) + scratch.rename(target) + finally: + if scratch.exists(): + shutil.rmtree(scratch) + + +def write_report(kind, report, system=False): + state = Path('/var/lib/Fiw-Gentoo-Dots') if system else Path.home() / '.local/state/Fiw-Gentoo-Dots' + state.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + path = state / (kind + '-' + stamp + '.json') + path.write_text(json.dumps(report, indent=2) + '\n') + print('Report: ' + str(path)) + return path + + +def install_flatpaks(selection, ask): + if os.geteuid() == 0: + raise RuntimeError('Install user Flatpaks as the target user, not root.') + apps = selection.get('flatpaks', []) + if not apps: + print('No Flatpaks selected.') + return + if not shutil.which('flatpak'): + raise RuntimeError('Flatpak is missing. Install sys-apps/flatpak or select it through the package workflow.') + missing = [app for app in apps if subprocess.run( + ['flatpak', 'info', '--user', app], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode] + report = {'selected': apps, 'already_installed': [app for app in apps if app not in missing], + 'installed': [], 'failed': [], 'skipped': []} + print('User Flatpaks from Flathub: ' + (', '.join(missing) or 'all selected apps already installed')) + if missing and ask('Install these selected Flatpaks for your user?', ['install', 'skip'], 'install') == 'skip': + report['skipped'] = missing + elif missing: + remote = subprocess.run(['flatpak', 'remote-add', '--user', '--if-not-exists', + 'flathub', 'https://flathub.org/repo/flathub.flatpakrepo']) + if remote.returncode: + report['failed'] = missing + else: + for app in missing: + result = subprocess.run(['flatpak', 'install', '--user', '--noninteractive', + '--assumeyes', 'flathub', app]) + present = subprocess.run(['flatpak', 'info', '--user', app], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + report['installed' if not result.returncode and not present.returncode else 'failed'].append(app) + write_report('flatpaks', report) + print('Missing Flatpaks: ' + (', '.join(report['failed'] + report['skipped']) or 'none')) + if report['failed']: + raise RuntimeError('Some Flatpaks did not install; see the report.') + + +def enable_services(selection, ask): + system = os.geteuid() == 0 + scope = 'system' if system else 'user' + units = list(dict.fromkeys(unit for key in selection.get('services', []) + if SERVICES[key]['scope'] == scope for unit in SERVICES[key]['units'])) + if not units: + print('No ' + scope + ' services selected.') + return + command = ['systemctl'] + ([] if system else ['--user']) + report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []} + available = [] + for unit in units: + result = subprocess.run(command + ['show', '--property=LoadState', '--value', unit], + text=True, capture_output=True) + if result.returncode or result.stdout.strip() != 'loaded': + report['missing'].append(unit) + else: + available.append(unit) + print('Enable at next boot/login: ' + (', '.join(available) or 'none')) + if available and ask('Enable the selected ' + scope + ' services?', ['enable', 'skip'], 'enable') == 'skip': + report['skipped'] = available + else: + for unit in available: + result = subprocess.run(command + ['enable', unit]) + report['enabled' if not result.returncode else 'failed'].append(unit) + write_report('services', report, system) + print('Missing service units: ' + (', '.join(report['missing']) or 'none')) + if report['failed']: + raise RuntimeError('Some services could not be enabled; see the report.') diff --git a/presets/fiw-ryzen.json b/presets/fiw-ryzen.json index 81255a0..256b560 100644 --- a/presets/fiw-ryzen.json +++ b/presets/fiw-ryzen.json @@ -31,5 +31,7 @@ "btrfs" ], "firefox_privacy": false, - "spotify_custom": false + "spotify_custom": false, + "flatpaks": [], + "services": [] } diff --git a/presets/stock.json b/presets/stock.json index 9c93ed5..963a10f 100644 --- a/presets/stock.json +++ b/presets/stock.json @@ -26,5 +26,7 @@ "bootloader": "keep", "extras": [], "firefox_privacy": false, - "spotify_custom": false + "spotify_custom": false, + "flatpaks": [], + "services": [] } diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..44d4979 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,142 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +LIB = Path(__file__).resolve().parents[1] / 'lib' + + +def module(name): + spec = importlib.util.spec_from_file_location(name, LIB / (name + '.py')) + value = importlib.util.module_from_spec(spec) + spec.loader.exec_module(value) + return value + + +setup = module('setup') +boot = module('boot') + + +class SetupTests(unittest.TestCase): + def test_only_selected_overlays_are_staged_and_existing_ones_are_preserved(self): + self.assertEqual(setup.required_repositories({'groups': ['kde']}), []) + self.assertEqual(setup.required_repositories({'groups': ['gaming']}), ['guru', 'steam-overlay']) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + def clone(command, **kwargs): + destination = Path(command[-1]) + (destination / 'profiles').mkdir(parents=True) + (destination / 'profiles/repo_name').write_text(destination.name + '\n') + return subprocess.CompletedProcess(command, 0) + with patch.object(setup, 'existing_repository', side_effect=lambda name: Path('/existing') if name == 'guru' else None), patch.object(setup.shutil, 'which', return_value='/usr/bin/git'), patch.object(setup.subprocess, 'run', side_effect=clone) as run: + fetched = setup.stage_repositories({'groups': ['gaming']}, root) + self.assertEqual(set(fetched), {'steam-overlay'}) + self.assertEqual(run.call_count, 1) + config = root / 'etc/portage/repos.conf/fiw-dots-steam-overlay.conf' + self.assertIn(str(root / 'var/db/repos/steam-overlay'), config.read_text()) + self.assertFalse((root / 'etc/portage/repos.conf/fiw-dots-guru.conf').exists()) + + def test_flatpak_failure_is_reported_and_installs_are_user_scoped(self): + reports = [] + installed = set() + def run(command, **kwargs): + if command[:2] == ['flatpak', 'info']: + return subprocess.CompletedProcess(command, 0 if command[-1] in installed else 1) + if 'install' in command and command[-1] == 'org.localsend.localsend_app': + installed.add(command[-1]) + return subprocess.CompletedProcess(command, 0) + if 'install' in command: + return subprocess.CompletedProcess(command, 1) + return subprocess.CompletedProcess(command, 0) + with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.shutil, 'which', return_value='/usr/bin/flatpak'), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report: reports.append(report)): + with self.assertRaises(RuntimeError): + setup.install_flatpaks({'flatpaks': list(setup.FLATPAKS)}, lambda *args: 'install') + self.assertEqual(reports[0]['installed'], ['org.localsend.localsend_app']) + self.assertEqual(reports[0]['failed'], ['org.vinegarhq.Sober']) + for call in commands.call_args_list: + self.assertIn('--user', call.args[0]) + + def test_missing_services_are_reported_and_enabled_without_starting(self): + reports = [] + def run(command, **kwargs): + if 'show' in command: + state = 'not-found\n' if command[-1] == 'wireplumber.service' else 'loaded\n' + return subprocess.CompletedProcess(command, 0, state) + return subprocess.CompletedProcess(command, 0) + with patch.object(setup.os, 'geteuid', return_value=1000), patch.object(setup.subprocess, 'run', side_effect=run) as commands, patch.object(setup, 'write_report', side_effect=lambda kind, report, system: reports.append(report)): + setup.enable_services({'services': ['audio', 'network']}, lambda *args: 'enable') + self.assertEqual(reports[0]['missing'], ['wireplumber.service']) + self.assertEqual(reports[0]['enabled'], ['pipewire.socket', 'pipewire-pulse.socket']) + for call in commands.call_args_list: + self.assertNotIn('--now', call.args[0]) + self.assertIn('--user', call.args[0]) + + +class BootTests(unittest.TestCase): + def test_managed_limine_block_preserves_other_entries_and_rejects_bad_markers(self): + original = 'timeout: 8\n\n/Other OS\n protocol: efi\n path: boot():/other.efi\n' + block = boot.render_limine([{'version': 'test-gentoo'}], 'root=UUID=target-root ro') + merged = boot.merge_limine(original, block) + self.assertTrue(merged.startswith(original)) + self.assertEqual(boot.merge_limine(merged, block), merged) + with self.assertRaises(RuntimeError): + boot.merge_limine(original + boot.BEGIN, block) + + def test_edited_limine_entries_get_proposal_while_external_entries_remain(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + image = root / 'kernel';image.write_bytes(b'kernel') + initrd = root / 'initrd';initrd.write_bytes(b'initrd') + images = [{'version': 'test-gentoo', 'kernel': str(image), 'initrd': str(initrd)}] + saved = {'mode': 'limine', 'esp': str(root / 'esp'), 'cmdline': 'root=UUID=target-root ro'} + (root / 'esp').mkdir() + with patch.object(boot, 'discover_esp', return_value={'path': saved['esp']}), patch.object(boot, 'kernel_images', return_value=images): + saved = boot.refresh(saved, root / 'backups') + config = root / 'esp/EFI/Fiw-Gentoo/limine.conf' + config.write_text(config.read_text() + '\n/Other OS\n protocol: efi\n') + saved = boot.refresh(saved, root / 'backups') + self.assertIn('/Other OS', config.read_text()) + config.write_text(config.read_text().replace('target-root', 'user-edited-root')) + previous = config.read_text() + boot.refresh(saved, root / 'backups') + self.assertEqual(config.read_text(), previous) + proposals = list(config.parent.glob('limine.conf.new.*')) + self.assertEqual(len(proposals), 1) + self.assertIn('/Other OS', proposals[0].read_text()) + + def test_failed_grub_generation_does_not_replace_existing_menu(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + folder = root / 'boot';(folder / 'grub').mkdir(parents=True) + config = folder / 'grub/grub.cfg';config.write_text('working config\n') + images = [] + def run(command, **kwargs): + Path(command[-1]).write_text('partial failed config') + raise subprocess.CalledProcessError(1, command) + # /boot sources prevent normalization writes outside the staging dir. + images = [{'version': 'test', 'kernel': '/boot/kernel-test', 'initrd': '/boot/initramfs-test.img'}] + saved = {'mode': 'grub', 'esp': str(root), 'config_hash': boot.file_hash(config)} + with patch.object(boot, 'GRUB_DIRECTORY', folder), patch.object(boot, 'discover_esp', return_value={'path': str(root)}), patch.object(boot, 'kernel_images', return_value=images), patch.object(boot.subprocess, 'run', side_effect=run): + with self.assertRaises(subprocess.CalledProcessError): + boot.refresh(saved, root / 'backups') + self.assertEqual(config.read_text(), 'working config\n') + self.assertFalse(list(config.parent.glob('.*.tmp'))) + + def test_firmware_add_preserves_bootorder_and_repeat_default_does_not_duplicate(self): + target = {'disk': '/dev/testdisk', 'partition': 2, 'partuuid': 'target-partition'} + loader = '/EFI/Fiw-Gentoo/BOOTX64.EFI' + command = boot.firmware_commands(target, 'limine', loader, 'add', '')[0] + self.assertIn('--create-only', command) + self.assertNotIn('--create', command) + listing = 'BootOrder: 0001,0002\nBoot0002* Fiw-Gentoo-limine HD(2,GPT,target-partition)/File(\\EFI\\Fiw-Gentoo\\BOOTX64.EFI)\n' + commands = boot.firmware_commands(target, 'limine', loader, 'default', listing) + self.assertEqual(commands, [['efibootmgr', '--bootorder', '0002,0001']]) + self.assertEqual(boot.firmware_commands(target, 'limine', loader, 'add', listing), []) + self.assertEqual(boot.plan('keep'), {'mode': 'keep', 'changes': []}) + + +if __name__ == '__main__': + unittest.main()