diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 512640358..aa1890537 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -9,6 +9,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/shellembed" "github.com/spf13/cobra" ) @@ -217,21 +218,17 @@ func runVersion(cmd *cobra.Command, args []string) { // Git builds: dms (git) v0.6.2-XXXX // Stable releases: dms v0.6.2 func formatVersion(version string) string { - // Arch/Debian/Ubuntu/OpenSUSE git format: 0.6.2+git2264.c5c5ce84 - re := regexp.MustCompile(`^([\d.]+)\+git(\d+)\.`) - if matches := re.FindStringSubmatch(version); matches != nil { - return fmt.Sprintf("dms (git) v%s-%s", matches[1], matches[2]) - } - - // Fedora COPR git format: 0.0.git.2267.d430cae9 - re = regexp.MustCompile(`^[\d.]+\.git\.(\d+)\.`) - if matches := re.FindStringSubmatch(version); matches != nil { - baseVersion := getBaseVersion() - return fmt.Sprintf("dms (git) v%s-%s", baseVersion, matches[1]) + if n := sysupdate.GitBuildCount(version); n > 0 { + // Fedora COPR (0.0.git.2267.d430cae9) carries no real base version. + base, _, ok := strings.Cut(version, "+git") + if !ok { + base = getBaseVersion() + } + return fmt.Sprintf("dms (git) v%s-%d", strings.TrimPrefix(base, "v"), n) } // Stable release format: 0.6.2 - re = regexp.MustCompile(`^([\d.]+)$`) + re := regexp.MustCompile(`^([\d.]+)$`) if matches := re.FindStringSubmatch(version); matches != nil { return fmt.Sprintf("dms v%s", matches[1]) } diff --git a/core/cmd/dms/commands_features.go b/core/cmd/dms/commands_features.go index ac8aa27e3..d4a45d237 100644 --- a/core/cmd/dms/commands_features.go +++ b/core/cmd/dms/commands_features.go @@ -18,6 +18,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch" "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/spf13/cobra" @@ -46,6 +47,16 @@ func runUpdateCheck() { fmt.Println("Checking for DankMaterialShell updates...") fmt.Println() + shell := sysupdate.ProbeShell(context.Background(), Version) + switch shell.InstallMethod { + case sysupdate.InstallPacman, sysupdate.InstallRPM, sysupdate.InstallDpkg, sysupdate.InstallXbps: + fmt.Printf("Installed as package %s (%s); update it with your package manager.\n", shell.PackageName, shell.InstallMethod) + os.Exit(0) + case sysupdate.InstallNix: + fmt.Println("Managed by Nix; update through your flake or channel.") + os.Exit(0) + } + versionInfo, err := version.GetDMSVersionInfo() if err != nil { log.Fatalf("Error checking for updates: %v", err) @@ -182,12 +193,8 @@ func updateOtherDistros() error { } dmsPath := filepath.Join(homeDir, ".config", "quickshell", "dms") - - if _, err := os.Stat(dmsPath); os.IsNotExist(err) { - return fmt.Errorf("DMS configuration directory not found at %s", dmsPath) - } - - fmt.Printf("Found DMS configuration at %s\n", dmsPath) + _, statErr := os.Stat(dmsPath) + hasClone := statErr == nil versionInfo, err := version.GetDMSVersionInfo() if err == nil && !versionInfo.HasUpdate { @@ -199,6 +206,22 @@ func updateOtherDistros() error { return errdefs.ErrNoUpdateNeeded } + // Release binaries embed the shell; without a clone the binary is the whole install. + if !hasClone { + fmt.Println("\nThis will update the dms binary from GitHub releases.") + if !confirmUpdate() { + return errdefs.ErrUpdateCancelled + } + fmt.Println("\n=== Updating dms binary ===") + if err := updateDMSBinary(); err != nil { + return fmt.Errorf("failed to update dms binary: %w", err) + } + fmt.Println("dms binary successfully updated") + return nil + } + + fmt.Printf("Found DMS configuration at %s\n", dmsPath) + fmt.Println("\nThis will update:") fmt.Println(" 1. The dms binary from GitHub releases") fmt.Println(" 2. DankMaterialShell configuration using git") diff --git a/core/internal/netfetch/netfetch.go b/core/internal/netfetch/netfetch.go index 08dab5b24..dd191e287 100644 --- a/core/internal/netfetch/netfetch.go +++ b/core/internal/netfetch/netfetch.go @@ -21,6 +21,8 @@ type Options struct { ConnectTimeout time.Duration IPv4Only bool CheckRedirect func(req *http.Request, via []*http.Request) error + // 0 = unlimited; only BytesConditional honours it. + MaxBytes int64 } type StatusError struct { @@ -177,3 +179,48 @@ func ToFile(ctx context.Context, url string, opts Options, path string) error { } return nil } + +type Conditional struct { + Body []byte + ETag string + NotModified bool +} + +func BytesConditional(ctx context.Context, url, etag string, opts Options) (Conditional, error) { + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + req, err := opts.request(ctx, url) + if err != nil { + return Conditional{}, err + } + if etag != "" { + req.Header.Set("If-None-Match", etag) + } + resp, err := opts.client().Do(req) + if err != nil { + return Conditional{}, fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotModified { + return Conditional{ETag: etag, NotModified: true}, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Conditional{}, &StatusError{Code: resp.StatusCode} + } + var r io.Reader = resp.Body + if opts.MaxBytes > 0 { + r = io.LimitReader(resp.Body, opts.MaxBytes+1) + } + body, err := io.ReadAll(r) + if err != nil { + return Conditional{}, err + } + if opts.MaxBytes > 0 && int64(len(body)) > opts.MaxBytes { + return Conditional{}, fmt.Errorf("response exceeds %d bytes", opts.MaxBytes) + } + return Conditional{Body: body, ETag: resp.Header.Get("ETag")}, nil +} diff --git a/core/internal/netfetch/netfetch_test.go b/core/internal/netfetch/netfetch_test.go index 914120257..23d4105ed 100644 --- a/core/internal/netfetch/netfetch_test.go +++ b/core/internal/netfetch/netfetch_test.go @@ -41,3 +41,17 @@ func TestRepeatedFetchesReuseOneConnection(t *testing.T) { require.Equal(t, conns[0], conns[1]) require.Equal(t, conns[0], conns[2]) } + +func TestBytesConditionalMaxBytes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(make([]byte, 2048)) + })) + defer server.Close() + + _, err := BytesConditional(context.Background(), server.URL, "", Options{Timeout: 5 * time.Second, MaxBytes: 1024}) + require.Error(t, err) + + res, err := BytesConditional(context.Background(), server.URL, "", Options{Timeout: 5 * time.Second, MaxBytes: 4096}) + require.NoError(t, err) + require.Len(t, res.Body, 2048) +} diff --git a/core/internal/notify/notify.go b/core/internal/notify/notify.go index 56abf27eb..8bcd38a97 100644 --- a/core/internal/notify/notify.go +++ b/core/internal/notify/notify.go @@ -35,6 +35,8 @@ type Notification struct { Body string FilePath string Timeout int32 + // Pairs of id, label; ignored when FilePath is set. + Actions []string } func Send(n Notification) (uint32, error) { @@ -53,20 +55,19 @@ func Send(n Notification) (uint32, error) { n.Timeout = 5000 } - if len(n.Summary) > maxSummaryLen { - n.Summary = n.Summary[:maxSummaryLen-3] + "..." - } - if len(n.Body) > maxBodyLen { - n.Body = n.Body[:maxBodyLen-3] + "..." - } + n.Summary = truncate(n.Summary, maxSummaryLen) + n.Body = truncate(n.Body, maxBodyLen) - var actions []string + actions := n.Actions if n.FilePath != "" { actions = []string{ "open", "Open", "folder", "Open Folder", } } + if actions == nil { + actions = []string{} + } hints := map[string]dbus.Variant{} if n.AppName == appName { @@ -200,3 +201,12 @@ func openPath(path string) { } cmd.Start() } + +// Cuts on rune boundaries; a byte slice mid-character makes the dbus encoder reject the string. +func truncate(s string, maxRunes int) string { + r := []rune(s) + if len(r) <= maxRunes { + return s + } + return string(r[:maxRunes-3]) + "..." +} diff --git a/core/internal/server/notifyactions/handlers.go b/core/internal/server/notifyactions/handlers.go index d65648453..82f833370 100644 --- a/core/internal/server/notifyactions/handlers.go +++ b/core/internal/server/notifyactions/handlers.go @@ -1,14 +1,20 @@ package notifyactions import ( + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/dankgo/ipc" + "github.com/AvengeMedia/dankgo/ipc/params" ) func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { switch req.Method { case "notify.watchAction": handleWatchAction(conn, req, manager) + case "notify.send": + handleSend(conn, req, manager) default: models.RespondError(conn, req.ID, "unknown method") } @@ -28,3 +34,51 @@ func handleWatchAction(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) manager.Watch(uint32(id), path) models.Respond(conn, req.ID, "ok") } + +// The shell can't post its own: notify-send blocks until the notification closes, and the shell is the server. +// Only the arguments come from the client; the executable is always this dms binary. +func handleSend(conn *ipc.ConnWriter, req ipc.Request, manager *Manager) { + summary, ok := models.Get[string](req, "summary") + if !ok || summary == "" { + models.RespondError(conn, req.ID, "invalid summary parameter") + return + } + n := notify.Notification{ + Summary: summary, + Body: params.StringOpt(req.Params, "body", ""), + Icon: params.StringOpt(req.Params, "icon", ""), + } + label := params.StringOpt(req.Params, "actionLabel", "") + var argv []string + if args := stringSlice(req.Params["actionArgs"]); label != "" && len(args) > 0 { + exe, err := os.Executable() + if err != nil { + exe = "dms" + } + argv = append([]string{exe}, args...) + n.Actions = []string{"default", label} + } + id, err := notify.Send(n) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + if len(n.Actions) > 0 && id != 0 { + manager.WatchCommand(id, argv) + } + models.Respond(conn, req.ID, map[string]any{"id": id}) +} + +func stringSlice(v any) []string { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out +} diff --git a/core/internal/server/notifyactions/manager.go b/core/internal/server/notifyactions/manager.go index 289c1313e..5d1dc6d7e 100644 --- a/core/internal/server/notifyactions/manager.go +++ b/core/internal/server/notifyactions/manager.go @@ -12,17 +12,25 @@ import ( ) const ( - notifyPath = "/org/freedesktop/Notifications" - notifyInterface = "org.freedesktop.Notifications" - actionInvoked = notifyInterface + ".ActionInvoked" - closed = notifyInterface + ".NotificationClosed" + notifyPath = "/org/freedesktop/Notifications" + notifyInterface = "org.freedesktop.Notifications" + actionInvoked = notifyInterface + ".ActionInvoked" + closed = notifyInterface + ".NotificationClosed" + dbusInterface = "org.freedesktop.DBus" + nameOwnerChanged = "NameOwnerChanged" + ownerChanged = dbusInterface + "." + nameOwnerChanged ) +type watched struct { + path string + command []string +} + type Manager struct { conn *dbus.Conn signals chan *dbus.Signal mu sync.Mutex - watched map[uint32]string + watched map[uint32]watched } func NewManager() (*Manager, error) { @@ -33,10 +41,14 @@ func NewManager() (*Manager, error) { if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(notifyPath), dbus.WithMatchInterface(notifyInterface)); err != nil { return nil, err } + // A restarted notification server reuses ids without sending NotificationClosed. + if err := conn.AddMatchSignal(dbus.WithMatchInterface(dbusInterface), dbus.WithMatchMember(nameOwnerChanged), dbus.WithMatchArg(0, notifyInterface)); err != nil { + return nil, err + } m := &Manager{ conn: conn, signals: make(chan *dbus.Signal, 32), - watched: make(map[uint32]string), + watched: make(map[uint32]watched), } conn.Signal(m.signals) go m.loop() @@ -45,13 +57,20 @@ func NewManager() (*Manager, error) { func (m *Manager) Watch(id uint32, path string) { m.mu.Lock() - m.watched[id] = path + m.watched[id] = watched{path: path} + m.mu.Unlock() +} + +func (m *Manager) WatchCommand(id uint32, argv []string) { + m.mu.Lock() + m.watched[id] = watched{command: argv} m.mu.Unlock() } func (m *Manager) Close() { m.conn.RemoveSignal(m.signals) _ = m.conn.RemoveMatchSignal(dbus.WithMatchObjectPath(notifyPath), dbus.WithMatchInterface(notifyInterface)) + _ = m.conn.RemoveMatchSignal(dbus.WithMatchInterface(dbusInterface), dbus.WithMatchMember(nameOwnerChanged), dbus.WithMatchArg(0, notifyInterface)) close(m.signals) } @@ -62,26 +81,34 @@ func (m *Manager) loop() { m.handleAction(sig) case closed: m.forget(sig) + case ownerChanged: + // The shared bus connection also delivers other managers' NameOwnerChanged matches. + if len(sig.Body) < 1 || sig.Body[0] != notifyInterface { + continue + } + m.mu.Lock() + clear(m.watched) + m.mu.Unlock() } } } -func (m *Manager) take(sig *dbus.Signal) (string, bool) { +func (m *Manager) take(sig *dbus.Signal) (watched, bool) { if len(sig.Body) < 1 { - return "", false + return watched{}, false } id, ok := sig.Body[0].(uint32) if !ok { - return "", false + return watched{}, false } m.mu.Lock() defer m.mu.Unlock() - path, ok := m.watched[id] + w, ok := m.watched[id] if !ok { - return "", false + return watched{}, false } delete(m.watched, id) - return path, true + return w, true } func (m *Manager) forget(sig *dbus.Signal) { @@ -96,21 +123,28 @@ func (m *Manager) handleAction(sig *dbus.Signal) { if !ok { return } - path, ok := m.take(sig) + w, ok := m.take(sig) if !ok { return } + if len(w.command) > 0 { + if action == "default" { + run(w.command) + } + return + } + path := w.path if action == "folder" { path = filepath.Dir(path) } - openPath(path) + run([]string{"xdg-open", path}) } -func openPath(path string) { - cmd := exec.Command("xdg-open", path) +func run(argv []string) { + cmd := exec.Command(argv[0], argv[1:]...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { - log.Warnf("notifyactions: xdg-open %s: %v", path, err) + log.Warnf("notifyactions: %v: %v", argv, err) return } go func() { _ = cmd.Wait() }() diff --git a/core/internal/server/notifyactions/manager_test.go b/core/internal/server/notifyactions/manager_test.go new file mode 100644 index 000000000..58eb28d2d --- /dev/null +++ b/core/internal/server/notifyactions/manager_test.go @@ -0,0 +1,42 @@ +package notifyactions + +import ( + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestUnrelatedOwnerChangeKeepsWatchers(t *testing.T) { + m := &Manager{signals: make(chan *dbus.Signal), watched: map[uint32]watched{7: {path: "/tmp/x"}}} + done := make(chan struct{}) + go func() { + m.loop() + close(done) + }() + + m.signals <- &dbus.Signal{Name: ownerChanged, Body: []any{"org.example.Other", ":1.2", ":1.3"}} + m.signals <- &dbus.Signal{Name: ownerChanged, Body: []any{notifyInterface, ":1.4", ":1.5"}} + close(m.signals) + <-done + + if _, ok := m.watched[7]; ok { + t.Fatal("notification server restart did not clear watchers") + } +} + +func TestUnrelatedOwnerChangeAlone(t *testing.T) { + m := &Manager{signals: make(chan *dbus.Signal), watched: map[uint32]watched{7: {path: "/tmp/x"}}} + done := make(chan struct{}) + go func() { + m.loop() + close(done) + }() + + m.signals <- &dbus.Signal{Name: ownerChanged, Body: []any{"org.example.Other", ":1.2", ":1.3"}} + close(m.signals) + <-done + + if _, ok := m.watched[7]; !ok { + t.Fatal("an unrelated NameOwnerChanged cleared the watchers") + } +} diff --git a/core/internal/server/server.go b/core/internal/server/server.go index efe653e38..e1359eae4 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -38,7 +38,7 @@ import ( "github.com/AvengeMedia/dankgo/syncmap" ) -const APIVersion = 35 +const APIVersion = 36 var CLIVersion = "dev" @@ -373,7 +373,7 @@ func InitializeNotifyActionsManager() error { } func InitializeSysUpdateManager() error { - manager, err := sysupdate.NewManager() + manager, err := sysupdate.NewManager(CLIVersion) if err != nil { log.Warnf("Failed to initialize sysupdate manager: %v", err) return err diff --git a/core/internal/server/sysupdate/backend_xbps.go b/core/internal/server/sysupdate/backend_xbps.go new file mode 100644 index 000000000..7381a93fd --- /dev/null +++ b/core/internal/server/sysupdate/backend_xbps.go @@ -0,0 +1,140 @@ +package sysupdate + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "syscall" +) + +func init() { + RegisterSystemBackend(func() Backend { return &xbpsBackend{} }) +} + +type xbpsBackend struct{} + +func (xbpsBackend) ID() string { return "xbps" } +func (xbpsBackend) DisplayName() string { return "XBPS" } +func (xbpsBackend) Repo() RepoKind { return RepoSystem } +func (xbpsBackend) NeedsAuth() bool { return true } +func (xbpsBackend) RunsInTerminal() bool { return false } +func (xbpsBackend) IsAvailable(_ context.Context) bool { return commandExists("xbps-install") } + +func (b xbpsBackend) CheckUpdates(ctx context.Context) ([]Package, error) { + // -M syncs repodata into memory only, so the check is fresh without root + out, err := exec.CommandContext(ctx, "xbps-install", "-Mun").Output() + if err != nil { + // EBUSY: xbps refuses a full update while xbps itself is outdated + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || exitErr.ExitCode() != int(syscall.EBUSY) { + return nil, err + } + if out, err = exec.CommandContext(ctx, "xbps-install", "-Mun", "xbps").Output(); err != nil { + return nil, err + } + } + installed, err := exec.CommandContext(ctx, "xbps-query", "-l").Output() + if err != nil { + return nil, err + } + return parseXbpsDryRun(string(out), parseXbpsInstalled(string(installed))), nil +} + +func (xbpsBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + if opts.DryRun { + return Run(ctx, []string{"xbps-install", "-Mun"}, RunOptions{OnLine: onLine}) + } + if !BackendHasTargets(xbpsBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return nil + } + return Run(ctx, xbpsUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func xbpsUpgradeArgv(opts UpgradeOptions) []string { + ignored := shellSafeNames(opts.Ignored) + selfUpdate := xbpsSelfUpdatePending(opts.Targets) + if len(ignored) == 0 && !selfUpdate { + return privilegedArgv(opts, "xbps-install", "-Syu", "-y") + } + return privilegedArgv(opts, "sh", "-c", xbpsUpgradeScript(ignored, selfUpdate)) +} + +func xbpsSelfUpdatePending(targets []Package) bool { + for _, p := range targets { + if p.Backend == "xbps" && p.Name == "xbps" { + return true + } + } + return false +} + +// One script = one auth prompt: xbps updates itself first (Void requires it); ignored packages are +// held only for this run, pre-existing user holds stay. +func xbpsUpgradeScript(ignored []string, selfUpdate bool) string { + var sb strings.Builder + if len(ignored) > 0 { + fmt.Fprintf(&sb, + `new=""; for p in %s; do xbps-query -H | sed 's/-[^-]*$//' | grep -qxF "$p" || new="$new $p"; done; `+ + `[ -n "$new" ] && xbps-pkgdb -m hold $new; `, + strings.Join(ignored, " ")) + } + if selfUpdate { + sb.WriteString(`xbps-install -Syu -y xbps && xbps-install -u -y; rc=$?; `) + } else { + sb.WriteString(`xbps-install -Syu -y; rc=$?; `) + } + if len(ignored) > 0 { + sb.WriteString(`[ -n "$new" ] && xbps-pkgdb -m unhold $new; `) + } + sb.WriteString(`exit $rc`) + return sb.String() +} + +// pkgver is "name-version_revision"; names may contain '-', versions never do. +func splitPkgver(pkgver string) (name, version string, ok bool) { + idx := strings.LastIndex(pkgver, "-") + if idx <= 0 || idx == len(pkgver)-1 { + return "", "", false + } + return pkgver[:idx], pkgver[idx+1:], true +} + +// `xbps-query -l`: " " +func parseXbpsInstalled(text string) map[string]string { + installed := make(map[string]string) + for line := range strings.SplitSeq(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + if name, version, ok := splitPkgver(fields[1]); ok { + installed[name] = version + } + } + return installed +} + +// `xbps-install -n`: " " +func parseXbpsDryRun(text string, installed map[string]string) []Package { + var pkgs []Package + for line := range strings.SplitSeq(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || fields[1] != "update" { + continue + } + name, version, ok := splitPkgver(fields[0]) + if !ok { + continue + } + pkgs = append(pkgs, Package{ + Name: name, + Repo: RepoSystem, + Backend: "xbps", + FromVersion: installed[name], + ToVersion: version, + }) + } + return pkgs +} diff --git a/core/internal/server/sysupdate/backend_xbps_test.go b/core/internal/server/sysupdate/backend_xbps_test.go new file mode 100644 index 000000000..ba478a2bd --- /dev/null +++ b/core/internal/server/sysupdate/backend_xbps_test.go @@ -0,0 +1,90 @@ +package sysupdate + +import ( + "reflect" + "testing" +) + +func TestParseXbpsInstalled(t *testing.T) { + tests := []struct { + name string + input string + want map[string]string + }{ + { + name: "empty", + input: "", + want: map[string]string{}, + }, + { + name: "splits on the last dash so hyphenated names survive", + input: `ii bash-5.2.21_1 GNU Bourne Again Shell +ii xorg-server-xwayland-24.1.2_1 Nested X server that runs as a wayland client +ii dms-shell-git-1.7.0+git4915_1 DankMaterialShell +`, + want: map[string]string{ + "bash": "5.2.21_1", + "xorg-server-xwayland": "24.1.2_1", + "dms-shell-git": "1.7.0+git4915_1", + }, + }, + { + name: "skips lines without a pkgver", + input: "ii\nii nodash\n\nuu glibc-2.39_2 GNU C library\n", + want: map[string]string{"glibc": "2.39_2"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseXbpsInstalled(tt.input) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestParseXbpsDryRun(t *testing.T) { + installed := map[string]string{ + "xorg-server-xwayland": "24.1.1_1", + "bash": "5.2.21_1", + } + tests := []struct { + name string + input string + want []Package + }{ + { + name: "no updates", + input: "", + want: nil, + }, + { + name: "updates only, installs and removals ignored", + input: `xorg-server-xwayland-24.1.2_1 update x86_64 https://repo-default.voidlinux.org/current 2461696 912345 +bash-5.2.32_1 update x86_64 https://repo-default.voidlinux.org/current 9043968 1602832 +libnew-1.0_1 install x86_64 https://repo-default.voidlinux.org/current 102400 40960 +oldlib-0.9_3 remove x86_64 https://repo-default.voidlinux.org/current 0 0 +`, + want: []Package{ + {Name: "xorg-server-xwayland", Repo: RepoSystem, Backend: "xbps", FromVersion: "24.1.1_1", ToVersion: "24.1.2_1"}, + {Name: "bash", Repo: RepoSystem, Backend: "xbps", FromVersion: "5.2.21_1", ToVersion: "5.2.32_1"}, + }, + }, + { + name: "unknown installed version stays empty", + input: "xbps-0.60.3_1 update x86_64 https://repo-default.voidlinux.org/current 1638400 409600\n", + want: []Package{ + {Name: "xbps", Repo: RepoSystem, Backend: "xbps", FromVersion: "", ToVersion: "0.60.3_1"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseXbpsDryRun(tt.input, installed) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %#v, want %#v", got, tt.want) + } + }) + } +} diff --git a/core/internal/server/sysupdate/handlers.go b/core/internal/server/sysupdate/handlers.go index e234f6322..6019e7715 100644 --- a/core/internal/server/sysupdate/handlers.go +++ b/core/internal/server/sysupdate/handlers.go @@ -12,7 +12,8 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { models.Respond(conn, req.ID, m.GetState()) case "sysupdate.refresh": force := params.BoolOpt(req.Params, "force", false) - m.Refresh(RefreshOptions{Force: force}) + background := params.BoolOpt(req.Params, "background", false) + m.Refresh(RefreshOptions{Force: force, Background: background}) models.Respond(conn, req.ID, m.GetState()) case "sysupdate.upgrade": handleUpgrade(conn, req, m) @@ -25,6 +26,9 @@ func HandleRequest(conn *ipc.ConnWriter, req ipc.Request, m *Manager) { case "sysupdate.release": m.Release() models.Respond(conn, req.ID, models.SuccessResult{Success: true}) + case "sysupdate.releases": + force := params.BoolOpt(req.Params, "force", false) + models.Respond(conn, req.ID, m.Releases(force)) case "sysupdate.setInterval": seconds, err := params.Int(req.Params, "seconds") if err != nil { diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go index 1468e6c8f..dfa12064a 100644 --- a/core/internal/server/sysupdate/manager.go +++ b/core/internal/server/sysupdate/manager.go @@ -16,15 +16,18 @@ import ( ) const ( - defaultIntervalSeconds = 30 * 60 - minIntervalSeconds = 5 * 60 - recentLogCapacity = 200 - checkTimeout = 5 * time.Minute - retryIntervalSeconds = 5 * 60 - upgradeTimeout = 30 * time.Minute - postUpgradeCompleteDelay = 3 * time.Second + defaultIntervalSeconds = 24 * 60 * 60 + minIntervalSeconds = 5 * 60 + startupGraceSeconds = 2 * 60 + recentLogCapacity = 200 + checkTimeout = 5 * time.Minute + retryIntervalSeconds = 5 * 60 + upgradeTimeout = 30 * time.Minute ) +// A var so tests can skip the linger. +var postUpgradeCompleteDelay = 3 * time.Second + type Manager struct { mu sync.RWMutex state State @@ -41,23 +44,41 @@ type Manager struct { wakeSched chan struct{} refreshSerial sync.Mutex + releasesMu sync.Mutex opMu sync.Mutex opCtx context.Context opCancel context.CancelFunc } -func NewManager() (*Manager, error) { +func NewManager(runningVersion string) (*Manager, error) { m := &Manager{ notifyDirty: make(chan struct{}, 1), stopChan: make(chan struct{}), wakeSched: make(chan struct{}, 1), } + persisted := loadPersisted() + if persisted.Packages == nil { + persisted.Packages = []Package{} + } m.state = State{ Phase: PhaseIdle, IntervalSeconds: defaultIntervalSeconds, Backends: []BackendInfo{}, - Packages: []Package{}, + Packages: persisted.Packages, + Count: len(persisted.Packages), + LastCheckUnix: persisted.LastCheckUnix, + LastSuccessUnix: persisted.LastSuccessUnix, + Shell: ShellInfo{InstallMethod: InstallUnknown, Channel: ChannelUnknown, Running: runningVersion}, + } + if persisted.RebootBootID != "" && persisted.RebootBootID == bootID() { + m.state.Reboot = RebootInfo{Recommended: true, Packages: persisted.RebootPackages} + } + if feed, ok := LoadReleases(); ok { + m.state.ReleasesFetchedUnix = feed.FetchedAt + if feed.Latest != nil { + m.state.Shell.LatestVersion = feed.Latest.Version + } } id, pretty := readOSRelease() @@ -70,7 +91,7 @@ func NewManager() (*Manager, error) { m.state.Error = &ErrorInfo{ Code: ErrCodeNoBackend, Message: "no supported package manager found", - Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak", + Hint: "install a supported package manager (pacman, dnf, apt, zypper, xbps) or flatpak", } } @@ -80,9 +101,48 @@ func NewManager() (*Manager, error) { m.schedulerWG.Add(1) go m.scheduler() + go m.probeShell() + return m, nil } +// Off the startup path: the package-owner query can take a moment. +func (m *Manager) probeShell() { + m.mu.RLock() + running := m.state.Shell.Running + m.mu.RUnlock() + + info := ProbeShell(context.Background(), running) + + feed, _ := LoadReleases() + m.mu.Lock() + info.LatestVersion = m.state.Shell.LatestVersion + info.UpdatePackage = findShellPackage(m.state.Packages, info.PackageName) + info.CommitsBehind = commitsBehind(info, feed.Master) + m.state.Shell = info + m.mu.Unlock() + m.markDirty() +} + +func (m *Manager) Releases(force bool) ReleasesFeed { + feed, err := m.refreshReleases(context.Background(), force) + if err != nil { + log.Debugf("[sysupdate] releases feed: %v", err) + } + return feed +} + +// Scheduled from the persisted last check so shell restarts (which re-send the interval) don't push a daily cadence out. +func (m *Manager) nextCheckLocked() int64 { + now := time.Now().Unix() + interval := int64(m.state.IntervalSeconds) + // A failed last check keeps its short retry across restarts. + if m.state.LastSuccessUnix < m.state.LastCheckUnix { + interval = min(interval, retryIntervalSeconds) + } + return max(m.state.LastCheckUnix+interval, now+startupGraceSeconds) +} + func (m *Manager) GetState() State { m.mu.RLock() defer m.mu.RUnlock() @@ -132,7 +192,7 @@ func (m *Manager) SetInterval(seconds int) { } m.mu.Lock() m.state.IntervalSeconds = seconds - m.state.NextCheckUnix = time.Now().Unix() + int64(seconds) + m.state.NextCheckUnix = m.nextCheckLocked() m.mu.Unlock() m.wake() m.markDirty() @@ -151,7 +211,7 @@ func (m *Manager) Refresh(opts RefreshOptions) { m.refreshSerial.Unlock() return } - m.runRefresh(context.Background(), true) + m.runRefresh(context.Background(), !opts.Background) } func (m *Manager) Upgrade(opts UpgradeOptions) error { @@ -187,7 +247,7 @@ func (m *Manager) Acquire() { atomic.AddInt32(&m.acquireCount, 1) m.mu.Lock() if m.state.NextCheckUnix == 0 { - m.state.NextCheckUnix = time.Now().Unix() + int64(m.state.IntervalSeconds) + m.state.NextCheckUnix = m.nextCheckLocked() } m.mu.Unlock() m.wake() @@ -247,16 +307,28 @@ func (m *Manager) runRefresh(parent context.Context, manual bool) { m.refreshSerial.Lock() defer m.refreshSerial.Unlock() - if len(m.selection.All()) == 0 { - return - } - ctx, cancel := context.WithTimeout(parent, checkTimeout) defer cancel() + // No package manager: still refresh the feed, and move the deadline or the scheduler spins. + if len(m.selection.All()) == 0 { + if _, err := m.refreshReleases(ctx, manual); err != nil { + log.Debugf("[sysupdate] releases feed: %v", err) + } + m.mu.Lock() + m.state.NextCheckUnix = time.Now().Unix() + int64(m.state.IntervalSeconds) + m.mu.Unlock() + m.wake() + m.markDirty() + return + } + m.mu.Lock() if m.state.Phase == PhaseUpgrading { + // Same spin hazard mid-upgrade. + m.state.NextCheckUnix = time.Now().Unix() + retryIntervalSeconds m.mu.Unlock() + m.wake() return } m.state.Phase = PhaseRefreshing @@ -280,9 +352,17 @@ func (m *Manager) runRefresh(parent context.Context, manual bool) { results[i] = backendResult{pkgs: pkgs, err: err} }(i, b) } + wg.Add(1) + go func() { + defer wg.Done() + if _, err := m.refreshReleases(ctx, manual); err != nil { + log.Debugf("[sysupdate] releases feed: %v", err) + } + }() wg.Wait() now := time.Now().Unix() + replaced := binaryReplaced(dmsBinaryPath()) m.mu.Lock() m.state.LastCheckUnix = now prev := m.state.Packages @@ -306,6 +386,10 @@ func (m *Manager) runRefresh(parent context.Context, manual bool) { m.state.Packages = next m.state.Count = len(next) m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds) + m.state.Shell.UpdatePackage = findShellPackage(next, m.state.Shell.PackageName) + // An upgrade outside DMS changes the installed version only the probe knows. + reprobe := replaced && !m.state.Shell.RestartPending + m.state.Shell.RestartPending = replaced switch { case firstErr == nil: m.state.Phase = PhaseIdle @@ -320,7 +404,12 @@ func (m *Manager) runRefresh(parent context.Context, manual bool) { m.state.NextCheckUnix = now + retry log.Warnf("[sysupdate] background check failed, retrying in %ds: %v", retry, firstErr) } + persist := m.persistedLocked() m.mu.Unlock() + savePersisted(persist) + if reprobe { + go m.probeShell() + } m.wake() m.markDirty() } @@ -389,7 +478,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) { } } - m.finishSuccessfulUpgrade(true) + m.finishSuccessfulUpgrade(true, opts.Targets) } func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) { @@ -424,7 +513,10 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, opts UpgradeOptions) { return } - m.finishSuccessfulUpgrade(false) + m.mu.RLock() + installed := append([]Package(nil), m.state.Packages...) + m.mu.RUnlock() + m.finishSuccessfulUpgrade(false, installed) m.runRefresh(context.Background(), false) } @@ -436,7 +528,7 @@ func (m *Manager) failCustomUpgrade(code ErrorCode, err error) { m.markDirty() } -func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { +func (m *Manager) finishSuccessfulUpgrade(clearPackages bool, installed []Package) { m.appendLog("Upgrade complete.") timer := time.NewTimer(postUpgradeCompleteDelay) @@ -455,9 +547,25 @@ func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { if clearPackages { m.state.Packages = m.state.Packages[:0] m.state.Count = 0 + m.state.Shell.UpdatePackage = nil } + if names := rebootPackages(installed); len(names) > 0 { + m.state.Reboot = RebootInfo{Recommended: true, Packages: names} + } + persist := m.persistedLocked() m.mu.Unlock() + savePersisted(persist) m.markDirty() + go m.probeShell() +} + +func (m *Manager) persistedLocked() persistedState { + p := persistedState{LastCheckUnix: m.state.LastCheckUnix, LastSuccessUnix: m.state.LastSuccessUnix, Packages: m.state.Packages} + if m.state.Reboot.Recommended { + p.RebootBootID = bootID() + p.RebootPackages = m.state.Reboot.Packages + } + return p } func dropIgnoredTargets(targets []Package, ignored []string) []Package { @@ -553,10 +661,19 @@ func cloneState(s State) State { out.Backends = append([]BackendInfo(nil), s.Backends...) out.Packages = append([]Package(nil), s.Packages...) out.RecentLog = append([]string(nil), s.RecentLog...) + out.Reboot.Packages = append([]string(nil), s.Reboot.Packages...) if s.Error != nil { errCopy := *s.Error out.Error = &errCopy } + if s.Shell.UpdatePackage != nil { + pkg := *s.Shell.UpdatePackage + out.Shell.UpdatePackage = &pkg + } + if s.Shell.CommitsBehind != nil { + n := *s.Shell.CommitsBehind + out.Shell.CommitsBehind = &n + } return out } diff --git a/core/internal/server/sysupdate/reboot.go b/core/internal/server/sysupdate/reboot.go new file mode 100644 index 000000000..9e7e80852 --- /dev/null +++ b/core/internal/server/sysupdate/reboot.go @@ -0,0 +1,36 @@ +package sysupdate + +import ( + "os" + "regexp" + "strings" +) + +// Upgrades that only take effect after a reboot: kernels (linux*, kernel*, linux-image-*), systemd, glibc, firmware. +var rebootPackageRe = regexp.MustCompile(`^(linux(-[a-z0-9]+)*|linux-image-.*|kernel(-.*)?|systemd(-[a-z]+)?|glibc|libc6|linux-firmware|dracut|mesa)$`) + +type RebootInfo struct { + Recommended bool `json:"recommended"` + Packages []string `json:"packages,omitempty"` +} + +func rebootPackages(pkgs []Package) []string { + var out []string + for _, p := range pkgs { + if p.Repo == RepoFlatpak || p.Repo == RepoAUR { + continue + } + if rebootPackageRe.MatchString(strings.ToLower(p.Name)) { + out = append(out, p.Name) + } + } + return out +} + +func bootID() string { + data, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/core/internal/server/sysupdate/releases.go b/core/internal/server/sysupdate/releases.go new file mode 100644 index 000000000..3149c95a3 --- /dev/null +++ b/core/internal/server/sysupdate/releases.go @@ -0,0 +1,155 @@ +package sysupdate + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +const ( + releasesURL = "https://api.danklinux.com/dms/releases" + releasesTimeout = 10 * time.Second + // Checks may run every 15 min; the feed changes a few times a month and runs ~20 KB. + releasesMinAge = time.Hour + releasesMaxBytes = 1 << 20 +) + +var releasesFetch = func(ctx context.Context, etag string) (netfetch.Conditional, error) { + return netfetch.BytesConditional(ctx, releasesURL, etag, netfetch.Options{Timeout: releasesTimeout, MaxBytes: releasesMaxBytes}) +} + +func cacheDir() string { + return filepath.Join(utils.XDGCacheHome(), "dms") +} + +func releasesCachePath() string { + return filepath.Join(cacheDir(), "releases.json") +} + +// The feed never lives in Manager memory; every reader goes through the cache file. +func LoadReleases() (ReleasesFeed, bool) { + data, err := os.ReadFile(releasesCachePath()) + if err != nil { + return ReleasesFeed{}, false + } + var feed ReleasesFeed + if err := json.Unmarshal(data, &feed); err != nil { + return ReleasesFeed{}, false + } + if feed.Releases == nil { + feed.Releases = []Release{} + } + return feed, true +} + +func saveReleases(feed ReleasesFeed) error { + data, err := json.Marshal(feed) + if err != nil { + return err + } + return writeFileAtomic(releasesCachePath(), data) +} + +func writeFileAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// Serialized so a manual check and a page open don't fetch twice. +func (m *Manager) refreshReleases(ctx context.Context, force bool) (ReleasesFeed, error) { + m.releasesMu.Lock() + defer m.releasesMu.Unlock() + cached, ok := LoadReleases() + if ok && !force && time.Since(time.Unix(cached.FetchedAt, 0)) < releasesMinAge { + return cached, nil + } + + res, err := releasesFetch(ctx, cached.ETag) + if err != nil { + if ok { + return cached, err + } + return ReleasesFeed{Releases: []Release{}}, err + } + + now := time.Now().Unix() + feed := cached + if !res.NotModified { + feed = ReleasesFeed{} + if err := json.Unmarshal(res.Body, &feed); err != nil { + if ok { + return cached, err + } + return ReleasesFeed{Releases: []Release{}}, err + } + if feed.Releases == nil { + feed.Releases = []Release{} + } + } + feed.FetchedAt = now + feed.ETag = res.ETag + if err := saveReleases(feed); err != nil { + log.Warnf("[sysupdate] save releases cache: %v", err) + } + m.applyReleases(feed) + return feed, nil +} + +func (m *Manager) applyReleases(feed ReleasesFeed) { + m.mu.Lock() + m.state.ReleasesFetchedUnix = feed.FetchedAt + if feed.Latest != nil { + m.state.Shell.LatestVersion = feed.Latest.Version + } + m.state.Shell.CommitsBehind = commitsBehind(m.state.Shell, feed.Master) + m.mu.Unlock() + m.markDirty() +} + +// Packages ride along so a restart doesn't show an empty list until the next daily check. +type persistedState struct { + LastCheckUnix int64 `json:"lastCheckUnix"` + LastSuccessUnix int64 `json:"lastSuccessUnix"` + Packages []Package `json:"packages,omitempty"` + // Survives shell restarts, not the reboot itself. + RebootBootID string `json:"rebootBootId,omitempty"` + RebootPackages []string `json:"rebootPackages,omitempty"` +} + +func statePath() string { + return filepath.Join(cacheDir(), "sysupdate.json") +} + +func loadPersisted() persistedState { + var s persistedState + data, err := os.ReadFile(statePath()) + if err != nil { + return s + } + if err := json.Unmarshal(data, &s); err != nil { + return persistedState{} + } + return s +} + +func savePersisted(s persistedState) { + data, err := json.Marshal(s) + if err != nil { + return + } + if err := writeFileAtomic(statePath(), data); err != nil { + log.Debugf("[sysupdate] persist state: %v", err) + } +} diff --git a/core/internal/server/sysupdate/shell.go b/core/internal/server/sysupdate/shell.go new file mode 100644 index 000000000..e3290bf27 --- /dev/null +++ b/core/internal/server/sysupdate/shell.go @@ -0,0 +1,183 @@ +package sysupdate + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +const ownerQueryTimeout = 10 * time.Second + +var dmsPackageNames = map[string]bool{ + "dms": true, "dms-git": true, + "dms-shell": true, "dms-shell-git": true, "dms-shell-bin": true, +} + +// Arch/Debian/SUSE: 1.7.0+git4915.56234761; Fedora COPR: 0.0.git.4915.56234761 +var gitBuildRe = regexp.MustCompile(`\+git(\d+)\.|\.git\.(\d+)\.`) + +func GitBuildCount(version string) int { + m := gitBuildRe.FindStringSubmatch(version) + if m == nil { + return 0 + } + for _, s := range m[1:] { + if s != "" { + n, _ := strconv.Atoi(s) + return n + } + } + return 0 +} + +// "/usr/bin/dms (deleted)" after a package upgrade. +func dmsBinaryPath() string { + path, err := os.Executable() + if err != nil { + path, err = exec.LookPath("dms") + if err != nil { + return "" + } + } + path = strings.TrimSuffix(path, " (deleted)") + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} + +// Package managers unlink+create, so a different inode than /proc/self/exe means a new build. +func binaryReplaced(path string) bool { + if path == "" { + return false + } + running, err := os.Stat("/proc/self/exe") + if err != nil { + return false + } + onDisk, err := os.Stat(path) + if err != nil { + return false + } + return !os.SameFile(running, onDisk) +} + +// Unowned binaries (manual copy, dev checkout) stay unknown: DMS only updates through packages. +func ProbeShell(ctx context.Context, running string) ShellInfo { + info := ShellInfo{InstallMethod: InstallUnknown, Channel: ChannelUnknown, Running: running} + path := dmsBinaryPath() + + if strings.HasPrefix(path, "/nix/store/") { + info.InstallMethod = InstallNix + } else if method, name, version := packageOwner(ctx, path); method != "" { + info.InstallMethod = method + info.PackageName = name + info.Installed = version + } + + info.Channel = detectChannel(info, running) + info.GitBuild = GitBuildCount(running) + info.RestartPending = binaryReplaced(path) + return info +} + +func detectChannel(info ShellInfo, running string) Channel { + switch { + case strings.HasSuffix(info.PackageName, "-git"), GitBuildCount(running) > 0: + return ChannelGit + case info.InstallMethod == InstallUnknown, running == "", running == "dev": + return ChannelUnknown + default: + return ChannelStable + } +} + +// Only one query runs on a normal system, but rpm can take a moment to open its database. +func packageOwner(ctx context.Context, path string) (InstallMethod, string, string) { + if path == "" { + return "", "", "" + } + ctx, cancel := context.WithTimeout(ctx, ownerQueryTimeout) + defer cancel() + + switch { + case commandExists("pacman"): + // "/usr/bin/dms is owned by dms-shell-git 1.7.0+git4915-1" + out := runOutput(ctx, "pacman", "-Qo", path) + fields := strings.Fields(out) + if len(fields) < 2 { + return "", "", "" + } + return InstallPacman, fields[len(fields)-2], fields[len(fields)-1] + case commandExists("rpm"): + out := runOutput(ctx, "rpm", "-qf", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}`, path) + name, version, _ := strings.Cut(strings.TrimSpace(out), "\t") + if name == "" || strings.Contains(name, "not owned") { + return "", "", "" + } + return InstallRPM, name, version + case commandExists("dpkg-query"): + // "dms: /usr/bin/dms" + out := runOutput(ctx, "dpkg-query", "-S", path) + name, _, ok := strings.Cut(out, ":") + if !ok || name == "" { + return "", "", "" + } + name = strings.TrimSpace(name) + version := strings.TrimSpace(runOutput(ctx, "dpkg-query", "-W", "-f=${Version}", name)) + return InstallDpkg, name, version + case commandExists("xbps-query"): + // "dms-1.7_1: /usr/bin/dms (regular file)" + out := runOutput(ctx, "xbps-query", "-o", path) + pkgver, _, found := strings.Cut(out, ":") + name, version, ok := splitPkgver(pkgver) + if !found || !ok { + return "", "", "" + } + return InstallXbps, name, version + } + return "", "", "" +} + +func runOutput(ctx context.Context, name string, args ...string) string { + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + log.Debugf("[sysupdate] %s %v: %v", name, args, err) + return "" + } + return string(out) +} + +// Returns a copy: the packages slice is reused in place after upgrades. +func findShellPackage(pkgs []Package, ownerName string) *Package { + var fallback *Package + for i := range pkgs { + p := pkgs[i] + if ownerName != "" && p.Name == ownerName { + return &p + } + if fallback == nil && dmsPackageNames[p.Name] { + fallback = &p + } + } + return fallback +} + +func commitsBehind(shell ShellInfo, master *MasterInfo) *int { + if master == nil || shell.Channel != ChannelGit { + return nil + } + n := GitBuildCount(shell.Running) + if n == 0 { + return nil + } + behind := max(master.CommitCount-n, 0) + return &behind +} diff --git a/core/internal/server/sysupdate/shell_test.go b/core/internal/server/sysupdate/shell_test.go new file mode 100644 index 000000000..5ca74d9b5 --- /dev/null +++ b/core/internal/server/sysupdate/shell_test.go @@ -0,0 +1,324 @@ +package sysupdate + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/netfetch" +) + +func TestGitBuildCount(t *testing.T) { + for _, tt := range []struct { + version string + want int + }{ + {"1.7.0+git4915.56234761", 4915}, + {"0.0.git.4920.d430cae9", 4920}, + {"1.7.0", 0}, + {"dev", 0}, + } { + if got := GitBuildCount(tt.version); got != tt.want { + t.Errorf("GitBuildCount(%q) = %d, want %d", tt.version, got, tt.want) + } + } +} + +func TestPackageOwnerParsing(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + for _, tt := range []struct { + tool, body string + wantMethod InstallMethod + wantName string + wantVersion string + }{ + {"pacman", `echo "/usr/bin/dms is owned by dms-shell-git 1.7.0+git4915-1"`, InstallPacman, "dms-shell-git", "1.7.0+git4915-1"}, + {"rpm", `printf 'dms-git\t1.7.0-1.fc44'`, InstallRPM, "dms-git", "1.7.0-1.fc44"}, + {"xbps-query", `echo "dms-1.7.0_1: /usr/bin/dms (regular file)"`, InstallXbps, "dms", "1.7.0_1"}, + } { + t.Run(tt.tool, func(t *testing.T) { + writeUpdateExecutable(t, dir, tt.tool, tt.body) + defer os.Remove(filepath.Join(dir, tt.tool)) + method, name, version := packageOwner(context.Background(), "/usr/bin/dms") + if method != tt.wantMethod || name != tt.wantName || version != tt.wantVersion { + t.Errorf("packageOwner = (%s, %s, %s), want (%s, %s, %s)", method, name, version, tt.wantMethod, tt.wantName, tt.wantVersion) + } + }) + } +} + +func TestPackageOwnerDpkg(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + writeUpdateExecutable(t, dir, "dpkg-query", `case "$1" in +-S) echo "dms: /usr/bin/dms" ;; +-W) echo "1.7.0-1" ;; +esac`) + method, name, version := packageOwner(context.Background(), "/usr/bin/dms") + if method != InstallDpkg || name != "dms" || version != "1.7.0-1" { + t.Errorf("packageOwner = (%s, %s, %s)", method, name, version) + } +} + +func TestDetectChannel(t *testing.T) { + for _, tt := range []struct { + name string + info ShellInfo + running string + want Channel + }{ + {"git package", ShellInfo{InstallMethod: InstallPacman, PackageName: "dms-shell-git"}, "1.7.0", ChannelGit}, + {"git version", ShellInfo{InstallMethod: InstallRPM, PackageName: "dms"}, "0.0.git.4920.abc", ChannelGit}, + {"stable package", ShellInfo{InstallMethod: InstallPacman, PackageName: "dms-shell-bin"}, "1.7.0", ChannelStable}, + {"dev build", ShellInfo{InstallMethod: InstallPacman, PackageName: "dms-shell"}, "dev", ChannelUnknown}, + {"unknown method", ShellInfo{InstallMethod: InstallUnknown}, "1.7.0", ChannelUnknown}, + } { + if got := detectChannel(tt.info, tt.running); got != tt.want { + t.Errorf("%s: detectChannel = %s, want %s", tt.name, got, tt.want) + } + } +} + +func TestFindShellPackagePrefersOwner(t *testing.T) { + pkgs := []Package{{Name: "dms-shell"}, {Name: "dms-shell-git"}, {Name: "firefox"}} + if got := findShellPackage(pkgs, "dms-shell-git"); got == nil || got.Name != "dms-shell-git" { + t.Errorf("owner match = %v", got) + } + if got := findShellPackage(pkgs, ""); got == nil || got.Name != "dms-shell" { + t.Errorf("fallback = %v", got) + } + if got := findShellPackage([]Package{{Name: "firefox"}}, "dms"); got != nil { + t.Errorf("no dms package = %v", got) + } +} + +func TestCommitsBehind(t *testing.T) { + master := &MasterInfo{CommitCount: 4920} + if got := commitsBehind(ShellInfo{Channel: ChannelStable, Running: "1.7.0+git4915.abc"}, master); got != nil { + t.Errorf("stable channel = %d, want nil", *got) + } + if got := commitsBehind(ShellInfo{Channel: ChannelGit, Running: "1.7.0+git4915.abc"}, master); got == nil || *got != 5 { + t.Errorf("git channel = %v, want 5", got) + } + if got := commitsBehind(ShellInfo{Channel: ChannelGit, Running: "1.7.0+git4930.abc"}, master); got == nil || *got != 0 { + t.Errorf("ahead of feed = %v, want 0", got) + } +} + +func newTestManager(t *testing.T) *Manager { + t.Helper() + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + return &Manager{ + notifyDirty: make(chan struct{}, 1), + stopChan: make(chan struct{}), + state: State{Shell: ShellInfo{Channel: ChannelGit, Running: "1.7.0+git4915.abc"}}, + } +} + +func stubFeed(t *testing.T, res netfetch.Conditional, err error) *int { + t.Helper() + calls := 0 + orig := releasesFetch + releasesFetch = func(_ context.Context, etag string) (netfetch.Conditional, error) { + calls++ + if res.NotModified && etag == "" { + t.Error("304 stub called without an ETag") + } + return res, err + } + t.Cleanup(func() { releasesFetch = orig }) + return &calls +} + +func TestRefreshReleasesFetchesAndCaches(t *testing.T) { + m := newTestManager(t) + body, _ := json.Marshal(ReleasesFeed{ + Latest: &Release{Tag: "v1.7.1", Version: "1.7.1"}, + Releases: []Release{{Tag: "v1.7.1", Version: "1.7.1"}}, + Master: &MasterInfo{CommitCount: 4920}, + }) + calls := stubFeed(t, netfetch.Conditional{Body: body, ETag: `"abc"`}, nil) + + feed, err := m.refreshReleases(context.Background(), false) + if err != nil || feed.Latest == nil || feed.Latest.Version != "1.7.1" || feed.ETag != `"abc"` { + t.Fatalf("first fetch = %+v, %v", feed, err) + } + state := m.GetState() + if state.Shell.LatestVersion != "1.7.1" || state.Shell.CommitsBehind == nil || *state.Shell.CommitsBehind != 5 || state.ReleasesFetchedUnix == 0 { + t.Errorf("shell after fetch = %+v, fetched=%d", state.Shell, state.ReleasesFetchedUnix) + } + + // Fresh cache: no network. + if _, err := m.refreshReleases(context.Background(), false); err != nil || *calls != 1 { + t.Errorf("fresh cache fetched again: calls=%d err=%v", *calls, err) + } + // Force: network with the cached ETag. + if _, err := m.refreshReleases(context.Background(), true); err != nil || *calls != 2 { + t.Errorf("force did not fetch: calls=%d err=%v", *calls, err) + } +} + +func TestRefreshReleasesNotModifiedKeepsBody(t *testing.T) { + m := newTestManager(t) + old := time.Now().Add(-2 * time.Hour).Unix() + if err := saveReleases(ReleasesFeed{FetchedAt: old, ETag: `"abc"`, Latest: &Release{Version: "1.7.0"}, Releases: []Release{}}); err != nil { + t.Fatal(err) + } + stubFeed(t, netfetch.Conditional{ETag: `"abc"`, NotModified: true}, nil) + + feed, err := m.refreshReleases(context.Background(), false) + if err != nil || feed.Latest == nil || feed.Latest.Version != "1.7.0" { + t.Fatalf("304 dropped cached body: %+v, %v", feed, err) + } + if feed.FetchedAt == old { + t.Error("304 did not bump fetchedAt") + } +} + +func TestRefreshReleasesErrorServesCache(t *testing.T) { + m := newTestManager(t) + old := time.Now().Add(-2 * time.Hour).Unix() + if err := saveReleases(ReleasesFeed{FetchedAt: old, Latest: &Release{Version: "1.7.0"}, Releases: []Release{}}); err != nil { + t.Fatal(err) + } + stubFeed(t, netfetch.Conditional{}, errors.New("offline")) + + feed, err := m.refreshReleases(context.Background(), true) + if err == nil || feed.Latest == nil || feed.Latest.Version != "1.7.0" || feed.FetchedAt != old { + t.Errorf("offline = %+v, %v", feed, err) + } +} + +func TestSetIntervalSchedulesFromLastCheck(t *testing.T) { + m := &Manager{ + notifyDirty: make(chan struct{}, 1), + stopChan: make(chan struct{}), + wakeSched: make(chan struct{}, 1), + } + now := time.Now().Unix() + m.state.LastCheckUnix = now - 3600 + m.state.LastSuccessUnix = m.state.LastCheckUnix + m.SetInterval(86400) + if next := m.GetState().NextCheckUnix; next < now+86400-3600-5 || next > now+86400-3600+5 { + t.Errorf("next = now+%d, want now+%d", next-now, 86400-3600) + } + + // A failed last check keeps its short retry instead of waiting a full interval. + m.state.LastCheckUnix = now - 60 + m.state.LastSuccessUnix = now - 3600 + m.SetInterval(86400) + if next := m.GetState().NextCheckUnix; next < now+retryIntervalSeconds-60-5 || next > now+retryIntervalSeconds-60+5 { + t.Errorf("failed next = now+%d, want now+%d", next-now, retryIntervalSeconds-60) + } + + // Overdue checks wait out the startup grace instead of firing into login. + m.state.LastCheckUnix = now - 2*86400 + m.SetInterval(86400) + if next := m.GetState().NextCheckUnix; next < now+startupGraceSeconds-5 || next > now+startupGraceSeconds+5 { + t.Errorf("overdue next = now+%d, want now+%d", next-now, startupGraceSeconds) + } +} + +func TestRefreshWithoutBackendsAdvancesDeadline(t *testing.T) { + m := newTestManager(t) + m.state.IntervalSeconds = 3600 + body, _ := json.Marshal(ReleasesFeed{Latest: &Release{Version: "1.7.1"}, Releases: []Release{}}) + calls := stubFeed(t, netfetch.Conditional{Body: body}, nil) + + now := time.Now().Unix() + m.runRefresh(context.Background(), false) + + state := m.GetState() + if state.NextCheckUnix < now+3600-5 || state.NextCheckUnix > now+3600+5 { + t.Errorf("next = now+%d, want now+3600 (an unmoved deadline spins the scheduler)", state.NextCheckUnix-now) + } + if *calls != 1 || state.Shell.LatestVersion != "1.7.1" { + t.Errorf("feed not refreshed without package backends: calls=%d latest=%q", *calls, state.Shell.LatestVersion) + } +} + +func TestRefreshDuringUpgradeAdvancesDeadline(t *testing.T) { + m := newTestManager(t) + m.selection = Selection{Overlay: []Backend{&fakeBackend{}}} + m.state.IntervalSeconds = 3600 + m.state.Phase = PhaseUpgrading + + now := time.Now().Unix() + m.runRefresh(context.Background(), false) + if next := m.GetState().NextCheckUnix; next < now+retryIntervalSeconds-5 { + t.Errorf("next = now+%d, want >= now+%d", next-now, retryIntervalSeconds) + } +} + +func TestSuccessfulUpgradePersistsClearedPackages(t *testing.T) { + m := newTestManager(t) + orig := postUpgradeCompleteDelay + postUpgradeCompleteDelay = 0 + t.Cleanup(func() { postUpgradeCompleteDelay = orig }) + + savePersisted(persistedState{LastCheckUnix: 10, Packages: []Package{{Name: "firefox"}}}) + m.state.Packages = []Package{{Name: "firefox"}} + m.state.Count = 1 + + m.finishSuccessfulUpgrade(true, nil) + if got := loadPersisted(); len(got.Packages) != 0 { + t.Errorf("persisted packages after upgrade = %v, want none (they resurrect on restart)", got.Packages) + } +} + +type fakeBackend struct{} + +func (fakeBackend) ID() string { return "fake" } +func (fakeBackend) DisplayName() string { return "fake" } +func (fakeBackend) Repo() RepoKind { return RepoFlatpak } +func (fakeBackend) IsAvailable(context.Context) bool { return true } +func (fakeBackend) NeedsAuth() bool { return false } +func (fakeBackend) RunsInTerminal() bool { return false } +func (fakeBackend) CheckUpdates(context.Context) ([]Package, error) { return nil, nil } +func (fakeBackend) Upgrade(context.Context, UpgradeOptions, func(string)) error { return nil } + +func TestRebootPackages(t *testing.T) { + pkgs := []Package{ + {Name: "linux-zen", Repo: RepoSystem}, {Name: "kernel-core", Repo: RepoSystem}, {Name: "linux-image-6.12.0-amd64", Repo: RepoSystem}, + {Name: "systemd", Repo: RepoSystem}, {Name: "glibc", Repo: RepoSystem}, {Name: "firefox", Repo: RepoSystem}, + {Name: "linux-headers", Repo: RepoAUR}, {Name: "kernel-tools", Repo: RepoSystem}, + } + got := rebootPackages(pkgs) + want := []string{"linux-zen", "kernel-core", "linux-image-6.12.0-amd64", "systemd", "glibc", "kernel-tools"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("rebootPackages = %v, want %v", got, want) + } +} + +func TestRebootHintPersistsAcrossRestartNotReboot(t *testing.T) { + m := newTestManager(t) + orig := postUpgradeCompleteDelay + postUpgradeCompleteDelay = 0 + t.Cleanup(func() { postUpgradeCompleteDelay = orig }) + + m.finishSuccessfulUpgrade(true, []Package{{Name: "systemd", Repo: RepoSystem}}) + if !m.GetState().Reboot.Recommended { + t.Fatal("systemd upgrade did not recommend a reboot") + } + p := loadPersisted() + if p.RebootBootID != bootID() || len(p.RebootPackages) != 1 { + t.Fatalf("persisted = %+v", p) + } + // A different boot id means the machine rebooted: the hint must not come back. + p.RebootBootID = "stale" + savePersisted(p) + m2, err := NewManager("1.7.0") + if err != nil { + t.Fatal(err) + } + defer m2.Close() + if m2.GetState().Reboot.Recommended { + t.Error("reboot hint survived a reboot") + } +} diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go index 8664ef3f5..6af020f13 100644 --- a/core/internal/server/sysupdate/types.go +++ b/core/internal/server/sysupdate/types.go @@ -55,21 +55,91 @@ type ErrorInfo struct { Hint string `json:"hint,omitempty"` } +type InstallMethod string + +const ( + InstallPacman InstallMethod = "pacman" + InstallRPM InstallMethod = "rpm" + InstallDpkg InstallMethod = "dpkg" + InstallXbps InstallMethod = "xbps" + InstallNix InstallMethod = "nix" + InstallUnknown InstallMethod = "unknown" +) + +type Channel string + +const ( + ChannelStable Channel = "stable" + ChannelGit Channel = "git" + ChannelUnknown Channel = "unknown" +) + +type ShellInfo struct { + InstallMethod InstallMethod `json:"installMethod"` + PackageName string `json:"packageName,omitempty"` + Channel Channel `json:"channel"` + Running string `json:"running,omitempty"` + Installed string `json:"installed,omitempty"` + GitBuild int `json:"gitBuild,omitempty"` + RestartPending bool `json:"restartPending"` + UpdatePackage *Package `json:"updatePackage,omitempty"` + LatestVersion string `json:"latestVersion,omitempty"` + CommitsBehind *int `json:"commitsBehind,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"` + 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"` + Shell ShellInfo `json:"shell"` + Reboot RebootInfo `json:"reboot"` + ReleasesFetchedUnix int64 `json:"releasesFetchedUnix,omitempty"` +} + +type ReleaseCounts struct { + Breaking int `json:"breaking"` + Features int `json:"features"` + Fixes int `json:"fixes"` + Other int `json:"other"` +} + +type Release struct { + Tag string `json:"tag"` + Version string `json:"version"` + Codename string `json:"codename,omitempty"` + Prerelease bool `json:"prerelease"` + PublishedAt string `json:"publishedAt"` + URL string `json:"url"` + BlogURL string `json:"blogUrl,omitempty"` + Summary string `json:"summary,omitempty"` + Counts ReleaseCounts `json:"counts"` + Highlights []string `json:"highlights"` +} + +type MasterInfo struct { + SHA string `json:"sha"` + CommitCount int `json:"commitCount"` + Date string `json:"date"` +} + +// The api.danklinux.com /dms/releases document plus cache metadata. +type ReleasesFeed struct { + FetchedAt int64 `json:"fetchedAt"` + ETag string `json:"etag,omitempty"` + Latest *Release `json:"latest,omitempty"` + Releases []Release `json:"releases"` + Master *MasterInfo `json:"master,omitempty"` } type UpgradeOptions struct { @@ -87,4 +157,6 @@ type UpgradeOptions struct { type RefreshOptions struct { Force bool + // Background checks (check-on-start) fail quietly and don't force the feed; manual ones surface errors. + Background bool } diff --git a/dank-qml-common b/dank-qml-common index 4caf23091..445e6ad07 160000 --- a/dank-qml-common +++ b/dank-qml-common @@ -1 +1 @@ -Subproject commit 4caf2309145923503a1beed6a450498814210e55 +Subproject commit 445e6ad070f956920e23de62086257167af7cb06 diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index bc7792645..d98bc3578 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -43,6 +43,9 @@ readonly property int sessionConfigVersion: 7 property bool idleInhibited: false property real idleInhibitedUntil: 0 property string terminalOverride: "" + property int updaterNotifiedUnix: 0 + property int updaterNotifiedCount: 0 + property string changelogSeenVersion: "" property bool isSwitchingMode: false property bool suppressOSD: true diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 63cf9b3cb..f80c0ef6a 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -1077,6 +1077,9 @@ Singleton { property string updaterCustomCommand: Spec.SPEC.updaterCustomCommand.def property string updaterTerminalAdditionalParams: Spec.SPEC.updaterTerminalAdditionalParams.def property int updaterIntervalSeconds: Spec.SPEC.updaterIntervalSeconds.def + property bool updaterNotify: Spec.SPEC.updaterNotify.def + property bool updaterPauseOnBattery: Spec.SPEC.updaterPauseOnBattery.def + property int updaterNotifyMinSeconds: Spec.SPEC.updaterNotifyMinSeconds.def property bool updaterIncludeFlatpak: Spec.SPEC.updaterIncludeFlatpak.def property bool updaterAllowAUR: Spec.SPEC.updaterAllowAUR.def property bool updaterReopenAfterUpgrade: Spec.SPEC.updaterReopenAfterUpgrade.def diff --git a/quickshell/Common/SettingsTabs.qml b/quickshell/Common/SettingsTabs.qml index 3006891cb..6a1baca29 100644 --- a/quickshell/Common/SettingsTabs.qml +++ b/quickshell/Common/SettingsTabs.qml @@ -542,11 +542,18 @@ Singleton { "children": [ { "id": "updater", - "text": I18n.tr("System updater"), - "icon": "refresh", + "text": I18n.tr("Software updates"), + "icon": "system_update_alt", "tabIndex": 20, - "updaterOnly": true, - "hint": I18n.tr("Check interval, ignored packages, custom command") + "hint": I18n.tr("DMS and system updates") + }, + { + "id": "updater_changelog", + "hidden": true, + "text": I18n.tr("Release notes"), + "icon": "auto_awesome", + "tabIndex": 66, + "hint": I18n.tr("Summary, highlights, links") }, { "id": "clipboard", @@ -751,8 +758,6 @@ Singleton { return false; if (entry.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23)) return false; - if (entry.updaterOnly && !SystemUpdateService.sysupdateAvailable) - return false; if (entry.greeterOnly && !GreeterService.available) return false; if (entry.autostartOnly && !DesktopService.autostartAvailable) diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index dfe856439..237ab7276 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -24,6 +24,15 @@ var LOCAL_SPEC = { idleInhibitedUntil: { def: 0 }, + updaterNotifiedUnix: { + def: 0 + }, + updaterNotifiedCount: { + def: 0 + }, + changelogSeenVersion: { + def: "" + }, terminalOverride: { def: "" }, diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 2f9059c61..18a93941c 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1269,7 +1269,17 @@ var LOCAL_SPEC = { def: "" }, updaterIntervalSeconds: { - def: 1800 + def: 86400 + }, + updaterNotify: { + def: false + }, + updaterPauseOnBattery: { + def: false + }, + // 0 = every time the count grows + updaterNotifyMinSeconds: { + def: 86400 }, updaterIncludeFlatpak: { def: true diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 4a30ecc4d..5a7440cc7 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -1343,11 +1343,6 @@ Item { Component.onCompleted: show() } - Component.onCompleted: { - if (ChangelogService.shouldShowChangelog) - active = true; - } - Connections { target: ChangelogService function onChangelogRequested() { diff --git a/quickshell/Modals/Changelog/ChangelogContent.qml b/quickshell/Modals/Changelog/ChangelogContent.qml index 560d13a5f..afe110b0e 100644 --- a/quickshell/Modals/Changelog/ChangelogContent.qml +++ b/quickshell/Modals/Changelog/ChangelogContent.qml @@ -3,6 +3,7 @@ import QtQuick.Effects import qs.Common import qs.Services import qs.Widgets +import qs.Modules.Settings.Widgets Column { id: root @@ -270,4 +271,39 @@ Column { } } } + + SettingsDivider { + visible: ChangelogService.release !== null + } + + Column { + width: parent.width + visible: ChangelogService.release !== null + spacing: Theme.spacingS + + Row { + spacing: Theme.spacingS + + DankIcon { + name: "auto_awesome" + size: Theme.iconSizeSmall + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("What changed") + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + ReleaseNotesCard { + width: parent.width + release: ChangelogService.release + showTitle: false + } + } } diff --git a/quickshell/Modals/Changelog/ChangelogModal.qml b/quickshell/Modals/Changelog/ChangelogModal.qml index 376c840ea..c5ec75f99 100644 --- a/quickshell/Modals/Changelog/ChangelogModal.qml +++ b/quickshell/Modals/Changelog/ChangelogModal.qml @@ -92,11 +92,14 @@ DankFloatingWindow { spacing: Theme.spacingM DankButton { - text: I18n.tr("Open in Browser") - iconName: "open_in_new" + text: I18n.tr("Release notes") + iconName: "auto_awesome" backgroundColor: Theme.chipSurface textColor: Theme.surfaceText - onClicked: Qt.openUrlExternally("https://danklinux.com/blog/v1-6-release") + onClicked: { + root.dismiss(); + PopoutService.openSettingsWithTab("updater_changelog"); + } } DankButton { diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index bdd09553c..3887952c0 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -228,7 +228,8 @@ FocusScope { "osd": "OSDTab.qml", "default_apps": "DefaultAppsTab.qml", "running_apps": "RunningAppsTab.qml", - "updater": "SystemUpdaterTab.qml", + "updater": "SoftwareUpdatesTab.qml", + "updater_changelog": "ChangelogTab.qml", "power_sleep": "PowerSleepTab.qml", "clipboard": "ClipboardTab.qml", "desktop_widgets": "DesktopWidgetsTab.qml", @@ -247,7 +248,7 @@ FocusScope { "plugins_manage": "PluginsManageTab.qml" }) - readonly property var pagesWithParentModal: ["dankbar_widgets", "window_rules", "display_config", "users", "time_weather", "weather", "lock_screen", "greeter", "dank_dash", "wallpaper_cycling", "theme_schedule", "surface_shadows", "keybinds", "dankbar_settings", "dankbar_appearance", "bar_widget", "dock_general", "dock_widgets", "dock_appearance", "dock_advanced", "launcher", "theme", "theme_apps", "media_player", "desktop_widgets", "autostart", "compositor_layout"] + readonly property var pagesWithParentModal: ["dankbar_widgets", "window_rules", "display_config", "users", "time_weather", "weather", "lock_screen", "greeter", "dank_dash", "wallpaper_cycling", "theme_schedule", "surface_shadows", "keybinds", "dankbar_settings", "dankbar_appearance", "bar_widget", "dock_general", "dock_widgets", "dock_appearance", "dock_advanced", "launcher", "theme", "theme_apps", "media_player", "desktop_widgets", "autostart", "compositor_layout", "updater"] Column { anchors.fill: parent diff --git a/quickshell/Modules/Settings/AboutTab.qml b/quickshell/Modules/Settings/AboutTab.qml index 36734d65d..b0939941d 100644 --- a/quickshell/Modules/Settings/AboutTab.qml +++ b/quickshell/Modules/Settings/AboutTab.qml @@ -184,61 +184,15 @@ Item { StyledText { text: { - if (!ShellVersionService.shellVersion && !DMSService.cliVersion) - return "dms"; - - let version = ShellVersionService.shellVersion || ""; - let cliVersion = DMSService.cliVersion || ""; - - // Debian/Ubuntu/OpenSUSE git format: 1.0.3+git2264.c5c5ce84 - let match = version.match(/^([\d.]+)\+git(\d+)\./); - if (match) { - return `dms (git) v${match[1]}-${match[2]}`; - } - - // Fedora COPR git format: 0.0.git.2267.d430cae9 - match = version.match(/^[\d.]+\.git\.(\d+)\./); - if (match) { - function extractBaseVersion(value) { - if (!value) - return ""; - let baseMatch = value.match(/(\d+\.\d+\.\d+)/); - if (baseMatch) - return baseMatch[1]; - baseMatch = value.match(/(\d+\.\d+)/); - if (baseMatch) - return baseMatch[1]; - return ""; - } - - let baseVersion = extractBaseVersion(cliVersion); - if (!baseVersion) - baseVersion = extractBaseVersion(ShellVersionService.semverVersion); - if (baseVersion) { - return `dms (git) v${baseVersion}-${match[1]}`; - } - return `dms (git) v${match[1]}`; - } - - // Stable release format: 1.0.3 - match = version.match(/^([\d.]+)$/); - if (match) { - return `dms v${match[1]}`; - } - - if (!version && cliVersion) { - match = cliVersion.match(/^([\d.]+)\+git(\d+)\./); - if (match) { - return `dms (git) v${match[1]}-${match[2]}`; - } - match = cliVersion.match(/^([\d.]+)$/); - if (match) { - return `dms v${match[1]}`; - } - return `dms ${cliVersion}`; - } - - return `dms ${version}`; + const running = SystemUpdateService.shellRunning; + if (!SystemUpdateService.sysupdateAvailable || !running) + return ShellVersionService.shellVersion ? `dms ${ShellVersionService.shellVersion}` : "dms"; + if (SystemUpdateService.shellChannel !== "git") + return /^\d/.test(running) ? `dms v${running}` : `dms ${running}`; + // COPR builds (0.0.git.N.hash) carry no base version; the VERSION file does. + const base = running.startsWith("0.0.git") ? ShellVersionService.semverVersion : running.replace(/^v/, "").split("+")[0]; + const build = SystemUpdateService.shellGitBuild; + return build > 0 ? `dms (git) v${base}-${build}` : `dms (git) ${running}`; } font.pixelSize: Theme.fontSizeXLarge color: Theme.surfaceText diff --git a/quickshell/Modules/Settings/BarWidgetOptions/SystemUpdateOptions.qml b/quickshell/Modules/Settings/BarWidgetOptions/SystemUpdateOptions.qml index 446d2803a..bff38ba34 100644 --- a/quickshell/Modules/Settings/BarWidgetOptions/SystemUpdateOptions.qml +++ b/quickshell/Modules/Settings/BarWidgetOptions/SystemUpdateOptions.qml @@ -20,5 +20,12 @@ Column { checked: root.page.value("hideWhenIdle") onToggled: checked => root.page.set("hideWhenIdle", checked) } + + SettingsNavRow { + iconName: "system_update_alt" + title: I18n.tr("Software updates") + hint: I18n.tr("DMS and system updates") + onClicked: root.page.parentModal?.navigateTo("updater") + } } } diff --git a/quickshell/Modules/Settings/ChangelogTab.qml b/quickshell/Modules/Settings/ChangelogTab.qml new file mode 100644 index 000000000..ad322c5b2 --- /dev/null +++ b/quickshell/Modules/Settings/ChangelogTab.qml @@ -0,0 +1,76 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + Ref { + service: SystemUpdateService + modules: ["releases"] + } + + readonly property var releases: SystemUpdateService.releases?.releases ?? [] + property int selectedIndex: Math.max(0, releases.indexOf(SystemUpdateService.notesRelease)) + readonly property var selectedRelease: releases.length > selectedIndex ? releases[selectedIndex] : null + readonly property bool feedMissing: releases.length === 0 + + SettingsPage { + SettingsCard { + width: parent.width + settingKey: "softwareUpdatesChangelog" + tags: ["changelog", "release", "notes", "highlights"] + + // Reachable with an empty feed via search or `settings openWith`. + SettingsRow { + visible: root.feedMissing + iconName: "cloud_off" + title: I18n.tr("Release notes are unavailable") + subtitle: I18n.tr("They load with the next update check. Release notes are also published on GitHub.") + + DankButton { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Retry") + iconName: "refresh" + busy: SystemUpdateService.isChecking + enabled: !SystemUpdateService.isChecking + backgroundColor: Theme.chipSurface + textColor: Theme.surfaceText + onClicked: SystemUpdateService.loadReleases(true) + } + + DankButton { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("View on GitHub") + iconName: "open_in_new" + backgroundColor: Theme.chipSurface + textColor: Theme.surfaceText + onClicked: Qt.openUrlExternally("https://github.com/AvengeMedia/DankMaterialShell/releases") + } + } + + SettingsRow { + visible: !root.feedMissing + body: DankFilterChips { + width: parent.width + model: root.releases.map(r => "v" + r.version) + currentIndex: root.selectedIndex + showCheck: false + showCounts: false + onSelectionChanged: index => root.selectedIndex = index + } + } + + SettingsRow { + visible: !root.feedMissing + paddingV: SettingsMetrics.heroPadding + body: ReleaseNotesCard { + width: parent.width + release: root.selectedRelease + } + } + } + } +} diff --git a/quickshell/Modules/Settings/SoftwareUpdatesTab.qml b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml new file mode 100644 index 000000000..69ed2355e --- /dev/null +++ b/quickshell/Modules/Settings/SoftwareUpdatesTab.qml @@ -0,0 +1,819 @@ +import QtQuick +import QtQuick.Effects +import qs.Common +import qs.Modals.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + property var parentModal: null + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + Ref { + service: SystemUpdateService + modules: ["releases"] + } + + ConfirmModal { + id: updateConfirm + } + + readonly property var intervalOptions: [ + { + label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(900)), + seconds: 900 + }, + { + label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(1800)), + seconds: 1800 + }, + { + label: I18n.tr("Every hour"), + seconds: 3600 + }, + { + label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(14400)), + seconds: 14400 + }, + { + label: I18n.tr("Once a day"), + seconds: 86400 + } + ] + + readonly property string customIntervalLabel: I18n.tr("Custom") + property bool customIntervalSelected: false + property bool packagesExpanded: false + property int nowUnix: Math.floor(Date.now() / 1000) + + readonly property int packageListCap: 60 + readonly property bool upgradeRunsInTerminal: SystemUpdateService.useCustomCommand || (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true) + readonly property int systemCount: SystemUpdateService.systemUpdates.length + readonly property int flatpakCount: SystemUpdateService.systemUpdates.filter(p => p.repo === "flatpak").length + readonly property bool busy: SystemUpdateService.isChecking || SystemUpdateService.isUpgrading + readonly property bool anythingToInstall: SystemUpdateService.updateCount > 0 && SystemUpdateService.helperAvailable + readonly property string displayVersion: { + const semver = ShellVersionService.semverVersion.replace(/^v/, ""); + const base = semver.match(/^\d+\.\d+/); + return base ? base[0] : semver || SystemUpdateService.shellRunning.replace(/^v/, ""); + } + readonly property var notesRelease: SystemUpdateService.notesRelease + readonly property int serviceLastCheckUnix: SystemUpdateService.lastCheckUnix + + onServiceLastCheckUnixChanged: nowUnix = Math.floor(Date.now() / 1000) + + Component.onCompleted: { + customIntervalSelected = !intervalOptions.some(o => o.seconds === SettingsData.updaterIntervalSeconds); + nowUnix = Math.floor(Date.now() / 1000); + } + + readonly property var notifyOptions: [ + { + label: I18n.tr("Every check"), + seconds: 0 + }, + { + label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(3600)), + seconds: 3600 + }, + { + label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(4 * 3600)), + seconds: 4 * 3600 + }, + { + label: I18n.tr("Once a day"), + seconds: 86400 + }, + { + label: I18n.tr("Once a week"), + seconds: 7 * 86400 + } + ] + + function intervalLabelFor(seconds) { + for (const opt of intervalOptions) { + if (opt.seconds === seconds) + return opt.label; + } + return customIntervalLabel; + } + + function intervalSecondsFor(label) { + for (const opt of intervalOptions) { + if (opt.label === label) + return opt.seconds; + } + return 86400; + } + + function lastCheckedText() { + const last = SystemUpdateService.lastCheckUnix; + if (!last) + return ""; + const delta = Math.max(0, nowUnix - last); + if (delta < 90) + return I18n.tr("checked just now"); + if (delta < 3600) + return I18n.tr("checked %1m ago", "system update last check time, %1 is minutes").arg(Math.round(delta / 60)); + if (delta < 86400) + return I18n.tr("checked %1h ago", "system update last check time, %1 is hours").arg(Math.round(delta / 3600)); + return I18n.tr("checked %1d ago", "system update last check time, %1 is days").arg(Math.round(delta / 86400)); + } + + function countText(count) { + return count === 1 ? I18n.tr("%1 update", "singular, %1 is 1, available system update count").arg(count) : I18n.tr("%1 updates", "plural, %1 is a count of available system updates").arg(count); + } + + function installMethodLabel() { + switch (SystemUpdateService.shellInstallMethod) { + case "pacman": + case "rpm": + case "dpkg": + case "xbps": + return SystemUpdateService.shellPackageName || SystemUpdateService.shellInstallMethod; + case "nix": + return "Nix"; + default: + return ""; + } + } + + function channelLabel() { + switch (SystemUpdateService.shellChannel) { + case "stable": + return I18n.tr("Stable", "release channel"); + case "git": + return I18n.tr("Git", "release channel that follows the master branch"); + default: + return I18n.tr("Unknown"); + } + } + + function heroStatus() { + const checked = lastCheckedText(); + switch (true) { + case !SystemUpdateService.sysupdateAvailable: + return I18n.tr("Unavailable"); + case SystemUpdateService.isUpgrading: + return I18n.tr("Upgrading...", "system update popout status while packages upgrade"); + case SystemUpdateService.isChecking: + return I18n.tr("Checking for updates..."); + case SystemUpdateService.restartPending: + return I18n.tr("Requires restart"); + case SystemUpdateService.hasError: + return I18n.tr("Failed: %1", "system update error status, %1 is the error message").arg(SystemUpdateService.errorMessage); + case !SystemUpdateService.helperAvailable: + return I18n.tr("No supported package manager found."); + case SystemUpdateService.shellUpdateAvailable: { + const shell = I18n.tr("DMS %1 available", "software updates hero, %1 is the new DMS version").arg(SystemUpdateService.shellUpdateVersion); + return root.systemCount > 0 ? shell + " · " + root.countText(root.systemCount) : shell; + } + case root.systemCount > 0: + return checked ? root.countText(root.systemCount) + " · " + checked : root.countText(root.systemCount); + default: + return checked ? I18n.tr("Up to date") + " · " + checked : I18n.tr("Up to date"); + } + } + + function primaryLabel() { + switch (true) { + case SystemUpdateService.isUpgrading: + return I18n.tr("Cancel"); + case SystemUpdateService.restartPending: + return I18n.tr("Restart DMS"); + case root.anythingToInstall: + return I18n.tr("Update All"); + default: + return I18n.tr("Check for updates"); + } + } + + function primaryIcon() { + switch (true) { + case SystemUpdateService.isUpgrading: + return "stop"; + case SystemUpdateService.restartPending: + return "restart_alt"; + case root.anythingToInstall: + return "system_update_alt"; + default: + return "refresh"; + } + } + + function primaryAction() { + switch (true) { + case SystemUpdateService.isUpgrading: + SystemUpdateService.cancelUpdates(); + return; + case SystemUpdateService.restartPending: + SystemUpdateService.restartShell(); + return; + case root.anythingToInstall: + root.confirmUpdateAll(); + return; + default: + SystemUpdateService.checkForUpdates(); + } + } + + function confirmUpdateAll() { + updateConfirm.showWithOptions({ + "title": I18n.tr("Install %1?", "confirm dialog title, %1 is an update count such as '34 updates'").arg(root.countText(SystemUpdateService.updateCount)), + "message": root.upgradeRunsInTerminal ? I18n.tr("The upgrade runs in a terminal window and may ask for your password.") : I18n.tr("You may be asked for your password. Keep the shell running until it finishes."), + "confirmText": I18n.tr("Update All"), + "onConfirm": () => root.runUpdateAll() + }); + } + + function runUpdateAll() { + SystemUpdateService.runUpdates({ + includeFlatpak: SettingsData.updaterIncludeFlatpak, + includeAUR: SettingsData.updaterAllowAUR, + terminal: SessionData.terminalOverride + }); + packagesExpanded = false; + } + + SettingsPage { + id: mainColumn + + DankCard { + id: hero + width: parent.width + height: heroColumn.implicitHeight + SettingsMetrics.heroPadding * 3 + restRadius: Theme.cornerRadiusXL + color: Theme.surfaceContainerLow + pad: 0 + clipContent: true + showFocusRing: false + + // The danklinux.com release banner, tinted to the accent. + Image { + anchors.fill: parent + source: "file://" + Theme.shellDir + "/assets/release-banner.svg" + fillMode: Image.Stretch + asynchronous: true + cache: false + sourceSize: Qt.size(width, height) + opacity: Theme.pendingOpacity + layer.enabled: true + layer.effect: MultiEffect { + colorization: 1 + colorizationColor: Theme.primary + } + } + + Column { + id: heroColumn + anchors.centerIn: parent + width: parent.width - SettingsMetrics.heroPadding * 2 + spacing: Theme.spacingS + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingS + + StyledText { + id: heroBrand + text: "DMS" + font.pixelSize: Theme.fontSizeDisplayLarge + font.weight: Theme.shiftedFontWeight(Font.ExtraBold) + font.letterSpacing: -Theme.fontSizeDisplayLarge * 0.04 + color: Theme.surfaceText + } + + // The blog's silver-to-accent version number. + GradientText { + anchors.verticalCenter: parent.verticalCenter + text: root.displayVersion + font: heroBrand.font + } + } + + StyledText { + width: parent.width + visible: ShellVersionService.shellCodename !== "" + text: ShellVersionService.shellCodename.toUpperCase() + font.pixelSize: Theme.fontSizeMedium + font.weight: Theme.fontWeightBold + font.letterSpacing: Theme.fontSizeMedium * 0.25 + color: Theme.primary + horizontalAlignment: Text.AlignHCenter + elide: Text.ElideRight + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + topPadding: Theme.spacingM + spacing: Theme.spacingS + visible: SystemUpdateService.sysupdateAvailable + + DankButton { + id: primaryButton + anchors.verticalCenter: parent.verticalCenter + buttonHeight: Theme.buttonHeightS + text: root.primaryLabel() + iconName: root.primaryIcon() + busy: SystemUpdateService.isChecking + enabled: !SystemUpdateService.isChecking + backgroundColor: Theme.primary + textColor: Theme.onPrimary + onClicked: root.primaryAction() + } + + // Re-check while the primary button is busy installing or restarting. + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + visible: root.anythingToInstall || SystemUpdateService.restartPending + enabled: !root.busy + buttonSize: Theme.buttonHeightS + iconName: "refresh" + iconColor: Theme.surfaceText + backgroundColor: Theme.chipSurface + tooltipText: I18n.tr("Check for updates") + onClicked: SystemUpdateService.checkForUpdates() + } + } + + StyledText { + width: parent.width + topPadding: Theme.spacingXS + text: root.heroStatus() + font.pixelSize: Theme.fontSizeSmall + color: SystemUpdateService.hasError && !root.busy ? Theme.error : Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Item { + width: parent.width + height: rebootChip.implicitHeight + visible: SystemUpdateService.rebootRecommended + + DankBadge { + id: rebootChip + anchors.horizontalCenter: parent.horizontalCenter + maximumWidth: parent.width + text: I18n.tr("Reboot recommended", "chip shown after a kernel or systemd upgrade") + " · " + SystemUpdateService.rebootPackages.join(", ") + color: Theme.tertiaryContainer + textColor: Theme.onTertiaryContainer + } + } + + StyledText { + width: parent.width + visible: SystemUpdateService.hasError && !root.busy && SystemUpdateService.errorHint !== "" + text: SystemUpdateService.errorHint + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + M3WaveProgress { + id: upgradeWave + width: parent.width + height: Theme.spacingM + visible: SystemUpdateService.isUpgrading + isPlaying: visible + value: 1 + trackColor: "transparent" + playheadColor: "transparent" + } + } + } + + SettingsCard { + width: parent.width + title: "DankMaterialShell" + iconName: "deployed_code" + settingKey: "softwareUpdatesShell" + tags: ["dms", "shell", "version", "channel", "restart"] + + SettingsRow { + title: I18n.tr("Version") + iconName: "tag" + subtitle: { + const running = SystemUpdateService.shellRunning || ShellVersionService.shellVersion; + const to = SystemUpdateService.shellUpdateVersion; + if (to) + return running + " → " + to; + return running; + } + trailingBadge: root.installMethodLabel() + } + + SettingsRow { + title: I18n.tr("Channel") + iconName: "alt_route" + subtitle: root.channelLabel() + trailingBadge: SystemUpdateService.shellManagedExternally ? I18n.tr("Managed by Nix") : "" + } + + SettingsRow { + visible: SystemUpdateService.shellChannel === "git" && SystemUpdateService.commitsBehind >= 0 + title: SystemUpdateService.commitsBehind === 0 ? I18n.tr("Up to date with master") : I18n.tr("%1 commits behind master", "git channel row, %1 is a count").arg(SystemUpdateService.commitsBehind) + iconName: "commit" + clickable: true + showChevron: true + onClicked: Qt.openUrlExternally("https://github.com/AvengeMedia/DankMaterialShell/commits/master") + } + + SettingsNavRow { + visible: root.notesRelease !== null + title: I18n.tr("Release notes") + iconName: "auto_awesome" + hint: root.notesRelease?.codename ? "v" + root.notesRelease.version + " · " + root.notesRelease.codename : "v" + (root.notesRelease?.version ?? "") + onClicked: root.parentModal?.navigateTo("updater_changelog") + } + + SettingsRow { + visible: SystemUpdateService.restartPending + title: I18n.tr("Requires restart") + iconName: "restart_alt" + iconColor: Theme.warning + subtitle: SystemUpdateService.shellInstalled ? I18n.tr("Installed %1, still running %2", "%1 is the installed version, %2 the running one").arg(SystemUpdateService.shellInstalled).arg(SystemUpdateService.shellRunning) : I18n.tr("A newer dms binary is installed.") + + DankButton { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Restart DMS") + iconName: "restart_alt" + backgroundColor: Theme.chipSurface + textColor: Theme.surfaceText + onClicked: SystemUpdateService.restartShell() + } + } + } + + SettingsCard { + width: parent.width + visible: SystemUpdateService.sysupdateAvailable + title: I18n.tr("System packages") + iconName: "inventory_2" + settingKey: "softwareUpdatesSystem" + tags: ["system", "packages", "flatpak", "aur"] + + SettingsRow { + title: { + if (!SystemUpdateService.helperAvailable) + return I18n.tr("No supported package manager found."); + if (root.systemCount === 0) + return I18n.tr("Up to date"); + const base = root.countText(root.systemCount); + return root.flatpakCount > 0 ? base + " · " + I18n.tr("%1 Flatpak", "count of flatpak updates, %1 is a number").arg(root.flatpakCount) : base; + } + subtitle: { + const distro = SystemUpdateService.distributionPretty || SystemUpdateService.distribution; + const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", "); + return distro && names ? distro + " · " + names : distro || names; + } + iconName: root.packagesExpanded ? "expand_less" : "expand_more" + clickable: root.systemCount > 0 + onClicked: root.packagesExpanded = !root.packagesExpanded + + DankButton { + anchors.verticalCenter: parent.verticalCenter + visible: root.systemCount > 0 && !SystemUpdateService.isUpgrading + text: I18n.tr("Update All") + iconName: "system_update_alt" + backgroundColor: Theme.chipSurface + textColor: Theme.surfaceText + enabled: !root.busy + onClicked: root.confirmUpdateAll() + } + } + + Repeater { + model: root.packagesExpanded && !SystemUpdateService.isUpgrading ? SystemUpdateService.systemUpdates.slice(0, root.packageListCap) : [] + + delegate: SettingsRow { + id: packageRow + required property var modelData + + title: modelData.name || "" + subtitle: { + const from = modelData.fromVersion || ""; + const to = modelData.toVersion || ""; + const version = from && to ? from + " → " + to : to || from; + const repo = modelData.repo || ""; + return repo && version ? repo + " · " + version : repo || version; + } + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "visibility_off" + visible: SystemUpdateService.canIgnorePackage(packageRow.modelData) + tooltipText: I18n.tr("Ignore package", "tooltip, exclude a package from system updates") + onClicked: SystemUpdateService.ignorePackage(packageRow.modelData.name) + } + } + } + + SettingsRow { + visible: root.packagesExpanded && !SystemUpdateService.isUpgrading && root.systemCount > root.packageListCap + title: I18n.tr("and %1 more", "%1 is the number of update rows not shown").arg(root.systemCount - root.packageListCap) + titleColor: Theme.surfaceVariantText + } + + SettingsRow { + visible: SystemUpdateService.isUpgrading + body: DankFlickable { + id: upgradeLog + width: parent.width + height: Math.min(logText.implicitHeight, Theme.listItemHeight * 5) + contentWidth: width + contentHeight: logText.implicitHeight + clip: true + onContentHeightChanged: contentY = Math.max(0, contentHeight - height) + + StyledText { + id: logText + width: upgradeLog.width + text: root.upgradeRunsInTerminal ? I18n.tr("Running in terminal") : (SystemUpdateService.recentLog || []).join("\n") + font.family: Theme.monoFontFamily + font.pixelSize: Theme.fontSizeSmall + color: Theme.onSurface + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + } + + SettingsRow { + visible: SystemUpdateService.hasError && !root.busy + title: I18n.tr("Failed: %1", "system update error status, %1 is the error message").arg(SystemUpdateService.errorMessage) + titleColor: Theme.error + subtitle: SystemUpdateService.errorHint + iconName: "error_outline" + iconColor: Theme.error + } + } + + SettingsCard { + width: parent.width + title: I18n.tr("Options") + iconName: "tune" + settingKey: "systemUpdater" + collapsible: true + expanded: false + + SettingsDropdownRow { + settingKey: "systemUpdaterCheckInterval" + resetKeys: ["updaterIntervalSeconds"] + resetByKeys: false + onResetRequested: { + root.customIntervalSelected = false; + SettingsData.resetToDefault(["updaterIntervalSeconds"]); + SystemUpdateService.setInterval(SettingsData.updaterIntervalSeconds); + } + tags: ["interval", "poll", "frequency"] + text: I18n.tr("Check interval") + options: root.intervalOptions.map(o => o.label).concat([root.customIntervalLabel]) + currentValue: root.customIntervalSelected ? root.customIntervalLabel : root.intervalLabelFor(SettingsData.updaterIntervalSeconds) + onValueChanged: label => { + if (label === root.customIntervalLabel) { + root.customIntervalSelected = true; + return; + } + root.customIntervalSelected = false; + const secs = root.intervalSecondsFor(label); + SettingsData.set("updaterIntervalSeconds", secs); + SystemUpdateService.setInterval(secs); + } + } + + SettingsTextFieldRow { + id: customIntervalField + leftIconName: "timer" + visible: root.customIntervalSelected + text: I18n.tr("Custom interval in minutes (minimum 5)") + placeholderText: "1440" + value: Math.round(SettingsData.updaterIntervalSeconds / 60).toString() + validator: IntValidator { + bottom: 5 + } + onValueEdited: value => { + const minutes = parseInt(value, 10); + if (isNaN(minutes) || minutes < 5) + return; + const seconds = minutes * 60; + SettingsData.set("updaterIntervalSeconds", seconds); + SystemUpdateService.setInterval(seconds); + } + } + + SettingsToggleRow { + settingKey: "systemUpdaterCheckOnStart" + tags: ["startup", "check", "boot"] + resetKeys: ["updaterCheckOnStart"] + text: I18n.tr("Check on startup") + checked: SettingsData.updaterCheckOnStart + onToggled: checked => SettingsData.set("updaterCheckOnStart", checked) + } + + SettingsToggleRow { + settingKey: "systemUpdaterPauseOnBattery" + tags: ["battery", "power", "pause"] + resetKeys: ["updaterPauseOnBattery"] + visible: BatteryService.batteryAvailable + text: I18n.tr("Pause checks on battery") + checked: SettingsData.updaterPauseOnBattery + onToggled: checked => SettingsData.set("updaterPauseOnBattery", checked) + } + + SettingsToggleRow { + settingKey: "systemUpdaterNotify" + tags: ["notify", "notification", "alert"] + resetKeys: ["updaterNotify"] + text: I18n.tr("Notify me on new updates") + description: I18n.tr("Only when the count grows.") + checked: SettingsData.updaterNotify + onToggled: checked => SettingsData.set("updaterNotify", checked) + } + + SettingsDropdownRow { + settingKey: "systemUpdaterNotifyMin" + tags: ["notify", "notification", "frequency", "throttle"] + resetKeys: ["updaterNotifyMinSeconds"] + visible: SettingsData.updaterNotify + text: I18n.tr("Notify at most") + options: root.notifyOptions.map(o => o.label) + currentValue: (root.notifyOptions.find(o => o.seconds === SettingsData.updaterNotifyMinSeconds) ?? root.notifyOptions[root.notifyOptions.length - 1]).label + onValueChanged: label => { + const opt = root.notifyOptions.find(o => o.label === label); + if (opt) + SettingsData.set("updaterNotifyMinSeconds", opt.seconds); + } + } + + SettingsToggleRow { + settingKey: "systemUpdaterFlatpak" + tags: ["flatpak", "include"] + resetKeys: ["updaterIncludeFlatpak"] + text: I18n.tr("Include Flatpak updates") + visible: (SystemUpdateService.backends || []).some(b => b.repo === "flatpak") + checked: SettingsData.updaterIncludeFlatpak + onToggled: checked => SettingsData.set("updaterIncludeFlatpak", checked) + } + + SettingsToggleRow { + settingKey: "systemUpdaterAUR" + tags: ["aur", "paru", "yay", "shelly"] + resetKeys: ["updaterAllowAUR"] + text: I18n.tr("Include AUR updates") + visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id)) + checked: SettingsData.updaterAllowAUR + onToggled: checked => SettingsData.set("updaterAllowAUR", checked) + } + + SettingsToggleRow { + settingKey: "systemUpdaterReopenAfterUpgrade" + tags: ["reopen", "popout", "terminal", "upgrade"] + resetKeys: ["updaterReopenAfterUpgrade"] + text: I18n.tr("Reopen panel after update") + visible: root.upgradeRunsInTerminal + checked: SettingsData.updaterReopenAfterUpgrade + onToggled: checked => SettingsData.set("updaterReopenAfterUpgrade", checked) + } + + TerminalPickerRow {} + } + + SettingsCard { + id: ignoredPackagesCard + width: parent.width + iconName: "visibility_off" + title: I18n.tr("Ignored packages") + settingKey: "systemUpdaterIgnoredPackages" + tags: ["system", "update", "package", "ignore"] + collapsible: true + expanded: false + + property bool errorIsInvalidName: false + + function addIgnoredPackage() { + const name = newIgnoredPackageField.value.trim(); + if (name === "") + return; + errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name); + if (errorIsInvalidName) { + ignoredPackageError.visible = true; + return; + } + ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name); + if (ignoredPackageError.visible) + return; + newIgnoredPackageField.value = ""; + } + + SettingsTextFieldRow { + id: newIgnoredPackageField + leftIconName: "inventory_2" + text: I18n.tr("Name") + description: { + if (SettingsData.updaterUseCustomCommand) + return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions."); + if (SystemUpdateService.pkgManager === "shelly") + return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored."); + return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button."); + } + placeholderText: I18n.tr("Package name (e.g., docker)") + onAccepted: ignoredPackagesCard.addIgnoredPackage() + onValueEdited: ignoredPackageError.visible = false + + actions: DankIconButton { + variant: "filled" + iconName: "add" + tooltipText: I18n.tr("Ignore package", "tooltip, exclude a package from system updates") + enabled: newIgnoredPackageField.value.trim() !== "" + onClicked: ignoredPackagesCard.addIgnoredPackage() + } + } + + SettingsRow { + id: ignoredPackageError + visible: false + title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.") + titleColor: Theme.error + } + + Repeater { + model: SettingsData.updaterIgnoredPackages + + delegate: SettingsRow { + id: ignoredRow + required property string modelData + required property int index + + title: modelData + iconName: "visibility_off" + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "delete" + iconColor: Theme.error + tooltipText: I18n.tr("Stop ignoring %1", "system updater button tooltip, %1 is the package name").arg(ignoredRow.modelData) + onClicked: SystemUpdateService.unignorePackage(ignoredRow.modelData) + } + } + } + } + + SettingsCard { + width: parent.width + iconName: "terminal" + title: I18n.tr("Advanced") + settingKey: "systemUpdaterAdvanced" + collapsible: true + expanded: false + + SettingsToggleRow { + settingKey: "systemUpdaterCustomCommand" + tags: ["custom", "command", "terminal"] + text: I18n.tr("Use custom command") + checked: SettingsData.updaterUseCustomCommand + onToggled: checked => { + if (!checked) { + updaterCustomCommand.value = ""; + updaterTerminalCustomClass.value = ""; + SettingsData.set("updaterCustomCommand", ""); + SettingsData.set("updaterTerminalAdditionalParams", ""); + } + SettingsData.set("updaterUseCustomCommand", checked); + } + } + + SettingsNoteRow { + enabled: SettingsData.updaterUseCustomCommand + text: I18n.tr("Custom command and terminal params are split on whitespace; paths with spaces will break.") + } + + SettingsTextFieldRow { + id: updaterCustomCommand + leftIconName: "terminal" + visible: SettingsData.updaterUseCustomCommand + resetKeys: ["updaterCustomCommand"] + text: I18n.tr("Custom update command") + placeholderText: "topgrade --no-retry" + value: SettingsData.updaterCustomCommand + onValueEdited: value => SettingsData.set("updaterCustomCommand", value.trim()) + } + + SettingsTextFieldRow { + id: updaterTerminalCustomClass + leftIconName: "terminal" + visible: SettingsData.updaterUseCustomCommand + resetKeys: ["updaterTerminalAdditionalParams"] + text: I18n.tr("Terminal additional parameters") + placeholderText: "-T updater" + value: SettingsData.updaterTerminalAdditionalParams + onValueEdited: value => SettingsData.set("updaterTerminalAdditionalParams", value.trim()) + } + } + } +} diff --git a/quickshell/Modules/Settings/SystemUpdaterTab.qml b/quickshell/Modules/Settings/SystemUpdaterTab.qml deleted file mode 100644 index 0d121eae2..000000000 --- a/quickshell/Modules/Settings/SystemUpdaterTab.qml +++ /dev/null @@ -1,322 +0,0 @@ -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 %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(900)), - seconds: 900 - }, - { - label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(1800)), - seconds: 1800 - }, - { - label: I18n.tr("Every hour"), - seconds: 3600 - }, - { - label: I18n.tr("Every %1", "update check interval option, %1 is a duration such as 30 minutes").arg(I18n.duration(14400)), - seconds: 14400 - }, - { - label: I18n.tr("Once a day"), - seconds: 86400 - } - ] - - readonly property string customIntervalLabel: I18n.tr("Custom") - property bool customIntervalSelected: false - - Component.onCompleted: { - customIntervalSelected = !intervalOptions.some(o => o.seconds === SettingsData.updaterIntervalSeconds); - } - - function intervalLabelFor(seconds) { - for (const opt of intervalOptions) { - if (opt.seconds === seconds) { - return opt.label; - } - } - return customIntervalLabel; - } - - function intervalSecondsFor(label) { - for (const opt of intervalOptions) { - if (opt.label === label) { - return opt.seconds; - } - } - return 1800; - } - - SettingsPage { - id: mainColumn - - SettingsCard { - width: parent.width - settingKey: "systemUpdater" - - SettingsRow { - visible: SystemUpdateService.backends.length > 0 - body: StyledText { - width: parent.width - text: { - const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", "); - return I18n.tr("Detected backends: %1", "system updater settings, %1 is a comma-separated list of package manager names").arg(names); - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - wrapMode: Text.WordWrap - } - } - - SettingsDropdownRow { - settingKey: "systemUpdaterCheckInterval" - resetKeys: ["updaterIntervalSeconds"] - resetByKeys: false - onResetRequested: { - root.customIntervalSelected = false; - SettingsData.resetToDefault(["updaterIntervalSeconds"]); - SystemUpdateService.setInterval(SettingsData.updaterIntervalSeconds); - } - tags: ["interval", "poll", "frequency"] - text: I18n.tr("Check interval") - options: root.intervalOptions.map(o => o.label).concat([root.customIntervalLabel]) - currentValue: root.customIntervalSelected ? root.customIntervalLabel : root.intervalLabelFor(SettingsData.updaterIntervalSeconds) - onValueChanged: label => { - if (label === root.customIntervalLabel) { - root.customIntervalSelected = true; - return; - } - root.customIntervalSelected = false; - const secs = root.intervalSecondsFor(label); - SettingsData.set("updaterIntervalSeconds", secs); - SystemUpdateService.setInterval(secs); - } - } - - SettingsTextFieldRow { - id: customIntervalField - leftIconName: "timer" - visible: root.customIntervalSelected - text: I18n.tr("Custom interval in minutes (minimum 5)") - placeholderText: "30" - value: Math.round(SettingsData.updaterIntervalSeconds / 60).toString() - validator: IntValidator { - bottom: 5 - } - onValueEdited: value => { - const minutes = parseInt(value, 10); - if (isNaN(minutes) || minutes < 5) - return; - const seconds = minutes * 60; - SettingsData.set("updaterIntervalSeconds", seconds); - SystemUpdateService.setInterval(seconds); - } - } - - SettingsToggleRow { - settingKey: "systemUpdaterCheckOnStart" - tags: ["startup", "check", "boot"] - resetKeys: ["updaterCheckOnStart"] - text: I18n.tr("Check on startup") - checked: SettingsData.updaterCheckOnStart - onToggled: checked => SettingsData.set("updaterCheckOnStart", checked) - } - - SettingsToggleRow { - settingKey: "systemUpdaterFlatpak" - tags: ["flatpak", "include"] - resetKeys: ["updaterIncludeFlatpak"] - text: I18n.tr("Include Flatpak updates") - visible: (SystemUpdateService.backends || []).some(b => b.repo === "flatpak") - checked: SettingsData.updaterIncludeFlatpak - onToggled: checked => SettingsData.set("updaterIncludeFlatpak", checked) - } - - SettingsToggleRow { - settingKey: "systemUpdaterAUR" - tags: ["aur", "paru", "yay", "shelly"] - resetKeys: ["updaterAllowAUR"] - text: I18n.tr("Include AUR updates") - visible: (SystemUpdateService.backends || []).some(b => ["paru", "yay", "shelly"].includes(b.id)) - checked: SettingsData.updaterAllowAUR - onToggled: checked => SettingsData.set("updaterAllowAUR", checked) - } - - SettingsToggleRow { - settingKey: "systemUpdaterReopenAfterUpgrade" - tags: ["reopen", "popout", "terminal", "upgrade"] - resetKeys: ["updaterReopenAfterUpgrade"] - text: I18n.tr("Reopen panel after update") - visible: SystemUpdateService.useCustomCommand || (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true) - checked: SettingsData.updaterReopenAfterUpgrade - onToggled: checked => SettingsData.set("updaterReopenAfterUpgrade", checked) - } - - TerminalPickerRow {} - } - - SettingsCard { - id: ignoredPackagesCard - width: parent.width - iconName: "inventory_2" - title: I18n.tr("Ignored packages") - settingKey: "systemUpdaterIgnoredPackages" - tags: ["system", "update", "package", "ignore"] - - property bool errorIsInvalidName: false - - function addIgnoredPackage() { - const name = newIgnoredPackageField.value.trim(); - if (name === "") { - return; - } - errorIsInvalidName = !/^[A-Za-z0-9@._+:-]+$/.test(name); - if (errorIsInvalidName) { - ignoredPackageError.visible = true; - return; - } - ignoredPackageError.visible = !SystemUpdateService.ignorePackage(name); - if (ignoredPackageError.visible) - return; - newIgnoredPackageField.value = ""; - } - - SettingsTextFieldRow { - id: newIgnoredPackageField - leftIconName: "inventory_2" - text: I18n.tr("Name") - description: { - if (SettingsData.updaterUseCustomCommand) - return I18n.tr("Ignored packages only apply to the built-in updater. Your custom command controls its own exclusions."); - if (SystemUpdateService.pkgManager === "shelly") - return I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored."); - return (SettingsData.updaterIgnoredPackages || []).length > 0 ? I18n.tr("Ignored packages are hidden from the updater and skipped by 'Update All'.") : I18n.tr("No packages ignored. Add one here or hover an update in the popout and click the hide button."); - } - placeholderText: I18n.tr("Package name (e.g., docker)") - onAccepted: ignoredPackagesCard.addIgnoredPackage() - onValueEdited: ignoredPackageError.visible = false - - actions: DankIconButton { - variant: "filled" - iconName: "add" - tooltipText: I18n.tr("Ignore package", "tooltip, exclude a package from system updates") - enabled: newIgnoredPackageField.value.trim() !== "" - onClicked: ignoredPackagesCard.addIgnoredPackage() - } - } - - SettingsRow { - id: ignoredPackageError - visible: false - title: ignoredPackagesCard.errorIsInvalidName ? I18n.tr("Invalid package name — letters, digits and @._+:- only.") : I18n.tr("With Shelly, only Flatpak packages in the current update list can be ignored.") - titleColor: Theme.error - } - - SettingsCard { - iconName: "visibility_off" - title: I18n.tr("Ignored (%1)", "ignored update packages section title, %1 is a count").arg((SettingsData.updaterIgnoredPackages || []).length) - collapsible: true - expanded: false - visible: (SettingsData.updaterIgnoredPackages || []).length > 0 - - Repeater { - model: SettingsData.updaterIgnoredPackages - - delegate: SettingsRow { - id: ignoredRow - required property string modelData - required property int index - - title: modelData - iconName: "visibility_off" - - DankActionButton { - anchors.verticalCenter: parent.verticalCenter - iconName: "delete" - iconColor: Theme.error - tooltipText: I18n.tr("Stop ignoring %1", "system updater button tooltip, %1 is the package name").arg(ignoredRow.modelData) - onClicked: { - const list = (SettingsData.updaterIgnoredPackages || []).slice(); - list.splice(ignoredRow.index, 1); - SettingsData.set("updaterIgnoredPackages", list); - } - } - } - } - } - } - - SettingsCard { - width: parent.width - iconName: "tune" - title: I18n.tr("Advanced") - settingKey: "systemUpdaterAdvanced" - - SettingsToggleRow { - settingKey: "systemUpdaterCustomCommand" - tags: ["custom", "command", "terminal"] - text: I18n.tr("Use custom command") - checked: SettingsData.updaterUseCustomCommand - onToggled: checked => { - if (!checked) { - updaterCustomCommand.value = ""; - updaterTerminalCustomClass.value = ""; - SettingsData.set("updaterCustomCommand", ""); - SettingsData.set("updaterTerminalAdditionalParams", ""); - } - SettingsData.set("updaterUseCustomCommand", checked); - } - } - - SettingsRow { - enabled: SettingsData.updaterUseCustomCommand - body: Rectangle { - width: parent.width - height: warnText.implicitHeight + Theme.spacingS * 2 - radius: Theme.cornerRadius - color: Theme.warningHover - - 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 - } - } - } - - SettingsTextFieldRow { - id: updaterCustomCommand - leftIconName: "terminal" - visible: SettingsData.updaterUseCustomCommand - resetKeys: ["updaterCustomCommand"] - text: I18n.tr("Custom update command") - placeholderText: "topgrade --no-retry" - value: SettingsData.updaterCustomCommand - onValueEdited: value => SettingsData.set("updaterCustomCommand", value.trim()) - } - - SettingsTextFieldRow { - id: updaterTerminalCustomClass - leftIconName: "terminal" - visible: SettingsData.updaterUseCustomCommand - resetKeys: ["updaterTerminalAdditionalParams"] - text: I18n.tr("Terminal additional parameters") - placeholderText: "-T updater" - value: SettingsData.updaterTerminalAdditionalParams - onValueEdited: value => SettingsData.set("updaterTerminalAdditionalParams", value.trim()) - } - } - } -} diff --git a/quickshell/Modules/Settings/Widgets/SettingsNoteRow.qml b/quickshell/Modules/Settings/Widgets/SettingsNoteRow.qml new file mode 100644 index 000000000..0a7a83315 --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/SettingsNoteRow.qml @@ -0,0 +1,43 @@ +import QtQuick +import qs.Common +import qs.Widgets + +// Tinted callout inside a card: a warning or hint that applies to the rows around it. +SettingsRow { + id: root + + property string text: "" + property string noteIconName: "warning" + property color tint: Theme.warning + property color tintBackground: Theme.warningHover + + body: Rectangle { + width: parent.width + height: noteRow.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: root.tintBackground + + Row { + id: noteRow + anchors.fill: parent + anchors.margins: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: root.noteIconName + size: Theme.iconSizeSmall + color: root.tint + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + width: parent.width - parent.spacing - Theme.iconSizeSmall + text: root.text + font.pixelSize: Theme.fontSizeSmall + color: root.tint + wrapMode: Text.WordWrap + anchors.verticalCenter: parent.verticalCenter + } + } + } +} diff --git a/quickshell/Services/ChangelogService.qml b/quickshell/Services/ChangelogService.qml index 42ae5dc57..57de93e24 100644 --- a/quickshell/Services/ChangelogService.qml +++ b/quickshell/Services/ChangelogService.qml @@ -1,106 +1,72 @@ pragma Singleton pragma ComponentBehavior: Bound -import QtCore import QtQuick import Quickshell -import Quickshell.Io import qs.Common import qs.Services +// Welcome page, once per release line: hand-authored ChangelogContent (flip changelogEnabled +// once it's written for the line) with the feed's notes chained underneath. Singleton { id: root - readonly property var log: Log.scoped("ChangelogService") - readonly property string currentVersion: "1.6" - readonly property bool changelogEnabled: false - - readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + "/DankMaterialShell" - readonly property string changelogMarkerPath: configDir + "/.changelog-" + currentVersion - - property bool checkComplete: false - property bool changelogDismissed: false - - readonly property bool shouldShowChangelog: { - if (!checkComplete) - return false; - if (!changelogEnabled) - return false; - if (changelogDismissed) - return false; - if (typeof FirstLaunchService !== "undefined" && FirstLaunchService.isFirstLaunch) - return false; - return true; + // Point releases don't re-trigger it. + readonly property string currentVersion: { + const v = ShellVersionService.semverVersion.replace(/^v/, ""); + const m = v.match(/^\d+\.\d+/); + return m ? m[0] : ""; } + readonly property bool changelogEnabled: false + readonly property bool ready: SessionData._hasLoaded && !SessionData.isGreeterMode && FirstLaunchService.checkComplete && currentVersion !== "" + + property bool changelogDismissed: false + property var release: null + property bool releasePending: false + + readonly property bool shouldShowChangelog: ready && changelogEnabled && !changelogDismissed && !FirstLaunchService.isFirstLaunch && SessionData.changelogSeenVersion !== currentVersion signal changelogRequested signal changelogCompleted - Component.onCompleted: { - if (!changelogEnabled) + onReadyChanged: { + if (ready && FirstLaunchService.isFirstLaunch) + SessionData.set("changelogSeenVersion", currentVersion); + } + + onShouldShowChangelogChanged: { + if (!shouldShowChangelog) return; - if (FirstLaunchService.checkComplete) - handleFirstLaunchResult(); + changelogRequested(); + loadRelease(); } - function handleFirstLaunchResult() { - if (FirstLaunchService.isFirstLaunch) { - checkComplete = true; - changelogDismissed = true; - touchMarkerProcess.running = true; - } else { - changelogCheckProcess.running = true; + // Never blocks the welcome page: the daemon may not be up yet. + function loadRelease() { + if (!SystemUpdateService.sysupdateAvailable) { + releasePending = true; + return; } + releasePending = false; + DMSService.sysupdateReleases(false, resp => { + if (root.changelogDismissed) + return; + const list = resp?.result?.releases ?? []; + root.release = list.find(r => r.version === root.currentVersion || (r.version || "").startsWith(root.currentVersion + ".")) ?? null; + }); } - Connections { - target: FirstLaunchService - - function onCheckCompleteChanged() { - if (FirstLaunchService.checkComplete && root.changelogEnabled && !root.checkComplete) - root.handleFirstLaunchResult(); - } + readonly property bool sysupdateAvailable: SystemUpdateService.sysupdateAvailable + onSysupdateAvailableChanged: { + if (sysupdateAvailable && releasePending) + loadRelease(); } function dismissChangelog() { changelogDismissed = true; - touchMarkerProcess.running = true; + release = null; + releasePending = false; + SessionData.set("changelogSeenVersion", currentVersion); changelogCompleted(); } - - Process { - id: changelogCheckProcess - - command: ["sh", "-c", "[ -f '" + changelogMarkerPath + "' ] && echo 'seen' || echo 'show'"] - running: false - - stdout: SplitParser { - onRead: data => { - const result = data.trim(); - root.checkComplete = true; - - switch (result) { - case "seen": - root.changelogDismissed = true; - break; - case "show": - root.changelogRequested(); - break; - } - } - } - } - - Process { - id: touchMarkerProcess - - command: ["sh", "-c", "mkdir -p '" + configDir + "' && touch '" + changelogMarkerPath + "'"] - running: false - - onExited: exitCode => { - if (exitCode !== 0) { - log.warn("Failed to create changelog marker"); - } - } - } } diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index de1b9cf70..b4b57a8a9 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -41,8 +41,6 @@ Singleton { property var clipboardRequestIds: ({}) property int requestIdCounter: 0 property bool shownOutdatedError: false - property string updateCommand: "dms update" - property bool checkingUpdateCommand: false signal pluginsListReceived(var plugins) signal installedPluginsReceived(var plugins) @@ -88,71 +86,9 @@ Singleton { Component.onCompleted: { if (!socketPath || socketPath.length === 0) return; - detectUpdateCommand(); requestSocket.connected = true; } - function detectUpdateCommand() { - checkingUpdateCommand = true; - checkAurHelper.running = true; - } - - Process { - id: checkAurHelper - command: ["sh", "-c", "command -v paru || command -v yay"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - const helper = text.trim(); - if (helper.includes("paru")) { - checkDmsPackage.helper = "paru"; - checkDmsPackage.running = true; - } else if (helper.includes("yay")) { - checkDmsPackage.helper = "yay"; - checkDmsPackage.running = true; - } else { - updateCommand = "dms update"; - checkingUpdateCommand = false; - } - } - } - - onExited: exitCode => { - if (exitCode !== 0) { - updateCommand = "dms update"; - checkingUpdateCommand = false; - } - } - } - - Process { - id: checkDmsPackage - property string helper: "" - command: ["sh", "-c", "pacman -Qi dms-shell-git 2>/dev/null || pacman -Qi dms-shell-bin 2>/dev/null"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - if (text.includes("dms-shell-git")) { - updateCommand = checkDmsPackage.helper + " -S dms-shell-git"; - } else if (text.includes("dms-shell-bin")) { - updateCommand = checkDmsPackage.helper + " -S dms-shell-bin"; - } else { - updateCommand = "dms update"; - } - checkingUpdateCommand = false; - } - } - - onExited: exitCode => { - if (exitCode !== 0) { - updateCommand = "dms update"; - checkingUpdateCommand = false; - } - } - } - DankSocket { id: requestSocket path: root.socketPath @@ -320,7 +256,7 @@ Singleton { if (response.error.includes("unknown method") && response.error.includes("subscribe")) { if (!shownOutdatedError) { log.error("Server does not support subscribe method"); - ToastService.showError(I18n.tr("DMS out of date"), I18n.tr("To update, run the following command:"), updateCommand); + ToastService.showError(I18n.tr("DMS out of date"), I18n.tr("Update the dms package with your package manager, then restart the shell.")); shownOutdatedError = true; } } @@ -844,9 +780,10 @@ Singleton { sendRequest("sysupdate.getState", null, callback); } - function sysupdateRefresh(force, callback) { + function sysupdateRefresh(force, callback, background) { sendRequest("sysupdate.refresh", { - "force": force === true + "force": force === true, + "background": background === true }, callback); } @@ -872,4 +809,14 @@ Singleton { function sysupdateRelease(callback) { sendRequest("sysupdate.release", null, callback); } + + function notifySend(params, callback) { + sendRequest("notify.send", params, callback); + } + + function sysupdateReleases(force, callback) { + sendRequest("sysupdate.releases", { + "force": force === true + }, callback); + } } diff --git a/quickshell/Services/ShellVersionService.qml b/quickshell/Services/ShellVersionService.qml index c554b5bed..dbc2bbe5d 100644 --- a/quickshell/Services/ShellVersionService.qml +++ b/quickshell/Services/ShellVersionService.qml @@ -51,29 +51,34 @@ Singleton { return { major: 0, minor: 0, - patch: 0 + patch: 0, + prerelease: "" }; } 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); } + let prerelease = ""; + const dashIdx = v.indexOf("-"); + if (dashIdx !== -1) { + prerelease = v.substring(dashIdx + 1); + v = v.substring(0, dashIdx); + } const parts = v.split("."); return { major: parseInt(parts[0], 10) || 0, minor: parseInt(parts[1], 10) || 0, - patch: parseInt(parts[2], 10) || 0 + patch: parseInt(parts[2], 10) || 0, + prerelease }; } + // Numeric core only; plugin requirements treat 1.7.0-beta as satisfying >=1.7.0. function compareVersions(v1, v2) { if (v1.major !== v2.major) { return v1.major - v2.major; @@ -84,6 +89,41 @@ Singleton { return v1.patch - v2.patch; } + // SemVer ordering for release feeds: 1.7.0-beta.2 < 1.7.0-beta.10 < 1.7.0. + function compareReleases(v1, v2) { + const core = compareVersions(v1, v2); + if (core !== 0) + return core; + const p1 = v1.prerelease || ""; + const p2 = v2.prerelease || ""; + if (p1 === p2) + return 0; + if (p1 === "") + return 1; + if (p2 === "") + return -1; + const a = p1.split("."); + const b = p2.split("."); + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (a[i] === undefined) + return -1; + if (b[i] === undefined) + return 1; + const na = /^\d+$/.test(a[i]); + const nb = /^\d+$/.test(b[i]); + if (na && nb) { + if (a[i] !== b[i]) + return parseInt(a[i], 10) - parseInt(b[i], 10); + continue; + } + if (na !== nb) + return na ? -1 : 1; + if (a[i] !== b[i]) + return a[i] < b[i] ? -1 : 1; + } + return 0; + } + function checkVersionRequirement(requirementStr, currentVersion) { if (!requirementStr || typeof requirementStr !== "string") { return true; diff --git a/quickshell/Services/SystemUpdateService.qml b/quickshell/Services/SystemUpdateService.qml index b0d07ccd9..5acfea616 100644 --- a/quickshell/Services/SystemUpdateService.qml +++ b/quickshell/Services/SystemUpdateService.qml @@ -10,6 +10,20 @@ Singleton { id: root property int refCount: 0 + // `Ref { modules: ["releases"] }` also holds the feed document; a plain Ref only keeps the daemon polling. + property int releasesRefCount: 0 + + function addRef(modules) { + refCount++; + if (modules.includes("releases")) + releasesRefCount++; + } + + function removeRef(modules) { + refCount = Math.max(0, refCount - 1); + if (modules.includes("releases")) + releasesRefCount = Math.max(0, releasesRefCount - 1); + } property bool sysupdateAvailable: false @@ -27,11 +41,41 @@ Singleton { property string pkgManager: "" property bool distributionSupported: false property var recentLog: [] - property int intervalSeconds: 1800 + property int intervalSeconds: 86400 property int lastCheckUnix: 0 property int nextCheckUnix: 0 + property string shellInstallMethod: "unknown" + property string shellPackageName: "" + property string shellChannel: "unknown" + property string shellRunning: "" + property string shellInstalled: "" + property int shellGitBuild: 0 + property bool restartPending: false + property bool rebootRecommended: false + property var rebootPackages: [] + property var _rawShellUpdate: null + // From the filtered list, so an AUR-off/ignored dms package doesn't advertise. + readonly property var shellUpdate: _rawShellUpdate ? (availableUpdates.find(p => p.name === _rawShellUpdate.name) ?? null) : null + property string shellLatestVersion: "" + // -1 while unknown (stable channel or no feed yet) + property int commitsBehind: -1 + property int releasesFetchedUnix: 0 + property var releases: null + readonly property int updateCount: availableUpdates.length + readonly property var systemUpdates: availableUpdates.filter(p => !shellUpdate || p.name !== shellUpdate.name) + // The feed never flips this: DMS only updates through the package manager. + readonly property bool shellUpdateAvailable: shellUpdate !== null + readonly property string shellUpdateVersion: shellUpdate?.toVersion || "" + // Notes follow the version the repo offers (or the running one), never the feed's newest tag. + readonly property string notesVersion: (shellUpdateVersion || shellRunning).replace(/^v/, "") + readonly property var notesRelease: { + const list = releases?.releases ?? []; + const mm = notesVersion.match(/^\d+\.\d+/)?.[0] ?? ""; + return list.find(r => r.version === notesVersion) ?? list.find(r => mm !== "" && (r.version === mm || r.version.startsWith(mm + "."))) ?? null; + } + readonly property bool shellManagedExternally: shellInstallMethod === "nix" readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0 readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0 @@ -98,9 +142,7 @@ Singleton { if (has && !sysupdateAvailable) { sysupdateAvailable = true; requestState(); - // The daemon always starts at defaultIntervalSeconds (30 min) and - // has no persistence of its own, so re-apply the saved interval - // on every fresh connection (daemon (re)start). + // The daemon persists its last check but not the interval; re-apply it on every fresh connection. setInterval(SettingsData.updaterIntervalSeconds); } else if (!has) { sysupdateAvailable = false; @@ -132,10 +174,31 @@ Singleton { distributionPretty = data.distroPretty || ""; distributionSupported = (backends.length > 0); recentLog = data.recentLog || []; - intervalSeconds = data.intervalSeconds || 1800; + intervalSeconds = data.intervalSeconds || 86400; lastCheckUnix = data.lastCheckUnix || 0; nextCheckUnix = data.nextCheckUnix || 0; + const shell = data.shell || {}; + shellInstallMethod = shell.installMethod || "unknown"; + shellPackageName = shell.packageName || ""; + shellChannel = shell.channel || "unknown"; + shellRunning = shell.running || ""; + shellInstalled = shell.installed || ""; + shellGitBuild = shell.gitBuild || 0; + restartPending = shell.restartPending === true; + const reboot = data.reboot || {}; + rebootRecommended = reboot.recommended === true; + rebootPackages = reboot.packages || []; + _rawShellUpdate = shell.updatePackage || null; + shellLatestVersion = shell.latestVersion || ""; + commitsBehind = typeof shell.commitsBehind === "number" ? shell.commitsBehind : -1; + const fetched = data.releasesFetchedUnix || 0; + if (fetched !== releasesFetchedUnix) { + releasesFetchedUnix = fetched; + if (releasesRefCount > 0) + loadReleases(false); + } + const phase = data.phase || "idle"; switch (phase) { case "refreshing": @@ -162,6 +225,7 @@ Singleton { errorCode = ""; errorHint = ""; } + _maybeNotify(); } function _filterUpdates(pkgs) { @@ -179,6 +243,42 @@ Singleton { availableUpdates = _filterUpdates(_rawUpdates); } + // Only when the count grows past the last announcement. + function _maybeNotify() { + if (!SettingsData.updaterNotify || isChecking || isUpgrading) + return; + if (updateCount === 0) { + if (SessionData.updaterNotifiedCount !== 0) + SessionData.set("updaterNotifiedCount", 0); + return; + } + if (updateCount <= SessionData.updaterNotifiedCount) + return; + const now = Math.floor(Date.now() / 1000); + if (now - SessionData.updaterNotifiedUnix < SettingsData.updaterNotifyMinSeconds) + return; + if (_notifyInFlight) + return; + _notifyInFlight = true; + const count = updateCount; + DMSService.notifySend({ + "summary": count === 1 ? I18n.tr("%1 update", "singular, %1 is 1, available system update count").arg(count) : I18n.tr("%1 updates", "plural, %1 is a count of available system updates").arg(count), + "body": I18n.tr("Software updates are ready to install."), + "icon": "system-software-update", + "actionLabel": I18n.tr("Settings"), + "actionArgs": ["ipc", "call", "settings", "openWith", "updater"] + }, resp => { + root._notifyInFlight = false; + // A failed send retries on the next state push. + if (!resp || resp.error) + return; + SessionData.set("updaterNotifiedUnix", Math.floor(Date.now() / 1000)); + SessionData.set("updaterNotifiedCount", count); + }); + } + + property bool _notifyInFlight: false + function ignorePackage(name) { if (!name) return false; @@ -203,6 +303,20 @@ Singleton { DMSService.sysupdateRefresh(false, null); } + function loadReleases(force) { + if (!DMSService.isConnected || !sysupdateAvailable) + return; + DMSService.sysupdateReleases(force, resp => { + // A late reply must not repopulate a feed the last Ref already dropped. + if (root.releasesRefCount > 0 && resp && resp.result) + root.releases = resp.result; + }); + } + + function restartShell() { + Quickshell.execDetached(["dms", "restart"]); + } + function runUpdates(opts) { const params = opts || {}; params.ignored = SettingsData.updaterIgnoredPackages || []; @@ -238,7 +352,7 @@ Singleton { if (!DMSService.isConnected || !sysupdateAvailable) return; _startupCheckDone = true; - Qt.callLater(() => root.checkForUpdates()); + Qt.callLater(() => DMSService.sysupdateRefresh(false, null, true)); } onRefCountChanged: { @@ -247,12 +361,21 @@ Singleton { _syncAcquire(); Qt.callLater(() => root._maybeStartupCheck()); } + onReleasesRefCountChanged: { + if (releasesRefCount <= 0) + releases = null; + else if (releases === null) + loadReleases(false); + } onSysupdateAvailableChanged: _syncAcquire() property bool _acquired: false + // Releasing the ref parks the daemon scheduler; its deadline is kept, so checks resume on AC. + readonly property bool pausedOnBattery: SettingsData.updaterPauseOnBattery && BatteryService.batteryAvailable && !BatteryService.isPluggedIn + onPausedOnBatteryChanged: _syncAcquire() function _syncAcquire() { - const want = refCount > 0 && sysupdateAvailable; + const want = refCount > 0 && sysupdateAvailable && !pausedOnBattery; if (want === _acquired) { return; } diff --git a/quickshell/Widgets/GradientText.qml b/quickshell/Widgets/GradientText.qml new file mode 100644 index 000000000..4f79085fc --- /dev/null +++ b/quickshell/Widgets/GradientText.qml @@ -0,0 +1,44 @@ +import QtQuick +import QtQuick.Effects +import qs.Common + +// StyledText filled with a horizontal gradient; the glyphs are the mask. +Item { + id: root + + property alias text: mask.text + property alias font: mask.font + property color startColor: Theme.surfaceText + property color endColor: Theme.primary + + implicitWidth: mask.implicitWidth + implicitHeight: mask.implicitHeight + + StyledText { + id: mask + anchors.fill: parent + visible: false + layer.enabled: true + color: root.startColor + } + + Rectangle { + anchors.fill: parent + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: mask + } + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0 + color: root.startColor + } + GradientStop { + position: 1 + color: root.endColor + } + } + } +} diff --git a/quickshell/Widgets/ReleaseNotesCard.qml b/quickshell/Widgets/ReleaseNotesCard.qml new file mode 100644 index 000000000..473318b9b --- /dev/null +++ b/quickshell/Widgets/ReleaseNotesCard.qml @@ -0,0 +1,137 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Column { + id: root + + property var release: null + property bool showTitle: true + + readonly property bool hasRelease: release !== null && release !== undefined + readonly property var counts: hasRelease ? (release.counts || {}) : ({}) + readonly property var highlights: hasRelease ? (release.highlights || []) : [] + // A feed string only reaches a URL handler as a plain https link. + readonly property string blogUrl: webUrl(hasRelease ? release.blogUrl : "") + readonly property string releaseUrl: webUrl(hasRelease ? release.url : "") + + function webUrl(u) { + return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; + } + + spacing: Theme.spacingM + + Row { + visible: root.showTitle && root.hasRelease + spacing: Theme.spacingS + + StyledText { + text: root.hasRelease ? "v" + root.release.version : "" + font.pixelSize: Theme.fontSizeXLarge + font.weight: Theme.fontWeightBold + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + DankBadge { + visible: root.hasRelease && (root.release.codename || "") !== "" + text: root.hasRelease ? root.release.codename || "" : "" + color: Theme.primaryContainer + textColor: Theme.accentOnPrimaryContainer + anchors.verticalCenter: parent.verticalCenter + } + + DankBadge { + visible: root.hasRelease && root.release.prerelease === true + text: I18n.tr("Pre-release") + color: Theme.chipSurface + textColor: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + } + + StyledText { + width: parent.width + visible: root.hasRelease && (root.release.summary || "") !== "" + text: root.hasRelease ? root.release.summary || "" : "" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + visible: text !== "" + text: { + const parts = []; + if (root.counts.breaking > 0) + parts.push(I18n.tr("%1 breaking", "release counts, %1 is a number of breaking changes").arg(root.counts.breaking)); + if (root.counts.features > 0) + parts.push(I18n.tr("%1 features", "release counts, %1 is a number").arg(root.counts.features)); + if (root.counts.fixes > 0) + parts.push(I18n.tr("%1 fixes", "release counts, %1 is a number").arg(root.counts.fixes)); + if (root.counts.other > 0) + parts.push(I18n.tr("%1 other", "release counts, %1 is a number of other changes").arg(root.counts.other)); + return parts.join(" · "); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + Column { + width: parent.width + visible: root.highlights.length > 0 + spacing: Theme.spacingS + + Repeater { + model: root.highlights + + delegate: Row { + id: highlightRow + required property string modelData + width: parent.width + spacing: Theme.spacingS + + DankIcon { + name: "arrow_right" + size: Theme.iconSizeSmall + color: Theme.primary + anchors.top: parent.top + } + + StyledText { + width: parent.width - Theme.iconSizeSmall - Theme.spacingS + text: highlightRow.modelData + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + wrapMode: Text.WordWrap + } + } + } + } + + Flow { + width: parent.width + visible: root.hasRelease + spacing: Theme.spacingS + + DankButton { + visible: root.blogUrl !== "" + text: I18n.tr("Read the blog post") + iconName: "article" + backgroundColor: Theme.primary + textColor: Theme.onPrimary + onClicked: Qt.openUrlExternally(root.blogUrl) + } + + DankButton { + visible: root.releaseUrl !== "" + text: I18n.tr("View on GitHub") + iconName: "open_in_new" + backgroundColor: Theme.chipSurface + textColor: Theme.surfaceText + onClicked: Qt.openUrlExternally(root.releaseUrl) + } + } +} diff --git a/quickshell/assets/release-banner.svg b/quickshell/assets/release-banner.svg new file mode 100644 index 000000000..eb84a6c38 --- /dev/null +++ b/quickshell/assets/release-banner.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/quickshell/tests/qml/software-updates-load.qml b/quickshell/tests/qml/software-updates-load.qml new file mode 100644 index 000000000..acb949e5a --- /dev/null +++ b/quickshell/tests/qml/software-updates-load.qml @@ -0,0 +1,58 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.DankCommon.Common as DC + +// The Software updates page and its Release notes leaf must instantiate. +ShellRoot { + id: root + + Component.onCompleted: { + Quickshell.watchFiles = false; + DC.Style.theme = Theme; + DC.Style.settings = SettingsData; + DC.I18n.backend = I18n; + } + + Timer { + interval: 0 + running: SettingsData._hasLoaded && SessionData._hasLoaded + onTriggered: { + host.setSource("Modules/Settings/SoftwareUpdatesTab.qml"); + leaf.setSource("Modules/Settings/ChangelogTab.qml"); + } + } + + Item { + width: 720 + height: 900 + + Loader { + id: host + width: parent.width + } + + Loader { + id: leaf + width: parent.width + } + } + + Timer { + interval: 25 + repeat: true + running: host.status !== Loader.Null && leaf.status !== Loader.Null + + onTriggered: { + if (host.status === Loader.Loading || leaf.status === Loader.Loading) + return; + if (host.status === Loader.Error || leaf.status === Loader.Error) { + console.log("FIXTURE_FAIL " + host.sourceComponent?.errorString() + " " + leaf.sourceComponent?.errorString()); + Qt.exit(1); + return; + } + console.log("FIXTURE_PASS"); + Qt.exit(0); + } + } +} diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 74f14ba18..50a94d6f2 100644 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -129,7 +129,8 @@ TAB_INDEX_MAP = { "NotificationRulesTab.qml": 55, "OSDTab.qml": 18, "RunningAppsTab.qml": 19, - "SystemUpdaterTab.qml": 20, + "SoftwareUpdatesTab.qml": 20, + "ChangelogTab.qml": 66, "PowerSleepTab.qml": 21, "ClipboardTab.qml": 23, "DisplayConfigTab.qml": 24, diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index fc2aa3533..f470d0a64 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -2196,15 +2196,15 @@ "history", "integrations", "multiplexers", - "packages", + "notes", "paste", "print", "printers", "printing", + "release", + "software", "system", - "updater", - "updates", - "upgrade" + "updates" ], "icon": "memory" }, @@ -8557,38 +8557,34 @@ "section": "systemUpdaterAdvanced", "label": "Advanced", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "advanced", "command", "custom", "integrations", - "packages", + "software", "system", "terminal", - "updater", - "updates", - "upgrade" + "updates" ], "parentLabel": "System & integrations", - "icon": "tune" + "icon": "terminal" }, { "section": "systemUpdaterCheckInterval", "label": "Check interval", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "check", "frequency", "integrations", "interval", - "packages", "poll", + "software", "system", - "updater", - "updates", - "upgrade" + "updates" ], "parentLabel": "System & integrations" }, @@ -8596,56 +8592,71 @@ "section": "systemUpdaterCheckOnStart", "label": "Check on startup", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "boot", "check", "integrations", - "packages", + "software", "startup", "system", - "updater", - "updates", - "upgrade" + "updates" ], "parentLabel": "System & integrations" }, + { + "section": "softwareUpdatesShell", + "label": "DankMaterialShell", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "channel", + "dankmaterialshell", + "dms", + "integrations", + "restart", + "shell", + "software", + "system", + "updates", + "version" + ], + "parentLabel": "System & integrations", + "icon": "deployed_code" + }, { "section": "systemUpdaterIgnoredPackages", "label": "Ignored packages", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "ignore", "ignored", "integrations", "package", "packages", + "software", "system", "update", - "updater", - "updates", - "upgrade" + "updates" ], "parentLabel": "System & integrations", - "icon": "inventory_2" + "icon": "visibility_off" }, { "section": "systemUpdaterAUR", "label": "Include AUR updates", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "aur", "include", "integrations", - "packages", "paru", "shelly", + "software", "system", - "updater", "updates", - "upgrade", "yay" ], "parentLabel": "System & integrations" @@ -8654,16 +8665,92 @@ "section": "systemUpdaterFlatpak", "label": "Include Flatpak updates", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "flatpak", "include", "integrations", - "packages", + "software", "system", - "updater", - "updates", - "upgrade" + "updates" + ], + "parentLabel": "System & integrations" + }, + { + "section": "systemUpdaterNotifyMin", + "label": "Notify at most", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "frequency", + "integrations", + "most", + "notification", + "notify", + "software", + "system", + "throttle", + "updates" + ], + "parentLabel": "System & integrations" + }, + { + "section": "systemUpdaterNotify", + "label": "Notify me on new updates", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "alert", + "count", + "grows", + "integrations", + "new", + "notification", + "notify", + "software", + "system", + "updates" + ], + "parentLabel": "System & integrations", + "description": "Only when the count grows." + }, + { + "section": "systemUpdater", + "label": "Options", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "count", + "frequency", + "grows", + "integrations", + "interval", + "options", + "poll", + "software", + "system", + "updates" + ], + "parentLabel": "System & integrations", + "icon": "tune", + "description": "Only when the count grows." + }, + { + "section": "systemUpdaterPauseOnBattery", + "label": "Pause checks on battery", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "battery", + "charge", + "charging", + "checks", + "integrations", + "pause", + "power", + "software", + "system", + "updates" ], "parentLabel": "System & integrations" }, @@ -8671,56 +8758,67 @@ "section": "systemUpdaterReopenAfterUpgrade", "label": "Reopen panel after update", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "after", "integrations", - "packages", "panel", "popout", "reopen", + "software", "system", "terminal", "update", - "updater", "updates", "upgrade" ], "parentLabel": "System & integrations" }, { - "section": "systemUpdater", - "label": "System updater", + "section": "_tab_20", + "label": "Software updates", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ - "frequency", "integrations", - "interval", - "packages", - "poll", + "software", "system", - "updater", - "updates", - "upgrade" + "updates" ], - "parentLabel": "System & integrations" + "icon": "system_update_alt", + "parentLabel": "System & integrations", + "description": "DMS and system updates" + }, + { + "section": "softwareUpdatesSystem", + "label": "System packages", + "tabIndex": 20, + "category": "Software updates", + "keywords": [ + "aur", + "flatpak", + "integrations", + "packages", + "software", + "system", + "updates" + ], + "parentLabel": "System & integrations", + "icon": "inventory_2" }, { "section": "systemUpdaterCustomCommand", "label": "Use custom command", "tabIndex": 20, - "category": "System updater", + "category": "Software updates", "keywords": [ "command", "custom", "integrations", - "packages", + "software", "system", "terminal", - "updater", - "updates", - "upgrade" + "updates" ], "parentLabel": "System & integrations" }, @@ -10215,6 +10313,14 @@ "runtimeType": "barWidgetOption", "optionFile": "LauncherButtonOptions.qml" }, + { + "section": "barWidgetOption:SystemUpdateOptions.qml:2", + "label": "Software updates", + "tabIndex": 22, + "keywords": [], + "runtimeType": "barWidgetOption", + "optionFile": "SystemUpdateOptions.qml" + }, { "section": "barWidgetOption:AppsDockOptions.qml:28", "label": "Spacing", @@ -16988,5 +17094,21 @@ ], "parentLabel": "Bar", "conditionKey": "dotEnabled" + }, + { + "section": "softwareUpdatesChangelog", + "label": "Release notes", + "tabIndex": 66, + "category": "Release notes", + "keywords": [ + "changelog", + "highlights", + "integrations", + "notes", + "release", + "system" + ], + "parentLabel": "System & integrations", + "icon": "cloud_off" } ] diff --git a/scripts/release-notes.py b/scripts/release-notes.py index 61fe3884c..b32ed26b6 100755 --- a/scripts/release-notes.py +++ b/scripts/release-notes.py @@ -8,6 +8,7 @@ master commit so point releases credit the right PR and author. Usage: release-notes.py v1.4.6..v1.5.0 --format github # GH release "What's Changed" + release-notes.py v1.4.6..v1.5.0 --format changelog # danklinux-docs changelog.mdx entry release-notes.py v1.4.6..v1.5.0 --format blog # MDX contributor tables for danklinux-docs release-notes.py v1.4.6..v1.5.0 --format checklist # flat PR/author review list @@ -38,7 +39,7 @@ CATEGORIES = OrderedDict([ ("other", "Other Changes"), ]) SUBJECT_HINTS = [ - (re.compile(r"^feat", re.I), "feature"), + (re.compile(r"^(feat|add)\b", re.I), "feature"), (re.compile(r"^(fix|hotfix|bugfix)", re.I), "fix"), (re.compile(r"^[\w./-]+: *fix", re.I), "fix"), (re.compile(r"^docs?\b", re.I), "docs"), @@ -110,13 +111,30 @@ def fetch_pr_data(repo, shas): return result +INTERNAL_PREFIX_RE = re.compile( + r"^(?:refactor|chore|ci|build|test|style|perf|imp|revert|cleanup|bump|sync|release)\b", re.I) +FEATURE_CUE_RE = re.compile( + r"^(?:add(?:s|ed)?|introduce|implement|support|new|allow|enable|expose)\b" + r"|\b(?:add|added|adds) support\b|\bnew (?:option|setting|widget|command|backend)\b", re.I) +FIX_CUE_RE = re.compile( + r"\b(?:fix(?:es|ed)?|regression|crash(?:es|ing)?|leak|broken|no longer|stop(?:s|ped)? (?:\w+ )?(?:crash|leak|spam)\w*)\b", re.I) + + def categorize(labels, subject): for key in CATEGORIES: if key in labels: return key + subject = subject or "" for rx, key in SUBJECT_HINTS: - if rx.search(subject or ""): + if rx.search(subject): return key + body = AREA_PREFIX_RE.sub("", TYPE_PREFIX_RE.sub("", subject, count=1), count=1) + if INTERNAL_PREFIX_RE.search(subject): + return "other" + if FEATURE_CUE_RE.search(body): + return "feature" + if FIX_CUE_RE.search(body): + return "fix" return "other" @@ -183,10 +201,8 @@ def author_md(e): return e["author_name"] -def format_github(repo, entries, rng, bare=False): - prev = rng.split("..")[0] - tag = rng.split("..")[1] if ".." in rng else "HEAD" - out = [] if bare else ["## What's Changed", ""] +def format_sections(entries, escape=lambda t: t): + out = [] for key, heading in CATEGORIES.items(): group = [e for e in entries if e["category"] == key] if not group: @@ -194,13 +210,37 @@ def format_github(repo, entries, rng, bare=False): out.append(f"### {heading}") for e in group: ref = f" in #{e['number']}" if e["number"] else f" ({e['sha'][:7]})" - out.append(f"- {e['title']} by {author_md(e)}{ref}") + out.append(f"- {escape(e['title'])} by {author_md(e)}{ref}") out.append("") + return out + + +def format_github(repo, entries, rng, bare=False): + prev = rng.split("..")[0] + tag = rng.split("..")[1] if ".." in rng else "HEAD" + out = [] if bare else ["## What's Changed", ""] + out += format_sections(entries) if not bare: out.append(f"**Full Changelog**: https://github.com/{repo}/compare/{prev}...{tag}") return "\n".join(out) +def mdx_safe(text): + # titles land in MDX: escape JSX/expression/table chars + return (text.replace("{", "{").replace("<", "<") + .replace("|", "\\|")) + + +# Same sections as the GitHub body the release feed parses, so docs and shell never disagree. +def format_changelog(repo, entries, rng): + tag = rng.split("..")[1] if ".." in rng else "HEAD" + date = run(["git", "log", "-1", "--format=%cs", tag]).strip() + out = [f"## {tag}", "", f"**Released**: {date}", ""] + out += format_sections(entries, mdx_safe) + out += [f"**GitHub Release**: https://github.com/{repo}/releases/tag/{tag}", "", "---"] + return "\n".join(out) + + TYPE_PREFIX_RE = re.compile( r"^(?:feat(?:ure)?|fix(?:es)?|hotfix|bugfix|docs?|refactor|chore|perf" r"|style|test|i18n|build|ci)\b!?\s*(?:\([^)]*\))?\s*[:/\-]\s*", re.I) @@ -221,11 +261,6 @@ def clean_title(title): def format_blog(repo, entries, rng, exclude=frozenset()): - def mdx_safe(text): - # titles land in MDX table cells: escape JSX/expression/table chars - return (text.replace("{", "{").replace("<", "<") - .replace("|", "\\|")) - def is_excluded(e): return ((e["login"] or "").lower() in exclude or e["author_name"].lower() in exclude) @@ -302,7 +337,7 @@ def format_checklist(entries): def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("range", help="git range, e.g. v1.4.6..v1.5.0") - ap.add_argument("--format", choices=["github", "blog", "checklist"], default="github") + ap.add_argument("--format", choices=["github", "changelog", "blog", "checklist"], default="github") ap.add_argument("--repo", default=None, help="owner/name (default: from origin)") ap.add_argument("--exclude", action="append", default=None, metavar="LOGIN", help="drop this author (repeatable). Blog format defaults " @@ -345,6 +380,8 @@ def main(): and e["author_name"].lower() not in drop] if args.format == "github": text = format_github(repo, entries, args.range, bare=args.bare) + elif args.format == "changelog": + text = format_changelog(repo, entries, args.range) else: text = format_checklist(entries)