1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-30 17:42:06 -04:00

system updater: complete overhaul

Move system update flow to GO, with a CLI (convenient AIO tool) and
server integration. All lifecycle, scheduling, execution occurs on
backend side.

Run some backends via pkexec, some via terminal like paru/yay.

Incorporate flatpak as an option to update.

Add terminal override setting in GUI, in addition to $TERMINAL env
variable.

fixes #2307
fixes #822
fixes #1102
fixes #1812
fixes #1087
fixes #1743
This commit is contained in:
bbedward
2026-04-29 12:33:57 -04:00
parent a4cfdf4a59
commit 7bd9574868
43 changed files with 3556 additions and 710 deletions

View File

@@ -0,0 +1,96 @@
package sysupdate
import (
"context"
"os/exec"
"sync"
)
type Backend interface {
ID() string
DisplayName() string
Repo() RepoKind
IsAvailable(ctx context.Context) bool
NeedsAuth() bool
RunsInTerminal() bool
CheckUpdates(ctx context.Context) ([]Package, error)
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
}
type Selection struct {
System Backend
Overlay []Backend
}
func (s Selection) All() []Backend {
if s.System == nil {
return s.Overlay
}
out := make([]Backend, 0, 1+len(s.Overlay))
out = append(out, s.System)
out = append(out, s.Overlay...)
return out
}
func (s Selection) Info() []BackendInfo {
all := s.All()
out := make([]BackendInfo, 0, len(all))
for _, b := range all {
out = append(out, BackendInfo{
ID: b.ID(),
DisplayName: b.DisplayName(),
Repo: b.Repo(),
NeedsAuth: b.NeedsAuth(),
RunsInTerminal: b.RunsInTerminal(),
})
}
return out
}
var (
registryMu sync.RWMutex
systemCandidates []func() Backend
overlayCandidate []func() Backend
)
func RegisterSystemBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
systemCandidates = append(systemCandidates, factory)
}
func RegisterOverlayBackend(factory func() Backend) {
registryMu.Lock()
defer registryMu.Unlock()
overlayCandidate = append(overlayCandidate, factory)
}
func Select(ctx context.Context) Selection {
registryMu.RLock()
sys := append([]func() Backend(nil), systemCandidates...)
ov := append([]func() Backend(nil), overlayCandidate...)
registryMu.RUnlock()
var sel Selection
for _, factory := range sys {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.System = b
break
}
for _, factory := range ov {
b := factory()
if !b.IsAvailable(ctx) {
continue
}
sel.Overlay = append(sel.Overlay, b)
}
return sel
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

View File

@@ -0,0 +1,75 @@
package sysupdate
import (
"context"
"os/exec"
"regexp"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &aptBackend{} })
}
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
type aptBackend struct{}
func (aptBackend) ID() string { return "apt" }
func (aptBackend) DisplayName() string { return "APT" }
func (aptBackend) Repo() RepoKind { return RepoSystem }
func (aptBackend) NeedsAuth() bool { return true }
func (aptBackend) RunsInTerminal() bool { return false }
func (aptBackend) IsAvailable(_ context.Context) bool {
return commandExists("apt") || commandExists("apt-get")
}
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
out, err := cmd.Output()
if err != nil {
return nil, err
}
return parseAptUpgradable(string(out)), nil
}
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
bin := "apt-get"
if !commandExists(bin) {
bin = "apt"
}
if opts.DryRun {
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
OnLine: onLine,
})
}
argv := []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y"}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func parseAptUpgradable(text string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := aptUpgradableLine.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, Package{
Name: m[1],
Repo: RepoSystem,
Backend: "apt",
FromVersion: m[3],
ToVersion: m[2],
})
}
return pkgs
}

View File

