mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-02 02:22:06 -04:00
system updater: complete overhaul
Move system update flow to GO, with a CLI (convenient AIO tool) and server integration. All lifecycle, scheduling, execution occurs on backend side. Run some backends via pkexec, some via terminal like paru/yay. Incorporate flatpak as an option to update. Add terminal override setting in GUI, in addition to $TERMINAL env variable. fixes #2307 fixes #822 fixes #1102 fixes #1812 fixes #1087 fixes #1743
This commit is contained in:
@@ -527,5 +527,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
randrCmd,
|
randrCmd,
|
||||||
blurCmd,
|
blurCmd,
|
||||||
trashCmd,
|
trashCmd,
|
||||||
|
systemCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
277
core/cmd/dms/commands_system.go
Normal file
277
core/cmd/dms/commands_system.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var systemCmd = &cobra.Command{
|
||||||
|
Use: "system",
|
||||||
|
Short: "System operations",
|
||||||
|
Long: "System-level operations (updates, etc.). Runs against installed package managers directly; does not require the DMS server.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemUpdateCmd = &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Apply or list system updates",
|
||||||
|
Long: `Apply or list system updates across detected package managers.
|
||||||
|
|
||||||
|
Default behavior is to apply available updates after prompting for confirmation.
|
||||||
|
Use --check to list updates without applying.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
dms system update --check # list available updates
|
||||||
|
dms system update # apply updates (interactive prompt)
|
||||||
|
dms system update --noconfirm # apply updates without prompting
|
||||||
|
dms system update --dry # simulate without changing anything
|
||||||
|
dms system update --no-flatpak --noconfirm # apply system updates only
|
||||||
|
dms system update --interval 3600 # set the server poll interval to 1h`,
|
||||||
|
Run: runSystemUpdate,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sysUpdateCheck bool
|
||||||
|
sysUpdateNoConfirm bool
|
||||||
|
sysUpdateDry bool
|
||||||
|
sysUpdateJSON bool
|
||||||
|
sysUpdateNoFlatpak bool
|
||||||
|
sysUpdateNoAUR bool
|
||||||
|
sysUpdateIntervalS int
|
||||||
|
sysUpdateListPmTime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateCheck, "check", false, "List available updates without applying")
|
||||||
|
systemUpdateCmd.Flags().BoolVarP(&sysUpdateNoConfirm, "noconfirm", "y", false, "Apply updates without prompting")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateDry, "dry", false, "Simulate the upgrade without applying changes")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateJSON, "json", false, "Output as JSON (with --check)")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoFlatpak, "no-flatpak", false, "Skip the Flatpak overlay")
|
||||||
|
systemUpdateCmd.Flags().BoolVar(&sysUpdateNoAUR, "no-aur", false, "Skip the AUR (paru/yay only)")
|
||||||
|
systemUpdateCmd.Flags().IntVar(&sysUpdateIntervalS, "interval", -1, "Set the DMS server poll interval in seconds and exit (requires running server)")
|
||||||
|
|
||||||
|
systemCmd.AddCommand(systemUpdateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdate(cmd *cobra.Command, args []string) {
|
||||||
|
switch {
|
||||||
|
case sysUpdateIntervalS >= 0:
|
||||||
|
runSystemUpdateSetInterval(sysUpdateIntervalS)
|
||||||
|
case sysUpdateCheck:
|
||||||
|
runSystemUpdateCheck()
|
||||||
|
default:
|
||||||
|
runSystemUpdateApply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectBackends(ctx context.Context) []sysupdate.Backend {
|
||||||
|
sel := sysupdate.Select(ctx)
|
||||||
|
backends := sel.All()
|
||||||
|
if !sysUpdateNoFlatpak {
|
||||||
|
return backends
|
||||||
|
}
|
||||||
|
out := backends[:0]
|
||||||
|
for _, b := range backends {
|
||||||
|
if b.Repo() == sysupdate.RepoFlatpak {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateCheck() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
backends := selectBackends(ctx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Display string `json:"displayName"`
|
||||||
|
Packages []sysupdate.Package `json:"packages"`
|
||||||
|
}
|
||||||
|
var results []backendResult
|
||||||
|
var allPkgs []sysupdate.Package
|
||||||
|
var firstErr error
|
||||||
|
|
||||||
|
for _, b := range backends {
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||||
|
}
|
||||||
|
results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs})
|
||||||
|
allPkgs = append(allPkgs, pkgs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sysUpdateJSON {
|
||||||
|
out, _ := json.MarshalIndent(map[string]any{
|
||||||
|
"backends": results,
|
||||||
|
"packages": allPkgs,
|
||||||
|
"error": errOrEmpty(firstErr),
|
||||||
|
"count": len(allPkgs),
|
||||||
|
}, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(allPkgs))
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Error: %v\n", firstErr)
|
||||||
|
}
|
||||||
|
if len(allPkgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range allPkgs {
|
||||||
|
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateApply() {
|
||||||
|
checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime)
|
||||||
|
defer checkCancel()
|
||||||
|
|
||||||
|
backends := selectBackends(checkCtx)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
log.Fatal("No supported package manager found")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, firstErr := collectUpdates(checkCtx, backends)
|
||||||
|
if firstErr != nil {
|
||||||
|
fmt.Printf("Warning: %v\n\n", firstErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
printBackends(backends)
|
||||||
|
fmt.Printf("Updates: %d\n", len(pkgs))
|
||||||
|
if len(pkgs) == 0 {
|
||||||
|
fmt.Println("Nothing to upgrade.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
for _, p := range pkgs {
|
||||||
|
fmt.Printf(" [%s] %s %s -> %s\n", p.Repo, p.Name, defaultIfEmpty(p.FromVersion, "?"), defaultIfEmpty(p.ToVersion, "?"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !sysUpdateNoConfirm && !sysUpdateDry {
|
||||||
|
if !promptYesNo("Proceed with upgrade? [y/N]: ") {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
opts := sysupdate.UpgradeOptions{
|
||||||
|
IncludeFlatpak: !sysUpdateNoFlatpak,
|
||||||
|
IncludeAUR: !sysUpdateNoAUR,
|
||||||
|
DryRun: sysUpdateDry,
|
||||||
|
}
|
||||||
|
|
||||||
|
onLine := func(line string) { fmt.Println(line) }
|
||||||
|
for _, b := range backends {
|
||||||
|
fmt.Printf("\n== %s ==\n", b.DisplayName())
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
log.Fatalf("%s upgrade failed: %v", b.ID(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sysUpdateDry {
|
||||||
|
fmt.Println("\nDry run complete (no changes applied).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println("\nUpgrade complete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupdate.Package, error) {
|
||||||
|
var all []sysupdate.Package
|
||||||
|
var firstErr error
|
||||||
|
for _, b := range backends {
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
if err != nil && firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", b.ID(), err)
|
||||||
|
}
|
||||||
|
all = append(all, pkgs...)
|
||||||
|
}
|
||||||
|
return all, firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSystemUpdateSetInterval(seconds int) {
|
||||||
|
resp, err := sendServerRequest(models.Request{
|
||||||
|
ID: 1,
|
||||||
|
Method: "sysupdate.setInterval",
|
||||||
|
Params: map[string]any{"seconds": float64(seconds)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed: %v (is dms server running?)", err)
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
log.Fatalf("Error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("Interval set to %d seconds.\n", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptYesNo(prompt string) bool {
|
||||||
|
if !stdinIsTTY() {
|
||||||
|
log.Fatal("Refusing to apply updates non-interactively. Re-run with --noconfirm or --check.")
|
||||||
|
}
|
||||||
|
fmt.Print(prompt)
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||||
|
case "y", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printBackends(backends []sysupdate.Backend) {
|
||||||
|
if len(backends) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(backends))
|
||||||
|
for _, b := range backends {
|
||||||
|
names = append(names, b.DisplayName())
|
||||||
|
}
|
||||||
|
fmt.Printf("Backends: %s\n", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stdinIsTTY() bool {
|
||||||
|
fi, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func errOrEmpty(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -202,6 +203,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.Method, "sysupdate.") {
|
||||||
|
if sysUpdateManager == nil {
|
||||||
|
models.RespondError(conn, req.ID, "sysupdate manager not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sysupdate.HandleRequest(conn, req, sysUpdateManager)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch req.Method {
|
switch req.Method {
|
||||||
case "ping":
|
case "ping":
|
||||||
models.Respond(conn, req.ID, "pong")
|
models.Respond(conn, req.ID, "pong")
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/sysupdate"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||||
@@ -75,6 +76,7 @@ var wlContext *wlcontext.SharedContext
|
|||||||
var themeModeManager *thememode.Manager
|
var themeModeManager *thememode.Manager
|
||||||
var trayRecoveryManager *trayrecovery.Manager
|
var trayRecoveryManager *trayrecovery.Manager
|
||||||
var locationManager *location.Manager
|
var locationManager *location.Manager
|
||||||
|
var sysUpdateManager *sysupdate.Manager
|
||||||
var geoClientInstance geolocation.Client
|
var geoClientInstance geolocation.Client
|
||||||
|
|
||||||
const dbusClientID = "dms-dbus-client"
|
const dbusClientID = "dms-dbus-client"
|
||||||
@@ -421,6 +423,19 @@ func InitializeLocationManager(geoClient geolocation.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func InitializeSysUpdateManager() error {
|
||||||
|
manager, err := sysupdate.NewManager()
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("Failed to initialize sysupdate manager: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sysUpdateManager = manager
|
||||||
|
|
||||||
|
log.Info("Sysupdate manager initialized")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleConnection(conn net.Conn) {
|
func handleConnection(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
@@ -506,6 +521,10 @@ func getCapabilities() Capabilities {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return Capabilities{Capabilities: caps}
|
return Capabilities{Capabilities: caps}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +595,10 @@ func getServerInfo() ServerInfo {
|
|||||||
caps = append(caps, "dbus")
|
caps = append(caps, "dbus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
caps = append(caps, "sysupdate")
|
||||||
|
}
|
||||||
|
|
||||||
return ServerInfo{
|
return ServerInfo{
|
||||||
APIVersion: APIVersion,
|
APIVersion: APIVersion,
|
||||||
CLIVersion: CLIVersion,
|
CLIVersion: CLIVersion,
|
||||||
@@ -1243,6 +1266,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldSubscribe("sysupdate") && sysUpdateManager != nil {
|
||||||
|
wg.Add(1)
|
||||||
|
sysupdateChan := sysUpdateManager.Subscribe(clientID + "-sysupdate")
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer sysUpdateManager.Unsubscribe(clientID + "-sysupdate")
|
||||||
|
|
||||||
|
initialState := sysUpdateManager.GetState()
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: initialState}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case state, ok := <-sysupdateChan:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case eventChan <- ServiceEvent{Service: "sysupdate", Data: state}:
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if shouldSubscribe("dbus") && dbusManager != nil {
|
if shouldSubscribe("dbus") && dbusManager != nil {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
dbusChan := dbusManager.SubscribeSignals(dbusClientID)
|
||||||
@@ -1348,6 +1403,9 @@ func cleanupManagers() {
|
|||||||
if locationManager != nil {
|
if locationManager != nil {
|
||||||
locationManager.Close()
|
locationManager.Close()
|
||||||
}
|
}
|
||||||
|
if sysUpdateManager != nil {
|
||||||
|
sysUpdateManager.Close()
|
||||||
|
}
|
||||||
if geoClientInstance != nil {
|
if geoClientInstance != nil {
|
||||||
geoClientInstance.Close()
|
geoClientInstance.Close()
|
||||||
}
|
}
|
||||||
@@ -1733,6 +1791,10 @@ func Start(printDocs bool) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if err := InitializeSysUpdateManager(); err != nil {
|
||||||
|
log.Warnf("Sysupdate manager unavailable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("")
|
log.Info("")
|
||||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||||
|
|
||||||
|
|||||||
96
core/internal/server/sysupdate/backend.go
Normal file
96
core/internal/server/sysupdate/backend.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Backend interface {
|
||||||
|
ID() string
|
||||||
|
DisplayName() string
|
||||||
|
Repo() RepoKind
|
||||||
|
IsAvailable(ctx context.Context) bool
|
||||||
|
NeedsAuth() bool
|
||||||
|
RunsInTerminal() bool
|
||||||
|
CheckUpdates(ctx context.Context) ([]Package, error)
|
||||||
|
Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Selection struct {
|
||||||
|
System Backend
|
||||||
|
Overlay []Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) All() []Backend {
|
||||||
|
if s.System == nil {
|
||||||
|
return s.Overlay
|
||||||
|
}
|
||||||
|
out := make([]Backend, 0, 1+len(s.Overlay))
|
||||||
|
out = append(out, s.System)
|
||||||
|
out = append(out, s.Overlay...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Selection) Info() []BackendInfo {
|
||||||
|
all := s.All()
|
||||||
|
out := make([]BackendInfo, 0, len(all))
|
||||||
|
for _, b := range all {
|
||||||
|
out = append(out, BackendInfo{
|
||||||
|
ID: b.ID(),
|
||||||
|
DisplayName: b.DisplayName(),
|
||||||
|
Repo: b.Repo(),
|
||||||
|
NeedsAuth: b.NeedsAuth(),
|
||||||
|
RunsInTerminal: b.RunsInTerminal(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
systemCandidates []func() Backend
|
||||||
|
overlayCandidate []func() Backend
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterSystemBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
systemCandidates = append(systemCandidates, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterOverlayBackend(factory func() Backend) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
overlayCandidate = append(overlayCandidate, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(ctx context.Context) Selection {
|
||||||
|
registryMu.RLock()
|
||||||
|
sys := append([]func() Backend(nil), systemCandidates...)
|
||||||
|
ov := append([]func() Backend(nil), overlayCandidate...)
|
||||||
|
registryMu.RUnlock()
|
||||||
|
|
||||||
|
var sel Selection
|
||||||
|
for _, factory := range sys {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.System = b
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, factory := range ov {
|
||||||
|
b := factory()
|
||||||
|
if !b.IsAvailable(ctx) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sel.Overlay = append(sel.Overlay, b)
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
75
core/internal/server/sysupdate/backend_apt.go
Normal file
75
core/internal/server/sysupdate/backend_apt.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &aptBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var aptUpgradableLine = regexp.MustCompile(`^([^/]+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+([^\]]+)\]`)
|
||||||
|
|
||||||
|
type aptBackend struct{}
|
||||||
|
|
||||||
|
func (aptBackend) ID() string { return "apt" }
|
||||||
|
func (aptBackend) DisplayName() string { return "APT" }
|
||||||
|
func (aptBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (aptBackend) NeedsAuth() bool { return true }
|
||||||
|
func (aptBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (aptBackend) IsAvailable(_ context.Context) bool {
|
||||||
|
return commandExists("apt") || commandExists("apt-get")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "apt", "list", "--upgradable")
|
||||||
|
cmd.Env = append(cmd.Environ(), "LC_ALL=C")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseAptUpgradable(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
bin := "apt-get"
|
||||||
|
if !commandExists(bin) {
|
||||||
|
bin = "apt"
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{bin, "upgrade", "--dry-run"}, RunOptions{
|
||||||
|
Env: []string{"DEBIAN_FRONTEND=noninteractive", "LC_ALL=C"},
|
||||||
|
OnLine: onLine,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
argv := []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y"}
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAptUpgradable(text string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := aptUpgradableLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "apt",
|
||||||
|
FromVersion: m[3],
|
||||||
|
ToVersion: m[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
72
core/internal/server/sysupdate/backend_apt_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAptUpgradable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header line only",
|
||||||
|
input: `Listing... Done
|
||||||
|
`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single upgradable",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple architectures and suites",
|
||||||
|
input: `Listing... Done
|
||||||
|
bash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]
|
||||||
|
libfoo/stable-security 1.0.0-2 amd64 [upgradable from: 1.0.0-1]
|
||||||
|
zsh/testing 5.9-6 arm64 [upgradable from: 5.9-5]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
{Name: "libfoo", Repo: RepoSystem, Backend: "apt", FromVersion: "1.0.0-1", ToVersion: "1.0.0-2"},
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "apt", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "package name with hyphens, dots, plus signs",
|
||||||
|
input: `Listing... Done
|
||||||
|
g++/stable 4:13.3.0-1 amd64 [upgradable from: 4:13.2.0-1]
|
||||||
|
libsdl2-2.0-0/stable 2.30.0+dfsg-1 amd64 [upgradable from: 2.28.5+dfsg-1]`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "g++", Repo: RepoSystem, Backend: "apt", FromVersion: "4:13.2.0-1", ToVersion: "4:13.3.0-1"},
|
||||||
|
{Name: "libsdl2-2.0-0", Repo: RepoSystem, Backend: "apt", FromVersion: "2.28.5+dfsg-1", ToVersion: "2.30.0+dfsg-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-matching lines ignored",
|
||||||
|
input: "WARNING: this is some warning\nbash/stable 5.2.40-1 amd64 [upgradable from: 5.2.39-1]",
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash", Repo: RepoSystem, Backend: "apt", FromVersion: "5.2.39-1", ToVersion: "5.2.40-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseAptUpgradable(tt.input)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseAptUpgradable() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
108
core/internal/server/sysupdate/backend_dnf.go
Normal file
108
core/internal/server/sysupdate/backend_dnf.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf5"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &dnfBackend{bin: "dnf"} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type dnfBackend struct {
|
||||||
|
bin string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) ID() string { return b.bin }
|
||||||
|
func (b dnfBackend) DisplayName() string { return strings.ToUpper(b.bin) }
|
||||||
|
func (b dnfBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b dnfBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b dnfBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b dnfBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists(b.bin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if commandExists("rpm-ostree") && ostreeBooted(ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := dnfListUpgrades(ctx, b.bin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installed := rpmInstalledVersions(ctx)
|
||||||
|
return parseDnfList(out, b.bin, installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
return Run(ctx, []string{"pkexec", b.bin, "upgrade", "-y"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnfListUpgrades(ctx context.Context, bin string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--quiet")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpmInstalledVersions(ctx context.Context) map[string]string {
|
||||||
|
out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]string)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
name, ver, ok := strings.Cut(line, "\t")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m[name] = ver
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDnfList(text, backendID string, installed map[string]string) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameArch := fields[0]
|
||||||
|
version := fields[1]
|
||||||
|
switch nameArch {
|
||||||
|
case "Available", "Upgrades":
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := nameArch
|
||||||
|
if dot := strings.LastIndex(nameArch, "."); dot > 0 {
|
||||||
|
name = nameArch[:dot]
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: nameArch,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: installed[name],
|
||||||
|
ToVersion: version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
77
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
77
core/internal/server/sysupdate/backend_dnf_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDnfList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
installed map[string]string
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package with installed cross-ref",
|
||||||
|
input: "bash.x86_64 5.2.40-1.fc41 updates",
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "noarch package and missing installed entry",
|
||||||
|
input: `bash.x86_64 5.2.40-1.fc41 updates
|
||||||
|
fonts-misc.noarch 1.0.5-2.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"bash": "5.2.39-1.fc41"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "5.2.39-1.fc41", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
{Name: "fonts-misc.noarch", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "1.0.5-2.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips header rows",
|
||||||
|
input: `Available
|
||||||
|
Upgrades
|
||||||
|
bash.x86_64 5.2.40-1.fc41 updates`,
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines with too few fields",
|
||||||
|
input: "incomplete",
|
||||||
|
backendID: "dnf",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "package without arch suffix",
|
||||||
|
input: "noarchpkg 1.2.3 updates",
|
||||||
|
backendID: "dnf",
|
||||||
|
installed: map[string]string{"noarchpkg": "1.2.0"},
|
||||||
|
want: []Package{
|
||||||
|
{Name: "noarchpkg", Repo: RepoSystem, Backend: "dnf", FromVersion: "1.2.0", ToVersion: "1.2.3"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseDnfList(tt.input, tt.backendID, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseDnfList() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
139
core/internal/server/sysupdate/backend_flatpak.go
Normal file
139
core/internal/server/sysupdate/backend_flatpak.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterOverlayBackend(func() Backend { return &flatpakBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakBackend struct{}
|
||||||
|
|
||||||
|
func (flatpakBackend) ID() string { return "flatpak" }
|
||||||
|
func (flatpakBackend) DisplayName() string { return "Flatpak" }
|
||||||
|
func (flatpakBackend) Repo() RepoKind { return RepoFlatpak }
|
||||||
|
func (flatpakBackend) NeedsAuth() bool { return false }
|
||||||
|
func (flatpakBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (flatpakBackend) IsAvailable(_ context.Context) bool { return commandExists("flatpak") }
|
||||||
|
|
||||||
|
func (flatpakBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "flatpak", "remote-ls", "--updates", "--columns=application,version,branch,commit,name")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installed := flatpakInstalled(ctx)
|
||||||
|
return parseFlatpakUpdates(string(out), installed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakInstalled(ctx context.Context) map[string]flatpakInstalledEntry {
|
||||||
|
out, err := exec.CommandContext(ctx, "flatpak", "list", "--columns=application,version,branch,active").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := make(map[string]flatpakInstalledEntry)
|
||||||
|
for line := range strings.SplitSeq(string(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) == 0 || fields[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := fields[0]
|
||||||
|
entry := flatpakInstalledEntry{}
|
||||||
|
if len(fields) > 1 {
|
||||||
|
entry.version = fields[1]
|
||||||
|
}
|
||||||
|
if len(fields) > 2 {
|
||||||
|
entry.branch = fields[2]
|
||||||
|
}
|
||||||
|
if len(fields) > 3 {
|
||||||
|
entry.commit = fields[3]
|
||||||
|
}
|
||||||
|
key := appID
|
||||||
|
if entry.branch != "" {
|
||||||
|
key = appID + "//" + entry.branch
|
||||||
|
}
|
||||||
|
m[key] = entry
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
type flatpakInstalledEntry struct {
|
||||||
|
version string
|
||||||
|
branch string
|
||||||
|
commit string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
argv := []string{"flatpak", "update", "-y", "--noninteractive"}
|
||||||
|
if opts.DryRun {
|
||||||
|
argv = []string{"flatpak", "update", "--no-deploy", "-y"}
|
||||||
|
}
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
if len(fields) == 0 || fields[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := fields[0]
|
||||||
|
version, branch, commit := "", "", ""
|
||||||
|
if len(fields) > 1 {
|
||||||
|
version = fields[1]
|
||||||
|
}
|
||||||
|
if len(fields) > 2 {
|
||||||
|
branch = fields[2]
|
||||||
|
}
|
||||||
|
if len(fields) > 3 {
|
||||||
|
commit = fields[3]
|
||||||
|
}
|
||||||
|
display := appID
|
||||||
|
if len(fields) > 4 && fields[4] != "" {
|
||||||
|
display = fields[4]
|
||||||
|
}
|
||||||
|
|
||||||
|
key := appID
|
||||||
|
if branch != "" {
|
||||||
|
key = appID + "//" + branch
|
||||||
|
}
|
||||||
|
inst := installed[key]
|
||||||
|
from, to := flatpakVersionPair(inst.version, inst.commit, version, commit)
|
||||||
|
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: display,
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: from,
|
||||||
|
ToVersion: to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatpakVersionPair(installedVer, installedCommit, remoteVer, remoteCommit string) (from, to string) {
|
||||||
|
if remoteVer != "" {
|
||||||
|
return installedVer, remoteVer
|
||||||
|
}
|
||||||
|
return shortCommit(installedCommit), shortCommit(remoteCommit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortCommit(c string) string {
|
||||||
|
if len(c) > 8 {
|
||||||
|
return c[:8]
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
137
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
137
core/internal/server/sysupdate/backend_flatpak_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFlatpakUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
installed map[string]flatpakInstalledEntry
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real flathub-style row with empty version, falls back to commit",
|
||||||
|
// columns: application,version,branch,commit,name
|
||||||
|
input: "com.discordapp.Discord\t\tstable\t43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586\tDiscord",
|
||||||
|
installed: map[string]flatpakInstalledEntry{
|
||||||
|
"com.discordapp.Discord//stable": {commit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855"},
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "Discord",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "8b16fa1a",
|
||||||
|
ToVersion: "43a1e5d2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote provides version, installed version known",
|
||||||
|
input: "com.example.App\t1.5.0\tstable\tdeadbeefcafe\tExample App",
|
||||||
|
installed: map[string]flatpakInstalledEntry{
|
||||||
|
"com.example.App//stable": {version: "1.4.2"},
|
||||||
|
},
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "Example App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "1.4.2",
|
||||||
|
ToVersion: "1.5.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no installed entry, remote has no version, falls back to commit on both sides",
|
||||||
|
input: "org.gnome.Platform\t\t49\tbadcd4afb1fe\tgnome platform",
|
||||||
|
installed: nil,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "gnome platform",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "badcd4af",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing display name falls back to application id",
|
||||||
|
input: "com.example.NoName\t2.0\tstable\tabcdef123456\t",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "com.example.NoName",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "2.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips blank lines and rows with empty application id",
|
||||||
|
input: "\n\t\t\t\t\norg.real.App\t1.0\tstable\tdeadbeef\tReal App",
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "Real App",
|
||||||
|
Repo: RepoFlatpak,
|
||||||
|
Backend: "flatpak",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseFlatpakUpdates(tt.input, tt.installed)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseFlatpakUpdates() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlatpakVersionPair(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
installedVer, installedCommit, remoteVer, remoteCommit string
|
||||||
|
wantFrom, wantTo string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote has version - prefer versions",
|
||||||
|
installedVer: "1.0.0", remoteVer: "1.1.0",
|
||||||
|
wantFrom: "1.0.0", wantTo: "1.1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote has no version - both sides fall to short commit",
|
||||||
|
installedCommit: "8b16fa1a9b2aa189302c2428c8a7bb33dd050faf7e535dd1d975044cb0986855",
|
||||||
|
remoteCommit: "43a1e5d2d3a446919356fd86d9f984ad7c6a0e20f109250d9d868223f26ca586",
|
||||||
|
wantFrom: "8b16fa1a", wantTo: "43a1e5d2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short commits left as-is",
|
||||||
|
installedCommit: "abc123", remoteCommit: "def456",
|
||||||
|
wantFrom: "abc123", wantTo: "def456",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
from, to := flatpakVersionPair(tt.installedVer, tt.installedCommit, tt.remoteVer, tt.remoteCommit)
|
||||||
|
if from != tt.wantFrom || to != tt.wantTo {
|
||||||
|
t.Errorf("flatpakVersionPair() = (%q, %q), want (%q, %q)", from, to, tt.wantFrom, tt.wantTo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
232
core/internal/server/sysupdate/backend_pacman.go
Normal file
232
core/internal/server/sysupdate/backend_pacman.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "paru"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &archHelperBackend{id: "yay"} })
|
||||||
|
RegisterSystemBackend(func() Backend { return &pacmanBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
var archUpdateLine = regexp.MustCompile(`^(\S+)\s+(\S+)\s+->\s+(\S+)`)
|
||||||
|
|
||||||
|
type pacmanBackend struct{}
|
||||||
|
|
||||||
|
func (pacmanBackend) ID() string { return "pacman" }
|
||||||
|
func (pacmanBackend) DisplayName() string { return "Pacman" }
|
||||||
|
func (pacmanBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (pacmanBackend) NeedsAuth() bool { return true }
|
||||||
|
func (pacmanBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (pacmanBackend) IsAvailable(_ context.Context) bool { return commandExists("pacman") }
|
||||||
|
|
||||||
|
func (b pacmanBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
out, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseArchUpdates(out, b.ID(), RepoSystem), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
return Run(ctx, []string{"pkexec", "pacman", "-Syu", "--noconfirm"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
type archHelperBackend struct {
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) ID() string { return b.id }
|
||||||
|
func (b archHelperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (b archHelperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (b archHelperBackend) RunsInTerminal() bool { return true }
|
||||||
|
func (b archHelperBackend) IsAvailable(_ context.Context) bool { return commandExists(b.id) }
|
||||||
|
|
||||||
|
func (b archHelperBackend) DisplayName() string {
|
||||||
|
switch b.id {
|
||||||
|
case "paru":
|
||||||
|
return "Paru (AUR)"
|
||||||
|
case "yay":
|
||||||
|
return "Yay (AUR)"
|
||||||
|
default:
|
||||||
|
return b.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
repoOut, err := pacmanRepoUpdates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := parseArchUpdates(repoOut, b.id, RepoSystem)
|
||||||
|
|
||||||
|
aurOut, err := capturePermissive(ctx, b.id, "-Qua")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, parseArchUpdates(aurOut, b.id, RepoAUR)...)
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
term := findTerminal(opts.Terminal)
|
||||||
|
if term == "" {
|
||||||
|
return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("%s -Syu", b.id)
|
||||||
|
if !opts.IncludeAUR {
|
||||||
|
cmd += " --repo"
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("DMS — System Update (%s)", b.id)
|
||||||
|
return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanRepoUpdates(ctx context.Context) (string, error) {
|
||||||
|
if commandExists("checkupdates") {
|
||||||
|
return capturePermissive(ctx, "checkupdates")
|
||||||
|
}
|
||||||
|
if commandExists("fakeroot") {
|
||||||
|
out, err := pacmanCheckViaFakeroot(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
log.Warnf("[sysupdate] fakeroot db refresh failed, falling back to stale pacman -Qu: %v", err)
|
||||||
|
}
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanCheckViaFakeroot(ctx context.Context) (string, error) {
|
||||||
|
dir, err := pacmanPrivateDB()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedPacmanDB(dir); err != nil {
|
||||||
|
return "", fmt.Errorf("seed sync db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh := exec.CommandContext(ctx, "fakeroot", "--", "pacman", "-Sy", "--dbpath", dir, "--logfile", "/dev/null", "--disable-sandbox")
|
||||||
|
if out, err := refresh.CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("fakeroot pacman -Sy: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return capturePermissive(ctx, "pacman", "-Qu", "--dbpath", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedPacmanDB(dir string) error {
|
||||||
|
syncDir := filepath.Join(dir, "sync")
|
||||||
|
if err := os.MkdirAll(syncDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dbs, err := filepath.Glob("/var/lib/pacman/sync/*.db")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, src := range dbs {
|
||||||
|
if err := copyFile(src, filepath.Join(syncDir, filepath.Base(src))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localLink := filepath.Join(dir, "local")
|
||||||
|
if fi, err := os.Lstat(localLink); err == nil {
|
||||||
|
if fi.Mode()&os.ModeSymlink == 0 {
|
||||||
|
if err := os.RemoveAll(localLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Symlink("/var/lib/pacman/local", localLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pacmanPrivateDB() (string, error) {
|
||||||
|
tmp := os.Getenv("TMPDIR")
|
||||||
|
if tmp == "" {
|
||||||
|
tmp = "/tmp"
|
||||||
|
}
|
||||||
|
dir := filepath.Join(tmp, fmt.Sprintf("dms-checkup-db-%d", os.Getuid()))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func capturePermissive(ctx context.Context, argv ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err == nil {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 1, 2:
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArchUpdates(text, backendID string, repo RepoKind) []Package {
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var pkgs []Package
|
||||||
|
for line := range strings.SplitSeq(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := archUpdateLine.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := Package{
|
||||||
|
Name: m[1],
|
||||||
|
Repo: repo,
|
||||||
|
Backend: backendID,
|
||||||
|
FromVersion: m[2],
|
||||||
|
ToVersion: m[3],
|
||||||
|
}
|
||||||
|
if repo == RepoAUR {
|
||||||
|
p.ChangelogURL = "https://aur.archlinux.org/packages/" + p.Name
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, p)
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
114
core/internal/server/sysupdate/backend_pacman_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseArchUpdates(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
backendID string
|
||||||
|
repo RepoKind
|
||||||
|
want []Package
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
input: "",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
input: " \n\n \n",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single repo update",
|
||||||
|
input: "bat 0.26.0-1 -> 0.26.1-2",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple updates with epoch versions",
|
||||||
|
input: `cups 2:2.4.18-1 -> 2:2.4.19-1
|
||||||
|
linux 6.18.0-1 -> 6.18.1-1
|
||||||
|
mesa 26.4.0-1 -> 26.4.1-1`,
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "cups", Repo: RepoSystem, Backend: "paru", FromVersion: "2:2.4.18-1", ToVersion: "2:2.4.19-1"},
|
||||||
|
{Name: "linux", Repo: RepoSystem, Backend: "paru", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
{Name: "mesa", Repo: RepoSystem, Backend: "paru", FromVersion: "26.4.0-1", ToVersion: "26.4.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AUR update with changelog url",
|
||||||
|
input: "google-chrome 147.0.7727.116-1 -> 147.0.7727.137-1",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "google-chrome",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "paru",
|
||||||
|
FromVersion: "147.0.7727.116-1",
|
||||||
|
ToVersion: "147.0.7727.137-1",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/google-chrome",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git package latest-commit marker",
|
||||||
|
input: "niri-git 26.04.r5.ga85b922-1 -> latest-commit",
|
||||||
|
backendID: "yay",
|
||||||
|
repo: RepoAUR,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "niri-git",
|
||||||
|
Repo: RepoAUR,
|
||||||
|
Backend: "yay",
|
||||||
|
FromVersion: "26.04.r5.ga85b922-1",
|
||||||
|
ToVersion: "latest-commit",
|
||||||
|
ChangelogURL: "https://aur.archlinux.org/packages/niri-git",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips lines that don't match arrow format",
|
||||||
|
input: `bat 0.26.0-1 -> 0.26.1-2
|
||||||
|
this is not an update line
|
||||||
|
foo`,
|
||||||
|
backendID: "pacman",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "pacman", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extra whitespace tolerated",
|
||||||
|
input: " bat 0.26.0-1 -> 0.26.1-2 ",
|
||||||
|
backendID: "paru",
|
||||||
|
repo: RepoSystem,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "bat", Repo: RepoSystem, Backend: "paru", FromVersion: "0.26.0-1", ToVersion: "0.26.1-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseArchUpdates(tt.input, tt.backendID, tt.repo)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseArchUpdates() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
125
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
125
core/internal/server/sysupdate/backend_rpmostree.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ostreeExitUpdateAvailable = 77
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &rpmOstreeBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpmOstreeBackend struct{}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) ID() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) DisplayName() string { return "rpm-ostree" }
|
||||||
|
func (rpmOstreeBackend) Repo() RepoKind { return RepoOSTree }
|
||||||
|
func (rpmOstreeBackend) NeedsAuth() bool { return true }
|
||||||
|
func (rpmOstreeBackend) RunsInTerminal() bool { return false }
|
||||||
|
|
||||||
|
func (b rpmOstreeBackend) IsAvailable(ctx context.Context) bool {
|
||||||
|
if !commandExists("rpm-ostree") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ostreeBooted(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeStatus struct {
|
||||||
|
Deployments []ostreeDeployment `json:"deployments"`
|
||||||
|
CachedUpdate *ostreeCached `json:"cached-update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeDeployment struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Booted bool `json:"booted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ostreeCached struct {
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ostreeBooted(ctx context.Context) bool {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "status", "--json")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(out, &s); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(s.Deployments) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "rpm-ostree", "upgrade", "--check")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
exitErr, ok := errors.AsType[*exec.ExitError](err)
|
||||||
|
if !ok || exitErr.ExitCode() != ostreeExitUpdateAvailable {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusOut, err := exec.CommandContext(ctx, "rpm-ostree", "status", "--json").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRpmOstreeStatus(statusOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRpmOstreeStatus(statusOut []byte) ([]Package, error) {
|
||||||
|
var s ostreeStatus
|
||||||
|
if err := json.Unmarshal(statusOut, &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.CachedUpdate == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
booted := bootedDeployment(s.Deployments)
|
||||||
|
from := ""
|
||||||
|
if booted != nil {
|
||||||
|
from = booted.Version
|
||||||
|
}
|
||||||
|
if from == s.CachedUpdate.Version {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := s.CachedUpdate.Origin
|
||||||
|
if name == "" {
|
||||||
|
name = "system"
|
||||||
|
}
|
||||||
|
return []Package{{
|
||||||
|
Name: name,
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: from,
|
||||||
|
ToVersion: s.CachedUpdate.Version,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment {
|
||||||
|
for i := range deps {
|
||||||
|
if deps[i].Booted {
|
||||||
|
return &deps[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
argv := []string{"rpm-ostree", "upgrade"}
|
||||||
|
if opts.DryRun {
|
||||||
|
argv = append(argv, "--check")
|
||||||
|
}
|
||||||
|
return Run(ctx, argv, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
104
core/internal/server/sysupdate/backend_rpmostree_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRpmOstreeStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no cached update",
|
||||||
|
input: `{"deployments":[{"version":"39.20240101.0","booted":true}],"cached-update":null}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update available, booted version differs",
|
||||||
|
input: `{
|
||||||
|
"deployments": [
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20240101.0", "booted": true},
|
||||||
|
{"origin": "fedora:fedora/x86_64/silverblue", "version": "39.20231215.0", "booted": false}
|
||||||
|
],
|
||||||
|
"cached-update": {
|
||||||
|
"origin": "fedora:fedora/x86_64/silverblue",
|
||||||
|
"version": "39.20240115.0",
|
||||||
|
"checksum": "abc123"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:fedora/x86_64/silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "39.20240101.0",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cached update equals booted version (no real update)",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": true}],
|
||||||
|
"cached-update": {"origin": "x", "version": "39.20240101.0"}
|
||||||
|
}`,
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no booted deployment falls back to empty from",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "39.20240101.0", "booted": false}],
|
||||||
|
"cached-update": {"origin": "fedora:silverblue", "version": "39.20240115.0"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "fedora:silverblue",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "",
|
||||||
|
ToVersion: "39.20240115.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing origin defaults to system",
|
||||||
|
input: `{
|
||||||
|
"deployments": [{"version": "1.0", "booted": true}],
|
||||||
|
"cached-update": {"version": "1.1"}
|
||||||
|
}`,
|
||||||
|
want: []Package{
|
||||||
|
{
|
||||||
|
Name: "system",
|
||||||
|
Repo: RepoOSTree,
|
||||||
|
Backend: "rpm-ostree",
|
||||||
|
FromVersion: "1.0",
|
||||||
|
ToVersion: "1.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed JSON",
|
||||||
|
input: `{not json`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseRpmOstreeStatus([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseRpmOstreeStatus() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseRpmOstreeStatus() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
78
core/internal/server/sysupdate/backend_zypper.go
Normal file
78
core/internal/server/sysupdate/backend_zypper.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterSystemBackend(func() Backend { return &zypperBackend{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperBackend struct{}
|
||||||
|
|
||||||
|
func (zypperBackend) ID() string { return "zypper" }
|
||||||
|
func (zypperBackend) DisplayName() string { return "Zypper" }
|
||||||
|
func (zypperBackend) Repo() RepoKind { return RepoSystem }
|
||||||
|
func (zypperBackend) NeedsAuth() bool { return true }
|
||||||
|
func (zypperBackend) RunsInTerminal() bool { return false }
|
||||||
|
func (zypperBackend) IsAvailable(_ context.Context) bool { return commandExists("zypper") }
|
||||||
|
|
||||||
|
type zypperUpdateList struct {
|
||||||
|
XMLName xml.Name `xml:"stream"`
|
||||||
|
Updates []zypperUpdate `xml:"update-list>update"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type zypperUpdate struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Edition string `xml:"edition,attr"`
|
||||||
|
EditionOld string `xml:"edition-old,attr"`
|
||||||
|
Kind string `xml:"kind,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) CheckUpdates(ctx context.Context) ([]Package, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "zypper", "--non-interactive", "--xmlout", "list-updates")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
|
switch exitErr.ExitCode() {
|
||||||
|
case 100, 101, 102, 103:
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseZypperXML(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseZypperXML(out []byte) ([]Package, error) {
|
||||||
|
var list zypperUpdateList
|
||||||
|
if err := xml.Unmarshal(out, &list); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pkgs := make([]Package, 0, len(list.Updates))
|
||||||
|
for _, u := range list.Updates {
|
||||||
|
if u.Kind != "" && u.Kind != "package" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pkgs = append(pkgs, Package{
|
||||||
|
Name: u.Name,
|
||||||
|
Repo: RepoSystem,
|
||||||
|
Backend: "zypper",
|
||||||
|
FromVersion: u.EditionOld,
|
||||||
|
ToVersion: u.Edition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error {
|
||||||
|
if opts.DryRun {
|
||||||
|
return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
|
return Run(ctx, []string{"pkexec", "zypper", "--non-interactive", "update"}, RunOptions{OnLine: onLine})
|
||||||
|
}
|
||||||
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
80
core/internal/server/sysupdate/backend_zypper_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseZypperXML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []Package
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty stream",
|
||||||
|
input: `<?xml version="1.0"?><stream><update-list></update-list></stream>`,
|
||||||
|
want: []Package{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single package update",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="zsh" edition="5.9-6" edition-old="5.9-5" kind="package" arch="x86_64">
|
||||||
|
<source url="https://download.opensuse.org/" alias="repo-oss"/>
|
||||||
|
</update>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "zsh", Repo: RepoSystem, Backend: "zypper", FromVersion: "5.9-5", ToVersion: "5.9-6"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skips non-package kinds",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream>
|
||||||
|
<update-list>
|
||||||
|
<update name="foo" edition="2.0" edition-old="1.0" kind="package"/>
|
||||||
|
<update name="security-patch" edition="1" edition-old="0" kind="patch"/>
|
||||||
|
<update name="bar" edition="3.0" edition-old="2.0" kind="package"/>
|
||||||
|
</update-list>
|
||||||
|
</stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "foo", Repo: RepoSystem, Backend: "zypper", FromVersion: "1.0", ToVersion: "2.0"},
|
||||||
|
{Name: "bar", Repo: RepoSystem, Backend: "zypper", FromVersion: "2.0", ToVersion: "3.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "treats missing kind as package",
|
||||||
|
input: `<?xml version="1.0"?>
|
||||||
|
<stream><update-list>
|
||||||
|
<update name="kernel" edition="6.18.1-1" edition-old="6.18.0-1"/>
|
||||||
|
</update-list></stream>`,
|
||||||
|
want: []Package{
|
||||||
|
{Name: "kernel", Repo: RepoSystem, Backend: "zypper", FromVersion: "6.18.0-1", ToVersion: "6.18.1-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed XML returns error",
|
||||||
|
input: `not xml at all`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := parseZypperXML([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("parseZypperXML() err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("parseZypperXML() = %#v\nwant %#v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
117
core/internal/server/sysupdate/executor.go
Normal file
117
core/internal/server/sysupdate/executor.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunOptions struct {
|
||||||
|
Env []string
|
||||||
|
OnLine func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, argv []string, opts RunOptions) error {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("sysupdate.Run: empty argv")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
if len(opts.Env) > 0 {
|
||||||
|
cmd.Env = append(cmd.Environ(), opts.Env...)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go pump(stdout, opts.OnLine, &wg)
|
||||||
|
go pump(stderr, opts.OnLine, &wg)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pump(r io.Reader, onLine func(string), wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
if onLine == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
onLine(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Capture(ctx context.Context, argv []string) (string, error) {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return "", fmt.Errorf("sysupdate.Capture: empty argv")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||||
|
out, err := cmd.Output()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTerminal(override string) string {
|
||||||
|
if override != "" && commandExists(override) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
if t := os.Getenv("TERMINAL"); t != "" && commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
for _, t := range []string{"ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"} {
|
||||||
|
if commandExists(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapInTerminal(term, title, shellCmd string) []string {
|
||||||
|
const appID = "dms-sysupdate"
|
||||||
|
banner := fmt.Sprintf(
|
||||||
|
`printf '\033[1;36m=== %s ===\033[0m\n'; printf '\033[2m$ %s\033[0m\n'; printf '\033[33mYou may be prompted for your sudo password to apply system updates.\033[0m\n\n'`,
|
||||||
|
title, shellCmd,
|
||||||
|
)
|
||||||
|
closer := `printf '\n\033[1;32m=== Done. Press Enter to close. ===\033[0m\n'; read`
|
||||||
|
export := `export SUDO_PROMPT="[DMS] sudo password for %u: "; `
|
||||||
|
full := export + banner + "; " + shellCmd + "; " + closer
|
||||||
|
|
||||||
|
switch term {
|
||||||
|
case "kitty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "alacritty":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "foot":
|
||||||
|
return []string{term, "--app-id=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "ghostty":
|
||||||
|
return []string{term, "--class=" + appID, "--title=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "wezterm":
|
||||||
|
return []string{term, "--class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "xterm":
|
||||||
|
return []string{term, "-class", appID, "-T", title, "-e", "sh", "-c", full}
|
||||||
|
case "konsole":
|
||||||
|
return []string{term, "-p", "tabtitle=" + title, "-e", "sh", "-c", full}
|
||||||
|
case "gnome-terminal":
|
||||||
|
return []string{term, "--title=" + title, "--", "sh", "-c", full}
|
||||||
|
default:
|
||||||
|
return []string{term, "-e", "sh", "-c", full}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
core/internal/server/sysupdate/handlers.go
Normal file
55
core/internal/server/sysupdate/handlers.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
switch req.Method {
|
||||||
|
case "sysupdate.getState":
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.refresh":
|
||||||
|
force := params.BoolOpt(req.Params, "force", false)
|
||||||
|
m.Refresh(RefreshOptions{Force: force})
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.upgrade":
|
||||||
|
handleUpgrade(conn, req, m)
|
||||||
|
case "sysupdate.cancel":
|
||||||
|
m.Cancel()
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
case "sysupdate.acquire":
|
||||||
|
m.Acquire()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.release":
|
||||||
|
m.Release()
|
||||||
|
models.Respond(conn, req.ID, models.SuccessResult{Success: true})
|
||||||
|
case "sysupdate.setInterval":
|
||||||
|
seconds, err := params.Int(req.Params, "seconds")
|
||||||
|
if err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.SetInterval(seconds)
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
default:
|
||||||
|
models.RespondError(conn, req.ID, "unknown method: "+req.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUpgrade(conn net.Conn, req models.Request, m *Manager) {
|
||||||
|
opts := UpgradeOptions{
|
||||||
|
IncludeFlatpak: params.BoolOpt(req.Params, "includeFlatpak", true),
|
||||||
|
IncludeAUR: params.BoolOpt(req.Params, "includeAUR", true),
|
||||||
|
DryRun: params.BoolOpt(req.Params, "dry", false),
|
||||||
|
CustomCommand: params.StringOpt(req.Params, "customCommand", ""),
|
||||||
|
Terminal: params.StringOpt(req.Params, "terminal", ""),
|
||||||
|
}
|
||||||
|
if err := m.Upgrade(opts); err != nil {
|
||||||
|
models.RespondError(conn, req.ID, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Respond(conn, req.ID, m.GetState())
|
||||||
|
}
|
||||||
493
core/internal/server/sysupdate/manager.go
Normal file
493
core/internal/server/sysupdate/manager.go
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultIntervalSeconds = 30 * 60
|
||||||
|
minIntervalSeconds = 5 * 60
|
||||||
|
recentLogCapacity = 200
|
||||||
|
checkTimeout = 5 * time.Minute
|
||||||
|
upgradeTimeout = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
state State
|
||||||
|
subscribers syncmap.Map[string, chan State]
|
||||||
|
|
||||||
|
selection Selection
|
||||||
|
|
||||||
|
notifyDirty chan struct{}
|
||||||
|
stopChan chan struct{}
|
||||||
|
notifierWG sync.WaitGroup
|
||||||
|
schedulerWG sync.WaitGroup
|
||||||
|
|
||||||
|
acquireCount int32
|
||||||
|
wakeSched chan struct{}
|
||||||
|
|
||||||
|
opMu sync.Mutex
|
||||||
|
opCtx context.Context
|
||||||
|
opCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() (*Manager, error) {
|
||||||
|
m := &Manager{
|
||||||
|
notifyDirty: make(chan struct{}, 1),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
wakeSched: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
m.state = State{
|
||||||
|
Phase: PhaseIdle,
|
||||||
|
IntervalSeconds: defaultIntervalSeconds,
|
||||||
|
Backends: []BackendInfo{},
|
||||||
|
Packages: []Package{},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, pretty := readOSRelease()
|
||||||
|
m.state.Distro = id
|
||||||
|
m.state.DistroPretty = pretty
|
||||||
|
|
||||||
|
m.selection = Select(context.Background())
|
||||||
|
m.state.Backends = m.selection.Info()
|
||||||
|
if len(m.state.Backends) == 0 {
|
||||||
|
m.state.Error = &ErrorInfo{
|
||||||
|
Code: ErrCodeNoBackend,
|
||||||
|
Message: "no supported package manager found",
|
||||||
|
Hint: "install a supported package manager (pacman, dnf, apt, zypper) or flatpak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.notifierWG.Add(1)
|
||||||
|
go m.notifier()
|
||||||
|
|
||||||
|
m.schedulerWG.Add(1)
|
||||||
|
go m.scheduler()
|
||||||
|
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetState() State {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return cloneState(m.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Subscribe(id string) chan State {
|
||||||
|
ch := make(chan State, 16)
|
||||||
|
m.subscribers.Store(id, ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Unsubscribe(id string) {
|
||||||
|
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||||
|
close(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
close(m.stopChan)
|
||||||
|
}
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel()
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
m.schedulerWG.Wait()
|
||||||
|
m.notifierWG.Wait()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
close(ch)
|
||||||
|
m.subscribers.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetInterval(seconds int) {
|
||||||
|
if seconds < minIntervalSeconds {
|
||||||
|
seconds = minIntervalSeconds
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.IntervalSeconds = seconds
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Refresh(opts RefreshOptions) {
|
||||||
|
m.mu.RLock()
|
||||||
|
phase := m.state.Phase
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case phase == PhaseUpgrading:
|
||||||
|
return
|
||||||
|
case phase == PhaseRefreshing && !opts.Force:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Upgrade(opts UpgradeOptions) error {
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return errors.New("no backend available")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opMu.Unlock()
|
||||||
|
return errors.New("operation already running")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), upgradeTimeout)
|
||||||
|
m.opCtx = ctx
|
||||||
|
m.opCancel = cancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
|
||||||
|
go m.runUpgrade(ctx, opts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Cancel() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
cancel := m.opCancel
|
||||||
|
m.opMu.Unlock()
|
||||||
|
if cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Acquire() {
|
||||||
|
first := atomic.AddInt32(&m.acquireCount, 1) == 1
|
||||||
|
select {
|
||||||
|
case m.wakeSched <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if first {
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Release() {
|
||||||
|
if atomic.AddInt32(&m.acquireCount, -1) < 0 {
|
||||||
|
atomic.StoreInt32(&m.acquireCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) scheduler() {
|
||||||
|
defer m.schedulerWG.Done()
|
||||||
|
for {
|
||||||
|
if atomic.LoadInt32(&m.acquireCount) == 0 {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
interval := m.state.IntervalSeconds
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if interval < minIntervalSeconds {
|
||||||
|
interval = minIntervalSeconds
|
||||||
|
}
|
||||||
|
t := time.NewTimer(time.Duration(interval) * time.Second)
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
t.Stop()
|
||||||
|
return
|
||||||
|
case <-m.wakeSched:
|
||||||
|
t.Stop()
|
||||||
|
case <-t.C:
|
||||||
|
m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runRefresh(parent context.Context) {
|
||||||
|
if len(m.selection.All()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(parent, checkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.state.Phase == PhaseUpgrading {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.state.Phase = PhaseRefreshing
|
||||||
|
m.state.Error = nil
|
||||||
|
m.state.RecentLog = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
type backendResult struct {
|
||||||
|
pkgs []Package
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
backends := m.selection.All()
|
||||||
|
results := make([]backendResult, len(backends))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, b := range backends {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, b Backend) {
|
||||||
|
defer wg.Done()
|
||||||
|
pkgs, err := b.CheckUpdates(ctx)
|
||||||
|
results[i] = backendResult{pkgs: pkgs, err: err}
|
||||||
|
}(i, b)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.LastCheckUnix = now
|
||||||
|
m.state.Packages = m.state.Packages[:0]
|
||||||
|
var firstErr error
|
||||||
|
for i, r := range results {
|
||||||
|
if r.err != nil {
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = fmt.Errorf("%s: %w", backends[i].ID(), r.err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.state.Packages = append(m.state.Packages, r.pkgs...)
|
||||||
|
}
|
||||||
|
m.state.Count = len(m.state.Packages)
|
||||||
|
if firstErr != nil {
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: ErrCodeBackendFailed, Message: firstErr.Error()}
|
||||||
|
} else {
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.LastSuccessUnix = now
|
||||||
|
m.state.NextCheckUnix = now + int64(m.state.IntervalSeconds)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) {
|
||||||
|
defer func() {
|
||||||
|
m.opMu.Lock()
|
||||||
|
if m.opCancel != nil {
|
||||||
|
m.opCancel = nil
|
||||||
|
m.opCtx = nil
|
||||||
|
}
|
||||||
|
m.opMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if opts.CustomCommand != "" {
|
||||||
|
m.runCustomUpgrade(ctx, opts.CustomCommand, opts.Terminal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backends := upgradeBackends(m.selection, opts)
|
||||||
|
if len(backends) == 0 {
|
||||||
|
m.setError(ErrCodeNoBackend, "no backend selected for upgrade")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
for _, b := range backends {
|
||||||
|
m.appendLog(fmt.Sprintf("== %s ==", b.DisplayName()))
|
||||||
|
if err := b.Upgrade(ctx, opts, onLine); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
} else if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: fmt.Sprintf("%s: %v", b.ID(), err)}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.OperationID = ""
|
||||||
|
m.state.OperationStarted = 0
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) {
|
||||||
|
term := findTerminal(terminalOverride)
|
||||||
|
if term == "" {
|
||||||
|
m.setError(ErrCodeBackendFailed, "no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opID := fmt.Sprintf("op-%d", time.Now().UnixNano())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseUpgrading
|
||||||
|
m.state.OperationID = opID
|
||||||
|
m.state.OperationStarted = time.Now().Unix()
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:0]
|
||||||
|
m.state.Error = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
|
||||||
|
onLine := func(line string) { m.appendLog(line) }
|
||||||
|
argv := wrapInTerminal(term, "DMS — System Update (custom)", command)
|
||||||
|
if err := Run(ctx, argv, RunOptions{OnLine: onLine}); err != nil {
|
||||||
|
code := ErrCodeBackendFailed
|
||||||
|
switch {
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
code = ErrCodeTimeout
|
||||||
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
|
code = ErrCodeCancelled
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: err.Error()}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseIdle
|
||||||
|
m.state.OperationID = ""
|
||||||
|
m.state.OperationStarted = 0
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
go m.runRefresh(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend {
|
||||||
|
var out []Backend
|
||||||
|
if sel.System != nil {
|
||||||
|
out = append(out, sel.System)
|
||||||
|
}
|
||||||
|
for _, b := range sel.Overlay {
|
||||||
|
switch {
|
||||||
|
case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) appendLog(line string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if cap(m.state.RecentLog) == 0 {
|
||||||
|
m.state.RecentLog = make([]string, 0, recentLogCapacity)
|
||||||
|
}
|
||||||
|
if len(m.state.RecentLog) >= recentLogCapacity {
|
||||||
|
copy(m.state.RecentLog, m.state.RecentLog[1:])
|
||||||
|
m.state.RecentLog = m.state.RecentLog[:recentLogCapacity-1]
|
||||||
|
}
|
||||||
|
m.state.RecentLog = append(m.state.RecentLog, line)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) setError(code ErrorCode, msg string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.state.Phase = PhaseError
|
||||||
|
m.state.Error = &ErrorInfo{Code: code, Message: msg}
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.markDirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) markDirty() {
|
||||||
|
select {
|
||||||
|
case m.notifyDirty <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) notifier() {
|
||||||
|
defer m.notifierWG.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopChan:
|
||||||
|
return
|
||||||
|
case <-m.notifyDirty:
|
||||||
|
snap := m.GetState()
|
||||||
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||||
|
select {
|
||||||
|
case ch <- snap:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneState(s State) State {
|
||||||
|
out := s
|
||||||
|
out.Backends = append([]BackendInfo(nil), s.Backends...)
|
||||||
|
out.Packages = append([]Package(nil), s.Packages...)
|
||||||
|
out.RecentLog = append([]string(nil), s.RecentLog...)
|
||||||
|
if s.Error != nil {
|
||||||
|
errCopy := *s.Error
|
||||||
|
out.Error = &errCopy
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOSRelease() (id, pretty string) {
|
||||||
|
f, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
k, v, ok := strings.Cut(scanner.Text(), "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.Trim(v, "\"")
|
||||||
|
switch k {
|
||||||
|
case "ID":
|
||||||
|
id = v
|
||||||
|
case "PRETTY_NAME":
|
||||||
|
pretty = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
log.Debugf("[sysupdate] read os-release: %v", err)
|
||||||
|
}
|
||||||
|
return id, pretty
|
||||||
|
}
|
||||||
84
core/internal/server/sysupdate/types.go
Normal file
84
core/internal/server/sysupdate/types.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package sysupdate
|
||||||
|
|
||||||
|
type Phase string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PhaseIdle Phase = "idle"
|
||||||
|
PhaseRefreshing Phase = "refreshing"
|
||||||
|
PhaseUpgrading Phase = "upgrading"
|
||||||
|
PhaseError Phase = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RepoSystem RepoKind = "system"
|
||||||
|
RepoAUR RepoKind = "aur"
|
||||||
|
RepoFlatpak RepoKind = "flatpak"
|
||||||
|
RepoOSTree RepoKind = "ostree"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrCodeNone ErrorCode = ""
|
||||||
|
ErrCodeNoBackend ErrorCode = "no-backend"
|
||||||
|
ErrCodeBusy ErrorCode = "busy"
|
||||||
|
ErrCodeBackendFailed ErrorCode = "backend-failed"
|
||||||
|
ErrCodeTimeout ErrorCode = "timeout"
|
||||||
|
ErrCodeCancelled ErrorCode = "cancelled"
|
||||||
|
ErrCodeInvalidRequest ErrorCode = "invalid-request"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Package struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
FromVersion string `json:"fromVersion,omitempty"`
|
||||||
|
ToVersion string `json:"toVersion,omitempty"`
|
||||||
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||||
|
ChangelogURL string `json:"changelogUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackendInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Repo RepoKind `json:"repo"`
|
||||||
|
NeedsAuth bool `json:"needsAuth"`
|
||||||
|
RunsInTerminal bool `json:"runsInTerminal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorInfo struct {
|
||||||
|
Code ErrorCode `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Phase Phase `json:"phase"`
|
||||||
|
Distro string `json:"distro,omitempty"`
|
||||||
|
DistroPretty string `json:"distroPretty,omitempty"`
|
||||||
|
Backends []BackendInfo `json:"backends"`
|
||||||
|
Packages []Package `json:"packages"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
IntervalSeconds int `json:"intervalSeconds"`
|
||||||
|
LastCheckUnix int64 `json:"lastCheckUnix,omitempty"`
|
||||||
|
LastSuccessUnix int64 `json:"lastSuccessUnix,omitempty"`
|
||||||
|
NextCheckUnix int64 `json:"nextCheckUnix,omitempty"`
|
||||||
|
OperationID string `json:"operationId,omitempty"`
|
||||||
|
OperationStarted int64 `json:"operationStartedUnix,omitempty"`
|
||||||
|
RecentLog []string `json:"recentLog,omitempty"`
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpgradeOptions struct {
|
||||||
|
IncludeFlatpak bool
|
||||||
|
IncludeAUR bool
|
||||||
|
DryRun bool
|
||||||
|
CustomCommand string
|
||||||
|
Terminal string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshOptions struct {
|
||||||
|
Force bool
|
||||||
|
}
|
||||||
@@ -30,9 +30,36 @@ Singleton {
|
|||||||
property bool isLightMode: false
|
property bool isLightMode: false
|
||||||
property bool doNotDisturb: false
|
property bool doNotDisturb: false
|
||||||
property real doNotDisturbUntil: 0
|
property real doNotDisturbUntil: 0
|
||||||
|
property string terminalOverride: ""
|
||||||
property bool isSwitchingMode: false
|
property bool isSwitchingMode: false
|
||||||
property bool suppressOSD: true
|
property bool suppressOSD: true
|
||||||
|
|
||||||
|
readonly property var terminalOptions: ["ghostty", "kitty", "foot", "alacritty", "wezterm", "konsole", "gnome-terminal", "xterm"]
|
||||||
|
property var installedTerminals: []
|
||||||
|
|
||||||
|
function resolveTerminal() {
|
||||||
|
if (terminalOverride && terminalOverride.length > 0) {
|
||||||
|
return terminalOverride;
|
||||||
|
}
|
||||||
|
const env = Quickshell.env("TERMINAL");
|
||||||
|
if (env && env.length > 0) {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: terminalProbe
|
||||||
|
running: true
|
||||||
|
command: ["sh", "-c", "for t in ghostty kitty foot alacritty wezterm konsole gnome-terminal xterm; do command -v \"$t\" >/dev/null 2>&1 && echo \"$t\"; done"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const found = text.trim().split("\n").filter(line => line.length > 0);
|
||||||
|
root.installedTerminals = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: dndExpireTimer
|
id: dndExpireTimer
|
||||||
repeat: false
|
repeat: false
|
||||||
|
|||||||
@@ -640,6 +640,9 @@ Singleton {
|
|||||||
property bool updaterUseCustomCommand: false
|
property bool updaterUseCustomCommand: false
|
||||||
property string updaterCustomCommand: ""
|
property string updaterCustomCommand: ""
|
||||||
property string updaterTerminalAdditionalParams: ""
|
property string updaterTerminalAdditionalParams: ""
|
||||||
|
property int updaterIntervalSeconds: 1800
|
||||||
|
property bool updaterIncludeFlatpak: true
|
||||||
|
property bool updaterAllowAUR: true
|
||||||
|
|
||||||
property string displayNameMode: "system"
|
property string displayNameMode: "system"
|
||||||
property var screenPreferences: ({})
|
property var screenPreferences: ({})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ var SPEC = {
|
|||||||
isLightMode: { def: false },
|
isLightMode: { def: false },
|
||||||
doNotDisturb: { def: false },
|
doNotDisturb: { def: false },
|
||||||
doNotDisturbUntil: { def: 0 },
|
doNotDisturbUntil: { def: 0 },
|
||||||
|
terminalOverride: { def: "" },
|
||||||
|
|
||||||
wallpaperPath: { def: "" },
|
wallpaperPath: { def: "" },
|
||||||
perMonitorWallpaper: { def: false },
|
perMonitorWallpaper: { def: false },
|
||||||
|
|||||||
@@ -428,6 +428,9 @@ var SPEC = {
|
|||||||
updaterUseCustomCommand: { def: false },
|
updaterUseCustomCommand: { def: false },
|
||||||
updaterCustomCommand: { def: "" },
|
updaterCustomCommand: { def: "" },
|
||||||
updaterTerminalAdditionalParams: { def: "" },
|
updaterTerminalAdditionalParams: { def: "" },
|
||||||
|
updaterIntervalSeconds: { def: 1800 },
|
||||||
|
updaterIncludeFlatpak: { def: true },
|
||||||
|
updaterAllowAUR: { def: true },
|
||||||
|
|
||||||
displayNameMode: { def: "system" },
|
displayNameMode: { def: "system" },
|
||||||
screenPreferences: { def: {} },
|
screenPreferences: { def: {} },
|
||||||
|
|||||||
@@ -895,7 +895,12 @@ Item {
|
|||||||
|
|
||||||
SystemUpdatePopout {
|
SystemUpdatePopout {
|
||||||
id: systemUpdatePopout
|
id: systemUpdatePopout
|
||||||
onPopoutClosed: PopoutService.unloadSystemUpdate()
|
onPopoutClosed: {
|
||||||
|
if (systemUpdatePopout._reopenAfterUpgrade) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PopoutService.unloadSystemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
PopoutService.systemUpdatePopout = systemUpdatePopout;
|
PopoutService.systemUpdatePopout = systemUpdatePopout;
|
||||||
|
|||||||
@@ -1881,7 +1881,7 @@ Item {
|
|||||||
function openTerminal(path) {
|
function openTerminal(path) {
|
||||||
if (!path)
|
if (!path)
|
||||||
return;
|
return;
|
||||||
var terminal = Quickshell.env("TERMINAL") || "xterm";
|
var terminal = SessionData.resolveTerminal() || "xterm";
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
command: [terminal],
|
command: [terminal],
|
||||||
workingDirectory: path
|
workingDirectory: path
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ Rectangle {
|
|||||||
"id": "updater",
|
"id": "updater",
|
||||||
"text": I18n.tr("System Updater"),
|
"text": I18n.tr("System Updater"),
|
||||||
"icon": "refresh",
|
"icon": "refresh",
|
||||||
"tabIndex": 20
|
"tabIndex": 20,
|
||||||
|
"updaterOnly": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "desktop_widgets",
|
"id": "desktop_widgets",
|
||||||
@@ -340,6 +341,8 @@ Rectangle {
|
|||||||
return false;
|
return false;
|
||||||
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
|
||||||
return false;
|
return false;
|
||||||
|
if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable)
|
||||||
|
return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
441
quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
Normal file
441
quickshell/Modules/DankBar/Popouts/SystemUpdatePopout.qml
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
DankPopout {
|
||||||
|
id: systemUpdatePopout
|
||||||
|
|
||||||
|
layerNamespace: "dms:system-update"
|
||||||
|
|
||||||
|
property var parentWidget: null
|
||||||
|
property var triggerScreen: null
|
||||||
|
|
||||||
|
Ref {
|
||||||
|
service: SystemUpdateService
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool _reopenAfterUpgrade: false
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: SystemUpdateService
|
||||||
|
function onIsUpgradingChanged() {
|
||||||
|
if (SystemUpdateService.isUpgrading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!systemUpdatePopout._reopenAfterUpgrade) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
systemUpdatePopout._reopenAfterUpgrade = false;
|
||||||
|
systemUpdatePopout.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popupWidth: 440
|
||||||
|
popupHeight: 560
|
||||||
|
triggerWidth: 55
|
||||||
|
positioning: ""
|
||||||
|
screen: triggerScreen
|
||||||
|
shouldBeVisible: false
|
||||||
|
|
||||||
|
onBackgroundClicked: close()
|
||||||
|
|
||||||
|
onShouldBeVisibleChanged: {
|
||||||
|
if (!shouldBeVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stale = !SystemUpdateService.lastCheckUnix || (Date.now() / 1000 - SystemUpdateService.lastCheckUnix) > 300;
|
||||||
|
if (stale && !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading) {
|
||||||
|
SystemUpdateService.checkForUpdates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content: Component {
|
||||||
|
Rectangle {
|
||||||
|
id: updaterPanel
|
||||||
|
|
||||||
|
color: "transparent"
|
||||||
|
focus: true
|
||||||
|
|
||||||
|
readonly property bool hasTerminalBackend: (SystemUpdateService.backends || []).some(b => b.runsInTerminal === true)
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Escape) {
|
||||||
|
systemUpdatePopout.close();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (systemUpdatePopout.shouldBeVisible) {
|
||||||
|
forceActiveFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: header
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.leftMargin: Theme.spacingL
|
||||||
|
anchors.rightMargin: Theme.spacingL
|
||||||
|
anchors.topMargin: Theme.spacingL
|
||||||
|
height: 40
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("System Updates")
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Medium
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: {
|
||||||
|
switch (true) {
|
||||||
|
case SystemUpdateService.isUpgrading:
|
||||||
|
return I18n.tr("Upgrading...");
|
||||||
|
case SystemUpdateService.isChecking:
|
||||||
|
return I18n.tr("Checking...");
|
||||||
|
case SystemUpdateService.hasError:
|
||||||
|
return I18n.tr("Error");
|
||||||
|
case SystemUpdateService.updateCount === 0:
|
||||||
|
return I18n.tr("Up to date");
|
||||||
|
case SystemUpdateService.updateCount === 1:
|
||||||
|
return I18n.tr("%1 update").arg(SystemUpdateService.updateCount);
|
||||||
|
default:
|
||||||
|
return I18n.tr("%1 updates").arg(SystemUpdateService.updateCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
color: SystemUpdateService.hasError ? Theme.error : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
id: refreshButton
|
||||||
|
buttonSize: 28
|
||||||
|
iconName: "refresh"
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
enabled: !SystemUpdateService.isChecking && !SystemUpdateService.isUpgrading
|
||||||
|
opacity: enabled ? 1.0 : 0.5
|
||||||
|
onClicked: SystemUpdateService.checkForUpdates()
|
||||||
|
|
||||||
|
RotationAnimation {
|
||||||
|
target: refreshButton
|
||||||
|
property: "rotation"
|
||||||
|
from: 0
|
||||||
|
to: 360
|
||||||
|
duration: 1000
|
||||||
|
running: SystemUpdateService.isChecking
|
||||||
|
loops: Animation.Infinite
|
||||||
|
|
||||||
|
onRunningChanged: {
|
||||||
|
if (!running) {
|
||||||
|
refreshButton.rotation = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: backendsRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: header.bottom
|
||||||
|
anchors.leftMargin: Theme.spacingL
|
||||||
|
anchors.rightMargin: Theme.spacingL
|
||||||
|
anchors.topMargin: Theme.spacingS
|
||||||
|
visible: SystemUpdateService.backends.length > 0 && !SystemUpdateService.isUpgrading
|
||||||
|
text: {
|
||||||
|
const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
|
||||||
|
return I18n.tr("Backends: %1").arg(names);
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: buttonsRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: Theme.spacingL
|
||||||
|
anchors.rightMargin: Theme.spacingL
|
||||||
|
anchors.bottomMargin: Theme.spacingL
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
height: 44
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: (parent.width - Theme.spacingM) / 2
|
||||||
|
height: parent.height
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: primaryMouseArea.containsMouse && primaryMouseArea.enabled ? Theme.primaryHover : Theme.secondaryHover
|
||||||
|
opacity: primaryMouseArea.enabled ? 1.0 : 0.5
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: SystemUpdateService.isUpgrading ? I18n.tr("Cancel") : I18n.tr("Update All")
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: primaryMouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
enabled: SystemUpdateService.isUpgrading || SystemUpdateService.updateCount > 0
|
||||||
|
onClicked: {
|
||||||
|
if (SystemUpdateService.isUpgrading) {
|
||||||
|
SystemUpdateService.cancelUpdates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opts = {
|
||||||
|
includeFlatpak: SettingsData.updaterIncludeFlatpak,
|
||||||
|
includeAUR: SettingsData.updaterAllowAUR,
|
||||||
|
terminal: SessionData.terminalOverride
|
||||||
|
};
|
||||||
|
if (updaterPanel.hasTerminalBackend) {
|
||||||
|
systemUpdatePopout._reopenAfterUpgrade = true;
|
||||||
|
SystemUpdateService.runUpdates(opts);
|
||||||
|
systemUpdatePopout.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SystemUpdateService.runUpdates(opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: (parent.width - Theme.spacingM) / 2
|
||||||
|
height: parent.height
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: closeMouseArea.containsMouse ? Theme.errorPressed : Theme.secondaryHover
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: I18n.tr("Close")
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: closeMouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: systemUpdatePopout.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
ColorAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.standardEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bodyArea
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: backendsRow.visible ? backendsRow.bottom : header.bottom
|
||||||
|
anchors.bottom: buttonsRow.top
|
||||||
|
anchors.leftMargin: Theme.spacingL
|
||||||
|
anchors.rightMargin: Theme.spacingL
|
||||||
|
anchors.topMargin: Theme.spacingM
|
||||||
|
anchors.bottomMargin: Theme.spacingM
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: statusText
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
visible: !SystemUpdateService.isUpgrading && (SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking)
|
||||||
|
text: {
|
||||||
|
switch (true) {
|
||||||
|
case SystemUpdateService.hasError:
|
||||||
|
return I18n.tr("Failed: %1").arg(SystemUpdateService.errorMessage);
|
||||||
|
case !SystemUpdateService.helperAvailable:
|
||||||
|
return I18n.tr("No supported package manager found.");
|
||||||
|
case SystemUpdateService.isChecking:
|
||||||
|
return I18n.tr("Checking for updates...");
|
||||||
|
default:
|
||||||
|
return I18n.tr("Your system is up to date!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
color: SystemUpdateService.hasError ? Theme.errorText : Theme.surfaceText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
DankListView {
|
||||||
|
id: packagesList
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
visible: !SystemUpdateService.isUpgrading && SystemUpdateService.updateCount > 0 && !SystemUpdateService.hasError && !SystemUpdateService.isChecking
|
||||||
|
clip: true
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
model: SystemUpdateService.availableUpdates
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
width: ListView.view.width
|
||||||
|
height: 48
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent"
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 64
|
||||||
|
height: 18
|
||||||
|
radius: 9
|
||||||
|
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.18)
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData.repo || ""
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - 64 - Theme.spacingS
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: modelData.name || ""
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: {
|
||||||
|
const from = modelData.fromVersion || "";
|
||||||
|
const to = modelData.toVersion || "";
|
||||||
|
if (from && to) {
|
||||||
|
return `${from} → ${to}`;
|
||||||
|
}
|
||||||
|
return to || from || "";
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: packageMouseArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: modelData.changelogUrl ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: {
|
||||||
|
if (modelData.changelogUrl) {
|
||||||
|
Qt.openUrlExternally(modelData.changelogUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: SystemUpdateService.isUpgrading && updaterPanel.hasTerminalBackend
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "terminal"
|
||||||
|
size: 32
|
||||||
|
color: Theme.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: I18n.tr("Running in terminal")
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: I18n.tr("AUR helpers are interactive — see the terminal window for prompts. This popout will return to idle when the upgrade exits.")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankFlickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingM
|
||||||
|
visible: SystemUpdateService.isUpgrading && !updaterPanel.hasTerminalBackend
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: logText.implicitHeight
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
onContentHeightChanged: {
|
||||||
|
if (contentHeight > height) {
|
||||||
|
contentY = contentHeight - height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: logText
|
||||||
|
width: parent.width
|
||||||
|
text: (SystemUpdateService.recentLog || []).join("\n")
|
||||||
|
font.family: Theme.monoFontFamily || "monospace"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,41 +7,33 @@ import qs.Widgets
|
|||||||
BasePill {
|
BasePill {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property var widgetData: null
|
||||||
property bool isActive: false
|
property bool isActive: false
|
||||||
|
|
||||||
readonly property bool hasUpdates: SystemUpdateService.updateCount > 0
|
readonly property bool hasUpdates: SystemUpdateService.updateCount > 0
|
||||||
readonly property bool isChecking: SystemUpdateService.isChecking
|
readonly property bool isChecking: SystemUpdateService.isChecking
|
||||||
readonly property bool shouldHide: SettingsData.updaterHideWidget && !hasUpdates && !isChecking && !SystemUpdateService.hasError
|
readonly property bool isClean: SystemUpdateService.sysupdateAvailable && !hasUpdates && !isChecking && !SystemUpdateService.hasError
|
||||||
|
readonly property bool hideWhenIdle: widgetData?.hideWhenIdle === true
|
||||||
|
readonly property bool shouldHide: hideWhenIdle && isClean
|
||||||
|
|
||||||
|
width: shouldHide ? 0 : (isVerticalOrientation ? barThickness : visualWidth)
|
||||||
|
height: shouldHide ? 0 : (isVerticalOrientation ? visualHeight : barThickness)
|
||||||
|
visible: !shouldHide
|
||||||
opacity: shouldHide ? 0 : 1
|
opacity: shouldHide ? 0 : 1
|
||||||
|
|
||||||
states: [
|
Behavior on width {
|
||||||
State {
|
NumberAnimation {
|
||||||
name: "hidden_horizontal"
|
duration: Theme.shortDuration
|
||||||
when: root.shouldHide && !isVerticalOrientation
|
easing.type: Theme.standardEasing
|
||||||
PropertyChanges {
|
}
|
||||||
target: root
|
}
|
||||||
width: 0
|
|
||||||
}
|
Behavior on height {
|
||||||
},
|
|
||||||
State {
|
|
||||||
name: "hidden_vertical"
|
|
||||||
when: root.shouldHide && isVerticalOrientation
|
|
||||||
PropertyChanges {
|
|
||||||
target: root
|
|
||||||
height: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
transitions: [
|
|
||||||
Transition {
|
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
properties: "width,height"
|
|
||||||
duration: Theme.shortDuration
|
duration: Theme.shortDuration
|
||||||
easing.type: Theme.standardEasing
|
easing.type: Theme.standardEasing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
|
|||||||
@@ -189,10 +189,10 @@ Item {
|
|||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: {
|
text: {
|
||||||
if (!SystemUpdateService.shellVersion && !DMSService.cliVersion)
|
if (!ShellVersionService.shellVersion && !DMSService.cliVersion)
|
||||||
return "dms";
|
return "dms";
|
||||||
|
|
||||||
let version = SystemUpdateService.shellVersion || "";
|
let version = ShellVersionService.shellVersion || "";
|
||||||
let cliVersion = DMSService.cliVersion || "";
|
let cliVersion = DMSService.cliVersion || "";
|
||||||
|
|
||||||
// Debian/Ubuntu/OpenSUSE git format: 1.0.3+git2264.c5c5ce84
|
// Debian/Ubuntu/OpenSUSE git format: 1.0.3+git2264.c5c5ce84
|
||||||
@@ -218,7 +218,7 @@ Item {
|
|||||||
|
|
||||||
let baseVersion = extractBaseVersion(cliVersion);
|
let baseVersion = extractBaseVersion(cliVersion);
|
||||||
if (!baseVersion)
|
if (!baseVersion)
|
||||||
baseVersion = extractBaseVersion(SystemUpdateService.semverVersion);
|
baseVersion = extractBaseVersion(ShellVersionService.semverVersion);
|
||||||
if (baseVersion) {
|
if (baseVersion) {
|
||||||
return `dms (git) v${baseVersion}-${match[1]}`;
|
return `dms (git) v${baseVersion}-${match[1]}`;
|
||||||
}
|
}
|
||||||
@@ -253,8 +253,8 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
visible: SystemUpdateService.shellCodename.length > 0
|
visible: ShellVersionService.shellCodename.length > 0
|
||||||
text: `"${SystemUpdateService.shellCodename}"`
|
text: `"${ShellVersionService.shellCodename}"`
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
font.italic: true
|
font.italic: true
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
|
|||||||
@@ -325,6 +325,8 @@ Item {
|
|||||||
placeholderText: I18n.tr("Enter launch prefix (e.g., 'uwsm-app')")
|
placeholderText: I18n.tr("Enter launch prefix (e.g., 'uwsm-app')")
|
||||||
onTextEdited: SettingsData.set("launchPrefix", text)
|
onTextEdited: SettingsData.set("launchPrefix", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TerminalPickerRow {}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.Common
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
import qs.Modules.Settings.Widgets
|
import qs.Modules.Settings.Widgets
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var intervalOptions: [
|
||||||
|
{
|
||||||
|
label: I18n.tr("Every 15 minutes"),
|
||||||
|
seconds: 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Every 30 minutes"),
|
||||||
|
seconds: 1800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Every hour"),
|
||||||
|
seconds: 3600
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Every 4 hours"),
|
||||||
|
seconds: 14400
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: I18n.tr("Once a day"),
|
||||||
|
seconds: 86400
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function intervalLabelFor(seconds) {
|
||||||
|
for (const opt of intervalOptions) {
|
||||||
|
if (opt.seconds === seconds) {
|
||||||
|
return opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intervalOptions[1].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalSecondsFor(label) {
|
||||||
|
for (const opt of intervalOptions) {
|
||||||
|
if (opt.label === label) {
|
||||||
|
return opt.seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1800;
|
||||||
|
}
|
||||||
|
|
||||||
DankFlickable {
|
DankFlickable {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
clip: true
|
clip: true
|
||||||
@@ -25,18 +67,60 @@ Item {
|
|||||||
title: I18n.tr("System Updater")
|
title: I18n.tr("System Updater")
|
||||||
settingKey: "systemUpdater"
|
settingKey: "systemUpdater"
|
||||||
|
|
||||||
SettingsToggleRow {
|
StyledText {
|
||||||
text: I18n.tr("Hide Updater Widget", "When updater widget is used, then hide it if no update found")
|
width: parent.width - Theme.spacingM * 2
|
||||||
description: I18n.tr("When updater widget is used, then hide it if no update found")
|
anchors.left: parent.left
|
||||||
checked: SettingsData.updaterHideWidget
|
anchors.leftMargin: Theme.spacingM
|
||||||
onToggled: checked => {
|
visible: SystemUpdateService.backends.length > 0
|
||||||
SettingsData.set("updaterHideWidget", checked);
|
text: {
|
||||||
|
const names = (SystemUpdateService.backends || []).map(b => b.displayName).join(", ");
|
||||||
|
return I18n.tr("Detected backends: %1").arg(names);
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDropdownRow {
|
||||||
|
text: I18n.tr("Check interval")
|
||||||
|
description: I18n.tr("How often the server polls for new updates.")
|
||||||
|
options: root.intervalOptions.map(o => o.label)
|
||||||
|
currentValue: root.intervalLabelFor(SettingsData.updaterIntervalSeconds)
|
||||||
|
onValueChanged: label => {
|
||||||
|
const secs = root.intervalSecondsFor(label);
|
||||||
|
SettingsData.set("updaterIntervalSeconds", secs);
|
||||||
|
SystemUpdateService.setInterval(secs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsToggleRow {
|
SettingsToggleRow {
|
||||||
text: I18n.tr("Use Custom Command")
|
text: I18n.tr("Include Flatpak updates")
|
||||||
description: I18n.tr("Use custom command for update your system")
|
description: I18n.tr("Apply Flatpak updates alongside system updates when running 'Update All'.")
|
||||||
|
visible: (SystemUpdateService.backends || []).some(b => b.repo === "flatpak")
|
||||||
|
checked: SettingsData.updaterIncludeFlatpak
|
||||||
|
onToggled: checked => SettingsData.set("updaterIncludeFlatpak", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsToggleRow {
|
||||||
|
text: I18n.tr("Include AUR updates")
|
||||||
|
description: I18n.tr("Run paru/yay with AUR enabled when 'Update All' is clicked.")
|
||||||
|
visible: (SystemUpdateService.backends || []).some(b => b.id === "paru" || b.id === "yay")
|
||||||
|
checked: SettingsData.updaterAllowAUR
|
||||||
|
onToggled: checked => SettingsData.set("updaterAllowAUR", checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalPickerRow {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
width: parent.width
|
||||||
|
iconName: "tune"
|
||||||
|
title: I18n.tr("Advanced")
|
||||||
|
settingKey: "systemUpdaterAdvanced"
|
||||||
|
|
||||||
|
SettingsToggleRow {
|
||||||
|
text: I18n.tr("Use custom command")
|
||||||
|
description: I18n.tr("Open a terminal and run a custom command instead of the in-shell upgrade flow.")
|
||||||
checked: SettingsData.updaterUseCustomCommand
|
checked: SettingsData.updaterUseCustomCommand
|
||||||
onToggled: checked => {
|
onToggled: checked => {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
@@ -49,11 +133,32 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width - Theme.spacingM * 2
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
visible: SettingsData.updaterUseCustomCommand
|
||||||
|
height: warnText.implicitHeight + Theme.spacingS * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: warnText
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
text: I18n.tr("Custom command and terminal params are split on whitespace; paths with spaces will break.")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.warning
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FocusScope {
|
FocusScope {
|
||||||
width: parent.width - Theme.spacingM * 2
|
width: parent.width - Theme.spacingM * 2
|
||||||
height: customCommandColumn.implicitHeight
|
height: customCommandColumn.implicitHeight
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingM
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
visible: SettingsData.updaterUseCustomCommand
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: customCommandColumn
|
id: customCommandColumn
|
||||||
@@ -61,7 +166,7 @@ Item {
|
|||||||
spacing: Theme.spacingXS
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("System update custom command")
|
text: I18n.tr("Custom update command")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
@@ -69,7 +174,7 @@ Item {
|
|||||||
DankTextField {
|
DankTextField {
|
||||||
id: updaterCustomCommand
|
id: updaterCustomCommand
|
||||||
width: parent.width
|
width: parent.width
|
||||||
placeholderText: "myPkgMngr --sysupdate"
|
placeholderText: "topgrade --no-retry"
|
||||||
backgroundColor: Theme.surfaceContainerHighest
|
backgroundColor: Theme.surfaceContainerHighest
|
||||||
normalBorderColor: Theme.outlineMedium
|
normalBorderColor: Theme.outlineMedium
|
||||||
focusedBorderColor: Theme.primary
|
focusedBorderColor: Theme.primary
|
||||||
@@ -98,6 +203,7 @@ Item {
|
|||||||
height: terminalParamsColumn.implicitHeight
|
height: terminalParamsColumn.implicitHeight
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingM
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
visible: SettingsData.updaterUseCustomCommand
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: terminalParamsColumn
|
id: terminalParamsColumn
|
||||||
@@ -105,7 +211,7 @@ Item {
|
|||||||
spacing: Theme.spacingXS
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
text: I18n.tr("Terminal custom additional parameters")
|
text: I18n.tr("Terminal additional parameters")
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
@@ -113,7 +219,7 @@ Item {
|
|||||||
DankTextField {
|
DankTextField {
|
||||||
id: updaterTerminalCustomClass
|
id: updaterTerminalCustomClass
|
||||||
width: parent.width
|
width: parent.width
|
||||||
placeholderText: "-T udpClass"
|
placeholderText: "-T updater"
|
||||||
backgroundColor: Theme.surfaceContainerHighest
|
backgroundColor: Theme.surfaceContainerHighest
|
||||||
normalBorderColor: Theme.outlineMedium
|
normalBorderColor: Theme.outlineMedium
|
||||||
focusedBorderColor: Theme.primary
|
focusedBorderColor: Theme.primary
|
||||||
|
|||||||
31
quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml
Normal file
31
quickshell/Modules/Settings/Widgets/TerminalPickerRow.qml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
SettingsDropdownRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string autoLabel: I18n.tr("Auto")
|
||||||
|
|
||||||
|
text: I18n.tr("Terminal")
|
||||||
|
settingKey: "terminalOverride"
|
||||||
|
|
||||||
|
options: {
|
||||||
|
const opts = [autoLabel];
|
||||||
|
const installed = SessionData.installedTerminals || [];
|
||||||
|
const list = installed.length > 0 ? installed : SessionData.terminalOptions;
|
||||||
|
for (const t of list) {
|
||||||
|
opts.push(t);
|
||||||
|
}
|
||||||
|
if (SessionData.terminalOverride && !opts.includes(SessionData.terminalOverride)) {
|
||||||
|
opts.push(SessionData.terminalOverride);
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue: SessionData.terminalOverride.length > 0 ? SessionData.terminalOverride : autoLabel
|
||||||
|
|
||||||
|
onValueChanged: label => {
|
||||||
|
const next = label === autoLabel ? "" : label;
|
||||||
|
SessionData.set("terminalOverride", next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -246,7 +246,8 @@ Item {
|
|||||||
"text": I18n.tr("System Update"),
|
"text": I18n.tr("System Update"),
|
||||||
"description": I18n.tr("Check for system updates"),
|
"description": I18n.tr("Check for system updates"),
|
||||||
"icon": "update",
|
"icon": "update",
|
||||||
"enabled": SystemUpdateService.distributionSupported
|
"enabled": SystemUpdateService.sysupdateAvailable,
|
||||||
|
"warning": SystemUpdateService.sysupdateAvailable ? undefined : I18n.tr("Requires DMS server with sysupdate capability")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "powerMenuButton",
|
"id": "powerMenuButton",
|
||||||
@@ -430,7 +431,7 @@ Item {
|
|||||||
"id": widget.id,
|
"id": widget.id,
|
||||||
"enabled": widget.enabled
|
"enabled": widget.enabled
|
||||||
};
|
};
|
||||||
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion"];
|
var keys = ["size", "selectedGpuIndex", "pciId", "mountPath", "diskUsageMode", "minimumWidth", "showSwap", "showInGb", "mediaSize", "clockCompactMode", "focusedWindowCompactMode", "runningAppsCompactMode", "keyboardLayoutNameCompactMode", "runningAppsGroupByApp", "runningAppsCurrentWorkspace", "runningAppsCurrentMonitor", "showNetworkIcon", "showBluetoothIcon", "showAudioIcon", "showAudioPercent", "showVpnIcon", "showBrightnessIcon", "showBrightnessPercent", "showMicIcon", "showMicPercent", "showBatteryIcon", "showPrinterIcon", "showScreenSharingIcon", "controlCenterGroupOrder", "barMaxVisibleApps", "barMaxVisibleRunningApps", "barShowOverflowBadge", "trayUseInlineExpansion", "hideWhenIdle"];
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
if (widget[keys[i]] !== undefined)
|
if (widget[keys[i]] !== undefined)
|
||||||
result[keys[i]] = widget[keys[i]];
|
result[keys[i]] = widget[keys[i]];
|
||||||
@@ -579,6 +580,17 @@ Item {
|
|||||||
setWidgetsForSection(sectionId, widgets);
|
setWidgetsForSection(sectionId, widgets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleHideWhenIdleChanged(sectionId, widgetIndex, enabled) {
|
||||||
|
var widgets = getWidgetsForSection(sectionId).slice();
|
||||||
|
if (widgetIndex < 0 || widgetIndex >= widgets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var newWidget = cloneWidgetData(widgets[widgetIndex]);
|
||||||
|
newWidget.hideWhenIdle = enabled;
|
||||||
|
widgets[widgetIndex] = newWidget;
|
||||||
|
setWidgetsForSection(sectionId, widgets);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDiskUsageModeChanged(sectionId, widgetIndex, mode) {
|
function handleDiskUsageModeChanged(sectionId, widgetIndex, mode) {
|
||||||
var widgets = getWidgetsForSection(sectionId).slice();
|
var widgets = getWidgetsForSection(sectionId).slice();
|
||||||
if (widgetIndex < 0 || widgetIndex >= widgets.length) {
|
if (widgetIndex < 0 || widgetIndex >= widgets.length) {
|
||||||
@@ -714,6 +726,8 @@ Item {
|
|||||||
item.barShowOverflowBadge = widget.barShowOverflowBadge;
|
item.barShowOverflowBadge = widget.barShowOverflowBadge;
|
||||||
if (widget.trayUseInlineExpansion !== undefined)
|
if (widget.trayUseInlineExpansion !== undefined)
|
||||||
item.trayUseInlineExpansion = widget.trayUseInlineExpansion;
|
item.trayUseInlineExpansion = widget.trayUseInlineExpansion;
|
||||||
|
if (widget.hideWhenIdle !== undefined)
|
||||||
|
item.hideWhenIdle = widget.hideWhenIdle;
|
||||||
}
|
}
|
||||||
widgets.push(item);
|
widgets.push(item);
|
||||||
});
|
});
|
||||||
@@ -1003,6 +1017,9 @@ Item {
|
|||||||
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
||||||
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
||||||
}
|
}
|
||||||
|
onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
|
||||||
|
widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,6 +1087,9 @@ Item {
|
|||||||
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
||||||
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
||||||
}
|
}
|
||||||
|
onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
|
||||||
|
widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,6 +1157,9 @@ Item {
|
|||||||
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
onOverflowSettingChanged: (sectionId, widgetIndex, settingName, value) => {
|
||||||
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
widgetsTab.handleOverflowSettingChanged(sectionId, widgetIndex, settingName, value);
|
||||||
}
|
}
|
||||||
|
onHideWhenIdleChanged: (sectionId, widgetIndex, enabled) => {
|
||||||
|
widgetsTab.handleHideWhenIdleChanged(sectionId, widgetIndex, enabled);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Column {
|
|||||||
signal showInGbChanged(string sectionId, int widgetIndex, bool enabled)
|
signal showInGbChanged(string sectionId, int widgetIndex, bool enabled)
|
||||||
signal diskUsageModeChanged(string sectionId, int widgetIndex, int mode)
|
signal diskUsageModeChanged(string sectionId, int widgetIndex, int mode)
|
||||||
signal overflowSettingChanged(string sectionId, int widgetIndex, string settingName, var value)
|
signal overflowSettingChanged(string sectionId, int widgetIndex, string settingName, var value)
|
||||||
|
signal hideWhenIdleChanged(string sectionId, int widgetIndex, bool enabled)
|
||||||
|
|
||||||
function cloneWidgetData(widget) {
|
function cloneWidgetData(widget) {
|
||||||
var result = {
|
var result = {
|
||||||
@@ -335,6 +336,25 @@ Column {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
id: hideWhenIdleButton
|
||||||
|
buttonSize: 28
|
||||||
|
visible: modelData.id === "systemUpdate"
|
||||||
|
iconName: "visibility_off"
|
||||||
|
iconSize: 16
|
||||||
|
iconColor: (modelData.hideWhenIdle === true) ? Theme.primary : Theme.outline
|
||||||
|
onClicked: {
|
||||||
|
root.hideWhenIdleChanged(root.sectionId, index, modelData.hideWhenIdle !== true);
|
||||||
|
}
|
||||||
|
onEntered: {
|
||||||
|
const tooltipText = modelData.hideWhenIdle === true ? "Hide when no updates: ON" : "Hide when no updates: OFF";
|
||||||
|
sharedTooltip.show(tooltipText, hideWhenIdleButton, 0, 0, "bottom");
|
||||||
|
}
|
||||||
|
onExited: {
|
||||||
|
sharedTooltip.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DankActionButton {
|
DankActionButton {
|
||||||
id: memMenuButton
|
id: memMenuButton
|
||||||
visible: modelData.id === "memUsage"
|
visible: modelData.id === "memUsage"
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import QtQuick
|
|
||||||
import qs.Common
|
|
||||||
import qs.Services
|
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
DankPopout {
|
|
||||||
id: systemUpdatePopout
|
|
||||||
|
|
||||||
layerNamespace: "dms:system-update"
|
|
||||||
|
|
||||||
property var parentWidget: null
|
|
||||||
property var triggerScreen: null
|
|
||||||
|
|
||||||
Ref {
|
|
||||||
service: SystemUpdateService
|
|
||||||
}
|
|
||||||
|
|
||||||
popupWidth: 400
|
|
||||||
popupHeight: 500
|
|
||||||
triggerWidth: 55
|
|
||||||
positioning: ""
|
|
||||||
screen: triggerScreen
|
|
||||||
shouldBeVisible: false
|
|
||||||
|
|
||||||
onBackgroundClicked: close()
|
|
||||||
|
|
||||||
onShouldBeVisibleChanged: {
|
|
||||||
if (shouldBeVisible) {
|
|
||||||
if (SystemUpdateService.updateCount === 0 && !SystemUpdateService.isChecking) {
|
|
||||||
SystemUpdateService.checkForUpdates();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
content: Component {
|
|
||||||
Rectangle {
|
|
||||||
id: updaterPanel
|
|
||||||
|
|
||||||
color: "transparent"
|
|
||||||
|
|
||||||
Column {
|
|
||||||
width: parent.width - Theme.spacingL * 2
|
|
||||||
height: parent.height - Theme.spacingL * 2
|
|
||||||
x: Theme.spacingL
|
|
||||||
y: Theme.spacingL
|
|
||||||
spacing: Theme.spacingL
|
|
||||||
|
|
||||||
Item {
|
|
||||||
width: parent.width
|
|
||||||
height: 40
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("System Updates")
|
|
||||||
font.pixelSize: Theme.fontSizeLarge
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: {
|
|
||||||
if (SystemUpdateService.isChecking)
|
|
||||||
return I18n.tr("Checking...");
|
|
||||||
if (SystemUpdateService.hasError)
|
|
||||||
return I18n.tr("Error");
|
|
||||||
if (SystemUpdateService.updateCount === 0)
|
|
||||||
return I18n.tr("Up to date");
|
|
||||||
return SystemUpdateService.updateCount === 1
|
|
||||||
? I18n.tr("%1 update").arg(SystemUpdateService.updateCount)
|
|
||||||
: I18n.tr("%1 updates").arg(SystemUpdateService.updateCount);
|
|
||||||
}
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: {
|
|
||||||
if (SystemUpdateService.hasError)
|
|
||||||
return Theme.error;
|
|
||||||
return Theme.surfaceText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankActionButton {
|
|
||||||
id: checkForUpdatesButton
|
|
||||||
buttonSize: 28
|
|
||||||
iconName: "refresh"
|
|
||||||
iconSize: 18
|
|
||||||
z: 15
|
|
||||||
iconColor: Theme.surfaceText
|
|
||||||
enabled: !SystemUpdateService.isChecking
|
|
||||||
opacity: enabled ? 1.0 : 0.5
|
|
||||||
onClicked: {
|
|
||||||
SystemUpdateService.checkForUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
RotationAnimation {
|
|
||||||
target: checkForUpdatesButton
|
|
||||||
property: "rotation"
|
|
||||||
from: 0
|
|
||||||
to: 360
|
|
||||||
duration: 1000
|
|
||||||
running: SystemUpdateService.isChecking
|
|
||||||
loops: Animation.Infinite
|
|
||||||
|
|
||||||
onRunningChanged: {
|
|
||||||
if (!running) {
|
|
||||||
checkForUpdatesButton.rotation = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: {
|
|
||||||
let usedHeight = 40 + Theme.spacingL;
|
|
||||||
usedHeight += 48 + Theme.spacingL;
|
|
||||||
return parent.height - usedHeight;
|
|
||||||
}
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.1)
|
|
||||||
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
|
|
||||||
border.width: 0
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
anchors.rightMargin: 0
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
id: statusText
|
|
||||||
width: parent.width
|
|
||||||
text: {
|
|
||||||
if (SystemUpdateService.hasError) {
|
|
||||||
return I18n.tr("Failed to check for updates:\n%1").arg(SystemUpdateService.errorMessage);
|
|
||||||
}
|
|
||||||
if (!SystemUpdateService.helperAvailable) {
|
|
||||||
return I18n.tr("No package manager found. Please install 'paru' or 'yay' on Arch-based systems to check for updates.");
|
|
||||||
}
|
|
||||||
if (SystemUpdateService.isChecking) {
|
|
||||||
return I18n.tr("Checking for updates...");
|
|
||||||
}
|
|
||||||
if (SystemUpdateService.updateCount === 0) {
|
|
||||||
return I18n.tr("Your system is up to date!");
|
|
||||||
}
|
|
||||||
return SystemUpdateService.updateCount === 1
|
|
||||||
? I18n.tr("Found %1 package to update:").arg(SystemUpdateService.updateCount)
|
|
||||||
: I18n.tr("Found %1 packages to update:").arg(SystemUpdateService.updateCount);
|
|
||||||
}
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: {
|
|
||||||
if (SystemUpdateService.hasError)
|
|
||||||
return Theme.errorText;
|
|
||||||
return Theme.surfaceText;
|
|
||||||
}
|
|
||||||
wrapMode: Text.WordWrap
|
|
||||||
visible: SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking
|
|
||||||
}
|
|
||||||
|
|
||||||
DankListView {
|
|
||||||
id: packagesList
|
|
||||||
|
|
||||||
width: parent.width
|
|
||||||
height: parent.height - (SystemUpdateService.updateCount === 0 || SystemUpdateService.hasError || SystemUpdateService.isChecking ? statusText.height + Theme.spacingM : 0)
|
|
||||||
visible: SystemUpdateService.updateCount > 0 && !SystemUpdateService.isChecking && !SystemUpdateService.hasError
|
|
||||||
clip: true
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
model: SystemUpdateService.availableUpdates
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
|
||||||
width: ListView.view.width - Theme.spacingM
|
|
||||||
height: 48
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: packageMouseArea.containsMouse ? Theme.primaryHoverLight : "transparent"
|
|
||||||
border.color: Theme.outlineLight
|
|
||||||
border.width: 0
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.spacingM
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
|
|
||||||
Column {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: parent.width - Theme.spacingM
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
width: parent.width
|
|
||||||
text: modelData.name || ""
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Medium
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
width: parent.width
|
|
||||||
text: `${modelData.currentVersion} → ${modelData.newVersion}`
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceVariantText
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: packageMouseArea
|
|
||||||
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
width: parent.width
|
|
||||||
height: 48
|
|
||||||
spacing: Theme.spacingM
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: (parent.width - Theme.spacingM) / 2
|
|
||||||
height: parent.height
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: updateMouseArea.containsMouse ? Theme.primaryHover : Theme.secondaryHover
|
|
||||||
opacity: SystemUpdateService.updateCount > 0 ? 1.0 : 0.5
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: "system_update_alt"
|
|
||||||
size: Theme.iconSize
|
|
||||||
color: Theme.primary
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("Update All")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
font.weight: Font.Medium
|
|
||||||
color: Theme.primary
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: updateMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
enabled: SystemUpdateService.updateCount > 0
|
|
||||||
onClicked: {
|
|
||||||
SystemUpdateService.runUpdates();
|
|
||||||
systemUpdatePopout.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: (parent.width - Theme.spacingM) / 2
|
|
||||||
height: parent.height
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: closeMouseArea.containsMouse ? Theme.errorPressed : Theme.secondaryHover
|
|
||||||
|
|
||||||
Behavior on color {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
spacing: Theme.spacingS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
name: "close"
|
|
||||||
size: Theme.iconSize
|
|
||||||
color: Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
text: I18n.tr("Close")
|
|
||||||
font.pixelSize: Theme.fontSizeMedium
|
|
||||||
font.weight: Font.Medium
|
|
||||||
color: Theme.surfaceText
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: closeMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
systemUpdatePopout.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,12 +61,13 @@ Singleton {
|
|||||||
signal screensaverStateUpdate(var data)
|
signal screensaverStateUpdate(var data)
|
||||||
signal clipboardStateUpdate(var data)
|
signal clipboardStateUpdate(var data)
|
||||||
signal locationStateUpdate(var data)
|
signal locationStateUpdate(var data)
|
||||||
|
signal sysupdateStateUpdate(var data)
|
||||||
|
|
||||||
property bool capsLockState: false
|
property bool capsLockState: false
|
||||||
property bool screensaverInhibited: false
|
property bool screensaverInhibited: false
|
||||||
property var screensaverInhibitors: []
|
property var screensaverInhibitors: []
|
||||||
|
|
||||||
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location"]
|
property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus", "clipboard", "location", "sysupdate"]
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
if (socketPath && socketPath.length > 0) {
|
if (socketPath && socketPath.length > 0) {
|
||||||
@@ -393,6 +394,8 @@ Singleton {
|
|||||||
clipboardStateUpdate(data);
|
clipboardStateUpdate(data);
|
||||||
} else if (service === "location") {
|
} else if (service === "location") {
|
||||||
locationStateUpdate(data);
|
locationStateUpdate(data);
|
||||||
|
} else if (service === "sysupdate") {
|
||||||
|
sysupdateStateUpdate(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,4 +752,37 @@ Singleton {
|
|||||||
"name": name
|
"name": name
|
||||||
}, callback);
|
}, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sysupdateGetState(callback) {
|
||||||
|
sendRequest("sysupdate.getState", null, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateRefresh(force, callback) {
|
||||||
|
sendRequest("sysupdate.refresh", {
|
||||||
|
"force": force === true
|
||||||
|
}, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateUpgrade(opts, callback) {
|
||||||
|
const params = opts || {};
|
||||||
|
sendRequest("sysupdate.upgrade", params, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateCancel(callback) {
|
||||||
|
sendRequest("sysupdate.cancel", null, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateSetInterval(seconds, callback) {
|
||||||
|
sendRequest("sysupdate.setInterval", {
|
||||||
|
"seconds": seconds
|
||||||
|
}, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateAcquire(callback) {
|
||||||
|
sendRequest("sysupdate.acquire", null, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sysupdateRelease(callback) {
|
||||||
|
sendRequest("sysupdate.release", null, callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Singleton {
|
|||||||
return terminalFlags[terminal] ?? ["-e"]
|
return terminalFlags[terminal] ?? ["-e"]
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly property string terminal: Quickshell.env("TERMINAL") || "ghostty"
|
readonly property string terminal: SessionData.resolveTerminal() || "ghostty"
|
||||||
|
|
||||||
function _terminalPrefix() {
|
function _terminalPrefix() {
|
||||||
return [terminal].concat(getTerminalFlag(terminal))
|
return [terminal].concat(getTerminalFlag(terminal))
|
||||||
|
|||||||
@@ -860,7 +860,7 @@ Singleton {
|
|||||||
function checkPluginCompatibility(requiresDms) {
|
function checkPluginCompatibility(requiresDms) {
|
||||||
if (!requiresDms)
|
if (!requiresDms)
|
||||||
return true;
|
return true;
|
||||||
return SystemUpdateService.checkVersionRequirement(requiresDms, SystemUpdateService.getParsedShellVersion());
|
return ShellVersionService.checkVersionRequirement(requiresDms, ShellVersionService.getParsedShellVersion());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIncompatiblePlugins() {
|
function getIncompatiblePlugins() {
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ Singleton {
|
|||||||
const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
|
const finalEnv = Object.assign({}, cursorEnv, overrideEnv);
|
||||||
|
|
||||||
if (desktopEntry.runInTerminal) {
|
if (desktopEntry.runInTerminal) {
|
||||||
const terminal = Quickshell.env("TERMINAL") || "xterm";
|
const terminal = SessionData.resolveTerminal() || "xterm";
|
||||||
const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
|
const escapedCmd = cmd.map(arg => escapeShellArg(arg)).join(" ");
|
||||||
const shellCmd = prefix.length > 0 ? `${prefix} ${escapedCmd}` : escapedCmd;
|
const shellCmd = prefix.length > 0 ? `${prefix} ${escapedCmd}` : escapedCmd;
|
||||||
Quickshell.execDetached({
|
Quickshell.execDetached({
|
||||||
|
|||||||
134
quickshell/Services/ShellVersionService.qml
Normal file
134
quickshell/Services/ShellVersionService.qml
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string shellVersion: ""
|
||||||
|
property string shellCodename: ""
|
||||||
|
property string semverVersion: ""
|
||||||
|
|
||||||
|
function getParsedShellVersion() {
|
||||||
|
return parseVersion(semverVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: versionDetection
|
||||||
|
running: true
|
||||||
|
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -d .git ]; then echo "(git) $(git rev-parse --short HEAD)"; elif [ -f VERSION ]; then cat VERSION; fi`]
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: shellVersion = text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: semverDetection
|
||||||
|
running: true
|
||||||
|
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f VERSION ]; then cat VERSION; fi`]
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: semverVersion = text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: codenameDetection
|
||||||
|
running: true
|
||||||
|
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f CODENAME ]; then cat CODENAME; fi`]
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: shellCodename = text.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(versionStr) {
|
||||||
|
if (!versionStr || typeof versionStr !== "string") {
|
||||||
|
return {
|
||||||
|
major: 0,
|
||||||
|
minor: 0,
|
||||||
|
patch: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let v = versionStr.trim();
|
||||||
|
if (v.startsWith("v")) {
|
||||||
|
v = v.substring(1);
|
||||||
|
}
|
||||||
|
const dashIdx = v.indexOf("-");
|
||||||
|
if (dashIdx !== -1) {
|
||||||
|
v = v.substring(0, dashIdx);
|
||||||
|
}
|
||||||
|
const plusIdx = v.indexOf("+");
|
||||||
|
if (plusIdx !== -1) {
|
||||||
|
v = v.substring(0, plusIdx);
|
||||||
|
}
|
||||||
|
const parts = v.split(".");
|
||||||
|
return {
|
||||||
|
major: parseInt(parts[0], 10) || 0,
|
||||||
|
minor: parseInt(parts[1], 10) || 0,
|
||||||
|
patch: parseInt(parts[2], 10) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(v1, v2) {
|
||||||
|
if (v1.major !== v2.major) {
|
||||||
|
return v1.major - v2.major;
|
||||||
|
}
|
||||||
|
if (v1.minor !== v2.minor) {
|
||||||
|
return v1.minor - v2.minor;
|
||||||
|
}
|
||||||
|
return v1.patch - v2.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkVersionRequirement(requirementStr, currentVersion) {
|
||||||
|
if (!requirementStr || typeof requirementStr !== "string") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const req = requirementStr.trim();
|
||||||
|
let operator = ">=";
|
||||||
|
let versionPart = req;
|
||||||
|
switch (true) {
|
||||||
|
case req.startsWith(">="):
|
||||||
|
operator = ">=";
|
||||||
|
versionPart = req.substring(2);
|
||||||
|
break;
|
||||||
|
case req.startsWith("<="):
|
||||||
|
operator = "<=";
|
||||||
|
versionPart = req.substring(2);
|
||||||
|
break;
|
||||||
|
case req.startsWith(">"):
|
||||||
|
operator = ">";
|
||||||
|
versionPart = req.substring(1);
|
||||||
|
break;
|
||||||
|
case req.startsWith("<"):
|
||||||
|
operator = "<";
|
||||||
|
versionPart = req.substring(1);
|
||||||
|
break;
|
||||||
|
case req.startsWith("="):
|
||||||
|
operator = "=";
|
||||||
|
versionPart = req.substring(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqVersion = parseVersion(versionPart);
|
||||||
|
const cmp = compareVersions(currentVersion, reqVersion);
|
||||||
|
switch (operator) {
|
||||||
|
case ">=":
|
||||||
|
return cmp >= 0;
|
||||||
|
case ">":
|
||||||
|
return cmp > 0;
|
||||||
|
case "<=":
|
||||||
|
return cmp <= 0;
|
||||||
|
case "<":
|
||||||
|
return cmp < 0;
|
||||||
|
case "=":
|
||||||
|
return cmp === 0;
|
||||||
|
default:
|
||||||
|
return cmp >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,288 +10,185 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property int refCount: 0
|
property int refCount: 0
|
||||||
|
|
||||||
|
property bool sysupdateAvailable: false
|
||||||
|
|
||||||
property var availableUpdates: []
|
property var availableUpdates: []
|
||||||
property bool isChecking: false
|
property bool isChecking: false
|
||||||
|
property bool isUpgrading: false
|
||||||
property bool hasError: false
|
property bool hasError: false
|
||||||
property string errorMessage: ""
|
property string errorMessage: ""
|
||||||
property string updChecker: ""
|
property string errorCode: ""
|
||||||
property string pkgManager: ""
|
property var backends: []
|
||||||
property string distribution: ""
|
property string distribution: ""
|
||||||
|
property string distributionPretty: ""
|
||||||
|
property string pkgManager: ""
|
||||||
property bool distributionSupported: false
|
property bool distributionSupported: false
|
||||||
property string shellVersion: ""
|
property var recentLog: []
|
||||||
property string shellCodename: ""
|
property int intervalSeconds: 1800
|
||||||
property string semverVersion: ""
|
property int lastCheckUnix: 0
|
||||||
|
property int nextCheckUnix: 0
|
||||||
|
|
||||||
function getParsedShellVersion() {
|
|
||||||
return parseVersion(semverVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var archBasedUCSettings: {
|
|
||||||
"listUpdatesSettings": {
|
|
||||||
"params": [],
|
|
||||||
"correctExitCodes": [0, 2] // Exit code 0 = updates available, 2 = no updates
|
|
||||||
},
|
|
||||||
"parserSettings": {
|
|
||||||
"lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
|
|
||||||
"entryProducer": function (match) {
|
|
||||||
return {
|
|
||||||
"name": match[1],
|
|
||||||
"currentVersion": match[2],
|
|
||||||
"newVersion": match[3],
|
|
||||||
"description": `${match[1]} ${match[2]} → ${match[3]}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var archBasedPMSettings: function(requiresSudo) {
|
|
||||||
return {
|
|
||||||
"listUpdatesSettings": {
|
|
||||||
"params": ["-Qu"],
|
|
||||||
"correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
|
|
||||||
},
|
|
||||||
"upgradeSettings": {
|
|
||||||
"params": ["-Syu"],
|
|
||||||
"requiresSudo": requiresSudo
|
|
||||||
},
|
|
||||||
"parserSettings": {
|
|
||||||
"lineRegex": /^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/,
|
|
||||||
"entryProducer": function (match) {
|
|
||||||
return {
|
|
||||||
"name": match[1],
|
|
||||||
"currentVersion": match[2],
|
|
||||||
"newVersion": match[3],
|
|
||||||
"description": `${match[1]} ${match[2]} → ${match[3]}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var fedoraBasedPMSettings: {
|
|
||||||
"listUpdatesSettings": {
|
|
||||||
"params": ["list", "--upgrades", "--quiet", "--color=never"],
|
|
||||||
"correctExitCodes": [0, 1] // Exit code 0 = updates available, 1 = no updates
|
|
||||||
},
|
|
||||||
"upgradeSettings": {
|
|
||||||
"params": ["upgrade"],
|
|
||||||
"requiresSudo": true
|
|
||||||
},
|
|
||||||
"parserSettings": {
|
|
||||||
"lineRegex": /^([^\s]+)\s+([^\s]+)\s+.*$/,
|
|
||||||
"entryProducer": function (match) {
|
|
||||||
return {
|
|
||||||
"name": match[1],
|
|
||||||
"currentVersion": "",
|
|
||||||
"newVersion": match[2],
|
|
||||||
"description": `${match[1]} → ${match[2]}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly property var updateCheckerParams: {
|
|
||||||
"checkupdates": archBasedUCSettings
|
|
||||||
}
|
|
||||||
readonly property var packageManagerParams: {
|
|
||||||
"yay": archBasedPMSettings(false),
|
|
||||||
"paru": archBasedPMSettings(false),
|
|
||||||
"pacman": archBasedPMSettings(true),
|
|
||||||
"dnf": fedoraBasedPMSettings
|
|
||||||
}
|
|
||||||
readonly property list<string> supportedDistributions: ["arch", "artix", "cachyos", "manjaro", "endeavouros", "fedora"]
|
|
||||||
readonly property int updateCount: availableUpdates.length
|
readonly property int updateCount: availableUpdates.length
|
||||||
readonly property bool helperAvailable: pkgManager !== "" && distributionSupported
|
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
|
||||||
|
|
||||||
Process {
|
Connections {
|
||||||
id: distributionDetection
|
target: DMSService
|
||||||
command: ["sh", "-c", "cat /etc/os-release | grep '^ID=' | cut -d'=' -f2 | tr -d '\"'"]
|
function onCapabilitiesReceived() {
|
||||||
running: true
|
root.checkCapabilities();
|
||||||
|
}
|
||||||
onExited: exitCode => {
|
function onConnectionStateChanged() {
|
||||||
if (exitCode === 0) {
|
if (DMSService.isConnected) {
|
||||||
distribution = stdout.text.trim().toLowerCase();
|
root.checkCapabilities();
|
||||||
distributionSupported = supportedDistributions.includes(distribution);
|
|
||||||
|
|
||||||
if (distributionSupported) {
|
|
||||||
updateFinderDetection.running = true;
|
|
||||||
pkgManagerDetection.running = true;
|
|
||||||
checkForUpdates();
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("SystemUpdate: Unsupported distribution:", distribution);
|
root.sysupdateAvailable = false;
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
console.warn("SystemUpdate: Failed to detect distribution");
|
function onSysupdateStateUpdate(data) {
|
||||||
|
root._applyState(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
versionDetection.running = true;
|
if (DMSService.dmsAvailable) {
|
||||||
|
checkCapabilities();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
function checkCapabilities() {
|
||||||
id: versionDetection
|
if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
|
||||||
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -d .git ]; then echo "(git) $(git rev-parse --short HEAD)"; elif [ -f VERSION ]; then cat VERSION; fi`]
|
sysupdateAvailable = false;
|
||||||
|
return;
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
shellVersion = text.trim();
|
|
||||||
}
|
}
|
||||||
|
const has = DMSService.capabilities.includes("sysupdate");
|
||||||
|
if (has && !sysupdateAvailable) {
|
||||||
|
sysupdateAvailable = true;
|
||||||
|
requestState();
|
||||||
|
} else if (!has) {
|
||||||
|
sysupdateAvailable = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
function requestState() {
|
||||||
id: semverDetection
|
if (!DMSService.isConnected || !sysupdateAvailable) {
|
||||||
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f VERSION ]; then cat VERSION; fi`]
|
return;
|
||||||
running: true
|
}
|
||||||
|
DMSService.sysupdateGetState(resp => {
|
||||||
stdout: StdioCollector {
|
if (resp && resp.result) {
|
||||||
onStreamFinished: {
|
_applyState(resp.result);
|
||||||
semverVersion = text.trim();
|
}
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: codenameDetection
|
|
||||||
command: ["sh", "-c", `cd "${Quickshell.shellDir}" && if [ -f CODENAME ]; then cat CODENAME; fi`]
|
|
||||||
running: true
|
|
||||||
|
|
||||||
stdout: StdioCollector {
|
|
||||||
onStreamFinished: {
|
|
||||||
shellCodename = text.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: updateFinderDetection
|
|
||||||
command: ["sh", "-c", "which checkupdates"]
|
|
||||||
|
|
||||||
onExited: exitCode => {
|
|
||||||
if (exitCode === 0) {
|
|
||||||
const exeFound = stdout.text.trim();
|
|
||||||
updChecker = exeFound.split('/').pop();
|
|
||||||
} else {
|
|
||||||
console.warn("SystemUpdate: No update checker found. Will use package manager.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
function _applyState(data) {
|
||||||
id: pkgManagerDetection
|
if (!data) {
|
||||||
command: ["sh", "-c", "which paru || which yay || which pacman || which dnf"]
|
return;
|
||||||
|
|
||||||
onExited: exitCode => {
|
|
||||||
if (exitCode === 0) {
|
|
||||||
const exeFound = stdout.text.trim();
|
|
||||||
pkgManager = exeFound.split('/').pop();
|
|
||||||
} else {
|
|
||||||
console.warn("SystemUpdate: No package manager found");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
availableUpdates = data.packages || [];
|
||||||
|
backends = data.backends || [];
|
||||||
|
distribution = data.distro || "";
|
||||||
|
distributionPretty = data.distroPretty || "";
|
||||||
|
distributionSupported = (backends.length > 0);
|
||||||
|
recentLog = data.recentLog || [];
|
||||||
|
intervalSeconds = data.intervalSeconds || 1800;
|
||||||
|
lastCheckUnix = data.lastCheckUnix || 0;
|
||||||
|
nextCheckUnix = data.nextCheckUnix || 0;
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
const phase = data.phase || "idle";
|
||||||
}
|
switch (phase) {
|
||||||
|
case "refreshing":
|
||||||
Process {
|
isChecking = true;
|
||||||
id: updateChecker
|
isUpgrading = false;
|
||||||
|
break;
|
||||||
onExited: exitCode => {
|
case "upgrading":
|
||||||
isChecking = false;
|
isChecking = false;
|
||||||
const correctExitCodes = updChecker.length > 0 ? [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.correctExitCodes) : [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.correctExitCodes);
|
isUpgrading = true;
|
||||||
if (correctExitCodes.includes(exitCode)) {
|
break;
|
||||||
parseUpdates(stdout.text);
|
default:
|
||||||
|
isChecking = false;
|
||||||
|
isUpgrading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
hasError = true;
|
||||||
|
errorMessage = data.error.message || "";
|
||||||
|
errorCode = data.error.code || "";
|
||||||
|
} else {
|
||||||
hasError = false;
|
hasError = false;
|
||||||
errorMessage = "";
|
errorMessage = "";
|
||||||
|
errorCode = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backends.length > 0) {
|
||||||
|
const sys = backends.find(b => b.repo === "system" || b.repo === "ostree");
|
||||||
|
pkgManager = sys ? sys.id : backends[0].id;
|
||||||
} else {
|
} else {
|
||||||
hasError = true;
|
pkgManager = "";
|
||||||
errorMessage = "Failed to check for updates";
|
|
||||||
console.warn("SystemUpdate: Update check failed with code:", exitCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: updater
|
|
||||||
onExited: exitCode => {
|
|
||||||
checkForUpdates();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkForUpdates() {
|
function checkForUpdates() {
|
||||||
if (!distributionSupported || (!pkgManager && !updChecker) || isChecking)
|
DMSService.sysupdateRefresh(false, null);
|
||||||
return;
|
|
||||||
isChecking = true;
|
|
||||||
hasError = false;
|
|
||||||
if (pkgManager === "paru" || pkgManager === "yay") {
|
|
||||||
const repoCmd = updChecker.length > 0 ? updChecker : `${pkgManager} -Qu`;
|
|
||||||
updateChecker.command = ["sh", "-c", `(${repoCmd} 2>/dev/null; ${pkgManager} -Qua 2>/dev/null) || true`];
|
|
||||||
} else if (updChecker.length > 0) {
|
|
||||||
updateChecker.command = [updChecker].concat(updateCheckerParams[updChecker].listUpdatesSettings.params);
|
|
||||||
} else {
|
|
||||||
updateChecker.command = [pkgManager].concat(packageManagerParams[pkgManager].listUpdatesSettings.params);
|
|
||||||
}
|
|
||||||
updateChecker.running = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseUpdates(output) {
|
function runUpdates(opts) {
|
||||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
const params = opts || {};
|
||||||
const updates = [];
|
|
||||||
|
|
||||||
const regex = packageManagerParams[pkgManager].parserSettings.lineRegex;
|
|
||||||
const entryProducer = packageManagerParams[pkgManager].parserSettings.entryProducer;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(regex);
|
|
||||||
if (match) {
|
|
||||||
updates.push(entryProducer(match));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
availableUpdates = updates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runUpdates() {
|
|
||||||
if (!distributionSupported || !pkgManager || updateCount === 0)
|
|
||||||
return;
|
|
||||||
const terminal = Quickshell.env("TERMINAL") || "xterm";
|
|
||||||
|
|
||||||
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
|
if (SettingsData.updaterUseCustomCommand && SettingsData.updaterCustomCommand.length > 0) {
|
||||||
|
_runCustomTerminalCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DMSService.sysupdateUpgrade(params, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelUpdates() {
|
||||||
|
DMSService.sysupdateCancel(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInterval(seconds) {
|
||||||
|
DMSService.sysupdateSetInterval(seconds, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _runCustomTerminalCommand() {
|
||||||
|
const terminal = SessionData.resolveTerminal();
|
||||||
|
if (!terminal || terminal.length === 0) {
|
||||||
|
ToastService.showError(I18n.tr("No terminal configured"), I18n.tr("Pick a terminal in Settings → Launcher (or set $TERMINAL)."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
|
const updateCommand = `${SettingsData.updaterCustomCommand} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
|
||||||
const termClass = SettingsData.updaterTerminalAdditionalParams;
|
const termClass = SettingsData.updaterTerminalAdditionalParams || "";
|
||||||
|
var argv = [terminal];
|
||||||
var finalCommand = [terminal];
|
|
||||||
if (termClass.length > 0) {
|
if (termClass.length > 0) {
|
||||||
finalCommand = finalCommand.concat(termClass.split(" "));
|
argv = argv.concat(termClass.split(" "));
|
||||||
}
|
}
|
||||||
finalCommand.push("-e");
|
argv.push("-e");
|
||||||
finalCommand.push("sh");
|
argv.push("sh");
|
||||||
finalCommand.push("-c");
|
argv.push("-c");
|
||||||
finalCommand.push(updateCommand);
|
argv.push(updateCommand);
|
||||||
updater.command = finalCommand;
|
customRunner.command = argv;
|
||||||
} else {
|
customRunner.running = true;
|
||||||
const params = packageManagerParams[pkgManager].upgradeSettings.params.join(" ");
|
|
||||||
const sudo = packageManagerParams[pkgManager].upgradeSettings.requiresSudo ? "sudo" : "";
|
|
||||||
const updateCommand = `${sudo} ${pkgManager} ${params} && echo -n "Updates complete! " ; echo "Press Enter to close..." && read`;
|
|
||||||
|
|
||||||
updater.command = [terminal, "-e", "sh", "-c", updateCommand];
|
|
||||||
}
|
|
||||||
updater.running = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Process {
|
||||||
interval: 30 * 60 * 1000
|
id: customRunner
|
||||||
repeat: true
|
onExited: root.checkForUpdates()
|
||||||
running: refCount > 0 && distributionSupported && (pkgManager || updChecker)
|
}
|
||||||
onTriggered: checkForUpdates()
|
|
||||||
|
onRefCountChanged: _syncAcquire()
|
||||||
|
onSysupdateAvailableChanged: _syncAcquire()
|
||||||
|
|
||||||
|
property bool _acquired: false
|
||||||
|
|
||||||
|
function _syncAcquire() {
|
||||||
|
const want = refCount > 0 && sysupdateAvailable;
|
||||||
|
if (want === _acquired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_acquired = want;
|
||||||
|
if (want) {
|
||||||
|
DMSService.sysupdateAcquire(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DMSService.sysupdateRelease(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
@@ -301,96 +198,11 @@ Singleton {
|
|||||||
if (root.isChecking) {
|
if (root.isChecking) {
|
||||||
return "ERROR: already checking";
|
return "ERROR: already checking";
|
||||||
}
|
}
|
||||||
if (!distributionSupported) {
|
if (root.backends.length === 0) {
|
||||||
return "ERROR: distribution not supported";
|
return "ERROR: no package manager available";
|
||||||
}
|
|
||||||
if (!pkgManager && !updChecker) {
|
|
||||||
return "ERROR: update checker not available";
|
|
||||||
}
|
}
|
||||||
root.checkForUpdates();
|
root.checkForUpdates();
|
||||||
return "SUCCESS: Now checking...";
|
return "SUCCESS: Now checking...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVersion(versionStr) {
|
|
||||||
if (!versionStr || typeof versionStr !== "string")
|
|
||||||
return {
|
|
||||||
major: 0,
|
|
||||||
minor: 0,
|
|
||||||
patch: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
let v = versionStr.trim();
|
|
||||||
if (v.startsWith("v"))
|
|
||||||
v = v.substring(1);
|
|
||||||
|
|
||||||
const dashIdx = v.indexOf("-");
|
|
||||||
if (dashIdx !== -1)
|
|
||||||
v = v.substring(0, dashIdx);
|
|
||||||
|
|
||||||
const plusIdx = v.indexOf("+");
|
|
||||||
if (plusIdx !== -1)
|
|
||||||
v = v.substring(0, plusIdx);
|
|
||||||
|
|
||||||
const parts = v.split(".");
|
|
||||||
return {
|
|
||||||
major: parseInt(parts[0], 10) || 0,
|
|
||||||
minor: parseInt(parts[1], 10) || 0,
|
|
||||||
patch: parseInt(parts[2], 10) || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareVersions(v1, v2) {
|
|
||||||
if (v1.major !== v2.major)
|
|
||||||
return v1.major - v2.major;
|
|
||||||
if (v1.minor !== v2.minor)
|
|
||||||
return v1.minor - v2.minor;
|
|
||||||
return v1.patch - v2.patch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkVersionRequirement(requirementStr, currentVersion) {
|
|
||||||
if (!requirementStr || typeof requirementStr !== "string")
|
|
||||||
return true;
|
|
||||||
|
|
||||||
const req = requirementStr.trim();
|
|
||||||
let operator = "";
|
|
||||||
let versionPart = req;
|
|
||||||
|
|
||||||
if (req.startsWith(">=")) {
|
|
||||||
operator = ">=";
|
|
||||||
versionPart = req.substring(2);
|
|
||||||
} else if (req.startsWith("<=")) {
|
|
||||||
operator = "<=";
|
|
||||||
versionPart = req.substring(2);
|
|
||||||
} else if (req.startsWith(">")) {
|
|
||||||
operator = ">";
|
|
||||||
versionPart = req.substring(1);
|
|
||||||
} else if (req.startsWith("<")) {
|
|
||||||
operator = "<";
|
|
||||||
versionPart = req.substring(1);
|
|
||||||
} else if (req.startsWith("=")) {
|
|
||||||
operator = "=";
|
|
||||||
versionPart = req.substring(1);
|
|
||||||
} else {
|
|
||||||
operator = ">=";
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqVersion = parseVersion(versionPart);
|
|
||||||
const cmp = compareVersions(currentVersion, reqVersion);
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case ">=":
|
|
||||||
return cmp >= 0;
|
|
||||||
case ">":
|
|
||||||
return cmp > 0;
|
|
||||||
case "<=":
|
|
||||||
return cmp <= 0;
|
|
||||||
case "<":
|
|
||||||
return cmp < 0;
|
|
||||||
case "=":
|
|
||||||
return cmp === 0;
|
|
||||||
default:
|
|
||||||
return cmp >= 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user