diff --git a/README.md b/README.md index 9661485..2bd4131 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ 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. +`u` opens config backups for preview and restoration. Apply actions finish +with a [combined report](docs/reports.md) of outcomes and remaining setup. 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. @@ -109,6 +111,9 @@ Fish, Kitty and Neovim are usable without KDE or KWin. Fastfetch keeps the tree layout and purple colours with the built-in Gentoo logo. MangoHud and Vesktop preferences are included; browser profiles, chat sessions and account data are excluded. App autostart is a separate optional selection. +Ark, Gwenview and Prism preferences are individually optional too; see +[app configs and requirements](docs/app-configs.md). Each config's required +packages appear in the final preview without changing package selections. Spotify is stock by default. The existing customization scripts remain an [explicit optional step](docs/spotify.md). [Firefox-Privacy](docs/firefox.md) @@ -118,6 +123,8 @@ Existing differences prompt apply-or-keep and get backups when replaced. Updates leave locally edited configs intact and write the proposal to `.new`. KConfig patches preserve unrelated keys and groups; JSON patches preserve unrelated preferences and login data already on the target device. +The TUI can [restore user config backups](docs/backups.md), with a preview, +confirmation and a new backup of the replaced current files. ## Project layout diff --git a/cmd/dots/main.go b/cmd/dots/main.go index 9ba9bc8..092377c 100644 --- a/cmd/dots/main.go +++ b/cmd/dots/main.go @@ -2,14 +2,17 @@ package main import ( "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" ) type selection struct { @@ -52,14 +55,22 @@ type previewMsg struct { content string err error } +type backupChoice struct { + ID string `json:"id"` + Count int `json:"count"` +} +type backupsMsg struct { + choices []backupChoice + err error +} type model struct { - repo string - catalog catalog - selection selection - rows []row - stage, cursor, height, scroll int - preview, errorText, action string - done, loading bool + repo string + catalog catalog + selection selection + rows []row + stage, cursor, height, width, scroll int + preview, errorText, action, backupID string + done, loading bool } func contains(values []string, target string) bool { @@ -215,16 +226,57 @@ func (m model) makePreview() tea.Cmd { return previewMsg{string(output), err} } } +func (m model) loadBackups() tea.Cmd { + return func() tea.Msg { + output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--list-backups").CombinedOutput() + var choices []backupChoice + if err == nil { + err = json.Unmarshal(output, &choices) + } else { + err = fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return backupsMsg{choices, err} + } +} +func (m model) backupPreview() tea.Cmd { + return func() tea.Msg { + output, err := exec.Command("python3", filepath.Join(m.repo, "lib", "rice.py"), "--backup-plan", m.backupID).CombinedOutput() + return previewMsg{string(output), err} + } +} +func (m model) previewLines() []string { + width := m.width + if width == 0 { + width = 80 + } + if width < 10 { + width = 10 + } + return strings.Split(ansi.Wrap(m.preview, width-4, ""), "\n") +} func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch v := msg.(type) { case tea.WindowSizeMsg: m.height = v.Height + m.width = v.Width + if m.scroll >= len(m.previewLines()) { + m.scroll = len(m.previewLines()) - 1 + } case previewMsg: m.loading = false m.preview = v.content if v.err != nil { m.errorText = v.err.Error() } + case backupsMsg: + m.loading = false + m.rows = nil + if v.err != nil { + m.errorText = v.err.Error() + } + for _, choice := range v.choices { + m.rows = append(m.rows, row{choice.ID, fmt.Sprintf("%s — %d files", choice.ID, choice.Count), false}) + } case tea.KeyMsg: key := v.String() if key == "ctrl+c" || key == "q" { @@ -233,21 +285,40 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.loading { return m, nil } - if m.stage == 5 { + if m.stage == 5 || m.stage == 7 { switch key { case "up", "k": if m.scroll > 0 { m.scroll-- } case "down", "j": - if m.scroll < len(strings.Split(m.preview, "\n"))-1 { + if m.scroll < len(m.previewLines())-1 { m.scroll++ } case "esc": m.stage-- m.prepare() + if m.stage == 6 { + m.loading = true + m.errorText = "" + return m, m.loadBackups() + } + case "u": + if m.stage == 5 { + m.stage = 6 + m.prepare() + m.loading = true + m.errorText = "" + return m, m.loadBackups() + } case "enter", "s", "a", "i", "f", "v", "b", "r": if m.errorText == "" { + if m.stage == 7 { + if key != "enter" { + return m, nil + } + key = "u" + } m.done = true m.action = key return m, tea.Quit @@ -255,6 +326,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.stage == 6 { + switch key { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + case "enter": + if len(m.rows) > 0 && m.errorText == "" { + m.backupID = m.rows[m.cursor].id + m.stage = 7 + m.scroll = 0 + m.loading = true + return m, m.backupPreview() + } + case "esc": + m.stage = 5 + m.scroll = 0 + m.errorText = "" + m.loading = true + return m, m.makePreview() + } + return m, nil + } switch key { case "up", "k": if m.cursor > 0 { @@ -325,17 +423,17 @@ 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", "Flatpaks and optional services", "Final preview"} + titles := []string{"Preset", "Package sections", "Optional app configs", "Kernel, bootloader and extras", "Flatpaks and optional services", "Final preview", "Config backups", "Restore backup 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 == 5 { + if m.stage == 5 || m.stage == 7 { if m.loading { return text + " Preparing preview…\n" } - lines := strings.Split(m.preview, "\n") + lines := m.previewLines() end := m.scroll + budget if end > len(lines) { end = len(lines) @@ -343,12 +441,26 @@ func (m model) View() string { for _, line := range lines[m.scroll:end] { text += " " + line + "\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.stage == 7 { + text += "\n ↑/↓ scroll · Esc back · Enter restore with confirmation · q cancel\n" + } else { + text += "\n ↑/↓ scroll · Esc back · Enter save · r full restore · a configs · i packages\n f Flatpaks · v services · b bootloader · u config backups · q cancel\n" + } if m.errorText != "" { text += " " + m.errorText + "\n" } return text } + if m.stage == 6 && (m.loading || len(m.rows) == 0 || m.errorText != "") { + message := "No config backups yet. Backups are created when existing configs are replaced." + if m.loading { + message = "Loading config backups…" + } + if m.errorText != "" { + message = m.errorText + } + return text + " " + message + "\n\n Esc back · q cancel\n" + } start := 0 if m.cursor >= budget { start = m.cursor - budget + 1 @@ -373,6 +485,9 @@ func (m model) View() string { } text += line + "\n" } + if m.stage == 6 { + return text + "\n ↑/↓ move · Enter preview backup · Esc back · q cancel\n" + } return text + "\n ↑/↓ move · Space select · Enter next · Esc back · q cancel\n" } @@ -423,8 +538,10 @@ func main() { } fmt.Println(final.preview) fmt.Println("\nSaved selection:", path) - run := func(action string, root bool) error { - args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action} + runID := fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid()) + run := func(action string, root bool, extra ...string) error { + args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, "--run-id", runID, action} + args = append(args, extra...) program := "python3" if root { program = "sudo" @@ -458,17 +575,26 @@ func main() { return nil } var actionErr error + workflow := "restore" switch final.action { case "a": + workflow = "configs" actionErr = run("--apply-configs", false) case "i": + workflow = "packages" actionErr = run("--install-packages", true) case "f": + workflow = "flatpaks" actionErr = run("--install-flatpaks", false) case "v": + workflow = "services" actionErr = services() case "b": + workflow = "boot" actionErr = run("--deploy-bootloader", true) + case "u": + workflow = "backup" + actionErr = run("--restore-backup", false, final.backupID) 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) { @@ -490,7 +616,19 @@ func main() { fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.") return } + summaryArgs := []string{"--workflow", workflow} if actionErr != nil { + summaryArgs = append(summaryArgs, "--execution-error", actionErr.Error()) + } + if summaryErr := run("--summary", false, summaryArgs...); summaryErr != nil { + fmt.Fprintln(os.Stderr, "Cannot create the combined report:", summaryErr) + } + if actionErr != nil { + var exitError *exec.ExitError + if errors.As(actionErr, &exitError) && exitError.ExitCode() == 130 { + fmt.Println("Cancelled. Completed steps remain recorded in the summary.") + os.Exit(130) + } fmt.Fprintln(os.Stderr, actionErr) os.Exit(1) } diff --git a/cmd/dots/main_test.go b/cmd/dots/main_test.go index 50baff2..07c3c18 100644 --- a/cmd/dots/main_test.go +++ b/cmd/dots/main_test.go @@ -2,6 +2,7 @@ package main import ( tea "github.com/charmbracelet/bubbletea" + "strings" "testing" ) @@ -66,3 +67,51 @@ func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) { t.Fatalf("setup selections lost before preview: %+v", result.selection) } } + +func TestBackupNeedsSelectionPreviewAndExplicitRestoreAction(t *testing.T) { + m := model{stage: 5} + next, command := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'u'}}) + m = next.(model) + if m.stage != 6 || command == nil || m.done { + t.Fatal("backup key did not open the chooser") + } + next, _ = m.Update(backupsMsg{choices: []backupChoice{{ID: "20260101T000000Z", Count: 1}}}) + m = next.(model) + next, command = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = next.(model) + if m.stage != 7 || command == nil || m.done { + t.Fatal("choosing a backup must only open its preview") + } + next, _ = m.Update(previewMsg{content: "preview"}) + m = next.(model) + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + m = next.(model) + if m.done { + t.Fatal("config application key triggered a restore from backup preview") + } + next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = next.(model) + if !m.done || m.action != "u" || m.backupID != "20260101T000000Z" { + t.Fatal("explicit backup action was lost") + } +} + +func TestEmptyBackupChooserDoesNotStartRestore(t *testing.T) { + m := model{stage: 6} + next, command := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = next.(model) + if m.done || command != nil || m.stage != 6 { + t.Fatal("empty backup chooser started a restore") + } +} + +func TestPreviewCanScrollThroughLongRequirementLines(t *testing.T) { + m := model{stage: 5, height: 20, width: 40, preview: strings.Repeat("package [missing] ", 20)} + for range 3 { + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = next.(model) + } + if m.scroll != 3 { + t.Fatal("wrapped requirements cannot be scrolled into view") + } +} diff --git a/configs/ark/kconfig/.config/arkrc b/configs/ark/kconfig/.config/arkrc new file mode 100644 index 0000000..3a8ce89 --- /dev/null +++ b/configs/ark/kconfig/.config/arkrc @@ -0,0 +1,9 @@ +[Extraction] +openDestinationFolderAfterExtraction=true + +[General] +LockSidebar=true +ShowSidebar=true + +[MainWindow] +StatusBar=Disabled diff --git a/configs/catalog.json b/configs/catalog.json index 30a31c6..75a4746 100644 --- a/configs/catalog.json +++ b/configs/catalog.json @@ -2,58 +2,143 @@ "kde-style": { "label": "KDE + GTK colours, fonts, cursor and decorations", "group": "kde", - "kde": true + "kde": true, + "packages": [ + "kde-plasma/plasma-workspace", + "kde-plasma/kwin", + "kde-plasma/breeze", + "kde-plasma/breeze-gtk", + "kde-frameworks/breeze-icons", + "media-fonts/dejavu" + ], + "note": "Qogir cursors and Utterly Round decoration assets are bundled." }, "kde-shortcuts": { "label": "All KDE shortcuts + keyboard layouts + screenshot helpers", "group": "kde", - "kde": true + "kde": true, + "packages": [ + "kde-plasma/plasma-workspace", + "kde-plasma/kwin", + "kde-plasma/spectacle", + "x11-terms/kitty", + "gui-apps/wl-clipboard", + "x11-libs/libnotify" + ] }, "dolphin": { "label": "Dolphin toolbar, sidebar and transparency", "group": "fiw-apps", - "kde": true + "kde": true, + "packages": [ + "kde-apps/dolphin", + "kde-plasma/kwin" + ] }, "spectacle": { "label": "Spectacle capture preferences", "group": "fiw-apps", - "kde": true + "kde": true, + "packages": [ + "kde-plasma/spectacle" + ] }, "fish": { "label": "Fish aliases, greeting and notification settings", - "group": "cli" + "group": "cli", + "packages": [ + "app-shells/fish", + "app-misc/fastfetch", + "sys-apps/eza", + "sys-apps/bat", + "app-editors/neovim", + "app-portage/portage-utils", + "x11-libs/libnotify" + ], + "note": "The fiw-update alias is enabled only when that local helper exists. Font icons use the optional fonts preset." }, "kitty": { "label": "Kitty lavender profiles", - "group": "fiw-apps" + "group": "fiw-apps", + "packages": [ + "x11-terms/kitty" + ], + "note": "Select fonts separately for the bundled JetBrainsMono Nerd Font." }, "neovim": { "label": "Neovim transparent purple style", - "group": "cli" + "group": "cli", + "packages": [ + "app-editors/neovim" + ], + "note": "Catppuccin is optional; the captured purple highlights also work with the built-in fallback." }, "fastfetch": { "label": "Fastfetch tree layout with Gentoo logo", - "group": "cli" + "group": "cli", + "packages": [ + "app-misc/fastfetch" + ] }, "mangohud": { "label": "MangoHud appearance and detail presets", - "group": "gaming" + "group": "gaming", + "packages": [ + "games-util/mangohud" + ] }, "vesktop": { "label": "Vesktop preferences (no account data)", - "group": "fiw-apps" + "group": "fiw-apps", + "packages": [ + "net-im/vesktop-bin" + ] }, "vscode": { "label": "VS Code preferences", - "group": "fiw-apps" + "group": "fiw-apps", + "packages": [ + "app-editors/vscode" + ] }, "autostart": { "label": "Optional application autostart", "group": null, - "kde": true + "kde": true, + "packages": [ + "kde-plasma/kwin" + ], + "note": "Only apps from selected package groups with an installed launch command are started." }, "fonts": { "label": "JetBrainsMono Nerd Font Mono (four styles)", - "group": null + "group": null, + "packages": [ + "media-libs/fontconfig" + ] + }, + "ark": { + "label": "Ark sidebar, status bar and extraction behaviour", + "group": "fiw-apps", + "packages": [ + "kde-apps/ark" + ] + }, + "gwenview": { + "label": "Gwenview menu bar and information sidebar", + "group": "fiw-apps", + "packages": [ + "kde-apps/gwenview" + ] + }, + "prism": { + "label": "Prism appearance, console and launcher behaviour", + "group": "gaming", + "packages": [ + "games-action/prismlauncher", + "media-fonts/dejavu", + "kde-frameworks/breeze-icons" + ], + "note": "Native Gentoo path. Minecraft instances, accounts, Java locations and memory limits remain local." } } diff --git a/configs/gwenview/kconfig/.config/gwenviewrc b/configs/gwenview/kconfig/.config/gwenviewrc new file mode 100644 index 0000000..374ef05 --- /dev/null +++ b/configs/gwenview/kconfig/.config/gwenviewrc @@ -0,0 +1,5 @@ +[MainWindow] +MenuBar=Disabled + +[SideBar] +InformationSplitterSizes=211,244 diff --git a/configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg b/configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg new file mode 100644 index 0000000..400581f --- /dev/null +++ b/configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg @@ -0,0 +1,19 @@ +[General] +ApplicationTheme=Breeze +AutoCloseConsole=false +CloseAfterLaunch=false +ConsoleFont=DejaVu Sans Mono +ConsoleFontSize=11 +ConsoleMaxLines=100000 +ConsoleOverflowStop=true +IconTheme=breeze_dark +MenuBarInsteadOfToolBar=false +QuitAfterGameStop=false +RecordGameTime=true +ShowConsole=false +ShowConsoleOnError=true +ShowGameTime=true +ShowGameTimeWithoutDays=false +ShowGlobalGameTime=true +StatusBarVisible=true +ToolbarsLocked=false diff --git a/docs/app-configs.md b/docs/app-configs.md new file mode 100644 index 0000000..530b693 --- /dev/null +++ b/docs/app-configs.md @@ -0,0 +1,56 @@ +# Optional app preferences + +Each preset has its own checkbox. Configs can be restored for already installed +apps without selecting a package section. Close the affected app before +restoring its preferences so it reads the new settings on its next launch. + +## Additional captured apps + +| Preset | Captured preferences | Target file | +|---|---|---| +| Ark | Open the destination after extraction; visible, locked sidebar; hidden status bar | `.config/arkrc` | +| Gwenview | Hidden menu bar and information sidebar splitter proportions | `.config/gwenviewrc` | +| Prism | Breeze appearance, dark Breeze icons, console font/limits, console visibility and launcher/game-time behaviour | `.local/share/PrismLauncher/prismlauncher.cfg` | + +These use key-by-key merges. Existing recent files, accounts, Minecraft +instances, machine paths, Java selection, memory limits and unrelated +preferences stay on the target. The Prism path is for the native Gentoo +package. Its Minecraft and Java setup is listed in the final manual steps. + +Ark, Gwenview and Prism are selected in both shipped presets and can be +unchecked independently. Recapture only these allowlisted keys with: + +```sh +python3 tools/capture.py --apps-only +``` + +## Config requirements + +The final preview lists the packages needed for each selected config: + +| Status | Meaning | +|---|---| +| installed | Portage confirms the package is present on this device | +| selected | The package is directly included in the selected package plan | +| missing | The package is absent and is not directly selected | +| unverified | Portage is unavailable, so installation cannot be checked | + +Some requirements arrive as dependencies, such as Plasma components through +`plasma-meta`. Their preview status can be missing until Portage resolves and +installs them. Requirements do not automatically check package groups or +block config-only restoration. The combined report checks actual installed +packages again, including requirements of apps whose installation was skipped. + +Fish, Kitty, Neovim and Fastfetch have no Plasma or KWin requirements. Fonts +have their own checkbox; Kitty can use its fallback font if those are omitted. +The original Neovim config uses a built-in fallback when Catppuccin is absent. + +## r2modman + +Set its appearance through the app's settings. Global and game settings use a +shared IndexedDB store, as shown in the +[upstream settings implementation](https://github.com/ebkr/r2modmanPlus/blob/a1897e3d6f7c1946dc51312252db1cc25f1dace0/src/r2mm/manager/SettingsDexieStore.ts). +There is no captured r2modman config preset; its Electron profile and databases +remain local. Restore wanted mod profiles through the app's +[profile export/import workflow](https://github.com/ebkr/r2modmanPlus/wiki/Profiles). +Selected gaming installs include this reminder in their combined report. diff --git a/docs/backups.md b/docs/backups.md new file mode 100644 index 0000000..be9e4c4 --- /dev/null +++ b/docs/backups.md @@ -0,0 +1,32 @@ +# Restore a config backup + +At the final TUI preview, press **u** to browse user config backups. Choose a +backup to see its file list, then press Enter to begin the restore prompts. +Esc returns to the selection screen. An empty chooser does not apply anything. + +The command-line equivalents are: + +```sh +./install --list-backups +./install --backup-plan BACKUP_ID +./install --restore-backup BACKUP_ID +``` + +Run restoration as the target user. KDE backups require logging out of Plasma +and running from a TTY. The backup's contents determine this requirement, +independently of which presets are currently selected. + +Restoration has a final confirmation and apply-or-keep prompts for existing +files. Each replaced current file gets a new backup. Afterwards, updates +treat the restored settings as local preferences and leave new proposals in +`.new` files. Unchanged files are left as they are. + +Backups contain files replaced during earlier config application. Files first +created by that application are retained when restoring a backup. Unrelated +files are also retained. Only recognized config paths inside the target home +can be restored; unsafe directory links and unexpected backup paths stop the +operation before writes. + +User backups live under `~/.local/state/Fiw-Gentoo-Dots/backups`. The TUI action +restores user configs. System Portage and boot backups remain available under +`/var/lib/Fiw-Gentoo-Dots/backups` for manual restoration. diff --git a/docs/reports.md b/docs/reports.md new file mode 100644 index 0000000..b6dda40 --- /dev/null +++ b/docs/reports.md @@ -0,0 +1,42 @@ +# Restoration reports + +Every TUI apply action ends with one combined report, including failure and +cancellation paths. It correlates the user's and root's steps by a generated +run ID and the saved selection. Reports from other runs are kept separate. + +The report includes: + +- Completed, partial, failed, cancelled and not attempted steps. +- Confirmed present Portage packages and skipped or missing packages. +- Installed, existing, skipped or failed user Flatpaks. +- Enabled, missing or skipped service units. +- Applied configs, preserved conflicts and pending `.new` proposals. +- Boot deployment outcome or backup restoration outcome. +- Missing config requirements and selected manual setup steps. + +A cancelled operation stops the full restore sequence. Completed earlier +steps remain recorded; it does not undo their changes. + +Combined reports live in `~/.local/state/Fiw-Gentoo-Dots/summary-RUN_ID.json`. +Each step also keeps its own local report. Existing detailed package and boot +reports remain in `/var/lib/Fiw-Gentoo-Dots`. + +Show a summary for the latest run matching a saved selection: + +```sh +./install --selection local/selection.json --summary +``` + +For a command-line sequence, use the same explicit run ID for all steps: + +```sh +sudo ./install --selection local/selection.json --run-id my-restore --install-packages +./install --selection local/selection.json --run-id my-restore --install-flatpaks +./install --selection local/selection.json --run-id my-restore --apply-configs +./install --selection local/selection.json --run-id my-restore --summary +``` + +Run the combined summary as your regular user. `--workflow configs`, +`packages`, `flatpaks`, `services`, `boot` or `backup` limits the expected steps +when summarizing one action. Full restoration is the default. Without an +explicit ID, separate CLI apply commands are separate runs. diff --git a/docs/scripts.md b/docs/scripts.md index ac71254..828cfbb 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -6,6 +6,8 @@ | `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 | +| `lib/backups.py` | Lists and previews user config backups, restores selected files and backs up their current versions | TUI backup action or backend commands | +| `lib/reporting.py` | Correlates root/user steps, failures, skips and manual setup into one local summary | Runs after TUI apply actions; `--summary` for CLI use | | `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 | diff --git a/docs/setup.md b/docs/setup.md index e69dbba..98d8c8b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -22,6 +22,7 @@ At the final preview: | v | Enable selected services | | a | Restore selected user configs | | b | Preview and deploy the selected bootloader | +| u | Browse, preview and restore a user config backup | 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 @@ -39,6 +40,9 @@ sudo ./install --selection local/selection.json --deploy-bootloader The root service command handles system services; the regular-user command handles PipeWire units. User Flatpaks and user configs are never applied as root. +Every apply action finishes with a [combined report](reports.md), including +skipped/missing packages and manual setup. Cancellation stops the full restore +sequence and records the completed earlier steps. ## Packages and repositories @@ -87,6 +91,9 @@ 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). +Ark, Gwenview and Prism also have individually optional preferences. Their +capture scope and the preview's package requirements are described in +[app configs](app-configs.md). r2modman setup remains a manual step. Review Git changes before pulling updates, then restore your saved selection: @@ -99,6 +106,7 @@ 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`. +Use the TUI's **u** action to [restore a user config backup](backups.md). ## Fiw tools diff --git a/go.mod b/go.mod index 029a5cf..5c90afb 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,15 @@ module github.com/Fi3w0/Fiw-Gentoo-Dots go 1.24.0 -require github.com/charmbracelet/bubbletea v1.3.10 +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/x/ansi v0.10.1 +) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect diff --git a/lib/backups.py b/lib/backups.py new file mode 100644 index 0000000..e9d2ddb --- /dev/null +++ b/lib/backups.py @@ -0,0 +1,123 @@ +"""Preview and restore user config backups, preserving the current files.""" +import hashlib +import json +import os +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + + +def stamp(): + return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + + +def user_state(home): + home = home.resolve() + state = home / '.local/state/Fiw-Gentoo-Dots' + if not state.resolve().is_relative_to(home): + raise RuntimeError('Config state directory points outside the target home.') + backup_root = state / 'backups' + if backup_root.is_symlink() or not backup_root.resolve().is_relative_to(state.resolve()): + raise RuntimeError('Config backup directory points outside its managed location.') + return state + + +def digest(path): + if path.is_symlink(): + return 'link:' + os.readlink(path) + return hashlib.sha256(path.read_bytes()).hexdigest() if path.is_file() else None + + +def files(home, backup_id, allowed): + if not re.fullmatch(r'[0-9]{8}T[0-9]{6}(?:\.[0-9]+)?Z', backup_id): + raise ValueError('Invalid config backup ID.') + root = user_state(home) / 'backups' / backup_id + if not root.is_dir() or root.is_symlink(): + raise RuntimeError('Config backup not found: ' + backup_id) + if not root.resolve().is_relative_to(user_state(home).resolve()): + raise RuntimeError('Backup directory points outside config state.') + result = [] + for directory, dirs, names in os.walk(root, followlinks=False): + if any((Path(directory) / name).is_symlink() for name in dirs): + raise RuntimeError('A backup directory is a symlink; preserving all files.') + for name in sorted(names): + path = Path(directory) / name + relative = path.relative_to(root).as_posix() + if relative not in allowed: + raise RuntimeError('Unrecognized file in config backup: ' + relative) + if not path.is_file() and not path.is_symlink(): + raise RuntimeError('Unsupported config backup file: ' + relative) + destination = home / relative + if not destination.parent.resolve().is_relative_to(home.resolve()): + raise RuntimeError('Config parent points outside the target home: ' + relative) + if destination.is_dir() and not destination.is_symlink(): + raise RuntimeError('Config destination is a directory: ' + relative) + result.append((relative, path, destination)) + return sorted(result) + + +def list_backups(home, allowed): + root = user_state(home) / 'backups' + if not root.exists(): + return [] + if root.is_symlink(): + raise RuntimeError('Config backup directory is a symlink.') + choices = [] + for directory in sorted(root.iterdir(), reverse=True): + if directory.is_dir() and re.fullmatch(r'[0-9]{8}T[0-9]{6}(?:\.[0-9]+)?Z', directory.name): + entries = files(home, directory.name, allowed) + if entries: + choices.append({'id': directory.name, 'count': len(entries)}) + return choices + + +def plan(home, backup_id, allowed): + entries = files(home, backup_id, allowed) + return {'backup': backup_id, 'files': [ + {'path': relative, 'config': allowed[relative]['name'], + 'change': 'unchanged' if digest(source) == digest(destination) else 'restore'} + for relative, source, destination in entries], + 'note': 'Current files get a new backup. Files created by the original restore are retained. Only user configs are restored.'} + + +def restore(home, backup_id, allowed, ask, active_plasma, conflict='ask'): + if os.geteuid() == 0: + raise RuntimeError('Restore user config backups as the target user, not root.') + home = home.resolve() + entries = files(home, backup_id, allowed) + if any(allowed[relative].get('kde') for relative, _, _ in entries) and active_plasma(home): + raise RuntimeError('Log out of Plasma before restoring a KDE config backup.') + print(json.dumps(plan(home, backup_id, allowed), indent=2)) + report = {'backup': backup_id, 'restored': [], 'unchanged': [], 'kept': []} + if not entries or ask('Restore the previewed user config backup?', ['restore', 'cancel'], 'cancel') != 'restore': + report['status'] = 'cancelled' + return report + state = user_state(home) + index_path = state / 'managed.json' + index = json.loads(index_path.read_text()) if index_path.is_file() else {} + backup_id_new = stamp() + for relative, source, destination in entries: + if digest(source) == digest(destination): + report['unchanged'].append(relative) + continue + if destination.exists() or destination.is_symlink(): + if conflict == 'keep' or (conflict == 'ask' and ask('Restore ' + relative + ' after backing up the current file?', ['restore', 'keep'], 'keep') == 'keep'): + report['kept'].append(relative) + continue + current = state / 'backups' / backup_id_new / relative + current.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(destination, current, follow_symlinks=False) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name('.' + destination.name + '.fiw-restore-' + backup_id_new) + shutil.copy2(source, temporary, follow_symlinks=False) + os.replace(temporary, destination) + # A restored previous preference is a local choice; future updates + # should propose changes rather than treating it as the current preset. + index.pop(relative, None) + report['restored'].append(relative) + temporary = index_path.with_name('.managed-restore-' + backup_id_new) + temporary.write_text(json.dumps(index, indent=2) + '\n') + temporary.replace(index_path) + report['current_backup'] = backup_id_new if (state / 'backups' / backup_id_new).exists() else None + return report diff --git a/lib/boot.py b/lib/boot.py index b5ee5b8..958437d 100644 --- a/lib/boot.py +++ b/lib/boot.py @@ -338,7 +338,7 @@ def firmware_commands(target, mode, loader_path, choice, listing): def deploy(mode, esp=None, ask=confirm): if mode == 'keep': print('Keeping the current bootloader.') - return + return {'status': 'skipped'} if os.geteuid() != 0: raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection .') proposed = plan(mode, esp) @@ -360,7 +360,7 @@ def deploy(mode, esp=None, ask=confirm): if commands: print('Proposed firmware commands: ' + json.dumps(commands)) if ask('Deploy the previewed bootloader and kernel refresh hook?', ['deploy', 'cancel'], 'cancel') != 'deploy': - return + return {'status': 'cancelled'} 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 @@ -403,6 +403,8 @@ def deploy(mode, esp=None, ask=confirm): report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware, 'backups': str(backups)}, indent=2) + '\n') print('Boot deployment finished. Report: ' + str(report)) + return {'status': 'completed', 'mode': mode, 'firmware_choice': firmware, + 'report': str(report), 'backups': str(backups)} def main(): diff --git a/lib/reporting.py b/lib/reporting.py new file mode 100644 index 0000000..7a8a4fd --- /dev/null +++ b/lib/reporting.py @@ -0,0 +1,128 @@ +"""Correlate user and root setup actions into one local restoration report.""" +import hashlib +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from backups import user_state + +SYSTEM_STATE = Path('/var/lib/Fiw-Gentoo-Dots') + + +def selection_key(selection): + return hashlib.sha256(json.dumps(selection, sort_keys=True).encode()).hexdigest() + + +def validate_run(run_id): + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,95}', run_id): + raise ValueError('Invalid restoration run ID.') + return run_id + + +def result_status(details): + if details.get('status'): + return details['status'] + if any(details.get(key) for key in ('failed', 'missing', 'skipped', 'kept', 'review')): + return 'partial' + return 'completed' + + +def record(selection, run_id, action, home, details, error=None): + state = SYSTEM_STATE if os.geteuid() == 0 else user_state(home) + state.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + event = {'run': validate_run(run_id), 'selection_key': selection_key(selection), + 'action': action, 'time': timestamp, 'details': details, + 'status': 'failed' if error else result_status(details)} + if error: + event['error'] = str(error) + path = state / ('action-' + run_id + '-' + action + '-' + timestamp + '.json') + path.write_text(json.dumps(event, indent=2) + '\n') + path.chmod(0o644 if os.geteuid() == 0 else 0o600) + return event + + +def expected(selection, workflow): + if workflow != 'restore': + if workflow == 'services': + # Scope is provided by the caller's user/root invocation. + return ['services-user', 'services-system'] + return [workflow] + actions = [] + if selection['groups'] or selection.get('extras') or selection.get('flatpaks') or selection.get('services') or selection['bootloader'] != 'keep': + actions.append('packages') + if selection.get('flatpaks'): + actions.append('flatpaks') + if any(service != 'audio' for service in selection.get('services', [])): + actions.append('services-system') + if 'audio' in selection.get('services', []): + actions.append('services-user') + if selection['configs']: + actions.append('configs') + if selection['bootloader'] != 'keep': + actions.append('boot') + return actions + + +def summarize(selection, home, requirements, manual, run_id=None, workflow='restore', execution_error=None): + events, inaccessible = [], [] + for state in (user_state(home), SYSTEM_STATE): + try: + for path in state.glob('action-*.json'): + try: + event = json.loads(path.read_text()) + if event.get('selection_key') == selection_key(selection): + events.append(event) + except (OSError, ValueError): + inaccessible.append(str(path)) + except OSError: + inaccessible.append(str(state)) + if run_id: + validate_run(run_id) + elif events: + run_id = max(events, key=lambda event: event['time'])['run'] + events = [event for event in events if event['run'] == run_id] + latest = {} + for event in sorted(events, key=lambda item: item['time']): + latest[event['action']] = event + actions = expected(selection, workflow) + if workflow == 'services': + actions = [action for action in expected(selection, 'restore') if action.startswith('services-')] + actions = list(dict.fromkeys(actions + list(latest))) + result = {'run': run_id, 'workflow': workflow, + 'steps': {action: latest.get(action, {'status': 'not attempted'}) for action in actions}, + 'config_requirements': requirements, 'manual_steps': manual, + 'unreadable_reports': inaccessible} + if execution_error: + result['execution_error'] = execution_error + state = user_state(home) + state.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + path = state / ('summary-' + (run_id or timestamp) + '.json') + temporary = path.with_name('.' + path.name + '.tmp') + temporary.write_text(json.dumps(result, indent=2) + '\n') + temporary.chmod(0o600) + temporary.replace(path) + print('Restoration summary' + (': ' + run_id if run_id else '')) + if execution_error: + print(' Workflow stopped: ' + execution_error) + for action, event in result['steps'].items(): + print(' ' + action + ': ' + event['status']) + details = event.get('details', {}) + for key in ('present', 'installed', 'already_installed', 'enabled', 'applied', 'requested', 'skipped', 'missing', 'failed', 'review', 'kept', 'restored'): + if details.get(key): + print(' ' + key.replace('_', ' ') + ': ' + ', '.join(map(str, details[key]))) + if event.get('error'): + print(' error: ' + event['error']) + for config, entries in requirements.items(): + for entry in entries: + if entry['status'] in ('missing', 'unverified'): + print(' Config requirement ' + entry['status'] + ': ' + config + ' needs ' + entry['package']) + for item in manual: + print(' Manual: ' + item) + for path in inaccessible: + print(' Unreadable report: ' + path) + print('Combined report: ' + str(path)) + return result diff --git a/lib/rice.py b/lib/rice.py index 9075202..c3194b4 100644 --- a/lib/rice.py +++ b/lib/rice.py @@ -16,6 +16,8 @@ REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO / 'tools')) sys.path.insert(0, str(REPO / 'lib')) from capture import sections +import backups +import reporting from setup import (FLATPAKS, SERVICES, required_repositories, existing_repository, repository_config, stage_repositories, publish_repositories, install_flatpaks, enable_services) @@ -87,6 +89,43 @@ def source_policy(selection): return result +def config_requirements(selection, planned=True): + selected = {cp_from_cpv(atom.split('::')[0].split(':')[0].lstrip('<>=~')) + for values in package_map(selection).values() for atom in values} + packages = {atom for name in selection['configs'] for atom in CATALOG[name].get('packages', [])} + installed = {} + query = shutil.which('portageq') + for atom in sorted(packages): + installed[atom] = subprocess.run([query, 'has_version', '/', atom], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode == 0 if query else None + return {name: [{'package': atom, + 'status': 'installed' if installed[atom] else 'selected' if planned and atom in selected else 'unverified' if installed[atom] is None else 'missing'} + for atom in CATALOG[name].get('packages', [])] for name in selection['configs']} + + +def backup_paths(): + selection = {'configs': list(CATALOG), 'groups': GROUPS} + return {str(relative): {'name': name, 'kde': CATALOG[name].get('kde', False)} + for name, _, _, relative in config_entries(selection)} + + +def manual_steps(selection): + steps = [] + if selection.get('firefox_privacy'): + steps.append('Apply Firefox-Privacy after reviewing docs/firefox.md.') + if selection.get('spotify_custom'): + steps.append('Optional Spotify customization requires the manual setup in docs/spotify.md.') + if 'fiw-tools' in selection['groups']: + steps.append('Add Apdatifier through Plasma Add Widgets; FiwNode uses its default config.') + if 'gaming' in selection['groups']: + steps.append('Choose r2modman appearance in its settings and import any wanted profiles through the app (docs/app-configs.md).') + if 'prism' in selection['configs']: + steps.append('Set up Prism accounts, Java and Minecraft instances on this device; see docs/app-configs.md.') + if 'tidewm' in selection['groups']: + steps.append('The optional TideWM live recipe needs an upstream refresh before use.') + return steps + + def merge_kconfig(old, patch): """Edit selected keys while retaining comments and unrelated groups/keys.""" desired = sections(patch) @@ -184,7 +223,7 @@ def apply_configs(selection, home, update=False, conflict='ask'): home = home.resolve() entries = list(config_entries(selection)) check_configs(selection, home) - state_dir = home / '.local/state/Fiw-Gentoo-Dots' + state_dir = backups.user_state(home) index_path = state_dir / 'managed.json' index = json.loads(index_path.read_text()) if index_path.is_file() else {} stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') @@ -231,7 +270,10 @@ def apply_configs(selection, home, update=False, conflict='ask'): else: backup = state_dir / 'backups' / stamp / relative backup.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(dest, backup, follow_symlinks=False) + # Several selected presets may patch the same KConfig file. + # Keep its original version from before this entire operation. + if not backup.exists() and not backup.is_symlink(): + shutil.copy2(dest, backup, follow_symlinks=False) target.parent.mkdir(parents=True, exist_ok=True) tmp = target.parent / ('.' + target.name + '.fiw-dots-' + stamp) if src.is_symlink(): @@ -244,7 +286,9 @@ def apply_configs(selection, home, update=False, conflict='ask'): index[key] = new_hash report['applied'].append(key) state_dir.mkdir(parents=True, exist_ok=True) - index_path.write_text(json.dumps(index, indent=2) + '\n') + temporary = index_path.with_name('.managed-' + stamp) + temporary.write_text(json.dumps(index, indent=2) + '\n') + temporary.replace(index_path) (state_dir / 'selection.json').write_text(json.dumps(selection, indent=2) + '\n') (state_dir / ('report-' + stamp + '.json')).write_text(json.dumps(report, indent=2) + '\n') if 'fonts' in selection['configs'] and home == Path.home().resolve() and shutil.which('fc-cache'): @@ -318,11 +362,17 @@ def preview(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:'] + requirements = config_requirements(selection) for name in selection['configs']: lines.append(' ' + name + ': ' + CATALOG[name]['label']) + if requirements[name]: + lines.append(' Needs: ' + ', '.join(entry['package'] + ' [' + entry['status'] + ']' for entry in requirements[name])) + if CATALOG[name].get('note'): + lines.append(' ' + CATALOG[name]['note']) lines += ['', 'Config files: ' + str(len(list(config_entries(selection)))), 'Root configuration files: ' + str(len(root_files(selection))), 'Existing config conflicts: ask; updates preserve edits as .new.'] + lines.append('Config requirements are informational; config choices do not automatically select package groups.') if selection['bootloader'] != 'keep': lines.append('Boot deployment: target ESP and boot files are previewed before applying; firmware changes are separately selectable (docs/boot.md).') @@ -429,9 +479,9 @@ def install_packages(selection): print('Skipped: ' + (', '.join(skipped) or 'none')) if not accepted: print('All requested packages were skipped; no live configuration applied.') - return + return {'status': 'skipped', 'requested': requested, 'skipped': skipped, 'missing': requested} if ask('Apply the previewed Portage files and install the accepted packages?', ['apply', 'cancel'], 'cancel') != 'apply': - return + return {'status': 'cancelled', 'requested': requested, 'skipped': skipped} 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') @@ -485,7 +535,8 @@ def install_packages(selection): missing = [atom for atom in requested if subprocess.run( ['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0] - report = {'requested': requested, 'skipped': skipped, 'emerge_exit_code': result.returncode, + report = {'requested': requested, 'present': [atom for atom in accepted if atom not in missing], + 'skipped': skipped, 'emerge_exit_code': result.returncode, 'missing': missing, 'pending': []} state = Path('/var/lib/Fiw-Gentoo-Dots') @@ -494,10 +545,13 @@ def install_packages(selection): print('Package report: ' + str(state / ('packages-' + stamp + '.json'))) print('Missing packages: ' + (', '.join(missing) or 'none')) if result.returncode: - raise RuntimeError('Some packages did not install; consult the package report and emerge output.') + error = RuntimeError('Some packages did not install; consult the package report and emerge output.') + error.report = report + raise error 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. Use the selected Flatpak, service and boot actions or the full restore workflow to finish setup.') + return report def check_packages(selection): @@ -539,6 +593,13 @@ def main(): 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('--list-backups', action='store_true') + parser.add_argument('--backup-plan', metavar='ID') + parser.add_argument('--restore-backup', metavar='ID') + parser.add_argument('--summary', action='store_true') + parser.add_argument('--run-id') + parser.add_argument('--execution-error', help=argparse.SUPPRESS) + parser.add_argument('--workflow', choices=['restore', 'configs', 'packages', 'flatpaks', 'services', 'boot', 'backup'], default='restore') parser.add_argument('--update', action='store_true') parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask') args = parser.parse_args() @@ -548,29 +609,63 @@ def main(): 'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')})) return selection = load_selection(args.selection, args.profile) + if args.run_id: + reporting.validate_run(args.run_id) + home = args.home.resolve() + if args.list_backups: + print(json.dumps(backups.list_backups(home, backup_paths()))) + return + if args.backup_plan: + print(json.dumps(backups.plan(home, args.backup_plan, backup_paths()), indent=2)) + return + if args.summary: + if os.geteuid() == 0: + raise RuntimeError('Create the combined restoration summary as the target user.') + reporting.summarize(selection, home, config_requirements(selection, planned=False), manual_steps(selection), args.run_id, args.workflow, args.execution_error) + return + action = ('configs' if args.apply_configs else 'packages' if args.install_packages else + 'flatpaks' if args.install_flatpaks else 'services-system' if args.enable_services and os.geteuid() == 0 else + 'services-user' if args.enable_services else 'boot' if args.deploy_bootloader else + 'backup' if args.restore_backup else 'preflight' if args.check_configs and args.run_id else None) + if action: + run_id = args.run_id or datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ') + details = {} + try: + if action == 'configs': + if os.geteuid() == 0: + raise RuntimeError('Apply user configs as the target user, not as root.') + details = apply_configs(selection, home, args.update, args.conflict) + elif action == 'packages': + details = install_packages(selection) + elif action == 'flatpaks': + details = install_flatpaks(selection, ask) + elif action.startswith('services-'): + details = enable_services(selection, ask) + elif action == 'boot': + from boot import deploy + details = deploy(selection['bootloader'], args.esp, ask) + elif action == 'backup': + details = backups.restore(home, args.restore_backup, backup_paths(), ask, active_plasma, args.conflict) + else: + check_configs(selection, home) + print('Config restoration preflight passed.') + except (ValueError, OSError, RuntimeError, subprocess.CalledProcessError) as error: + reporting.record(selection, run_id, action, home, getattr(error, 'report', details) or {}, error) + raise + event = reporting.record(selection, run_id, action, home, details or {}) + if event['status'] == 'cancelled': + raise SystemExit(130) + return 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.') - apply_configs(selection, args.home, args.update, args.conflict) - elif args.install_packages: - 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) + elif args.boot_plan: + from boot import plan + print(json.dumps(plan(selection['bootloader'], args.esp), indent=2)) else: print(preview(selection)) diff --git a/lib/setup.py b/lib/setup.py index 990db99..10560a7 100644 --- a/lib/setup.py +++ b/lib/setup.py @@ -114,7 +114,7 @@ def install_flatpaks(selection, ask): apps = selection.get('flatpaks', []) if not apps: print('No Flatpaks selected.') - return + return {'status': 'skipped'} 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( @@ -140,7 +140,10 @@ def install_flatpaks(selection, ask): 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.') + error = RuntimeError('Some Flatpaks did not install; see the report.') + error.report = report + raise error + return report def enable_services(selection, ask): @@ -150,7 +153,7 @@ def enable_services(selection, ask): if SERVICES[key]['scope'] == scope for unit in SERVICES[key]['units'])) if not units: print('No ' + scope + ' services selected.') - return + return {'status': 'skipped'} command = ['systemctl'] + ([] if system else ['--user']) report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []} available = [] @@ -171,4 +174,7 @@ def enable_services(selection, ask): 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.') + error = RuntimeError('Some services could not be enabled; see the report.') + error.report = report + raise error + return report diff --git a/presets/fiw-ryzen.json b/presets/fiw-ryzen.json index 256b560..84c26d4 100644 --- a/presets/fiw-ryzen.json +++ b/presets/fiw-ryzen.json @@ -22,7 +22,10 @@ "mangohud", "vesktop", "vscode", - "fonts" + "fonts", + "ark", + "gwenview", + "prism" ], "kernel": "custom", "bootloader": "keep", diff --git a/presets/stock.json b/presets/stock.json index 963a10f..681957a 100644 --- a/presets/stock.json +++ b/presets/stock.json @@ -20,7 +20,10 @@ "mangohud", "vesktop", "vscode", - "fonts" + "fonts", + "ark", + "gwenview", + "prism" ], "kernel": "binary", "bootloader": "keep", diff --git a/tests/test_restoration.py b/tests/test_restoration.py new file mode 100644 index 0000000..f745596 --- /dev/null +++ b/tests/test_restoration.py @@ -0,0 +1,119 @@ +import importlib.util +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('rice', Path(__file__).resolve().parents[1] / 'lib/rice.py') +rice = importlib.util.module_from_spec(spec) +spec.loader.exec_module(rice) + + +class RestorationTests(unittest.TestCase): + def test_app_capture_uses_explicit_preferences_and_omits_accounts_and_paths(self): + import capture + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + home, repo = root / 'home', root / 'repo' + source = home / '.local/share/PrismLauncher/prismlauncher.cfg' + source.parent.mkdir(parents=True) + source.write_text('[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\nSelectedInstance=local-instance\nJavaPath=/device/java\nModrinthToken=local-only\n[Accounts]\nuser=local-user\n') + with patch.object(capture, 'HOME', home), patch.object(capture, 'REPO', repo): + capture.capture_apps() + result = (repo / 'configs/prism/kconfig/.local/share/PrismLauncher/prismlauncher.cfg').read_text() + self.assertEqual(result, '[General]\nApplicationTheme=Breeze\nIconTheme=breeze_dark\n') + + def test_config_requirements_do_not_select_packages_and_report_actual_missing_apps(self): + selection = rice.load_selection() + selection.update(groups=[], configs=['kitty'], extras=[], bootloader='keep') + with patch.object(rice.shutil, 'which', return_value='portageq'), patch.object(rice.subprocess, 'run', return_value=subprocess.CompletedProcess([], 1)): + requirements = rice.config_requirements(selection) + self.assertEqual(requirements['kitty'], [{'package': 'x11-terms/kitty', 'status': 'missing'}]) + self.assertEqual(rice.package_map(selection), {}) + selection['groups'] = ['fiw-apps'] + self.assertEqual(rice.config_requirements(selection)['kitty'][0]['status'], 'selected') + self.assertEqual(rice.config_requirements(selection, planned=False)['kitty'][0]['status'], 'missing') + + def test_backup_restore_preserves_current_file_and_future_updates_preserve_restored_edits(self): + selection = rice.load_selection() + selection.update(groups=[], configs=['fastfetch']) + with tempfile.TemporaryDirectory() as temp, patch.object(rice.backups.os, 'geteuid', return_value=1000): + home = Path(temp) + target = home / '.config/fastfetch/config.jsonc' + target.parent.mkdir(parents=True) + target.write_text('original personal config\n') + rice.apply_configs(selection, home, conflict='apply') + current = target.read_text() + choices = rice.backups.list_backups(home, rice.backup_paths()) + self.assertEqual(len(choices), 1) + report = rice.backups.restore(home, choices[0]['id'], rice.backup_paths(), lambda *args: 'restore', lambda *args: False, conflict='apply') + self.assertEqual(target.read_text(), 'original personal config\n') + saved = rice.backups.user_state(home) / 'backups' / report['current_backup'] / '.config/fastfetch/config.jsonc' + self.assertEqual(saved.read_text(), current) + rice.apply_configs(selection, home, update=True, conflict='apply') + self.assertEqual(target.read_text(), 'original personal config\n') + self.assertTrue(Path(str(target) + '.new').is_file()) + + def test_backup_preview_rejects_unknown_paths_and_destinations_outside_home(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + home, outside = root / 'home', root / 'outside' + home.mkdir(); outside.mkdir() + backup_id = '20260101T000000.000000Z' + backup = rice.backups.user_state(home) / 'backups' / backup_id + source = backup / '.config/fastfetch/config.jsonc' + source.parent.mkdir(parents=True); source.write_text('previous') + (home / '.config').symlink_to(outside, target_is_directory=True) + with self.assertRaises(RuntimeError): + rice.backups.plan(home, backup_id, rice.backup_paths()) + (home / '.config').unlink() + (backup / 'unrecognized').write_text('unrecognized') + with self.assertRaises(RuntimeError): + rice.backups.plan(home, backup_id, rice.backup_paths()) + self.assertEqual(list(outside.iterdir()), []) + + def test_shared_config_backup_keeps_original_before_all_selected_patches(self): + selection = rice.load_selection() + selection['configs'] = ['kde-style', 'kde-shortcuts'] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); home = root / 'home'; home.mkdir() + relative = Path('.config/kwinrc') + target = home / relative; target.parent.mkdir() + original = '[Appearance]\ncolour=orange\n[Desktops]\nNumber=4\n' + target.write_text(original) + style, shortcuts = root / 'style', root / 'shortcuts' + style.write_text('[Appearance]\ncolour=purple\n') + shortcuts.write_text('[Desktops]\nNumber=1\n') + entries = [('kde-style', 'kconfig', style, relative), ('kde-shortcuts', 'kconfig', shortcuts, relative)] + with patch.object(rice, 'config_entries', return_value=entries): + rice.apply_configs(selection, home, conflict='apply') + saved = list((rice.backups.user_state(home) / 'backups').rglob('kwinrc')) + self.assertEqual(len(saved), 1) + self.assertEqual(saved[0].read_text(), original) + self.assertIn('colour=purple', target.read_text()) + self.assertIn('Number=1', target.read_text()) + + def test_combined_report_correlates_scopes_and_excludes_unrelated_runs(self): + selection = rice.load_selection() + selection.update(groups=['gaming'], configs=['fastfetch'], flatpaks=['org.vinegarhq.Sober'], services=['audio']) + with tempfile.TemporaryDirectory() as temp: + root = Path(temp); home = root / 'home'; home.mkdir() + with patch.object(rice.reporting, 'SYSTEM_STATE', root / 'system'): + with patch.object(rice.reporting.os, 'geteuid', return_value=0): + rice.reporting.record(selection, 'current', 'packages', home, {'present': ['app-misc/fastfetch'], 'skipped': ['games-util/example'], 'missing': ['games-util/example']}) + with patch.object(rice.reporting.os, 'geteuid', return_value=1000): + rice.reporting.record(selection, 'current', 'flatpaks', home, {'failed': ['org.vinegarhq.Sober']}, RuntimeError('download failed')) + rice.reporting.record(selection, 'different', 'configs', home, {'applied': ['unrelated-file']}) + result = rice.reporting.summarize(selection, home, {}, ['manual reminder'], 'current') + self.assertEqual(result['steps']['packages']['status'], 'partial') + self.assertEqual(result['steps']['flatpaks']['status'], 'failed') + self.assertEqual(result['steps']['configs']['status'], 'not attempted') + self.assertEqual(result['steps']['services-user']['status'], 'not attempted') + self.assertNotIn('unrelated-file', json.dumps(result)) + self.assertEqual(result['manual_steps'], ['manual reminder']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/capture.py b/tools/capture.py index 56537a9..d6ee6f9 100644 --- a/tools/capture.py +++ b/tools/capture.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Capture explicitly selected preferences, never whole application profiles.""" +import argparse import json import re import shutil @@ -7,6 +8,18 @@ from pathlib import Path REPO = Path(__file__).resolve().parents[1] HOME = Path.home() +APP_KEYS = { + 'ark': ('.config/arkrc', { + '[Extraction]': ['openDestinationFolderAfterExtraction'], + '[General]': ['LockSidebar', 'ShowSidebar'], '[MainWindow]': ['StatusBar']}), + 'gwenview': ('.config/gwenviewrc', { + '[MainWindow]': ['MenuBar'], '[SideBar]': ['InformationSplitterSizes']}), + 'prism': ('.local/share/PrismLauncher/prismlauncher.cfg', {'[General]': [ + 'ApplicationTheme', 'IconTheme', 'ConsoleFont', 'ConsoleFontSize', + 'ConsoleMaxLines', 'ConsoleOverflowStop', 'ShowConsole', 'ShowConsoleOnError', + 'AutoCloseConsole', 'CloseAfterLaunch', 'QuitAfterGameStop', 'RecordGameTime', + 'ShowGameTime', 'ShowGameTimeWithoutDays', 'ShowGlobalGameTime', + 'MenuBarInsteadOfToolBar', 'StatusBarVisible', 'ToolbarsLocked']})} def sections(text): @@ -78,7 +91,13 @@ def copy_assets(source, destination): shutil.copy2(src, dest) +def capture_apps(): + for name, (relative, keys) in APP_KEYS.items(): + kconfig(name, relative, keys=keys) + + def capture(): + capture_apps() kconfig("kde-style", ".config/kdeglobals", groups=[g for g in sections((HOME / '.config/kdeglobals').read_text()) if g.startswith(('[Colors:', '[ColorEffects:'))] @@ -183,4 +202,10 @@ def capture(): if __name__ == '__main__': - capture() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--apps-only', action='store_true', help='Recapture only Ark, Gwenview and Prism preferences') + args = parser.parse_args() + if args.apps_only: + capture_apps() + else: + capture()