Add optional app preferences, config backups and combined reports
This commit is contained in:
parent
8d59cc4f06
commit
1744a5ef3d
22 changed files with 1020 additions and 62 deletions
|
|
@ -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 final preview. Enter saves without applying. `r` runs the full restore;
|
||||||
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
|
`a` restores configs, `i` installs Portage packages, `f` installs Flatpaks,
|
||||||
`v` enables selected services and `b` deploys the selected bootloader.
|
`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
|
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.
|
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
|
tree layout and purple colours with the built-in Gentoo logo. MangoHud and
|
||||||
Vesktop preferences are included; browser profiles, chat sessions and account
|
Vesktop preferences are included; browser profiles, chat sessions and account
|
||||||
data are excluded. App autostart is a separate optional selection.
|
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
|
Spotify is stock by default. The existing customization scripts remain an
|
||||||
[explicit optional step](docs/spotify.md). [Firefox-Privacy](docs/firefox.md)
|
[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`.
|
Updates leave locally edited configs intact and write the proposal to `.new`.
|
||||||
KConfig patches preserve unrelated keys and groups; JSON patches preserve
|
KConfig patches preserve unrelated keys and groups; JSON patches preserve
|
||||||
unrelated preferences and login data already on the target device.
|
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
|
## Project layout
|
||||||
|
|
||||||
|
|
|
||||||
158
cmd/dots/main.go
158
cmd/dots/main.go
|
|
@ -2,14 +2,17 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
)
|
)
|
||||||
|
|
||||||
type selection struct {
|
type selection struct {
|
||||||
|
|
@ -52,13 +55,21 @@ type previewMsg struct {
|
||||||
content string
|
content string
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
type backupChoice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
type backupsMsg struct {
|
||||||
|
choices []backupChoice
|
||||||
|
err error
|
||||||
|
}
|
||||||
type model struct {
|
type model struct {
|
||||||
repo string
|
repo string
|
||||||
catalog catalog
|
catalog catalog
|
||||||
selection selection
|
selection selection
|
||||||
rows []row
|
rows []row
|
||||||
stage, cursor, height, scroll int
|
stage, cursor, height, width, scroll int
|
||||||
preview, errorText, action string
|
preview, errorText, action, backupID string
|
||||||
done, loading bool
|
done, loading bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,16 +226,57 @@ func (m model) makePreview() tea.Cmd {
|
||||||
return previewMsg{string(output), err}
|
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) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch v := msg.(type) {
|
switch v := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.height = v.Height
|
m.height = v.Height
|
||||||
|
m.width = v.Width
|
||||||
|
if m.scroll >= len(m.previewLines()) {
|
||||||
|
m.scroll = len(m.previewLines()) - 1
|
||||||
|
}
|
||||||
case previewMsg:
|
case previewMsg:
|
||||||
m.loading = false
|
m.loading = false
|
||||||
m.preview = v.content
|
m.preview = v.content
|
||||||
if v.err != nil {
|
if v.err != nil {
|
||||||
m.errorText = v.err.Error()
|
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:
|
case tea.KeyMsg:
|
||||||
key := v.String()
|
key := v.String()
|
||||||
if key == "ctrl+c" || key == "q" {
|
if key == "ctrl+c" || key == "q" {
|
||||||
|
|
@ -233,21 +285,40 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.stage == 5 {
|
if m.stage == 5 || m.stage == 7 {
|
||||||
switch key {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.scroll > 0 {
|
if m.scroll > 0 {
|
||||||
m.scroll--
|
m.scroll--
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
if m.scroll < len(strings.Split(m.preview, "\n"))-1 {
|
if m.scroll < len(m.previewLines())-1 {
|
||||||
m.scroll++
|
m.scroll++
|
||||||
}
|
}
|
||||||
case "esc":
|
case "esc":
|
||||||
m.stage--
|
m.stage--
|
||||||
m.prepare()
|
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":
|
case "enter", "s", "a", "i", "f", "v", "b", "r":
|
||||||
if m.errorText == "" {
|
if m.errorText == "" {
|
||||||
|
if m.stage == 7 {
|
||||||
|
if key != "enter" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
key = "u"
|
||||||
|
}
|
||||||
m.done = true
|
m.done = true
|
||||||
m.action = key
|
m.action = key
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
@ -255,6 +326,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
return m, nil
|
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 {
|
switch key {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
|
|
@ -325,17 +423,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
const purple = "\033[38;2;157;143;217m"
|
const purple = "\033[38;2;157;143;217m"
|
||||||
const reset = "\033[0m"
|
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"
|
text := purple + " Fiw-Gentoo-Dots\n" + reset + " " + titles[m.stage] + " · " + m.selection.Name + "\n\n"
|
||||||
budget := m.height - 10
|
budget := m.height - 10
|
||||||
if budget < 5 {
|
if budget < 5 {
|
||||||
budget = 5
|
budget = 5
|
||||||
}
|
}
|
||||||
if m.stage == 5 {
|
if m.stage == 5 || m.stage == 7 {
|
||||||
if m.loading {
|
if m.loading {
|
||||||
return text + " Preparing preview…\n"
|
return text + " Preparing preview…\n"
|
||||||
}
|
}
|
||||||
lines := strings.Split(m.preview, "\n")
|
lines := m.previewLines()
|
||||||
end := m.scroll + budget
|
end := m.scroll + budget
|
||||||
if end > len(lines) {
|
if end > len(lines) {
|
||||||
end = len(lines)
|
end = len(lines)
|
||||||
|
|
@ -343,12 +441,26 @@ func (m model) View() string {
|
||||||
for _, line := range lines[m.scroll:end] {
|
for _, line := range lines[m.scroll:end] {
|
||||||
text += " " + line + "\n"
|
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 != "" {
|
if m.errorText != "" {
|
||||||
text += " " + m.errorText + "\n"
|
text += " " + m.errorText + "\n"
|
||||||
}
|
}
|
||||||
return text
|
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
|
start := 0
|
||||||
if m.cursor >= budget {
|
if m.cursor >= budget {
|
||||||
start = m.cursor - budget + 1
|
start = m.cursor - budget + 1
|
||||||
|
|
@ -373,6 +485,9 @@ func (m model) View() string {
|
||||||
}
|
}
|
||||||
text += line + "\n"
|
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"
|
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(final.preview)
|
||||||
fmt.Println("\nSaved selection:", path)
|
fmt.Println("\nSaved selection:", path)
|
||||||
run := func(action string, root bool) error {
|
runID := fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid())
|
||||||
args := []string{filepath.Join(repo, "lib", "rice.py"), "--selection", path, action}
|
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"
|
program := "python3"
|
||||||
if root {
|
if root {
|
||||||
program = "sudo"
|
program = "sudo"
|
||||||
|
|
@ -458,17 +575,26 @@ func main() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var actionErr error
|
var actionErr error
|
||||||
|
workflow := "restore"
|
||||||
switch final.action {
|
switch final.action {
|
||||||
case "a":
|
case "a":
|
||||||
|
workflow = "configs"
|
||||||
actionErr = run("--apply-configs", false)
|
actionErr = run("--apply-configs", false)
|
||||||
case "i":
|
case "i":
|
||||||
|
workflow = "packages"
|
||||||
actionErr = run("--install-packages", true)
|
actionErr = run("--install-packages", true)
|
||||||
case "f":
|
case "f":
|
||||||
|
workflow = "flatpaks"
|
||||||
actionErr = run("--install-flatpaks", false)
|
actionErr = run("--install-flatpaks", false)
|
||||||
case "v":
|
case "v":
|
||||||
|
workflow = "services"
|
||||||
actionErr = services()
|
actionErr = services()
|
||||||
case "b":
|
case "b":
|
||||||
|
workflow = "boot"
|
||||||
actionErr = run("--deploy-bootloader", true)
|
actionErr = run("--deploy-bootloader", true)
|
||||||
|
case "u":
|
||||||
|
workflow = "backup"
|
||||||
|
actionErr = run("--restore-backup", false, final.backupID)
|
||||||
case "r":
|
case "r":
|
||||||
actionErr = run("--check-configs", false)
|
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) {
|
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.")
|
fmt.Println("Saved. Choose an action in the TUI or use the commands in docs/setup.md.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
summaryArgs := []string{"--workflow", workflow}
|
||||||
if actionErr != nil {
|
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)
|
fmt.Fprintln(os.Stderr, actionErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,3 +67,51 @@ func TestSetupCheckboxesSurviveFinalPreview(t *testing.T) {
|
||||||
t.Fatalf("setup selections lost before preview: %+v", result.selection)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
9
configs/ark/kconfig/.config/arkrc
Normal file
9
configs/ark/kconfig/.config/arkrc
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[Extraction]
|
||||||
|
openDestinationFolderAfterExtraction=true
|
||||||
|
|
||||||
|
[General]
|
||||||
|
LockSidebar=true
|
||||||
|
ShowSidebar=true
|
||||||
|
|
||||||
|
[MainWindow]
|
||||||
|
StatusBar=Disabled
|
||||||
|
|
@ -2,58 +2,143 @@
|
||||||
"kde-style": {
|
"kde-style": {
|
||||||
"label": "KDE + GTK colours, fonts, cursor and decorations",
|
"label": "KDE + GTK colours, fonts, cursor and decorations",
|
||||||
"group": "kde",
|
"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": {
|
"kde-shortcuts": {
|
||||||
"label": "All KDE shortcuts + keyboard layouts + screenshot helpers",
|
"label": "All KDE shortcuts + keyboard layouts + screenshot helpers",
|
||||||
"group": "kde",
|
"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": {
|
"dolphin": {
|
||||||
"label": "Dolphin toolbar, sidebar and transparency",
|
"label": "Dolphin toolbar, sidebar and transparency",
|
||||||
"group": "fiw-apps",
|
"group": "fiw-apps",
|
||||||
"kde": true
|
"kde": true,
|
||||||
|
"packages": [
|
||||||
|
"kde-apps/dolphin",
|
||||||
|
"kde-plasma/kwin"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"spectacle": {
|
"spectacle": {
|
||||||
"label": "Spectacle capture preferences",
|
"label": "Spectacle capture preferences",
|
||||||
"group": "fiw-apps",
|
"group": "fiw-apps",
|
||||||
"kde": true
|
"kde": true,
|
||||||
|
"packages": [
|
||||||
|
"kde-plasma/spectacle"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"fish": {
|
"fish": {
|
||||||
"label": "Fish aliases, greeting and notification settings",
|
"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": {
|
"kitty": {
|
||||||
"label": "Kitty lavender profiles",
|
"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": {
|
"neovim": {
|
||||||
"label": "Neovim transparent purple style",
|
"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": {
|
"fastfetch": {
|
||||||
"label": "Fastfetch tree layout with Gentoo logo",
|
"label": "Fastfetch tree layout with Gentoo logo",
|
||||||
"group": "cli"
|
"group": "cli",
|
||||||
|
"packages": [
|
||||||
|
"app-misc/fastfetch"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"mangohud": {
|
"mangohud": {
|
||||||
"label": "MangoHud appearance and detail presets",
|
"label": "MangoHud appearance and detail presets",
|
||||||
"group": "gaming"
|
"group": "gaming",
|
||||||
|
"packages": [
|
||||||
|
"games-util/mangohud"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vesktop": {
|
"vesktop": {
|
||||||
"label": "Vesktop preferences (no account data)",
|
"label": "Vesktop preferences (no account data)",
|
||||||
"group": "fiw-apps"
|
"group": "fiw-apps",
|
||||||
|
"packages": [
|
||||||
|
"net-im/vesktop-bin"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"label": "VS Code preferences",
|
"label": "VS Code preferences",
|
||||||
"group": "fiw-apps"
|
"group": "fiw-apps",
|
||||||
|
"packages": [
|
||||||
|
"app-editors/vscode"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"autostart": {
|
"autostart": {
|
||||||
"label": "Optional application autostart",
|
"label": "Optional application autostart",
|
||||||
"group": null,
|
"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": {
|
"fonts": {
|
||||||
"label": "JetBrainsMono Nerd Font Mono (four styles)",
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
configs/gwenview/kconfig/.config/gwenviewrc
Normal file
5
configs/gwenview/kconfig/.config/gwenviewrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[MainWindow]
|
||||||
|
MenuBar=Disabled
|
||||||
|
|
||||||
|
[SideBar]
|
||||||
|
InformationSplitterSizes=211,244
|
||||||
|
|
@ -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
56
docs/app-configs.md
Normal 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
32
docs/backups.md
Normal 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
42
docs/reports.md
Normal 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.
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
| `lib/rice.py` | Validates selections, previews plans, restores configs and installs Portage packages | Called by the entry point/TUI |
|
| `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/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/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 |
|
| `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/capture.py` | Recaptures the explicit preference allowlist without copying full personal profiles | Maintenance command; review the diff |
|
||||||
| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts |
|
| `tools/shortcuts.py` | Regenerates the readable shortcut reference from captured repo files | Run after changing/capturing shortcuts |
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ At the final preview:
|
||||||
| v | Enable selected services |
|
| v | Enable selected services |
|
||||||
| a | Restore selected user configs |
|
| a | Restore selected user configs |
|
||||||
| b | Preview and deploy the selected bootloader |
|
| 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
|
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
|
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
|
The root service command handles system services; the regular-user command
|
||||||
handles PipeWire units. User Flatpaks and user configs are never applied as root.
|
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
|
## 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.
|
The separate fonts config includes four JetBrainsMono Nerd Font Mono styles.
|
||||||
Vesktop's Midnight CSS is included with a fixed upstream snapshot. See the
|
Vesktop's Midnight CSS is included with a fixed upstream snapshot. See the
|
||||||
[KDE styling reference](kde-style.md) and [shortcut list](shortcuts.md).
|
[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:
|
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
|
replacement and receive backups too. Reports and user backups live under
|
||||||
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
|
`~/.local/state/Fiw-Gentoo-Dots`; system reports and backups live under
|
||||||
`/var/lib/Fiw-Gentoo-Dots`.
|
`/var/lib/Fiw-Gentoo-Dots`.
|
||||||
|
Use the TUI's **u** action to [restore a user config backup](backups.md).
|
||||||
|
|
||||||
## Fiw tools
|
## Fiw tools
|
||||||
|
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -2,13 +2,15 @@ module github.com/Fi3w0/Fiw-Gentoo-Dots
|
||||||
|
|
||||||
go 1.24.0
|
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 (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // 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/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
|
|
||||||
123
lib/backups.py
Normal file
123
lib/backups.py
Normal 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
|
||||||
|
|
@ -338,7 +338,7 @@ def firmware_commands(target, mode, loader_path, choice, listing):
|
||||||
def deploy(mode, esp=None, ask=confirm):
|
def deploy(mode, esp=None, ask=confirm):
|
||||||
if mode == 'keep':
|
if mode == 'keep':
|
||||||
print('Keeping the current bootloader.')
|
print('Keeping the current bootloader.')
|
||||||
return
|
return {'status': 'skipped'}
|
||||||
if os.geteuid() != 0:
|
if os.geteuid() != 0:
|
||||||
raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection <file>.')
|
raise RuntimeError('Boot deployment needs root. Run sudo ./install --deploy-bootloader --selection <file>.')
|
||||||
proposed = plan(mode, esp)
|
proposed = plan(mode, esp)
|
||||||
|
|
@ -360,7 +360,7 @@ def deploy(mode, esp=None, ask=confirm):
|
||||||
if commands:
|
if commands:
|
||||||
print('Proposed firmware commands: ' + json.dumps(commands))
|
print('Proposed firmware commands: ' + json.dumps(commands))
|
||||||
if ask('Deploy the previewed bootloader and kernel refresh hook?', ['deploy', 'cancel'], 'cancel') != 'deploy':
|
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')
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
||||||
backups = MANAGED / 'backups' / ('boot-' + stamp)
|
backups = MANAGED / 'backups' / ('boot-' + stamp)
|
||||||
folder = Path(proposed['esp']['path']) / EFI_DIRECTORY
|
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,
|
report.write_text(json.dumps({'plan': proposed, 'firmware_choice': firmware,
|
||||||
'backups': str(backups)}, indent=2) + '\n')
|
'backups': str(backups)}, indent=2) + '\n')
|
||||||
print('Boot deployment finished. Report: ' + str(report))
|
print('Boot deployment finished. Report: ' + str(report))
|
||||||
|
return {'status': 'completed', 'mode': mode, 'firmware_choice': firmware,
|
||||||
|
'report': str(report), 'backups': str(backups)}
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
|
||||||
128
lib/reporting.py
Normal file
128
lib/reporting.py
Normal 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
|
||||||
137
lib/rice.py
137
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 / 'tools'))
|
||||||
sys.path.insert(0, str(REPO / 'lib'))
|
sys.path.insert(0, str(REPO / 'lib'))
|
||||||
from capture import sections
|
from capture import sections
|
||||||
|
import backups
|
||||||
|
import reporting
|
||||||
from setup import (FLATPAKS, SERVICES, required_repositories,
|
from setup import (FLATPAKS, SERVICES, required_repositories,
|
||||||
existing_repository, repository_config, stage_repositories,
|
existing_repository, repository_config, stage_repositories,
|
||||||
publish_repositories, install_flatpaks, enable_services)
|
publish_repositories, install_flatpaks, enable_services)
|
||||||
|
|
@ -87,6 +89,43 @@ def source_policy(selection):
|
||||||
return result
|
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):
|
def merge_kconfig(old, patch):
|
||||||
"""Edit selected keys while retaining comments and unrelated groups/keys."""
|
"""Edit selected keys while retaining comments and unrelated groups/keys."""
|
||||||
desired = sections(patch)
|
desired = sections(patch)
|
||||||
|
|
@ -184,7 +223,7 @@ def apply_configs(selection, home, update=False, conflict='ask'):
|
||||||
home = home.resolve()
|
home = home.resolve()
|
||||||
entries = list(config_entries(selection))
|
entries = list(config_entries(selection))
|
||||||
check_configs(selection, home)
|
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_path = state_dir / 'managed.json'
|
||||||
index = json.loads(index_path.read_text()) if index_path.is_file() else {}
|
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')
|
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:
|
else:
|
||||||
backup = state_dir / 'backups' / stamp / relative
|
backup = state_dir / 'backups' / stamp / relative
|
||||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
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)
|
shutil.copy2(dest, backup, follow_symlinks=False)
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = target.parent / ('.' + target.name + '.fiw-dots-' + stamp)
|
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
|
index[key] = new_hash
|
||||||
report['applied'].append(key)
|
report['applied'].append(key)
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
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 / 'selection.json').write_text(json.dumps(selection, indent=2) + '\n')
|
||||||
(state_dir / ('report-' + stamp + '.json')).write_text(json.dumps(report, 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'):
|
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.',
|
'Other packages: prefer binaries; ask compile/skip for additional source builds.',
|
||||||
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
|
'Kernel: ' + selection['kernel'] + (' + binary fallback' if selection['kernel'] == 'custom' else ''),
|
||||||
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
|
'Bootloader: ' + selection['bootloader'], '', 'Selected configs:']
|
||||||
|
requirements = config_requirements(selection)
|
||||||
for name in selection['configs']:
|
for name in selection['configs']:
|
||||||
lines.append(' ' + name + ': ' + CATALOG[name]['label'])
|
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)))),
|
lines += ['', 'Config files: ' + str(len(list(config_entries(selection)))),
|
||||||
'Root configuration files: ' + str(len(root_files(selection))),
|
'Root configuration files: ' + str(len(root_files(selection))),
|
||||||
'Existing config conflicts: ask; updates preserve edits as .new.']
|
'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':
|
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).')
|
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'))
|
print('Skipped: ' + (', '.join(skipped) or 'none'))
|
||||||
if not accepted:
|
if not accepted:
|
||||||
print('All requested packages were skipped; no live configuration applied.')
|
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':
|
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)
|
publish_repositories(fetched)
|
||||||
# Back up each existing managed destination before replacing it.
|
# Back up each existing managed destination before replacing it.
|
||||||
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S.%fZ')
|
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(
|
missing = [atom for atom in requested if subprocess.run(
|
||||||
['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL,
|
['portageq', 'has_version', '/', atom], stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL).returncode != 0]
|
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,
|
'missing': missing,
|
||||||
'pending': []}
|
'pending': []}
|
||||||
state = Path('/var/lib/Fiw-Gentoo-Dots')
|
state = Path('/var/lib/Fiw-Gentoo-Dots')
|
||||||
|
|
@ -494,10 +545,13 @@ def install_packages(selection):
|
||||||
print('Package report: ' + str(state / ('packages-' + stamp + '.json')))
|
print('Package report: ' + str(state / ('packages-' + stamp + '.json')))
|
||||||
print('Missing packages: ' + (', '.join(missing) or 'none'))
|
print('Missing packages: ' + (', '.join(missing) or 'none'))
|
||||||
if result.returncode:
|
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:
|
if 'kde' in selection['groups'] and 'kde-plasma/plasma-login-manager' not in missing:
|
||||||
subprocess.run(['systemctl', 'enable', '--force', 'plasmalogin.service'], check=True)
|
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.')
|
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):
|
def check_packages(selection):
|
||||||
|
|
@ -539,6 +593,13 @@ def main():
|
||||||
parser.add_argument('--deploy-bootloader', action='store_true')
|
parser.add_argument('--deploy-bootloader', action='store_true')
|
||||||
parser.add_argument('--boot-plan', action='store_true')
|
parser.add_argument('--boot-plan', action='store_true')
|
||||||
parser.add_argument('--esp', type=Path)
|
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('--update', action='store_true')
|
||||||
parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
|
parser.add_argument('--conflict', choices=['ask', 'keep', 'apply'], default='ask')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
@ -548,29 +609,63 @@ def main():
|
||||||
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
|
'stock': load_selection(profile='stock'), 'ryzen': load_selection(profile='fiw-ryzen')}))
|
||||||
return
|
return
|
||||||
selection = load_selection(args.selection, args.profile)
|
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:
|
if args.export:
|
||||||
export(selection, args.export)
|
export(selection, args.export)
|
||||||
elif args.check_configs:
|
elif args.check_configs:
|
||||||
check_configs(selection, args.home)
|
check_configs(selection, args.home)
|
||||||
print('Config restoration preflight passed.')
|
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:
|
elif args.check_packages:
|
||||||
check_packages(selection)
|
check_packages(selection)
|
||||||
elif args.install_flatpaks:
|
elif args.boot_plan:
|
||||||
install_flatpaks(selection, ask)
|
from boot import plan
|
||||||
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))
|
print(json.dumps(plan(selection['bootloader'], args.esp), indent=2))
|
||||||
else:
|
|
||||||
deploy(selection['bootloader'], args.esp, ask)
|
|
||||||
else:
|
else:
|
||||||
print(preview(selection))
|
print(preview(selection))
|
||||||
|
|
||||||
|
|
|
||||||
14
lib/setup.py
14
lib/setup.py
|
|
@ -114,7 +114,7 @@ def install_flatpaks(selection, ask):
|
||||||
apps = selection.get('flatpaks', [])
|
apps = selection.get('flatpaks', [])
|
||||||
if not apps:
|
if not apps:
|
||||||
print('No Flatpaks selected.')
|
print('No Flatpaks selected.')
|
||||||
return
|
return {'status': 'skipped'}
|
||||||
if not shutil.which('flatpak'):
|
if not shutil.which('flatpak'):
|
||||||
raise RuntimeError('Flatpak is missing. Install sys-apps/flatpak or select it through the package workflow.')
|
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(
|
missing = [app for app in apps if subprocess.run(
|
||||||
|
|
@ -140,7 +140,10 @@ def install_flatpaks(selection, ask):
|
||||||
write_report('flatpaks', report)
|
write_report('flatpaks', report)
|
||||||
print('Missing Flatpaks: ' + (', '.join(report['failed'] + report['skipped']) or 'none'))
|
print('Missing Flatpaks: ' + (', '.join(report['failed'] + report['skipped']) or 'none'))
|
||||||
if report['failed']:
|
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):
|
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 SERVICES[key]['scope'] == scope for unit in SERVICES[key]['units']))
|
||||||
if not units:
|
if not units:
|
||||||
print('No ' + scope + ' services selected.')
|
print('No ' + scope + ' services selected.')
|
||||||
return
|
return {'status': 'skipped'}
|
||||||
command = ['systemctl'] + ([] if system else ['--user'])
|
command = ['systemctl'] + ([] if system else ['--user'])
|
||||||
report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []}
|
report = {'scope': scope, 'selected': units, 'enabled': [], 'missing': [], 'failed': [], 'skipped': []}
|
||||||
available = []
|
available = []
|
||||||
|
|
@ -171,4 +174,7 @@ def enable_services(selection, ask):
|
||||||
write_report('services', report, system)
|
write_report('services', report, system)
|
||||||
print('Missing service units: ' + (', '.join(report['missing']) or 'none'))
|
print('Missing service units: ' + (', '.join(report['missing']) or 'none'))
|
||||||
if report['failed']:
|
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
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@
|
||||||
"mangohud",
|
"mangohud",
|
||||||
"vesktop",
|
"vesktop",
|
||||||
"vscode",
|
"vscode",
|
||||||
"fonts"
|
"fonts",
|
||||||
|
"ark",
|
||||||
|
"gwenview",
|
||||||
|
"prism"
|
||||||
],
|
],
|
||||||
"kernel": "custom",
|
"kernel": "custom",
|
||||||
"bootloader": "keep",
|
"bootloader": "keep",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@
|
||||||
"mangohud",
|
"mangohud",
|
||||||
"vesktop",
|
"vesktop",
|
||||||
"vscode",
|
"vscode",
|
||||||
"fonts"
|
"fonts",
|
||||||
|
"ark",
|
||||||
|
"gwenview",
|
||||||
|
"prism"
|
||||||
],
|
],
|
||||||
"kernel": "binary",
|
"kernel": "binary",
|
||||||
"bootloader": "keep",
|
"bootloader": "keep",
|
||||||
|
|
|
||||||
119
tests/test_restoration.py
Normal file
119
tests/test_restoration.py
Normal 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()
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Capture explicitly selected preferences, never whole application profiles."""
|
"""Capture explicitly selected preferences, never whole application profiles."""
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -7,6 +8,18 @@ from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[1]
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
HOME = Path.home()
|
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):
|
def sections(text):
|
||||||
|
|
@ -78,7 +91,13 @@ def copy_assets(source, destination):
|
||||||
shutil.copy2(src, dest)
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_apps():
|
||||||
|
for name, (relative, keys) in APP_KEYS.items():
|
||||||
|
kconfig(name, relative, keys=keys)
|
||||||
|
|
||||||
|
|
||||||
def capture():
|
def capture():
|
||||||
|
capture_apps()
|
||||||
kconfig("kde-style", ".config/kdeglobals",
|
kconfig("kde-style", ".config/kdeglobals",
|
||||||
groups=[g for g in sections((HOME / '.config/kdeglobals').read_text())
|
groups=[g for g in sections((HOME / '.config/kdeglobals').read_text())
|
||||||
if g.startswith(('[Colors:', '[ColorEffects:'))]
|
if g.startswith(('[Colors:', '[ColorEffects:'))]
|
||||||
|
|
@ -183,4 +202,10 @@ def capture():
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
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()
|
capture()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue