diff --git a/core/internal/server/sysupdate/backend_apt.go b/core/internal/server/sysupdate/backend_apt.go index 53071eb9..ef1b2fad 100644 --- a/core/internal/server/sysupdate/backend_apt.go +++ b/core/internal/server/sysupdate/backend_apt.go @@ -45,7 +45,11 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func( OnLine: onLine, }) } - argv := []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y"} + names := pickTargetNames(opts.Targets, "apt", true) + if len(names) == 0 { + return nil + } + argv := append([]string{"pkexec", "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 9ae73bc6..050f7706 100644 --- a/core/internal/server/sysupdate/backend_dnf.go +++ b/core/internal/server/sysupdate/backend_dnf.go @@ -45,7 +45,12 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun if opts.DryRun { return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine}) } - return Run(ctx, []string{"pkexec", b.bin, "upgrade", "-y"}, RunOptions{OnLine: onLine}) + names := pickTargetNames(opts.Targets, b.bin, true) + if len(names) == 0 { + return nil + } + argv := append([]string{"pkexec", b.bin, "upgrade", "-y"}, names...) + return Run(ctx, argv, RunOptions{OnLine: onLine}) } func dnfListUpgrades(ctx context.Context, bin string) (string, error) { @@ -88,14 +93,14 @@ func parseDnfList(text, backendID string, installed map[string]string) []Package } nameArch := fields[0] version := fields[1] - switch nameArch { - case "Available", "Upgrades": + dot := strings.LastIndex(nameArch, ".") + if dot <= 0 { continue } - name := nameArch - if dot := strings.LastIndex(nameArch, "."); dot > 0 { - name = nameArch[:dot] + if !looksLikeRpmVersion(version) { + continue } + name := nameArch[:dot] pkgs = append(pkgs, Package{ Name: nameArch, Repo: RepoSystem, @@ -106,3 +111,15 @@ func parseDnfList(text, backendID string, installed map[string]string) []Package } return pkgs } + +func looksLikeRpmVersion(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r >= '0' && r <= '9' { + return true + } + } + return false +} diff --git a/core/internal/server/sysupdate/backend_dnf_test.go b/core/internal/server/sysupdate/backend_dnf_test.go index 17025ad3..a3370d11 100644 --- a/core/internal/server/sysupdate/backend_dnf_test.go +++ b/core/internal/server/sysupdate/backend_dnf_test.go @@ -56,12 +56,15 @@ bash.x86_64 5.2.40-1.fc41 updates`, want: nil, }, { - name: "package without arch suffix", - input: "noarchpkg 1.2.3 updates", + name: "skips dnf5 banner / column header lines", + input: `Updates available +Last metadata expiration check: 0:01:23 ago on Tue Apr 29 14:00:00 2026. +Package Version Repository Size +bash.x86_64 5.2.40-1.fc41 updates`, backendID: "dnf", - installed: map[string]string{"noarchpkg": "1.2.0"}, + installed: nil, want: []Package{ - {Name: "noarchpkg", Repo: RepoSystem, Backend: "dnf", FromVersion: "1.2.0", ToVersion: "1.2.3"}, + {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"}, }, }, } diff --git a/core/internal/server/sysupdate/backend_flatpak.go b/core/internal/server/sysupdate/backend_flatpak.go index fed5ba79..57874cf3 100644 --- a/core/internal/server/sysupdate/backend_flatpak.go +++ b/core/internal/server/sysupdate/backend_flatpak.go @@ -70,13 +70,32 @@ type flatpakInstalledEntry struct { } func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { - argv := []string{"flatpak", "update", "-y", "--noninteractive"} if opts.DryRun { - argv = []string{"flatpak", "update", "--no-deploy", "-y"} + return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine}) } + refs := flatpakTargetRefs(opts.Targets) + if len(refs) == 0 { + return nil + } + argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...) return Run(ctx, argv, RunOptions{OnLine: onLine}) } +func flatpakTargetRefs(targets []Package) []string { + out := make([]string, 0, len(targets)) + for _, p := range targets { + if p.Backend != "flatpak" { + continue + } + ref := p.Ref + if ref == "" { + ref = p.Name + } + out = append(out, ref) + } + return out +} + func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package { if text == "" { return nil @@ -111,14 +130,25 @@ func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry key = appID + "//" + branch } inst := installed[key] + + if inst.commit != "" && commit != "" && strings.HasPrefix(commit, inst.commit) { + continue + } + from, to := flatpakVersionPair(inst.version, inst.commit, version, commit) + ref := appID + if branch != "" { + ref = appID + "//" + branch + } + pkgs = append(pkgs, Package{ Name: display, Repo: RepoFlatpak, Backend: "flatpak", FromVersion: from, ToVersion: to, + Ref: ref, }) } return pkgs diff --git a/core/internal/server/sysupdate/backend_flatpak_test.go b/core/internal/server/sysupdate/backend_flatpak_test.go index cbd18a52..6705b641 100644 --- a/core/internal/server/sysupdate/backend_flatpak_test.go +++ b/core/internal/server/sysupdate/backend_flatpak_test.go @@ -31,6 +31,7 @@ func TestParseFlatpakUpdates(t *testing.T) { Backend: "flatpak", FromVersion: "8b16fa1a", ToVersion: "43a1e5d2", + Ref: "com.discordapp.Discord//stable", }, }, }, @@ -47,6 +48,7 @@ func TestParseFlatpakUpdates(t *testing.T) { Backend: "flatpak", FromVersion: "1.4.2", ToVersion: "1.5.0", + Ref: "com.example.App//stable", }, }, }, @@ -61,6 +63,7 @@ func TestParseFlatpakUpdates(t *testing.T) { Backend: "flatpak", FromVersion: "", ToVersion: "badcd4af", + Ref: "org.gnome.Platform//49", }, }, }, @@ -74,6 +77,7 @@ func TestParseFlatpakUpdates(t *testing.T) { Backend: "flatpak", FromVersion: "", ToVersion: "2.0", + Ref: "com.example.NoName//stable", }, }, }, @@ -87,9 +91,18 @@ func TestParseFlatpakUpdates(t *testing.T) { Backend: "flatpak", FromVersion: "", ToVersion: "1.0", + Ref: "org.real.App//stable", }, }, }, + { + name: "skips phantom updates where remote commit matches installed", + input: "com.phantom.App\t\tstable\tabc12345deadbeef\tPhantom", + installed: map[string]flatpakInstalledEntry{ + "com.phantom.App//stable": {commit: "abc12345"}, + }, + want: nil, + }, } for _, tt := range tests { diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go index 53d0987a..5b81cb5e 100644 --- a/core/internal/server/sysupdate/backend_pacman.go +++ b/core/internal/server/sysupdate/backend_pacman.go @@ -43,17 +43,24 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine if opts.DryRun { return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine}) } - return Run(ctx, []string{"pkexec", "pacman", "-Syu", "--noconfirm"}, RunOptions{OnLine: onLine}) + names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR) + if len(names) == 0 { + return nil + } + argv := append([]string{"pkexec", "pacman", "-Sy", "--noconfirm", "--needed"}, names...) + return Run(ctx, argv, RunOptions{OnLine: onLine}) } type archHelperBackend struct { id string } -func (b archHelperBackend) ID() string { return b.id } -func (b archHelperBackend) Repo() RepoKind { return RepoSystem } -func (b archHelperBackend) NeedsAuth() bool { return true } -func (b archHelperBackend) RunsInTerminal() bool { return true } +func (b archHelperBackend) ID() string { return b.id } +func (b archHelperBackend) Repo() RepoKind { return RepoSystem } +func (b archHelperBackend) NeedsAuth() bool { return true } +func (b archHelperBackend) RunsInTerminal() bool { + return os.Getenv("DMS_FORCE_PKEXEC") != "1" +} func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) } func (b archHelperBackend) DisplayName() string { @@ -86,18 +93,37 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL if opts.DryRun { return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine}) } + names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR) + if len(names) == 0 { + return nil + } + if os.Getenv("DMS_FORCE_PKEXEC") == "1" { + argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...) + return Run(ctx, argv, RunOptions{OnLine: onLine}) + } term := findTerminal(opts.Terminal) if term == "" { return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") } - cmd := fmt.Sprintf("%s -Syu", b.id) - if !opts.IncludeAUR { - cmd += " --repo" - } + cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " ")) title := fmt.Sprintf("DMS — System Update (%s)", b.id) return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) } +func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string { + out := make([]string, 0, len(targets)) + for _, p := range targets { + if p.Backend != backendID { + continue + } + if !includeAUR && p.Repo == RepoAUR { + continue + } + out = append(out, p.Name) + } + return out +} + func pacmanRepoUpdates(ctx context.Context) (string, error) { if commandExists("checkupdates") { return capturePermissive(ctx, "checkupdates") diff --git a/core/internal/server/sysupdate/backend_zypper.go b/core/internal/server/sysupdate/backend_zypper.go index f0aa0d5b..9966de36 100644 --- a/core/internal/server/sysupdate/backend_zypper.go +++ b/core/internal/server/sysupdate/backend_zypper.go @@ -74,5 +74,10 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu if opts.DryRun { return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine}) } - return Run(ctx, []string{"pkexec", "zypper", "--non-interactive", "update"}, RunOptions{OnLine: onLine}) + names := pickTargetNames(opts.Targets, "zypper", true) + if len(names) == 0 { + return nil + } + argv := append([]string{"pkexec", "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 9a086759..c7bc88e7 100644 --- a/core/internal/server/sysupdate/executor.go +++ b/core/internal/server/sysupdate/executor.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "sync" + "syscall" ) type RunOptions struct { @@ -24,6 +25,13 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error { if len(opts.Env) > 0 { cmd.Env = append(cmd.Environ(), opts.Env...) } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go index 0f5403ce..b8b0577f 100644 --- a/core/internal/server/sysupdate/manager.go +++ b/core/internal/server/sysupdate/manager.go @@ -309,6 +309,12 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) { return } + if len(opts.Targets) == 0 { + m.mu.RLock() + opts.Targets = append([]Package(nil), m.state.Packages...) + m.mu.RUnlock() + } + opID := fmt.Sprintf("op-%d", time.Now().UnixNano()) m.mu.Lock() m.state.Phase = PhaseUpgrading diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go index 06dbe174..372ac0c9 100644 --- a/core/internal/server/sysupdate/types.go +++ b/core/internal/server/sysupdate/types.go @@ -38,6 +38,7 @@ type Package struct { ToVersion string `json:"toVersion,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` ChangelogURL string `json:"changelogUrl,omitempty"` + Ref string `json:"-"` } type BackendInfo struct { @@ -77,6 +78,7 @@ type UpgradeOptions struct { DryRun bool CustomCommand string Terminal string + Targets []Package } type RefreshOptions struct { diff --git a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml index 7acd3877..c4434e6c 100644 --- a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml +++ b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell.Wayland import qs.Common import qs.Services import qs.Widgets @@ -17,6 +18,21 @@ DankPopout { property bool _reopenAfterUpgrade: false + readonly property bool polkitModalOpen: PopoutService.polkitAuthModal?.visible ?? false + readonly property bool anyModalOpen: polkitModalOpen + + backgroundInteractive: !anyModalOpen + + customKeyboardFocus: { + if (!shouldBeVisible) + return WlrKeyboardFocus.None; + if (anyModalOpen) + return WlrKeyboardFocus.None; + if (CompositorService.useHyprlandFocusGrab) + return WlrKeyboardFocus.OnDemand; + return WlrKeyboardFocus.Exclusive; + } + Connections { target: SystemUpdateService function onIsUpgradingChanged() { @@ -38,7 +54,11 @@ DankPopout { screen: triggerScreen shouldBeVisible: false - onBackgroundClicked: close() + onBackgroundClicked: { + if (anyModalOpen) + return; + close(); + } onShouldBeVisibleChanged: { if (!shouldBeVisible) { @@ -290,7 +310,7 @@ DankPopout { } } font.pixelSize: Theme.fontSizeMedium - color: SystemUpdateService.hasError ? Theme.errorText : Theme.surfaceText + color: SystemUpdateService.hasError ? Theme.error : Theme.surfaceText wrapMode: Text.WordWrap }