diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go index a7e48a34..266646f4 100644 --- a/core/cmd/dms/commands_system.go +++ b/core/cmd/dms/commands_system.go @@ -12,6 +12,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) @@ -98,6 +99,8 @@ func runSystemUpdateCheck() { log.Fatal("No supported package manager found") } + stopSpin := startSpinner("Checking for updates… ") + type backendResult struct { ID string `json:"id"` Display string `json:"displayName"` @@ -115,6 +118,7 @@ func runSystemUpdateCheck() { results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs}) allPkgs = append(allPkgs, pkgs...) } + stopSpin() if sysUpdateJSON { out, _ := json.MarshalIndent(map[string]any{ @@ -137,7 +141,7 @@ func runSystemUpdateCheck() { } fmt.Println() for _, p := range allPkgs { - fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?")) + printPackage(p) } } @@ -150,7 +154,9 @@ func runSystemUpdateApply() { log.Fatal("No supported package manager found") } + stopSpin := startSpinner("Checking for updates…") pkgs, firstErr := collectUpdates(checkCtx, backends) + stopSpin() if firstErr != nil { fmt.Printf("Warning: %v\n\n", firstErr) } @@ -163,12 +169,12 @@ func runSystemUpdateApply() { } fmt.Println() for _, p := range pkgs { - fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?")) + printPackage(p) } fmt.Println() if !sysUpdateNoConfirm && !sysUpdateDry { - if !promptYesNo("Proceed with upgrade? [y/N]: ") { + if !promptYesNo("Proceed with upgrade? [Y/n]: ") { fmt.Println("Aborted.") return } @@ -178,9 +184,11 @@ func runSystemUpdateApply() { defer cancel() opts := sysupdate.UpgradeOptions{ + Targets: pkgs, IncludeFlatpak: !sysUpdateNoFlatpak, IncludeAUR: !sysUpdateNoAUR, DryRun: sysUpdateDry, + UseSudo: true, } onLine := func(line string) { fmt.Println(line) } @@ -236,10 +244,10 @@ func promptYesNo(prompt string) bool { return false } switch strings.ToLower(strings.TrimSpace(line)) { - case "y", "yes": - return true - default: + case "n", "no": return false + default: + return true } } @@ -262,6 +270,57 @@ func stdinIsTTY() bool { return (fi.Mode() & os.ModeCharDevice) != 0 } +func stdoutIsTTY() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// startSpinner prints an animated spinner to stdout for progress indication +func startSpinner(msg string) func() { + if !stdoutIsTTY() { + return func() {} + } + frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + done := make(chan struct{}) + go func() { + for i := 0; ; i++ { + select { + case <-done: + fmt.Print("\r\033[K") + return + case <-time.After(80 * time.Millisecond): + fmt.Printf("\r%s %s", frames[i%len(frames)], msg) + } + } + }() + return func() { close(done) } +} + +var ( + styleRepo = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Bold(false) + styleName = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + styleFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + styleArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + styleTo = lipgloss.NewStyle().Foreground(lipgloss.Color("76")).Bold(true) +) + +func printPackage(p sysupdate.Package) { + if !stdoutIsTTY() { + fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?")) + return + } + fmt.Printf(" %s %s %s %s %s\n", + styleRepo.Render("["+string(p.Repo)+"]"), + styleName.Render(p.Name), + styleFrom.Render(defaultIfEmpty(p.FromVersion, "?")), + styleArrow.Render("->"), + styleTo.Render(defaultIfEmpty(p.ToVersion, "?")), + ) +} + func errOrEmpty(err error) string { if err == nil { return "" diff --git a/core/cmd/dms/utils.go b/core/cmd/dms/utils.go index 9459e41c..93c978a0 100644 --- a/core/cmd/dms/utils.go +++ b/core/cmd/dms/utils.go @@ -15,7 +15,7 @@ func isReadOnlyCommand(args []string) bool { continue } switch arg { - case "completion", "help", "__complete": + case "completion", "help", "__complete", "system": return true } return false diff --git a/core/internal/server/sysupdate/backend_apt.go b/core/internal/server/sysupdate/backend_apt.go index ef1b2fad..0791c740 100644 --- a/core/internal/server/sysupdate/backend_apt.go +++ b/core/internal/server/sysupdate/backend_apt.go @@ -49,7 +49,8 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func( if len(names) == 0 { return nil } - argv := append([]string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...) + privesc := privescBin(opts.UseSudo) + argv := append([]string{privesc, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...) return Run(ctx, argv, RunOptions{OnLine: onLine}) } diff --git a/core/internal/server/sysupdate/backend_dnf.go b/core/internal/server/sysupdate/backend_dnf.go index 1ca5ff52..bab2afb6 100644 --- a/core/internal/server/sysupdate/backend_dnf.go +++ b/core/internal/server/sysupdate/backend_dnf.go @@ -43,18 +43,19 @@ func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) { func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { if opts.DryRun { - return Run(ctx, []string{b.bin, "upgrade", "--refresh", "--assumeno"}, RunOptions{OnLine: onLine}) + return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine}) } names := pickTargetNames(opts.Targets, b.bin, true) if len(names) == 0 { return nil } - argv := append([]string{"pkexec", b.bin, "upgrade", "--refresh", "-y"}, names...) + privesc := privescBin(opts.UseSudo) + argv := append([]string{privesc, b.bin, "upgrade", "-y"}, names...) return Run(ctx, argv, RunOptions{OnLine: onLine}) } func dnfListUpgrades(ctx context.Context, bin string) (string, error) { - cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet") + cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--refresh", "--quiet") out, err := cmd.Output() if err == nil { return string(out), nil diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go index 5b81cb5e..6dea6010 100644 --- a/core/internal/server/sysupdate/backend_pacman.go +++ b/core/internal/server/sysupdate/backend_pacman.go @@ -47,7 +47,8 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine if len(names) == 0 { return nil } - argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...) + privesc := privescBin(opts.UseSudo) + argv := append([]string{privesc, "pacman", "-Sy", "--noconfirm", "--needed"}, names...) return Run(ctx, argv, RunOptions{OnLine: onLine}) } diff --git a/core/internal/server/sysupdate/backend_zypper.go b/core/internal/server/sysupdate/backend_zypper.go index 9966de36..3aacb0d4 100644 --- a/core/internal/server/sysupdate/backend_zypper.go +++ b/core/internal/server/sysupdate/backend_zypper.go @@ -78,6 +78,7 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu if len(names) == 0 { return nil } - argv := append([]string{"pkexec", "zypper", "--non-interactive", "update"}, names...) + privesc := privescBin(opts.UseSudo) + argv := append([]string{privesc, "zypper", "--non-interactive", "update"}, names...) return Run(ctx, argv, RunOptions{OnLine: onLine}) } diff --git a/core/internal/server/sysupdate/executor.go b/core/internal/server/sysupdate/executor.go index c7bc88e7..e43bb29c 100644 --- a/core/internal/server/sysupdate/executor.go +++ b/core/internal/server/sysupdate/executor.go @@ -9,6 +9,8 @@ import ( "os/exec" "sync" "syscall" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) type RunOptions struct { @@ -77,6 +79,18 @@ func Capture(ctx context.Context, argv []string) (string, error) { return string(out), err } +// privescBin returns the binary to use for privilege escalation. +// When useSudo is true it auto-detects the best available tool (sudo/doas/run0). +// When false it falls back to pkexec for GUI callers. +func privescBin(useSudo bool) string { + if useSudo { + if t, err := privesc.Detect(); err == nil { + return t.Name() + } + } + return "pkexec" +} + func findTerminal(override string) string { if override != "" && commandExists(override) { return override diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go index 372ac0c9..4205e522 100644 --- a/core/internal/server/sysupdate/types.go +++ b/core/internal/server/sysupdate/types.go @@ -76,6 +76,7 @@ type UpgradeOptions struct { IncludeFlatpak bool IncludeAUR bool DryRun bool + UseSudo bool CustomCommand string Terminal string Targets []Package diff --git a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml index bce02dd1..332009f3 100644 --- a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml +++ b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml @@ -365,19 +365,31 @@ DankPopout { elide: Text.ElideRight } - StyledText { + Row { width: parent.width - text: { - const from = modelData.fromVersion || ""; - const to = modelData.toVersion || ""; - if (from && to) { - return `${from} → ${to}`; + spacing: 4 + + StyledText { + text: { + const from = modelData.fromVersion || ""; + const to = modelData.toVersion || ""; + if (from && to) + return `${from} →`; + return ""; } - return to || from || ""; + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: text !== "" + } + + StyledText { + text: modelData.toVersion || modelData.fromVersion || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + font.weight: Font.Medium + elide: Text.ElideRight + width: parent.width - (parent.children[0].visible ? parent.children[0].implicitWidth + 4 : 0) } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - elide: Text.ElideRight } } }