diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go
index e0d4a324..15769f8c 100644
--- a/core/cmd/dms/commands_common.go
+++ b/core/cmd/dms/commands_common.go
@@ -527,5 +527,6 @@ func getCommonCommands() []*cobra.Command {
randrCmd,
blurCmd,
trashCmd,
+ systemCmd,
}
}
diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go
new file mode 100644
index 00000000..a7e48a34
--- /dev/null
+++ b/core/cmd/dms/commands_system.go
@@ -0,0 +1,277 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
+ "github.com/spf13/cobra"
+)
+
+var systemCmd = &cobra.Command{
+ Use: "system",
+ Short: "System operations",
+ Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
+}
+
+var systemUpdateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Apply or list system updates",
+ Long: `Apply or list system updates across detected package managers.
+
+Default behavior is to apply available updates after prompting for confirmation.
+Use --check to list updates without applying.
+
+Examples:
+ dms system update --check # list available updates
+ dms system update # apply updates (interactive prompt)
+ dms system update --noconfirm # apply updates without prompting
+ dms system update --dry # simulate without changing anything
+ dms system update --no-flatpak --noconfirm # apply system updates only
+ dms system update --interval 3600 # set the server poll interval to 1h`,
+ Run: runSystemUpdate,
+}
+
+var (
+ sysUpdateCheck bool
+ sysUpdateNoConfirm bool
+ sysUpdateDry bool
+ sysUpdateJSON bool
+ sysUpdateNoFlatpak bool
+ sysUpdateNoAUR bool
+ sysUpdateIntervalS int
+ sysUpdateListPmTime = 5 * time.Minute
+)
+
+func init() {
+ systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
+ systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
+ systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
+ systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
+ systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
+ systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
+ systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
+
+ systemCmd.AddCommand(systemUpdateCmd)
+}
+
+func runSystemUpdate(cmd *cobra.Command, args []string) {
+ switch {
+ case sysUpdateIntervalS >= 0:
+ runSystemUpdateSetInterval(sysUpdateIntervalS)
+ case sysUpdateCheck:
+ runSystemUpdateCheck()
+ default:
+ runSystemUpdateApply()
+ }
+}
+
+func selectBackends(ctx context.Context) []sysupdate.Backend {
+ sel := sysupdate.Select(ctx)
+ backends := sel.All()
+ if !sysUpdateNoFlatpak {
+ return backends
+ }
+ out := backends[:0]
+ for _, b := range backends {
+ if b.Repo() == sysupdate.RepoFlatpak {
+ continue
+ }
+ out = append(out, b)
+ }
+ return out
+}
+
+func runSystemUpdateCheck() {
+ ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
+ defer cancel()
+
+ backends := selectBackends(ctx)
+ if len(backends) == 0 {
+ log.Fatal("No supported package manager found")
+ }
+
+ type backendResult struct {
+ ID string `json:"id"`
+ Display string `json:"displayName"`
+ Packages []sysupdate.Package `json:"packages"`
+ }
+ var results []backendResult
+ var allPkgs []sysupdate.Package
+ var firstErr error
+
+ for _, b := range backends {
+ pkgs, err := b.CheckUpdates(ctx)
+ if err != nil && firstErr == nil {
+ firstErr = fmt.Errorf("%s: %w", b.ID(), err)
+ }
+ results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
+ allPkgs = append(allPkgs, pkgs...)
+ }
+
+ if sysUpdateJSON {
+ out, _ := json.MarshalIndent(map[string]any{
+ "backends": results,
+ "packages": allPkgs,
+ "error": errOrEmpty(firstErr),
+ "count": len(allPkgs),
+ }, "", " ")
+ fmt.Println(string(out))
+ return
+ }
+
+ printBackends(backends)
+ fmt.Printf("Updates: %d\n", len(allPkgs))
+ if firstErr != nil {
+ fmt.Printf("Error: %v\n", firstErr)
+ }
+ if len(allPkgs) == 0 {
+ return
+ }
+ fmt.Println()
+ for _, p := range allPkgs {
+ fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
+ }
+}
+
+func runSystemUpdateApply() {
+ checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
+ defer checkCancel()
+
+ backends := selectBackends(checkCtx)
+ if len(backends) == 0 {
+ log.Fatal("No supported package manager found")
+ }
+
+ pkgs, firstErr := collectUpdates(checkCtx, backends)
+ if firstErr != nil {
+ fmt.Printf("Warning: %v\n\n", firstErr)
+ }
+
+ printBackends(backends)
+ fmt.Printf("Updates: %d\n", len(pkgs))
+ if len(pkgs) == 0 {
+ fmt.Println("Nothing to upgrade.")
+ return
+ }
+ fmt.Println()
+ for _, p := range pkgs {
+ fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
+ }
+ fmt.Println()
+
+ if !sysUpdateNoConfirm && !sysUpdateDry {
+ if !promptYesNo("Proceed with upgrade? [y/N]: ") {
+ fmt.Println("Aborted.")
+ return
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ opts := sysupdate.UpgradeOptions{
+ IncludeFlatpak: !sysUpdateNoFlatpak,
+ IncludeAUR: !sysUpdateNoAUR,
+ DryRun: sysUpdateDry,
+ }
+
+ onLine := func(line string) { fmt.Println(line) }
+ for _, b := range backends {
+ fmt.Printf("\n== %s ==\n", b.DisplayName())
+ if err := b.Upgrade(ctx, opts, onLine); err != nil {
+ log.Fatalf("%s upgrade failed: %v", b.ID(), err)
+ }
+ }
+ if sysUpdateDry {
+ fmt.Println("\nDry run complete (no changes applied).")
+ return
+ }
+ fmt.Println("\nUpgrade complete.")
+}
+
+func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
+ var all []sysupdate.Package
+ var firstErr error
+ for _, b := range backends {
+ pkgs, err := b.CheckUpdates(ctx)
+ if err != nil && firstErr == nil {
+ firstErr = fmt.Errorf("%s: %w", b.ID(), err)
+ }
+ all = append(all, pkgs...)
+ }
+ return all, firstErr
+}
+
+func runSystemUpdateSetInterval(seconds int) {
+ resp, err := sendServerRequest(models.Request{
+ ID: 1,
+ Method: "sysupdate.setInterval",
+ Params: map[string]any{"seconds": float64(seconds)},
+ })
+ if err != nil {
+ log.Fatalf("Failed: %v (is dms server running?)", err)
+ }
+ if resp.Error != "" {
+ log.Fatalf("Error: %s", resp.Error)
+ }
+ fmt.Printf("Interval set to %d seconds.\n", seconds)
+}
+
+func promptYesNo(prompt string) bool {
+ if !stdinIsTTY() {
+ log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
+ }
+ fmt.Print(prompt)
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return false
+ }
+ switch strings.ToLower(strings.TrimSpace(line)) {
+ case "y", "yes":
+ return true
+ default:
+ return false
+ }
+}
+
+func printBackends(backends []sysupdate.Backend) {
+ if len(backends) == 0 {
+ return
+ }
+ names := make([]string, 0, len(backends))
+ for _, b := range backends {
+ names = append(names, b.DisplayName())
+ }
+ fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
+}
+
+func stdinIsTTY() bool {
+ fi, err := os.Stdin.Stat()
+ if err != nil {
+ return false
+ }
+ return (fi.Mode() & os.ModeCharDevice) != 0
+}
+
+func errOrEmpty(err error) string {
+ if err == nil {
+ return ""
+ }
+ return err.Error()
+}
+
+func defaultIfEmpty(s, def string) string {
+ if s == "" {
+ return def
+ }
+ return s
+}
diff --git a/core/internal/server/router.go b/core/internal/server/router.go
index 6b8c10b0..1cc88493 100644
--- a/core/internal/server/router.go
+++ b/core/internal/server/router.go
@@ -20,6 +20,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -202,6 +203,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
+ if strings.HasPrefix(req.Method, "sysupdate.") {
+ if sysUpdateManager == nil {
+ models.RespondError(conn, req.ID, "sysupdate manager not initialized")
+ return
+ }
+ sysupdate.HandleRequest(conn, req, sysUpdateManager)
+ return
+ }
+
switch req.Method {
case "ping":
models.Respond(conn, req.ID, "pong")
diff --git a/core/internal/server/server.go b/core/internal/server/server.go
index 58acb623..c5d5d840 100644
--- a/core/internal/server/server.go
+++ b/core/internal/server/server.go
@@ -30,6 +30,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
+ "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
@@ -75,6 +76,7 @@ var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
var trayRecoveryManager *trayrecovery.Manager
var locationManager *location.Manager
+var sysUpdateManager *sysupdate.Manager
var geoClientInstance geolocation.Client
const dbusClientID = "dms-dbus-client"
@@ -421,6 +423,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
return nil
}
+func InitializeSysUpdateManager() error {
+ manager, err := sysupdate.NewManager()
+ if err != nil {
+ log.Warnf("Failed to initialize sysupdate manager: %v", err)
+ return err
+ }
+
+ sysUpdateManager = manager
+
+ log.Info("Sysupdate manager initialized")
+ return nil
+}
+
func handleConnection(conn net.Conn) {
defer conn.Close()
@@ -506,6 +521,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "dbus")
}
+ if sysUpdateManager != nil {
+ caps = append(caps, "sysupdate")
+ }
+
return Capabilities{Capabilities: caps}
}
@@ -576,6 +595,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dbus")
}
+ if sysUpdateManager != nil {
+ caps = append(caps, "sysupdate")
+ }
+
return ServerInfo{
APIVersion: APIVersion,
CLIVersion: CLIVersion,
@@ -1243,6 +1266,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
+ if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
+ wg.Add(1)
+ sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
+ go func() {
+ defer wg.Done()
+ defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
+
+ initialState := sysUpdateManager.GetState()
+ select {
+ case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
+ case <-stopChan:
+ return
+ }
+
+ for {
+ select {
+ case state, ok := <-sysupdateChan:
+ if !ok {
+ return
+ }
+ select {
+ case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
+ case <-stopChan:
+ return
+ }
+ case <-stopChan:
+ return
+ }
+ }
+ }()
+ }
+
if shouldSubscribe("dbus") && dbusManager != nil {
wg.Add(1)
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
@@ -1348,6 +1403,9 @@ func cleanupManagers() {
if locationManager != nil {
locationManager.Close()
}
+ if sysUpdateManager != nil {
+ sysUpdateManager.Close()
+ }
if geoClientInstance != nil {
geoClientInstance.Close()
}
@@ -1733,6 +1791,10 @@ func Start(printDocs bool) error {
}
}()
+ if err := InitializeSysUpdateManager(); err != nil {
+ log.Warnf("Sysupdate manager unavailable: %v", err)
+ }
+
log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
diff --git a/core/internal/server/sysupdate/backend.go b/core/internal/server/sysupdate/backend.go
new file mode 100644
index 00000000..9bc71ce3
--- /dev/null
+++ b/core/internal/server/sysupdate/backend.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/backend_apt.go b/core/internal/server/sysupdate/backend_apt.go
new file mode 100644
index 00000000..53071eb9
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_apt.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/backend_apt_test.go b/core/internal/server/sysupdate/backend_apt_test.go
new file mode 100644
index 00000000..503eb1c7
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_apt_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/backend_dnf.go b/core/internal/server/sysupdate/backend_dnf.go
new file mode 100644
index 00000000..9ae73bc6
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_dnf.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/backend_dnf_test.go b/core/internal/server/sysupdate/backend_dnf_test.go
new file mode 100644
index 00000000..17025ad3
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_dnf_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/backend_flatpak.go b/core/internal/server/sysupdate/backend_flatpak.go
new file mode 100644
index 00000000..fed5ba79
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_flatpak.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/backend_flatpak_test.go b/core/internal/server/sysupdate/backend_flatpak_test.go
new file mode 100644
index 00000000..cbd18a52
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_flatpak_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go
new file mode 100644
index 00000000..53d0987a
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_pacman.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/backend_pacman_test.go b/core/internal/server/sysupdate/backend_pacman_test.go
new file mode 100644
index 00000000..8fd866f9
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_pacman_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/backend_rpmostree.go b/core/internal/server/sysupdate/backend_rpmostree.go
new file mode 100644
index 00000000..077b22c4
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_rpmostree.go
@@ -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})
+}
diff --git a/core/internal/server/sysupdate/backend_rpmostree_test.go b/core/internal/server/sysupdate/backend_rpmostree_test.go
new file mode 100644
index 00000000..7377f5c4
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_rpmostree_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/backend_zypper.go b/core/internal/server/sysupdate/backend_zypper.go
new file mode 100644
index 00000000..f0aa0d5b
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_zypper.go
@@ -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})
+}
diff --git a/core/internal/server/sysupdate/backend_zypper_test.go b/core/internal/server/sysupdate/backend_zypper_test.go
new file mode 100644
index 00000000..9d0c4ef0
--- /dev/null
+++ b/core/internal/server/sysupdate/backend_zypper_test.go
@@ -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: ``,
+ want: []Package{},
+ },
+ {
+ name: "single package update",
+ input: `
+
+
+
+
+
+
+`,
+ want: []Package{
+ {Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
+ },
+ },
+ {
+ name: "skips non-package kinds",
+ input: `
+
+
+
+
+
+
+`,
+ 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: `
+
+
+`,
+ 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)
+ }
+ })
+ }
+}
diff --git a/core/internal/server/sysupdate/executor.go b/core/internal/server/sysupdate/executor.go
new file mode 100644
index 00000000..9a086759
--- /dev/null
+++ b/core/internal/server/sysupdate/executor.go
@@ -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}
+ }
+}
diff --git a/core/internal/server/sysupdate/handlers.go b/core/internal/server/sysupdate/handlers.go
new file mode 100644
index 00000000..27dbc2a1
--- /dev/null
+++ b/core/internal/server/sysupdate/handlers.go
@@ -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())
+}
diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go
new file mode 100644
index 00000000..0f5403ce
--- /dev/null
+++ b/core/internal/server/sysupdate/manager.go
@@ -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
+}
diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go
new file mode 100644
index 00000000..06dbe174
--- /dev/null
+++ b/core/internal/server/sysupdate/types.go
@@ -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
+}
diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml
index 903a53a7..ea5be6a1 100644
--- a/quickshell/Common/SessionData.qml
+++ b/quickshell/Common/SessionData.qml
@@ -30,9 +30,36 @@ Singleton {
property bool isLightMode: false
property bool doNotDisturb: false
property real doNotDisturbUntil: 0
+ property string terminalOverride: ""
property bool isSwitchingMode: false
property bool suppressOSD: true
+ readonly property var terminalOptions: ["ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"]
+ property var installedTerminals: []
+
+ function resolveTerminal() {
+ if (terminalOverride && terminalOverride.length > 0) {
+ return terminalOverride;
+ }
+ const env = Quickshell.env("TERMINAL");
+ if (env && env.length > 0) {
+ return env;
+ }
+ return "";
+ }
+
+ Process {
+ id: terminalProbe
+ running: true
+ command: ["sh", "-c", "for t in ghostty kitty foot alacritty wezterm konsole gnome-terminal xterm; do command -v \"$t\" >/dev/null 2>&1 && echo \"$t\"; done"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const found = text.trim().split("\n").filter(line => line.length > 0);
+ root.installedTerminals = found;
+ }
+ }
+ }
+
Timer {
id: dndExpireTimer
repeat: false
diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml
index bf5ebde5..247a9949 100644
--- a/quickshell/Common/SettingsData.qml
+++ b/quickshell/Common/SettingsData.qml
@@ -640,6 +640,9 @@ Singleton {
property bool updaterUseCustomCommand: false
property string updaterCustomCommand: ""
property string updaterTerminalAdditionalParams: ""
+ property int updaterIntervalSeconds: 1800
+ property bool updaterIncludeFlatpak: true
+ property bool updaterAllowAUR: true
property string displayNameMode: "system"
property var screenPreferences: ({})
diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js
index f9d465de..07ff393e 100644
--- a/quickshell/Common/settings/SessionSpec.js
+++ b/quickshell/Common/settings/SessionSpec.js
@@ -4,6 +4,7 @@ var SPEC = {
isLightMode: { def: false },
doNotDisturb: { def: false },
doNotDisturbUntil: { def: 0 },
+ terminalOverride: { def: "" },
wallpaperPath: { def: "" },
perMonitorWallpaper: { def: false },
diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js
index 2bb88a6d..57a39e4a 100644
--- a/quickshell/Common/settings/SettingsSpec.js
+++ b/quickshell/Common/settings/SettingsSpec.js
@@ -428,6 +428,9 @@ var SPEC = {
updaterUseCustomCommand: { def: false },
updaterCustomCommand: { def: "" },
updaterTerminalAdditionalParams: { def: "" },
+ updaterIntervalSeconds: { def: 1800 },
+ updaterIncludeFlatpak: { def: true },
+ updaterAllowAUR: { def: true },
displayNameMode: { def: "system" },
screenPreferences: { def: {} },
diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml
index 092a8b11..c0cbf84a 100644
--- a/quickshell/DMSShell.qml
+++ b/quickshell/DMSShell.qml
@@ -895,7 +895,12 @@ Item {
SystemUpdatePopout {
id: systemUpdatePopout
- onPopoutClosed: PopoutService.unloadSystemUpdate()
+ onPopoutClosed: {
+ if (systemUpdatePopout._reopenAfterUpgrade) {
+ return;
+ }
+ PopoutService.unloadSystemUpdate();
+ }
Component.onCompleted: {
PopoutService.systemUpdatePopout = systemUpdatePopout;
diff --git a/quickshell/Modals/DankLauncherV2/Controller.qml b/quickshell/Modals/DankLauncherV2/Controller.qml
index 9cbba3e7..57e58695 100644
--- a/quickshell/Modals/DankLauncherV2/Controller.qml
+++ b/quickshell/Modals/DankLauncherV2/Controller.qml
@@ -1881,7 +1881,7 @@ Item {
function openTerminal(path) {
if (!path)
return;
- var terminal = Quickshell.env("TERMINAL") || "xterm";
+ var terminal = SessionData.resolveTerminal() || "xterm";
Quickshell.execDetached({
command: [terminal],
workingDirectory: path
diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml
index 092881c0..7654962f 100644
--- a/quickshell/Modals/Settings/SettingsSidebar.qml
+++ b/quickshell/Modals/Settings/SettingsSidebar.qml
@@ -164,7 +164,8 @@ Rectangle {
"id": "updater",
"text": I18n.tr("System Updater"),
"icon": "refresh",
- "tabIndex": 20
+ "tabIndex": 20,
+ "updaterOnly": true
},
{
"id": "desktop_widgets",
@@ -340,6 +341,8 @@ Rectangle {
return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
+ if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
+ return false;
return true;
}
diff --git a/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
new file mode 100644
index 00000000..7acd3877
--- /dev/null
+++ b/quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
@@ -0,0 +1,441 @@
+import QtQuick
+import qs.Common
+import qs.Services
+import qs.Widgets
+
+DankPopout {
+ id: systemUpdatePopout
+
+ layerNamespace: "dms:system-update"
+
+ property var parentWidget: null
+ property var triggerScreen: null
+
+ Ref {
+ service: SystemUpdateService
+ }
+
+ property bool _reopenAfterUpgrade: false
+
+ Connections {
+ target: SystemUpdateService
+ function onIsUpgradingChanged() {
+ if (SystemUpdateService.isUpgrading) {
+ return;
+ }
+ if (!systemUpdatePopout._reopenAfterUpgrade) {
+ return;
+ }
+ systemUpdatePopout._reopenAfterUpgrade = false;
+ systemUpdatePopout.open();
+ }
+ }
+
+ popupWidth: 440
+ popupHeight: 560
+ triggerWidth: 55
+ positioning: ""
+ screen: triggerScreen
+ shouldBeVisible: false
+
+ onBackgroundClicked: close()
+
+ onShouldBeVisibleChanged: {
+ if (!shouldBeVisible) {
+ return;
+ }
+ const stale = !SystemUpdateService.lastCheckUnix || (Date.now() / 1000 - SystemUpdateService.lastCheckUnix) > 300;
+ if (stale && !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading) {
+ SystemUpdateService.checkForUpdates();
+ }
+ }
+
+ content: Component {
+ Rectangle {
+ id: updaterPanel
+
+ color: "transparent"
+ focus: true
+
+ readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Escape) {
+ systemUpdatePopout.close();
+ event.accepted = true;
+ }
+ }
+
+ Component.onCompleted: {
+ if (systemUpdatePopout.shouldBeVisible) {
+ forceActiveFocus();
+ }
+ }
+
+ Item {
+ id: header
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.topMargin: Theme.spacingL
+ height: 40
+
+ StyledText {
+ text: I18n.tr("System Updates")
+ font.pixelSize: Theme.fontSizeLarge
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ Row {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: Theme.spacingS
+
+ StyledText {
+ anchors.verticalCenter: parent.verticalCenter
+ text: {
+ switch (true) {
+ case SystemUpdateService.isUpgrading:
+ return I18n.tr("Upgrading...");
+ case SystemUpdateService.isChecking:
+ return I18n.tr("Checking...");
+ case SystemUpdateService.hasError:
+ return I18n.tr("Error");
+ case SystemUpdateService.updateCount === 0:
+ return I18n.tr("Up to date");
+ case SystemUpdateService.updateCount === 1:
+ return I18n.tr("%1 update").arg(SystemUpdateService.updateCount);
+ default:
+ return I18n.tr("%1 updates").arg(SystemUpdateService.updateCount);
+ }
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: SystemUpdateService.hasError ? Theme.error : Theme.surfaceVariantText
+ }
+
+ DankActionButton {
+ id: refreshButton
+ buttonSize: 28
+ iconName: "refresh"
+ iconSize: 18
+ iconColor: Theme.surfaceText
+ enabled: !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading
+ opacity: enabled ? 1.0 : 0.5
+ onClicked: SystemUpdateService.checkForUpdates()
+
+ RotationAnimation {
+ target: refreshButton
+ property: "rotation"
+ from: 0
+ to: 360
+ duration: 1000
+ running: SystemUpdateService.isChecking
+ loops: Animation.Infinite
+
+ onRunningChanged: {
+ if (!running) {
+ refreshButton.rotation = 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ StyledText {
+ id: backendsRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: header.bottom
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.topMargin: Theme.spacingS
+ visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading
+ text: {
+ const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
+ return I18n.tr("Backends: %1").arg(names);
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ elide: Text.ElideRight
+ }
+
+ Row {
+ id: buttonsRow
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.bottomMargin: Theme.spacingL
+ spacing: Theme.spacingM
+ height: 44
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: parent.height
+ radius: Theme.cornerRadius
+ color: primaryMouseArea.containsMouse && primaryMouseArea.enabled ? Theme.primaryHover : Theme.secondaryHover
+ opacity: primaryMouseArea.enabled ? 1.0 : 0.5
+
+ StyledText {
+ anchors.centerIn: parent
+ text: SystemUpdateService.isUpgrading ? I18n.tr("Cancel") : I18n.tr("Update All")
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.primary
+ }
+
+ MouseArea {
+ id: primaryMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: SystemUpdateService.isUpgrading || SystemUpdateService.updateCount > 0
+ onClicked: {
+ if (SystemUpdateService.isUpgrading) {
+ SystemUpdateService.cancelUpdates();
+ return;
+ }
+ const opts = {
+ includeFlatpak: SettingsData.updaterIncludeFlatpak,
+ includeAUR: SettingsData.updaterAllowAUR,
+ terminal: SessionData.terminalOverride
+ };
+ if (updaterPanel.hasTerminalBackend) {
+ systemUpdatePopout._reopenAfterUpgrade = true;
+ SystemUpdateService.runUpdates(opts);
+ systemUpdatePopout.close();
+ return;
+ }
+ SystemUpdateService.runUpdates(opts);
+ }
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+
+ Rectangle {
+ width: (parent.width - Theme.spacingM) / 2
+ height: parent.height
+ radius: Theme.cornerRadius
+ color: closeMouseArea.containsMouse ? Theme.errorPressed : Theme.secondaryHover
+
+ StyledText {
+ anchors.centerIn: parent
+ text: I18n.tr("Close")
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: closeMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: systemUpdatePopout.close()
+ }
+
+ Behavior on color {
+ ColorAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ id: bodyArea
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: backendsRow.visible ? backendsRow.bottom : header.bottom
+ anchors.bottom: buttonsRow.top
+ anchors.leftMargin: Theme.spacingL
+ anchors.rightMargin: Theme.spacingL
+ anchors.topMargin: Theme.spacingM
+ anchors.bottomMargin: Theme.spacingM
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
+
+ StyledText {
+ id: statusText
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ visible: !SystemUpdateService.isUpgrading && (SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking)
+ text: {
+ switch (true) {
+ case SystemUpdateService.hasError:
+ return I18n.tr("Failed: %1").arg(SystemUpdateService.errorMessage);
+ case !SystemUpdateService.helperAvailable:
+ return I18n.tr("No supported package manager found.");
+ case SystemUpdateService.isChecking:
+ return I18n.tr("Checking for updates...");
+ default:
+ return I18n.tr("Your system is up to date!");
+ }
+ }
+ font.pixelSize: Theme.fontSizeMedium
+ color: SystemUpdateService.hasError ? Theme.errorText : Theme.surfaceText
+ wrapMode: Text.WordWrap
+ }
+
+ DankListView {
+ id: packagesList
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
+ clip: true
+ spacing: Theme.spacingXS
+ model: SystemUpdateService.availableUpdates
+
+ delegate: Rectangle {
+ width: ListView.view.width
+ height: 48
+ radius: Theme.cornerRadius
+ color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent"
+
+ required property var modelData
+
+ Row {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.leftMargin: Theme.spacingM
+ anchors.rightMargin: Theme.spacingM
+ spacing: Theme.spacingS
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 64
+ height: 18
+ radius: 9
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.18)
+
+ StyledText {
+ anchors.centerIn: parent
+ text: modelData.repo || ""
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.primary
+ }
+ }
+
+ Column {
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - 64 - Theme.spacingS
+ spacing: 2
+
+ StyledText {
+ width: parent.width
+ text: modelData.name || ""
+ font.pixelSize: Theme.fontSizeMedium
+ color: Theme.surfaceText
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ StyledText {
+ width: parent.width
+ text: {
+ const from = modelData.fromVersion || "";
+ const to = modelData.toVersion || "";
+ if (from && to) {
+ return `${from} → ${to}`;
+ }
+ return to || from || "";
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ elide: Text.ElideRight
+ }
+ }
+ }
+
+ MouseArea {
+ id: packageMouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (modelData.changelogUrl) {
+ Qt.openUrlExternally(modelData.changelogUrl);
+ }
+ }
+ }
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ spacing: Theme.spacingS
+ visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
+
+ DankIcon {
+ anchors.horizontalCenter: parent.horizontalCenter
+ name: "terminal"
+ size: 32
+ color: Theme.primary
+ }
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("Running in terminal")
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Medium
+ color: Theme.surfaceText
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ StyledText {
+ width: parent.width
+ text: I18n.tr("AUR helpers are interactive — see the terminal window for prompts. This popout will return to idle when the upgrade exits.")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ DankFlickable {
+ anchors.fill: parent
+ anchors.margins: Theme.spacingM
+ visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
+ contentWidth: width
+ contentHeight: logText.implicitHeight
+ clip: true
+
+ onContentHeightChanged: {
+ if (contentHeight > height) {
+ contentY = contentHeight - height;
+ }
+ }
+
+ StyledText {
+ id: logText
+ width: parent.width
+ text: (SystemUpdateService.recentLog || []).join("\n")
+ font.family: Theme.monoFontFamily || "monospace"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ wrapMode: Text.NoWrap
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml b/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml
index 696c76c8..ac53c3fe 100644
--- a/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml
+++ b/quickshell/Modules/DankBar/Widgets/SystemUpdate.qml
@@ -7,41 +7,33 @@ import qs.Widgets
BasePill {
id: root
+ property var widgetData: null
property bool isActive: false
+
readonly property bool hasUpdates: SystemUpdateService.updateCount > 0
readonly property bool isChecking: SystemUpdateService.isChecking
- readonly property bool shouldHide: SettingsData.updaterHideWidget && !hasUpdates && !isChecking && !SystemUpdateService.hasError
+ readonly property bool isClean: SystemUpdateService.sysupdateAvailable && !hasUpdates && !isChecking && !SystemUpdateService.hasError
+ readonly property bool hideWhenIdle: widgetData?.hideWhenIdle === true
+ readonly property bool shouldHide: hideWhenIdle && isClean
+ width: shouldHide ? 0 : (isVerticalOrientation ? barThickness : visualWidth)
+ height: shouldHide ? 0 : (isVerticalOrientation ? visualHeight : barThickness)
+ visible: !shouldHide
opacity: shouldHide ? 0 : 1
- states: [
- State {
- name: "hidden_horizontal"
- when: root.shouldHide && !isVerticalOrientation
- PropertyChanges {
- target: root
- width: 0
- }
- },
- State {
- name: "hidden_vertical"
- when: root.shouldHide && isVerticalOrientation
- PropertyChanges {
- target: root
- height: 0
- }
+ Behavior on width {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
}
- ]
+ }
- transitions: [
- Transition {
- NumberAnimation {
- properties: "width,height"
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
- }
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
}
- ]
+ }
Behavior on opacity {
NumberAnimation {
diff --git a/quickshell/Modules/Settings/AboutTab.qml b/quickshell/Modules/Settings/AboutTab.qml
index 67c2c21f..ff3c2b3d 100644
--- a/quickshell/Modules/Settings/AboutTab.qml
+++ b/quickshell/Modules/Settings/AboutTab.qml
@@ -189,10 +189,10 @@ Item {
StyledText {
text: {
- if (!SystemUpdateService.shellVersion && !DMSService.cliVersion)
+ if (!ShellVersionService.shellVersion && !DMSService.cliVersion)
return "dms";
- let version = SystemUpdateService.shellVersion || "";
+ let version = ShellVersionService.shellVersion || "";
let cliVersion = DMSService.cliVersion || "";
// Debian/Ubuntu/OpenSUSE git format: 1.0.3+git2264.c5c5ce84
@@ -218,7 +218,7 @@ Item {
let baseVersion = extractBaseVersion(cliVersion);
if (!baseVersion)
- baseVersion = extractBaseVersion(SystemUpdateService.semverVersion);
+ baseVersion = extractBaseVersion(ShellVersionService.semverVersion);
if (baseVersion) {
return `dms (git) v${baseVersion}-${match[1]}`;
}
@@ -253,8 +253,8 @@ Item {
}
StyledText {
- visible: SystemUpdateService.shellCodename.length > 0
- text: `"${SystemUpdateService.shellCodename}"`
+ visible: ShellVersionService.shellCodename.length > 0
+ text: `"${ShellVersionService.shellCodename}"`
font.pixelSize: Theme.fontSizeMedium
font.italic: true
color: Theme.surfaceVariantText
diff --git a/quickshell/Modules/Settings/LauncherTab.qml b/quickshell/Modules/Settings/LauncherTab.qml
index 8705d420..70bf57bd 100644
--- a/quickshell/Modules/Settings/LauncherTab.qml
+++ b/quickshell/Modules/Settings/LauncherTab.qml
@@ -325,6 +325,8 @@ Item {
placeholderText: I18n.tr("Enter launch prefix (e.g., 'uwsm-app')")
onTextEdited: SettingsData.set("launchPrefix", text)
}
+
+ TerminalPickerRow {}
}
SettingsCard {
diff --git a/quickshell/Modules/Settings/SystemUpdaterTab.qml b/quickshell/Modules/Settings/SystemUpdaterTab.qml
index 5a61dbb0..1f98744e 100644
--- a/quickshell/Modules/Settings/SystemUpdaterTab.qml
+++ b/quickshell/Modules/Settings/SystemUpdaterTab.qml
@@ -1,11 +1,53 @@
import QtQuick
import qs.Common
+import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
+ readonly property var intervalOptions: [
+ {
+ label: I18n.tr("Every 15 minutes"),
+ seconds: 900
+ },
+ {
+ label: I18n.tr("Every 30 minutes"),
+ seconds: 1800
+ },
+ {
+ label: I18n.tr("Every hour"),
+ seconds: 3600
+ },
+ {
+ label: I18n.tr("Every 4 hours"),
+ seconds: 14400
+ },
+ {
+ label: I18n.tr("Once a day"),
+ seconds: 86400
+ }
+ ]
+
+ function intervalLabelFor(seconds) {
+ for (const opt of intervalOptions) {
+ if (opt.seconds === seconds) {
+ return opt.label;
+ }
+ }
+ return intervalOptions[1].label;
+ }
+
+ function intervalSecondsFor(label) {
+ for (const opt of intervalOptions) {
+ if (opt.label === label) {
+ return opt.seconds;
+ }
+ }
+ return 1800;
+ }
+
DankFlickable {
anchors.fill: parent
clip: true
@@ -25,18 +67,60 @@ Item {
title: I18n.tr("System Updater")
settingKey: "systemUpdater"
- SettingsToggleRow {
- text: I18n.tr("Hide Updater Widget", "When updater widget is used, then hide it if no update found")
- description: I18n.tr("When updater widget is used, then hide it if no update found")
- checked: SettingsData.updaterHideWidget
- onToggled: checked => {
- SettingsData.set("updaterHideWidget", checked);
+ StyledText {
+ width: parent.width - Theme.spacingM * 2
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ visible: SystemUpdateService.backends.length > 0
+ text: {
+ const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
+ return I18n.tr("Detected backends: %1").arg(names);
+ }
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceVariantText
+ wrapMode: Text.WordWrap
+ }
+
+ SettingsDropdownRow {
+ text: I18n.tr("Check interval")
+ description: I18n.tr("How often the server polls for new updates.")
+ options: root.intervalOptions.map(o => o.label)
+ currentValue: root.intervalLabelFor(SettingsData.updaterIntervalSeconds)
+ onValueChanged: label => {
+ const secs = root.intervalSecondsFor(label);
+ SettingsData.set("updaterIntervalSeconds", secs);
+ SystemUpdateService.setInterval(secs);
}
}
SettingsToggleRow {
- text: I18n.tr("Use Custom Command")
- description: I18n.tr("Use custom command for update your system")
+ text: I18n.tr("Include Flatpak updates")
+ description: I18n.tr("Apply Flatpak updates alongside system updates when running 'Update All'.")
+ visible: (SystemUpdateService.backends || []).some(b => b.repo === "flatpak")
+ checked: SettingsData.updaterIncludeFlatpak
+ onToggled: checked => SettingsData.set("updaterIncludeFlatpak", checked)
+ }
+
+ SettingsToggleRow {
+ text: I18n.tr("Include AUR updates")
+ description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.")
+ visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay")
+ checked: SettingsData.updaterAllowAUR
+ onToggled: checked => SettingsData.set("updaterAllowAUR", checked)
+ }
+
+ TerminalPickerRow {}
+ }
+
+ SettingsCard {
+ width: parent.width
+ iconName: "tune"
+ title: I18n.tr("Advanced")
+ settingKey: "systemUpdaterAdvanced"
+
+ SettingsToggleRow {
+ text: I18n.tr("Use custom command")
+ description: I18n.tr("Open a terminal and run a custom command instead of the in-shell upgrade flow.")
checked: SettingsData.updaterUseCustomCommand
onToggled: checked => {
if (!checked) {
@@ -49,11 +133,32 @@ Item {
}
}
+ Rectangle {
+ width: parent.width - Theme.spacingM * 2
+ anchors.left: parent.left
+ anchors.leftMargin: Theme.spacingM
+ visible: SettingsData.updaterUseCustomCommand
+ height: warnText.implicitHeight + Theme.spacingS * 2
+ radius: Theme.cornerRadius
+ color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
+
+ StyledText {
+ id: warnText
+ anchors.fill: parent
+ anchors.margins: Theme.spacingS
+ text: I18n.tr("Custom command and terminal params are split on whitespace; paths with spaces will break.")
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.warning
+ wrapMode: Text.WordWrap
+ }
+ }
+
FocusScope {
width: parent.width - Theme.spacingM * 2
height: customCommandColumn.implicitHeight
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
+ visible: SettingsData.updaterUseCustomCommand
Column {
id: customCommandColumn
@@ -61,7 +166,7 @@ Item {
spacing: Theme.spacingXS
StyledText {
- text: I18n.tr("System update custom command")
+ text: I18n.tr("Custom update command")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
@@ -69,7 +174,7 @@ Item {
DankTextField {
id: updaterCustomCommand
width: parent.width
- placeholderText: "myPkgMngr --sysupdate"
+ placeholderText: "topgrade --no-retry"
backgroundColor: Theme.surfaceContainerHighest
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
@@ -98,6 +203,7 @@ Item {
height: terminalParamsColumn.implicitHeight
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
+ visible: SettingsData.updaterUseCustomCommand
Column {
id: terminalParamsColumn
@@ -105,7 +211,7 @@ Item {
spacing: Theme.spacingXS
StyledText {
- text: I18n.tr("Terminal custom additional parameters")
+ text: I18n.tr("Terminal additional parameters")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
@@ -113,7 +219,7 @@ Item {
DankTextField {
id: updaterTerminalCustomClass
width: parent.width
- placeholderText: "-T udpClass"
+ placeholderText: "-T updater"
backgroundColor: Theme.surfaceContainerHighest
normalBorderColor: Theme.outlineMedium
focusedBorderColor: Theme.primary
diff --git a/quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml b/quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml
new file mode 100644
index 00000000..62e58070
--- /dev/null
+++ b/quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml
@@ -0,0 +1,31 @@
+import QtQuick
+import qs.Common
+
+SettingsDropdownRow {
+ id: root
+
+ readonly property string autoLabel: I18n.tr("Auto")
+
+ text: I18n.tr("Terminal")
+ settingKey: "terminalOverride"
+
+ options: {
+ const opts = [autoLabel];
+ const installed = SessionData.installedTerminals || [];
+ const list = installed.length > 0 ? installed : SessionData.terminalOptions;
+ for (const t of list) {
+ opts.push(t);
+ }
+ if (SessionData.terminalOverride && !opts.includes(SessionData.terminalOverride)) {
+ opts.push(SessionData.terminalOverride);
+ }
+ return opts;
+ }
+
+ currentValue: SessionData.terminalOverride.length > 0 ? SessionData.terminalOverride : autoLabel
+
+ onValueChanged: label => {
+ const next = label === autoLabel ? "" : label;
+ SessionData.set("terminalOverride", next);
+ }
+}
diff --git a/quickshell/Modules/Settings/WidgetsTab.qml b/quickshell/Modules/Settings/WidgetsTab.qml
index 86bc391e..46ac68c2 100644
--- a/quickshell/Modules/Settings/WidgetsTab.qml
+++ b/quickshell/Modules/Settings/WidgetsTab.qml
@@ -246,7 +246,8 @@ Item {
"text": I18n.tr("System Update"),
"description": I18n.tr("Check for system updates"),
"icon": "update",
- "enabled": SystemUpdateService.distributionSupported
+ "enabled": SystemUpdateService.sysupdateAvailable,
+ "warning": SystemUpdateService.sysupdateAvailable ? undefined : I18n.tr("Requires DMS server with sysupdate capability")
},
{
"id": "powerMenuButton",
@@ -430,7 +431,7 @@ Item {
"id": widget.id,
"enabled": widget.enabled
};
- var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion"];
+ var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion", "hideWhenIdle"];
for (var i = 0; i < keys.length; i++) {
if (widget[keys[i]] !== undefined)
result[keys[i]] = widget[keys[i]];
@@ -579,6 +580,17 @@ Item {
setWidgetsForSection(sectionId, widgets);
}
+ function handleHideWhenIdleChanged(sectionId, widgetIndex, enabled) {
+ var widgets = getWidgetsForSection(sectionId).slice();
+ if (widgetIndex < 0 || widgetIndex >= widgets.length) {
+ return;
+ }
+ var newWidget = cloneWidgetData(widgets[widgetIndex]);
+ newWidget.hideWhenIdle = enabled;
+ widgets[widgetIndex] = newWidget;
+ setWidgetsForSection(sectionId, widgets);
+ }
+
function handleDiskUsageModeChanged(sectionId, widgetIndex, mode) {
var widgets = getWidgetsForSection(sectionId).slice();
if (widgetIndex < 0 || widgetIndex >= widgets.length) {
@@ -714,6 +726,8 @@ Item {
item.barShowOverflowBadge = widget.barShowOverflowBadge;
if (widget.trayUseInlineExpansion !== undefined)
item.trayUseInlineExpansion = widget.trayUseInlineExpansion;
+ if (widget.hideWhenIdle !== undefined)
+ item.hideWhenIdle = widget.hideWhenIdle;
}
widgets.push(item);
});
@@ -1003,6 +1017,9 @@ Item {
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
+ onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
+ widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
+ }
}
}
@@ -1070,6 +1087,9 @@ Item {
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
+ onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
+ widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
+ }
}
}
@@ -1137,6 +1157,9 @@ Item {
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
}
+ onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
+ widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
+ }
}
}
}
diff --git a/quickshell/Modules/Settings/WidgetsTabSection.qml b/quickshell/Modules/Settings/WidgetsTabSection.qml
index 88c12703..5363f371 100644
--- a/quickshell/Modules/Settings/WidgetsTabSection.qml
+++ b/quickshell/Modules/Settings/WidgetsTabSection.qml
@@ -33,6 +33,7 @@ Column {
signal showInGbChanged(string sectionId, int widgetIndex, bool enabled)
signal diskUsageModeChanged(string sectionId, int widgetIndex, int mode)
signal overflowSettingChanged(string sectionId, int widgetIndex, string settingName, var value)
+ signal hideWhenIdleChanged(string sectionId, int widgetIndex, bool enabled)
function cloneWidgetData(widget) {
var result = {
@@ -335,6 +336,25 @@ Column {
}
}
+ DankActionButton {
+ id: hideWhenIdleButton
+ buttonSize: 28
+ visible: modelData.id === "systemUpdate"
+ iconName: "visibility_off"
+ iconSize: 16
+ iconColor: (modelData.hideWhenIdle === true) ? Theme.primary : Theme.outline
+ onClicked: {
+ root.hideWhenIdleChanged(root.sectionId, index, modelData.hideWhenIdle !== true);
+ }
+ onEntered: {
+ const tooltipText = modelData.hideWhenIdle === true ? "Hide when no updates: ON" : "Hide when no updates: OFF";
+ sharedTooltip.show(tooltipText, hideWhenIdleButton, 0, 0, "bottom");
+ }
+ onExited: {
+ sharedTooltip.hide();
+ }
+ }
+
DankActionButton {
id: memMenuButton
visible: modelData.id === "memUsage"
diff --git a/quickshell/Modules/SystemUpdatePopout.qml b/quickshell/Modules/SystemUpdatePopout.qml
deleted file mode 100644
index 49cdb930..00000000
--- a/quickshell/Modules/SystemUpdatePopout.qml
+++ /dev/null
@@ -1,329 +0,0 @@
-import QtQuick
-import qs.Common
-import qs.Services
-import qs.Widgets
-
-DankPopout {
- id: systemUpdatePopout
-
- layerNamespace: "dms:system-update"
-
- property var parentWidget: null
- property var triggerScreen: null
-
- Ref {
- service: SystemUpdateService
- }
-
- popupWidth: 400
- popupHeight: 500
- triggerWidth: 55
- positioning: ""
- screen: triggerScreen
- shouldBeVisible: false
-
- onBackgroundClicked: close()
-
- onShouldBeVisibleChanged: {
- if (shouldBeVisible) {
- if (SystemUpdateService.updateCount === 0 && !SystemUpdateService.isChecking) {
- SystemUpdateService.checkForUpdates();
- }
- }
- }
-
- content: Component {
- Rectangle {
- id: updaterPanel
-
- color: "transparent"
-
- Column {
- width: parent.width - Theme.spacingL * 2
- height: parent.height - Theme.spacingL * 2
- x: Theme.spacingL
- y: Theme.spacingL
- spacing: Theme.spacingL
-
- Item {
- width: parent.width
- height: 40
-
- StyledText {
- text: I18n.tr("System Updates")
- font.pixelSize: Theme.fontSizeLarge
- color: Theme.surfaceText
- font.weight: Font.Medium
- anchors.left: parent.left
- anchors.verticalCenter: parent.verticalCenter
- }
-
- Row {
- anchors.right: parent.right
- anchors.verticalCenter: parent.verticalCenter
- spacing: Theme.spacingXS
-
- StyledText {
- anchors.verticalCenter: parent.verticalCenter
- text: {
- if (SystemUpdateService.isChecking)
- return I18n.tr("Checking...");
- if (SystemUpdateService.hasError)
- return I18n.tr("Error");
- if (SystemUpdateService.updateCount === 0)
- return I18n.tr("Up to date");
- return SystemUpdateService.updateCount === 1
- ? I18n.tr("%1 update").arg(SystemUpdateService.updateCount)
- : I18n.tr("%1 updates").arg(SystemUpdateService.updateCount);
- }
- font.pixelSize: Theme.fontSizeMedium
- color: {
- if (SystemUpdateService.hasError)
- return Theme.error;
- return Theme.surfaceText;
- }
- }
-
- DankActionButton {
- id: checkForUpdatesButton
- buttonSize: 28
- iconName: "refresh"
- iconSize: 18
- z: 15
- iconColor: Theme.surfaceText
- enabled: !SystemUpdateService.isChecking
- opacity: enabled ? 1.0 : 0.5
- onClicked: {
- SystemUpdateService.checkForUpdates();
- }
-
- RotationAnimation {
- target: checkForUpdatesButton
- property: "rotation"
- from: 0
- to: 360
- duration: 1000
- running: SystemUpdateService.isChecking
- loops: Animation.Infinite
-
- onRunningChanged: {
- if (!running) {
- checkForUpdatesButton.rotation = 0;
- }
- }
- }
- }
- }
- }
-
- Rectangle {
- width: parent.width
- height: {
- let usedHeight = 40 + Theme.spacingL;
- usedHeight += 48 + Theme.spacingL;
- return parent.height - usedHeight;
- }
- radius: Theme.cornerRadius
- color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
- border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
- border.width: 0
-
- Column {
- anchors.fill: parent
- anchors.margins: Theme.spacingM
- anchors.rightMargin: 0
-
- StyledText {
- id: statusText
- width: parent.width
- text: {
- if (SystemUpdateService.hasError) {
- return I18n.tr("Failed to check for updates:\n%1").arg(SystemUpdateService.errorMessage);
- }
- if (!SystemUpdateService.helperAvailable) {
- return I18n.tr("No package manager found. Please install 'paru' or 'yay' on Arch-based systems to check for updates.");
- }
- if (SystemUpdateService.isChecking) {
- return I18n.tr("Checking for updates...");
- }
- if (SystemUpdateService.updateCount === 0) {
- return I18n.tr("Your system is up to date!");
- }
- return SystemUpdateService.updateCount === 1
- ? I18n.tr("Found %1 package to update:").arg(SystemUpdateService.updateCount)
- : I18n.tr("Found %1 packages to update:").arg(SystemUpdateService.updateCount);
- }
- font.pixelSize: Theme.fontSizeMedium
- color: {
- if (SystemUpdateService.hasError)
- return Theme.errorText;
- return Theme.surfaceText;
- }
- wrapMode: Text.WordWrap
- visible: SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking
- }
-
- DankListView {
- id: packagesList
-
- width: parent.width
- height: parent.height - (SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking ? statusText.height + Theme.spacingM : 0)
- visible: SystemUpdateService.updateCount > 0 && !SystemUpdateService.isChecking && !SystemUpdateService.hasError
- clip: true
- spacing: Theme.spacingXS
-
- model: SystemUpdateService.availableUpdates
-
- delegate: Rectangle {
- width: ListView.view.width - Theme.spacingM
- height: 48
- radius: Theme.cornerRadius
- color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent"
- border.color: Theme.outlineLight
- border.width: 0
-
- Row {
- anchors.fill: parent
- anchors.margins: Theme.spacingM
- spacing: Theme.spacingM
-
- Column {
- anchors.verticalCenter: parent.verticalCenter
- width: parent.width - Theme.spacingM
- spacing: 2
-
- StyledText {
- width: parent.width
- text: modelData.name || ""
- font.pixelSize: Theme.fontSizeMedium
- color: Theme.surfaceText
- font.weight: Font.Medium
- elide: Text.ElideRight
- }
-
- StyledText {
- width: parent.width
- text: `${modelData.currentVersion} → ${modelData.newVersion}`
- font.pixelSize: Theme.fontSizeSmall
- color: Theme.surfaceVariantText
- elide: Text.ElideRight
- }
- }
- }
-
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- }
- }
-
- MouseArea {
- id: packageMouseArea
-
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- }
- }
- }
- }
- }
-
- Row {
- width: parent.width
- height: 48
- spacing: Theme.spacingM
-
- Rectangle {
- width: (parent.width - Theme.spacingM) / 2
- height: parent.height
- radius: Theme.cornerRadius
- color: updateMouseArea.containsMouse ? Theme.primaryHover : Theme.secondaryHover
- opacity: SystemUpdateService.updateCount > 0 ? 1.0 : 0.5
-
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- }
- }
-
- Row {
- anchors.centerIn: parent
- spacing: Theme.spacingS
-
- DankIcon {
- name: "system_update_alt"
- size: Theme.iconSize
- color: Theme.primary
- anchors.verticalCenter: parent.verticalCenter
- }
-
- StyledText {
- text: I18n.tr("Update All")
- font.pixelSize: Theme.fontSizeMedium
- font.weight: Font.Medium
- color: Theme.primary
- anchors.verticalCenter: parent.verticalCenter
- }
- }
-
- MouseArea {
- id: updateMouseArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- enabled: SystemUpdateService.updateCount > 0
- onClicked: {
- SystemUpdateService.runUpdates();
- systemUpdatePopout.close();
- }
- }
- }
-
- Rectangle {
- width: (parent.width - Theme.spacingM) / 2
- height: parent.height
- radius: Theme.cornerRadius
- color: closeMouseArea.containsMouse ? Theme.errorPressed : Theme.secondaryHover
-
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- }
- }
-
- Row {
- anchors.centerIn: parent
- spacing: Theme.spacingS
-
- DankIcon {
- name: "close"
- size: Theme.iconSize
- color: Theme.surfaceText
- anchors.verticalCenter: parent.verticalCenter
- }
-
- StyledText {
- text: I18n.tr("Close")
- font.pixelSize: Theme.fontSizeMedium
- font.weight: Font.Medium
- color: Theme.surfaceText
- anchors.verticalCenter: parent.verticalCenter
- }
- }
-
- MouseArea {
- id: closeMouseArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- systemUpdatePopout.close();
- }
- }
- }
- }
- }
- }
- }
-}
diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml
index 585e2622..62236b2d 100644
--- a/quickshell/Services/DMSService.qml
+++ b/quickshell/Services/DMSService.qml
@@ -61,12 +61,13 @@ Singleton {
signal screensaverStateUpdate(var data)
signal clipboardStateUpdate(var data)
signal locationStateUpdate(var data)
+ signal sysupdateStateUpdate(var data)
property bool capsLockState: false
property bool screensaverInhibited: false
property var screensaverInhibitors: []
- property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location"]
+ property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location", "sysupdate"]
Component.onCompleted: {
if (socketPath && socketPath.length > 0) {
@@ -393,6 +394,8 @@ Singleton {
clipboardStateUpdate(data);
} else if (service === "location") {
locationStateUpdate(data);
+ } else if (service === "sysupdate") {
+ sysupdateStateUpdate(data);
}
}
@@ -749,4 +752,37 @@ Singleton {
"name": name
}, callback);
}
+
+ function sysupdateGetState(callback) {
+ sendRequest("sysupdate.getState", null, callback);
+ }
+
+ function sysupdateRefresh(force, callback) {
+ sendRequest("sysupdate.refresh", {
+ "force": force === true
+ }, callback);
+ }
+
+ function sysupdateUpgrade(opts, callback) {
+ const params = opts || {};
+ sendRequest("sysupdate.upgrade", params, callback);
+ }
+
+ function sysupdateCancel(callback) {
+ sendRequest("sysupdate.cancel", null, callback);
+ }
+
+ function sysupdateSetInterval(seconds, callback) {
+ sendRequest("sysupdate.setInterval", {
+ "seconds": seconds
+ }, callback);
+ }
+
+ function sysupdateAcquire(callback) {
+ sendRequest("sysupdate.acquire", null, callback);
+ }
+
+ function sysupdateRelease(callback) {
+ sendRequest("sysupdate.release", null, callback);
+ }
}
diff --git a/quickshell/Services/MuxService.qml b/quickshell/Services/MuxService.qml
index 09132b93..59737f94 100644
--- a/quickshell/Services/MuxService.qml
+++ b/quickshell/Services/MuxService.qml
@@ -37,7 +37,7 @@ Singleton {
return terminalFlags[terminal] ?? ["-e"]
}
- readonly property string terminal: Quickshell.env("TERMINAL") || "ghostty"
+ readonly property string terminal: SessionData.resolveTerminal() || "ghostty"
function _terminalPrefix() {
return [terminal].concat(getTerminalFlag(terminal))
diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml
index 03f9be64..1a58d98a 100644
--- a/quickshell/Services/PluginService.qml
+++ b/quickshell/Services/PluginService.qml
@@ -860,7 +860,7 @@ Singleton {
function checkPluginCompatibility(requiresDms) {
if (!requiresDms)
return true;
- return SystemUpdateService.checkVersionRequirement(requiresDms, SystemUpdateService.getParsedShellVersion());
+ return ShellVersionService.checkVersionRequirement(requiresDms, ShellVersionService.getParsedShellVersion());
}
function getIncompatiblePlugins() {
diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml
index c46cb7df..6b008f5a 100644
--- a/quickshell/Services/SessionService.qml
+++ b/quickshell/Services/SessionService.qml
@@ -237,7 +237,7 @@ Singleton {
const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
if (desktopEntry.runInTerminal) {
- const terminal = Quickshell.env("TERMINAL") || "xterm";
+ const terminal = SessionData.resolveTerminal() || "xterm";
const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
const shellCmd = prefix.length > 0 ? `${prefix} ${escapedCmd}` : escapedCmd;
Quickshell.execDetached({
diff --git a/quickshell/Services/ShellVersionService.qml b/quickshell/Services/ShellVersionService.qml
new file mode 100644
index 00000000..c554b5be
--- /dev/null
+++ b/quickshell/Services/ShellVersionService.qml
@@ -0,0 +1,134 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+Singleton {
+ id: root
+
+ property string shellVersion: ""
+ property string shellCodename: ""
+ property string semverVersion: ""
+
+ function getParsedShellVersion() {
+ return parseVersion(semverVersion);
+ }
+
+ Process {
+ id: versionDetection
+ running: true
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -d .git ]; then echo "(git) $(git rev-parse --short HEAD)"; elif [ -f VERSION ]; then cat VERSION; fi`]
+
+ stdout: StdioCollector {
+ onStreamFinished: shellVersion = text.trim()
+ }
+ }
+
+ Process {
+ id: semverDetection
+ running: true
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f VERSION ]; then cat VERSION; fi`]
+
+ stdout: StdioCollector {
+ onStreamFinished: semverVersion = text.trim()
+ }
+ }
+
+ Process {
+ id: codenameDetection
+ running: true
+ command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f CODENAME ]; then cat CODENAME; fi`]
+
+ stdout: StdioCollector {
+ onStreamFinished: shellCodename = text.trim()
+ }
+ }
+
+ function parseVersion(versionStr) {
+ if (!versionStr || typeof versionStr !== "string") {
+ return {
+ major: 0,
+ minor: 0,
+ patch: 0
+ };
+ }
+ let v = versionStr.trim();
+ if (v.startsWith("v")) {
+ v = v.substring(1);
+ }
+ const dashIdx = v.indexOf("-");
+ if (dashIdx !== -1) {
+ v = v.substring(0, dashIdx);
+ }
+ const plusIdx = v.indexOf("+");
+ if (plusIdx !== -1) {
+ v = v.substring(0, plusIdx);
+ }
+ const parts = v.split(".");
+ return {
+ major: parseInt(parts[0], 10) || 0,
+ minor: parseInt(parts[1], 10) || 0,
+ patch: parseInt(parts[2], 10) || 0
+ };
+ }
+
+ function compareVersions(v1, v2) {
+ if (v1.major !== v2.major) {
+ return v1.major - v2.major;
+ }
+ if (v1.minor !== v2.minor) {
+ return v1.minor - v2.minor;
+ }
+ return v1.patch - v2.patch;
+ }
+
+ function checkVersionRequirement(requirementStr, currentVersion) {
+ if (!requirementStr || typeof requirementStr !== "string") {
+ return true;
+ }
+ const req = requirementStr.trim();
+ let operator = ">=";
+ let versionPart = req;
+ switch (true) {
+ case req.startsWith(">="):
+ operator = ">=";
+ versionPart = req.substring(2);
+ break;
+ case req.startsWith("<="):
+ operator = "<=";
+ versionPart = req.substring(2);
+ break;
+ case req.startsWith(">"):
+ operator = ">";
+ versionPart = req.substring(1);
+ break;
+ case req.startsWith("<"):
+ operator = "<";
+ versionPart = req.substring(1);
+ break;
+ case req.startsWith("="):
+ operator = "=";
+ versionPart = req.substring(1);
+ break;
+ }
+
+ const reqVersion = parseVersion(versionPart);
+ const cmp = compareVersions(currentVersion, reqVersion);
+ switch (operator) {
+ case ">=":
+ return cmp >= 0;
+ case ">":
+ return cmp > 0;
+ case "<=":
+ return cmp <= 0;
+ case "<":
+ return cmp < 0;
+ case "=":
+ return cmp === 0;
+ default:
+ return cmp >= 0;
+ }
+ }
+}
diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml
index 53ca7ade..85710ce8 100644
--- a/quickshell/Services/SystemUpdateService.qml
+++ b/quickshell/Services/SystemUpdateService.qml
@@ -10,288 +10,185 @@ Singleton {
id: root
property int refCount: 0
+
+ property bool sysupdateAvailable: false
+
property var availableUpdates: []
property bool isChecking: false
+ property bool isUpgrading: false
property bool hasError: false
property string errorMessage: ""
- property string updChecker: ""
- property string pkgManager: ""
+ property string errorCode: ""
+ property var backends: []
property string distribution: ""
+ property string distributionPretty: ""
+ property string pkgManager: ""
property bool distributionSupported: false
- property string shellVersion: ""
- property string shellCodename: ""
- property string semverVersion: ""
+ property var recentLog: []
+ property int intervalSeconds: 1800
+ property int lastCheckUnix: 0
+ property int nextCheckUnix: 0
- function getParsedShellVersion() {
- return parseVersion(semverVersion);
- }
-
- readonly property var archBasedUCSettings: {
- "listUpdatesSettings": {
- "params": [],
- "correctExitCodes": [0, 2] // Exit code 0 = updates available, 2 = no updates
- },
- "parserSettings": {
- "lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
- "entryProducer": function (match) {
- return {
- "name": match[1],
- "currentVersion": match[2],
- "newVersion": match[3],
- "description": `${match[1]} ${match[2]} → ${match[3]}`
- };
- }
- }
- }
-
- readonly property var archBasedPMSettings: function(requiresSudo) {
- return {
- "listUpdatesSettings": {
- "params": ["-Qu"],
- "correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
- },
- "upgradeSettings": {
- "params": ["-Syu"],
- "requiresSudo": requiresSudo
- },
- "parserSettings": {
- "lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
- "entryProducer": function (match) {
- return {
- "name": match[1],
- "currentVersion": match[2],
- "newVersion": match[3],
- "description": `${match[1]} ${match[2]} → ${match[3]}`
- };
- }
- }
- }
- }
-
- readonly property var fedoraBasedPMSettings: {
- "listUpdatesSettings": {
- "params": ["list", "--upgrades", "--quiet", "--color=never"],
- "correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
- },
- "upgradeSettings": {
- "params": ["upgrade"],
- "requiresSudo": true
- },
- "parserSettings": {
- "lineRegex": /^([^\s]+)\s+([^\s]+)\s+.*$/,
- "entryProducer": function (match) {
- return {
- "name": match[1],
- "currentVersion": "",
- "newVersion": match[2],
- "description": `${match[1]} → ${match[2]}`
- };
- }
- }
- }
-
- readonly property var updateCheckerParams: {
- "checkupdates": archBasedUCSettings
- }
- readonly property var packageManagerParams: {
- "yay": archBasedPMSettings(false),
- "paru": archBasedPMSettings(false),
- "pacman": archBasedPMSettings(true),
- "dnf": fedoraBasedPMSettings
- }
- readonly property list supportedDistributions: ["arch", "artix", "cachyos", "manjaro", "endeavouros", "fedora"]
readonly property int updateCount: availableUpdates.length
- readonly property bool helperAvailable: pkgManager !== "" && distributionSupported
+ readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
- Process {
- id: distributionDetection
- command: ["sh", "-c", "cat /etc/os-release | grep '^ID=' | cut -d'=' -f2 | tr -d '\"'"]
- running: true
-
- onExited: exitCode => {
- if (exitCode === 0) {
- distribution = stdout.text.trim().toLowerCase();
- distributionSupported = supportedDistributions.includes(distribution);
-
- if (distributionSupported) {
- updateFinderDetection.running = true;
- pkgManagerDetection.running = true;
- checkForUpdates();
- } else {
- console.warn("SystemUpdate: Unsupported distribution:", distribution);
- }
+ Connections {
+ target: DMSService
+ function onCapabilitiesReceived() {
+ root.checkCapabilities();
+ }
+ function onConnectionStateChanged() {
+ if (DMSService.isConnected) {
+ root.checkCapabilities();
} else {
- console.warn("SystemUpdate: Failed to detect distribution");
+ root.sysupdateAvailable = false;
}
}
-
- stdout: StdioCollector {}
-
- Component.onCompleted: {
- versionDetection.running = true;
+ function onSysupdateStateUpdate(data) {
+ root._applyState(data);
}
}
- Process {
- id: versionDetection
- command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -d .git ]; then echo "(git) $(git rev-parse --short HEAD)"; elif [ -f VERSION ]; then cat VERSION; fi`]
-
- stdout: StdioCollector {
- onStreamFinished: {
- shellVersion = text.trim();
- }
+ Component.onCompleted: {
+ if (DMSService.dmsAvailable) {
+ checkCapabilities();
}
}
- Process {
- id: semverDetection
- command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f VERSION ]; then cat VERSION; fi`]
- running: true
-
- stdout: StdioCollector {
- onStreamFinished: {
- semverVersion = text.trim();
- }
+ function checkCapabilities() {
+ if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
+ sysupdateAvailable = false;
+ return;
+ }
+ const has = DMSService.capabilities.includes("sysupdate");
+ if (has && !sysupdateAvailable) {
+ sysupdateAvailable = true;
+ requestState();
+ } else if (!has) {
+ sysupdateAvailable = false;
}
}
- Process {
- id: codenameDetection
- command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f CODENAME ]; then cat CODENAME; fi`]
- running: true
-
- stdout: StdioCollector {
- onStreamFinished: {
- shellCodename = text.trim();
- }
+ function requestState() {
+ if (!DMSService.isConnected || !sysupdateAvailable) {
+ return;
}
+ DMSService.sysupdateGetState(resp => {
+ if (resp && resp.result) {
+ _applyState(resp.result);
+ }
+ });
}
- Process {
- id: updateFinderDetection
- command: ["sh", "-c", "which checkupdates"]
-
- onExited: exitCode => {
- if (exitCode === 0) {
- const exeFound = stdout.text.trim();
- updChecker = exeFound.split('/').pop();
- } else {
- console.warn("SystemUpdate: No update checker found. Will use package manager.");
- }
+ function _applyState(data) {
+ if (!data) {
+ return;
}
+ availableUpdates = data.packages || [];
+ backends = data.backends || [];
+ distribution = data.distro || "";
+ distributionPretty = data.distroPretty || "";
+ distributionSupported = (backends.length > 0);
+ recentLog = data.recentLog || [];
+ intervalSeconds = data.intervalSeconds || 1800;
+ lastCheckUnix = data.lastCheckUnix || 0;
+ nextCheckUnix = data.nextCheckUnix || 0;
- stdout: StdioCollector {}
- }
-
- Process {
- id: pkgManagerDetection
- command: ["sh", "-c", "which paru || which yay || which pacman || which dnf"]
-
- onExited: exitCode => {
- if (exitCode === 0) {
- const exeFound = stdout.text.trim();
- pkgManager = exeFound.split('/').pop();
- } else {
- console.warn("SystemUpdate: No package manager found");
- }
- }
-
- stdout: StdioCollector {}
- }
-
- Process {
- id: updateChecker
-
- onExited: exitCode => {
+ const phase = data.phase || "idle";
+ switch (phase) {
+ case "refreshing":
+ isChecking = true;
+ isUpgrading = false;
+ break;
+ case "upgrading":
isChecking = false;
- const correctExitCodes = updChecker.length > 0 ? [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.correctExitCodes) : [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.correctExitCodes);
- if (correctExitCodes.includes(exitCode)) {
- parseUpdates(stdout.text);
- hasError = false;
- errorMessage = "";
- } else {
- hasError = true;
- errorMessage = "Failed to check for updates";
- console.warn("SystemUpdate: Update check failed with code:", exitCode);
- }
+ isUpgrading = true;
+ break;
+ default:
+ isChecking = false;
+ isUpgrading = false;
}
- stdout: StdioCollector {}
- }
+ if (data.error) {
+ hasError = true;
+ errorMessage = data.error.message || "";
+ errorCode = data.error.code || "";
+ } else {
+ hasError = false;
+ errorMessage = "";
+ errorCode = "";
+ }
- Process {
- id: updater
- onExited: exitCode => {
- checkForUpdates();
+ if (backends.length > 0) {
+ const sys = backends.find(b => b.repo === "system" || b.repo === "ostree");
+ pkgManager = sys ? sys.id : backends[0].id;
+ } else {
+ pkgManager = "";
}
}
function checkForUpdates() {
- if (!distributionSupported || (!pkgManager && !updChecker) || isChecking)
- return;
- isChecking = true;
- hasError = false;
- if (pkgManager === "paru" || pkgManager === "yay") {
- const repoCmd = updChecker.length > 0 ? updChecker : `${pkgManager} -Qu`;
- updateChecker.command = ["sh", "-c", `(${repoCmd} 2>/dev/null; ${pkgManager} -Qua 2>/dev/null) || true`];
- } else if (updChecker.length > 0) {
- updateChecker.command = [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.params);
- } else {
- updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params);
- }
- updateChecker.running = true;
+ DMSService.sysupdateRefresh(false, null);
}
- function parseUpdates(output) {
- const lines = output.trim().split('\n').filter(line => line.trim());
- const updates = [];
-
- const regex = packageManagerParams[pkgManager].parserSettings.lineRegex;
- const entryProducer = packageManagerParams[pkgManager].parserSettings.entryProducer;
-
- for (const line of lines) {
- const match = line.match(regex);
- if (match) {
- updates.push(entryProducer(match));
- }
- }
-
- availableUpdates = updates;
- }
-
- function runUpdates() {
- if (!distributionSupported || !pkgManager || updateCount === 0)
- return;
- const terminal = Quickshell.env("TERMINAL") || "xterm";
-
+ function runUpdates(opts) {
+ const params = opts || {};
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
- const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
- const termClass = SettingsData.updaterTerminalAdditionalParams;
-
- var finalCommand = [terminal];
- if (termClass.length > 0) {
- finalCommand = finalCommand.concat(termClass.split(" "));
- }
- finalCommand.push("-e");
- finalCommand.push("sh");
- finalCommand.push("-c");
- finalCommand.push(updateCommand);
- updater.command = finalCommand;
- } else {
- const params = packageManagerParams[pkgManager].upgradeSettings.params.join(" ");
- const sudo = packageManagerParams[pkgManager].upgradeSettings.requiresSudo ? "sudo" : "";
- const updateCommand = `${sudo} ${pkgManager} ${params} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
-
- updater.command = [terminal, "-e", "sh", "-c", updateCommand];
+ _runCustomTerminalCommand();
+ return;
}
- updater.running = true;
+ DMSService.sysupdateUpgrade(params, null);
}
- Timer {
- interval: 30 * 60 * 1000
- repeat: true
- running: refCount > 0 && distributionSupported && (pkgManager || updChecker)
- onTriggered: checkForUpdates()
+ function cancelUpdates() {
+ DMSService.sysupdateCancel(null);
+ }
+
+ function setInterval(seconds) {
+ DMSService.sysupdateSetInterval(seconds, null);
+ }
+
+ function _runCustomTerminalCommand() {
+ const terminal = SessionData.resolveTerminal();
+ if (!terminal || terminal.length === 0) {
+ ToastService.showError(I18n.tr("No terminal configured"), I18n.tr("Pick a terminal in Settings → Launcher (or set $TERMINAL)."));
+ return;
+ }
+ const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
+ const termClass = SettingsData.updaterTerminalAdditionalParams || "";
+ var argv = [terminal];
+ if (termClass.length > 0) {
+ argv = argv.concat(termClass.split(" "));
+ }
+ argv.push("-e");
+ argv.push("sh");
+ argv.push("-c");
+ argv.push(updateCommand);
+ customRunner.command = argv;
+ customRunner.running = true;
+ }
+
+ Process {
+ id: customRunner
+ onExited: root.checkForUpdates()
+ }
+
+ onRefCountChanged: _syncAcquire()
+ onSysupdateAvailableChanged: _syncAcquire()
+
+ property bool _acquired: false
+
+ function _syncAcquire() {
+ const want = refCount > 0 && sysupdateAvailable;
+ if (want === _acquired) {
+ return;
+ }
+ _acquired = want;
+ if (want) {
+ DMSService.sysupdateAcquire(null);
+ return;
+ }
+ DMSService.sysupdateRelease(null);
}
IpcHandler {
@@ -301,96 +198,11 @@ Singleton {
if (root.isChecking) {
return "ERROR: already checking";
}
- if (!distributionSupported) {
- return "ERROR: distribution not supported";
- }
- if (!pkgManager && !updChecker) {
- return "ERROR: update checker not available";
+ if (root.backends.length === 0) {
+ return "ERROR: no package manager available";
}
root.checkForUpdates();
return "SUCCESS: Now checking...";
}
}
-
- function parseVersion(versionStr) {
- if (!versionStr || typeof versionStr !== "string")
- return {
- major: 0,
- minor: 0,
- patch: 0
- };
-
- let v = versionStr.trim();
- if (v.startsWith("v"))
- v = v.substring(1);
-
- const dashIdx = v.indexOf("-");
- if (dashIdx !== -1)
- v = v.substring(0, dashIdx);
-
- const plusIdx = v.indexOf("+");
- if (plusIdx !== -1)
- v = v.substring(0, plusIdx);
-
- const parts = v.split(".");
- return {
- major: parseInt(parts[0], 10) || 0,
- minor: parseInt(parts[1], 10) || 0,
- patch: parseInt(parts[2], 10) || 0
- };
- }
-
- function compareVersions(v1, v2) {
- if (v1.major !== v2.major)
- return v1.major - v2.major;
- if (v1.minor !== v2.minor)
- return v1.minor - v2.minor;
- return v1.patch - v2.patch;
- }
-
- function checkVersionRequirement(requirementStr, currentVersion) {
- if (!requirementStr || typeof requirementStr !== "string")
- return true;
-
- const req = requirementStr.trim();
- let operator = "";
- let versionPart = req;
-
- if (req.startsWith(">=")) {
- operator = ">=";
- versionPart = req.substring(2);
- } else if (req.startsWith("<=")) {
- operator = "<=";
- versionPart = req.substring(2);
- } else if (req.startsWith(">")) {
- operator = ">";
- versionPart = req.substring(1);
- } else if (req.startsWith("<")) {
- operator = "<";
- versionPart = req.substring(1);
- } else if (req.startsWith("=")) {
- operator = "=";
- versionPart = req.substring(1);
- } else {
- operator = ">=";
- }
-
- const reqVersion = parseVersion(versionPart);
- const cmp = compareVersions(currentVersion, reqVersion);
-
- switch (operator) {
- case ">=":
- return cmp >= 0;
- case ">":
- return cmp > 0;
- case "<=":
- return cmp <= 0;
- case "<":
- return cmp < 0;
- case "=":
- return cmp === 0;
- default:
- return cmp >= 0;
- }
- }
}