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:
purian23
2026-10-17 22:20:12 -04:00
parent 47885e4981
commit 207cbe5aae
19 changed files with 1174 additions and 50 deletions
+1
View File
@@ -794,6 +794,7 @@ func getCommonCommands() []*cobra.Command {
systemCmd,
systemdCmd,
switchUserCmd,
switchCmd,
dmenuCmd,
}...)
}
+282
View File
@@ -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"
}
+19 -6
View File
@@ -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
+193
View File
@@ -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
}
})
}
+141
View File
@@ -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")
}
}
+54 -11
View File
@@ -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 {
+5 -5
View File
@@ -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 {
+22 -4
View File
@@ -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 {
+74 -5
View File
@@ -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 {
+37 -5
View File
@@ -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 {
+21 -4
View File
@@ -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 {
+125
View File
@@ -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)
}
}
+45 -3
View File
@@ -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{
+1 -1
View File
@@ -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
+7 -3
View File
@@ -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),
}
}
+32
View File
@@ -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")
}
}
+21 -3
View File
@@ -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 {
+14
View File
@@ -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