From 207cbe5aae538f250d241f40a10d087d0fbcd15d Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 17 Oct 2026 22:20:12 -0400 Subject: [PATCH] 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 --- core/cmd/dms/commands_common.go | 1 + core/cmd/dms/commands_switch.go | 282 +++++++++++++++++++++ core/internal/backup/backup.go | 25 +- core/internal/backup/snapshot.go | 193 ++++++++++++++ core/internal/backup/snapshot_test.go | 141 +++++++++++ core/internal/distros/arch.go | 65 ++++- core/internal/distros/base.go | 10 +- core/internal/distros/debian.go | 26 +- core/internal/distros/fedora.go | 79 +++++- core/internal/distros/opensuse.go | 42 ++- core/internal/distros/ubuntu.go | 25 +- core/internal/distros/variant_swap.go | 125 +++++++++ core/internal/distros/variant_swap_test.go | 80 ++++++ core/internal/distros/void.go | 48 +++- core/internal/headless/runner.go | 2 +- core/internal/tui/app.go | 10 +- core/internal/tui/app_test.go | 32 +++ core/internal/tui/views_dependencies.go | 24 +- quickshell/Common/SettingsData.qml | 14 + 19 files changed, 1174 insertions(+), 50 deletions(-) create mode 100644 core/cmd/dms/commands_switch.go create mode 100644 core/internal/backup/snapshot.go create mode 100644 core/internal/backup/snapshot_test.go create mode 100644 core/internal/distros/variant_swap.go create mode 100644 core/internal/distros/variant_swap_test.go diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 89659a839..ec30fca5a 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -794,6 +794,7 @@ func getCommonCommands() []*cobra.Command { systemCmd, systemdCmd, switchUserCmd, + switchCmd, dmenuCmd, }...) } diff --git a/core/cmd/dms/commands_switch.go b/core/cmd/dms/commands_switch.go new file mode 100644 index 000000000..13f75fce6 --- /dev/null +++ b/core/cmd/dms/commands_switch.go @@ -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 [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" +} diff --git a/core/internal/backup/backup.go b/core/internal/backup/backup.go index b0559eeef..487ca0d01 100644 --- a/core/internal/backup/backup.go +++ b/core/internal/backup/backup.go @@ -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 diff --git a/core/internal/backup/snapshot.go b/core/internal/backup/snapshot.go new file mode 100644 index 000000000..431b845c1 --- /dev/null +++ b/core/internal/backup/snapshot.go @@ -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 + } + }) +} diff --git a/core/internal/backup/snapshot_test.go b/core/internal/backup/snapshot_test.go new file mode 100644 index 000000000..a45b0f981 --- /dev/null +++ b/core/internal/backup/snapshot_test.go @@ -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") + } +} diff --git a/core/internal/distros/arch.go b/core/internal/distros/arch.go index 32ceffcfe..d9fd991c5 100644 --- a/core/internal/distros/arch.go +++ b/core/internal/distros/arch.go @@ -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 { diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index 42d825e7c..0ea5826a7 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -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 "-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 { diff --git a/core/internal/distros/debian.go b/core/internal/distros/debian.go index 6b8590bb7..7aac80cbc 100644 --- a/core/internal/distros/debian.go +++ b/core/internal/distros/debian.go @@ -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 { diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index e258bad20..9ad61a097 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -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 { diff --git a/core/internal/distros/opensuse.go b/core/internal/distros/opensuse.go index 5bfc7dc66..d9d195db1 100644 --- a/core/internal/distros/opensuse.go +++ b/core/internal/distros/opensuse.go @@ -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 { diff --git a/core/internal/distros/ubuntu.go b/core/internal/distros/ubuntu.go index b9bd0f7ec..1deccf08e 100644 --- a/core/internal/distros/ubuntu.go +++ b/core/internal/distros/ubuntu.go @@ -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 { diff --git a/core/internal/distros/variant_swap.go b/core/internal/distros/variant_swap.go new file mode 100644 index 000000000..1da078f7a --- /dev/null +++ b/core/internal/distros/variant_swap.go @@ -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 +} diff --git a/core/internal/distros/variant_swap_test.go b/core/internal/distros/variant_swap_test.go new file mode 100644 index 000000000..a129e6574 --- /dev/null +++ b/core/internal/distros/variant_swap_test.go @@ -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) + } +} diff --git a/core/internal/distros/void.go b/core/internal/distros/void.go index 49b347302..d5e6f6b28 100644 --- a/core/internal/distros/void.go +++ b/core/internal/distros/void.go @@ -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{ diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go index d25aff087..6ada8078b 100644 --- a/core/internal/headless/runner.go +++ b/core/internal/headless/runner.go @@ -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 diff --git a/core/internal/tui/app.go b/core/internal/tui/app.go index 5d8a65d7e..c29ee20ab 100644 --- a/core/internal/tui/app.go +++ b/core/internal/tui/app.go @@ -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), } } diff --git a/core/internal/tui/app_test.go b/core/internal/tui/app_test.go index baca944f2..a239257e7 100644 --- a/core/internal/tui/app_test.go +++ b/core/internal/tui/app_test.go @@ -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") + } +} diff --git a/core/internal/tui/views_dependencies.go b/core/internal/tui/views_dependencies.go index 6e33a7b0a..d3fb1dea1 100644 --- a/core/internal/tui/views_dependencies.go +++ b/core/internal/tui/views_dependencies.go @@ -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 { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index a4407e9c2..9528d9afc 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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