Add optional app preferences, config backups and combined reports

This commit is contained in:
fi3w0 2026-10-03 06:11:51 +02:00
parent 8d59cc4f06
commit 1744a5ef3d
22 changed files with 1020 additions and 62 deletions

View file

@ -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

View file

@ -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,13 +55,21 @@ 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
stage, cursor, height, width, scroll int
preview, errorText, action, backupID string
done, loading 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)
}

View file

@ -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")
}
}

View file

@ -0,0 +1,9 @@
[Extraction]
openDestinationFolderAfterExtraction=true
[General]
LockSidebar=true
ShowSidebar=true
[MainWindow]
StatusBar=Disabled

View file

@ -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."
}
}

View file

@ -0,0 +1,5 @@
[MainWindow]
MenuBar=Disabled
[SideBar]
InformationSplitterSizes=211,244

View file

@ -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

56
docs/app-configs.md Normal file
View file

@ -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.

32
docs/backups.md Normal file
View file

@ -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.

42
docs/reports.md Normal file
View file

@ -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.

View file

@ -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 |

View file

@ -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

6
go.mod
View file

@ -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

123
lib/backups.py Normal file
View file

@ -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

View file

@ -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 <file>.')
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():

128
lib/reporting.py Normal file
View file

@ -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

View file

@ -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,6 +270,9 @@ def apply_configs(selection, home, update=False, conflict='ask'):
else:
backup = state_dir / 'backups' / stamp / relative
backup.parent.mkdir(parents=True, exist_ok=True)
# 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)
@ -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:
elif args.boot_plan:
from boot import plan
print(json.dumps(plan(selection['bootloader'], args.esp), indent=2))
else:
deploy(selection['bootloader'], args.esp, ask)
else:
print(preview(selection))

View file

@ -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

View file

@ -22,7 +22,10 @@
"mangohud",
"vesktop",
"vscode",
"fonts"
"fonts",
"ark",
"gwenview",
"prism"
],
"kernel": "custom",
"bootloader": "keep",

View file

@ -20,7 +20,10 @@
"mangohud",
"vesktop",
"vscode",
"fonts"
"fonts",
"ark",
"gwenview",
"prism"
],
"kernel": "binary",
"bootloader": "keep",

119
tests/test_restoration.py Normal file
View file

@ -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()

View file

@ -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__':
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()