1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

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

(cherry picked from commit e4657aa5f9)
This commit is contained in:
purian23
2026-07-13 12:02:13 -04:00
committed by dms-ci[bot]
parent dd9edd8a00
commit e9d958b32b
19 changed files with 674 additions and 40 deletions
+12 -2
View File
@@ -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)
+47 -2
View File
@@ -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 {
@@ -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)
}
}
+19 -1
View File
@@ -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" {
@@ -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 {
@@ -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
@@ -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 ",
@@ -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)
}
@@ -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
}
+51 -11
View File
@@ -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 {
+15
View File
@@ -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
+1
View File
@@ -81,6 +81,7 @@ type UpgradeOptions struct {
CustomCommand string
Terminal string
Targets []Package
Ignored []string
}
type RefreshOptions struct {
@@ -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"},