Commit 12fc4db8 authored by Roman Alifanov's avatar Roman Alifanov

Refactor project structure with store/service/ui architecture

- Replace lib/apm with backend/dbus and backend/eepm clients - Add Redux-like store with state management and actions - Add service layer (update, history, notification, scheduler) - Add UI components (category_row, package_row) and pages - Add model types (Package, EventData, ActiveUpdate, History) - Add pkg/storage for settings and history persistence
parent bbfcabd7
.vscode .vscodeSystemUpdater
\ No newline at end of file
package dbus
import (
"context"
"log"
"github.com/diamondburned/gotk4/pkg/gio/v2"
)
// EEPM D-Bus service constants
const (
ServiceName = "ru.etersoft.EPM"
ObjectPath = "/ru/etersoft/EPM"
QueryInterface = "ru.etersoft.EPM.Query"
ManageInterface = "ru.etersoft.EPM.Manage"
SignalInterface = "ru.etersoft.EPM" // signals are emitted on base interface
)
// Connection holds D-Bus connection and proxies for EEPM service
type Connection struct {
Conn *gio.DBusConnection
QueryProxy *gio.DBusProxy
ManageProxy *gio.DBusProxy
}
var epmConn *Connection
// GetConnection returns singleton connection to EEPM D-Bus service
func GetConnection() (*Connection, error) {
if epmConn != nil {
return epmConn, nil
}
c, err := gio.BusGetSync(context.Background(), gio.BusTypeSystem)
if err != nil {
return nil, err
}
queryProxy, err := gio.NewDBusProxyForBusSync(
context.Background(),
gio.BusTypeSystem,
gio.DBusProxyFlagsNone,
nil,
ServiceName,
ObjectPath,
QueryInterface,
)
if err != nil {
return nil, err
}
manageProxy, err := gio.NewDBusProxyForBusSync(
context.Background(),
gio.BusTypeSystem,
gio.DBusProxyFlagsNone,
nil,
ServiceName,
ObjectPath,
ManageInterface,
)
if err != nil {
return nil, err
}
epmConn = &Connection{
Conn: c,
QueryProxy: queryProxy,
ManageProxy: manageProxy,
}
log.Println("D-Bus connection to EEPM established")
return epmConn, nil
}
package eepm
import (
"SystemUpdater/backend/dbus"
"SystemUpdater/model"
"context"
"encoding/json"
"log"
"sync"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
// Client is the main EEPM D-Bus client
type Client struct {
conn *dbus.Connection
// Signal subscriptions
signalMu sync.RWMutex
subscriptions map[string]chan model.EventData
subID uint
// Lifecycle
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new EEPM client with signal listener
func NewClient() (*Client, error) {
conn, err := dbus.GetConnection()
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
client := &Client{
conn: conn,
subscriptions: make(map[string]chan model.EventData),
ctx: ctx,
cancel: cancel,
}
// Start global signal listener
go client.listenSignals()
log.Println("EEPM Client initialized")
return client, nil
}
// Close closes the client and stops signal listening
func (c *Client) Close() {
c.cancel()
if c.subID != 0 {
c.conn.Conn.SignalUnsubscribe(c.subID)
}
log.Println("EEPM Client closed")
}
// SubscribeProgress subscribes to progress events for a transaction
func (c *Client) SubscribeProgress(transaction string, ch chan model.EventData) {
c.signalMu.Lock()
defer c.signalMu.Unlock()
c.subscriptions[transaction] = ch
log.Printf("Subscribed to progress for transaction: %s", transaction)
}
// UnsubscribeProgress unsubscribes from progress events
func (c *Client) UnsubscribeProgress(transaction string) {
c.signalMu.Lock()
defer c.signalMu.Unlock()
delete(c.subscriptions, transaction)
log.Printf("Unsubscribed from progress for transaction: %s", transaction)
}
// listenSignals starts the global D-Bus signal listener
func (c *Client) listenSignals() {
c.subID = c.conn.Conn.SignalSubscribe(
dbus.ServiceName,
dbus.SignalInterface,
"Notification",
dbus.ObjectPath,
"",
gio.DBusSignalFlagsNone,
c.handleSignal,
)
log.Println("Started listening to D-Bus signals")
<-c.ctx.Done()
// Cleanup
if c.subID != 0 {
c.conn.Conn.SignalUnsubscribe(c.subID)
}
}
// handleSignal processes incoming D-Bus signals
func (c *Client) handleSignal(
conn *gio.DBusConnection,
senderName string,
objectPath string,
interfaceName string,
signalName string,
parameters *glib.Variant,
) {
// Parse JSON from first parameter
raw := parameters.ChildValue(0).String()
var event model.EventData
if err := json.Unmarshal([]byte(raw), &event); err != nil {
log.Printf("Failed to parse notification: %v", err)
return
}
// Find subscriber for this transaction
c.signalMu.RLock()
ch, exists := c.subscriptions[event.Transaction]
c.signalMu.RUnlock()
if !exists {
// No subscriber for this transaction
return
}
// Send to subscriber (non-blocking)
select {
case ch <- event:
// Successfully sent
case <-c.ctx.Done():
// Client closed
return
default:
// Channel full, skip this event
log.Printf("Progress channel full for transaction %s, skipping event", event.Transaction)
}
}
package eepm
import (
"SystemUpdater/model"
"context"
"encoding/json"
"fmt"
"log"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
// CheckKernelUpdates checks for available kernel updates
func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInfo, error) {
resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1)
c.conn.QueryProxy.Call(
ctx,
"CheckUpdateKernel",
nil,
gio.DBusCallFlagsNone,
300000, // 5 minutes timeout
func(res gio.AsyncResulter) {
reply, err := c.conn.QueryProxy.CallFinish(res)
if err != nil {
errorCh <- err
return
}
resultCh <- reply
},
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errorCh:
log.Printf("CheckKernelUpdates D-Bus error: %v", err)
return nil, err
case reply := <-resultCh:
var parsed KernelUpdateRootResponse
if err := json.Unmarshal([]byte(reply.ChildValue(0).String()), &parsed); err != nil {
log.Printf("Failed to parse CheckUpdateKernel response: %v", err)
return nil, err
}
return &parsed.Data.Info, nil
}
}
// RunKernelUpgrade executes kernel upgrade
func (c *Client) RunKernelUpgrade(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1)
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString(transaction),
})
c.conn.ManageProxy.Call(
ctx,
"UpdateKernel",
args,
gio.DBusCallFlagsNone,
2147483647, // Max int32 timeout
func(res gio.AsyncResulter) {
_, err := c.conn.ManageProxy.CallFinish(res)
if err != nil {
errorCh <- err
return
}
errorCh <- nil
},
)
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errorCh:
if err != nil {
log.Printf("RunKernelUpgrade error: %v", err)
return fmt.Errorf("kernel upgrade failed: %w", err)
}
return nil
}
}
package eepm
import (
"SystemUpdater/model"
"context"
"encoding/json"
"fmt"
"log"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
// CheckSystemUpdates checks for available system updates
func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges, error) {
resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1)
c.conn.QueryProxy.Call(
ctx,
"CheckUpgrade",
nil,
gio.DBusCallFlagsNone,
300000, // 5 minutes timeout
func(res gio.AsyncResulter) {
reply, err := c.conn.QueryProxy.CallFinish(res)
if err != nil {
errorCh <- err
return
}
resultCh <- reply
},
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-errorCh:
log.Printf("CheckSystemUpdates D-Bus error: %v", err)
return nil, err
case reply := <-resultCh:
var parsed CheckUpgradeRootResponse
if err := json.Unmarshal([]byte(reply.ChildValue(0).String()), &parsed); err != nil {
log.Printf("Failed to parse CheckUpgrade response: %v", err)
return nil, err
}
return &parsed.Data.Info, nil
}
}
// RunSystemUpgrade executes system upgrade
func (c *Client) RunSystemUpgrade(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1)
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString(transaction),
})
c.conn.ManageProxy.Call(
ctx,
"Upgrade",
args,
gio.DBusCallFlagsNone,
2147483647, // Max int32 timeout
func(res gio.AsyncResulter) {
_, err := c.conn.ManageProxy.CallFinish(res)
if err != nil {
errorCh <- err
return
}
errorCh <- nil
},
)
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errorCh:
if err != nil {
log.Printf("RunSystemUpgrade error: %v", err)
return fmt.Errorf("upgrade failed: %w", err)
}
return nil
}
}
...@@ -5,6 +5,7 @@ go 1.25 ...@@ -5,6 +5,7 @@ go 1.25
require ( require (
github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6 github.com/diamondburned/gotk4-adwaita/pkg v0.0.0-20250703085337-e94555b846b6
github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a github.com/diamondburned/gotk4/pkg v0.3.2-0.20250703063411-16654385f59a
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
......
...@@ -8,3 +8,7 @@ go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN ...@@ -8,3 +8,7 @@ go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN
go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
package apm
import (
"SystemUpdater/lib/apm/sudbus"
"context"
"encoding/json"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
func GetPackageInfo(name string) InfoResponse {
conn := sudbus.GetSystemConnection()
reply, err := conn.Proxy.CallSync(
context.Background(),
"Info",
glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString(name),
glib.NewVariantString("Ximper System Updater"),
}),
gio.DBusCallFlagsNone,
-1,
)
if err != nil {
panic("Error during async method call: " + err.Error())
}
responseStr := reply.ChildValue(0).String()
var response InfoRootResponse
if err := json.Unmarshal([]byte(responseStr), &response); err != nil {
panic("Failed to parse response: " + err.Error())
}
return response.Data
}
package apm
import (
"encoding/xml"
"time"
)
type LocalizedText struct {
Lang string `xml:"lang,attr,omitempty" json:"lang,omitempty"`
Value string `xml:",innerxml" json:"value"`
}
type URL struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type Keyword struct {
Value string `xml:",chardata" json:"value"`
}
type ScreenshotImage struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
Height int `xml:"height,attr,omitempty" json:"height,omitempty"`
URL string `xml:",chardata" json:"url"`
}
type Screenshot struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Caption []LocalizedText `xml:"caption" json:"caption,omitempty"`
Images []ScreenshotImage `xml:"image" json:"images,omitempty"`
}
type Release struct {
Timestamp int64 `xml:"timestamp,attr" json:"timestamp"`
Version string `xml:"version,attr" json:"version"`
}
type Launchable struct {
Type string `xml:"type,attr" json:"type,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type ContentRating struct {
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
Content string `xml:",innerxml" json:"content"`
}
type Icon struct {
Type string `xml:"type,attr" json:"type"`
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
Height int `xml:"height,attr,omitempty" json:"height,omitempty"`
Value string `xml:",chardata" json:"value"`
}
type Component struct {
XMLName xml.Name `xml:"component" json:"-"`
Type string `xml:"type,attr" json:"type"`
ID string `xml:"id" json:"id,omitempty"`
MetadataLicense string `xml:"metadata_license" json:"metadata_license,omitempty"`
ProjectLicense string `xml:"project_license,omitempty" json:"project_license,omitempty"`
Name []LocalizedText `xml:"name" json:"name,omitempty"`
Summary []LocalizedText `xml:"summary" json:"summary,omitempty"`
Description []LocalizedText `xml:"description" json:"description,omitempty"`
Keywords []Keyword `xml:"keywords>keyword" json:"keywords,omitempty"`
Categories []string `xml:"categories>category" json:"categories,omitempty"`
Urls []URL `xml:"url" json:"urls,omitempty"`
Screenshots []Screenshot `xml:"screenshots>screenshot" json:"screenshots,omitempty"`
Releases []Release `xml:"releases>release" json:"releases,omitempty"`
Icons []Icon `xml:"icon" json:"icons,omitempty"`
Launchable *Launchable `xml:"launchable,omitempty" json:"launchable,omitempty"`
ContentRating *ContentRating `xml:"content_rating,omitempty" json:"content_rating,omitempty"`
PkgName string `xml:"pkgname" json:"pkgname"`
}
type Package struct {
Name string `json:"name"`
Architecture string `json:"architecture"`
Section string `json:"section"`
InstalledSize int `json:"installedSize"`
Maintainer string `json:"maintainer"`
Version string `json:"version"`
VersionRaw string `json:"versionRaw"`
VersionInstalled string `json:"versionInstalled"`
Depends []string `json:"depends"`
Aliases []string `json:"aliases"`
Provides []string `json:"provides"`
Size int `json:"size"`
Filename string `json:"filename"`
Description string `json:"description"`
AppStream *Component `json:"appStream"`
Changelog string `json:"lastChangelog"`
Installed bool `json:"installed"`
TypePackage int `json:"typePackage"`
}
type EventData struct {
Name string `json:"name"`
View string `json:"message"`
State string `json:"state"`
Type string `json:"type"`
ProgressPercent float64 `json:"progress"`
ProgressDone string `json:"progressDone"`
Transaction string `json:"transaction,omitempty"`
}
type PackageChanges struct {
ExtraInstalled []string `json:"extraInstalled"`
UpgradedPackages []string `json:"upgradedPackages"`
NewInstalledPackages []string `json:"newInstalledPackages"`
RemovedPackages []string `json:"removedPackages"`
UpgradedCount int `json:"upgradedCount"`
NewInstalledCount int `json:"newInstalledCount"`
RemovedCount int `json:"removedCount"`
NotUpgradedCount int `json:"-"`
DownloadSize uint64 `json:"downloadSize"`
InstallSize uint64 `json:"installSize"`
}
type InfoResponse struct {
Message string `json:"message"`
PackageInfo Package `json:"packageInfo"`
}
// KERNEL
type KernelInfo struct {
PackageName string `json:"packageName"`
Flavour string `json:"flavour"`
Version string `json:"version"`
VersionInstalled string `json:"versionInstalled"`
Release string `json:"release"`
BuildTime time.Time `json:"buildTime"`
IsInstalled bool `json:"isInstalled"`
IsRunning bool `json:"isRunning"`
FullVersion string `json:"fullVersion"`
AgeInDays int `json:"ageInDays"`
}
type FullKernelInfo struct {
PackageName string `json:"packageName"`
Flavour string `json:"flavour"`
Version string `json:"version"`
VersionInstalled string `json:"versionInstalled"`
Release string `json:"release"`
FullVersion string `json:"fullVersion"`
IsInstalled bool `json:"isInstalled"`
IsRunning bool `json:"isRunning"`
AgeInDays int `json:"ageInDays"`
BuildTime string `json:"buildTime"`
InstalledModules []InstalledKernelModuleInfo `json:"installedModules,omitempty"`
}
type ShortKernelInfo struct {
Version string `json:"version"`
VersionInstalled string `json:"versionInstalled"`
Flavour string `json:"flavour"`
FullVersion string `json:"fullVersion"`
IsInstalled bool `json:"isInstalled"`
IsRunning bool `json:"isRunning"`
}
type InstalledKernelModuleInfo struct {
Name string `json:"name"`
PackageName string `json:"packageName"`
}
type KernelModuleInfo struct {
Name string `json:"name"`
IsInstalled bool `json:"isInstalled"`
PackageName string `json:"packageName"`
}
type KernelUpgradePreview struct {
Changes *PackageChanges `json:"changes"`
SelectedModules []string `json:"selectedModules"`
MissingModules []string `json:"missingModules"`
}
type ListKernelsResponse struct {
Message string `json:"message"`
Kernels []FullKernelInfo `json:"kernels"`
}
type GetCurrentKernelResponse struct {
Message string `json:"message"`
Kernel FullKernelInfo `json:"kernel"`
}
type InstallUpdateKernelResponse struct {
Message string `json:"message"`
Kernel FullKernelInfo `json:"kernel"`
Preview *KernelUpgradePreview `json:"preview,omitempty"`
}
type KernelWithReasons struct {
Kernel KernelInfo `json:"kernel"`
Reasons []string `json:"reasons"`
}
type CleanOldKernelsResponse struct {
Message string `json:"message"`
RemoveKernels []KernelInfo `json:"removeKernels"`
KeptKernels []KernelWithReasons `json:"keptKernels"`
Preview *PackageChanges `json:"preview,omitempty"`
}
type ListKernelModulesResponse struct {
Message string `json:"message"`
Kernel FullKernelInfo `json:"kernel"`
Modules KernelModuleInfo `json:"modules"`
}
type InstallKernelModulesResponse struct {
Message string `json:"message"`
Kernel FullKernelInfo `json:"kernel"`
Preview PackageChanges `json:"preview,omitempty"`
}
type RemoveKernelModulesResponse struct {
Message string `json:"message"`
Kernel FullKernelInfo `json:"kernel"`
Preview *PackageChanges `json:"preview,omitempty"`
}
// misc
type InfoRootResponse struct {
Data InfoResponse `json:"data"`
Error bool `json:"error"`
}
type UpdateKernelRootResponse struct {
Data struct {
Kernel FullKernelInfo `json:"kernel"`
Preview KernelUpgradePreview `json:"preview"`
Message string `json:"message"`
} `json:"data"`
Error bool `json:"error"`
}
type CheckUpgradeRootResponse struct {
Data struct {
Info PackageChanges `json:"info"`
Message string `json:"message"`
} `json:"data"`
Error bool `json:"error"`
}
package sudbus
import (
"context"
"log"
"github.com/diamondburned/gotk4/pkg/gio/v2"
)
type Connection struct {
Conn *gio.DBusConnection
Proxy *gio.DBusProxy
}
var systemConn *Connection
func GetSystemConnection() *Connection {
if systemConn == nil {
c, err := gio.BusGetSync(context.Background(), gio.BusTypeSystem)
if err != nil {
log.Fatal("DBus connect:", err)
}
proxy, err := gio.NewDBusProxyForBusSync(
context.Background(),
gio.BusTypeSystem,
gio.DBusProxyFlagsNone,
nil,
"org.altlinux.APM",
"/org/altlinux/APM",
"org.altlinux.APM.system",
)
if err != nil {
log.Fatal("DBus proxy:", err)
}
systemConn = &Connection{
Conn: c,
Proxy: proxy,
}
}
return systemConn
}
var kernelConn *Connection
func GetKernelConnection() *Connection {
if kernelConn == nil {
c, err := gio.BusGetSync(context.Background(), gio.BusTypeSystem)
if err != nil {
log.Fatal("DBus connect:", err)
}
proxy, err := gio.NewDBusProxyForBusSync(
context.Background(),
gio.BusTypeSystem,
gio.DBusProxyFlagsNone,
nil,
"org.altlinux.APM",
"/org/altlinux/APM",
"org.altlinux.APM.kernel",
)
if err != nil {
log.Fatal("DBus proxy:", err)
}
kernelConn = &Connection{
Conn: c,
Proxy: proxy,
}
}
return kernelConn
}
package apm
import (
"SystemUpdater/lib/apm/sudbus"
"context"
"encoding/json"
"fmt"
"log"
"sync"
"github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
type UpdaterSource interface {
GetPackageChanges() PackageChanges
RunUpgrade(
func(),
func(EventData),
) error
}
type UpdatesSources map[string]UpdaterSource
func NewUpdatesSources() UpdatesSources {
systemConn := sudbus.GetSystemConnection()
kernelConn := sudbus.GetKernelConnection()
return UpdatesSources{
"System": &SystemUpdatesSource{Proxy: systemConn.Proxy, Conn: systemConn.Conn},
"Kernel": &KernelUpdatesSource{Proxy: kernelConn.Proxy, Conn: kernelConn.Conn},
}
}
type SystemUpdatesSource struct {
Proxy *gio.DBusProxy
Conn *gio.DBusConnection
}
func (s *SystemUpdatesSource) RunUpgrade(
onDone func(),
onLog func(EventData),
) error {
stopSignals := make(chan struct{})
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
subID := s.Conn.SignalSubscribe(
"org.altlinux.APM",
"org.altlinux.APM",
"Notification",
"/org/altlinux/APM",
"",
gio.DBusSignalFlagsNone,
func(conn *gio.DBusConnection,
senderName string,
objectPath string,
interfaceName string,
signalName string,
parameters *glib.Variant) {
select {
case <-stopSignals:
return
default:
}
raw := parameters.ChildValue(0).String()
var data EventData
if err := json.Unmarshal([]byte(raw), &data); err == nil {
glib.IdleAdd(func() { onLog(data) })
} else {
fmt.Println("JSON decode error:", err)
}
},
)
<-stopSignals
s.Conn.SignalUnsubscribe(subID)
}()
go func() {
defer wg.Done()
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString("Ximper System Updater"),
})
_, err := s.Proxy.CallSync(
context.Background(),
"Upgrade",
args,
gio.DBusCallFlagsNone,
glib.MAXINT64,
)
if err != nil {
log.Println("Upgrade error:", err)
}
close(stopSignals)
glib.IdleAdd(onDone)
}()
wg.Wait()
return nil
}
func (s *SystemUpdatesSource) GetPackageChanges() PackageChanges {
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString("Ximper System Updater"),
})
reply, err := s.Proxy.CallSync(
context.Background(),
"CheckUpgrade",
args,
gio.DBusCallFlagsNone,
300000,
)
if err != nil {
panic("DBus CheckUpgrade error: " + err.Error())
}
var parsed CheckUpgradeRootResponse
if err := json.Unmarshal([]byte(reply.ChildValue(0).String()), &parsed); err != nil {
panic("Failed to parse CheckUpgrade: " + err.Error())
}
return parsed.Data.Info
}
type KernelUpdatesSource struct {
Proxy *gio.DBusProxy
Conn *gio.DBusConnection
}
func (s *KernelUpdatesSource) RunUpgrade(
onDone func(),
onLog func(EventData),
) error {
stopSignals := make(chan struct{})
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
subID := s.Conn.SignalSubscribe(
"org.altlinux.APM",
"org.altlinux.APM",
"Notification",
"/org/altlinux/APM",
"",
gio.DBusSignalFlagsNone,
func(conn *gio.DBusConnection,
senderName string,
objectPath string,
interfaceName string,
signalName string,
parameters *glib.Variant) {
select {
case <-stopSignals:
return
default:
}
raw := parameters.ChildValue(0).String()
var data EventData
if err := json.Unmarshal([]byte(raw), &data); err == nil {
glib.IdleAdd(func() { onLog(data) })
} else {
fmt.Println("JSON decode error:", err)
}
},
)
<-stopSignals
s.Conn.SignalUnsubscribe(subID)
}()
go func() {
defer wg.Done()
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString(""),
glib.NewVariantArray(glib.NewVariantType("s"), []*glib.Variant{}),
glib.NewVariantBoolean(false),
glib.NewVariantBoolean(false),
glib.NewVariantString("Ximper System Updater"),
})
_, err := s.Proxy.CallSync(
context.Background(),
"UpdateKernel",
args,
gio.DBusCallFlagsNone,
glib.MAXINT64,
)
if err != nil {
log.Println("Upgrade error:", err)
}
close(stopSignals)
glib.IdleAdd(onDone)
}()
wg.Wait()
return nil
}
func (s *KernelUpdatesSource) GetPackageChanges() PackageChanges {
args := glib.NewVariantTuple([]*glib.Variant{
glib.NewVariantString(""),
glib.NewVariantArray(glib.NewVariantType("s"), []*glib.Variant{}),
glib.NewVariantBoolean(false),
glib.NewVariantBoolean(true),
glib.NewVariantString("Ximper System Updater"),
})
reply, err := s.Proxy.CallSync(
context.Background(),
"UpdateKernel",
args,
gio.DBusCallFlagsNone,
300000,
)
if err != nil {
panic("DBus CheckUpgrade error: " + err.Error())
}
fmt.Println(reply.ChildValue(0).String())
var parsed UpdateKernelRootResponse
if err := json.Unmarshal([]byte(reply.ChildValue(0).String()), &parsed); err != nil {
panic("Failed to parse CheckUpgrade: " + err.Error())
}
if parsed.Data.Preview.Changes == nil {
return PackageChanges{}
}
return *parsed.Data.Preview.Changes
}
...@@ -16,6 +16,12 @@ func New(uiXML string) *gtk.Builder { ...@@ -16,6 +16,12 @@ func New(uiXML string) *gtk.Builder {
return builder return builder
} }
func NewBuilder(path string) *gtk.Builder {
builder := gtk.NewBuilderFromFile(path)
builder.SetTranslationDomain(translationDomain)
return builder
}
func GetObject[T any](builder *gtk.Builder, name string) T { func GetObject[T any](builder *gtk.Builder, name string) T {
return builder.GetObject(name).Cast().(T) return builder.GetObject(name).Cast().(T)
} }
......
package main package main
import ( import (
"SystemUpdater/lib/apm" "SystemUpdater/backend/eepm"
bldr "SystemUpdater/lib/gtks/builder" "SystemUpdater/pkg/storage"
"fmt" "SystemUpdater/service"
"SystemUpdater/store"
"SystemUpdater/ui"
"context"
"log"
"os" "os"
"unsafe"
_ "embed"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gio/v2"
"github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
type SystemUpdater struct { func main() {
XDGName string // Disable debug logging for GTK
App *adw.Application glib.LogSetDebugEnabled(false)
AppGTK *gtk.Application
}
var mainApp *SystemUpdater
func GetSystemUpdater() *SystemUpdater { st := store.NewStore()
if mainApp == nil { defer st.Close()
xdgName := "ru.ximperlinux.SystemUpdater"
appAdw := adw.NewApplication(xdgName, gio.ApplicationFlags(8))
appGtk := (*gtk.Application)(unsafe.Pointer(appAdw))
mainApp = &SystemUpdater{ historyFile, err := storage.NewHistoryFile()
XDGName: xdgName, if err != nil {
App: appAdw, log.Fatalf("Failed to create history file: %v", err)
AppGTK: appGtk,
}
} }
return mainApp
}
var updates map[string]apm.PackageChanges
var updatesLoaded bool
func (su *SystemUpdater) backgroundStart() { settingsFile, err := storage.NewSettingsFile()
updatesSources := apm.NewUpdatesSources() if err != nil {
us := make(map[string]apm.PackageChanges) log.Fatalf("Failed to create settings file: %v", err)
for name, source := range updatesSources {
changes := source.GetPackageChanges()
if len(changes.UpgradedPackages) > 0 ||
len(changes.NewInstalledPackages) > 0 ||
len(changes.RemovedPackages) > 0 {
us[name] = changes
}
} }
updates = us settings, err := settingsFile.Load()
updatesLoaded = true if err != nil {
} log.Printf("Failed to load settings: %v", err)
} else {
func (su *SystemUpdater) onActivate() { st.Dispatch(&store.UpdateSettingsAction{Settings: settings})
su.App.Release()
window := GetSystemUpdaterWindow()
window.SetApplication(su.AppGTK)
window.stack.SetVisibleChildName("loading")
window.Present()
glib.TimeoutAdd(100, func() bool {
if !updatesLoaded {
return true
}
if len(updates) > 0 {
window.FillWithChanges(su, updates)
window.stack.SetVisibleChildName("main")
window.updateButton.ConnectActivated(window.StartUpdateProcess)
} else {
window.stack.SetVisibleChildName("noupdates")
}
return false
})
}
//go:embed window.ui
var SystemUpdaterWindowUIXML string
type SystemUpdaterWindow struct {
*adw.ApplicationWindow
navView *adw.NavigationView
listbox *gtk.ListBox
stack *gtk.Stack
updateButton *adw.ButtonRow
}
var mainWin *SystemUpdaterWindow
func GetSystemUpdaterWindow() *SystemUpdaterWindow {
if mainWin == nil {
builder := bldr.New(SystemUpdaterWindowUIXML)
win := bldr.GetObject[*adw.ApplicationWindow](builder, "main_window")
navView := bldr.GetObject[*adw.NavigationView](builder, "navigationv")
listbox := bldr.GetObject[*gtk.ListBox](builder, "updates_listbox")
stack := bldr.GetObject[*gtk.Stack](builder, "main_stack")
updateButton := bldr.GetObject[*adw.ButtonRow](builder, "apply_button")
mainWin = &SystemUpdaterWindow{
win,
navView,
listbox,
stack,
updateButton,
}
} }
return mainWin
}
func (sw *SystemUpdaterWindow) FillWithChanges(su *SystemUpdater, u map[string]apm.PackageChanges) { eepmClient, err := eepm.NewClient()
for name, updatesList := range u { if err != nil {
sw.listbox.Append(NewUpdateRow(name, updatesList, sw.StartUpdateProcess)) log.Fatalf("Failed to create EEPM client: %v", err)
} }
} defer eepmClient.Close()
func (sw *SystemUpdaterWindow) StartUpdateProcess() {
RunUpgrade(apm.NewUpdatesSources())
}
func SystemUpdaterApplication(su *SystemUpdater) {
su.App.ConnectActivate(func() {
su.onActivate()
})
su.App.ConnectCommandLine(func(cmd *gio.ApplicationCommandLine) int { notificationSvc := service.NewNotificationService()
args := cmd.Arguments() historySvc := service.NewHistoryService(st, historyFile)
fmt.Println("Got args:", args) updateSvc := service.NewUpdateService(st, eepmClient, historySvc)
schedulerSvc := service.NewSchedulerService(st, updateSvc, notificationSvc)
if cmd.IsRemote() { if err := historySvc.LoadHistory(); err != nil {
fmt.Println("Remote command received!") log.Printf("Failed to load history: %v", err)
}
su.App.Activate() app := adw.NewApplication("ru.ximperlinux.SystemUpdater", gio.ApplicationFlagsNone)
cmd.Done() app.ConnectActivate(func() {
return 0 window := ui.NewWindow(app, st, updateSvc, historySvc)
} window.Window.Present()
backgroundMode := false go func() {
if err := updateSvc.CheckAllUpdates(context.Background()); err != nil {
if len(args) > 1 { log.Printf("Initial update check failed: %v", err)
for _, arg := range args[1:] {
switch arg {
case "--background", "-b":
backgroundMode = true
default:
fmt.Fprintf(os.Stderr, "Error: unknown argument %q\n", arg)
cmd.Done()
os.Exit(1)
}
} }
} }()
})
if backgroundMode {
fmt.Println("Running in background mode...")
su.App.Hold() app.ConnectCommandLine(func(cmdLine *gio.ApplicationCommandLine) int {
go su.backgroundStart() args := cmdLine.Arguments()
cmd.Done() for _, arg := range args {
return 0 if arg == "--background" {
log.Println("Running in background mode")
app.Hold()
schedulerSvc.Start()
return 0
}
} }
go su.backgroundStart() app.Activate()
su.App.Activate()
cmd.Done()
return 0 return 0
}) })
os.Exit(su.App.Run(os.Args)) defer func() {
} schedulerSvc.Stop()
log.Println("Application shutdown complete")
}()
func main() { os.Exit(app.Run(os.Args))
glib.LogSetDebugEnabled(false)
SystemUpdaterApplication(GetSystemUpdater())
} }
package model
// HistoryEntry represents a single update history record
type HistoryEntry struct {
ID int `yaml:"id"`
Timestamp string `yaml:"timestamp"` // RFC3339
Categories []string `yaml:"categories"`
Success bool `yaml:"success"`
DurationSeconds int `yaml:"duration_seconds"`
PackagesUpdated int `yaml:"packages_updated"`
Log []string `yaml:"log"`
ErrorMessage string `yaml:"error_message,omitempty"`
}
package model
// Package represents a package in EEPM responses
type Package struct {
Name string `json:"name"`
Version string `json:"version"`
Release string `json:"release"`
Arch string `json:"arch"`
Summary *string `json:"summary"`
Description *string `json:"description"`
Size *uint64 `json:"size"`
InstalledSize *uint64 `json:"installed_size"`
URL *string `json:"url"`
License *string `json:"license"`
Group *string `json:"group"`
Repo *string `json:"repo"`
IsInstalled bool `json:"is_installed"`
HasUpdate bool `json:"has_update"`
InstalledVersion *string `json:"installed_version"`
}
// PackageChanges represents changes from check/simulate operations
type PackageChanges struct {
Install []Package `json:"install"`
Remove []Package `json:"remove"`
Upgrade []Package `json:"upgrade"`
Downgrade []Package `json:"downgrade"`
InstalledCount uint32 `json:"installed_count"`
RemovedCount uint32 `json:"removed_count"`
UpgradedCount uint32 `json:"upgraded_count"`
DownloadSize uint64 `json:"download_size"`
InstallSize int64 `json:"install_size"`
}
// HasChanges returns true if there are any package changes
func (pc *PackageChanges) HasChanges() bool {
return len(pc.Upgrade) > 0 || len(pc.Install) > 0 || len(pc.Remove) > 0 || len(pc.Downgrade) > 0
}
package model
// EventData represents progress event from D-Bus signal
type EventData struct {
Name string `json:"name"`
Message string `json:"message"`
State string `json:"state"`
EventType string `json:"type"`
Progress float64 `json:"progress"`
ProgressDone string `json:"progressDone"`
Transaction string `json:"transaction"`
}
package main
import (
"SystemUpdater/lib/apm"
bldr "SystemUpdater/lib/gtks/builder"
"fmt"
"strings"
"sync"
_ "embed"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
func NewPackageRow(name string, info apm.Package) *adw.ActionRow {
row := adw.NewActionRow()
if info.AppStream != nil && len(info.AppStream.Icons) > 0 {
ico := info.AppStream.Icons[len(info.AppStream.Icons)-1]
icoPath := fmt.Sprintf(
"/usr/share/swcatalog/icons/altlinux/%dx%d/%s",
ico.Height, ico.Width, ico.Value,
)
icoWidget := gtk.NewImageFromFile(icoPath)
icoWidget.SetIconSize(gtk.IconSizeLarge)
row.AddPrefix(icoWidget)
}
row.SetTitle(name)
row.SetSubtitle(info.VersionInstalled + " -> " + strings.Split(info.VersionRaw, ":")[0])
return row
}
//go:embed listpage.ui
var SystemUpdaterListPageUIXML string
type SystemUpdaterListPage struct {
nav *adw.NavigationPage
listbox *gtk.ListBox
applyButton *adw.ButtonRow
}
func NewListPage(runUpgrade func()) *SystemUpdaterListPage {
builder := bldr.New(SystemUpdaterListPageUIXML)
page := bldr.GetObject[*adw.NavigationPage](builder, "listpage")
listbox := bldr.GetObject[*gtk.ListBox](builder, "updates_listbox")
applyButton := bldr.GetObject[*adw.ButtonRow](builder, "apply_button")
applyButton.ConnectActivated(runUpgrade)
return &SystemUpdaterListPage{page, listbox, applyButton}
}
func NewUpdateRow(name string, info apm.PackageChanges, runUpgradeCallback func()) *adw.ActionRow {
win := GetSystemUpdaterWindow()
urow := adw.NewActionRow()
urow.SetTitle(name)
urow.SetActivatable(true)
upage := NewListPage(runUpgradeCallback)
urow.ConnectActivated(func() {
win.navView.Push(upage.nav)
})
for title, pkgs := range map[string][]string{
"Upgraded Packages": info.UpgradedPackages,
"New Installed Packages": info.NewInstalledPackages,
"Removed Packages": info.RemovedPackages,
} {
if len(pkgs) == 0 {
continue
}
pkgsWithInfo := make(map[string]apm.InfoResponse)
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, 15)
for _, p := range pkgs {
wg.Add(1)
pkg := p
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
trimmedPkg := pkg
if strings.HasPrefix(pkg, "kernel-modules-") || strings.HasPrefix(pkg, "kernel-image-") {
if idx := strings.Index(pkg, "#"); idx != -1 {
trimmedPkg = pkg[:idx]
}
}
info := apm.GetPackageInfo(trimmedPkg)
mu.Lock()
pkgsWithInfo[pkg] = info
mu.Unlock()
}()
}
go func(title string) {
wg.Wait()
glib.IdleAdd(func() {
row := adw.NewActionRow()
row.SetTitle(title)
row.SetActivatable(true)
page := NewListPage(runUpgradeCallback)
row.ConnectActivated(func() {
win.navView.Push(page.nav)
})
for p, info := range pkgsWithInfo {
ar := NewPackageRow(p, info.PackageInfo)
page.listbox.Append(ar)
}
upage.listbox.Append(row)
})
}(title)
}
return urow
}
package storage
import (
"SystemUpdater/model"
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// HistoryFile manages history storage in YAML format
type HistoryFile struct {
path string
mu sync.RWMutex
}
// HistoryData represents the YAML file structure
type HistoryData struct {
Entries []model.HistoryEntry `yaml:"entries"`
}
// NewHistoryFile creates a new history file manager
func NewHistoryFile() (*HistoryFile, error) {
homeDir := os.Getenv("HOME")
if homeDir == "" {
homeDir = "/tmp"
}
path := filepath.Join(homeDir, ".local/share/ximper-system-updater/history.yaml")
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return &HistoryFile{path: path}, nil
}
// Insert adds a new history entry
func (hf *HistoryFile) Insert(entry model.HistoryEntry) error {
hf.mu.Lock()
defer hf.mu.Unlock()
data := hf.readAllUnsafe()
// Assign ID
entry.ID = len(data.Entries) + 1
data.Entries = append(data.Entries, entry)
// Keep only last 100 entries
if len(data.Entries) > 100 {
data.Entries = data.Entries[len(data.Entries)-100:]
// Reassign IDs
for i := range data.Entries {
data.Entries[i].ID = i + 1
}
}
return hf.writeAllUnsafe(data)
}
// GetAll returns all history entries
func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) {
hf.mu.RLock()
defer hf.mu.RUnlock()
data := hf.readAllUnsafe()
return data.Entries, nil
}
// GetRecent returns N most recent entries
func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) {
hf.mu.RLock()
defer hf.mu.RUnlock()
data := hf.readAllUnsafe()
if len(data.Entries) <= limit {
return data.Entries, nil
}
return data.Entries[len(data.Entries)-limit:], nil
}
// readAllUnsafe reads all data without locking (internal use)
func (hf *HistoryFile) readAllUnsafe() HistoryData {
file, err := os.ReadFile(hf.path)
if err != nil {
// File doesn't exist yet
return HistoryData{Entries: []model.HistoryEntry{}}
}
var data HistoryData
if err := yaml.Unmarshal(file, &data); err != nil {
// Corrupted file, return empty
return HistoryData{Entries: []model.HistoryEntry{}}
}
return data
}
// writeAllUnsafe writes all data without locking (internal use)
func (hf *HistoryFile) writeAllUnsafe(data HistoryData) error {
bytes, err := yaml.Marshal(data)
if err != nil {
return err
}
return os.WriteFile(hf.path, bytes, 0644)
}
package storage
import (
"SystemUpdater/store"
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// SettingsFile manages application settings
type SettingsFile struct {
path string
mu sync.RWMutex
}
// NewSettingsFile creates a new settings file manager
func NewSettingsFile() (*SettingsFile, error) {
homeDir := os.Getenv("HOME")
if homeDir == "" {
homeDir = "/tmp"
}
path := filepath.Join(homeDir, ".config/ximper-system-updater/settings.yaml")
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return &SettingsFile{path: path}, nil
}
// Load loads settings from file
func (sf *SettingsFile) Load() (store.AppSettings, error) {
sf.mu.RLock()
defer sf.mu.RUnlock()
file, err := os.ReadFile(sf.path)
if err != nil {
// File doesn't exist, return defaults
return store.AppSettings{
AutoCheckEnabled: false,
CheckInterval: 24,
}, nil
}
var settings store.AppSettings
if err := yaml.Unmarshal(file, &settings); err != nil {
// Corrupted file, return defaults
return store.AppSettings{
AutoCheckEnabled: false,
CheckInterval: 24,
}, nil
}
return settings, nil
}
// Save saves settings to file
func (sf *SettingsFile) Save(settings store.AppSettings) error {
sf.mu.Lock()
defer sf.mu.Unlock()
bytes, err := yaml.Marshal(settings)
if err != nil {
return err
}
return os.WriteFile(sf.path, bytes, 0644)
}
package service
import (
"SystemUpdater/model"
"SystemUpdater/pkg/storage"
"SystemUpdater/store"
"log"
)
// HistoryService manages update history
type HistoryService struct {
store *store.Store
historyFile *storage.HistoryFile
}
// NewHistoryService creates a new history service
func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *HistoryService {
return &HistoryService{
store: st,
historyFile: historyFile,
}
}
// LoadHistory loads history from file into store
func (hs *HistoryService) LoadHistory() error {
entries, err := hs.historyFile.GetAll()
if err != nil {
log.Printf("Failed to load history: %v", err)
return err
}
hs.store.Dispatch(&store.LoadHistoryAction{Entries: entries})
log.Printf("Loaded %d history entries", len(entries))
return nil
}
// RecordUpdate records a new update in history
func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error {
if err := hs.historyFile.Insert(entry); err != nil {
log.Printf("Failed to save history: %v", err)
return err
}
hs.store.Dispatch(&store.AddHistoryAction{Entry: entry})
log.Printf("Recorded update in history: categories=%v, success=%v", entry.Categories, entry.Success)
return nil
}
// GetHistory returns all history entries
func (hs *HistoryService) GetHistory() ([]model.HistoryEntry, error) {
return hs.historyFile.GetAll()
}
// GetRecent returns N most recent history entries
func (hs *HistoryService) GetRecent(limit int) ([]model.HistoryEntry, error) {
return hs.historyFile.GetRecent(limit)
}
package service
import (
"log"
"os/exec"
)
// NotificationService handles system notifications
type NotificationService struct{}
// NewNotificationService creates a new notification service
func NewNotificationService() *NotificationService {
return &NotificationService{}
}
// NotifyUpdatesAvailable sends a notification that updates are available
func (ns *NotificationService) NotifyUpdatesAvailable() {
// Use notify-send for system notifications
cmd := exec.Command(
"notify-send",
"--app-name=Ximper System Updater",
"--icon=system-software-update",
"System Updates Available",
"Updates are available for your system. Click to open System Updater.",
)
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
} else {
log.Println("Notification sent: updates available")
}
}
// NotifyUpdateComplete sends a notification that update completed
func (ns *NotificationService) NotifyUpdateComplete(success bool) {
title := "System Update Complete"
message := "Your system has been successfully updated."
icon := "emblem-default"
if !success {
title = "System Update Failed"
message = "The system update encountered an error. Please check the logs."
icon = "dialog-error"
}
cmd := exec.Command(
"notify-send",
"--app-name=Ximper System Updater",
"--icon="+icon,
title,
message,
)
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
} else {
log.Printf("Notification sent: update complete (success=%v)", success)
}
}
package service
import (
"SystemUpdater/store"
"context"
"log"
"time"
)
// SchedulerService handles scheduled update checks
type SchedulerService struct {
store *store.Store
updateSvc *UpdateService
notifySvc *NotificationService
ticker *time.Ticker
ctx context.Context
cancel context.CancelFunc
}
// NewSchedulerService creates a new scheduler service
func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *NotificationService) *SchedulerService {
return &SchedulerService{
store: st,
updateSvc: updateSvc,
notifySvc: notifySvc,
}
}
// Start starts the scheduler
func (ss *SchedulerService) Start() {
state := ss.store.GetState()
settings := state.Settings
if !settings.AutoCheckEnabled {
log.Println("Auto-check disabled, scheduler not started")
return
}
ss.ctx, ss.cancel = context.WithCancel(context.Background())
interval := time.Duration(settings.CheckInterval) * time.Hour
ss.ticker = time.NewTicker(interval)
log.Printf("Scheduler started with interval: %v", interval)
go ss.run()
}
// Stop stops the scheduler
func (ss *SchedulerService) Stop() {
if ss.cancel != nil {
ss.cancel()
}
if ss.ticker != nil {
ss.ticker.Stop()
}
log.Println("Scheduler stopped")
}
// run is the main scheduler loop
func (ss *SchedulerService) run() {
// Run immediately on start
ss.checkUpdates()
for {
select {
case <-ss.ctx.Done():
return
case <-ss.ticker.C:
ss.checkUpdates()
}
}
}
// checkUpdates performs scheduled update check
func (ss *SchedulerService) checkUpdates() {
log.Println("Scheduled update check started")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := ss.updateSvc.CheckAllUpdates(ctx); err != nil {
log.Printf("Scheduled check failed: %v", err)
return
}
// Check if updates available
state := ss.store.GetState()
hasUpdates := store.HasAnyUpdates(&state)
if hasUpdates {
log.Println("Updates available, sending notification")
ss.notifySvc.NotifyUpdatesAvailable()
} else {
log.Println("No updates available")
}
settings := state.Settings
settings.LastAutoCheck = time.Now().Format(time.RFC3339)
ss.store.Dispatch(&store.UpdateSettingsAction{Settings: settings})
}
package store
// Selectors provide convenient read access to state
// HasAnyUpdates returns true if any category has updates available
func HasAnyUpdates(state *State) bool {
return state.SystemUpdates.Available ||
state.KernelUpdates.Available ||
state.PlayUpdates.Available
}
// GetEnabledCategories returns list of enabled category names
func GetEnabledCategories(state *State) []string {
categories := []string{}
if state.SystemUpdates.Enabled && state.SystemUpdates.Available {
categories = append(categories, "System")
}
if state.KernelUpdates.Enabled && state.KernelUpdates.Available {
categories = append(categories, "Kernel")
}
if state.PlayUpdates.Enabled && state.PlayUpdates.Available {
categories = append(categories, "Play")
}
return categories
}
// CanStartUpdate returns true if update can be started
func CanStartUpdate(state *State) bool {
if state.Phase == PhaseUpdating {
return false
}
return len(GetEnabledCategories(state)) > 0
}
// IsUpdating returns true if update is in progress
func IsUpdating(state *State) bool {
return state.Phase == PhaseUpdating && state.ActiveUpdate != nil
}
// GetTotalDownloadSize returns total download size for enabled categories
func GetTotalDownloadSize(state *State) uint64 {
var total uint64
if state.SystemUpdates.Enabled {
total += state.SystemUpdates.DownloadSize
}
if state.KernelUpdates.Enabled {
total += state.KernelUpdates.DownloadSize
}
// PlayUpdates doesn't have detailed size info yet
return total
}
package store
import "SystemUpdater/model"
// AppPhase represents the current application phase
type AppPhase int
const (
PhaseLoading AppPhase = iota
PhaseReady
PhaseUpdating
PhaseError
)
func (ap AppPhase) String() string {
switch ap {
case PhaseLoading:
return "Loading"
case PhaseReady:
return "Ready"
case PhaseUpdating:
return "Updating"
case PhaseError:
return "Error"
default:
return "Unknown"
}
}
// AppSettings holds application settings
type AppSettings struct {
AutoCheckEnabled bool
CheckInterval int // in hours
LastAutoCheck string // RFC3339 timestamp
}
// State represents the complete application state
type State struct {
Phase AppPhase
// Update categories
SystemUpdates *model.UpdateCategory
KernelUpdates *model.UpdateCategory
PlayUpdates *model.UpdateCategory
// Active update process
ActiveUpdate *model.ActiveUpdate
// Update history
History []model.HistoryEntry
// Application settings
Settings AppSettings
// Error state
LastError string
}
// NewState creates a new initial state
func NewState() *State {
return &State{
Phase: PhaseLoading,
SystemUpdates: &model.UpdateCategory{
Name: "System",
Enabled: true,
},
KernelUpdates: &model.UpdateCategory{
Name: "Kernel",
Enabled: true,
},
PlayUpdates: &model.UpdateCategory{
Name: "Play Apps",
Enabled: true,
},
ActiveUpdate: nil,
History: []model.HistoryEntry{},
Settings: AppSettings{
AutoCheckEnabled: false,
CheckInterval: 24, // 24 hours default
},
}
}
package store
import (
"context"
"sync"
)
// StateChangeType represents type of state change
type StateChangeType string
const (
ChangePhase StateChangeType = "PHASE"
ChangeSystemUpdates StateChangeType = "SYSTEM_UPDATES"
ChangeKernelUpdates StateChangeType = "KERNEL_UPDATES"
ChangePlayUpdates StateChangeType = "PLAY_UPDATES"
ChangeCategoryToggle StateChangeType = "CATEGORY_TOGGLE"
ChangeUpdateStart StateChangeType = "UPDATE_START"
ChangeUpdateProgress StateChangeType = "UPDATE_PROGRESS"
ChangeUpdateFinish StateChangeType = "UPDATE_FINISH"
ChangeHistory StateChangeType = "HISTORY"
ChangeSettings StateChangeType = "SETTINGS"
ChangeError StateChangeType = "ERROR"
)
// StateChange represents a change in application state
type StateChange struct {
Type StateChangeType
Data interface{}
}
// Store manages application state with thread-safety
type Store struct {
mu sync.RWMutex
state *State
// Event bus for UI updates
subscribersMu sync.RWMutex
subscribers []chan StateChange
// Lifecycle context
ctx context.Context
cancel context.CancelFunc
}
// NewStore creates a new Store
func NewStore() *Store {
ctx, cancel := context.WithCancel(context.Background())
return &Store{
state: NewState(),
subscribers: []chan StateChange{},
ctx: ctx,
cancel: cancel,
}
}
// GetState returns a copy of current state (thread-safe read)
func (s *Store) GetState() State {
s.mu.RLock()
defer s.mu.RUnlock()
// Return copy to prevent external mutations
return *s.state
}
// Dispatch applies an action to state and notifies subscribers
func (s *Store) Dispatch(action Action) error {
s.mu.Lock()
err := action.Apply(s.state)
s.mu.Unlock()
if err != nil {
return err
}
// Determine change type from action
changeType := s.actionToChangeType(action)
// Notify subscribers (non-blocking)
go s.notifySubscribers(StateChange{
Type: changeType,
Data: action,
})
return nil
}
// Subscribe adds a subscriber channel
func (s *Store) Subscribe(ch chan StateChange) {
s.subscribersMu.Lock()
defer s.subscribersMu.Unlock()
s.subscribers = append(s.subscribers, ch)
}
// Unsubscribe removes a subscriber channel
func (s *Store) Unsubscribe(ch chan StateChange) {
s.subscribersMu.Lock()
defer s.subscribersMu.Unlock()
for i, sub := range s.subscribers {
if sub == ch {
s.subscribers = append(s.subscribers[:i], s.subscribers[i+1:]...)
break
}
}
}
// notifySubscribers sends state change to all subscribers
func (s *Store) notifySubscribers(change StateChange) {
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
for _, ch := range s.subscribers {
select {
case ch <- change:
// Successfully sent
case <-s.ctx.Done():
// Store closed, stop notifying
return
default:
// Channel full, skip this subscriber
}
}
}
// actionToChangeType maps action type to change type
func (s *Store) actionToChangeType(action Action) StateChangeType {
switch action.(type) {
case *SetPhaseAction:
return ChangePhase
case *LoadSystemUpdatesAction:
return ChangeSystemUpdates
case *LoadKernelUpdatesAction:
return ChangeKernelUpdates
case *LoadPlayUpdatesAction:
return ChangePlayUpdates
case *ToggleCategoryAction:
return ChangeCategoryToggle
case *StartUpdateAction:
return ChangeUpdateStart
case *UpdateProgressAction:
return ChangeUpdateProgress
case *FinishUpdateAction:
return ChangeUpdateFinish
case *AddHistoryAction, *LoadHistoryAction:
return ChangeHistory
case *UpdateSettingsAction:
return ChangeSettings
case *SetErrorAction:
return ChangeError
default:
return ChangePhase
}
}
// Close closes the store and cancels all operations
func (s *Store) Close() {
s.cancel()
}
package ui
import (
"log"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// LoadBuilder loads a GTK builder from XML file
func LoadBuilder(path string) *gtk.Builder {
builder := gtk.NewBuilderFromFile(path)
if builder == nil {
log.Fatalf("Failed to load UI file: %s", path)
}
return builder
}
// GetObject gets an object from builder by ID
func GetObject[T gtk.Widgetter](builder *gtk.Builder, id string) T {
obj := builder.GetObject(id)
if obj == nil {
log.Fatalf("Failed to get object: %s", id)
}
caster := obj.Cast()
result, ok := caster.(T)
if !ok {
log.Fatalf("Object %s has wrong type", id)
}
return result
}
package components
import (
"SystemUpdater/model"
"SystemUpdater/store"
"fmt"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// CategoryRow represents a category update row with navigation
type CategoryRow struct {
*adw.ActionRow
category string
st *store.Store
onNavigate func()
}
// NewCategoryRow creates a new category row
func NewCategoryRow(category string, st *store.Store, onNavigate func()) *CategoryRow {
row := adw.NewActionRow()
row.SetTitle(category)
row.SetActivatable(true)
chevron := gtk.NewImageFromIconName("go-next-symbolic")
row.AddSuffix(chevron)
cr := &CategoryRow{
ActionRow: row,
category: category,
st: st,
onNavigate: onNavigate,
}
row.ConnectActivated(func() {
if cr.onNavigate != nil {
cr.onNavigate()
}
})
return cr
}
// Update updates the row with category data
func (cr *CategoryRow) Update(cat *model.UpdateCategory) {
cr.SetTitle(cat.Name)
if cat.Available {
subtitle := fmt.Sprintf("%d packages", len(cat.Packages))
if cat.DownloadSize > 0 {
subtitle += fmt.Sprintf(", %s", formatBytes(cat.DownloadSize))
}
cr.SetSubtitle(subtitle)
cr.SetVisible(true)
cr.SetSensitive(true)
} else {
cr.SetVisible(false)
}
}
// formatBytes formats byte size to human readable string
func formatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := uint64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
units := []string{"KB", "MB", "GB", "TB"}
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
}
package components
import (
"SystemUpdater/model"
"fmt"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// PackageRow represents a single package row
type PackageRow struct {
*adw.ActionRow
}
// NewPackageRow creates a new package row
func NewPackageRow(pkg model.Package) *PackageRow {
row := adw.NewActionRow()
row.SetTitle(pkg.Name)
subtitle := ""
if pkg.InstalledVersion != nil && *pkg.InstalledVersion != "" {
oldVer := stripVersionTimestamp(*pkg.InstalledVersion)
newVer := stripVersionTimestamp(pkg.Version)
subtitle += fmt.Sprintf("%s -> %s", oldVer, newVer)
} else {
subtitle += stripVersionTimestamp(pkg.Version)
}
if pkg.Size != nil && *pkg.Size > 0 {
subtitle += fmt.Sprintf(" • %s", formatBytes(*pkg.Size))
}
row.SetSubtitle(subtitle)
if pkg.Summary != nil && *pkg.Summary != "" {
row.SetTooltipText(*pkg.Summary)
}
icon := gtk.NewImageFromIconName("package-x-generic-symbolic")
row.AddPrefix(icon)
return &PackageRow{ActionRow: row}
}
// stripVersionTimestamp removes @timestamp suffix from version string
func stripVersionTimestamp(version string) string {
if idx := len(version); idx > 0 {
for i := 0; i < len(version); i++ {
if version[i] == '@' {
return version[:i]
}
}
}
return version
}
package pages
import (
"SystemUpdater/model"
"fmt"
"strings"
"time"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// HistoryPage represents the history page
type HistoryPage struct {
*adw.NavigationPage
listBox *gtk.ListBox
}
// NewHistoryPage creates a new history page
func NewHistoryPage() *HistoryPage {
toolbarView := adw.NewToolbarView()
// Header bar
headerBar := adw.NewHeaderBar()
toolbarView.AddTopBar(headerBar)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
toolbarView.SetContent(scrolled)
navPage := adw.NewNavigationPage(toolbarView, "History")
return &HistoryPage{
NavigationPage: navPage,
listBox: listBox,
}
}
// Update updates the history list
func (p *HistoryPage) Update(entries []model.HistoryEntry) {
// Clear existing rows
for child := p.listBox.FirstChild(); child != nil; child = p.listBox.FirstChild() {
p.listBox.Remove(child)
}
// Add entries in reverse order (newest first)
for i := len(entries) - 1; i >= 0; i-- {
entry := entries[i]
row := p.createHistoryRow(entry)
p.listBox.Append(row)
}
}
// createHistoryRow creates a row for a history entry
func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRow {
row := adw.NewExpanderRow()
// Parse timestamp
timestamp, _ := time.Parse(time.RFC3339, entry.Timestamp)
timeStr := timestamp.Format("Jan 02, 2006 15:04")
// Title: timestamp
row.SetTitle(timeStr)
// Subtitle: categories and status
categoriesStr := strings.Join(entry.Categories, ", ")
status := "Success"
icon := "emblem-ok-symbolic"
if !entry.Success {
status = "Failed"
icon = "dialog-error-symbolic"
}
subtitle := fmt.Sprintf("%s • %s • %d packages • %ds",
categoriesStr, status, entry.PackagesUpdated, entry.DurationSeconds)
row.SetSubtitle(subtitle)
// Icon prefix
image := gtk.NewImageFromIconName(icon)
row.AddPrefix(image)
// Add log entries as child rows
if len(entry.Log) > 0 {
// Show first 5 log lines
logLines := entry.Log
if len(logLines) > 5 {
logLines = logLines[:5]
}
for _, line := range logLines {
logRow := adw.NewActionRow()
logRow.SetTitle(line)
logRow.AddCSSClass("monospace")
row.AddRow(logRow)
}
if len(entry.Log) > 5 {
moreRow := adw.NewActionRow()
moreRow.SetTitle(fmt.Sprintf("... and %d more lines", len(entry.Log)-5))
row.AddRow(moreRow)
}
}
if entry.ErrorMessage != "" {
errorRow := adw.NewActionRow()
errorRow.SetTitle("Error: " + entry.ErrorMessage)
errorRow.AddCSSClass("error")
row.AddRow(errorRow)
}
return row
}
package pages
import (
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
)
// LoadingPage represents the loading page
type LoadingPage struct {
*adw.StatusPage
}
// NewLoadingPage creates a new loading page
func NewLoadingPage() *LoadingPage {
page := adw.NewStatusPage()
page.SetTitle("Loading…")
page.SetIconName("emblem-synchronizing-symbolic")
spinner := adw.NewSpinner()
spinner.SetSizeRequest(80, 80)
page.SetChild(spinner)
return &LoadingPage{StatusPage: page}
}
package pages
import (
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
)
// NoUpdatesPage represents the no updates page
type NoUpdatesPage struct {
*adw.StatusPage
}
// NewNoUpdatesPage creates a new no updates page
func NewNoUpdatesPage() *NoUpdatesPage {
page := adw.NewStatusPage()
page.SetTitle("System is up to date")
page.SetIconName("emblem-ok-symbolic")
return &NoUpdatesPage{StatusPage: page}
}
package pages
import (
"SystemUpdater/model"
"SystemUpdater/ui/components"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// PackageListPage represents a page with list of packages for a category
type PackageListPage struct {
*adw.NavigationPage
listBox *gtk.ListBox
category string
}
// NewPackageListPage creates a new package list page
func NewPackageListPage(category string, packages []model.Package) *PackageListPage {
toolbarView := adw.NewToolbarView()
// Header bar
headerBar := adw.NewHeaderBar()
toolbarView.AddTopBar(headerBar)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list-separate")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
toolbarView.SetContent(scrolled)
navPage := adw.NewNavigationPage(toolbarView, category)
page := &PackageListPage{
NavigationPage: navPage,
listBox: listBox,
category: category,
}
// Populate with packages
page.SetPackages(packages)
return page
}
func (p *PackageListPage) SetPackages(packages []model.Package) {
for child := p.listBox.FirstChild(); child != nil; child = p.listBox.FirstChild() {
p.listBox.Remove(child)
}
for _, pkg := range packages {
row := components.NewPackageRow(pkg)
p.listBox.Append(row)
}
}
package pages
import (
"SystemUpdater/store"
"SystemUpdater/ui/components"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// UpdatesListPage represents the main updates list page
type UpdatesListPage struct {
*gtk.Box
st *store.Store
listBox *gtk.ListBox
systemRow *components.CategoryRow
kernelRow *components.CategoryRow
playRow *components.CategoryRow
updateButton *adw.ButtonRow
}
// NewUpdatesListPage creates a new updates list page
func NewUpdatesListPage(st *store.Store, onUpdate func(), onNavigateToCategory func(category string)) *UpdatesListPage {
box := gtk.NewBox(gtk.OrientationVertical, 0)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list-separate")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
box.Append(scrolled)
// Bottom bar with update button
bottomClamp := adw.NewClamp()
bottomClamp.SetMarginTop(12)
bottomClamp.SetMarginBottom(12)
bottomClamp.SetMarginStart(12)
bottomClamp.SetMarginEnd(12)
bottomClamp.SetMaximumSize(500)
buttonListBox := gtk.NewListBox()
updateButton := adw.NewButtonRow()
updateButton.SetTitle("Update")
updateButton.AddCSSClass("suggested-action")
buttonListBox.Append(updateButton)
buttonListBox.AddCSSClass("boxed-list")
bottomClamp.SetChild(buttonListBox)
box.Append(bottomClamp)
page := &UpdatesListPage{
Box: box,
st: st,
listBox: listBox,
systemRow: components.NewCategoryRow("System", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("System")
}
}),
kernelRow: components.NewCategoryRow("Kernel", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Kernel")
}
}),
playRow: components.NewCategoryRow("Play", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Play")
}
}),
updateButton: updateButton,
}
listBox.Append(page.systemRow)
listBox.Append(page.kernelRow)
listBox.Append(page.playRow)
updateButton.ConnectActivated(func() {
if onUpdate != nil {
onUpdate()
}
})
return page
}
func (p *UpdatesListPage) Update() {
state := p.st.GetState()
p.systemRow.Update(state.SystemUpdates)
p.kernelRow.Update(state.KernelUpdates)
p.playRow.Update(state.PlayUpdates)
canUpdate := store.CanStartUpdate(&state)
p.updateButton.SetSensitive(canUpdate)
}
package main
import (
"SystemUpdater/lib/apm"
bldr "SystemUpdater/lib/gtks/builder"
_ "embed"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
//go:embed process-page.ui
var ProcessPageUIXML string
type SystemUpdaterProcessPage struct {
}
func appendLog(logView *gtk.TextView, text string) {
buf := logView.Buffer()
iter := buf.EndIter()
buf.Insert(iter, text)
}
func RunUpgrade(srcs apm.UpdatesSources) {
builder := bldr.New(ProcessPageUIXML)
page := bldr.GetObject[*adw.NavigationPage](builder, "process_page")
GetSystemUpdaterWindow().navView.Replace([]*adw.NavigationPage{
page,
})
stack := bldr.GetObject[*gtk.Stack](builder, "process_stack")
statusPage := bldr.GetObject[*adw.StatusPage](builder, "status_page")
progressBar := bldr.GetObject[*gtk.ProgressBar](builder, "progress_bar")
logView := bldr.GetObject[*gtk.TextView](builder, "log_view")
go func() {
IA := glib.IdleAdd
for name, u := range srcs {
glib.IdleAdd(func() { appendLog(logView, "=== Starting update "+name+" ===\n") })
err := u.RunUpgrade(
func() {
IA(func() { appendLog(logView, "=== Update has been completed ===\n") })
},
func(ev apm.EventData) {
IA(func() {
appendLog(logView, ev.View+"\n")
statusPage.SetDescription(ev.View)
if ev.ProgressPercent > 0 {
progressBar.SetFraction(float64(ev.ProgressPercent) / 100)
}
})
},
)
if err != nil {
IA(func() { appendLog(logView, "Error during the update "+name+": "+err.Error()+"\n") })
break
}
IA(func() { progressBar.SetFraction(0) })
}
IA(func() { stack.SetVisibleChildName("finish") })
_ = stack
}()
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment