Commit 08e4bea4 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 2d0b2626
.vscode/ .vscode/
SystemUpdater SystemUpdater
# Generated from .blp by blueprint-compiler
*.ui
...@@ -3,11 +3,11 @@ package dbus ...@@ -3,11 +3,11 @@ package dbus
import ( import (
"context" "context"
"log" "log"
"sync"
"github.com/diamondburned/gotk4/pkg/gio/v2" "github.com/diamondburned/gotk4/pkg/gio/v2"
) )
// EEPM D-Bus service constants
const ( const (
ServiceName = "ru.etersoft.EPM" ServiceName = "ru.etersoft.EPM"
ObjectPath = "/ru/etersoft/EPM" ObjectPath = "/ru/etersoft/EPM"
...@@ -16,21 +16,51 @@ const ( ...@@ -16,21 +16,51 @@ const (
SignalInterface = "ru.etersoft.EPM" // signals are emitted on base interface SignalInterface = "ru.etersoft.EPM" // signals are emitted on base interface
) )
// Connection holds D-Bus connection and proxies for EEPM service
type Connection struct { type Connection struct {
Conn *gio.DBusConnection Conn *gio.DBusConnection
QueryProxy *gio.DBusProxy QueryProxy *gio.DBusProxy
ManageProxy *gio.DBusProxy ManageProxy *gio.DBusProxy
} }
var epmConn *Connection var (
epmConn *Connection
connMu sync.Mutex
)
// GetConnection returns singleton connection to EEPM D-Bus service
func GetConnection() (*Connection, error) { func GetConnection() (*Connection, error) {
connMu.Lock()
defer connMu.Unlock()
if epmConn != nil { if epmConn != nil {
return epmConn, nil return epmConn, nil
} }
return connect()
}
func ResetConnection() {
connMu.Lock()
defer connMu.Unlock()
if epmConn != nil {
log.Println("Resetting D-Bus connection")
epmConn = nil
}
}
func Reconnect() (*Connection, error) {
connMu.Lock()
defer connMu.Unlock()
if epmConn != nil {
log.Println("Dropping old D-Bus connection for reconnect")
epmConn = nil
}
return connect()
}
func connect() (*Connection, error) {
c, err := gio.BusGetSync(context.Background(), gio.BusTypeSystem) c, err := gio.BusGetSync(context.Background(), gio.BusTypeSystem)
if err != nil { if err != nil {
return nil, err return nil, err
......
...@@ -5,14 +5,18 @@ import ( ...@@ -5,14 +5,18 @@ import (
"SystemUpdater/model" "SystemUpdater/model"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"sync" "sync"
"time"
"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"
) )
// Client is the main EEPM D-Bus client // SignalTimeout is the max time without receiving a signal before we consider the connection lost
const SignalTimeout = 60 * time.Second
type Client struct { type Client struct {
conn *dbus.Connection conn *dbus.Connection
...@@ -21,12 +25,15 @@ type Client struct { ...@@ -21,12 +25,15 @@ type Client struct {
subscriptions map[string]chan model.EventData subscriptions map[string]chan model.EventData
subID uint subID uint
// Tracks last signal time for timeout detection
lastSignalMu sync.Mutex
lastSignal time.Time
// Lifecycle // Lifecycle
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
} }
// NewClient creates a new EEPM client with signal listener
func NewClient() (*Client, error) { func NewClient() (*Client, error) {
conn, err := dbus.GetConnection() conn, err := dbus.GetConnection()
if err != nil { if err != nil {
...@@ -49,7 +56,27 @@ func NewClient() (*Client, error) { ...@@ -49,7 +56,27 @@ func NewClient() (*Client, error) {
return client, nil return client, nil
} }
// Close closes the client and stops signal listening func (c *Client) Reconnect() error {
// Unsubscribe old signal listener
if c.subID != 0 {
c.conn.Conn.SignalUnsubscribe(c.subID)
c.subID = 0
}
conn, err := dbus.Reconnect()
if err != nil {
return fmt.Errorf("reconnect failed: %w", err)
}
c.conn = conn
// Restart signal listener with new connection
go c.listenSignals()
log.Println("EEPM Client reconnected")
return nil
}
func (c *Client) Close() { func (c *Client) Close() {
c.cancel() c.cancel()
...@@ -60,16 +87,15 @@ func (c *Client) Close() { ...@@ -60,16 +87,15 @@ func (c *Client) Close() {
log.Println("EEPM Client closed") log.Println("EEPM Client closed")
} }
// SubscribeProgress subscribes to progress events for a transaction
func (c *Client) SubscribeProgress(transaction string, ch chan model.EventData) { func (c *Client) SubscribeProgress(transaction string, ch chan model.EventData) {
c.signalMu.Lock() c.signalMu.Lock()
defer c.signalMu.Unlock() defer c.signalMu.Unlock()
c.subscriptions[transaction] = ch c.subscriptions[transaction] = ch
c.touchLastSignal() // reset timer on subscribe
log.Printf("Subscribed to progress for transaction: %s", transaction) log.Printf("Subscribed to progress for transaction: %s", transaction)
} }
// UnsubscribeProgress unsubscribes from progress events
func (c *Client) UnsubscribeProgress(transaction string) { func (c *Client) UnsubscribeProgress(transaction string) {
c.signalMu.Lock() c.signalMu.Lock()
defer c.signalMu.Unlock() defer c.signalMu.Unlock()
...@@ -78,7 +104,28 @@ func (c *Client) UnsubscribeProgress(transaction string) { ...@@ -78,7 +104,28 @@ func (c *Client) UnsubscribeProgress(transaction string) {
log.Printf("Unsubscribed from progress for transaction: %s", transaction) log.Printf("Unsubscribed from progress for transaction: %s", transaction)
} }
// listenSignals starts the global D-Bus signal listener func (c *Client) IsSignalTimedOut() bool {
c.signalMu.RLock()
hasSubs := len(c.subscriptions) > 0
c.signalMu.RUnlock()
if !hasSubs {
return false
}
c.lastSignalMu.Lock()
elapsed := time.Since(c.lastSignal)
c.lastSignalMu.Unlock()
return elapsed > SignalTimeout
}
func (c *Client) touchLastSignal() {
c.lastSignalMu.Lock()
c.lastSignal = time.Now()
c.lastSignalMu.Unlock()
}
func (c *Client) listenSignals() { func (c *Client) listenSignals() {
c.subID = c.conn.Conn.SignalSubscribe( c.subID = c.conn.Conn.SignalSubscribe(
dbus.ServiceName, dbus.ServiceName,
...@@ -100,7 +147,6 @@ func (c *Client) listenSignals() { ...@@ -100,7 +147,6 @@ func (c *Client) listenSignals() {
} }
} }
// handleSignal processes incoming D-Bus signals
func (c *Client) handleSignal( func (c *Client) handleSignal(
conn *gio.DBusConnection, conn *gio.DBusConnection,
senderName string, senderName string,
...@@ -109,6 +155,8 @@ func (c *Client) handleSignal( ...@@ -109,6 +155,8 @@ func (c *Client) handleSignal(
signalName string, signalName string,
parameters *glib.Variant, parameters *glib.Variant,
) { ) {
c.touchLastSignal()
// Parse JSON from first parameter // Parse JSON from first parameter
raw := parameters.ChildValue(0).String() raw := parameters.ChildValue(0).String()
......
...@@ -11,7 +11,6 @@ import ( ...@@ -11,7 +11,6 @@ import (
"github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/glib/v2"
) )
// CheckKernelUpdates checks for available kernel updates
func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInfo, error) { func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInfo, error) {
resultCh := make(chan *glib.Variant, 1) resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
...@@ -49,7 +48,6 @@ func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInf ...@@ -49,7 +48,6 @@ func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInf
} }
} }
// RunKernelUpgrade executes kernel upgrade
func (c *Client) RunKernelUpgrade(ctx context.Context, transaction string) error { func (c *Client) RunKernelUpgrade(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
......
...@@ -11,7 +11,6 @@ import ( ...@@ -11,7 +11,6 @@ import (
"github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/glib/v2"
) )
// CheckPlayUpdates checks if play apps updates are available
func (c *Client) CheckPlayUpdates(ctx context.Context) (*model.PlayUpdateInfo, error) { func (c *Client) CheckPlayUpdates(ctx context.Context) (*model.PlayUpdateInfo, error) {
resultCh := make(chan *glib.Variant, 1) resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
...@@ -56,7 +55,6 @@ func (c *Client) CheckPlayUpdates(ctx context.Context) (*model.PlayUpdateInfo, e ...@@ -56,7 +55,6 @@ func (c *Client) CheckPlayUpdates(ctx context.Context) (*model.PlayUpdateInfo, e
} }
} }
// RunPlayUpdate executes play apps update
// NOTE: Requires PlayUpdate method in eepm-dbus Manage interface // NOTE: Requires PlayUpdate method in eepm-dbus Manage interface
func (c *Client) RunPlayUpdate(ctx context.Context, transaction string) error { func (c *Client) RunPlayUpdate(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
......
...@@ -11,7 +11,6 @@ import ( ...@@ -11,7 +11,6 @@ import (
"github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/glib/v2"
) )
// CheckSystemUpdates checks for available system updates
func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges, error) { func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges, error) {
resultCh := make(chan *glib.Variant, 1) resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
...@@ -49,7 +48,6 @@ func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges, ...@@ -49,7 +48,6 @@ func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges,
} }
} }
// RunSystemUpgrade executes system upgrade
func (c *Client) RunSystemUpgrade(ctx context.Context, transaction string) error { func (c *Client) RunSystemUpgrade(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1) errorCh := make(chan error, 1)
......
...@@ -2,45 +2,38 @@ package eepm ...@@ -2,45 +2,38 @@ package eepm
import "SystemUpdater/model" import "SystemUpdater/model"
// API Response wrapper matching eepm-dbus-rs ApiResponse<T>
type ApiResponse[T any] struct { type ApiResponse[T any] struct {
Data T `json:"data"` Data T `json:"data"`
Error bool `json:"error"` Error bool `json:"error"`
Message *string `json:"message"` Message *string `json:"message"`
} }
// InfoResponse for package info
type InfoResponse struct { type InfoResponse struct {
Message string `json:"message"` Message string `json:"message"`
Package model.Package `json:"package"` Package model.Package `json:"package"`
} }
// CheckResponse for check-upgrade, check-install, check-remove
type CheckResponse struct { type CheckResponse struct {
Message string `json:"message"` Message string `json:"message"`
Info model.PackageChanges `json:"info"` Info model.PackageChanges `json:"info"`
} }
// UpgradeResponse for upgrade operations
type UpgradeResponse struct { type UpgradeResponse struct {
Message string `json:"message"` Message string `json:"message"`
Info model.PackageChanges `json:"info"` Info model.PackageChanges `json:"info"`
} }
// KernelUpdateResponse for kernel operations
type KernelUpdateResponse struct { type KernelUpdateResponse struct {
Message string `json:"message"` Message string `json:"message"`
Info model.KernelUpdateInfo `json:"info"` Info model.KernelUpdateInfo `json:"info"`
} }
// PlayListUpdatesResponse for play list-updates
type PlayListUpdatesResponse struct { type PlayListUpdatesResponse struct {
Message string `json:"message"` Message string `json:"message"`
Apps []model.PlayUpdateApp `json:"apps"` Apps []model.PlayUpdateApp `json:"apps"`
TotalCount uint32 `json:"total_count"` TotalCount uint32 `json:"total_count"`
} }
// Root response types (wrapped in ApiResponse)
type InfoRootResponse = ApiResponse[InfoResponse] type InfoRootResponse = ApiResponse[InfoResponse]
type CheckUpgradeRootResponse = ApiResponse[CheckResponse] type CheckUpgradeRootResponse = ApiResponse[CheckResponse]
type UpgradeRootResponse = ApiResponse[UpgradeResponse] type UpgradeRootResponse = ApiResponse[UpgradeResponse]
......
package gtksbuilder package gtksbuilder
import ( import (
"fmt"
"reflect"
"unsafe"
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
...@@ -22,14 +26,51 @@ func NewBuilder(path string) *gtk.Builder { ...@@ -22,14 +26,51 @@ func NewBuilder(path string) *gtk.Builder {
return builder return builder
} }
func GetObject[T any](builder *gtk.Builder, name string) T { // Unmarshal fills struct fields from a GTK Builder using `gtk:"object_id"` tags.
return builder.GetObject(name).Cast().(T) //
} // type MyPage struct {
// Button *gtk.Button `gtk:"my_button"`
// Label *gtk.Label `gtk:"my_label"`
// Stack *adw.ViewStack `gtk:"main_stack"`
// }
//
// var page MyPage
// gtksbuilder.Unmarshal(builder, &page)
func Unmarshal(builder *gtk.Builder, dest any) error {
v := reflect.ValueOf(dest)
if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
return fmt.Errorf("gtksbuilder.Unmarshal: dest must be a pointer to struct")
}
v = v.Elem()
t := v.Type()
func GetObjects[T any](builder *gtk.Builder, names ...string) map[string]T { for i := range t.NumField() {
objects := make(map[string]T) field := t.Field(i)
for _, name := range names { tag := field.Tag.Get("gtk")
objects[name] = GetObject[T](builder, name) if tag == "" || tag == "-" {
continue
} }
return objects
obj := builder.GetObject(tag)
if obj == nil {
return fmt.Errorf("gtksbuilder.Unmarshal: object %q not found in builder", tag)
}
casted := obj.Cast()
castedVal := reflect.ValueOf(casted)
if !castedVal.Type().AssignableTo(field.Type) {
return fmt.Errorf("gtksbuilder.Unmarshal: object %q is %T, expected %s", tag, casted, field.Type)
}
f := v.Field(i)
if !f.CanSet() {
// unexported field — use unsafe to set it anyway
f = reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem()
}
f.Set(castedVal)
}
return nil
} }
package gtkslogging
import (
"log/slog"
"os"
)
func init() {
// GTK4/gotk4 routes all GLib structured logs through slog.
// Raise minimum level to WARN to suppress GDK/Vulkan INFO noise.
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelWarn,
})))
}
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Cambalache 0.94.1 -->
<interface domain="ximper-system-updater">
<requires lib="gtk" version="4.12"/>
<requires lib="libadwaita" version="1.6"/>
<object class="AdwNavigationPage" id="listpage">
<child>
<object class="AdwToolbarView">
<property name="top-bar-style">raised</property>
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkButton">
<property name="action-name">app.about</property>
<property name="icon-name">help-about-symbolic</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwToolbarView">
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="AdwClamp">
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkListBox" id="updates_listbox">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list-separate"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
<child type="bottom">
<object class="GtkCenterBox">
<property name="center-widget">
<object class="AdwClamp">
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="maximum-size">500</property>
<child>
<object class="GtkListBox">
<child>
<object class="AdwButtonRow" id="apply_button">
<property name="title" translatable="yes">Update</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
</object>
</property>
<property name="halign">center</property>
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
...@@ -2,6 +2,7 @@ package main ...@@ -2,6 +2,7 @@ package main
import ( import (
"SystemUpdater/backend/eepm" "SystemUpdater/backend/eepm"
_ "SystemUpdater/lib/gtks/logging"
"SystemUpdater/pkg/storage" "SystemUpdater/pkg/storage"
"SystemUpdater/service" "SystemUpdater/service"
"SystemUpdater/store" "SystemUpdater/store"
...@@ -16,9 +17,6 @@ import ( ...@@ -16,9 +17,6 @@ import (
) )
func main() { func main() {
// Disable debug logging for GTK
glib.LogSetDebugEnabled(false)
st := store.NewStore() st := store.NewStore()
defer st.Close() defer st.Close()
...@@ -45,7 +43,9 @@ func main() { ...@@ -45,7 +43,9 @@ func main() {
} }
defer eepmClient.Close() defer eepmClient.Close()
notificationSvc := service.NewNotificationService() app := adw.NewApplication("ru.ximperlinux.SystemUpdater", gio.ApplicationHandlesCommandLine)
notificationSvc := service.NewNotificationService(&app.Application.Application)
historySvc := service.NewHistoryService(st, historyFile) historySvc := service.NewHistoryService(st, historyFile)
updateSvc := service.NewUpdateService(st, eepmClient, historySvc) updateSvc := service.NewUpdateService(st, eepmClient, historySvc)
schedulerSvc := service.NewSchedulerService(st, updateSvc, notificationSvc) schedulerSvc := service.NewSchedulerService(st, updateSvc, notificationSvc)
...@@ -54,7 +54,15 @@ func main() { ...@@ -54,7 +54,15 @@ func main() {
log.Printf("Failed to load history: %v", err) log.Printf("Failed to load history: %v", err)
} }
app := adw.NewApplication("ru.ximperlinux.SystemUpdater", gio.ApplicationFlagsNone) // Register action for notification click → opens/focuses the window
showAction := gio.NewSimpleAction("show-updates", nil)
showAction.ConnectActivate(func(_ *glib.Variant) {
app.Activate()
})
app.AddAction(showAction)
app.Hold()
schedulerSvc.Start()
app.ConnectActivate(func() { app.ConnectActivate(func() {
window := ui.NewWindow(app, st, updateSvc, historySvc) window := ui.NewWindow(app, st, updateSvc, historySvc)
...@@ -70,16 +78,17 @@ func main() { ...@@ -70,16 +78,17 @@ func main() {
app.ConnectCommandLine(func(cmdLine *gio.ApplicationCommandLine) int { app.ConnectCommandLine(func(cmdLine *gio.ApplicationCommandLine) int {
args := cmdLine.Arguments() args := cmdLine.Arguments()
background := false
for _, arg := range args { for _, arg := range args {
if arg == "--background" { if arg == "--background" {
log.Println("Running in background mode") background = true
app.Hold()
schedulerSvc.Start()
return 0
} }
} }
if !background {
app.Activate() app.Activate()
}
return 0 return 0
}) })
......
package model package model
// HistoryEntry represents a single update history record
type HistoryEntry struct { type HistoryEntry struct {
ID int `yaml:"id"` ID int `yaml:"id"`
Timestamp string `yaml:"timestamp"` // RFC3339 Timestamp string `yaml:"timestamp"` // RFC3339
......
package model package model
// Package represents a package in EEPM responses
type Package struct { type Package struct {
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
...@@ -19,7 +18,6 @@ type Package struct { ...@@ -19,7 +18,6 @@ type Package struct {
InstalledVersion *string `json:"installed_version"` InstalledVersion *string `json:"installed_version"`
} }
// PackageChanges represents changes from check/simulate operations
type PackageChanges struct { type PackageChanges struct {
Install []Package `json:"install"` Install []Package `json:"install"`
Remove []Package `json:"remove"` Remove []Package `json:"remove"`
...@@ -34,7 +32,6 @@ type PackageChanges struct { ...@@ -34,7 +32,6 @@ type PackageChanges struct {
InstallSize int64 `json:"install_size"` InstallSize int64 `json:"install_size"`
} }
// HasChanges returns true if there are any package changes
func (pc *PackageChanges) HasChanges() bool { func (pc *PackageChanges) HasChanges() bool {
return len(pc.Upgrade) > 0 || len(pc.Install) > 0 || len(pc.Remove) > 0 || len(pc.Downgrade) > 0 return len(pc.Upgrade) > 0 || len(pc.Install) > 0 || len(pc.Remove) > 0 || len(pc.Downgrade) > 0
} }
package model package model
// EventData represents progress event from D-Bus signal
type EventData struct { type EventData struct {
Name string `json:"name"` Name string `json:"name"`
Message string `json:"message"` Message string `json:"message"`
......
package model package model
// UpdateCategory represents a category of updates (System, Kernel, Play)
type UpdateCategory struct { type UpdateCategory struct {
Name string Name string
Enabled bool // User can toggle this category Enabled bool // User can toggle this category
...@@ -11,7 +10,6 @@ type UpdateCategory struct { ...@@ -11,7 +10,6 @@ type UpdateCategory struct {
LastChecked string // RFC3339 timestamp LastChecked string // RFC3339 timestamp
} }
// KernelUpdateInfo represents kernel update information
type KernelUpdateInfo struct { type KernelUpdateInfo struct {
RunningKernel string RunningKernel string
AvailableKernel string AvailableKernel string
...@@ -26,14 +24,12 @@ type KernelUpdateInfo struct { ...@@ -26,14 +24,12 @@ type KernelUpdateInfo struct {
UpToDate bool UpToDate bool
} }
// PlayUpdateApp represents a play app with an available update
type PlayUpdateApp struct { type PlayUpdateApp struct {
Name string `json:"name"` Name string `json:"name"`
InstalledVersion string `json:"installed_version"` InstalledVersion string `json:"installed_version"`
AvailableVersion string `json:"available_version"` AvailableVersion string `json:"available_version"`
} }
// PlayUpdateInfo represents play apps update information
type PlayUpdateInfo struct { type PlayUpdateInfo struct {
Available bool Available bool
Message string Message string
...@@ -41,7 +37,6 @@ type PlayUpdateInfo struct { ...@@ -41,7 +37,6 @@ type PlayUpdateInfo struct {
TotalCount uint32 TotalCount uint32
} }
// ActiveUpdate represents an ongoing update operation
type ActiveUpdate struct { type ActiveUpdate struct {
Categories []string // Which categories are being updated Categories []string // Which categories are being updated
Transaction string Transaction string
......
...@@ -9,18 +9,15 @@ import ( ...@@ -9,18 +9,15 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// HistoryFile manages history storage in YAML format
type HistoryFile struct { type HistoryFile struct {
path string path string
mu sync.RWMutex mu sync.RWMutex
} }
// HistoryData represents the YAML file structure
type HistoryData struct { type HistoryData struct {
Entries []model.HistoryEntry `yaml:"entries"` Entries []model.HistoryEntry `yaml:"entries"`
} }
// NewHistoryFile creates a new history file manager
func NewHistoryFile() (*HistoryFile, error) { func NewHistoryFile() (*HistoryFile, error) {
homeDir := os.Getenv("HOME") homeDir := os.Getenv("HOME")
if homeDir == "" { if homeDir == "" {
...@@ -37,7 +34,6 @@ func NewHistoryFile() (*HistoryFile, error) { ...@@ -37,7 +34,6 @@ func NewHistoryFile() (*HistoryFile, error) {
return &HistoryFile{path: path}, nil return &HistoryFile{path: path}, nil
} }
// Insert adds a new history entry
func (hf *HistoryFile) Insert(entry model.HistoryEntry) error { func (hf *HistoryFile) Insert(entry model.HistoryEntry) error {
hf.mu.Lock() hf.mu.Lock()
defer hf.mu.Unlock() defer hf.mu.Unlock()
...@@ -61,7 +57,6 @@ func (hf *HistoryFile) Insert(entry model.HistoryEntry) error { ...@@ -61,7 +57,6 @@ func (hf *HistoryFile) Insert(entry model.HistoryEntry) error {
return hf.writeAllUnsafe(data) return hf.writeAllUnsafe(data)
} }
// GetAll returns all history entries
func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) { func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) {
hf.mu.RLock() hf.mu.RLock()
defer hf.mu.RUnlock() defer hf.mu.RUnlock()
...@@ -70,7 +65,6 @@ func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) { ...@@ -70,7 +65,6 @@ func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) {
return data.Entries, nil return data.Entries, nil
} }
// GetRecent returns N most recent entries
func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) { func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) {
hf.mu.RLock() hf.mu.RLock()
defer hf.mu.RUnlock() defer hf.mu.RUnlock()
...@@ -84,7 +78,6 @@ func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) { ...@@ -84,7 +78,6 @@ func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) {
return data.Entries[len(data.Entries)-limit:], nil return data.Entries[len(data.Entries)-limit:], nil
} }
// readAllUnsafe reads all data without locking (internal use)
func (hf *HistoryFile) readAllUnsafe() HistoryData { func (hf *HistoryFile) readAllUnsafe() HistoryData {
file, err := os.ReadFile(hf.path) file, err := os.ReadFile(hf.path)
if err != nil { if err != nil {
...@@ -101,7 +94,6 @@ func (hf *HistoryFile) readAllUnsafe() HistoryData { ...@@ -101,7 +94,6 @@ func (hf *HistoryFile) readAllUnsafe() HistoryData {
return data return data
} }
// writeAllUnsafe writes all data without locking (internal use)
func (hf *HistoryFile) writeAllUnsafe(data HistoryData) error { func (hf *HistoryFile) writeAllUnsafe(data HistoryData) error {
bytes, err := yaml.Marshal(data) bytes, err := yaml.Marshal(data)
if err != nil { if err != nil {
......
...@@ -9,13 +9,11 @@ import ( ...@@ -9,13 +9,11 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// SettingsFile manages application settings
type SettingsFile struct { type SettingsFile struct {
path string path string
mu sync.RWMutex mu sync.RWMutex
} }
// NewSettingsFile creates a new settings file manager
func NewSettingsFile() (*SettingsFile, error) { func NewSettingsFile() (*SettingsFile, error) {
homeDir := os.Getenv("HOME") homeDir := os.Getenv("HOME")
if homeDir == "" { if homeDir == "" {
...@@ -32,7 +30,6 @@ func NewSettingsFile() (*SettingsFile, error) { ...@@ -32,7 +30,6 @@ func NewSettingsFile() (*SettingsFile, error) {
return &SettingsFile{path: path}, nil return &SettingsFile{path: path}, nil
} }
// Load loads settings from file
func (sf *SettingsFile) Load() (store.AppSettings, error) { func (sf *SettingsFile) Load() (store.AppSettings, error) {
sf.mu.RLock() sf.mu.RLock()
defer sf.mu.RUnlock() defer sf.mu.RUnlock()
...@@ -41,8 +38,8 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) { ...@@ -41,8 +38,8 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) {
if err != nil { if err != nil {
// File doesn't exist, return defaults // File doesn't exist, return defaults
return store.AppSettings{ return store.AppSettings{
AutoCheckEnabled: false, AutoCheckEnabled: true,
CheckInterval: 24, CheckInterval: 1,
}, nil }, nil
} }
...@@ -50,15 +47,14 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) { ...@@ -50,15 +47,14 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) {
if err := yaml.Unmarshal(file, &settings); err != nil { if err := yaml.Unmarshal(file, &settings); err != nil {
// Corrupted file, return defaults // Corrupted file, return defaults
return store.AppSettings{ return store.AppSettings{
AutoCheckEnabled: false, AutoCheckEnabled: true,
CheckInterval: 24, CheckInterval: 1,
}, nil }, nil
} }
return settings, nil return settings, nil
} }
// Save saves settings to file
func (sf *SettingsFile) Save(settings store.AppSettings) error { func (sf *SettingsFile) Save(settings store.AppSettings) error {
sf.mu.Lock() sf.mu.Lock()
defer sf.mu.Unlock() defer sf.mu.Unlock()
......
...@@ -7,13 +7,11 @@ import ( ...@@ -7,13 +7,11 @@ import (
"log" "log"
) )
// HistoryService manages update history
type HistoryService struct { type HistoryService struct {
store *store.Store store *store.Store
historyFile *storage.HistoryFile historyFile *storage.HistoryFile
} }
// NewHistoryService creates a new history service
func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *HistoryService { func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *HistoryService {
return &HistoryService{ return &HistoryService{
store: st, store: st,
...@@ -21,7 +19,6 @@ func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *Histo ...@@ -21,7 +19,6 @@ func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *Histo
} }
} }
// LoadHistory loads history from file into store
func (hs *HistoryService) LoadHistory() error { func (hs *HistoryService) LoadHistory() error {
entries, err := hs.historyFile.GetAll() entries, err := hs.historyFile.GetAll()
if err != nil { if err != nil {
...@@ -35,7 +32,6 @@ func (hs *HistoryService) LoadHistory() error { ...@@ -35,7 +32,6 @@ func (hs *HistoryService) LoadHistory() error {
return nil return nil
} }
// RecordUpdate records a new update in history
func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error { func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error {
if err := hs.historyFile.Insert(entry); err != nil { if err := hs.historyFile.Insert(entry); err != nil {
log.Printf("Failed to save history: %v", err) log.Printf("Failed to save history: %v", err)
...@@ -48,12 +44,10 @@ func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error { ...@@ -48,12 +44,10 @@ func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error {
return nil return nil
} }
// GetHistory returns all history entries
func (hs *HistoryService) GetHistory() ([]model.HistoryEntry, error) { func (hs *HistoryService) GetHistory() ([]model.HistoryEntry, error) {
return hs.historyFile.GetAll() return hs.historyFile.GetAll()
} }
// GetRecent returns N most recent history entries
func (hs *HistoryService) GetRecent(limit int) ([]model.HistoryEntry, error) { func (hs *HistoryService) GetRecent(limit int) ([]model.HistoryEntry, error) {
return hs.historyFile.GetRecent(limit) return hs.historyFile.GetRecent(limit)
} }
...@@ -2,58 +2,47 @@ package service ...@@ -2,58 +2,47 @@ package service
import ( import (
"log" "log"
"os/exec"
"github.com/diamondburned/gotk4/pkg/gio/v2"
) )
// NotificationService handles system notifications type NotificationService struct {
type NotificationService struct{} app *gio.Application
}
// NewNotificationService creates a new notification service func NewNotificationService(app *gio.Application) *NotificationService {
func NewNotificationService() *NotificationService { return &NotificationService{app: app}
return &NotificationService{}
} }
// NotifyUpdatesAvailable sends a notification that updates are available
func (ns *NotificationService) NotifyUpdatesAvailable() { func (ns *NotificationService) NotifyUpdatesAvailable() {
// Use notify-send for system notifications n := gio.NewNotification("Доступны обновления системы")
cmd := exec.Command( n.SetBody("Для обновления нажмите на уведомление или откройте программу.")
"notify-send", n.SetIcon(gio.NewThemedIcon("system-software-update"))
"--app-name=Ximper System Updater",
"--icon=system-software-update", // Click on notification body → activate the app
"System Updates Available", n.SetDefaultAction("app.show-updates")
"Updates are available for your system. Click to open System Updater.",
) ns.app.SendNotification("updates-available", n)
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
} else {
log.Println("Notification sent: updates available") log.Println("Notification sent: updates available")
}
} }
// NotifyUpdateComplete sends a notification that update completed
func (ns *NotificationService) NotifyUpdateComplete(success bool) { func (ns *NotificationService) NotifyUpdateComplete(success bool) {
title := "System Update Complete" var title, body, icon string
message := "Your system has been successfully updated."
icon := "emblem-default"
if !success { if success {
title = "System Update Failed" title = "Обновление завершено"
message = "The system update encountered an error. Please check the logs." body = "Система успешно обновлена."
icon = "emblem-default"
} else {
title = "Ошибка обновления"
body = "При обновлении произошла ошибка. Проверьте журнал."
icon = "dialog-error" icon = "dialog-error"
} }
cmd := exec.Command( n := gio.NewNotification(title)
"notify-send", n.SetBody(body)
"--app-name=Ximper System Updater", n.SetIcon(gio.NewThemedIcon(icon))
"--icon="+icon,
title,
message,
)
if err := cmd.Run(); err != nil { ns.app.SendNotification("update-result", n)
log.Printf("Failed to send notification: %v", err)
} else {
log.Printf("Notification sent: update complete (success=%v)", success) log.Printf("Notification sent: update complete (success=%v)", success)
}
} }
...@@ -7,7 +7,6 @@ import ( ...@@ -7,7 +7,6 @@ import (
"time" "time"
) )
// SchedulerService handles scheduled update checks
type SchedulerService struct { type SchedulerService struct {
store *store.Store store *store.Store
updateSvc *UpdateService updateSvc *UpdateService
...@@ -17,7 +16,6 @@ type SchedulerService struct { ...@@ -17,7 +16,6 @@ type SchedulerService struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
// NewSchedulerService creates a new scheduler service
func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *NotificationService) *SchedulerService { func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *NotificationService) *SchedulerService {
return &SchedulerService{ return &SchedulerService{
store: st, store: st,
...@@ -26,7 +24,6 @@ func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *N ...@@ -26,7 +24,6 @@ func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *N
} }
} }
// Start starts the scheduler
func (ss *SchedulerService) Start() { func (ss *SchedulerService) Start() {
state := ss.store.GetState() state := ss.store.GetState()
settings := state.Settings settings := state.Settings
...@@ -46,7 +43,6 @@ func (ss *SchedulerService) Start() { ...@@ -46,7 +43,6 @@ func (ss *SchedulerService) Start() {
go ss.run() go ss.run()
} }
// Stop stops the scheduler
func (ss *SchedulerService) Stop() { func (ss *SchedulerService) Stop() {
if ss.cancel != nil { if ss.cancel != nil {
ss.cancel() ss.cancel()
...@@ -59,7 +55,6 @@ func (ss *SchedulerService) Stop() { ...@@ -59,7 +55,6 @@ func (ss *SchedulerService) Stop() {
log.Println("Scheduler stopped") log.Println("Scheduler stopped")
} }
// run is the main scheduler loop
func (ss *SchedulerService) run() { func (ss *SchedulerService) run() {
// Run immediately on start // Run immediately on start
ss.checkUpdates() ss.checkUpdates()
...@@ -74,7 +69,6 @@ func (ss *SchedulerService) run() { ...@@ -74,7 +69,6 @@ func (ss *SchedulerService) run() {
} }
} }
// checkUpdates performs scheduled update check
func (ss *SchedulerService) checkUpdates() { func (ss *SchedulerService) checkUpdates() {
log.Println("Scheduled update check started") log.Println("Scheduled update check started")
......
...@@ -11,14 +11,12 @@ import ( ...@@ -11,14 +11,12 @@ import (
"time" "time"
) )
// UpdateService orchestrates update operations
type UpdateService struct { type UpdateService struct {
store *store.Store store *store.Store
eepmClient *eepm.Client eepmClient *eepm.Client
history *HistoryService history *HistoryService
} }
// NewUpdateService creates a new update service
func NewUpdateService(st *store.Store, client *eepm.Client, history *HistoryService) *UpdateService { func NewUpdateService(st *store.Store, client *eepm.Client, history *HistoryService) *UpdateService {
return &UpdateService{ return &UpdateService{
store: st, store: st,
...@@ -27,12 +25,33 @@ func NewUpdateService(st *store.Store, client *eepm.Client, history *HistoryServ ...@@ -27,12 +25,33 @@ func NewUpdateService(st *store.Store, client *eepm.Client, history *HistoryServ
} }
} }
// CheckAllUpdates checks for updates in all categories (parallel)
func (us *UpdateService) CheckAllUpdates(ctx context.Context) error { func (us *UpdateService) CheckAllUpdates(ctx context.Context) error {
log.Println("Checking for updates in all categories...") log.Println("Checking for updates in all categories...")
us.store.Dispatch(&store.SetPhaseAction{Phase: store.PhaseLoading}) us.store.Dispatch(&store.SetPhaseAction{Phase: store.PhaseLoading})
err := us.doCheckAllUpdates(ctx)
if err != nil {
// Try reconnect and retry once
log.Printf("Update check failed, attempting reconnect: %v", err)
if reconnErr := us.eepmClient.Reconnect(); reconnErr != nil {
log.Printf("Reconnect failed: %v", reconnErr)
us.store.Dispatch(&store.SetErrorAction{Error: err.Error()})
return err
}
// Retry after reconnect
if err = us.doCheckAllUpdates(ctx); err != nil {
us.store.Dispatch(&store.SetErrorAction{Error: err.Error()})
return err
}
}
us.store.Dispatch(&store.SetPhaseAction{Phase: store.PhaseReady})
log.Println("Update check completed")
return nil
}
func (us *UpdateService) doCheckAllUpdates(ctx context.Context) error {
var wg sync.WaitGroup var wg sync.WaitGroup
var mu sync.Mutex var mu sync.Mutex
errors := []error{} errors := []error{}
...@@ -90,16 +109,12 @@ func (us *UpdateService) CheckAllUpdates(ctx context.Context) error { ...@@ -90,16 +109,12 @@ func (us *UpdateService) CheckAllUpdates(ctx context.Context) error {
log.Printf("Update check error: %v", err) log.Printf("Update check error: %v", err)
errMsg += "; " + err.Error() errMsg += "; " + err.Error()
} }
us.store.Dispatch(&store.SetErrorAction{Error: errMsg})
return fmt.Errorf("%s", errMsg) return fmt.Errorf("%s", errMsg)
} }
us.store.Dispatch(&store.SetPhaseAction{Phase: store.PhaseReady})
log.Println("Update check completed")
return nil return nil
} }
// RunUpdates executes updates for selected categories
func (us *UpdateService) RunUpdates(ctx context.Context) error { func (us *UpdateService) RunUpdates(ctx context.Context) error {
state := us.store.GetState() state := us.store.GetState()
...@@ -185,7 +200,6 @@ func (us *UpdateService) RunUpdates(ctx context.Context) error { ...@@ -185,7 +200,6 @@ func (us *UpdateService) RunUpdates(ctx context.Context) error {
return nil return nil
} }
// runCategoryUpdate runs update for a specific category
func (us *UpdateService) runCategoryUpdate(ctx context.Context, category string, transaction string) error { func (us *UpdateService) runCategoryUpdate(ctx context.Context, category string, transaction string) error {
log.Printf("Updating %s...", category) log.Printf("Updating %s...", category)
...@@ -201,8 +215,10 @@ func (us *UpdateService) runCategoryUpdate(ctx context.Context, category string, ...@@ -201,8 +215,10 @@ func (us *UpdateService) runCategoryUpdate(ctx context.Context, category string,
} }
} }
// handleProgress processes progress events
func (us *UpdateService) handleProgress(ctx context.Context, ch chan model.EventData, done chan struct{}) { func (us *UpdateService) handleProgress(ctx context.Context, ch chan model.EventData, done chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
...@@ -216,6 +232,16 @@ func (us *UpdateService) handleProgress(ctx context.Context, ch chan model.Event ...@@ -216,6 +232,16 @@ func (us *UpdateService) handleProgress(ctx context.Context, ch chan model.Event
Message: event.Message, Message: event.Message,
EventType: event.EventType, EventType: event.EventType,
}) })
case <-ticker.C:
// Check if signals stopped arriving (D-Bus may have died)
if us.eepmClient.IsSignalTimedOut() {
log.Println("Signal timeout detected, attempting D-Bus reconnect...")
if err := us.eepmClient.Reconnect(); err != nil {
log.Printf("D-Bus reconnect failed: %v", err)
} else {
log.Println("D-Bus reconnected, signal listener restarted")
}
}
} }
} }
} }
...@@ -5,12 +5,10 @@ import ( ...@@ -5,12 +5,10 @@ import (
"time" "time"
) )
// Action represents a state modification action
type Action interface { type Action interface {
Apply(state *State) error Apply(state *State) error
} }
// SetPhaseAction sets the application phase
type SetPhaseAction struct { type SetPhaseAction struct {
Phase AppPhase Phase AppPhase
} }
...@@ -20,7 +18,6 @@ func (a *SetPhaseAction) Apply(state *State) error { ...@@ -20,7 +18,6 @@ func (a *SetPhaseAction) Apply(state *State) error {
return nil return nil
} }
// LoadSystemUpdatesAction loads system updates into state
type LoadSystemUpdatesAction struct { type LoadSystemUpdatesAction struct {
Changes model.PackageChanges Changes model.PackageChanges
} }
...@@ -35,7 +32,6 @@ func (a *LoadSystemUpdatesAction) Apply(state *State) error { ...@@ -35,7 +32,6 @@ func (a *LoadSystemUpdatesAction) Apply(state *State) error {
return nil return nil
} }
// LoadKernelUpdatesAction loads kernel updates into state
type LoadKernelUpdatesAction struct { type LoadKernelUpdatesAction struct {
Info model.KernelUpdateInfo Info model.KernelUpdateInfo
} }
...@@ -50,7 +46,6 @@ func (a *LoadKernelUpdatesAction) Apply(state *State) error { ...@@ -50,7 +46,6 @@ func (a *LoadKernelUpdatesAction) Apply(state *State) error {
return nil return nil
} }
// LoadPlayUpdatesAction loads play apps updates into state
type LoadPlayUpdatesAction struct { type LoadPlayUpdatesAction struct {
Info model.PlayUpdateInfo Info model.PlayUpdateInfo
} }
...@@ -74,7 +69,6 @@ func (a *LoadPlayUpdatesAction) Apply(state *State) error { ...@@ -74,7 +69,6 @@ func (a *LoadPlayUpdatesAction) Apply(state *State) error {
return nil return nil
} }
// ToggleCategoryAction toggles a category's enabled status
type ToggleCategoryAction struct { type ToggleCategoryAction struct {
Category string // "System", "Kernel", "Play" Category string // "System", "Kernel", "Play"
Enabled bool Enabled bool
...@@ -92,7 +86,6 @@ func (a *ToggleCategoryAction) Apply(state *State) error { ...@@ -92,7 +86,6 @@ func (a *ToggleCategoryAction) Apply(state *State) error {
return nil return nil
} }
// StartUpdateAction starts an update operation
type StartUpdateAction struct { type StartUpdateAction struct {
Categories []string Categories []string
Transaction string Transaction string
...@@ -111,7 +104,6 @@ func (a *StartUpdateAction) Apply(state *State) error { ...@@ -111,7 +104,6 @@ func (a *StartUpdateAction) Apply(state *State) error {
return nil return nil
} }
// UpdateProgressAction updates progress of active update
type UpdateProgressAction struct { type UpdateProgressAction struct {
Transaction string Transaction string
Progress float64 Progress float64
...@@ -131,7 +123,6 @@ func (a *UpdateProgressAction) Apply(state *State) error { ...@@ -131,7 +123,6 @@ func (a *UpdateProgressAction) Apply(state *State) error {
return nil return nil
} }
// FinishUpdateAction finishes an update operation
type FinishUpdateAction struct { type FinishUpdateAction struct {
Success bool Success bool
Error string Error string
...@@ -148,7 +139,6 @@ func (a *FinishUpdateAction) Apply(state *State) error { ...@@ -148,7 +139,6 @@ func (a *FinishUpdateAction) Apply(state *State) error {
return nil return nil
} }
// AddHistoryAction adds a history entry
type AddHistoryAction struct { type AddHistoryAction struct {
Entry model.HistoryEntry Entry model.HistoryEntry
} }
...@@ -164,7 +154,6 @@ func (a *AddHistoryAction) Apply(state *State) error { ...@@ -164,7 +154,6 @@ func (a *AddHistoryAction) Apply(state *State) error {
return nil return nil
} }
// LoadHistoryAction loads history from storage
type LoadHistoryAction struct { type LoadHistoryAction struct {
Entries []model.HistoryEntry Entries []model.HistoryEntry
} }
...@@ -174,7 +163,6 @@ func (a *LoadHistoryAction) Apply(state *State) error { ...@@ -174,7 +163,6 @@ func (a *LoadHistoryAction) Apply(state *State) error {
return nil return nil
} }
// UpdateSettingsAction updates application settings
type UpdateSettingsAction struct { type UpdateSettingsAction struct {
Settings AppSettings Settings AppSettings
} }
...@@ -184,7 +172,6 @@ func (a *UpdateSettingsAction) Apply(state *State) error { ...@@ -184,7 +172,6 @@ func (a *UpdateSettingsAction) Apply(state *State) error {
return nil return nil
} }
// SetErrorAction sets an error state
type SetErrorAction struct { type SetErrorAction struct {
Error string Error string
} }
......
package store package store
// Selectors provide convenient read access to state
// HasAnyUpdates returns true if any category has updates available
func HasAnyUpdates(state *State) bool { func HasAnyUpdates(state *State) bool {
return state.SystemUpdates.Available || return state.SystemUpdates.Available ||
state.KernelUpdates.Available || state.KernelUpdates.Available ||
state.PlayUpdates.Available state.PlayUpdates.Available
} }
// GetEnabledCategories returns list of enabled category names
func GetEnabledCategories(state *State) []string { func GetEnabledCategories(state *State) []string {
categories := []string{} categories := []string{}
...@@ -26,7 +22,6 @@ func GetEnabledCategories(state *State) []string { ...@@ -26,7 +22,6 @@ func GetEnabledCategories(state *State) []string {
return categories return categories
} }
// CanStartUpdate returns true if update can be started
func CanStartUpdate(state *State) bool { func CanStartUpdate(state *State) bool {
if state.Phase == PhaseUpdating { if state.Phase == PhaseUpdating {
return false return false
...@@ -35,12 +30,10 @@ func CanStartUpdate(state *State) bool { ...@@ -35,12 +30,10 @@ func CanStartUpdate(state *State) bool {
return len(GetEnabledCategories(state)) > 0 return len(GetEnabledCategories(state)) > 0
} }
// IsUpdating returns true if update is in progress
func IsUpdating(state *State) bool { func IsUpdating(state *State) bool {
return state.Phase == PhaseUpdating && state.ActiveUpdate != nil return state.Phase == PhaseUpdating && state.ActiveUpdate != nil
} }
// GetTotalDownloadSize returns total download size for enabled categories
func GetTotalDownloadSize(state *State) uint64 { func GetTotalDownloadSize(state *State) uint64 {
var total uint64 var total uint64
......
...@@ -2,7 +2,6 @@ package store ...@@ -2,7 +2,6 @@ package store
import "SystemUpdater/model" import "SystemUpdater/model"
// AppPhase represents the current application phase
type AppPhase int type AppPhase int
const ( const (
...@@ -27,14 +26,12 @@ func (ap AppPhase) String() string { ...@@ -27,14 +26,12 @@ func (ap AppPhase) String() string {
} }
} }
// AppSettings holds application settings
type AppSettings struct { type AppSettings struct {
AutoCheckEnabled bool AutoCheckEnabled bool
CheckInterval int // in hours CheckInterval int // in hours
LastAutoCheck string // RFC3339 timestamp LastAutoCheck string // RFC3339 timestamp
} }
// State represents the complete application state
type State struct { type State struct {
Phase AppPhase Phase AppPhase
...@@ -56,7 +53,6 @@ type State struct { ...@@ -56,7 +53,6 @@ type State struct {
LastError string LastError string
} }
// NewState creates a new initial state
func NewState() *State { func NewState() *State {
return &State{ return &State{
Phase: PhaseLoading, Phase: PhaseLoading,
......
...@@ -5,7 +5,6 @@ import ( ...@@ -5,7 +5,6 @@ import (
"sync" "sync"
) )
// StateChangeType represents type of state change
type StateChangeType string type StateChangeType string
const ( const (
...@@ -22,13 +21,11 @@ const ( ...@@ -22,13 +21,11 @@ const (
ChangeError StateChangeType = "ERROR" ChangeError StateChangeType = "ERROR"
) )
// StateChange represents a change in application state
type StateChange struct { type StateChange struct {
Type StateChangeType Type StateChangeType
Data interface{} Data interface{}
} }
// Store manages application state with thread-safety
type Store struct { type Store struct {
mu sync.RWMutex mu sync.RWMutex
state *State state *State
...@@ -42,7 +39,6 @@ type Store struct { ...@@ -42,7 +39,6 @@ type Store struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
// NewStore creates a new Store
func NewStore() *Store { func NewStore() *Store {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
...@@ -54,7 +50,6 @@ func NewStore() *Store { ...@@ -54,7 +50,6 @@ func NewStore() *Store {
} }
} }
// GetState returns a copy of current state (thread-safe read)
func (s *Store) GetState() State { func (s *Store) GetState() State {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
...@@ -63,7 +58,6 @@ func (s *Store) GetState() State { ...@@ -63,7 +58,6 @@ func (s *Store) GetState() State {
return *s.state return *s.state
} }
// Dispatch applies an action to state and notifies subscribers
func (s *Store) Dispatch(action Action) error { func (s *Store) Dispatch(action Action) error {
s.mu.Lock() s.mu.Lock()
err := action.Apply(s.state) err := action.Apply(s.state)
...@@ -85,7 +79,6 @@ func (s *Store) Dispatch(action Action) error { ...@@ -85,7 +79,6 @@ func (s *Store) Dispatch(action Action) error {
return nil return nil
} }
// Subscribe adds a subscriber channel
func (s *Store) Subscribe(ch chan StateChange) { func (s *Store) Subscribe(ch chan StateChange) {
s.subscribersMu.Lock() s.subscribersMu.Lock()
defer s.subscribersMu.Unlock() defer s.subscribersMu.Unlock()
...@@ -93,7 +86,6 @@ func (s *Store) Subscribe(ch chan StateChange) { ...@@ -93,7 +86,6 @@ func (s *Store) Subscribe(ch chan StateChange) {
s.subscribers = append(s.subscribers, ch) s.subscribers = append(s.subscribers, ch)
} }
// Unsubscribe removes a subscriber channel
func (s *Store) Unsubscribe(ch chan StateChange) { func (s *Store) Unsubscribe(ch chan StateChange) {
s.subscribersMu.Lock() s.subscribersMu.Lock()
defer s.subscribersMu.Unlock() defer s.subscribersMu.Unlock()
...@@ -106,7 +98,6 @@ func (s *Store) Unsubscribe(ch chan StateChange) { ...@@ -106,7 +98,6 @@ func (s *Store) Unsubscribe(ch chan StateChange) {
} }
} }
// notifySubscribers sends state change to all subscribers
func (s *Store) notifySubscribers(change StateChange) { func (s *Store) notifySubscribers(change StateChange) {
s.subscribersMu.RLock() s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock() defer s.subscribersMu.RUnlock()
...@@ -124,7 +115,6 @@ func (s *Store) notifySubscribers(change StateChange) { ...@@ -124,7 +115,6 @@ func (s *Store) notifySubscribers(change StateChange) {
} }
} }
// actionToChangeType maps action type to change type
func (s *Store) actionToChangeType(action Action) StateChangeType { func (s *Store) actionToChangeType(action Action) StateChangeType {
switch action.(type) { switch action.(type) {
case *SetPhaseAction: case *SetPhaseAction:
...@@ -154,7 +144,6 @@ func (s *Store) actionToChangeType(action Action) StateChangeType { ...@@ -154,7 +144,6 @@ func (s *Store) actionToChangeType(action Action) StateChangeType {
} }
} }
// Close closes the store and cancels all operations
func (s *Store) Close() { func (s *Store) Close() {
s.cancel() s.cancel()
} }
<?xml version='1.0' encoding='UTF-8' standalone='no'?>
<!DOCTYPE cambalache-project SYSTEM "cambalache-project.dtd">
<cambalache-project version="0.94.0" target_tk="gtk-4.0">
<ui>
(1,None,"window.ui","window.ui",None,None,None,None,"ximper-system-updater",None,None),
(2,None,None,"listpage.ui",None,None,None,None,"ximper-system-updater",None,None),
(5,None,"process-page.ui","process-page.ui",None,None,None,None,None,None,None)
</ui>
<ui_library>
(1,"gio","2.0",None),
(1,"gtk","4.0",None),
(1,"libadwaita","1.6",None),
(5,"gtk","4.0",None)
</ui_library>
<object>
(1,1,"AdwApplicationWindow","main_window",None,None,None,None,0,None,None),
(1,2,"AdwToolbarView",None,29,None,None,None,0,None,None),
(1,3,"GtkStack","main_stack",2,None,None,None,0,None,None),
(1,4,"GtkStackPage",None,3,None,None,None,0,None,None),
(1,5,"AdwToolbarView",None,4,None,None,None,0,None,None),
(1,6,"GtkBox",None,5,None,None,None,0,None,None),
(1,13,"GtkScrolledWindow",None,6,None,None,None,0,None,None),
(1,14,"AdwClamp",None,13,None,None,None,0,None,None),
(1,15,"GtkListBox","updates_listbox",14,None,None,None,0,None,None),
(1,16,"GtkCenterBox",None,5,None,"bottom",None,1,None,None),
(1,17,"AdwClamp",None,16,None,None,None,0,None,None),
(1,18,"GtkListBox",None,17,None,None,None,0,None,None),
(1,19,"AdwButtonRow","apply_button",18,None,None,None,0,None,None),
(1,20,"GtkStackPage",None,3,None,None,None,1,None,None),
(1,21,"AdwStatusPage","status_page",20,None,None,None,0,None,None),
(1,22,"AdwSpinner",None,21,None,None,None,0,None,None),
(1,23,"AdwHeaderBar","header_bar",2,None,"top",None,1,None,None),
(1,24,"GtkButton",None,23,None,"end",None,0,None,None),
(1,25,"(menu)","primary_menu",None,None,None,None,1,None,None),
(1,26,"(section)",None,25,None,None,None,0,None,None),
(1,27,"(item)",None,26,None,None,None,0,None,None),
(1,28,"AdwNavigationView","navigationv",1,None,None,None,0,None,None),
(1,29,"AdwNavigationPage",None,28,None,None,None,0,None,None),
(2,1,"AdwNavigationPage","listpage",None,None,None,None,0,None,None),
(2,2,"AdwToolbarView",None,1,None,None,None,0,None,None),
(2,5,"AdwToolbarView",None,2,None,None,None,2,None,None),
(2,6,"GtkBox",None,5,None,None,None,0,None,None),
(2,13,"GtkScrolledWindow",None,6,None,None,None,0,None,None),
(2,14,"AdwClamp",None,13,None,None,None,0,None,None),
(2,15,"GtkListBox","updates_listbox",14,None,None,None,0,None,None),
(2,16,"GtkCenterBox",None,5,None,"bottom",None,1,None,None),
(2,17,"AdwClamp",None,16,None,None,None,0,None,None),
(2,18,"GtkListBox",None,17,None,None,None,0,None,None),
(2,19,"AdwButtonRow","apply_button",18,None,None,None,0,None,None),
(2,23,"AdwHeaderBar","header_bar",2,None,"top",None,1,None,None),
(2,24,"GtkButton",None,23,None,"end",None,0,None,None),
(5,1,"AdwToolbarView","main_view",18,None,None,None,0,None,None),
(5,2,"AdwHeaderBar",None,1,None,"top",None,0,None,None),
(5,3,"AdwWindowTitle","window_title",2,None,None,None,0,None,None),
(5,4,"GtkScrolledWindow",None,1,None,None,None,1,None,None),
(5,5,"GtkStack","process_stack",4,None,None,None,0,None,None),
(5,6,"GtkStackPage",None,5,None,None,None,0,None,None),
(5,7,"AdwStatusPage","status_page",6,None,None,None,0,None,None),
(5,8,"AdwClamp",None,7,None,None,None,0,None,None),
(5,9,"GtkBox",None,8,None,None,None,0,None,None),
(5,10,"GtkListBox",None,9,None,None,None,1,None,None),
(5,11,"AdwExpanderRow",None,10,None,None,None,0,None,None),
(5,12,"GtkScrolledWindow",None,11,None,None,None,0,None,None),
(5,13,"GtkTextView","log_view",12,None,None,None,0,None,None),
(5,15,"GtkStackPage",None,5,None,None,None,1,None,None),
(5,16,"AdwStatusPage",None,15,None,None,None,0,None,None),
(5,17,"GtkButton","restart_button",16,None,None,None,0,None,None),
(5,18,"AdwNavigationPage","process_page",None,None,None,None,0,None,None),
(5,19,"GtkProgressBar","progress_bar",9,None,None,None,0,None,None)
</object>
<object_property>
(1,1,"GtkWidget","height-request","294",0,None,None,None,None,None,None,None,None),
(1,1,"GtkWidget","width-request","360",0,None,None,None,None,None,None,None,None),
(1,1,"GtkWindow","default-height","489",0,None,None,None,None,None,None,None,None),
(1,1,"GtkWindow","default-width","300",0,None,None,None,None,None,None,None,None),
(1,2,"AdwToolbarView","content",None,0,None,None,None,3,None,None,None,None),
(1,2,"AdwToolbarView","top-bar-style","raised",0,None,None,None,None,None,None,None,None),
(1,3,"GtkStack","transition-type","crossfade",0,None,None,None,None,None,None,None,None),
(1,4,"GtkStackPage","child",None,0,None,None,None,5,None,None,None,None),
(1,4,"GtkStackPage","name","main",0,None,None,None,None,None,None,None,None),
(1,5,"AdwToolbarView","content",None,0,None,None,None,6,None,None,None,None),
(1,6,"GtkOrientable","orientation","vertical",0,None,None,None,None,None,None,None,None),
(1,13,"GtkScrolledWindow","hscrollbar-policy","never",0,None,None,None,None,None,None,None,None),
(1,13,"GtkScrolledWindow","propagate-natural-height","true",0,None,None,None,None,None,None,None,None),
(1,14,"GtkWidget","margin-bottom","12",0,None,None,None,None,None,None,None,None),
(1,14,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(1,14,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(1,14,"GtkWidget","margin-top","12",0,None,None,None,None,None,None,None,None),
(1,15,"GtkListBox","selection-mode","none",0,None,None,None,None,None,None,None,None),
(1,16,"GtkCenterBox","center-widget",None,0,None,None,None,17,None,None,None,None),
(1,16,"GtkWidget","halign","center",0,None,None,None,None,None,None,None,None),
(1,16,"GtkWidget","margin-bottom","12",0,None,None,None,None,None,None,None,None),
(1,16,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(1,16,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(1,16,"GtkWidget","margin-top","12",0,None,None,None,None,None,None,None,None),
(1,17,"AdwClamp","maximum-size","500",0,None,None,None,None,None,None,None,None),
(1,17,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(1,17,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(1,19,"AdwPreferencesRow","title","Update",1,None,None,None,None,None,None,None,None),
(1,20,"GtkStackPage","child",None,0,None,None,None,21,None,None,None,None),
(1,20,"GtkStackPage","name","loading",0,None,None,None,None,None,None,None,None),
(1,21,"AdwStatusPage","child",None,0,None,None,None,22,None,None,None,None),
(1,21,"AdwStatusPage","title","Loading…",1,None,None,None,None,None,None,None,None),
(1,22,"GtkWidget","halign","center",0,None,None,None,None,None,None,None,None),
(1,22,"GtkWidget","height-request","80",0,None,None,None,None,None,None,None,None),
(1,22,"GtkWidget","valign","start",0,None,None,None,None,None,None,None,None),
(1,22,"GtkWidget","width-request","80",0,None,None,None,None,None,None,None,None),
(1,24,"GtkActionable","action-name","app.about",0,None,None,None,None,None,None,None,None),
(1,24,"GtkButton","icon-name","help-about-symbolic",0,None,None,None,None,None,None,None,None),
(1,27,"(item)","action","app.about",0,None,None,None,None,None,None,None,None),
(1,27,"(item)","label","_About Eepm-play-gui",1,None,None,None,None,None,None,None,None),
(2,2,"AdwToolbarView","top-bar-style","raised",0,None,None,None,None,None,None,None,None),
(2,5,"AdwToolbarView","content",None,0,None,None,None,6,None,None,None,None),
(2,6,"GtkOrientable","orientation","vertical",0,None,None,None,None,None,None,None,None),
(2,13,"GtkScrolledWindow","hscrollbar-policy","never",0,None,None,None,None,None,None,None,None),
(2,13,"GtkScrolledWindow","propagate-natural-height","true",0,None,None,None,None,None,None,None,None),
(2,14,"GtkWidget","margin-bottom","12",0,None,None,None,None,None,None,None,None),
(2,14,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(2,14,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(2,14,"GtkWidget","margin-top","12",0,None,None,None,None,None,None,None,None),
(2,15,"GtkListBox","selection-mode","none",0,None,None,None,None,None,None,None,None),
(2,16,"GtkCenterBox","center-widget",None,0,None,None,None,17,None,None,None,None),
(2,16,"GtkWidget","halign","center",0,None,None,None,None,None,None,None,None),
(2,16,"GtkWidget","margin-bottom","12",0,None,None,None,None,None,None,None,None),
(2,16,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(2,16,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(2,16,"GtkWidget","margin-top","12",0,None,None,None,None,None,None,None,None),
(2,17,"AdwClamp","maximum-size","500",0,None,None,None,None,None,None,None,None),
(2,17,"GtkWidget","margin-end","12",0,None,None,None,None,None,None,None,None),
(2,17,"GtkWidget","margin-start","12",0,None,None,None,None,None,None,None,None),
(2,19,"AdwPreferencesRow","title","Update",1,None,None,None,None,None,None,None,None),
(2,24,"GtkActionable","action-name","app.about",0,None,None,None,None,None,None,None,None),
(2,24,"GtkButton","icon-name","help-about-symbolic",0,None,None,None,None,None,None,None,None),
(5,1,"AdwToolbarView","content",None,0,None,None,None,4,None,None,None,None),
(5,2,"AdwHeaderBar","title-widget",None,0,None,None,None,3,None,None,None,None),
(5,3,"AdwWindowTitle","title","Updating",1,None,None,None,None,None,None,None,None),
(5,4,"GtkScrolledWindow","hscrollbar-policy","never",0,None,None,None,None,None,None,None,None),
(5,4,"GtkScrolledWindow","propagate-natural-height","true",0,None,None,None,None,None,None,None,None),
(5,6,"GtkStackPage","child",None,0,None,None,None,7,None,None,None,None),
(5,6,"GtkStackPage","name","default",0,None,None,None,None,None,None,None,None),
(5,7,"AdwStatusPage","title","The update process is underway...",1,None,None,None,None,None,None,None,None),
(5,8,"AdwClamp","maximum-size","500",0,None,None,None,None,None,None,None,None),
(5,9,"GtkBox","spacing","12",0,None,None,None,None,None,None,None,None),
(5,9,"GtkOrientable","orientation","vertical",0,None,None,None,None,None,None,None,None),
(5,10,"GtkListBox","selection-mode","none",0,None,None,None,None,None,None,None,None),
(5,11,"AdwPreferencesRow","title","Show more information",1,None,None,None,None,None,None,None,None),
(5,12,"GtkScrolledWindow","hscrollbar-policy","never",0,None,None,None,None,None,None,None,None),
(5,12,"GtkScrolledWindow","propagate-natural-height","true",0,None,None,None,None,None,None,None,None),
(5,12,"GtkWidget","height-request","300",0,None,None,None,None,None,None,None,None),
(5,13,"GtkTextView","cursor-visible","false",0,None,None,None,None,None,None,None,None),
(5,13,"GtkTextView","editable","false",0,None,None,None,None,None,None,None,None),
(5,13,"GtkTextView","wrap-mode","word-char",0,None,None,None,None,None,None,None,None),
(5,15,"GtkStackPage","child",None,0,None,None,None,16,None,None,None,None),
(5,15,"GtkStackPage","name","finish",0,None,None,None,None,None,None,None,None),
(5,16,"AdwStatusPage","description","Click on button below to restart device",1,None,None,None,None,None,None,None,None),
(5,16,"AdwStatusPage","icon-name","face-smile-big-symbolic",0,None,None,None,None,None,None,None,None),
(5,16,"AdwStatusPage","title","Updating complete!",1,None,None,None,None,None,None,None,None),
(5,17,"GtkButton","label","Reboot",1,None,None,None,None,None,None,None,None),
(5,17,"GtkWidget","halign","center",0,None,None,None,None,None,None,None,None),
(5,17,"GtkWidget","hexpand","false",0,None,None,None,None,None,None,None,None)
</object_property>
<object_data>
(1,15,"GtkWidget",1,1,None,None,None,None,None,None),
(1,15,"GtkWidget",2,2,None,1,None,None,None,None),
(1,19,"GtkWidget",1,1,None,None,None,None,None,None),
(1,19,"GtkWidget",2,2,None,1,None,None,None,None),
(1,18,"GtkWidget",1,1,None,None,None,None,None,None),
(1,18,"GtkWidget",2,2,None,1,None,None,None,None),
(2,15,"GtkWidget",1,1,None,None,None,None,None,None),
(2,15,"GtkWidget",2,2,None,1,None,None,None,None),
(2,19,"GtkWidget",2,2,None,1,None,None,None,None),
(2,19,"GtkWidget",1,1,None,None,None,None,None,None),
(2,18,"GtkWidget",2,2,None,1,None,None,None,None),
(2,18,"GtkWidget",1,1,None,None,None,None,None,None),
(5,10,"GtkWidget",1,1,None,None,None,None,None,None),
(5,10,"GtkWidget",2,2,None,1,None,None,None,None),
(5,17,"GtkWidget",1,1,None,None,None,None,None,None),
(5,17,"GtkWidget",2,2,None,1,None,None,None,None),
(5,17,"GtkWidget",2,3,None,1,None,None,None,None)
</object_data>
<object_data_arg>
(1,15,"GtkWidget",2,2,"name","boxed-list-separate"),
(1,19,"GtkWidget",2,2,"name","suggested-action"),
(1,18,"GtkWidget",2,2,"name","boxed-list"),
(2,15,"GtkWidget",2,2,"name","boxed-list-separate"),
(2,19,"GtkWidget",2,2,"name","suggested-action"),
(2,18,"GtkWidget",2,2,"name","boxed-list"),
(5,10,"GtkWidget",2,2,"name","boxed-list"),
(5,17,"GtkWidget",2,2,"name","pill"),
(5,17,"GtkWidget",2,3,"name","suggested-action")
</object_data_arg>
</cambalache-project>
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
}
using Gtk 4.0;
using Adw 1;
Adw.ActionRow category_row {
activatable: true;
[suffix]
Image {
icon-name: "go-next-symbolic";
}
}
package components package components
import ( import (
_ "embed"
"fmt"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model" "SystemUpdater/model"
"SystemUpdater/store" "SystemUpdater/store"
"fmt"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// CategoryRow represents a category update row with navigation //go:generate blueprint-compiler compile category-row.blp --output category-row.ui
//go:embed category-row.ui
var categoryRowUI string
type CategoryRow struct { type CategoryRow struct {
*adw.ActionRow Row *adw.ActionRow `gtk:"category_row"`
category string category string
st *store.Store st *store.Store
onNavigate func() onNavigate func()
} }
// NewCategoryRow creates a new category row
func NewCategoryRow(category string, st *store.Store, onNavigate func()) *CategoryRow { func NewCategoryRow(category string, st *store.Store, onNavigate func()) *CategoryRow {
row := adw.NewActionRow() b := gtksbuilder.New(categoryRowUI)
row.SetTitle(category)
row.SetActivatable(true)
chevron := gtk.NewImageFromIconName("go-next-symbolic")
row.AddSuffix(chevron)
cr := &CategoryRow{ cr := &CategoryRow{
ActionRow: row,
category: category, category: category,
st: st, st: st,
onNavigate: onNavigate, onNavigate: onNavigate,
} }
if err := gtksbuilder.Unmarshal(b, cr); err != nil {
panic(err)
}
cr.Row.SetTitle(category)
row.ConnectActivated(func() { cr.Row.ConnectActivated(func() {
if cr.onNavigate != nil { if cr.onNavigate != nil {
cr.onNavigate() cr.onNavigate()
} }
...@@ -42,24 +45,22 @@ func NewCategoryRow(category string, st *store.Store, onNavigate func()) *Catego ...@@ -42,24 +45,22 @@ func NewCategoryRow(category string, st *store.Store, onNavigate func()) *Catego
return cr return cr
} }
// Update updates the row with category data
func (cr *CategoryRow) Update(cat *model.UpdateCategory) { func (cr *CategoryRow) Update(cat *model.UpdateCategory) {
cr.SetTitle(cat.Name) cr.Row.SetTitle(cat.Name)
if cat.Available { if cat.Available {
subtitle := fmt.Sprintf("%d packages", len(cat.Packages)) subtitle := fmt.Sprintf("%d packages", len(cat.Packages))
if cat.DownloadSize > 0 { if cat.DownloadSize > 0 {
subtitle += fmt.Sprintf(", %s", formatBytes(cat.DownloadSize)) subtitle += fmt.Sprintf(", %s", formatBytes(cat.DownloadSize))
} }
cr.SetSubtitle(subtitle) cr.Row.SetSubtitle(subtitle)
cr.SetVisible(true) cr.Row.SetVisible(true)
cr.SetSensitive(true) cr.Row.SetSensitive(true)
} else { } else {
cr.SetVisible(false) cr.Row.SetVisible(false)
} }
} }
// formatBytes formats byte size to human readable string
func formatBytes(bytes uint64) string { func formatBytes(bytes uint64) string {
const unit = 1024 const unit = 1024
if bytes < unit { if bytes < unit {
......
using Gtk 4.0;
using Adw 1;
Adw.ActionRow package_row {
[prefix]
Image {
icon-name: "package-x-generic-symbolic";
}
}
package components package components
import ( import (
"SystemUpdater/model" _ "embed"
"fmt" "fmt"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// PackageRow represents a single package row //go:generate blueprint-compiler compile package-row.blp --output package-row.ui
//go:embed package-row.ui
var packageRowUI string
type PackageRow struct { type PackageRow struct {
*adw.ActionRow Row *adw.ActionRow `gtk:"package_row"`
} }
// NewPackageRow creates a new package row
func NewPackageRow(pkg model.Package) *PackageRow { func NewPackageRow(pkg model.Package) *PackageRow {
row := adw.NewActionRow() b := gtksbuilder.New(packageRowUI)
row.SetTitle(pkg.Name) pr := &PackageRow{}
if err := gtksbuilder.Unmarshal(b, pr); err != nil {
panic(err)
}
pr.Row.SetTitle(pkg.Name)
subtitle := "" subtitle := ""
if pkg.InstalledVersion != nil && *pkg.InstalledVersion != "" { if pkg.InstalledVersion != nil && *pkg.InstalledVersion != "" {
...@@ -31,26 +41,20 @@ func NewPackageRow(pkg model.Package) *PackageRow { ...@@ -31,26 +41,20 @@ func NewPackageRow(pkg model.Package) *PackageRow {
subtitle += fmt.Sprintf(" • %s", formatBytes(*pkg.Size)) subtitle += fmt.Sprintf(" • %s", formatBytes(*pkg.Size))
} }
row.SetSubtitle(subtitle) pr.Row.SetSubtitle(subtitle)
if pkg.Summary != nil && *pkg.Summary != "" { if pkg.Summary != nil && *pkg.Summary != "" {
row.SetTooltipText(*pkg.Summary) pr.Row.SetTooltipText(*pkg.Summary)
} }
icon := gtk.NewImageFromIconName("package-x-generic-symbolic") return pr
row.AddPrefix(icon)
return &PackageRow{ActionRow: row}
} }
// stripVersionTimestamp removes @timestamp suffix from version string
func stripVersionTimestamp(version string) string { func stripVersionTimestamp(version string) string {
if idx := len(version); idx > 0 { for i := range len(version) {
for i := 0; i < len(version); i++ {
if version[i] == '@' { if version[i] == '@' {
return version[:i] return version[:i]
} }
} }
}
return version return version
} }
using Gtk 4.0;
using Adw 1;
Adw.NavigationPage history_page {
title: _("History");
Adw.ToolbarView {
[top]
Adw.HeaderBar {}
content: ScrolledWindow {
vexpand: true;
Adw.Clamp {
margin-top: 12;
margin-bottom: 12;
margin-start: 12;
margin-end: 12;
ListBox history_listbox {
selection-mode: none;
styles [
"boxed-list",
]
}
}
};
}
}
package pages package pages
import ( import (
"SystemUpdater/model" _ "embed"
"fmt" "fmt"
"strings" "strings"
"time" "time"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// HistoryPage represents the history page //go:generate blueprint-compiler compile history.blp --output history.ui
//go:embed history.ui
var historyUI string
type HistoryPage struct { type HistoryPage struct {
*adw.NavigationPage NavigationPage *adw.NavigationPage `gtk:"history_page"`
listBox *gtk.ListBox listBox *gtk.ListBox `gtk:"history_listbox"`
} }
// NewHistoryPage creates a new history page
func NewHistoryPage() *HistoryPage { func NewHistoryPage() *HistoryPage {
toolbarView := adw.NewToolbarView() b := gtksbuilder.New(historyUI)
page := &HistoryPage{}
// Header bar if err := gtksbuilder.Unmarshal(b, page); err != nil {
headerBar := adw.NewHeaderBar() panic(err)
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,
} }
return page
} }
// Update updates the history list
func (p *HistoryPage) Update(entries []model.HistoryEntry) { func (p *HistoryPage) Update(entries []model.HistoryEntry) {
// Clear existing rows
for child := p.listBox.FirstChild(); child != nil; child = p.listBox.FirstChild() { for child := p.listBox.FirstChild(); child != nil; child = p.listBox.FirstChild() {
p.listBox.Remove(child) p.listBox.Remove(child)
} }
// Add entries in reverse order (newest first)
for i := len(entries) - 1; i >= 0; i-- { for i := len(entries) - 1; i >= 0; i-- {
entry := entries[i] row := p.createHistoryRow(entries[i])
row := p.createHistoryRow(entry)
p.listBox.Append(row) p.listBox.Append(row)
} }
} }
// createHistoryRow creates a row for a history entry
func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRow { func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRow {
row := adw.NewExpanderRow() row := adw.NewExpanderRow()
// Parse timestamp
timestamp, _ := time.Parse(time.RFC3339, entry.Timestamp) timestamp, _ := time.Parse(time.RFC3339, entry.Timestamp)
timeStr := timestamp.Format("Jan 02, 2006 15:04") row.SetTitle(timestamp.Format("Jan 02, 2006 15:04"))
// Title: timestamp
row.SetTitle(timeStr)
// Subtitle: categories and status
categoriesStr := strings.Join(entry.Categories, ", ") categoriesStr := strings.Join(entry.Categories, ", ")
status := "Success" status := "Success"
icon := "emblem-ok-symbolic" icon := "emblem-ok-symbolic"
...@@ -86,17 +58,11 @@ func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRo ...@@ -86,17 +58,11 @@ func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRo
icon = "dialog-error-symbolic" icon = "dialog-error-symbolic"
} }
subtitle := fmt.Sprintf("%s • %s • %d packages • %ds", row.SetSubtitle(fmt.Sprintf("%s • %s • %d packages • %ds",
categoriesStr, status, entry.PackagesUpdated, entry.DurationSeconds) categoriesStr, status, entry.PackagesUpdated, entry.DurationSeconds))
row.SetSubtitle(subtitle) row.AddPrefix(gtk.NewImageFromIconName(icon))
// Icon prefix
image := gtk.NewImageFromIconName(icon)
row.AddPrefix(image)
// Add log entries as child rows
if len(entry.Log) > 0 { if len(entry.Log) > 0 {
// Show first 5 log lines
logLines := entry.Log logLines := entry.Log
if len(logLines) > 5 { if len(logLines) > 5 {
logLines = logLines[:5] logLines = logLines[:5]
......
using Gtk 4.0;
using Adw 1;
Adw.StatusPage loading_page {
title: _("Loading…");
icon-name: "emblem-synchronizing-symbolic";
Adw.Spinner {
halign: center;
height-request: 80;
width-request: 80;
}
}
package pages package pages
import ( import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
) )
// LoadingPage represents the loading page //go:generate blueprint-compiler compile loading.blp --output loading.ui
//go:embed loading.ui
var loadingUI string
type LoadingPage struct { type LoadingPage struct {
*adw.StatusPage Page *adw.StatusPage `gtk:"loading_page"`
} }
// NewLoadingPage creates a new loading page
func NewLoadingPage() *LoadingPage { func NewLoadingPage() *LoadingPage {
page := adw.NewStatusPage() b := gtksbuilder.New(loadingUI)
page.SetTitle("Loading…") page := &LoadingPage{}
page.SetIconName("emblem-synchronizing-symbolic") if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
spinner := adw.NewSpinner() }
spinner.SetSizeRequest(80, 80) return page
page.SetChild(spinner)
return &LoadingPage{StatusPage: page}
} }
using Gtk 4.0;
using Adw 1;
Adw.StatusPage noupdates_page {
title: _("System is up to date");
icon-name: "emblem-ok-symbolic";
}
package pages package pages
import ( import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
) )
// NoUpdatesPage represents the no updates page //go:generate blueprint-compiler compile noupdates.blp --output noupdates.ui
//go:embed noupdates.ui
var noupdatesUI string
type NoUpdatesPage struct { type NoUpdatesPage struct {
*adw.StatusPage Page *adw.StatusPage `gtk:"noupdates_page"`
} }
// NewNoUpdatesPage creates a new no updates page
func NewNoUpdatesPage() *NoUpdatesPage { func NewNoUpdatesPage() *NoUpdatesPage {
page := adw.NewStatusPage() b := gtksbuilder.New(noupdatesUI)
page.SetTitle("System is up to date") page := &NoUpdatesPage{}
page.SetIconName("emblem-ok-symbolic") if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
return &NoUpdatesPage{StatusPage: page} }
return page
} }
using Gtk 4.0;
using Adw 1;
Adw.NavigationPage package_list_page {
Adw.ToolbarView {
[top]
Adw.HeaderBar {}
content: ScrolledWindow {
hscrollbar-policy: never;
vscrollbar-policy: automatic;
vexpand: true;
Adw.Clamp {
margin-top: 12;
margin-bottom: 12;
margin-start: 12;
margin-end: 12;
ListBox package_listbox {
selection-mode: none;
styles [
"boxed-list-separate",
]
}
}
};
}
}
package pages package pages
import ( import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model" "SystemUpdater/model"
"SystemUpdater/ui/components" "SystemUpdater/ui/components"
...@@ -8,49 +11,24 @@ import ( ...@@ -8,49 +11,24 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// PackageListPage represents a page with list of packages for a category //go:generate blueprint-compiler compile package-list.blp --output package-list.ui
//go:embed package-list.ui
var packageListUI string
type PackageListPage struct { type PackageListPage struct {
*adw.NavigationPage NavigationPage *adw.NavigationPage `gtk:"package_list_page"`
listBox *gtk.ListBox listBox *gtk.ListBox `gtk:"package_listbox"`
category string
} }
// NewPackageListPage creates a new package list page
func NewPackageListPage(category string, packages []model.Package) *PackageListPage { func NewPackageListPage(category string, packages []model.Package) *PackageListPage {
toolbarView := adw.NewToolbarView() b := gtksbuilder.New(packageListUI)
page := &PackageListPage{}
// Header bar if err := gtksbuilder.Unmarshal(b, page); err != nil {
headerBar := adw.NewHeaderBar() panic(err)
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.NavigationPage.SetTitle(category)
page.SetPackages(packages) page.SetPackages(packages)
return page return page
...@@ -62,7 +40,6 @@ func (p *PackageListPage) SetPackages(packages []model.Package) { ...@@ -62,7 +40,6 @@ func (p *PackageListPage) SetPackages(packages []model.Package) {
} }
for _, pkg := range packages { for _, pkg := range packages {
row := components.NewPackageRow(pkg) p.listBox.Append(components.NewPackageRow(pkg).Row)
p.listBox.Append(row)
} }
} }
using Gtk 4.0;
using Adw 1;
Adw.NavigationPage process_page {
Adw.ToolbarView main_view {
content: ScrolledWindow {
hscrollbar-policy: never;
propagate-natural-height: true;
Stack process_stack {
StackPage {
child: Adw.StatusPage status_page {
title: _("The update process is underway...");
Adw.Clamp {
maximum-size: 500;
Box {
orientation: vertical;
spacing: 12;
ProgressBar progress_bar {}
ListBox {
selection-mode: none;
Adw.ExpanderRow {
title: _("Show more information");
ScrolledWindow {
height-request: 300;
hscrollbar-policy: never;
propagate-natural-height: true;
TextView log_view {
cursor-visible: false;
editable: false;
wrap-mode: word_char;
}
}
}
styles [
"boxed-list",
]
}
}
}
};
name: "default";
}
StackPage {
child: Adw.StatusPage {
description: _("Click on button below to restart device");
icon-name: "face-smile-big-symbolic";
title: _("Updating complete!");
Button restart_button {
halign: center;
hexpand: false;
label: _("Reboot");
styles [
"pill",
"suggested-action",
]
}
};
name: "finish";
}
}
};
[top]
Adw.HeaderBar {
title-widget: Adw.WindowTitle window_title {
title: _("Updating");
};
}
}
}
<?xml version='1.0' encoding='UTF-8'?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Cambalache 0.94.1 --> <!--
DO NOT EDIT!
This file was @generated by blueprint-compiler. Instead, edit the
corresponding .blp file and regenerate this file with blueprint-compiler.
-->
<interface> <interface>
<!-- interface-name process-page.ui -->
<requires lib="gtk" version="4.0"/> <requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.4"/>
<object class="AdwNavigationPage" id="process_page"> <object class="AdwNavigationPage" id="process_page">
<child> <child>
<object class="AdwToolbarView" id="main_view"> <object class="AdwToolbarView" id="main_view">
<property name="content"> <property name="content">
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property> <property name="hscrollbar-policy">2</property>
<property name="propagate-natural-height">true</property> <property name="propagate-natural-height">true</property>
<child> <child>
<object class="GtkStack" id="process_stack"> <object class="GtkStack" id="process_stack">
...@@ -23,27 +25,27 @@ ...@@ -23,27 +25,27 @@
<property name="maximum-size">500</property> <property name="maximum-size">500</property>
<child> <child>
<object class="GtkBox"> <object class="GtkBox">
<property name="orientation">vertical</property> <property name="orientation">1</property>
<property name="spacing">12</property> <property name="spacing">12</property>
<child> <child>
<object class="GtkProgressBar" id="progress_bar"/> <object class="GtkProgressBar" id="progress_bar"></object>
</child> </child>
<child> <child>
<object class="GtkListBox"> <object class="GtkListBox">
<property name="selection-mode">none</property> <property name="selection-mode">0</property>
<child> <child>
<object class="AdwExpanderRow"> <object class="AdwExpanderRow">
<property name="title" translatable="yes">Show more information</property> <property name="title" translatable="yes">Show more information</property>
<child> <child>
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="height-request">300</property> <property name="height-request">300</property>
<property name="hscrollbar-policy">never</property> <property name="hscrollbar-policy">2</property>
<property name="propagate-natural-height">true</property> <property name="propagate-natural-height">true</property>
<child> <child>
<object class="GtkTextView" id="log_view"> <object class="GtkTextView" id="log_view">
<property name="cursor-visible">false</property> <property name="cursor-visible">false</property>
<property name="editable">false</property> <property name="editable">false</property>
<property name="wrap-mode">word-char</property> <property name="wrap-mode">3</property>
</object> </object>
</child> </child>
</object> </object>
...@@ -73,7 +75,7 @@ ...@@ -73,7 +75,7 @@
<property name="title" translatable="yes">Updating complete!</property> <property name="title" translatable="yes">Updating complete!</property>
<child> <child>
<object class="GtkButton" id="restart_button"> <object class="GtkButton" id="restart_button">
<property name="halign">center</property> <property name="halign">3</property>
<property name="hexpand">false</property> <property name="hexpand">false</property>
<property name="label" translatable="yes">Reboot</property> <property name="label" translatable="yes">Reboot</property>
<style> <style>
......
package pages package pages
import ( import (
_ "embed"
"SystemUpdater/model" "SystemUpdater/model"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
...@@ -9,26 +11,27 @@ import ( ...@@ -9,26 +11,27 @@ import (
gtksbuilder "SystemUpdater/lib/gtks/builder" gtksbuilder "SystemUpdater/lib/gtks/builder"
) )
//go:generate blueprint-compiler compile process-page.blp --output process-page.ui
//go:embed process-page.ui
var processPageUI string
type UpdateProcessPage struct { type UpdateProcessPage struct {
*adw.NavigationPage NavigationPage *adw.NavigationPage `gtk:"process_page"`
stack *gtk.Stack stack *gtk.Stack `gtk:"process_stack"`
statusPage *adw.StatusPage statusPage *adw.StatusPage `gtk:"status_page"`
progressBar *gtk.ProgressBar progressBar *gtk.ProgressBar `gtk:"progress_bar"`
logView *gtk.TextView logView *gtk.TextView `gtk:"log_view"`
logBuffer *gtk.TextBuffer logBuffer *gtk.TextBuffer
rebootBtn *gtk.Button rebootBtn *gtk.Button `gtk:"restart_button"`
} }
func NewUpdateProcessPage(onReboot func()) *UpdateProcessPage { func NewUpdateProcessPage(onReboot func()) *UpdateProcessPage {
b := gtksbuilder.NewBuilder("process-page.ui") b := gtksbuilder.New(processPageUI)
page := &UpdateProcessPage{ page := &UpdateProcessPage{}
NavigationPage: gtksbuilder.GetObject[*adw.NavigationPage](b, "process_page"), if err := gtksbuilder.Unmarshal(b, page); err != nil {
stack: gtksbuilder.GetObject[*gtk.Stack](b, "process_stack"), panic(err)
statusPage: gtksbuilder.GetObject[*adw.StatusPage](b, "status_page"),
progressBar: gtksbuilder.GetObject[*gtk.ProgressBar](b, "progress_bar"),
logView: gtksbuilder.GetObject[*gtk.TextView](b, "log_view"),
rebootBtn: gtksbuilder.GetObject[*gtk.Button](b, "restart_button"),
} }
page.logBuffer = page.logView.Buffer() page.logBuffer = page.logView.Buffer()
......
using Gtk 4.0;
using Adw 1;
Box updates_list_page {
orientation: vertical;
ScrolledWindow {
hscrollbar-policy: never;
vscrollbar-policy: automatic;
vexpand: true;
Adw.Clamp {
margin-top: 12;
margin-bottom: 12;
margin-start: 12;
margin-end: 12;
ListBox updates_listbox {
selection-mode: none;
styles [
"boxed-list-separate",
]
}
}
}
Adw.Clamp {
margin-top: 12;
margin-bottom: 12;
margin-start: 12;
margin-end: 12;
maximum-size: 500;
ListBox {
Adw.ButtonRow apply_button {
title: _("Update");
styles [
"suggested-action",
]
}
styles [
"boxed-list",
]
}
}
}
package pages package pages
import ( import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/store" "SystemUpdater/store"
"SystemUpdater/ui/components" "SystemUpdater/ui/components"
...@@ -8,86 +11,51 @@ import ( ...@@ -8,86 +11,51 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// UpdatesListPage represents the main updates list page //go:generate blueprint-compiler compile updates-list.blp --output updates-list.ui
//go:embed updates-list.ui
var updatesListUI string
type UpdatesListPage struct { type UpdatesListPage struct {
*gtk.Box Box *gtk.Box `gtk:"updates_list_page"`
st *store.Store listBox *gtk.ListBox `gtk:"updates_listbox"`
updateButton *adw.ButtonRow `gtk:"apply_button"`
listBox *gtk.ListBox st *store.Store
systemRow *components.CategoryRow systemRow *components.CategoryRow
kernelRow *components.CategoryRow kernelRow *components.CategoryRow
playRow *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 { func NewUpdatesListPage(st *store.Store, onUpdate func(), onNavigateToCategory func(category string)) *UpdatesListPage {
box := gtk.NewBox(gtk.OrientationVertical, 0) b := gtksbuilder.New(updatesListUI)
page := &UpdatesListPage{}
// Scrolled window with list if err := gtksbuilder.Unmarshal(b, page); err != nil {
scrolled := gtk.NewScrolledWindow() panic(err)
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{ page.st = st
Box: box, page.systemRow = components.NewCategoryRow("System", st, func() {
st: st,
listBox: listBox,
systemRow: components.NewCategoryRow("System", st, func() {
if onNavigateToCategory != nil { if onNavigateToCategory != nil {
onNavigateToCategory("System") onNavigateToCategory("System")
} }
}), })
kernelRow: components.NewCategoryRow("Kernel", st, func() { page.kernelRow = components.NewCategoryRow("Kernel", st, func() {
if onNavigateToCategory != nil { if onNavigateToCategory != nil {
onNavigateToCategory("Kernel") onNavigateToCategory("Kernel")
} }
}), })
playRow: components.NewCategoryRow("Play", st, func() { page.playRow = components.NewCategoryRow("Play", st, func() {
if onNavigateToCategory != nil { if onNavigateToCategory != nil {
onNavigateToCategory("Play") onNavigateToCategory("Play")
} }
}), })
updateButton: updateButton,
}
listBox.Append(page.systemRow) page.listBox.Append(page.systemRow.Row)
listBox.Append(page.kernelRow) page.listBox.Append(page.kernelRow.Row)
listBox.Append(page.playRow) page.listBox.Append(page.playRow.Row)
updateButton.ConnectActivated(func() { page.updateButton.ConnectActivated(func() {
if onUpdate != nil { if onUpdate != nil {
onUpdate() onUpdate()
} }
...@@ -103,6 +71,5 @@ func (p *UpdatesListPage) Update() { ...@@ -103,6 +71,5 @@ func (p *UpdatesListPage) Update() {
p.kernelRow.Update(state.KernelUpdates) p.kernelRow.Update(state.KernelUpdates)
p.playRow.Update(state.PlayUpdates) p.playRow.Update(state.PlayUpdates)
canUpdate := store.CanStartUpdate(&state) p.updateButton.SetSensitive(store.CanStartUpdate(&state))
p.updateButton.SetSensitive(canUpdate)
} }
using Gtk 4.0;
using Adw 1;
Adw.ApplicationWindow main_window {
default-width: 360;
default-height: 500;
title: _("System Updater");
Adw.NavigationView nav_view {
Adw.NavigationPage {
title: _("System Updater");
Adw.ToolbarView {
top-bar-style: raised;
[top]
Adw.HeaderBar header_bar {
[end]
Button history_button {
icon-name: "document-open-recent-symbolic";
tooltip-text: _("Update History");
}
[end]
Button about_button {
icon-name: "help-about-symbolic";
tooltip-text: _("About");
}
}
content: Stack main_stack {
transition-type: crossfade;
};
}
}
}
}
package ui package ui
import ( import (
_ "embed"
"context"
"fmt"
"log"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model" "SystemUpdater/model"
"SystemUpdater/service" "SystemUpdater/service"
"SystemUpdater/store" "SystemUpdater/store"
"SystemUpdater/ui/pages" "SystemUpdater/ui/pages"
"context"
"fmt"
"log"
"github.com/diamondburned/gotk4-adwaita/pkg/adw" "github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/glib/v2"
"github.com/diamondburned/gotk4/pkg/gtk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4"
) )
// Window is the main application window //go:generate blueprint-compiler compile window.blp --output window.ui
//go:embed window.ui
var windowUI string
type Window struct { type Window struct {
Window *adw.ApplicationWindow Window *adw.ApplicationWindow `gtk:"main_window"`
navView *adw.NavigationView `gtk:"nav_view"`
stack *gtk.Stack `gtk:"main_stack"`
headerBar *adw.HeaderBar `gtk:"header_bar"`
historyButton *gtk.Button `gtk:"history_button"`
aboutButton *gtk.Button `gtk:"about_button"`
st *store.Store st *store.Store
updateSvc *service.UpdateService updateSvc *service.UpdateService
historySvc *service.HistoryService historySvc *service.HistoryService
// State changes channel
stateChanges chan store.StateChange stateChanges chan store.StateChange
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
// UI components
navView *adw.NavigationView
stack *gtk.Stack
headerBar *adw.HeaderBar
historyButton *gtk.Button
// Pages
loadingPage *pages.LoadingPage loadingPage *pages.LoadingPage
noUpdatesPage *pages.NoUpdatesPage noUpdatesPage *pages.NoUpdatesPage
updatesPage *pages.UpdatesListPage updatesPage *pages.UpdatesListPage
...@@ -40,14 +45,10 @@ type Window struct { ...@@ -40,14 +45,10 @@ type Window struct {
historyPage *pages.HistoryPage historyPage *pages.HistoryPage
} }
// NewWindow creates a new main window
func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateService, historySvc *service.HistoryService) *Window { func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateService, historySvc *service.HistoryService) *Window {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
appWindow := adw.NewApplicationWindow(&app.Application)
w := &Window{ w := &Window{
Window: appWindow,
st: st, st: st,
updateSvc: updateSvc, updateSvc: updateSvc,
historySvc: historySvc, historySvc: historySvc,
...@@ -56,38 +57,12 @@ func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateS ...@@ -56,38 +57,12 @@ func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateS
cancel: cancel, cancel: cancel,
} }
w.Window.SetDefaultSize(360, 500) b := gtksbuilder.New(windowUI)
w.Window.SetTitle("System Updater") if err := gtksbuilder.Unmarshal(b, w); err != nil {
panic(err)
w.buildUI() }
w.connectSignals()
st.Subscribe(w.stateChanges)
go w.handleStateChanges()
return w
}
func (w *Window) buildUI() {
w.navView = adw.NewNavigationView()
toolbarView := adw.NewToolbarView()
toolbarView.SetTopBarStyle(adw.ToolbarRaised)
w.headerBar = adw.NewHeaderBar()
w.historyButton = gtk.NewButtonFromIconName("document-open-recent-symbolic")
w.historyButton.SetTooltipText("Update History")
w.headerBar.PackEnd(w.historyButton)
aboutButton := gtk.NewButtonFromIconName("help-about-symbolic")
aboutButton.SetTooltipText("About")
w.headerBar.PackEnd(aboutButton)
toolbarView.AddTopBar(w.headerBar)
w.stack = gtk.NewStack() w.Window.SetApplication(&app.Application)
w.stack.SetTransitionType(gtk.StackTransitionTypeCrossfade)
w.loadingPage = pages.NewLoadingPage() w.loadingPage = pages.NewLoadingPage()
w.noUpdatesPage = pages.NewNoUpdatesPage() w.noUpdatesPage = pages.NewNoUpdatesPage()
...@@ -95,40 +70,32 @@ func (w *Window) buildUI() { ...@@ -95,40 +70,32 @@ func (w *Window) buildUI() {
w.processPage = pages.NewUpdateProcessPage(w.onRebootClicked) w.processPage = pages.NewUpdateProcessPage(w.onRebootClicked)
w.historyPage = pages.NewHistoryPage() w.historyPage = pages.NewHistoryPage()
w.stack.AddNamed(w.loadingPage, "loading") w.stack.AddNamed(w.loadingPage.Page, "loading")
w.stack.AddNamed(w.noUpdatesPage, "noupdates") w.stack.AddNamed(w.noUpdatesPage.Page, "noupdates")
w.stack.AddNamed(w.updatesPage, "main") w.stack.AddNamed(w.updatesPage.Box, "main")
toolbarView.SetContent(w.stack)
mainPage := adw.NewNavigationPage(toolbarView, "System Updater")
w.navView.Add(mainPage)
w.Window.SetContent(w.navView)
w.stack.SetVisibleChildName("loading") w.stack.SetVisibleChildName("loading")
}
func (w *Window) connectSignals() {
w.historyButton.ConnectClicked(func() { w.historyButton.ConnectClicked(func() {
w.showHistory() w.showHistory()
}) })
// Window close
w.Window.ConnectCloseRequest(func() bool { w.Window.ConnectCloseRequest(func() bool {
w.Cleanup() w.Cleanup()
return false return false
}) })
st.Subscribe(w.stateChanges)
go w.handleStateChanges()
return w
} }
// handleStateChanges handles state changes from store
func (w *Window) handleStateChanges() { func (w *Window) handleStateChanges() {
for { for {
select { select {
case <-w.ctx.Done(): case <-w.ctx.Done():
return return
case change := <-w.stateChanges: case change := <-w.stateChanges:
// Update UI on main thread
glib.IdleAdd(func() { glib.IdleAdd(func() {
w.onStateChange(change) w.onStateChange(change)
}) })
...@@ -136,7 +103,6 @@ func (w *Window) handleStateChanges() { ...@@ -136,7 +103,6 @@ func (w *Window) handleStateChanges() {
} }
} }
// onStateChange handles a state change
func (w *Window) onStateChange(change store.StateChange) { func (w *Window) onStateChange(change store.StateChange) {
state := w.st.GetState() state := w.st.GetState()
...@@ -145,8 +111,6 @@ func (w *Window) onStateChange(change store.StateChange) { ...@@ -145,8 +111,6 @@ func (w *Window) onStateChange(change store.StateChange) {
w.updatePhase(state.Phase) w.updatePhase(state.Phase)
case store.ChangeSystemUpdates, store.ChangeKernelUpdates, store.ChangePlayUpdates: case store.ChangeSystemUpdates, store.ChangeKernelUpdates, store.ChangePlayUpdates:
// Only update the page content, don't change visibility yet
// Visibility will be changed when Phase becomes Ready
if state.Phase == store.PhaseReady { if state.Phase == store.PhaseReady {
w.updatesPage.Update() w.updatesPage.Update()
} }
...@@ -175,7 +139,6 @@ func (w *Window) onStateChange(change store.StateChange) { ...@@ -175,7 +139,6 @@ func (w *Window) onStateChange(change store.StateChange) {
} }
case store.ChangeHistory: case store.ChangeHistory:
// Refresh history page if visible
w.historyPage.Update(state.History) w.historyPage.Update(state.History)
case store.ChangeError: case store.ChangeError:
...@@ -183,7 +146,6 @@ func (w *Window) onStateChange(change store.StateChange) { ...@@ -183,7 +146,6 @@ func (w *Window) onStateChange(change store.StateChange) {
} }
} }
// updatePhase updates UI based on phase
func (w *Window) updatePhase(phase store.AppPhase) { func (w *Window) updatePhase(phase store.AppPhase) {
switch phase { switch phase {
case store.PhaseLoading: case store.PhaseLoading:
...@@ -199,12 +161,10 @@ func (w *Window) updatePhase(phase store.AppPhase) { ...@@ -199,12 +161,10 @@ func (w *Window) updatePhase(phase store.AppPhase) {
} }
} }
// onUpdateClicked handles update button click
func (w *Window) onUpdateClicked() { func (w *Window) onUpdateClicked() {
w.showUpdateDialog() w.showUpdateDialog()
} }
// showUpdateDialog shows dialog to select categories to update
func (w *Window) showUpdateDialog() { func (w *Window) showUpdateDialog() {
state := w.st.GetState() state := w.st.GetState()
...@@ -302,7 +262,6 @@ func (w *Window) runUpdate(categories []string) { ...@@ -302,7 +262,6 @@ func (w *Window) runUpdate(categories []string) {
} }
} }
// Run update in background
go func() { go func() {
if err := w.updateSvc.RunUpdates(w.ctx); err != nil { if err := w.updateSvc.RunUpdates(w.ctx); err != nil {
log.Printf("Update failed: %v", err) log.Printf("Update failed: %v", err)
...@@ -313,7 +272,6 @@ func (w *Window) runUpdate(categories []string) { ...@@ -313,7 +272,6 @@ func (w *Window) runUpdate(categories []string) {
}() }()
} }
// onRebootClicked handles reboot button click
func (w *Window) onRebootClicked() { func (w *Window) onRebootClicked() {
dialog := adw.NewMessageDialog( dialog := adw.NewMessageDialog(
nil, nil,
...@@ -327,7 +285,7 @@ func (w *Window) onRebootClicked() { ...@@ -327,7 +285,7 @@ func (w *Window) onRebootClicked() {
dialog.ConnectResponse(func(response string) { dialog.ConnectResponse(func(response string) {
if response == "reboot" { if response == "reboot" {
// TODO: Implement reboot via systemd or similar // TODO: reboot via logind
log.Println("Reboot requested") log.Println("Reboot requested")
} }
}) })
...@@ -335,14 +293,12 @@ func (w *Window) onRebootClicked() { ...@@ -335,14 +293,12 @@ func (w *Window) onRebootClicked() {
dialog.Present() dialog.Present()
} }
// showHistory shows the history page
func (w *Window) showHistory() { func (w *Window) showHistory() {
state := w.st.GetState() state := w.st.GetState()
w.historyPage.Update(state.History) w.historyPage.Update(state.History)
w.navView.Push(w.historyPage.NavigationPage) w.navView.Push(w.historyPage.NavigationPage)
} }
// onNavigateToCategory navigates to package list for a category
func (w *Window) onNavigateToCategory(category string) { func (w *Window) onNavigateToCategory(category string) {
state := w.st.GetState() state := w.st.GetState()
...@@ -353,15 +309,13 @@ func (w *Window) onNavigateToCategory(category string) { ...@@ -353,15 +309,13 @@ func (w *Window) onNavigateToCategory(category string) {
case "Kernel": case "Kernel":
packages = state.KernelUpdates.Packages packages = state.KernelUpdates.Packages
case "Play": case "Play":
// Play apps don't have detailed list yet packages = state.PlayUpdates.Packages
packages = []model.Package{}
} }
packageListPage := pages.NewPackageListPage(category, packages) packageListPage := pages.NewPackageListPage(category, packages)
w.navView.Push(packageListPage.NavigationPage) w.navView.Push(packageListPage.NavigationPage)
} }
// showError shows an error dialog
func (w *Window) showError(title, message string) { func (w *Window) showError(title, message string) {
dialog := adw.NewMessageDialog(nil, title, message) dialog := adw.NewMessageDialog(nil, title, message)
dialog.AddResponse("ok", "OK") dialog.AddResponse("ok", "OK")
...@@ -369,7 +323,6 @@ func (w *Window) showError(title, message string) { ...@@ -369,7 +323,6 @@ func (w *Window) showError(title, message string) {
dialog.Present() dialog.Present()
} }
// Cleanup cleans up resources
func (w *Window) Cleanup() { func (w *Window) Cleanup() {
w.cancel() w.cancel()
w.st.Unsubscribe(w.stateChanges) w.st.Unsubscribe(w.stateChanges)
......
<?xml version='1.0' encoding='UTF-8'?>
<!-- Created with Cambalache 0.94.1 -->
<interface domain="ximper-system-updater">
<!-- interface-name window.ui -->
<requires lib="gio" version="2.0"/>
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.6"/>
<object class="AdwApplicationWindow" id="main_window">
<property name="default-height">489</property>
<property name="default-width">300</property>
<property name="height-request">294</property>
<property name="width-request">360</property>
<child>
<object class="AdwNavigationView" id="navigationv">
<child>
<object class="AdwNavigationPage">
<child>
<object class="AdwToolbarView">
<property name="content">
<object class="GtkStack" id="main_stack">
<property name="transition-type">crossfade</property>
<child>
<object class="GtkStackPage">
<property name="child">
<object class="AdwToolbarView">
<property name="content">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="AdwClamp">
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
<child>
<object class="GtkListBox" id="updates_listbox">
<property name="selection-mode">none</property>
<style>
<class name="boxed-list-separate"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</property>
<child type="bottom">
<object class="GtkCenterBox">
<property name="center-widget">
<object class="AdwClamp">
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="maximum-size">500</property>
<child>
<object class="GtkListBox">
<child>
<object class="AdwButtonRow" id="apply_button">
<property name="title" translatable="yes">Update</property>
<style>
<class name="suggested-action"/>
</style>
</object>
</child>
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
</object>
</property>
<property name="halign">center</property>
<property name="margin-bottom">12</property>
<property name="margin-end">12</property>
<property name="margin-start">12</property>
<property name="margin-top">12</property>
</object>
</child>
</object>
</property>
<property name="name">main</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="child">
<object class="AdwStatusPage" id="status_page">
<property name="child">
<object class="AdwSpinner">
<property name="halign">center</property>
<property name="height-request">80</property>
<property name="valign">start</property>
<property name="width-request">80</property>
</object>
</property>
<property name="title" translatable="yes">Loading…</property>
</object>
</property>
<property name="name">loading</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="child">
<object class="AdwStatusPage">
<property name="icon-name">emblem-ok-symbolic</property>
<property name="title" translatable="yes">System is up to date</property>
</object>
</property>
<property name="name">noupdates</property>
</object>
</child>
</object>
</property>
<property name="top-bar-style">raised</property>
<child type="top">
<object class="AdwHeaderBar" id="header_bar">
<child type="end">
<object class="GtkButton">
<property name="action-name">app.about</property>
<property name="icon-name">help-about-symbolic</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
<menu id="primary_menu">
<section>
<item>
<attribute name="action">app.about</attribute>
<attribute name="label" translatable="yes">_About Eepm-play-gui</attribute>
</item>
</section>
</menu>
</interface>
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