@@ -0,0 +1,72 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseAptUpgradable(t *testing.T) {
tests := []struct {
name string
input string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "header line only",
input: `Listing... Done
`,
want: nil,
},
{
name: "single upgradable",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
{
name: "multiple architectures and suites",
input: `Listing... Done
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "package name with hyphens, dots, plus signs",
input: `Listing... Done
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
want: []Package{
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
},
},
{
name: "non-matching lines ignored",
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
want: []Package{
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseAptUpgradable(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,108 @@
package sysupdate
import (
"context"
"errors"
"os/exec"
"strings"
)
func init() {
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
}
type dnfBackend struct {
bin string
}
func (b dnfBackend) ID() string { return b.bin }
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
func (b dnfBackend) NeedsAuth() bool { return true }
func (b dnfBackend) RunsInTerminal() bool { return false }
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
if !commandExists(b.bin) {
return false
}
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
return false
}
return true
}
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := dnfListUpgrades(ctx, b.bin)
if err != nil {
return nil, err
}
installed := rpmInstalledVersions(ctx)
return parseDnfList(out, b.bin, installed), nil
}
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
}
return Run(ctx, []string{"pkexec", b.bin, "upgrade", "-y"}, RunOptions{OnLine: onLine})
}
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
return "", nil
}
return "", err
}
func rpmInstalledVersions(ctx context.Context) map[string]string {
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
if err != nil {
return nil
}
m := make(map[string]string)
for line := range strings.SplitSeq(string(out), "\n") {
name, ver, ok := strings.Cut(line, "\t")
if !ok {
continue
}
m[name] = ver
}
return m
}
func parseDnfList(text, backendID string, installed map[string]string) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
nameArch := fields[0]
version := fields[1]
switch nameArch {
case "Available", "Upgrades":
continue
}
name := nameArch
if dot := strings.LastIndex(nameArch, "."); dot > 0 {
name = nameArch[:dot]
}
pkgs = append(pkgs, Package{
Name: nameArch,
Repo: RepoSystem,
Backend: backendID,
FromVersion: installed[name],
ToVersion: version,
})
}
return pkgs
}

View File

@@ -0,0 +1,77 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseDnfList(t *testing.T) {
tests := []struct {
name string
input string
backendID string
installed map[string]string
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "single package with installed cross-ref",
input: "bash.x86_64 5.2.40-1.fc41 updates",
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "noarch package and missing installed entry",
input: `bash.x86_64 5.2.40-1.fc41 updates
fonts-misc.noarch 1.0.5-2.fc41 updates`,
backendID: "dnf",
installed: map[string]string{"bash": "5.2.39-1.fc41"},
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
},
},
{
name: "skips header rows",
input: `Available
Upgrades
bash.x86_64 5.2.40-1.fc41 updates`,
backendID: "dnf",
installed: nil,
want: []Package{
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
},
},
{
name: "skips lines with too few fields",
input: "incomplete",
backendID: "dnf",
want: nil,
},
{
name: "package without arch suffix",
input: "noarchpkg 1.2.3 updates",
backendID: "dnf",
installed: map[string]string{"noarchpkg": "1.2.0"},
want: []Package{
{Name: "noarchpkg", Repo: RepoSystem, Backend: "dnf", FromVersion: "1.2.0", ToVersion: "1.2.3"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDnfList(tt.input, tt.backendID, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,139 @@
package sysupdate
import (
"context"
"os/exec"
"strings"
)
func init() {
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
}
type flatpakBackend struct{}
func (flatpakBackend) ID() string { return "flatpak" }
func (flatpakBackend) DisplayName() string { return "Flatpak" }
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
func (flatpakBackend) NeedsAuth() bool { return false }
func (flatpakBackend) RunsInTerminal() bool { return false }
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
out, err := cmd.Output()
if err != nil {
return nil, err
}
installed := flatpakInstalled(ctx)
return parseFlatpakUpdates(string(out), installed), nil
}
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
if err != nil {
return nil
}
m := make(map[string]flatpakInstalledEntry)
for line := range strings.SplitSeq(string(out), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
entry := flatpakInstalledEntry{}
if len(fields) > 1 {
entry.version = fields[1]
}
if len(fields) > 2 {
entry.branch = fields[2]
}
if len(fields) > 3 {
entry.commit = fields[3]
}
key := appID
if entry.branch != "" {
key = appID + "//" + entry.branch
}
m[key] = entry
}
return m
}
type flatpakInstalledEntry struct {
version string
branch string
commit string
}
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"flatpak", "update", "-y", "--noninteractive"}
if opts.DryRun {
argv = []string{"flatpak", "update", "--no-deploy", "-y"}
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) == 0 || fields[0] == "" {
continue
}
appID := fields[0]
version, branch, commit := "", "", ""
if len(fields) > 1 {
version = fields[1]
}
if len(fields) > 2 {
branch = fields[2]
}
if len(fields) > 3 {
commit = fields[3]
}
display := appID
if len(fields) > 4 && fields[4] != "" {
display = fields[4]
}
key := appID
if branch != "" {
key = appID + "//" + branch
}
inst := installed[key]
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
pkgs = append(pkgs, Package{
Name: display,
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: from,
ToVersion: to,
})
}
return pkgs
}
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
if remoteVer != "" {
return installedVer, remoteVer
}
return shortCommit(installedCommit), shortCommit(remoteCommit)
}
func shortCommit(c string) string {
if len(c) > 8 {
return c[:8]
}
return c
}

