1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-01 19:18: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
This commit is contained in:
purian23
2026-07-13 12:02:13 -04:00
parent 197d17ac4e
commit e4657aa5f9
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"},
+1
View File
@@ -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: ({})
@@ -514,6 +514,7 @@ var SPEC = {
updaterIntervalSeconds: { def: 1800 },
updaterIncludeFlatpak: { def: true },
updaterAllowAUR: { def: true },
updaterIgnoredPackages: { def: [] },
displayNameMode: { def: "system" },
screenPreferences: { def: {} },
@@ -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)
}
}
}
@@ -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"
+37 -1
View File
@@ -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);
}
@@ -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",