mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
feat(settings): dms system software updates & versioning support
This commit is contained in:
1 parent
33bc0a0dff
commit
eda77f7816
45 files changed
+3182
-699
No files matched your search
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+1
-1
Submodule dank-qml-common updated: 4caf230914...445e6ad070.
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ""
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -8,6 +8,7 @@ master commit so point releases credit the right PR and author.
|
||||
|
||||
Usage:
|
||||
release-notes.py v1.4.6..v1.5.0 --format github # GH release "What's Changed"
|
||||
release-notes.py v1.4.6..v1.5.0 --format changelog # danklinux-docs changelog.mdx entry
|
||||
release-notes.py v1.4.6..v1.5.0 --format blog # MDX contributor tables for danklinux-docs
|
||||
release-notes.py v1.4.6..v1.5.0 --format checklist # flat PR/author review list
|
||||
|
||||
@@ -38,7 +39,7 @@ CATEGORIES = OrderedDict([
|
||||
("other", "Other Changes"),
|
||||
])
|
||||
SUBJECT_HINTS = [
|
||||
(re.compile(r"^feat", re.I), "feature"),
|
||||
(re.compile(r"^(feat|add)\b", re.I), "feature"),
|
||||
(re.compile(r"^(fix|hotfix|bugfix)", re.I), "fix"),
|
||||
(re.compile(r"^[\w./-]+: *fix", re.I), "fix"),
|
||||
(re.compile(r"^docs?\b", re.I), "docs"),
|
||||
@@ -110,13 +111,30 @@ def fetch_pr_data(repo, shas):
|
||||
return result
|
||||
|
||||
|
||||
INTERNAL_PREFIX_RE = re.compile(
|
||||
r"^(?:refactor|chore|ci|build|test|style|perf|imp|revert|cleanup|bump|sync|release)\b", re.I)
|
||||
FEATURE_CUE_RE = re.compile(
|
||||
r"^(?:add(?:s|ed)?|introduce|implement|support|new|allow|enable|expose)\b"
|
||||
r"|\b(?:add|added|adds) support\b|\bnew (?:option|setting|widget|command|backend)\b", re.I)
|
||||
FIX_CUE_RE = re.compile(
|
||||
r"\b(?:fix(?:es|ed)?|regression|crash(?:es|ing)?|leak|broken|no longer|stop(?:s|ped)? (?:\w+ )?(?:crash|leak|spam)\w*)\b", re.I)
|
||||
|
||||
|
||||
def categorize(labels, subject):
|
||||
for key in CATEGORIES:
|
||||
if key in labels:
|
||||
return key
|
||||
subject = subject or ""
|
||||
for rx, key in SUBJECT_HINTS:
|
||||
if rx.search(subject or ""):
|
||||
if rx.search(subject):
|
||||
return key
|
||||
body = AREA_PREFIX_RE.sub("", TYPE_PREFIX_RE.sub("", subject, count=1), count=1)
|
||||
if INTERNAL_PREFIX_RE.search(subject):
|
||||
return "other"
|
||||
if FEATURE_CUE_RE.search(body):
|
||||
return "feature"
|
||||
if FIX_CUE_RE.search(body):
|
||||
return "fix"
|
||||
return "other"
|
||||
|
||||
|
||||
@@ -183,10 +201,8 @@ def author_md(e):
|
||||
return e["author_name"]
|
||||
|
||||
|
||||
def format_github(repo, entries, rng, bare=False):
|
||||
prev = rng.split("..")[0]
|
||||
tag = rng.split("..")[1] if ".." in rng else "HEAD"
|
||||
out = [] if bare else ["## What's Changed", ""]
|
||||
def format_sections(entries, escape=lambda t: t):
|
||||
out = []
|
||||
for key, heading in CATEGORIES.items():
|
||||
group = [e for e in entries if e["category"] == key]
|
||||
if not group:
|
||||
@@ -194,13 +210,37 @@ def format_github(repo, entries, rng, bare=False):
|
||||
out.append(f"### {heading}")
|
||||
for e in group:
|
||||
ref = f" in #{e['number']}" if e["number"] else f" ({e['sha'][:7]})"
|
||||
out.append(f"- {e['title']} by {author_md(e)}{ref}")
|
||||
out.append(f"- {escape(e['title'])} by {author_md(e)}{ref}")
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
|
||||
def format_github(repo, entries, rng, bare=False):
|
||||
prev = rng.split("..")[0]
|
||||
tag = rng.split("..")[1] if ".." in rng else "HEAD"
|
||||
out = [] if bare else ["## What's Changed", ""]
|
||||
out += format_sections(entries)
|
||||
if not bare:
|
||||
out.append(f"**Full Changelog**: https://github.com/{repo}/compare/{prev}...{tag}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def mdx_safe(text):
|
||||
# titles land in MDX: escape JSX/expression/table chars
|
||||
return (text.replace("{", "{").replace("<", "<")
|
||||
.replace("|", "\\|"))
|
||||
|
||||
|
||||
# Same sections as the GitHub body the release feed parses, so docs and shell never disagree.
|
||||
def format_changelog(repo, entries, rng):
|
||||
tag = rng.split("..")[1] if ".." in rng else "HEAD"
|
||||
date = run(["git", "log", "-1", "--format=%cs", tag]).strip()
|
||||
out = [f"## {tag}", "", f"**Released**: {date}", ""]
|
||||
out += format_sections(entries, mdx_safe)
|
||||
out += [f"**GitHub Release**: https://github.com/{repo}/releases/tag/{tag}", "", "---"]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
TYPE_PREFIX_RE = re.compile(
|
||||
r"^(?:feat(?:ure)?|fix(?:es)?|hotfix|bugfix|docs?|refactor|chore|perf"
|
||||
r"|style|test|i18n|build|ci)\b!?\s*(?:\([^)]*\))?\s*[:/\-]\s*", re.I)
|
||||
@@ -221,11 +261,6 @@ def clean_title(title):
|
||||
|
||||
|
||||
def format_blog(repo, entries, rng, exclude=frozenset()):
|
||||
def mdx_safe(text):
|
||||
# titles land in MDX table cells: escape JSX/expression/table chars
|
||||
return (text.replace("{", "{").replace("<", "<")
|
||||
.replace("|", "\\|"))
|
||||
|
||||
def is_excluded(e):
|
||||
return ((e["login"] or "").lower() in exclude
|
||||
or e["author_name"].lower() in exclude)
|
||||
@@ -302,7 +337,7 @@ def format_checklist(entries):
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("range", help="git range, e.g. v1.4.6..v1.5.0")
|
||||
ap.add_argument("--format", choices=["github", "blog", "checklist"], default="github")
|
||||
ap.add_argument("--format", choices=["github", "changelog", "blog", "checklist"], default="github")
|
||||
ap.add_argument("--repo", default=None, help="owner/name (default: from origin)")
|
||||
ap.add_argument("--exclude", action="append", default=None, metavar="LOGIN",
|
||||
help="drop this author (repeatable). Blog format defaults "
|
||||
@@ -345,6 +380,8 @@ def main():
|
||||
and e["author_name"].lower() not in drop]
|
||||
if args.format == "github":
|
||||
text = format_github(repo, entries, args.range, bare=args.bare)
|
||||
elif args.format == "changelog":
|
||||
text = format_changelog(repo, entries, args.range)
|
||||
else:
|
||||
text = format_checklist(entries)
|
||||
|
||||
|
||||
Reference in new issue
Block a user