mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-19 09:44:00 -04:00
feat(switch): dms switch stable|beta replaces installed packages in place
- Settings, plugins and session state are backed up before dms changes w/restore options when returning to stable - Snapshots are only taken when dms itself changes channel - Info annotated on danklinux docs
This commit is contained in:
@@ -794,6 +794,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
systemCmd,
|
||||
systemdCmd,
|
||||
switchUserCmd,
|
||||
switchCmd,
|
||||
dmenuCmd,
|
||||
}...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/backup"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/distros"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/privesc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const switchDMSDep = "dms (DankMaterialShell)"
|
||||
|
||||
// The greeter runs the shell's QML, so it moves with dms or the login screen can break.
|
||||
var switchFamily = []string{switchDMSDep, "quickshell", "dms-greeter"}
|
||||
|
||||
var switchAliases = map[string]string{
|
||||
"dms": switchDMSDep,
|
||||
"quickshell": "quickshell",
|
||||
"dms-greeter": "dms-greeter",
|
||||
"greeter": "dms-greeter",
|
||||
"dankcalendar": "dankcalendar",
|
||||
"danksearch": "danksearch",
|
||||
}
|
||||
|
||||
var switchAssumeYes bool
|
||||
|
||||
var switchCmd = &cobra.Command{
|
||||
Use: "switch <stable|beta> [package...]",
|
||||
Short: "Switch DMS packages between stable and beta builds",
|
||||
Long: `Switch installed DMS packages between stable releases and beta builds.
|
||||
|
||||
With no packages, dms, quickshell and dms-greeter switch together. Name packages
|
||||
(dms, quickshell, greeter, dankcalendar, danksearch) to switch only those.
|
||||
|
||||
Settings, plugins, themes and session state are backed up before anything changes.
|
||||
Settings written by beta can lose options on stable, so switching back to stable
|
||||
offers to restore the backup taken the last time you left stable.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgs: []string{"stable", "beta"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runSwitch(args[0], args[1:]); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
switchCmd.Flags().BoolVarP(&switchAssumeYes, "yes", "y", false, "Do not ask for confirmation; keeps current settings when switching to stable")
|
||||
}
|
||||
|
||||
func runSwitch(channel string, packages []string) error {
|
||||
var target deps.PackageVariant
|
||||
switch channel {
|
||||
case "stable":
|
||||
target = deps.VariantStable
|
||||
case "beta":
|
||||
target = deps.VariantGit
|
||||
default:
|
||||
return fmt.Errorf("unknown channel %q: use stable or beta", channel)
|
||||
}
|
||||
|
||||
explicit := len(packages) > 0
|
||||
names := switchFamily
|
||||
if explicit {
|
||||
names = nil
|
||||
for _, pkg := range packages {
|
||||
name, ok := switchAliases[strings.ToLower(pkg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown package %q", pkg)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
osInfo, err := distros.GetOSInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("detecting distribution: %w", err)
|
||||
}
|
||||
distro, err := distros.NewDistribution(osInfo.Distribution.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The package names are compositor independent, so any window manager works here.
|
||||
var wm deps.WindowManager
|
||||
var swaps []distros.VariantSwap
|
||||
for _, name := range names {
|
||||
swap, status := distros.PlanVariantSwap(distro, wm, name, target)
|
||||
switch status {
|
||||
case distros.SwapNeeded:
|
||||
swaps = append(swaps, swap)
|
||||
case distros.SwapUnsupported:
|
||||
return fmt.Errorf("switching packages is not supported on %s", osInfo.PrettyName)
|
||||
case distros.SwapAlreadyOnTarget:
|
||||
fmt.Printf("%s is already on %s\n", name, channel)
|
||||
case distros.SwapNoVariant:
|
||||
if explicit {
|
||||
fmt.Printf("%s has no separate beta build on %s\n", name, osInfo.PrettyName)
|
||||
}
|
||||
case distros.SwapNotInstalled:
|
||||
if explicit {
|
||||
fmt.Printf("%s is not installed from a package\n", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(swaps) == 0 {
|
||||
fmt.Println("Nothing to switch.")
|
||||
return nil
|
||||
}
|
||||
|
||||
shellChanges := false
|
||||
snapshotLabel := ""
|
||||
fmt.Printf("\nSwitching to %s:\n", channel)
|
||||
for _, swap := range swaps {
|
||||
fmt.Printf(" %s → %s\n", switchLabel(swap.From), switchLabel(swap.To))
|
||||
switch swap.Dep {
|
||||
case switchDMSDep:
|
||||
shellChanges = true
|
||||
snapshotLabel = "stable"
|
||||
if target == deps.VariantStable {
|
||||
snapshotLabel = "beta"
|
||||
}
|
||||
case "quickshell":
|
||||
shellChanges = true
|
||||
}
|
||||
}
|
||||
if snapshotLabel != "" {
|
||||
dir, _ := backup.SnapshotDir()
|
||||
fmt.Printf("\nYour DMS settings will be backed up to %s first.\n", dir)
|
||||
}
|
||||
if shellChanges {
|
||||
fmt.Println("The shell restarts when the switch is done.")
|
||||
}
|
||||
if !switchAssumeYes && !askSwitch("\nContinue? [y/N] ") {
|
||||
return errors.New("cancelled")
|
||||
}
|
||||
|
||||
if snapshotLabel != "" {
|
||||
path, err := backup.Snapshot(snapshotLabel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backing up settings, nothing was changed: %w", err)
|
||||
}
|
||||
if path != "" {
|
||||
fmt.Printf("Backed up settings to %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if privesc.CheckCached(ctx) != nil {
|
||||
if err := privesc.Run(ctx, "", "true"); err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runSwaps(ctx, distro.(distros.VariantSwapper), swaps); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
restore := ""
|
||||
if snapshotLabel == "beta" && !switchAssumeYes {
|
||||
if path, ok := backup.LatestSnapshot("stable"); ok {
|
||||
fmt.Printf("\nSettings saved by beta may lose options on stable. Restore the backup from your last\nstable run (%s)? Your beta settings stay in the beta backup. [y/N] ", path)
|
||||
if askSwitch("") {
|
||||
restore = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if shellChanges || restore != "" {
|
||||
return restartShellAfterSwitch(restore)
|
||||
}
|
||||
fmt.Println("Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runSwaps(ctx context.Context, swapper distros.VariantSwapper, swaps []distros.VariantSwap) error {
|
||||
progressChan := make(chan distros.InstallProgressMsg, 100)
|
||||
swapErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(progressChan)
|
||||
swapErr <- swapper.SwapVariants(ctx, swaps, "", progressChan)
|
||||
}()
|
||||
|
||||
// runWithProgress repeats the step on every output line, so only print it when it changes.
|
||||
lastStep := ""
|
||||
for msg := range progressChan {
|
||||
switch {
|
||||
case msg.Error != nil:
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", msg.Error)
|
||||
case msg.Step != "" && msg.Step != lastStep:
|
||||
fmt.Println(msg.Step)
|
||||
lastStep = msg.Step
|
||||
}
|
||||
if msg.LogOutput != "" {
|
||||
fmt.Printf(" %s\n", msg.LogOutput)
|
||||
}
|
||||
}
|
||||
return <-swapErr
|
||||
}
|
||||
|
||||
// restartShellAfterSwitch stops the shell before restoring, or the running one would migrate the
|
||||
// restored settings and save them back. A plain restart would also reload the old binary's QML.
|
||||
func restartShellAfterSwitch(restore string) error {
|
||||
managed := runSystemctl("--user", "is-active", "--quiet", dmsSystemdUnit) == nil
|
||||
if managed {
|
||||
if err := runSystemctl("--user", "stop", dmsSystemdUnit); err != nil {
|
||||
return fmt.Errorf("stopping the shell: %w", err)
|
||||
}
|
||||
} else {
|
||||
stopShellForSwitch()
|
||||
}
|
||||
|
||||
if restore != "" {
|
||||
if err := backup.RestoreSnapshot(restore); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Restoring settings failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Restored settings from %s\n", restore)
|
||||
}
|
||||
}
|
||||
|
||||
if managed {
|
||||
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
|
||||
return fmt.Errorf("starting the shell: %w", err)
|
||||
}
|
||||
} else {
|
||||
dmsPath, err := exec.LookPath("dms")
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch done, but dms was not found to start the shell: %w", err)
|
||||
}
|
||||
if err := exec.Command(dmsPath, "run", "-d").Run(); err != nil {
|
||||
return fmt.Errorf("switch done, but starting the shell failed (run 'dms run -d'): %w", err)
|
||||
}
|
||||
}
|
||||
fmt.Println("Done. The shell has restarted.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopShellForSwitch terminates the supervisors and waits for them: Kill() would SIGKILL them,
|
||||
// orphaning quickshell in its own session, which then keeps saving settings over the restore.
|
||||
func stopShellForSwitch() {
|
||||
for _, pid := range shellApp.PIDs() {
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for len(shellApp.PIDs()) > 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
shellApp.Kill()
|
||||
}
|
||||
|
||||
func switchLabel(m distros.PackageMapping) string {
|
||||
if m.RepoURL == "" {
|
||||
return m.Name
|
||||
}
|
||||
return m.RepoURL + "/" + m.Name
|
||||
}
|
||||
|
||||
func askSwitch(prompt string) bool {
|
||||
if !stdinIsTTY() {
|
||||
return false
|
||||
}
|
||||
fmt.Print(prompt)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
answer := strings.ToLower(strings.TrimSpace(line))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
@@ -34,6 +34,12 @@ func Create(outputPath string) error {
|
||||
return fmt.Errorf("no DMS configuration found at %s: %w", srcDir, err)
|
||||
}
|
||||
|
||||
return writeArchive(outputPath, func(tw *tar.Writer) error {
|
||||
return addPath(tw, srcDir, dirName)
|
||||
})
|
||||
}
|
||||
|
||||
func writeArchive(outputPath string, fill func(*tar.Writer) error) error {
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -45,18 +51,25 @@ func Create(outputPath string) error {
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
return fill(tw)
|
||||
}
|
||||
|
||||
// addPath archives the file or tree at src as name. A symlinked src is followed so dotfile-managed
|
||||
// configs are captured, not just the link.
|
||||
func addPath(tw *tar.Writer, src, name string) error {
|
||||
root, err := filepath.EvalSymlinks(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(srcDir, path)
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
var link string
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
@@ -69,7 +82,7 @@ func Create(outputPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = filepath.ToSlash(filepath.Join(dirName, rel))
|
||||
header.Name = filepath.ToSlash(filepath.Join(name, rel))
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const snapshotsKept = 3
|
||||
|
||||
// snapshotRoot is one piece of user state a snapshot carries, archived under prefix/entry.
|
||||
type snapshotRoot struct {
|
||||
prefix string
|
||||
parent func() (string, error)
|
||||
entry string
|
||||
}
|
||||
|
||||
var snapshotRoots = []snapshotRoot{
|
||||
{prefix: "config", parent: os.UserConfigDir, entry: dirName},
|
||||
{prefix: "state", parent: stateDir, entry: filepath.Join(dirName, "session.json")},
|
||||
{prefix: "cache", parent: os.UserCacheDir, entry: filepath.Join(dirName, "cache.json")},
|
||||
}
|
||||
|
||||
func stateDir() (string, error) {
|
||||
if dir := os.Getenv("XDG_STATE_HOME"); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "state"), nil
|
||||
}
|
||||
|
||||
func SnapshotDir() (string, error) {
|
||||
dir, err := stateDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, dirName, "switch"), nil
|
||||
}
|
||||
|
||||
// Snapshot archives settings, plugins, themes, session and cache state under label, keeping the
|
||||
// newest few per label. It returns "" when there is no state to save.
|
||||
func Snapshot(label string) (string, error) {
|
||||
dir, err := SnapshotDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type source struct{ path, name string }
|
||||
var sources []source
|
||||
for _, root := range snapshotRoots {
|
||||
parent, err := root.parent()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(parent, root.entry)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source{path, filepath.Join(root.prefix, root.entry)})
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("%s-%s.tar.gz", label, time.Now().Format("20060102-150405")))
|
||||
tmp := path + ".partial"
|
||||
err = writeArchive(tmp, func(tw *tar.Writer) error {
|
||||
for _, src := range sources {
|
||||
if err := addPath(tw, src.path, src.name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
|
||||
pruneSnapshots(label)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func LatestSnapshot(label string) (string, bool) {
|
||||
snapshots := listSnapshots(label)
|
||||
if len(snapshots) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return snapshots[len(snapshots)-1], true
|
||||
}
|
||||
|
||||
func listSnapshots(label string) []string {
|
||||
dir, err := SnapshotDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(dir, label+"-*.tar.gz"))
|
||||
// The timestamp format sorts chronologically.
|
||||
slices.Sort(matches)
|
||||
return matches
|
||||
}
|
||||
|
||||
func pruneSnapshots(label string) {
|
||||
snapshots := listSnapshots(label)
|
||||
for len(snapshots) > snapshotsKept {
|
||||
os.Remove(snapshots[0])
|
||||
snapshots = snapshots[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreSnapshot writes the snapshot's files over the current ones, through any symlinks, so
|
||||
// dotfile-managed configs are updated in place. Files created since the snapshot are left alone;
|
||||
// callers take a snapshot of the current state first.
|
||||
func RestoreSnapshot(archivePath string) error {
|
||||
targets := make(map[string]string, len(snapshotRoots))
|
||||
for _, root := range snapshotRoots {
|
||||
parent, err := root.parent()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targets[root.prefix] = parent
|
||||
}
|
||||
|
||||
targetFor := func(name string) (string, error) {
|
||||
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
|
||||
return "", fmt.Errorf("unsafe path in snapshot: %s", name)
|
||||
}
|
||||
prefix, rest, _ := strings.Cut(filepath.ToSlash(name), "/")
|
||||
parent, ok := targets[prefix]
|
||||
if !ok || (rest != dirName && !strings.HasPrefix(rest, dirName+"/")) {
|
||||
return "", fmt.Errorf("not a DMS snapshot: unexpected entry %s", name)
|
||||
}
|
||||
return filepath.Join(parent, rest), nil
|
||||
}
|
||||
|
||||
if err := walkArchive(archivePath, func(header *tar.Header, _ *tar.Reader) error {
|
||||
_, err := targetFor(filepath.Clean(header.Name))
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return walkArchive(archivePath, func(header *tar.Header, tr *tar.Reader) error {
|
||||
target, err := targetFor(filepath.Clean(header.Name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
return os.MkdirAll(target, 0o755)
|
||||
case tar.TypeSymlink:
|
||||
if filepath.IsAbs(header.Linkname) || slices.Contains(strings.Split(filepath.ToSlash(header.Linkname), "/"), "..") {
|
||||
return fmt.Errorf("unsafe symlink in snapshot: %s -> %s", header.Name, header.Linkname)
|
||||
}
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(header.Linkname, target)
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, tr)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestSnapshotRestoresThroughSymlinkedConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
|
||||
dotfiles := filepath.Join(home, "dotfiles", "dms")
|
||||
writeFile(t, filepath.Join(dotfiles, "settings.json"), `{"configVersion":17}`)
|
||||
if err := os.MkdirAll(filepath.Join(home, ".config"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configLink := filepath.Join(home, ".config", "DankMaterialShell")
|
||||
if err := os.Symlink(dotfiles, configLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := filepath.Join(home, ".local", "state", "DankMaterialShell", "session.json")
|
||||
writeFile(t, session, `{"wallpaper":"a"}`)
|
||||
|
||||
path, err := Snapshot("stable")
|
||||
if err != nil || path == "" {
|
||||
t.Fatalf("Snapshot = %q, %v", path, err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(dotfiles, "settings.json"), `{"configVersion":42}`)
|
||||
writeFile(t, session, `{"wallpaper":"b"}`)
|
||||
|
||||
if err := RestoreSnapshot(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dotfiles, "settings.json")); got != `{"configVersion":17}` {
|
||||
t.Fatalf("settings = %s, want the snapshot's", got)
|
||||
}
|
||||
if got := readFile(t, session); got != `{"wallpaper":"a"}` {
|
||||
t.Fatalf("session = %s, want the snapshot's", got)
|
||||
}
|
||||
if info, err := os.Lstat(configLink); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatal("restore replaced the dotfiles symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotKeepsNewestPerLabel(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
writeFile(t, filepath.Join(home, ".config", "DankMaterialShell", "settings.json"), "{}")
|
||||
|
||||
dir, _ := SnapshotDir()
|
||||
for _, stamp := range []string{"20260101-000000", "20260102-000000", "20260103-000000"} {
|
||||
writeFile(t, filepath.Join(dir, "stable-"+stamp+".tar.gz"), "")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "beta-20250101-000000.tar.gz"), "")
|
||||
|
||||
newest, err := Snapshot("stable")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := listSnapshots("stable"); len(got) != snapshotsKept || got[len(got)-1] != newest {
|
||||
t.Fatalf("stable snapshots = %v, want the %d newest ending in %s", got, snapshotsKept, newest)
|
||||
}
|
||||
if got := listSnapshots("beta"); len(got) != 1 {
|
||||
t.Fatalf("pruning stable removed beta snapshots: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSnapshotRejectsForeignEntries(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
writeFile(t, filepath.Join(home, ".config", "DankMaterialShell", "settings.json"), "{}")
|
||||
|
||||
archive := filepath.Join(home, "backup.tar.gz")
|
||||
if err := Create(archive); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A plain backup lacks the config/ prefix, so its entries fall outside the snapshot roots.
|
||||
if err := RestoreSnapshot(archive); err == nil {
|
||||
t.Fatal("RestoreSnapshot accepted entries outside the snapshot roots")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSnapshotRejectsEscapingSymlink(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
|
||||
|
||||
archive := filepath.Join(home, "evil.tar.gz")
|
||||
f, err := os.Create(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gz := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gz)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config/DankMaterialShell/plugins", Typeflag: tar.TypeSymlink, Linkname: "../../../.ssh", Mode: 0o777}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []interface{ Close() error }{tw, gz, f} {
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := RestoreSnapshot(archive); err == nil {
|
||||
t.Fatal("RestoreSnapshot followed a symlink that points outside the config dir")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(home, ".config", "DankMaterialShell", "plugins")); err == nil {
|
||||
t.Fatal("the escaping symlink was created")
|
||||
}
|
||||
}
|
||||
@@ -134,8 +134,16 @@ func (a *ArchDistribution) detectDanksearch() deps.Dependency {
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDMS() deps.Dependency {
|
||||
dep := a.BaseDistribution.detectDMS()
|
||||
if a.packageInstalled("dms-shell-git") {
|
||||
dep.Variant = deps.VariantGit
|
||||
}
|
||||
return dep
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectDankCalendar() deps.Dependency {
|
||||
return a.detectDankCalendarVariant(a.packageInstalled("dankcalendar-git"))
|
||||
return withGitVariant(a.BaseDistribution.detectDankCalendar(), a.packageInstalled("dankcalendar-git"))
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) detectXDGPortal() deps.Dependency {
|
||||
@@ -301,11 +309,6 @@ func (a *ArchDistribution) getDMSMapping(variant deps.PackageVariant) PackageMap
|
||||
if forceDMSGit || variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
if a.packageInstalled("dms-shell-git") {
|
||||
return PackageMapping{Name: "dms-shell-git", Repository: RepoTypeAUR}
|
||||
}
|
||||
|
||||
return PackageMapping{Name: "dms-shell", Repository: RepoTypeSystem}
|
||||
}
|
||||
|
||||
@@ -386,7 +389,12 @@ func (a *ArchDistribution) InstallPackages(ctx context.Context, dependencies []d
|
||||
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||
}
|
||||
|
||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
skipFlags, err := swapReinstalledVariants(ctx, a, dependencies, wm, reinstallFlags, disabledFlags, sudoPassword, progressChan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemPkgs, aurPkgs, manualPkgs, variantMap := a.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||
|
||||
if slices.Contains(aurPkgs, "quickshell-git") && slices.Contains(systemPkgs, "dms-shell") {
|
||||
if err := a.preinstallQuickshellGit(ctx, sudoPassword, progressChan); err != nil {
|
||||
@@ -563,6 +571,36 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass
|
||||
return a.installSingleAURPackage(ctx, "quickshell-git", sudoPassword, progressChan, 0.18, 0.32)
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(a.packageInstalled, stable, git)
|
||||
}
|
||||
|
||||
// SwapVariants builds AUR targets before removing the old variant. Repo targets go through one
|
||||
// pacman transaction: --noconfirm answers the conflict prompt "no" and -Sw still runs the conflict
|
||||
// check, so --ask=4 pre-answers ALPM_QUESTION_CONFLICT_PKG instead.
|
||||
func (a *ArchDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
for _, swap := range swaps {
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseAURPackages,
|
||||
Progress: 0.40,
|
||||
Step: fmt.Sprintf("Switching %s to %s...", swap.From.Name, swap.To.Name),
|
||||
}
|
||||
|
||||
if swap.To.Repository == RepoTypeAUR {
|
||||
if err := a.installSingleAURPackageInternal(ctx, swap.To.Name, swap.From.Name, sudoPassword, progressChan, 0.40, 0.85, make(map[string]bool)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
command := "pacman -S --noconfirm --ask=4 " + swap.To.Name
|
||||
if err := a.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil {
|
||||
return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages []string, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
@@ -681,10 +719,11 @@ func (a *ArchDistribution) reorderAURPackages(packages []string) []string {
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) installSingleAURPackage(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64) error {
|
||||
return a.installSingleAURPackageInternal(ctx, pkg, sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool))
|
||||
return a.installSingleAURPackageInternal(ctx, pkg, "", sudoPassword, progressChan, startProgress, endProgress, make(map[string]bool))
|
||||
}
|
||||
|
||||
func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error {
|
||||
// installSingleAURPackageInternal removes replaces only after pkg built, so a failed build keeps the old variant.
|
||||
func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, pkg, replaces, sudoPassword string, progressChan chan<- InstallProgressMsg, startProgress, endProgress float64, visited map[string]bool) error {
|
||||
if visited[pkg] {
|
||||
a.log(fmt.Sprintf("Skipping %s (already being installed, cycle detected)", pkg))
|
||||
return nil
|
||||
@@ -817,7 +856,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
IsComplete: false,
|
||||
CommandInfo: fmt.Sprintf("Building AUR dependency: %s", aurDep),
|
||||
}
|
||||
if err := a.installSingleAURPackageInternal(ctx, aurDep, sudoPassword, progressChan,
|
||||
if err := a.installSingleAURPackageInternal(ctx, aurDep, "", sudoPassword, progressChan,
|
||||
startProgress+0.35*(endProgress-startProgress),
|
||||
startProgress+0.39*(endProgress-startProgress),
|
||||
visited,
|
||||
@@ -863,7 +902,11 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context,
|
||||
installArgs := []string{"pacman", "-U", "--noconfirm"}
|
||||
installArgs = append(installArgs, files...)
|
||||
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " "))
|
||||
command := strings.Join(installArgs, " ")
|
||||
if replaces != "" {
|
||||
command = fmt.Sprintf("sh -c '%s'", privesc.EscapeSingleQuotes("pacman -Rdd --noconfirm "+replaces+" && "+command))
|
||||
}
|
||||
installCmd := privesc.ExecCommand(ctx, sudoPassword, command)
|
||||
|
||||
fileNames := make([]string, len(files))
|
||||
for i, f := range files {
|
||||
|
||||
@@ -113,8 +113,7 @@ func (b *BaseDistribution) detectDankCalendar() deps.Dependency {
|
||||
return b.detectOptionalPackage("dankcalendar", "Calendar application", b.commandExists("dcal") || b.commandExists("dankcalendar"))
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDankCalendarVariant(gitInstalled bool) deps.Dependency {
|
||||
dep := b.detectDankCalendar()
|
||||
func withGitVariant(dep deps.Dependency, gitInstalled bool) deps.Dependency {
|
||||
dep.CanToggle = true
|
||||
if gitInstalled {
|
||||
dep.Variant = deps.VariantGit
|
||||
@@ -122,11 +121,12 @@ func (b *BaseDistribution) detectDankCalendarVariant(gitInstalled bool) deps.Dep
|
||||
return dep
|
||||
}
|
||||
|
||||
func dankCalendarMapping(variant deps.PackageVariant, repo RepositoryType, repoURL string) PackageMapping {
|
||||
// variantMapping is for packages whose git build is published as "<name>-git" in the same repo.
|
||||
func variantMapping(name string, variant deps.PackageVariant, repo RepositoryType, repoURL string) PackageMapping {
|
||||
if variant == deps.VariantGit {
|
||||
return PackageMapping{Name: "dankcalendar-git", Repository: repo, RepoURL: repoURL}
|
||||
name += "-git"
|
||||
}
|
||||
return PackageMapping{Name: "dankcalendar", Repository: repo, RepoURL: repoURL}
|
||||
return PackageMapping{Name: name, Repository: repo, RepoURL: repoURL}
|
||||
}
|
||||
|
||||
func (b *BaseDistribution) detectDMS() deps.Dependency {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (d *DebianDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, d.detectMatugen())
|
||||
dependencies = append(dependencies, d.detectDanksearch())
|
||||
dependencies = append(dependencies, d.detectDankCalendarVariant(d.packageInstalled("dankcalendar-git")))
|
||||
dependencies = append(dependencies, withGitVariant(d.detectDankCalendar(), d.packageInstalled("dankcalendar-git")))
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -90,13 +90,31 @@ func (d *DebianDistribution) detectAccountsService() deps.Dependency {
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return d.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", d.packageInstalled("dms-greeter"))
|
||||
gitInstalled := d.packageInstalled("dms-greeter-git")
|
||||
dep := d.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", gitInstalled || d.packageInstalled("dms-greeter"))
|
||||
return withGitVariant(dep, gitInstalled)
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) packageInstalled(pkg string) bool {
|
||||
return debianPackageInstalledPrecisely(pkg)
|
||||
}
|
||||
|
||||
func (d *DebianDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(d.packageInstalled, stable, git)
|
||||
}
|
||||
|
||||
// SwapVariants installs the new variants directly: their Conflicts/Replaces make apt remove the old ones.
|
||||
func (d *DebianDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if err := d.enableOBSRepos(ctx, swapTargets(swaps, RepoTypeOBS), sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
_, to := swapNames(swaps)
|
||||
if err := d.ensureQuickshellBackports(ctx, to, sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.installAPTPackages(ctx, to, sudoPassword, progressChan)
|
||||
}
|
||||
|
||||
func debianPackageInstalledPrecisely(pkg string) bool {
|
||||
cmd := exec.Command("dpkg-query", "-W", "-f=${db:Status-Status}", pkg)
|
||||
output, err := cmd.Output()
|
||||
@@ -133,11 +151,11 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
// DMS packages from OBS with variant support
|
||||
"dms (DankMaterialShell)": d.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": dankCalendarMapping(variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
}
|
||||
|
||||
if wm == deps.WindowManagerNiri {
|
||||
|
||||
@@ -128,7 +128,7 @@ func (f *FedoraDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, f.detectMatugen())
|
||||
dependencies = append(dependencies, f.detectDanksearch())
|
||||
dependencies = append(dependencies, f.detectDankCalendarVariant(f.packageInstalled("dankcalendar-git")))
|
||||
dependencies = append(dependencies, withGitVariant(f.detectDankCalendar(), f.packageInstalled("dankcalendar-git")))
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -143,6 +143,68 @@ func (f *FedoraDistribution) packageInstalled(pkg string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
if stable.Name != git.Name {
|
||||
return installedVariantByName(f.packageInstalled, stable, git)
|
||||
}
|
||||
if !f.packageInstalled(stable.Name) {
|
||||
return deps.VariantStable, false
|
||||
}
|
||||
if git.Repository == RepoTypeCOPR && fedoraInstalledFromCOPR(stable.Name, git.RepoURL) {
|
||||
return deps.VariantGit, true
|
||||
}
|
||||
return deps.VariantStable, true
|
||||
}
|
||||
|
||||
// fedoraInstalledFromCOPR tells same-named variants apart (dms, niri), which only differ by COPR.
|
||||
func fedoraInstalledFromCOPR(pkg, coprRepo string) bool {
|
||||
out, err := exec.Command("dnf", "repoquery", "--installed", "--qf", "%{from_repo}", pkg).Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(strings.TrimSpace(string(out)), ":"+strings.ReplaceAll(coprRepo, "/", ":"))
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if err := f.enableCOPRRepos(ctx, swapTargets(swaps, RepoTypeCOPR), sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, swap := range swaps {
|
||||
command := fmt.Sprintf("dnf swap -y %s %s", swap.From.Name, swap.To.Name)
|
||||
disabledRepo := ""
|
||||
if swap.From.Name == swap.To.Name {
|
||||
// Left enabled, the old COPR wins distro-sync whenever its version sorts higher (dms-git has Epoch 2).
|
||||
if swap.From.Repository == RepoTypeCOPR {
|
||||
disable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr disable -y "+swap.From.RepoURL)
|
||||
if err := f.runWithProgress(disable, progressChan, PhaseSystemPackages, 0.30, 0.35); err != nil {
|
||||
return fmt.Errorf("failed to disable COPR repo %s: %w", swap.From.RepoURL, err)
|
||||
}
|
||||
disabledRepo = swap.From.RepoURL
|
||||
}
|
||||
command = "dnf distro-sync -y " + swap.To.Name
|
||||
}
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.40,
|
||||
Step: fmt.Sprintf("Switching %s to %s...", swap.From.Name, swap.To.Name),
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo " + command,
|
||||
}
|
||||
if err := f.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85); err != nil {
|
||||
if disabledRepo != "" {
|
||||
reenable := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y "+disabledRepo)
|
||||
if reErr := reenable.Run(); reErr != nil {
|
||||
f.log(fmt.Sprintf("Warning: could not re-enable COPR repo %s: %v", disabledRepo, reErr))
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to switch %s to %s: %w", swap.From.Name, swap.To.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||
return f.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||
}
|
||||
@@ -159,11 +221,11 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
|
||||
// COPR packages
|
||||
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dankcalendar": dankCalendarMapping(variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"),
|
||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeCOPR, "avengemedia/danklinux"),
|
||||
}
|
||||
|
||||
switch wm {
|
||||
@@ -247,7 +309,9 @@ func (f *FedoraDistribution) detectAccountsService() deps.Dependency {
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return f.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", f.packageInstalled("dms-greeter"))
|
||||
gitInstalled := f.packageInstalled("dms-greeter-git")
|
||||
dep := f.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", gitInstalled || f.packageInstalled("dms-greeter"))
|
||||
return withGitVariant(dep, gitInstalled)
|
||||
}
|
||||
|
||||
func (f *FedoraDistribution) getPrerequisites() []string {
|
||||
@@ -367,7 +431,12 @@ func (f *FedoraDistribution) InstallPackages(ctx context.Context, dependencies [
|
||||
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||
}
|
||||
|
||||
dnfPkgs, coprPkgs, manualPkgs, variantMap := f.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
skipFlags, err := swapReinstalledVariants(ctx, f, dependencies, wm, reinstallFlags, disabledFlags, sudoPassword, progressChan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dnfPkgs, coprPkgs, manualPkgs, variantMap := f.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||
|
||||
// Phase 2: Enable COPR repositories
|
||||
if len(coprPkgs) > 0 {
|
||||
|
||||
@@ -92,7 +92,7 @@ func (o *OpenSUSEDistribution) DetectDependenciesWithTerminal(ctx context.Contex
|
||||
|
||||
dependencies = append(dependencies, o.detectMatugen())
|
||||
dependencies = append(dependencies, o.detectDanksearch())
|
||||
dependencies = append(dependencies, o.detectDankCalendarVariant(o.packageInstalled("dankcalendar-git")))
|
||||
dependencies = append(dependencies, withGitVariant(o.detectDankCalendar(), o.packageInstalled("dankcalendar-git")))
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -107,8 +107,35 @@ func (o *OpenSUSEDistribution) packageInstalled(pkg string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (o *OpenSUSEDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(o.packageInstalled, stable, git)
|
||||
}
|
||||
|
||||
func (o *OpenSUSEDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if err := o.enableOBSRepos(ctx, swapTargets(swaps, RepoTypeOBS), sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args := []string{"zypper", "install", "-y"}
|
||||
for _, swap := range swaps {
|
||||
args = append(args, swap.To.Name, "'!"+swap.From.Name+"'")
|
||||
}
|
||||
command := strings.Join(args, " ")
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.40,
|
||||
Step: "Switching package variants...",
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo " + command,
|
||||
}
|
||||
return o.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, command), progressChan, PhaseSystemPackages, 0.40, 0.85)
|
||||
}
|
||||
|
||||
func (o *OpenSUSEDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return o.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", o.packageInstalled("dms-greeter"))
|
||||
gitInstalled := o.packageInstalled("dms-greeter-git")
|
||||
dep := o.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", gitInstalled || o.packageInstalled("dms-greeter"))
|
||||
return withGitVariant(dep, gitInstalled)
|
||||
}
|
||||
|
||||
func (o *OpenSUSEDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||
@@ -127,11 +154,11 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
// DMS packages from OBS
|
||||
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"quickshell": o.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dankcalendar": dankCalendarMapping(variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypeOBS, "home:AvengeMedia:danklinux"),
|
||||
}
|
||||
|
||||
switch wm {
|
||||
@@ -285,7 +312,12 @@ func (o *OpenSUSEDistribution) InstallPackages(ctx context.Context, dependencies
|
||||
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||
}
|
||||
|
||||
systemPkgs, obsPkgs, manualPkgs, variantMap := o.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
skipFlags, err := swapReinstalledVariants(ctx, o, dependencies, wm, reinstallFlags, disabledFlags, sudoPassword, progressChan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemPkgs, obsPkgs, manualPkgs, variantMap := o.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||
|
||||
// Enable OBS repositories
|
||||
if len(obsPkgs) > 0 {
|
||||
|
||||
@@ -81,7 +81,7 @@ func (u *UbuntuDistribution) DetectDependenciesWithTerminal(ctx context.Context,
|
||||
|
||||
dependencies = append(dependencies, u.detectMatugen())
|
||||
dependencies = append(dependencies, u.detectDanksearch())
|
||||
dependencies = append(dependencies, u.detectDankCalendarVariant(u.packageInstalled("dankcalendar-git")))
|
||||
dependencies = append(dependencies, withGitVariant(u.detectDankCalendar(), u.packageInstalled("dankcalendar-git")))
|
||||
|
||||
return dependencies, nil
|
||||
}
|
||||
@@ -99,13 +99,30 @@ func (u *UbuntuDistribution) detectAccountsService() deps.Dependency {
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return u.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", u.packageInstalled("dms-greeter"))
|
||||
gitInstalled := u.packageInstalled("dms-greeter-git")
|
||||
dep := u.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", gitInstalled || u.packageInstalled("dms-greeter"))
|
||||
return withGitVariant(dep, gitInstalled)
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) packageInstalled(pkg string) bool {
|
||||
return debianPackageInstalledPrecisely(pkg)
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(u.packageInstalled, stable, git)
|
||||
}
|
||||
|
||||
// SwapVariants installs the new variants directly: their Conflicts/Replaces make apt remove the old ones.
|
||||
func (u *UbuntuDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if ppas := swapTargets(swaps, RepoTypePPA); len(ppas) > 0 {
|
||||
if err := u.enablePPARepos(ctx, ppas, sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, to := swapNames(swaps)
|
||||
return u.installAPTPackages(ctx, to, sudoPassword, progressChan)
|
||||
}
|
||||
|
||||
func (u *UbuntuDistribution) GetPackageMapping(wm deps.WindowManager) map[string]PackageMapping {
|
||||
return u.GetPackageMappingWithVariants(wm, make(map[string]deps.PackageVariant))
|
||||
}
|
||||
@@ -122,11 +139,11 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
// DMS packages from PPAs
|
||||
"dms (DankMaterialShell)": u.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dankcalendar": dankCalendarMapping(variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
||||
"dankcalendar": variantMapping("dankcalendar", variants["dankcalendar"], RepoTypePPA, "ppa:avengemedia/danklinux"),
|
||||
}
|
||||
|
||||
switch wm {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package distros
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
)
|
||||
|
||||
// VariantSwap replaces the installed variant of a dependency with its other variant.
|
||||
type VariantSwap struct {
|
||||
Dep string
|
||||
From PackageMapping
|
||||
To PackageMapping
|
||||
}
|
||||
|
||||
// VariantSwapper is implemented by distros that can replace an installed package with its other variant.
|
||||
type VariantSwapper interface {
|
||||
InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool)
|
||||
SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error
|
||||
}
|
||||
|
||||
type SwapStatus int
|
||||
|
||||
const (
|
||||
SwapNeeded SwapStatus = iota
|
||||
SwapAlreadyOnTarget
|
||||
SwapNotInstalled
|
||||
SwapNoVariant
|
||||
SwapUnsupported
|
||||
)
|
||||
|
||||
func PlanVariantSwap(d Distribution, wm deps.WindowManager, dep string, target deps.PackageVariant) (VariantSwap, SwapStatus) {
|
||||
swapper, ok := d.(VariantSwapper)
|
||||
if !ok {
|
||||
return VariantSwap{}, SwapUnsupported
|
||||
}
|
||||
stable, ok := d.GetPackageMappingWithVariants(wm, map[string]deps.PackageVariant{dep: deps.VariantStable})[dep]
|
||||
if !ok {
|
||||
return VariantSwap{}, SwapNoVariant
|
||||
}
|
||||
git := d.GetPackageMappingWithVariants(wm, map[string]deps.PackageVariant{dep: deps.VariantGit})[dep]
|
||||
if stable == git {
|
||||
return VariantSwap{}, SwapNoVariant
|
||||
}
|
||||
|
||||
current, installed := swapper.InstalledVariant(stable, git)
|
||||
switch {
|
||||
case !installed:
|
||||
return VariantSwap{}, SwapNotInstalled
|
||||
case current == target:
|
||||
return VariantSwap{}, SwapAlreadyOnTarget
|
||||
case target == deps.VariantGit:
|
||||
return VariantSwap{Dep: dep, From: stable, To: git}, SwapNeeded
|
||||
default:
|
||||
return VariantSwap{Dep: dep, From: git, To: stable}, SwapNeeded
|
||||
}
|
||||
}
|
||||
|
||||
// installedVariantByName works when the two variants ship under different package names.
|
||||
func installedVariantByName(installed func(string) bool, stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
switch {
|
||||
case installed(packageBaseName(git.Name)):
|
||||
return deps.VariantGit, true
|
||||
case installed(packageBaseName(stable.Name)):
|
||||
return deps.VariantStable, true
|
||||
}
|
||||
return deps.VariantStable, false
|
||||
}
|
||||
|
||||
// packageBaseName drops an apt target release suffix such as "quickshell/trixie-backports".
|
||||
func packageBaseName(name string) string {
|
||||
base, _, _ := strings.Cut(name, "/")
|
||||
return base
|
||||
}
|
||||
|
||||
// swapReinstalledVariants swaps reinstall-marked deps whose other variant is installed, since the
|
||||
// package managers refuse to install over it. The returned flags keep them out of the install pass.
|
||||
func swapReinstalledVariants(ctx context.Context, d Distribution, dependencies []deps.Dependency, wm deps.WindowManager, reinstallFlags, disabledFlags map[string]bool, sudoPassword string, progressChan chan<- InstallProgressMsg) (map[string]bool, error) {
|
||||
var swaps []VariantSwap
|
||||
for _, dep := range dependencies {
|
||||
if !reinstallFlags[dep.Name] || disabledFlags[dep.Name] {
|
||||
continue
|
||||
}
|
||||
if swap, status := PlanVariantSwap(d, wm, dep.Name, dep.Variant); status == SwapNeeded {
|
||||
swaps = append(swaps, swap)
|
||||
}
|
||||
}
|
||||
if len(swaps) == 0 {
|
||||
return disabledFlags, nil
|
||||
}
|
||||
|
||||
if err := d.(VariantSwapper).SwapVariants(ctx, swaps, sudoPassword, progressChan); err != nil {
|
||||
return nil, fmt.Errorf("failed to switch package variants: %w", err)
|
||||
}
|
||||
|
||||
skip := maps.Clone(disabledFlags)
|
||||
if skip == nil {
|
||||
skip = make(map[string]bool, len(swaps))
|
||||
}
|
||||
for _, swap := range swaps {
|
||||
skip[swap.Dep] = true
|
||||
}
|
||||
return skip, nil
|
||||
}
|
||||
|
||||
func swapNames(swaps []VariantSwap) (from, to []string) {
|
||||
for _, swap := range swaps {
|
||||
from = append(from, swap.From.Name)
|
||||
to = append(to, swap.To.Name)
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
|
||||
func swapTargets(swaps []VariantSwap, repo RepositoryType) []PackageMapping {
|
||||
var targets []PackageMapping
|
||||
for _, swap := range swaps {
|
||||
if swap.To.Repository == repo {
|
||||
targets = append(targets, swap.To)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package distros
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
|
||||
)
|
||||
|
||||
type swapFake struct {
|
||||
Distribution
|
||||
installed map[string]bool
|
||||
swapped []VariantSwap
|
||||
}
|
||||
|
||||
func (f *swapFake) GetPackageMappingWithVariants(_ deps.WindowManager, variants map[string]deps.PackageVariant) map[string]PackageMapping {
|
||||
return map[string]PackageMapping{
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeCOPR, "avengemedia/danklinux"),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *swapFake) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(func(p string) bool { return f.installed[p] }, stable, git)
|
||||
}
|
||||
|
||||
func (f *swapFake) SwapVariants(_ context.Context, swaps []VariantSwap, _ string, _ chan<- InstallProgressMsg) error {
|
||||
f.swapped = append(f.swapped, swaps...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPlanVariantSwap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dep string
|
||||
installed string
|
||||
target deps.PackageVariant
|
||||
want SwapStatus
|
||||
from, to string
|
||||
}{
|
||||
{"stable to git", "dms-greeter", "dms-greeter", deps.VariantGit, SwapNeeded, "dms-greeter", "dms-greeter-git"},
|
||||
{"git to stable", "dms-greeter", "dms-greeter-git", deps.VariantStable, SwapNeeded, "dms-greeter-git", "dms-greeter"},
|
||||
{"already on target", "dms-greeter", "dms-greeter-git", deps.VariantGit, SwapAlreadyOnTarget, "", ""},
|
||||
{"not installed", "dms-greeter", "", deps.VariantGit, SwapNotInstalled, "", ""},
|
||||
{"same mapping both ways", "matugen", "matugen", deps.VariantGit, SwapNoVariant, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d := &swapFake{installed: map[string]bool{tc.installed: true}}
|
||||
swap, status := PlanVariantSwap(d, deps.WindowManagerNiri, tc.dep, tc.target)
|
||||
if status != tc.want || swap.From.Name != tc.from || swap.To.Name != tc.to {
|
||||
t.Fatalf("got %v %q→%q, want %v %q→%q", status, swap.From.Name, swap.To.Name, tc.want, tc.from, tc.to)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwapReinstalledVariantsSkipsSwappedDeps(t *testing.T) {
|
||||
d := &swapFake{installed: map[string]bool{"dms-greeter": true}}
|
||||
dependencies := []deps.Dependency{
|
||||
{Name: "dms-greeter", Status: deps.StatusInstalled, Variant: deps.VariantGit},
|
||||
{Name: "matugen", Status: deps.StatusInstalled},
|
||||
}
|
||||
reinstall := map[string]bool{"dms-greeter": true, "matugen": true}
|
||||
disabled := map[string]bool{}
|
||||
|
||||
skip, err := swapReinstalledVariants(context.Background(), d, dependencies, deps.WindowManagerNiri, reinstall, disabled, "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(d.swapped) != 1 || d.swapped[0].To.Name != "dms-greeter-git" {
|
||||
t.Fatalf("swapped = %+v, want only dms-greeter → dms-greeter-git", d.swapped)
|
||||
}
|
||||
if !skip["dms-greeter"] || skip["matugen"] {
|
||||
t.Fatalf("skip = %v, want the swapped greeter skipped and matugen reinstalled", skip)
|
||||
}
|
||||
if len(disabled) != 0 {
|
||||
t.Fatalf("caller's disabled flags were mutated: %v", disabled)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,9 @@ func (v *VoidDistribution) detectXDGPortal() deps.Dependency {
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectDMSGreeter() deps.Dependency {
|
||||
return v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", v.packageInstalled("dms-greeter"))
|
||||
gitInstalled := v.packageInstalled("dms-greeter-git")
|
||||
dep := v.detectOptionalPackage("dms-greeter", "DankMaterialShell greetd greeter", gitInstalled || v.packageInstalled("dms-greeter"))
|
||||
return withGitVariant(dep, gitInstalled)
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) detectAccountsService() deps.Dependency {
|
||||
@@ -156,6 +158,41 @@ func (v *VoidDistribution) packageInstalled(pkg string) bool {
|
||||
return exec.Command("xbps-query", pkg).Run() == nil
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) InstalledVariant(stable, git PackageMapping) (deps.PackageVariant, bool) {
|
||||
return installedVariantByName(v.packageInstalled, stable, git)
|
||||
}
|
||||
|
||||
// SwapVariants removes before installing because xbps has no replace-in-place; a failed install
|
||||
// puts the old packages back from the local cache so the greeter is never left missing.
|
||||
func (v *VoidDistribution) SwapVariants(ctx context.Context, swaps []VariantSwap, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
if err := v.enableXBPSRepos(ctx, swapTargets(swaps, RepoTypeXBPS), sudoPassword, progressChan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
from, to := swapNames(swaps)
|
||||
remove := "xbps-remove -Fy " + strings.Join(from, " ")
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.40,
|
||||
Step: "Removing current package variants...",
|
||||
NeedsSudo: true,
|
||||
CommandInfo: "sudo " + remove,
|
||||
}
|
||||
if err := v.runWithProgress(privesc.ExecCommand(ctx, sudoPassword, remove), progressChan, PhaseSystemPackages, 0.40, 0.50); err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", strings.Join(from, ", "), err)
|
||||
}
|
||||
|
||||
installErr := v.installXBPSPackages(ctx, to, sudoPassword, progressChan)
|
||||
if installErr == nil {
|
||||
return nil
|
||||
}
|
||||
restore := privesc.ExecCommand(ctx, sudoPassword, "xbps-install -y "+strings.Join(from, " "))
|
||||
if err := v.runWithProgress(restore, progressChan, PhaseSystemPackages, 0.85, 0.90); err != nil {
|
||||
return fmt.Errorf("failed to install %s (%w) and to restore %s: %v", strings.Join(to, ", "), installErr, strings.Join(from, ", "), err)
|
||||
}
|
||||
return fmt.Errorf("failed to install %s, restored %s: %w", strings.Join(to, ", "), strings.Join(from, ", "), installErr)
|
||||
}
|
||||
|
||||
func (v *VoidDistribution) packageVersion(pkg string) string {
|
||||
output, err := exec.Command("xbps-query", "-p", "pkgver", pkg).Output()
|
||||
if err != nil {
|
||||
@@ -183,7 +220,7 @@ func (v *VoidDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"quickshell": {Name: "quickshell", Repository: RepoTypeSystem},
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeSystem},
|
||||
"dms (DankMaterialShell)": v.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dms-greeter": {Name: "dms-greeter", Repository: RepoTypeXBPS, RepoURL: VoidDMSRepo},
|
||||
"dms-greeter": variantMapping("dms-greeter", variants["dms-greeter"], RepoTypeXBPS, VoidDMSRepo),
|
||||
"danksearch": {Name: "danksearch", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
"dankcalendar": {Name: "dankcalendar", Repository: RepoTypeXBPS, RepoURL: VoidDankLinuxRepo},
|
||||
}
|
||||
@@ -251,7 +288,12 @@ func (v *VoidDistribution) InstallPackages(ctx context.Context, dependencies []d
|
||||
return fmt.Errorf("failed to install prerequisites: %w", err)
|
||||
}
|
||||
|
||||
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, disabledFlags)
|
||||
skipFlags, err := swapReinstalledVariants(ctx, v, dependencies, wm, reinstallFlags, disabledFlags, sudoPassword, progressChan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
systemPkgs, xbpsPkgs := v.categorizePackages(dependencies, wm, reinstallFlags, skipFlags)
|
||||
|
||||
if len(xbpsPkgs) > 0 {
|
||||
progressChan <- InstallProgressMsg{
|
||||
|
||||
@@ -331,7 +331,7 @@ func (r *Runner) applyGitVariants(dependencies []deps.Dependency, hasGitVariant
|
||||
|
||||
// InstallPackages skips StatusInstalled deps unless marked for reinstall
|
||||
markGit := func(dep *deps.Dependency) {
|
||||
if dep.Variant == deps.VariantStable && dep.Status == deps.StatusInstalled {
|
||||
if dep.Variant == deps.VariantStable && dep.Status != deps.StatusMissing {
|
||||
reinstallItems[dep.Name] = true
|
||||
}
|
||||
dep.Variant = deps.VariantGit
|
||||
|
||||
@@ -38,6 +38,8 @@ type Model struct {
|
||||
selectedConfig int
|
||||
selectedAuthMethod int
|
||||
reinstallItems map[string]bool
|
||||
detectedVariants map[string]deps.PackageVariant
|
||||
variantMarks map[string]bool
|
||||
disabledItems map[string]bool
|
||||
replaceConfigs map[string]bool
|
||||
skipGentooUseFlags bool
|
||||
@@ -77,9 +79,11 @@ func NewModel(version string, logFilePath string) Model {
|
||||
packageProgress: packageInstallProgressMsg{
|
||||
step: "Initializing package installation",
|
||||
},
|
||||
reinstallItems: make(map[string]bool),
|
||||
disabledItems: make(map[string]bool),
|
||||
replaceConfigs: make(map[string]bool),
|
||||
reinstallItems: make(map[string]bool),
|
||||
detectedVariants: make(map[string]deps.PackageVariant),
|
||||
variantMarks: make(map[string]bool),
|
||||
disabledItems: make(map[string]bool),
|
||||
replaceConfigs: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,3 +204,35 @@ func TestConfigCheckKeepsBindsByDefault(t *testing.T) {
|
||||
t.Fatalf("replaceConfigs = %v, want Mango replaced and its binds kept", m.replaceConfigs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleVariantMarksInstalledDepForSwap(t *testing.T) {
|
||||
m := archModel()
|
||||
m.dependencies = []deps.Dependency{
|
||||
{Name: "dms-greeter", Status: deps.StatusInstalled, CanToggle: true},
|
||||
{Name: "dankcalendar", Status: deps.StatusMissing, CanToggle: true},
|
||||
}
|
||||
m.detectedVariants["dms-greeter"] = deps.VariantStable
|
||||
m.disabledItems["dms-greeter"] = true
|
||||
|
||||
m.toggleSelectedVariant()
|
||||
if !m.reinstallItems["dms-greeter"] || m.disabledItems["dms-greeter"] {
|
||||
t.Fatal("switching an installed dep's variant should queue it for install")
|
||||
}
|
||||
m.toggleSelectedVariant()
|
||||
if m.reinstallItems["dms-greeter"] {
|
||||
t.Fatal("switching back to the installed variant should clear the reinstall")
|
||||
}
|
||||
|
||||
m.reinstallItems["dms-greeter"] = true
|
||||
m.toggleSelectedVariant()
|
||||
m.toggleSelectedVariant()
|
||||
if !m.reinstallItems["dms-greeter"] {
|
||||
t.Fatal("a reinstall the user marked by hand must survive toggling the variant and back")
|
||||
}
|
||||
|
||||
m.selectedDep = 1
|
||||
m.toggleSelectedVariant()
|
||||
if m.reinstallItems["dankcalendar"] {
|
||||
t.Fatal("a missing dep installs anyway and must not be marked for reinstall")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ func (m Model) updateDetectingDepsState(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
m.dependencies = partitionOptionalLast(depsMsg.deps)
|
||||
for _, dep := range m.dependencies {
|
||||
m.detectedVariants[dep.Name] = dep.Variant
|
||||
if dep.Required {
|
||||
continue
|
||||
}
|
||||
@@ -210,11 +211,28 @@ func (m Model) toggleSelectedVariant() {
|
||||
return
|
||||
}
|
||||
|
||||
if m.dependencies[m.selectedDep].Variant == deps.VariantStable {
|
||||
m.dependencies[m.selectedDep].Variant = deps.VariantGit
|
||||
dep := &m.dependencies[m.selectedDep]
|
||||
if dep.Variant == deps.VariantStable {
|
||||
dep.Variant = deps.VariantGit
|
||||
} else {
|
||||
dep.Variant = deps.VariantStable
|
||||
}
|
||||
|
||||
// The installer skips installed deps unless marked, and the mark is what triggers the variant swap.
|
||||
if dep.Status == deps.StatusMissing {
|
||||
return
|
||||
}
|
||||
m.dependencies[m.selectedDep].Variant = deps.VariantStable
|
||||
switched := dep.Variant != m.detectedVariants[dep.Name]
|
||||
if switched {
|
||||
m.variantMarks[dep.Name] = !m.reinstallItems[dep.Name]
|
||||
m.reinstallItems[dep.Name] = true
|
||||
m.disabledItems[dep.Name] = false
|
||||
return
|
||||
}
|
||||
if m.variantMarks[dep.Name] {
|
||||
delete(m.reinstallItems, dep.Name)
|
||||
delete(m.variantMarks, dep.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) installPackages() tea.Cmd {
|
||||
|
||||
@@ -1839,6 +1839,10 @@ Singleton {
|
||||
let loadedSettings = JSON.stringify(obj);
|
||||
|
||||
const oldVersion = obj?.configVersion ?? 0;
|
||||
if (oldVersion > settingsConfigVersion && !isGreeterMode) {
|
||||
newerSettingsBackup.path = `${_configDir}/DankMaterialShell/settings.v${oldVersion}.json`;
|
||||
newerSettingsBackup.setText(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
const legacyPins = oldVersion < 13 ? Store.extractPins(obj) : null;
|
||||
const sessionPayload = oldVersion < 15 ? Store.extractSessionPayload(obj) : null;
|
||||
const cachePayload = oldVersion < 15 ? Store.extractCachePayload(obj) : null;
|
||||
@@ -4060,6 +4064,16 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Our first save drops the keys a newer shell wrote, so its settings are kept for going back.
|
||||
FileView {
|
||||
id: newerSettingsBackup
|
||||
|
||||
preload: false
|
||||
blockWrites: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: pluginSettingsFile
|
||||
|
||||
|
||||
Reference in New Issue
Block a user