View File

@@ -0,0 +1,137 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseFlatpakUpdates(t *testing.T) {
tests := []struct {
name string
input string
installed map[string]flatpakInstalledEntry
want []Package
}{
{
name: "empty",
input: "",
want: nil,
},
{
name: "real flathub-style row with empty version, falls back to commit",
// columns: application,version,branch,commit,name
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
installed: map[string]flatpakInstalledEntry{
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
},
want: []Package{
{
Name: "Discord",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "8b16fa1a",
ToVersion: "43a1e5d2",
},
},
},
{
name: "remote provides version, installed version known",
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
installed: map[string]flatpakInstalledEntry{
"com.example.App//stable": {version: "1.4.2"},
},
want: []Package{
{
Name: "Example App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "1.4.2",
ToVersion: "1.5.0",
},
},
},
{
name: "no installed entry, remote has no version, falls back to commit on both sides",
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
installed: nil,
want: []Package{
{
Name: "gnome platform",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "badcd4af",
},
},
},
{
name: "missing display name falls back to application id",
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
want: []Package{
{
Name: "com.example.NoName",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "2.0",
},
},
},
{
name: "skips blank lines and rows with empty application id",
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
want: []Package{
{
Name: "Real App",
Repo: RepoFlatpak,
Backend: "flatpak",
FromVersion: "",
ToVersion: "1.0",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseFlatpakUpdates(tt.input, tt.installed)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}
func TestFlatpakVersionPair(t *testing.T) {
tests := []struct {
name string
installedVer, installedCommit, remoteVer, remoteCommit string
wantFrom, wantTo string
}{
{
name: "remote has version - prefer versions",
installedVer: "1.0.0", remoteVer: "1.1.0",
wantFrom: "1.0.0", wantTo: "1.1.0",
},
{
name: "remote has no version - both sides fall to short commit",
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
},
{
name: "short commits left as-is",
installedCommit: "abc123", remoteCommit: "def456",
wantFrom: "abc123", wantTo: "def456",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
if from != tt.wantFrom || to != tt.wantTo {
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
}
})
}
}

View File

@@ -0,0 +1,232 @@
package sysupdate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
)
func init() {
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
}
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
type pacmanBackend struct{}
func (pacmanBackend) ID() string { return "pacman" }
func (pacmanBackend) DisplayName() string { return "Pacman" }
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
func (pacmanBackend) NeedsAuth() bool { return true }
func (pacmanBackend) RunsInTerminal() bool { return false }
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
out, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
return parseArchUpdates(out, b.ID(), RepoSystem), nil
}
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
}
return Run(ctx, []string{"pkexec", "pacman", "-Syu", "--noconfirm"}, RunOptions{OnLine: onLine})
}
type archHelperBackend struct {
id string
}
func (b archHelperBackend) ID() string { return b.id }
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
func (b archHelperBackend) NeedsAuth() bool { return true }
func (b archHelperBackend) RunsInTerminal() bool { return true }
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
func (b archHelperBackend) DisplayName() string {
switch b.id {
case "paru":
return "Paru (AUR)"
case "yay":
return "Yay (AUR)"
default:
return b.id
}
}
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
repoOut, err := pacmanRepoUpdates(ctx)
if err != nil {
return nil, err
}
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
if err != nil {
return nil, err
}
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
return pkgs, nil
}
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
}
term := findTerminal(opts.Terminal)
if term == "" {
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
}
cmd := fmt.Sprintf("%s -Syu", b.id)
if !opts.IncludeAUR {
cmd += " --repo"
}
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
}
func pacmanRepoUpdates(ctx context.Context) (string, error) {
if commandExists("checkupdates") {
return capturePermissive(ctx, "checkupdates")
}
if commandExists("fakeroot") {
out, err := pacmanCheckViaFakeroot(ctx)
if err == nil {
return out, nil
}
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
}
return capturePermissive(ctx, "pacman", "-Qu")
}
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
dir, err := pacmanPrivateDB()
if err != nil {
return "", err
}
if err := seedPacmanDB(dir); err != nil {
return "", fmt.Errorf("seed sync db: %w", err)
}
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
if out, err := refresh.CombinedOutput(); err != nil {
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
}
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
}
func seedPacmanDB(dir string) error {
syncDir := filepath.Join(dir, "sync")
if err := os.MkdirAll(syncDir, 0o755); err != nil {
return err
}
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
if err != nil {
return err
}
for _, src := range dbs {
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
return err
}
}
localLink := filepath.Join(dir, "local")
if fi, err := os.Lstat(localLink); err == nil {
if fi.Mode()&os.ModeSymlink == 0 {
if err := os.RemoveAll(localLink); err != nil {
return err
}
} else {
return nil
}
}
return os.Symlink("/var/lib/pacman/local", localLink)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
func pacmanPrivateDB() (string, error) {
tmp := os.Getenv("TMPDIR")
if tmp == "" {
tmp = "/tmp"
}
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
if err == nil {
return string(out), nil
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 1, 2:
return string(out), nil
}
}
return "", err
}
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
if text == "" {
return nil
}
var pkgs []Package
for line := range strings.SplitSeq(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
m := archUpdateLine.FindStringSubmatch(line)
if m == nil {
continue
}
p := Package{
Name: m[1],
Repo: repo,
Backend: backendID,
FromVersion: m[2],
ToVersion: m[3],
}
if repo == RepoAUR {
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
}
pkgs = append(pkgs, p)
}
return pkgs
}

