diff --git a/README.md b/README.md index 0c77eb7..9661485 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@
+
# 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