feat(settings): dms system software updates & versioning support

This commit is contained in:
purian23 committed 2026-09-28 19:45:07 -04:00
1 parent 33bc0a0dff
commit eda77f7816
45 files changed
+3182 -699

No files matched your search

+9 -12
View File
@@ -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])
}
+29 -6
View File
@@ -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")
+47
View File
@@ -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
}
+14
View File
@@ -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)
}
+17 -7
View File
@@ -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]) + "..."
}
@@ -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
}
+52 -18
View File
@@ -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() }()
@@ -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")
}
}
+2 -2
View File
@@ -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
@@ -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`: "<state> <pkgver> <description>"
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`: "<pkgver> <action> <arch> <repository> <installedsize> <downloadsize>"
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
}
@@ -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)
}
})
}
}
+5 -1
View File
@@ -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 {
+137 -20
View File
@@ -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
}
+36
View File
@@ -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))
}
+155
View File
@@ -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)
}
}
+183
View File
@@ -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
}
@@ -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")
}
}
+86 -14
View File
@@ -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
}
+3
View File
@@ -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
+3
View File
@@ -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
+11 -6
View File
@@ -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)
@@ -24,6 +24,15 @@ var LOCAL_SPEC = {
idleInhibitedUntil: {
def: 0
},
updaterNotifiedUnix: {
def: 0
},
updaterNotifiedCount: {
def: 0
},
changelogSeenVersion: {
def: ""
},
terminalOverride: {
def: ""
},
+11 -1
View File
@@ -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
-5
View File
@@ -1343,11 +1343,6 @@ Item {
Component.onCompleted: show()
}
Component.onCompleted: {
if (ChangelogService.shouldShowChangelog)
active = true;
}
Connections {
target: ChangelogService
function onChangelogRequested() {
@@ -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
}
}
}
@@ -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 {
@@ -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
+9 -55
View File
@@ -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
@@ -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")
}
}
}
@@ -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
}
}
}
}
}
@@ -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())
}
}
}
}
@@ -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())
}
}
}
}
@@ -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
}
}
}
}
+43 -77
View File
@@ -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");
}
}
}
}
+14 -67
View File
@@ -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);
}
}
+46 -6
View File
@@ -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;
+130 -7
View File
@@ -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;
}
+44
View File
@@ -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
}
}
}
}
+137
View File
@@ -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)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
<!-- Release hero: the danklinux.com blog banner (diagonal bands, top/bottom glow, marble veins), white on transparent so the shell tints it. -->
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="300" viewBox="0 0 800 300" preserveAspectRatio="none">
<defs>
<radialGradient id="top" cx="50%" cy="-20%" rx="80%" ry="50%"><stop offset="0" stop-color="#fff" stop-opacity="0.55"/><stop offset="0.6" stop-color="#fff" stop-opacity="0"/></radialGradient>
<radialGradient id="bottom" cx="50%" cy="100%" rx="120%" ry="60%"><stop offset="0" stop-color="#fff" stop-opacity="0.25"/><stop offset="0.7" stop-color="#fff" stop-opacity="0"/></radialGradient>
<linearGradient id="vein" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0.3"/><stop offset="0.35" stop-color="#fff" stop-opacity="0.9"/><stop offset="0.65" stop-color="#fff" stop-opacity="0.7"/><stop offset="1" stop-color="#fff" stop-opacity="0.3"/></linearGradient>
<pattern id="bands" width="190" height="190" patternUnits="userSpaceOnUse" patternTransform="rotate(22)"><rect width="70" height="190" fill="#fff" fill-opacity="0.16"/></pattern>
<radialGradient id="scrim" cx="50%" cy="50%" rx="30%" ry="35%"><stop offset="0" stop-color="#000" stop-opacity="1"/><stop offset="0.5" stop-color="#000" stop-opacity="0.6"/><stop offset="0.78" stop-color="#000" stop-opacity="0"/></radialGradient>
<mask id="clear"><rect width="800" height="300" fill="#fff"/><rect width="800" height="300" fill="url(#scrim)"/></mask>
</defs>
<rect width="800" height="300" fill="url(#bands)"/>
<rect width="800" height="300" fill="url(#top)"/>
<rect width="800" height="300" fill="url(#bottom)"/>
<g fill="none" stroke="url(#vein)" stroke-linecap="round" mask="url(#clear)">
<path stroke-width="2.4" d="M -20 34 C 130 60, 250 12, 410 44 S 690 84, 830 58"/>
<path stroke-width="1.8" d="M -20 268 C 120 240, 310 286, 510 252 S 740 226, 830 250"/>
<path stroke-width="1.1" stroke-opacity="0.55" d="M 92 44 C 122 106, 84 170, 126 232 S 150 290, 144 320"/>
<path stroke-width="1.1" stroke-opacity="0.55" d="M 692 66 C 722 126, 706 186, 738 244 S 758 296, 752 320"/>
<path stroke-width="0.9" stroke-opacity="0.45" d="M 762 24 C 736 62, 772 100, 752 140"/>
<path stroke-width="0.7" stroke-opacity="0.4" d="M 36 88 C 58 128, 44 172, 68 210"/>
<path stroke-width="0.6" stroke-opacity="0.35" d="M 330 262 C 358 280, 350 300, 372 318"/>
<path stroke-width="0.6" stroke-opacity="0.35" d="M 476 34 C 502 48, 498 72, 522 84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -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);
}
}
}
@@ -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,
@@ -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"
}
]
+50 -13
View File
@@ -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("{", "&#123;").replace("<", "&lt;")
.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("{", "&#123;").replace("<", "&lt;")
.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)