View File

@@ -0,0 +1,114 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseArchUpdates(t *testing.T) {
tests := []struct {
name string
input string
backendID string
repo RepoKind
want []Package
}{
{
name: "empty",
input: "",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "whitespace only",
input: " \n\n \n",
backendID: "paru",
repo: RepoSystem,
want: nil,
},
{
name: "single repo update",
input: "bat 0.26.0-1 -> 0.26.1-2",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
{
name: "multiple updates with epoch versions",
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
linux 6.18.0-1 -> 6.18.1-1
mesa 26.4.0-1 -> 26.4.1-1`,
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
},
},
{
name: "AUR update with changelog url",
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
backendID: "paru",
repo: RepoAUR,
want: []Package{
{
Name: "google-chrome",
Repo: RepoAUR,
Backend: "paru",
FromVersion: "147.0.7727.116-1",
ToVersion: "147.0.7727.137-1",
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
},
},
},
{
name: "git package latest-commit marker",
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
backendID: "yay",
repo: RepoAUR,
want: []Package{
{
Name: "niri-git",
Repo: RepoAUR,
Backend: "yay",
FromVersion: "26.04.r5.ga85b922-1",
ToVersion: "latest-commit",
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
},
},
},
{
name: "skips lines that don't match arrow format",
input: `bat 0.26.0-1 -> 0.26.1-2
this is not an update line
foo`,
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 ",
backendID: "paru",
repo: RepoSystem,
want: []Package{
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,125 @@
package sysupdate
import (
"context"
"encoding/json"
"errors"
"os/exec"
)
const ostreeExitUpdateAvailable = 77
func init() {
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
}
type rpmOstreeBackend struct{}
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
func (rpmOstreeBackend) NeedsAuth() bool { return true }
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
if !commandExists("rpm-ostree") {
return false
}
return ostreeBooted(ctx)
}
type ostreeStatus struct {
Deployments []ostreeDeployment `json:"deployments"`
CachedUpdate *ostreeCached `json:"cached-update"`
}
type ostreeDeployment struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Booted bool `json:"booted"`
}
type ostreeCached struct {
Origin string `json:"origin"`
Version string `json:"version"`
Timestamp int64 `json:"timestamp"`
Checksum string `json:"checksum"`
}
func ostreeBooted(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
out, err := cmd.Output()
if err != nil {
return false
}
var s ostreeStatus
if err := json.Unmarshal(out, &s); err != nil {
return false
}
return len(s.Deployments) > 0
}
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
if err := cmd.Run(); err != nil {
exitErr, ok := errors.AsType[*exec.ExitError](err)
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
return nil, err
}
}
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
if err != nil {
return nil, err
}
return parseRpmOstreeStatus(statusOut)
}
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
var s ostreeStatus
if err := json.Unmarshal(statusOut, &s); err != nil {
return nil, err
}
if s.CachedUpdate == nil {
return nil, nil
}
booted := bootedDeployment(s.Deployments)
from := ""
if booted != nil {
from = booted.Version
}
if from == s.CachedUpdate.Version {
return nil, nil
}
name := s.CachedUpdate.Origin
if name == "" {
name = "system"
}
return []Package{{
Name: name,
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: from,
ToVersion: s.CachedUpdate.Version,
}}, nil
}
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
for i := range deps {
if deps[i].Booted {
return &deps[i]
}
}
return nil
}
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
argv := []string{"rpm-ostree", "upgrade"}
if opts.DryRun {
argv = append(argv, "--check")
}
return Run(ctx, argv, RunOptions{OnLine: onLine})
}

View File

@@ -0,0 +1,104 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseRpmOstreeStatus(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "no cached update",
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
want: nil,
},
{
name: "cached update available, booted version differs",
input: `{
"deployments": [
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
],
"cached-update": {
"origin": "fedora:fedora/x86_64/silverblue",
"version": "39.20240115.0",
"checksum": "abc123"
}
}`,
want: []Package{
{
Name: "fedora:fedora/x86_64/silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "39.20240101.0",
ToVersion: "39.20240115.0",
},
},
},
{
name: "cached update equals booted version (no real update)",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": true}],
"cached-update": {"origin": "x", "version": "39.20240101.0"}
}`,
want: nil,
},
{
name: "no booted deployment falls back to empty from",
input: `{
"deployments": [{"version": "39.20240101.0", "booted": false}],
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
}`,
want: []Package{
{
Name: "fedora:silverblue",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "",
ToVersion: "39.20240115.0",
},
},
},
{
name: "missing origin defaults to system",
input: `{
"deployments": [{"version": "1.0", "booted": true}],
"cached-update": {"version": "1.1"}
}`,
want: []Package{
{
Name: "system",
Repo: RepoOSTree,
Backend: "rpm-ostree",
FromVersion: "1.0",
ToVersion: "1.1",
},
},
},
{
name: "malformed JSON",
input: `{not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRpmOstreeStatus([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,78 @@
package sysupdate
import (
"context"
"encoding/xml"
"errors"
"os/exec"
)
func init() {
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
}
type zypperBackend struct{}
func (zypperBackend) ID() string { return "zypper" }
func (zypperBackend) DisplayName() string { return "Zypper" }
func (zypperBackend) Repo() RepoKind { return RepoSystem }
func (zypperBackend) NeedsAuth() bool { return true }
func (zypperBackend) RunsInTerminal() bool { return false }
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
type zypperUpdateList struct {
XMLName xml.Name `xml:"stream"`
Updates []zypperUpdate `xml:"update-list>update"`
}
type zypperUpdate struct {
Name string `xml:"name,attr"`
Edition string `xml:"edition,attr"`
EditionOld string `xml:"edition-old,attr"`
Kind string `xml:"kind,attr"`
}
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
out, err := cmd.Output()
if err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
switch exitErr.ExitCode() {
case 100, 101, 102, 103:
err = nil
}
}
if err != nil {
return nil, err
}
}
return parseZypperXML(out)
}
func parseZypperXML(out []byte) ([]Package, error) {
var list zypperUpdateList
if err := xml.Unmarshal(out, &list); err != nil {
return nil, err
}
pkgs := make([]Package, 0, len(list.Updates))
for _, u := range list.Updates {
if u.Kind != "" && u.Kind != "package" {
continue
}
pkgs = append(pkgs, Package{
Name: u.Name,
Repo: RepoSystem,
Backend: "zypper",
FromVersion: u.EditionOld,
ToVersion: u.Edition,
})
}
return pkgs, nil
}
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
if opts.DryRun {
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
}
return Run(ctx, []string{"pkexec", "zypper", "--non-interactive", "update"}, RunOptions{OnLine: onLine})
}

View File

@@ -0,0 +1,80 @@
package sysupdate
import (
"reflect"
"testing"
)
func TestParseZypperXML(t *testing.T) {
tests := []struct {
name string
input string
want []Package
wantErr bool
}{
{
name: "empty stream",
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
want: []Package{},
},
{
name: "single package update",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
<source url="https://download.opensuse.org/" alias="repo-oss"/>
</update>
</update-list>
</stream>`,
want: []Package{
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
},
},
{
name: "skips non-package kinds",
input: `<?xml version="1.0"?>
<stream>
<update-list>
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
</update-list>
</stream>`,
want: []Package{
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
},
},
{
name: "treats missing kind as package",
input: `<?xml version="1.0"?>
<stream><update-list>
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
</update-list></stream>`,
want: []Package{
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
},
},
{
name: "malformed XML returns error",
input: `not xml at all`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseZypperXML([]byte(tt.input))
if (err != nil) != tt.wantErr {
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,117 @@
package sysupdate
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"sync"
)
type RunOptions struct {
Env []string
OnLine func(string)
}
func Run(ctx context.Context, argv []string, opts RunOptions) error {
if len(argv) == 0 {
return fmt.Errorf("sysupdate.Run: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
var wg sync.WaitGroup
wg.Add(2)
go pump(stdout, opts.OnLine, &wg)
go pump(stderr, opts.OnLine, &wg)
wg.Wait()
return cmd.Wait()
}
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
defer wg.Done()
if onLine == nil {
_, _ = io.Copy(io.Discard, r)
return
}
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
onLine(scanner.Text())
}
}
func Capture(ctx context.Context, argv []string) (string, error) {
if len(argv) == 0 {
return "", fmt.Errorf("sysupdate.Capture: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
out, err := cmd.Output()
return string(out), err
}
func findTerminal(override string) string {
if override != "" && commandExists(override) {
return override
}
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
return t
}
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
if commandExists(t) {
return t
}
}
return ""
}
func wrapInTerminal(term, title, shellCmd string) []string {
const appID = "dms-sysupdate"
banner := fmt.Sprintf(
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
title, shellCmd,
)
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
full := export + banner + "; " + shellCmd + "; " + closer
switch term {
case "kitty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "alacritty":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "foot":
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "ghostty":
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
case "wezterm":
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
case "xterm":
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
case "konsole":
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
case "gnome-terminal":
return []string{term, "--title=" + title, "--", "sh", "-c", full}
default:
return []string{term, "-e", "sh", "-c", full}
}
}

View File

@@ -0,0 +1,55 @@
package sysupdate
import (
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
)
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
switch req.Method {
case "sysupdate.getState":
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.refresh":
force := params.BoolOpt(req.Params, "force", false)
m.Refresh(RefreshOptions{Force: force})
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.upgrade":
handleUpgrade(conn, req, m)
case "sysupdate.cancel":
m.Cancel()
models.Respond(conn, req.ID, m.GetState())
case "sysupdate.acquire":
m.Acquire()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.release":
m.Release()
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
case "sysupdate.setInterval":
seconds, err := params.Int(req.Params, "seconds")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
m.SetInterval(seconds)
models.Respond(conn, req.ID, m.GetState())
default:
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
}
}
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
opts := UpgradeOptions{
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
DryRun: params.BoolOpt(req.Params, "dry", false),
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
Terminal: params.StringOpt(req.Params, "terminal", ""),
}
if err := m.Upgrade(opts); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, m.GetState())
}

View File

@@ -0,0 +1,493 @@
package sysupdate
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
const (
defaultIntervalSeconds = 30 * 60
minIntervalSeconds = 5 * 60
recentLogCapacity = 200
checkTimeout = 5 * time.Minute
upgradeTimeout = 30 * time.Minute
)
type Manager struct {
mu sync.RWMutex
state State
subscribers syncmap.Map[string, chan State]
selection Selection
notifyDirty chan struct{}
stopChan chan struct{}
notifierWG sync.WaitGroup
schedulerWG sync.WaitGroup
acquireCount int32
wakeSched chan struct{}
opMu sync.Mutex
opCtx context.Context
opCancel context.CancelFunc
}
func NewManager() (*Manager, error) {
m := &Manager{
notifyDirty: make(chan struct{}, 1),
stopChan: make(chan struct{}),
wakeSched: make(chan struct{}, 1),
}
m.state = State{
Phase: PhaseIdle,
IntervalSeconds: defaultIntervalSeconds,
Backends: []BackendInfo{},
Packages: []Package{},
}
id, pretty := readOSRelease()
m.state.Distro = id
m.state.DistroPretty = pretty
m.selection = Select(context.Background())
m.state.Backends = m.selection.Info()
if len(m.state.Backends) == 0 {
m.state.Error = &ErrorInfo{
Code: ErrCodeNoBackend,
Message: "no supported package manager found",
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
}
}
m.notifierWG.Add(1)
go m.notifier()
m.schedulerWG.Add(1)
go m.scheduler()
go m.runRefresh(context.Background())
return m, nil
}
func (m *Manager) GetState() State {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneState(m.state)
}
func (m *Manager) Subscribe(id string) chan State {
ch := make(chan State, 16)
m.subscribers.Store(id, ch)
return ch
}
func (m *Manager) Unsubscribe(id string) {
if val, ok := m.subscribers.LoadAndDelete(id); ok {
close(val)
}
}
func (m *Manager) Close() {
select {
case <-m.stopChan:
return
default:
close(m.stopChan)
}
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel()
}
m.opMu.Unlock()
select {
case m.wakeSched <- struct{}{}:
default:
}
m.schedulerWG.Wait()
m.notifierWG.Wait()
m.subscribers.Range(func(key string, ch chan State) bool {
close(ch)
m.subscribers.Delete(key)
return true
})
}
func (m *Manager) SetInterval(seconds int) {
if seconds < minIntervalSeconds {
seconds = minIntervalSeconds
}
m.mu.Lock()
m.state.IntervalSeconds = seconds
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) Refresh(opts RefreshOptions) {
m.mu.RLock()
phase := m.state.Phase
m.mu.RUnlock()
switch {
case phase == PhaseUpgrading:
return
case phase == PhaseRefreshing && !opts.Force:
return
}
go m.runRefresh(context.Background())
}
func (m *Manager) Upgrade(opts UpgradeOptions) error {
if len(m.selection.All()) == 0 {
return errors.New("no backend available")
}
m.opMu.Lock()
if m.opCancel != nil {
m.opMu.Unlock()
return errors.New("operation already running")
}
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
m.opCtx = ctx
m.opCancel = cancel
m.opMu.Unlock()
go m.runUpgrade(ctx, opts)
return nil
}
func (m *Manager) Cancel() {
m.opMu.Lock()
cancel := m.opCancel
m.opMu.Unlock()
if cancel == nil {
return
}
cancel()
}
func (m *Manager) Acquire() {
first := atomic.AddInt32(&m.acquireCount, 1) == 1
select {
case m.wakeSched <- struct{}{}:
default:
}
if first {
go m.runRefresh(context.Background())
}
}
func (m *Manager) Release() {
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
atomic.StoreInt32(&m.acquireCount, 0)
}
}
func (m *Manager) scheduler() {
defer m.schedulerWG.Done()
for {
if atomic.LoadInt32(&m.acquireCount) == 0 {
select {
case <-m.stopChan:
return
case <-m.wakeSched:
}
continue
}
m.mu.RLock()
interval := m.state.IntervalSeconds
m.mu.RUnlock()
if interval < minIntervalSeconds {
interval = minIntervalSeconds
}
t := time.NewTimer(time.Duration(interval) * time.Second)
select {
case <-m.stopChan:
t.Stop()
return
case <-m.wakeSched:
t.Stop()
case <-t.C:
m.runRefresh(context.Background())
}
}
}
func (m *Manager) runRefresh(parent context.Context) {
if len(m.selection.All()) == 0 {
return
}
ctx, cancel := context.WithTimeout(parent, checkTimeout)
defer cancel()
m.mu.Lock()
if m.state.Phase == PhaseUpgrading {
m.mu.Unlock()
return
}
m.state.Phase = PhaseRefreshing
m.state.Error = nil
m.state.RecentLog = nil
m.mu.Unlock()
m.markDirty()
type backendResult struct {
pkgs []Package
err error
}
backends := m.selection.All()
results := make([]backendResult, len(backends))
var wg sync.WaitGroup
for i, b := range backends {
wg.Add(1)
go func(i int, b Backend) {
defer wg.Done()
pkgs, err := b.CheckUpdates(ctx)
results[i] = backendResult{pkgs: pkgs, err: err}
}(i, b)
}
wg.Wait()
now := time.Now().Unix()
m.mu.Lock()
m.state.LastCheckUnix = now
m.state.Packages = m.state.Packages[:0]
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)
}
continue
}
m.state.Packages = append(m.state.Packages, r.pkgs...)
}
m.state.Count = len(m.state.Packages)
if firstErr != nil {
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
} else {
m.state.Phase = PhaseIdle
m.state.LastSuccessUnix = now
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
defer func() {
m.opMu.Lock()
if m.opCancel != nil {
m.opCancel = nil
m.opCtx = nil
}
m.opMu.Unlock()
}()
if opts.CustomCommand != "" {
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
return
}
backends := upgradeBackends(m.selection, opts)
if len(backends) == 0 {
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
for _, b := range backends {
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
if err := b.Upgrade(ctx, opts, onLine); err != nil {
code := ErrCodeBackendFailed
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrCodeTimeout
} else if errors.Is(ctx.Err(), context.Canceled) {
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
m.mu.Unlock()
m.markDirty()
return
}
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
term := findTerminal(terminalOverride)
if term == "" {
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
return
}
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
m.mu.Lock()
m.state.Phase = PhaseUpgrading
m.state.OperationID = opID
m.state.OperationStarted = time.Now().Unix()
m.state.RecentLog = m.state.RecentLog[:0]
m.state.Error = nil
m.mu.Unlock()
m.markDirty()
onLine := func(line string) { m.appendLog(line) }
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
code := ErrCodeBackendFailed
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
code = ErrCodeTimeout
case errors.Is(ctx.Err(), context.Canceled):
code = ErrCodeCancelled
}
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
m.mu.Unlock()
m.markDirty()
return
}
m.mu.Lock()
m.state.Phase = PhaseIdle
m.state.OperationID = ""
m.state.OperationStarted = 0
m.mu.Unlock()
m.markDirty()
go m.runRefresh(context.Background())
}
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
var out []Backend
if sel.System != nil {
out = append(out, sel.System)
}
for _, b := range sel.Overlay {
switch {
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
continue
}
out = append(out, b)
}
return out
}
func (m *Manager) appendLog(line string) {
m.mu.Lock()
if cap(m.state.RecentLog) == 0 {
m.state.RecentLog = make([]string, 0, recentLogCapacity)
}
if len(m.state.RecentLog) >= recentLogCapacity {
copy(m.state.RecentLog, m.state.RecentLog[1:])
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
}
m.state.RecentLog = append(m.state.RecentLog, line)
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) setError(code ErrorCode, msg string) {
m.mu.Lock()
m.state.Phase = PhaseError
m.state.Error = &ErrorInfo{Code: code, Message: msg}
m.mu.Unlock()
m.markDirty()
}
func (m *Manager) markDirty() {
select {
case m.notifyDirty <- struct{}{}:
default:
}
}
func (m *Manager) notifier() {
defer m.notifierWG.Done()
for {
select {
case <-m.stopChan:
return
case <-m.notifyDirty:
snap := m.GetState()
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- snap:
default:
}
return true
})
}
}
}
func cloneState(s State) State {
out := s
out.Backends = append([]BackendInfo(nil), s.Backends...)
out.Packages = append([]Package(nil), s.Packages...)
out.RecentLog = append([]string(nil), s.RecentLog...)
if s.Error != nil {
errCopy := *s.Error
out.Error = &errCopy
}
return out
}
func readOSRelease() (id, pretty string) {
f, err := os.Open("/etc/os-release")
if err != nil {
return "", ""
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
k, v, ok := strings.Cut(scanner.Text(), "=")
if !ok {
continue
}
v = strings.Trim(v, "\"")
switch k {
case "ID":
id = v
case "PRETTY_NAME":
pretty = v
}
}
if err := scanner.Err(); err != nil {
log.Debugf("[sysupdate] read os-release: %v", err)
}
return id, pretty
}

