From 7bd95748688f9f27a1e3054b19be944ffbdbeafd Mon Sep 17 00:00:00 2001 From: bbedward Date: Wed, 29 Apr 2026 12:33:57 -0400 Subject: [PATCH] system updater: complete overhaul Move system update flow to GO, with a CLI (convenient AIO tool) and server integration. All lifecycle, scheduling, execution occurs on backend side. Run some backends via pkexec, some via terminal like paru/yay. Incorporate flatpak as an option to update. Add terminal override setting in GUI, in addition to $TERMINAL env variable. fixes #2307 fixes #822 fixes #1102 fixes #1812 fixes #1087 fixes #1743 --- core/cmd/dms/commands_common.go | 1 + core/cmd/dms/commands_system.go | 277 ++++++++++ core/internal/server/router.go | 10 + core/internal/server/server.go | 62 +++ core/internal/server/sysupdate/backend.go | 96 ++++ core/internal/server/sysupdate/backend_apt.go | 75 +++ .../server/sysupdate/backend_apt_test.go | 72 +++ core/internal/server/sysupdate/backend_dnf.go | 108 ++++ .../server/sysupdate/backend_dnf_test.go | 77 +++ .../server/sysupdate/backend_flatpak.go | 139 +++++ .../server/sysupdate/backend_flatpak_test.go | 137 +++++ .../server/sysupdate/backend_pacman.go | 232 +++++++++ .../server/sysupdate/backend_pacman_test.go | 114 ++++ .../server/sysupdate/backend_rpmostree.go | 125 +++++ .../sysupdate/backend_rpmostree_test.go | 104 ++++ .../server/sysupdate/backend_zypper.go | 78 +++ .../server/sysupdate/backend_zypper_test.go | 80 +++ core/internal/server/sysupdate/executor.go | 117 +++++ core/internal/server/sysupdate/handlers.go | 55 ++ core/internal/server/sysupdate/manager.go | 493 ++++++++++++++++++ core/internal/server/sysupdate/types.go | 84 +++ quickshell/Common/SessionData.qml | 27 + quickshell/Common/SettingsData.qml | 3 + quickshell/Common/settings/SessionSpec.js | 1 + quickshell/Common/settings/SettingsSpec.js | 3 + quickshell/DMSShell.qml | 7 +- .../Modals/DankLauncherV2/Controller.qml | 2 +- .../Modals/Settings/SettingsSidebar.qml | 5 +- .../DankBar/Popouts/SystemUpdatePopout.qml | 441 ++++++++++++++++ .../Modules/DankBar/Widgets/SystemUpdate.qml | 44 +- quickshell/Modules/Settings/AboutTab.qml | 10 +- quickshell/Modules/Settings/LauncherTab.qml | 2 + .../Modules/Settings/SystemUpdaterTab.qml | 130 ++++- .../Settings/Widgets/TerminalPickerRow.qml | 31 ++ quickshell/Modules/Settings/WidgetsTab.qml | 27 +- .../Modules/Settings/WidgetsTabSection.qml | 20 + quickshell/Modules/SystemUpdatePopout.qml | 329 ------------ quickshell/Services/DMSService.qml | 38 +- quickshell/Services/MuxService.qml | 2 +- quickshell/Services/PluginService.qml | 2 +- quickshell/Services/SessionService.qml | 2 +- quickshell/Services/ShellVersionService.qml | 134 +++++ quickshell/Services/SystemUpdateService.qml | 470 +++++------------ 43 files changed, 3556 insertions(+), 710 deletions(-) create mode 100644 core/cmd/dms/commands_system.go create mode 100644 core/internal/server/sysupdate/backend.go create mode 100644 core/internal/server/sysupdate/backend_apt.go create mode 100644 core/internal/server/sysupdate/backend_apt_test.go create mode 100644 core/internal/server/sysupdate/backend_dnf.go create mode 100644 core/internal/server/sysupdate/backend_dnf_test.go create mode 100644 core/internal/server/sysupdate/backend_flatpak.go create mode 100644 core/internal/server/sysupdate/backend_flatpak_test.go create mode 100644 core/internal/server/sysupdate/backend_pacman.go create mode 100644 core/internal/server/sysupdate/backend_pacman_test.go create mode 100644 core/internal/server/sysupdate/backend_rpmostree.go create mode 100644 core/internal/server/sysupdate/backend_rpmostree_test.go create mode 100644 core/internal/server/sysupdate/backend_zypper.go create mode 100644 core/internal/server/sysupdate/backend_zypper_test.go create mode 100644 core/internal/server/sysupdate/executor.go create mode 100644 core/internal/server/sysupdate/handlers.go create mode 100644 core/internal/server/sysupdate/manager.go create mode 100644 core/internal/server/sysupdate/types.go create mode 100644 quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml create mode 100644 quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml delete mode 100644 quickshell/Modules/SystemUpdatePopout.qml create mode 100644 quickshell/Services/ShellVersionService.qml 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; - } - } }