From e4657aa5f9b93f70c3c21547b5346f3b43e78bd9 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 13 Jul 2026 12:02:13 -0400 Subject: [PATCH] feat(dms updater): add support for ignoring specific packages during system updates - Added UI component & popout settings to manage ignored packages - Added CLI support available in danklinux docs Fixes: #2827 Closes: #2344, #1741 Port 1.5 --- core/cmd/dms/commands_system.go | 14 +- core/internal/server/sysupdate/backend_apt.go | 49 ++++- .../server/sysupdate/backend_apt_test.go | 19 ++ core/internal/server/sysupdate/backend_dnf.go | 20 +- .../server/sysupdate/backend_flatpak.go | 16 +- .../server/sysupdate/backend_pacman.go | 20 +- .../server/sysupdate/backend_pacman_test.go | 11 + .../server/sysupdate/backend_zypper.go | 19 +- core/internal/server/sysupdate/handlers.go | 19 ++ core/internal/server/sysupdate/manager.go | 62 +++++- core/internal/server/sysupdate/targets.go | 15 ++ core/internal/server/sysupdate/types.go | 1 + .../server/sysupdate/upgrade_commands_test.go | 82 ++++++- quickshell/Common/SettingsData.qml | 1 + quickshell/Common/settings/SettingsSpec.js | 1 + .../DankBar/Popouts/SystemUpdatePopout.qml | 102 ++++++++- .../Modules/Settings/SystemUpdaterTab.qml | 208 +++++++++++++++++- quickshell/Services/SystemUpdateService.qml | 38 +++- .../translations/settings_search_index.json | 17 ++ 19 files changed, 674 insertions(+), 40 deletions(-) diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go index 93e3322d5..296132bdf 100644 --- a/core/cmd/dms/commands_system.go +++ b/core/cmd/dms/commands_system.go @@ -47,6 +47,7 @@ var ( sysUpdateJSON bool sysUpdateNoFlatpak bool sysUpdateNoAUR bool + sysUpdateIgnore []string sysUpdateIntervalS int sysUpdateListPmTime = 5 * time.Minute ) @@ -58,6 +59,7 @@ func init() { systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay") systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)") + systemUpdateCmd.Flags().StringSliceVar(&sysUpdateIgnore, "ignore", nil, "Skip specific packages (repeatable or comma-separated)") systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)") systemCmd.AddCommand(systemUpdateCmd) @@ -192,6 +194,7 @@ func runSystemUpdateApply() { Targets: pkgs, IncludeFlatpak: !sysUpdateNoFlatpak, IncludeAUR: !sysUpdateNoAUR, + Ignored: sysUpdateIgnore, DryRun: sysUpdateDry, UseSudo: true, } @@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd } func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package { - if !sysUpdateNoAUR { + if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 { return pkgs } + ignored := make(map[string]bool, len(sysUpdateIgnore)) + for _, name := range sysUpdateIgnore { + ignored[name] = true + } out := pkgs[:0] for _, p := range pkgs { - if p.Repo == sysupdate.RepoAUR { + if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR { + continue + } + if ignored[p.Name] { continue } out = append(out, p) diff --git a/core/internal/server/sysupdate/backend_apt.go b/core/internal/server/sysupdate/backend_apt.go index 149167152..11ff99a31 100644 --- a/core/internal/server/sysupdate/backend_apt.go +++ b/core/internal/server/sysupdate/backend_apt.go @@ -2,6 +2,7 @@ package sysupdate import ( "context" + "fmt" "os/exec" "regexp" "strings" @@ -31,7 +32,36 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) { if err != nil { return nil, err } - return parseAptUpgradable(string(out)), nil + return filterAptHeld(parseAptUpgradable(string(out)), aptHeldPackages(ctx)), nil +} + +// aptHeldPackages returns held packages, which apt-get upgrade never applies. +func aptHeldPackages(ctx context.Context) map[string]bool { + out, err := exec.CommandContext(ctx, "apt-mark", "showhold").Output() + if err != nil { + return nil + } + held := make(map[string]bool) + for line := range strings.SplitSeq(string(out), "\n") { + if name := strings.TrimSpace(line); name != "" { + held[name] = true + } + } + return held +} + +func filterAptHeld(pkgs []Package, held map[string]bool) []Package { + if len(held) == 0 { + return pkgs + } + out := pkgs[:0] + for _, p := range pkgs { + if held[p.Name] { + continue + } + out = append(out, p) + } + return out } func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { @@ -52,7 +82,22 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func( } func aptUpgradeArgv(bin string, opts UpgradeOptions) []string { - return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y") + ignored := shellSafeNames(opts.Ignored) + if len(ignored) == 0 { + return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y") + } + return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "sh", "-c", aptHoldScript(bin, ignored)) +} + +// aptHoldScript holds ignored packages only for the upgrade, leaving pre-existing user holds untouched. +func aptHoldScript(bin string, ignored []string) string { + names := strings.Join(ignored, " ") + return fmt.Sprintf( + `new=""; for p in %s; do apt-mark showhold | grep -qx "$p" || new="$new $p"; done; `+ + `[ -n "$new" ] && apt-mark hold $new; `+ + `%s upgrade -y; rc=$?; `+ + `[ -n "$new" ] && apt-mark unhold $new; exit $rc`, + names, bin) } func parseAptUpgradable(text string) []Package { diff --git a/core/internal/server/sysupdate/backend_apt_test.go b/core/internal/server/sysupdate/backend_apt_test.go index 503eb1c72..02cabf9e6 100644 --- a/core/internal/server/sysupdate/backend_apt_test.go +++ b/core/internal/server/sysupdate/backend_apt_test.go @@ -70,3 +70,22 @@ libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`, }) } } + +func TestFilterAptHeld(t *testing.T) { + pkgs := []Package{ + {Name: "bash", Repo: RepoSystem, Backend: "apt"}, + {Name: "linux-image-generic", Repo: RepoSystem, Backend: "apt"}, + {Name: "zsh", Repo: RepoSystem, Backend: "apt"}, + } + + got := filterAptHeld(append([]Package(nil), pkgs...), map[string]bool{"linux-image-generic": true}) + want := []Package{pkgs[0], pkgs[2]} + if !reflect.DeepEqual(got, want) { + t.Errorf("filterAptHeld() = %#v\nwant %#v", got, want) + } + + unfiltered := filterAptHeld(append([]Package(nil), pkgs...), nil) + if !reflect.DeepEqual(unfiltered, pkgs) { + t.Errorf("filterAptHeld(nil held) = %#v\nwant %#v", unfiltered, pkgs) + } +} diff --git a/core/internal/server/sysupdate/backend_dnf.go b/core/internal/server/sysupdate/backend_dnf.go index 0d25f6ee9..049399158 100644 --- a/core/internal/server/sysupdate/backend_dnf.go +++ b/core/internal/server/sysupdate/backend_dnf.go @@ -3,6 +3,7 @@ package sysupdate import ( "context" "errors" + "fmt" "os/exec" "strings" ) @@ -52,7 +53,11 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun } func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string { - return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y") + argv := []string{bin, "upgrade", "--refresh", "-y"} + if len(opts.Ignored) > 0 { + argv = append(argv, "--exclude="+strings.Join(opts.Ignored, ",")) + } + return privilegedArgv(opts, argv...) } func dnfListUpgrades(ctx context.Context, bin string) (string, error) { @@ -65,9 +70,22 @@ func dnfListUpgrades(ctx context.Context, bin string) (string, error) { if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 { return string(out), nil } + if detail := lastNonEmptyLine(string(out)); detail != "" { + return "", fmt.Errorf("%w: %s", err, detail) + } return "", err } +func lastNonEmptyLine(text string) string { + lines := strings.Split(text, "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(lines[i]); line != "" { + return line + } + } + return "" +} + func dnfCheckUpdatesArgv(bin string) []string { subcommand := "check-update" if bin == "dnf5" { diff --git a/core/internal/server/sysupdate/backend_flatpak.go b/core/internal/server/sysupdate/backend_flatpak.go index 2494fed3c..e2b14d76d 100644 --- a/core/internal/server/sysupdate/backend_flatpak.go +++ b/core/internal/server/sysupdate/backend_flatpak.go @@ -95,11 +95,21 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine}) + return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine}) } -func flatpakUpgradeArgv() []string { - return []string{"flatpak", "update", "-y", "--noninteractive"} +func flatpakUpgradeArgv(opts UpgradeOptions) []string { + argv := []string{"flatpak", "update", "-y", "--noninteractive"} + if len(opts.Ignored) == 0 { + return argv + } + // No exclude flag; update the already-filtered refs explicitly. + for _, p := range opts.Targets { + if p.Repo == RepoFlatpak && p.Ref != "" { + argv = append(argv, p.Ref) + } + } + return argv } func parseFlatpakUpdateOutput(text string, installed map[string]flatpakInstalledEntry) []Package { diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go index 959b47ed8..e07f404af 100644 --- a/core/internal/server/sysupdate/backend_pacman.go +++ b/core/internal/server/sysupdate/backend_pacman.go @@ -50,7 +50,11 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine } func pacmanUpgradeArgv(opts UpgradeOptions) []string { - return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed") + argv := []string{"pacman", "-Syu", "--noconfirm", "--needed"} + if len(opts.Ignored) > 0 { + argv = append(argv, "--ignore", strings.Join(opts.Ignored, ",")) + } + return privilegedArgv(opts, argv...) } type archHelperBackend struct { @@ -99,23 +103,27 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL return nil } if os.Getenv("DMS_FORCE_PKEXEC") == "1" { - argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...) + argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored)...) return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) } 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 := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ") + cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR, opts.Ignored), " ") title := fmt.Sprintf("DMS — System Update (%s)", b.id) return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) } -func archHelperUpgradeArgv(id string, includeAUR bool) []string { +func archHelperUpgradeArgv(id string, includeAUR bool, ignored []string) []string { argv := []string{id, "-Syu", "--noconfirm", "--needed"} if !includeAUR { argv = append(argv, "--repo") } + ignored = shellSafeNames(ignored) + if len(ignored) > 0 { + argv = append(argv, "--ignore", strings.Join(ignored, ",")) + } return argv } @@ -248,6 +256,10 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package { if line == "" { continue } + // pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker + if strings.HasSuffix(line, "[ignored]") { + continue + } m := archUpdateLine.FindStringSubmatch(line) if m == nil { continue diff --git a/core/internal/server/sysupdate/backend_pacman_test.go b/core/internal/server/sysupdate/backend_pacman_test.go index 8fd866f9c..befe7ae95 100644 --- a/core/internal/server/sysupdate/backend_pacman_test.go +++ b/core/internal/server/sysupdate/backend_pacman_test.go @@ -92,6 +92,17 @@ foo`, {Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"}, }, }, + { + name: "skips IgnorePkg entries", + input: `bat 0.26.0-1 -> 0.26.1-2 +linux 6.18.0-1 -> 6.18.1-1 [ignored] +discord 0.0.108-1 -> 0.0.109-1 [ignored]`, + backendID: "pacman", + repo: RepoSystem, + want: []Package{ + {Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"}, + }, + }, { name: "extra whitespace tolerated", input: " bat 0.26.0-1 -> 0.26.1-2 ", diff --git a/core/internal/server/sysupdate/backend_zypper.go b/core/internal/server/sysupdate/backend_zypper.go index 1ea1ee46c..40efcd47b 100644 --- a/core/internal/server/sysupdate/backend_zypper.go +++ b/core/internal/server/sysupdate/backend_zypper.go @@ -4,7 +4,9 @@ import ( "context" "encoding/xml" "errors" + "fmt" "os/exec" + "strings" ) func init() { @@ -81,5 +83,20 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu } func zypperUpgradeArgv(opts UpgradeOptions) []string { - return privilegedArgv(opts, "zypper", "--non-interactive", "update") + ignored := shellSafeNames(opts.Ignored) + if len(ignored) == 0 { + return privilegedArgv(opts, "zypper", "--non-interactive", "update") + } + return privilegedArgv(opts, "sh", "-c", zypperLockScript(ignored)) +} + +// zypperLockScript locks ignored packages only for the update, leaving pre-existing user locks untouched. +func zypperLockScript(ignored []string) string { + names := strings.Join(ignored, " ") + return fmt.Sprintf( + `new=""; for p in %s; do grep -qsE "^solvable_name:[[:space:]]*$p$" /etc/zypp/locks || new="$new $p"; done; `+ + `[ -n "$new" ] && zypper --non-interactive al $new; `+ + `zypper --non-interactive update; rc=$?; `+ + `[ -n "$new" ] && zypper --non-interactive rl $new; exit $rc`, + names) } diff --git a/core/internal/server/sysupdate/handlers.go b/core/internal/server/sysupdate/handlers.go index 27dbc2a1a..a0f7e1a79 100644 --- a/core/internal/server/sysupdate/handlers.go +++ b/core/internal/server/sysupdate/handlers.go @@ -46,6 +46,7 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) { DryRun: params.BoolOpt(req.Params, "dry", false), CustomCommand: params.StringOpt(req.Params, "customCommand", ""), Terminal: params.StringOpt(req.Params, "terminal", ""), + Ignored: stringSliceOpt(req.Params, "ignored"), } if err := m.Upgrade(opts); err != nil { models.RespondError(conn, req.ID, err.Error()) @@ -53,3 +54,21 @@ func handleUpgrade(conn net.Conn, req models.Request, m *Manager) { } models.Respond(conn, req.ID, m.GetState()) } + +func stringSliceOpt(p map[string]any, key string) []string { + val, ok := params.Any(p, key) + if !ok { + return nil + } + arr, ok := val.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go index 3b8ec14ac..c54263d30 100644 --- a/core/internal/server/sysupdate/manager.go +++ b/core/internal/server/sysupdate/manager.go @@ -20,6 +20,7 @@ const ( minIntervalSeconds = 5 * 60 recentLogCapacity = 200 checkTimeout = 5 * time.Minute + retryIntervalSeconds = 5 * 60 upgradeTimeout = 30 * time.Minute postUpgradeCompleteDelay = 3 * time.Second ) @@ -150,7 +151,7 @@ func (m *Manager) Refresh(opts RefreshOptions) { m.refreshSerial.Unlock() return } - m.runRefresh(context.Background()) + m.runRefresh(context.Background(), true) } func (m *Manager) Upgrade(opts UpgradeOptions) error { @@ -237,12 +238,12 @@ func (m *Manager) scheduler() { case <-m.wakeSched: t.Stop() case <-t.C: - m.runRefresh(context.Background()) + m.runRefresh(context.Background(), false) } } } -func (m *Manager) runRefresh(parent context.Context) { +func (m *Manager) runRefresh(parent context.Context, manual bool) { m.refreshSerial.Lock() defer m.refreshSerial.Unlock() @@ -284,27 +285,43 @@ func (m *Manager) runRefresh(parent context.Context) { now := time.Now().Unix() m.mu.Lock() m.state.LastCheckUnix = now - m.state.Packages = m.state.Packages[:0] + prev := m.state.Packages + next := make([]Package, 0, len(prev)) var firstErr error for i, r := range results { if r.err != nil { if firstErr == nil { firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err) } + // Retain a failed backend's last known packages so a transient failure doesn't wipe the list. + for _, p := range prev { + if p.Backend == backends[i].ID() { + next = append(next, p) + } + } continue } - m.state.Packages = append(m.state.Packages, r.pkgs...) + next = append(next, r.pkgs...) } - m.state.Count = len(m.state.Packages) + m.state.Packages = next + m.state.Count = len(next) m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds) - if firstErr != nil { - m.state.Phase = PhaseError - m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()} - } else { + switch { + case firstErr == nil: m.state.Phase = PhaseIdle m.state.LastSuccessUnix = now + case manual: + m.state.Phase = PhaseError + m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()} + default: + // Background checks fail silently and retry sooner; only manual refreshes surface errors. + m.state.Phase = PhaseIdle + retry := min(int64(m.state.IntervalSeconds), retryIntervalSeconds) + m.state.NextCheckUnix = now + retry + log.Warnf("[sysupdate] background check failed, retrying in %ds: %v", retry, firstErr) } m.mu.Unlock() + m.wake() m.markDirty() } @@ -328,10 +345,15 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) { opts.Targets = append([]Package(nil), m.state.Packages...) m.mu.RUnlock() } + opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored) backends := upgradeBackends(m.selection, opts) if len(backends) == 0 { - m.setError(ErrCodeNoBackend, "no backend selected for upgrade") + if len(opts.Targets) > 0 { + m.setError(ErrCodeNoBackend, "all pending updates are excluded by current settings (AUR/Flatpak disabled)") + } else { + m.setError(ErrCodeNoBackend, "no backend selected for upgrade") + } return } @@ -429,6 +451,24 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { m.markDirty() } +func dropIgnoredTargets(targets []Package, ignored []string) []Package { + if len(ignored) == 0 { + return targets + } + skip := make(map[string]bool, len(ignored)) + for _, name := range ignored { + skip[name] = true + } + out := targets[:0] + for _, p := range targets { + if skip[p.Name] { + continue + } + out = append(out, p) + } + return out +} + func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend { var out []Backend if sel.System != nil { diff --git a/core/internal/server/sysupdate/targets.go b/core/internal/server/sysupdate/targets.go index c2b8ff194..ea18965e9 100644 --- a/core/internal/server/sysupdate/targets.go +++ b/core/internal/server/sysupdate/targets.go @@ -1,5 +1,20 @@ package sysupdate +import "regexp" + +var safePkgName = regexp.MustCompile(`^[A-Za-z0-9@._+:-]+$`) + +// shellSafeNames drops names unsafe to interpolate into the apt/zypper sh -c scripts. +func shellSafeNames(names []string) []string { + out := make([]string, 0, len(names)) + for _, n := range names { + if safePkgName.MatchString(n) { + out = append(out, n) + } + } + return out +} + func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool { if b == nil || len(targets) == 0 { return false diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go index 2d3c0d903..63ea14f92 100644 --- a/core/internal/server/sysupdate/types.go +++ b/core/internal/server/sysupdate/types.go @@ -81,6 +81,7 @@ type UpgradeOptions struct { CustomCommand string Terminal string Targets []Package + Ignored []string } type RefreshOptions struct { diff --git a/core/internal/server/sysupdate/upgrade_commands_test.go b/core/internal/server/sysupdate/upgrade_commands_test.go index 55ed28ee1..df5e8a694 100644 --- a/core/internal/server/sysupdate/upgrade_commands_test.go +++ b/core/internal/server/sysupdate/upgrade_commands_test.go @@ -2,6 +2,7 @@ package sysupdate import ( "reflect" + "strings" "testing" ) @@ -35,19 +36,55 @@ func TestUpgradeCommandBuilders(t *testing.T) { }, { name: "aur helper full update with aur", - got: archHelperUpgradeArgv("paru", true), + got: archHelperUpgradeArgv("paru", true, nil), want: []string{"paru", "-Syu", "--noconfirm", "--needed"}, }, { name: "aur helper repo-only full update", - got: archHelperUpgradeArgv("yay", false), + got: archHelperUpgradeArgv("yay", false, nil), want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"}, }, + { + name: "aur helper with ignored packages", + got: archHelperUpgradeArgv("paru", true, []string{"linux", "bad;name", "discord"}), + want: []string{"paru", "-Syu", "--noconfirm", "--needed", "--ignore", "linux,discord"}, + }, + { + name: "pacman with ignored packages", + got: pacmanUpgradeArgv(UpgradeOptions{Ignored: []string{"linux"}}), + want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed", "--ignore", "linux"}, + }, + { + name: "dnf with ignored packages", + got: dnfUpgradeArgv("dnf5", UpgradeOptions{Ignored: []string{"kernel", "mesa"}}), + want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y", "--exclude=kernel,mesa"}, + }, + { + name: "apt without ignored uses plain upgrade", + got: aptUpgradeArgv("apt-get", UpgradeOptions{}), + want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"}, + }, + { + name: "zypper without ignored uses plain update", + got: zypperUpgradeArgv(UpgradeOptions{}), + want: []string{"pkexec", "zypper", "--non-interactive", "update"}, + }, { name: "flatpak full update", - got: flatpakUpgradeArgv(), + got: flatpakUpgradeArgv(UpgradeOptions{}), want: []string{"flatpak", "update", "-y", "--noninteractive"}, }, + { + name: "flatpak update with ignored targets refs", + got: flatpakUpgradeArgv(UpgradeOptions{ + Ignored: []string{"org.mozilla.firefox"}, + Targets: []Package{ + {Name: "Discord", Repo: RepoFlatpak, Ref: "com.discordapp.Discord//stable"}, + {Name: "bash", Repo: RepoSystem, Backend: "apt"}, + }, + }), + want: []string{"flatpak", "update", "-y", "--noninteractive", "com.discordapp.Discord//stable"}, + }, { name: "rpm-ostree upgrade", got: rpmOstreeUpgradeArgv(UpgradeOptions{}), @@ -69,6 +106,45 @@ func TestUpgradeCommandBuilders(t *testing.T) { } } +func TestAptUpgradeArgvHoldsIgnored(t *testing.T) { + argv := aptUpgradeArgv("apt-get", UpgradeOptions{Ignored: []string{"linux-image-generic", "bad;name"}}) + if len(argv) < 2 || argv[len(argv)-2] != "-c" { + t.Fatalf("expected sh -c script, got %#v", argv) + } + script := argv[len(argv)-1] + if !strings.Contains(script, "apt-mark hold") || !strings.Contains(script, "apt-mark unhold") { + t.Fatalf("hold script missing hold/unhold: %q", script) + } + if !strings.Contains(script, "linux-image-generic") { + t.Fatalf("hold script missing ignored package: %q", script) + } + if strings.Contains(script, "bad;name") { + t.Fatalf("hold script must drop unsafe name: %q", script) + } +} + +func TestZypperUpgradeArgvLocksIgnored(t *testing.T) { + argv := zypperUpgradeArgv(UpgradeOptions{Ignored: []string{"kernel-default"}}) + if len(argv) < 2 || argv[len(argv)-2] != "-c" { + t.Fatalf("expected sh -c script, got %#v", argv) + } + script := argv[len(argv)-1] + if !strings.Contains(script, "zypper --non-interactive al") || !strings.Contains(script, "zypper --non-interactive rl") { + t.Fatalf("lock script missing add/remove lock: %q", script) + } + if !strings.Contains(script, "kernel-default") { + t.Fatalf("lock script missing ignored package: %q", script) + } +} + +func TestShellSafeNames(t *testing.T) { + got := shellSafeNames([]string{"linux", "gtk+", "bad name", "rm -rf /", "org.mozilla.firefox", "a;b"}) + want := []string{"linux", "gtk+", "org.mozilla.firefox"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("shellSafeNames() = %#v, want %#v", got, want) + } +} + func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) { targets := []Package{ {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"}, diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index a86b320a0..2c312a79a 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -959,6 +959,7 @@ Singleton { property int updaterIntervalSeconds: 1800 property bool updaterIncludeFlatpak: true property bool updaterAllowAUR: true + property var updaterIgnoredPackages: [] property string displayNameMode: "system" property var screenPreferences: ({}) diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 41140f422..c53f8c9ad 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -514,6 +514,7 @@ var SPEC = { updaterIntervalSeconds: { def: 1800 }, updaterIncludeFlatpak: { def: true }, updaterAllowAUR: { def: true }, + updaterIgnoredPackages: { def: [] }, displayNameMode: { def: "system" }, screenPreferences: { def: {} }, diff --git a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml index 78527274d..7306b77be 100644 --- a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml +++ b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml @@ -76,6 +76,47 @@ DankPopout { readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true) + property int nowUnix: Math.floor(Date.now() / 1000) + + Connections { + target: systemUpdatePopout + function onShouldBeVisibleChanged() { + if (systemUpdatePopout.shouldBeVisible) { + updaterPanel.nowUnix = Math.floor(Date.now() / 1000); + } + } + } + + function distroLabel() { + const pretty = (SystemUpdateService.distributionPretty || "").trim(); + if (pretty) { + return pretty.split(/\s+/)[0]; + } + const id = (SystemUpdateService.distribution || "").trim(); + if (id) { + return id.charAt(0).toUpperCase() + id.slice(1); + } + return I18n.tr("System"); + } + + function lastCheckedText() { + const last = SystemUpdateService.lastCheckUnix; + if (!last) { + return ""; + } + const delta = Math.max(0, nowUnix - last); + if (delta < 90) { + return I18n.tr("checked just now"); + } + if (delta < 3600) { + return I18n.tr("checked %1m ago").arg(Math.round(delta / 60)); + } + if (delta < 86400) { + return I18n.tr("checked %1h ago").arg(Math.round(delta / 3600)); + } + return I18n.tr("checked %1d ago").arg(Math.round(delta / 86400)); + } + Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { systemUpdatePopout.close(); @@ -171,8 +212,17 @@ DankPopout { anchors.topMargin: Theme.spacingS visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading text: { - const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", "); - return I18n.tr("Backends: %1").arg(names); + const kinds = []; + for (const b of SystemUpdateService.backends || []) { + const label = b.repo === "flatpak" ? I18n.tr("Flatpak") : I18n.tr("System"); + if (!kinds.includes(label)) { + kinds.push(label); + } + } + const distro = updaterPanel.distroLabel(); + const checked = updaterPanel.lastCheckedText(); + const base = `${distro}: ${kinds.join(", ")}`; + return checked ? `${base} · ${checked}` : base; } font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText @@ -180,6 +230,21 @@ DankPopout { elide: Text.ElideRight } + StyledText { + id: hiddenRow + anchors.left: parent.left + anchors.right: parent.right + anchors.top: backendsRow.visible ? backendsRow.bottom : header.bottom + anchors.leftMargin: Theme.spacingL + anchors.rightMargin: Theme.spacingL + anchors.topMargin: Theme.spacingXS + visible: SystemUpdateService.hiddenUpdateCount > 0 && !SystemUpdateService.isUpgrading + text: I18n.tr("%1 hidden (AUR disabled or ignored)").arg(SystemUpdateService.hiddenUpdateCount) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + Row { id: buttonsRow anchors.left: parent.left @@ -275,7 +340,7 @@ DankPopout { id: bodyArea anchors.left: parent.left anchors.right: parent.right - anchors.top: backendsRow.visible ? backendsRow.bottom : header.bottom + anchors.top: hiddenRow.visible ? hiddenRow.bottom : (backendsRow.visible ? backendsRow.bottom : header.bottom) anchors.bottom: buttonsRow.top anchors.leftMargin: Theme.spacingL anchors.rightMargin: Theme.spacingL @@ -294,7 +359,8 @@ DankPopout { text: { switch (true) { case SystemUpdateService.hasError: - return I18n.tr("Failed: %1").arg(SystemUpdateService.errorMessage); + const msg = I18n.tr("Failed: %1").arg(SystemUpdateService.errorMessage); + return SystemUpdateService.errorHint ? `${msg}\n\n${SystemUpdateService.errorHint}` : msg; case !SystemUpdateService.helperAvailable: return I18n.tr("No supported package manager found."); case SystemUpdateService.isChecking: @@ -318,13 +384,18 @@ DankPopout { model: SystemUpdateService.availableUpdates delegate: Rectangle { + id: packageRow width: ListView.view.width height: 48 radius: Theme.cornerRadius - color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0) + color: rowHoverHandler.hovered ? Theme.primaryHoverLight : Theme.withAlpha(Theme.primaryHoverLight, 0) required property var modelData + HoverHandler { + id: rowHoverHandler + } + Row { anchors.left: parent.left anchors.right: parent.right @@ -350,7 +421,7 @@ DankPopout { Column { anchors.verticalCenter: parent.verticalCenter - width: parent.width - 64 - Theme.spacingS + width: parent.width - 64 - Theme.spacingS * 2 - 28 spacing: Theme.spacingXXS StyledText { @@ -395,13 +466,26 @@ DankPopout { id: packageMouseArea anchors.fill: parent hoverEnabled: true - cursorShape: modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor + cursorShape: packageRow.modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: { - if (modelData.changelogUrl) { - Qt.openUrlExternally(modelData.changelogUrl); + if (packageRow.modelData.changelogUrl) { + Qt.openUrlExternally(packageRow.modelData.changelogUrl); } } } + + DankActionButton { + anchors.right: packageRow.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: packageRow.verticalCenter + buttonSize: 24 + iconName: "visibility_off" + iconSize: 16 + iconColor: Theme.surfaceVariantText + visible: rowHoverHandler.hovered + tooltipText: I18n.tr("Ignore this package") + onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name) + } } } diff --git a/quickshell/Modules/Settings/SystemUpdaterTab.qml b/quickshell/Modules/Settings/SystemUpdaterTab.qml index fd5192e56..67dedba2b 100644 --- a/quickshell/Modules/Settings/SystemUpdaterTab.qml +++ b/quickshell/Modules/Settings/SystemUpdaterTab.qml @@ -30,13 +30,20 @@ Item { } ] + readonly property string customIntervalLabel: I18n.tr("Custom") + property bool customIntervalSelected: false + + Component.onCompleted: { + customIntervalSelected = !intervalOptions.some(o => o.seconds === SettingsData.updaterIntervalSeconds); + } + function intervalLabelFor(seconds) { for (const opt of intervalOptions) { if (opt.seconds === seconds) { return opt.label; } } - return intervalOptions[1].label; + return customIntervalLabel; } function intervalSecondsFor(label) { @@ -84,15 +91,71 @@ Item { SettingsDropdownRow { text: I18n.tr("Check interval") description: I18n.tr("How often the server polls for new updates.") - options: root.intervalOptions.map(o => o.label) - currentValue: root.intervalLabelFor(SettingsData.updaterIntervalSeconds) + options: root.intervalOptions.map(o => o.label).concat([root.customIntervalLabel]) + currentValue: root.customIntervalSelected ? root.customIntervalLabel : root.intervalLabelFor(SettingsData.updaterIntervalSeconds) onValueChanged: label => { + if (label === root.customIntervalLabel) { + root.customIntervalSelected = true; + return; + } + root.customIntervalSelected = false; const secs = root.intervalSecondsFor(label); SettingsData.set("updaterIntervalSeconds", secs); SystemUpdateService.setInterval(secs); } } + FocusScope { + width: parent.width - Theme.spacingM * 2 + height: customIntervalColumn.implicitHeight + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + visible: root.customIntervalSelected + + Column { + id: customIntervalColumn + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Custom interval in minutes (minimum 5)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: customIntervalField + width: parent.width + placeholderText: "30" + backgroundColor: Theme.surfaceContainerHighest + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + + Component.onCompleted: { + text = Math.round(SettingsData.updaterIntervalSeconds / 60).toString(); + } + + onTextEdited: { + const minutes = parseInt(text, 10); + if (isNaN(minutes) || minutes < 5) { + return; + } + const secs = minutes * 60; + SettingsData.set("updaterIntervalSeconds", secs); + SystemUpdateService.setInterval(secs); + } + + MouseArea { + anchors.fill: parent + onPressed: mouse => { + customIntervalField.forceActiveFocus(); + mouse.accepted = false; + } + } + } + } + } + SettingsToggleRow { text: I18n.tr("Check on startup") description: I18n.tr("When enabled, checks updates on startup. When disabled, only the interval above or a manual refresh runs a check.") @@ -119,6 +182,145 @@ Item { TerminalPickerRow {} } + SettingsCard { + id: ignoredPackagesCard + width: parent.width + iconName: "inventory_2" + title: I18n.tr("Ignored Packages") + settingKey: "systemUpdaterIgnoredPackages" + tags: ["system", "update", "package", "ignore"] + + function addIgnoredPackage() { + const name = newIgnoredPackageField.text.trim(); + if (name === "") { + return; + } + if (!/^[A-Za-z0-9@._+:-]+$/.test(name)) { + ignoredPackageError.visible = true; + return; + } + ignoredPackageError.visible = false; + SystemUpdateService.ignorePackage(name); + newIgnoredPackageField.text = ""; + } + + Column { + width: parent.width + spacing: Theme.spacingS + + StyledText { + width: parent.width + text: { + if (SettingsData.updaterUseCustomCommand) { + return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions."); + } + return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button."); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + bottomPadding: Theme.spacingS + } + + Row { + width: parent.width + spacing: Theme.spacingS + + DankTextField { + id: newIgnoredPackageField + width: parent.width - addIgnoredBtn.width - Theme.spacingS + height: 36 + placeholderText: I18n.tr("Package name (e.g., docker)") + font.pixelSize: Theme.fontSizeSmall + onAccepted: ignoredPackagesCard.addIgnoredPackage() + onTextEdited: ignoredPackageError.visible = false + } + + DankActionButton { + id: addIgnoredBtn + buttonSize: 36 + iconName: "add" + iconSize: 20 + backgroundColor: Theme.primary + iconColor: Theme.onPrimary + tooltipText: I18n.tr("Ignore package") + onClicked: ignoredPackagesCard.addIgnoredPackage() + } + } + + StyledText { + id: ignoredPackageError + visible: false + text: I18n.tr("Invalid package name — letters, digits and @._+:- only.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + } + + SettingsCard { + width: parent.width + iconName: "visibility_off" + title: I18n.tr("Ignored (%1)").arg((SettingsData.updaterIgnoredPackages || []).length) + collapsible: true + expanded: false + visible: (SettingsData.updaterIgnoredPackages || []).length > 0 + color: Theme.withAlpha(Theme.surfaceContainer, 0.5) + + Repeater { + model: SettingsData.updaterIgnoredPackages + + delegate: Rectangle { + required property string modelData + required property int index + + width: parent.width + height: 40 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.5) + + DankIcon { + id: ignoredIcon + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: "visibility_off" + size: 18 + color: Theme.surfaceVariantText + } + + StyledText { + anchors.left: ignoredIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: removeIgnoredBtn.left + anchors.verticalCenter: parent.verticalCenter + text: parent.modelData + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + elide: Text.ElideRight + } + + DankActionButton { + id: removeIgnoredBtn + anchors.right: parent.right + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + buttonSize: 32 + iconName: "delete" + iconSize: 18 + iconColor: Theme.error + backgroundColor: "transparent" + tooltipText: I18n.tr("Stop ignoring %1").arg(parent.modelData) + onClicked: { + const list = (SettingsData.updaterIgnoredPackages || []).slice(); + list.splice(parent.index, 1); + SettingsData.set("updaterIgnoredPackages", list); + } + } + } + } + } + } + } + SettingsCard { width: parent.width iconName: "tune" diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index 2af4bd43f..70cd686f8 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -15,10 +15,12 @@ Singleton { property bool sysupdateAvailable: false property var availableUpdates: [] + property var _rawUpdates: [] property bool isChecking: false property bool isUpgrading: false property bool hasError: false property string errorMessage: "" + property string errorHint: "" property string errorCode: "" property var backends: [] property string distribution: "" @@ -31,6 +33,7 @@ Singleton { property int nextCheckUnix: 0 readonly property int updateCount: availableUpdates.length + readonly property int hiddenUpdateCount: _rawUpdates.length - availableUpdates.length readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0 Connections { @@ -57,6 +60,12 @@ Singleton { function onUpdaterCheckOnStartChanged() { Qt.callLater(() => root._maybeStartupCheck()); } + function onUpdaterAllowAURChanged() { + root._refilter(); + } + function onUpdaterIgnoredPackagesChanged() { + root._refilter(); + } function on_HasLoadedChanged() { Qt.callLater(() => root._maybeStartupCheck()); } @@ -100,7 +109,8 @@ Singleton { if (!data) { return; } - availableUpdates = data.packages || []; + _rawUpdates = data.packages || []; + availableUpdates = _filterUpdates(_rawUpdates); backends = data.backends || []; distribution = data.distro || ""; distributionPretty = data.distroPretty || ""; @@ -129,10 +139,12 @@ Singleton { hasError = true; errorMessage = data.error.message || ""; errorCode = data.error.code || ""; + errorHint = data.error.hint || ""; } else { hasError = false; errorMessage = ""; errorCode = ""; + errorHint = ""; } if (backends.length > 0) { @@ -143,6 +155,29 @@ Singleton { } } + function _filterUpdates(pkgs) { + const ignored = SettingsData.updaterIgnoredPackages || []; + return (pkgs || []).filter(p => { + if (!SettingsData.updaterAllowAUR && p.repo === "aur") + return false; + return ignored.indexOf(p.name) === -1; + }); + } + + function _refilter() { + availableUpdates = _filterUpdates(_rawUpdates); + } + + function ignorePackage(name) { + if (!name) + return; + const list = (SettingsData.updaterIgnoredPackages || []).slice(); + if (list.indexOf(name) !== -1) + return; + list.push(name); + SettingsData.set("updaterIgnoredPackages", list); + } + function checkForUpdates() { DMSService.sysupdateRefresh(false, null); } @@ -153,6 +188,7 @@ Singleton { _runCustomTerminalCommand(); return; } + params.ignored = SettingsData.updaterIgnoredPackages || []; DMSService.sysupdateUpgrade(params, null); } diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 31d12aa2a..09a11dad0 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -6804,6 +6804,23 @@ "icon": "tune", "description": "Open a terminal and run a custom command instead of the in-shell upgrade flow." }, + { + "section": "systemUpdaterIgnoredPackages", + "label": "Ignored Packages", + "tabIndex": 20, + "category": "System Updater", + "keywords": [ + "ignore", + "ignored", + "package", + "packages", + "system", + "update", + "updater", + "upgrade" + ], + "icon": "inventory_2" + }, { "section": "systemUpdater", "label": "System Updater",