View File

@@ -0,0 +1,84 @@
package sysupdate
type Phase string
const (
PhaseIdle Phase = "idle"
PhaseRefreshing Phase = "refreshing"
PhaseUpgrading Phase = "upgrading"
PhaseError Phase = "error"
)
type RepoKind string
const (
RepoSystem RepoKind = "system"
RepoAUR RepoKind = "aur"
RepoFlatpak RepoKind = "flatpak"
RepoOSTree RepoKind = "ostree"
)
type ErrorCode string
const (
ErrCodeNone ErrorCode = ""
ErrCodeNoBackend ErrorCode = "no-backend"
ErrCodeBusy ErrorCode = "busy"
ErrCodeBackendFailed ErrorCode = "backend-failed"
ErrCodeTimeout ErrorCode = "timeout"
ErrCodeCancelled ErrorCode = "cancelled"
ErrCodeInvalidRequest ErrorCode = "invalid-request"
)
type Package struct {
Name string `json:"name"`
Repo RepoKind `json:"repo"`
Backend string `json:"backend"`
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
ChangelogURL string `json:"changelogUrl,omitempty"`
}
type BackendInfo struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Repo RepoKind `json:"repo"`
NeedsAuth bool `json:"needsAuth"`
RunsInTerminal bool `json:"runsInTerminal"`
}
type ErrorInfo struct {
Code ErrorCode `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Hint string `json:"hint,omitempty"`
}
type State struct {
Phase Phase `json:"phase"`
Distro string `json:"distro,omitempty"`
DistroPretty string `json:"distroPretty,omitempty"`
Backends []BackendInfo `json:"backends"`
Packages []Package `json:"packages"`
Count int `json:"count"`
IntervalSeconds int `json:"intervalSeconds"`
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
OperationID string `json:"operationId,omitempty"`
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
RecentLog []string `json:"recentLog,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type UpgradeOptions struct {
IncludeFlatpak bool
IncludeAUR bool
DryRun bool
CustomCommand string
Terminal string
}
type RefreshOptions struct {
Force bool
}