mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-06 21:48:30 -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:
@@ -47,6 +47,7 @@ var (
|
|||||||
sysUpdateJSON bool
|
sysUpdateJSON bool
|
||||||
sysUpdateNoFlatpak bool
|
sysUpdateNoFlatpak bool
|
||||||
sysUpdateNoAUR bool
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIgnore []string
|
||||||
sysUpdateIntervalS int
|
sysUpdateIntervalS int
|
||||||
sysUpdateListPmTime = 5 * time.Minute
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,7 @@ func init() {
|
|||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
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(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
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)")
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
systemCmd.AddCommand(systemUpdateCmd)
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
@@ -192,6 +194,7 @@ func runSystemUpdateApply() {
|
|||||||
Targets: pkgs,
|
Targets: pkgs,
|
||||||
IncludeFlatpak: !sysUpdateNoFlatpak,
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
IncludeAUR: !sysUpdateNoAUR,
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
Ignored: sysUpdateIgnore,
|
||||||
DryRun: sysUpdateDry,
|
DryRun: sysUpdateDry,
|
||||||
UseSudo: true,
|
UseSudo: true,
|
||||||
}
|
}
|
||||||
@@ -234,12 +237,19 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package {
|
||||||
if !sysUpdateNoAUR {
|
if !sysUpdateNoAUR && len(sysUpdateIgnore) == 0 {
|
||||||
return pkgs
|
return pkgs
|
||||||
}
|
}
|
||||||
|
ignored := make(map[string]bool, len(sysUpdateIgnore))
|
||||||
|
for _, name := range sysUpdateIgnore {
|
||||||
|
ignored[name] = true
|
||||||
|
}
|
||||||
out := pkgs[:0]
|
out := pkgs[:0]
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
if p.Repo == sysupdate.RepoAUR {
|
if sysUpdateNoAUR && p.Repo == sysupdate.RepoAUR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ignored[p.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, p)
|
out = append(out, p)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sysupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,7 +32,36 @@ func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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 {
|
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 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sysupdate
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -52,7 +53,11 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun
|
|||||||
}
|
}
|
||||||
|
|
||||||
func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string {
|
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) {
|
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 {
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 {
|
||||||
return string(out), nil
|
return string(out), nil
|
||||||
}
|
}
|
||||||
|
if detail := lastNonEmptyLine(string(out)); detail != "" {
|
||||||
|
return "", fmt.Errorf("%w: %s", err, detail)
|
||||||
|
}
|
||||||
return "", err
|
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 {
|
func dnfCheckUpdatesArgv(bin string) []string {
|
||||||
subcommand := "check-update"
|
subcommand := "check-update"
|
||||||
if bin == "dnf5" {
|
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) {
|
if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine})
|
return Run(ctx, flatpakUpgradeArgv(opts), RunOptions{OnLine: onLine})
|
||||||
}
|
}
|
||||||
|
|
||||||
func flatpakUpgradeArgv() []string {
|
func flatpakUpgradeArgv(opts UpgradeOptions) []string {
|
||||||
return []string{"flatpak", "update", "-y", "--noninteractive"}
|
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 {
|
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 {
|
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 {
|
type archHelperBackend struct {
|
||||||
@@ -99,23 +103,27 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if os.Getenv("DMS_FORCE_PKEXEC") == "1" {
|
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})
|
return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio})
|
||||||
}
|
}
|
||||||
term := findTerminal(opts.Terminal)
|
term := findTerminal(opts.Terminal)
|
||||||
if term == "" {
|
if term == "" {
|
||||||
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
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)
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
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"}
|
argv := []string{id, "-Syu", "--noconfirm", "--needed"}
|
||||||
if !includeAUR {
|
if !includeAUR {
|
||||||
argv = append(argv, "--repo")
|
argv = append(argv, "--repo")
|
||||||
}
|
}
|
||||||
|
ignored = shellSafeNames(ignored)
|
||||||
|
if len(ignored) > 0 {
|
||||||
|
argv = append(argv, "--ignore", strings.Join(ignored, ","))
|
||||||
|
}
|
||||||
return argv
|
return argv
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +256,10 @@ func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
|||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// pacman -Qu / paru -Qua flag IgnorePkg entries with a trailing marker
|
||||||
|
if strings.HasSuffix(line, "[ignored]") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
m := archUpdateLine.FindStringSubmatch(line)
|
m := archUpdateLine.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -92,6 +92,17 @@ foo`,
|
|||||||
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
{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",
|
name: "extra whitespace tolerated",
|
||||||
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -81,5 +83,20 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func zypperUpgradeArgv(opts UpgradeOptions) []string {
|
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),
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
Ignored: stringSliceOpt(req.Params, "ignored"),
|
||||||
}
|
}
|
||||||
if err := m.Upgrade(opts); err != nil {
|
if err := m.Upgrade(opts); err != nil {
|
||||||
models.RespondError(conn, req.ID, err.Error())
|
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())
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
minIntervalSeconds = 5 * 60
|
minIntervalSeconds = 5 * 60
|
||||||
recentLogCapacity = 200
|
recentLogCapacity = 200
|
||||||
checkTimeout = 5 * time.Minute
|
checkTimeout = 5 * time.Minute
|
||||||
|
retryIntervalSeconds = 5 * 60
|
||||||
upgradeTimeout = 30 * time.Minute
|
upgradeTimeout = 30 * time.Minute
|
||||||
postUpgradeCompleteDelay = 3 * time.Second
|
postUpgradeCompleteDelay = 3 * time.Second
|
||||||
)
|
)
|
||||||
@@ -150,7 +151,7 @@ func (m *Manager) Refresh(opts RefreshOptions) {
|
|||||||
m.refreshSerial.Unlock()
|
m.refreshSerial.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.runRefresh(context.Background())
|
m.runRefresh(context.Background(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||||
@@ -237,12 +238,12 @@ func (m *Manager) scheduler() {
|
|||||||
case <-m.wakeSched:
|
case <-m.wakeSched:
|
||||||
t.Stop()
|
t.Stop()
|
||||||
case <-t.C:
|
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()
|
m.refreshSerial.Lock()
|
||||||
defer m.refreshSerial.Unlock()
|
defer m.refreshSerial.Unlock()
|
||||||
|
|
||||||
@@ -284,27 +285,43 @@ func (m *Manager) runRefresh(parent context.Context) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.state.LastCheckUnix = now
|
m.state.LastCheckUnix = now
|
||||||
m.state.Packages = m.state.Packages[:0]
|
prev := m.state.Packages
|
||||||
|
next := make([]Package, 0, len(prev))
|
||||||
var firstErr error
|
var firstErr error
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
if firstErr == nil {
|
if firstErr == nil {
|
||||||
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
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
|
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)
|
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||||
if firstErr != nil {
|
switch {
|
||||||
m.state.Phase = PhaseError
|
case firstErr == nil:
|
||||||
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
|
||||||
} else {
|
|
||||||
m.state.Phase = PhaseIdle
|
m.state.Phase = PhaseIdle
|
||||||
m.state.LastSuccessUnix = now
|
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.mu.Unlock()
|
||||||
|
m.wake()
|
||||||
m.markDirty()
|
m.markDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,10 +345,15 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
|||||||
opts.Targets = append([]Package(nil), m.state.Packages...)
|
opts.Targets = append([]Package(nil), m.state.Packages...)
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
opts.Targets = dropIgnoredTargets(opts.Targets, opts.Ignored)
|
||||||
|
|
||||||
backends := upgradeBackends(m.selection, opts)
|
backends := upgradeBackends(m.selection, opts)
|
||||||
if len(backends) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +451,24 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) {
|
|||||||
m.markDirty()
|
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 {
|
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||||
var out []Backend
|
var out []Backend
|
||||||
if sel.System != nil {
|
if sel.System != nil {
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
package sysupdate
|
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 {
|
func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool {
|
||||||
if b == nil || len(targets) == 0 {
|
if b == nil || len(targets) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type UpgradeOptions struct {
|
|||||||
CustomCommand string
|
CustomCommand string
|
||||||
Terminal string
|
Terminal string
|
||||||
Targets []Package
|
Targets []Package
|
||||||
|
Ignored []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshOptions struct {
|
type RefreshOptions struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sysupdate
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,19 +36,55 @@ func TestUpgradeCommandBuilders(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "aur helper full update with aur",
|
name: "aur helper full update with aur",
|
||||||
got: archHelperUpgradeArgv("paru", true),
|
got: archHelperUpgradeArgv("paru", true, nil),
|
||||||
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
want: []string{"paru", "-Syu", "--noconfirm", "--needed"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "aur helper repo-only full update",
|
name: "aur helper repo-only full update",
|
||||||
got: archHelperUpgradeArgv("yay", false),
|
got: archHelperUpgradeArgv("yay", false, nil),
|
||||||
want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"},
|
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",
|
name: "flatpak full update",
|
||||||
got: flatpakUpgradeArgv(),
|
got: flatpakUpgradeArgv(UpgradeOptions{}),
|
||||||
want: []string{"flatpak", "update", "-y", "--noninteractive"},
|
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",
|
name: "rpm-ostree upgrade",
|
||||||
got: rpmOstreeUpgradeArgv(UpgradeOptions{}),
|
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) {
|
func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) {
|
||||||
targets := []Package{
|
targets := []Package{
|
||||||
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"},
|
||||||
|
|||||||
@@ -959,6 +959,7 @@ Singleton {
|
|||||||
property int updaterIntervalSeconds: 1800
|
property int updaterIntervalSeconds: 1800
|
||||||
property bool updaterIncludeFlatpak: true
|
property bool updaterIncludeFlatpak: true
|
||||||
property bool updaterAllowAUR: true
|
property bool updaterAllowAUR: true
|
||||||
|
property var updaterIgnoredPackages: []
|
||||||
|
|
||||||
property string displayNameMode: "system"
|
property string displayNameMode: "system"
|
||||||
property var screenPreferences: ({})
|
property var screenPreferences: ({})
|
||||||
|
|||||||
@@ -514,6 +514,7 @@ var SPEC = {
|
|||||||
updaterIntervalSeconds: { def: 1800 },
|
updaterIntervalSeconds: { def: 1800 },
|
||||||
updaterIncludeFlatpak: { def: true },
|
updaterIncludeFlatpak: { def: true },
|
||||||
updaterAllowAUR: { def: true },
|
updaterAllowAUR: { def: true },
|
||||||
|
updaterIgnoredPackages: { def: [] },
|
||||||
|
|
||||||
displayNameMode: { def: "system" },
|
displayNameMode: { def: "system" },
|
||||||
screenPreferences: { def: {} },
|
screenPreferences: { def: {} },
|
||||||
|
|||||||
@@ -76,6 +76,47 @@ DankPopout {
|
|||||||
|
|
||||||
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
|
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 => {
|
Keys.onPressed: event => {
|
||||||
if (event.key === Qt.Key_Escape) {
|
if (event.key === Qt.Key_Escape) {
|
||||||
systemUpdatePopout.close();
|
systemUpdatePopout.close();
|
||||||
@@ -171,8 +212,17 @@ DankPopout {
|
|||||||
anchors.topMargin: Theme.spacingS
|
anchors.topMargin: Theme.spacingS
|
||||||
visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading
|
visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading
|
||||||
text: {
|
text: {
|
||||||
const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
|
const kinds = [];
|
||||||
return I18n.tr("Backends: %1").arg(names);
|
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
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
@@ -180,6 +230,21 @@ DankPopout {
|
|||||||
elide: Text.ElideRight
|
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 {
|
Row {
|
||||||
id: buttonsRow
|
id: buttonsRow
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -275,7 +340,7 @@ DankPopout {
|
|||||||
id: bodyArea
|
id: bodyArea
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
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.bottom: buttonsRow.top
|
||||||
anchors.leftMargin: Theme.spacingL
|
anchors.leftMargin: Theme.spacingL
|
||||||
anchors.rightMargin: Theme.spacingL
|
anchors.rightMargin: Theme.spacingL
|
||||||
@@ -294,7 +359,8 @@ DankPopout {
|
|||||||
text: {
|
text: {
|
||||||
switch (true) {
|
switch (true) {
|
||||||
case SystemUpdateService.hasError:
|
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:
|
case !SystemUpdateService.helperAvailable:
|
||||||
return I18n.tr("No supported package manager found.");
|
return I18n.tr("No supported package manager found.");
|
||||||
case SystemUpdateService.isChecking:
|
case SystemUpdateService.isChecking:
|
||||||
@@ -318,13 +384,18 @@ DankPopout {
|
|||||||
model: SystemUpdateService.availableUpdates
|
model: SystemUpdateService.availableUpdates
|
||||||
|
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
|
id: packageRow
|
||||||
width: ListView.view.width
|
width: ListView.view.width
|
||||||
height: 48
|
height: 48
|
||||||
radius: Theme.cornerRadius
|
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
|
required property var modelData
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rowHoverHandler
|
||||||
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -350,7 +421,7 @@ DankPopout {
|
|||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: parent.width - 64 - Theme.spacingS
|
width: parent.width - 64 - Theme.spacingS * 2 - 28
|
||||||
spacing: Theme.spacingXXS
|
spacing: Theme.spacingXXS
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
@@ -395,13 +466,26 @@ DankPopout {
|
|||||||
id: packageMouseArea
|
id: packageMouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: packageRow.modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (modelData.changelogUrl) {
|
if (packageRow.modelData.changelogUrl) {
|
||||||
Qt.openUrlExternally(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) {
|
function intervalLabelFor(seconds) {
|
||||||
for (const opt of intervalOptions) {
|
for (const opt of intervalOptions) {
|
||||||
if (opt.seconds === seconds) {
|
if (opt.seconds === seconds) {
|
||||||
return opt.label;
|
return opt.label;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return intervalOptions[1].label;
|
return customIntervalLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function intervalSecondsFor(label) {
|
function intervalSecondsFor(label) {
|
||||||
@@ -84,15 +91,71 @@ Item {
|
|||||||
SettingsDropdownRow {
|
SettingsDropdownRow {
|
||||||
text: I18n.tr("Check interval")
|
text: I18n.tr("Check interval")
|
||||||
description: I18n.tr("How often the server polls for new updates.")
|
description: I18n.tr("How often the server polls for new updates.")
|
||||||
options: root.intervalOptions.map(o => o.label)
|
options: root.intervalOptions.map(o => o.label).concat([root.customIntervalLabel])
|
||||||
currentValue: root.intervalLabelFor(SettingsData.updaterIntervalSeconds)
|
currentValue: root.customIntervalSelected ? root.customIntervalLabel : root.intervalLabelFor(SettingsData.updaterIntervalSeconds)
|
||||||
onValueChanged: label => {
|
onValueChanged: label => {
|
||||||
|
if (label === root.customIntervalLabel) {
|
||||||
|
root.customIntervalSelected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.customIntervalSelected = false;
|
||||||
const secs = root.intervalSecondsFor(label);
|
const secs = root.intervalSecondsFor(label);
|
||||||
SettingsData.set("updaterIntervalSeconds", secs);
|
SettingsData.set("updaterIntervalSeconds", secs);
|
||||||
SystemUpdateService.setInterval(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 {
|
SettingsToggleRow {
|
||||||
text: I18n.tr("Check on startup")
|
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.")
|
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 {}
|
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 {
|
SettingsCard {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
iconName: "tune"
|
iconName: "tune"
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ Singleton {
|
|||||||
property bool sysupdateAvailable: false
|
property bool sysupdateAvailable: false
|
||||||
|
|
||||||
property var availableUpdates: []
|
property var availableUpdates: []
|
||||||
|
property var _rawUpdates: []
|
||||||
property bool isChecking: false
|
property bool isChecking: false
|
||||||
property bool isUpgrading: false
|
property bool isUpgrading: false
|
||||||
property bool hasError: false
|
property bool hasError: false
|
||||||
property string errorMessage: ""
|
property string errorMessage: ""
|
||||||
|
property string errorHint: ""
|
||||||
property string errorCode: ""
|
property string errorCode: ""
|
||||||
property var backends: []
|
property var backends: []
|
||||||
property string distribution: ""
|
property string distribution: ""
|
||||||
@@ -31,6 +33,7 @@ Singleton {
|
|||||||
property int nextCheckUnix: 0
|
property int nextCheckUnix: 0
|
||||||
|
|
||||||
readonly property int updateCount: availableUpdates.length
|
readonly property int updateCount: availableUpdates.length
|
||||||
|
readonly property int hiddenUpdateCount: _rawUpdates.length - availableUpdates.length
|
||||||
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
@@ -57,6 +60,12 @@ Singleton {
|
|||||||
function onUpdaterCheckOnStartChanged() {
|
function onUpdaterCheckOnStartChanged() {
|
||||||
Qt.callLater(() => root._maybeStartupCheck());
|
Qt.callLater(() => root._maybeStartupCheck());
|
||||||
}
|
}
|
||||||
|
function onUpdaterAllowAURChanged() {
|
||||||
|
root._refilter();
|
||||||
|
}
|
||||||
|
function onUpdaterIgnoredPackagesChanged() {
|
||||||
|
root._refilter();
|
||||||
|
}
|
||||||
function on_HasLoadedChanged() {
|
function on_HasLoadedChanged() {
|
||||||
Qt.callLater(() => root._maybeStartupCheck());
|
Qt.callLater(() => root._maybeStartupCheck());
|
||||||
}
|
}
|
||||||
@@ -100,7 +109,8 @@ Singleton {
|
|||||||
if (!data) {
|
if (!data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
availableUpdates = data.packages || [];
|
_rawUpdates = data.packages || [];
|
||||||
|
availableUpdates = _filterUpdates(_rawUpdates);
|
||||||
backends = data.backends || [];
|
backends = data.backends || [];
|
||||||
distribution = data.distro || "";
|
distribution = data.distro || "";
|
||||||
distributionPretty = data.distroPretty || "";
|
distributionPretty = data.distroPretty || "";
|
||||||
@@ -129,10 +139,12 @@ Singleton {
|
|||||||
hasError = true;
|
hasError = true;
|
||||||
errorMessage = data.error.message || "";
|
errorMessage = data.error.message || "";
|
||||||
errorCode = data.error.code || "";
|
errorCode = data.error.code || "";
|
||||||
|
errorHint = data.error.hint || "";
|
||||||
} else {
|
} else {
|
||||||
hasError = false;
|
hasError = false;
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
errorCode = "";
|
errorCode = "";
|
||||||
|
errorHint = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backends.length > 0) {
|
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() {
|
function checkForUpdates() {
|
||||||
DMSService.sysupdateRefresh(false, null);
|
DMSService.sysupdateRefresh(false, null);
|
||||||
}
|
}
|
||||||
@@ -153,6 +188,7 @@ Singleton {
|
|||||||
_runCustomTerminalCommand();
|
_runCustomTerminalCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
params.ignored = SettingsData.updaterIgnoredPackages || [];
|
||||||
DMSService.sysupdateUpgrade(params, null);
|
DMSService.sysupdateUpgrade(params, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6804,6 +6804,23 @@
|
|||||||
"icon": "tune",
|
"icon": "tune",
|
||||||
"description": "Open a terminal and run a custom command instead of the in-shell upgrade flow."
|
"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",
|
"section": "systemUpdater",
|
||||||
"label": "System Updater",
|
"label": "System Updater",
|
||||||
|
|||||||
Reference in New Issue
Block a user