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/
SystemUpdater
# Generated from .blp by blueprint-compiler
*.ui
......@@ -3,11 +3,11 @@ package dbus
import (
"context"
"log"
"sync"
"github.com/diamondburned/gotk4/pkg/gio/v2"
)
// EEPM D-Bus service constants
const (
ServiceName = "ru.etersoft.EPM"
ObjectPath = "/ru/etersoft/EPM"
......@@ -16,21 +16,51 @@ const (
SignalInterface = "ru.etersoft.EPM" // signals are emitted on base interface
)
// Connection holds D-Bus connection and proxies for EEPM service
type Connection struct {
Conn *gio.DBusConnection
QueryProxy *gio.DBusProxy
ManageProxy *gio.DBusProxy
}
var epmConn *Connection
var (
epmConn *Connection
connMu sync.Mutex
)
// GetConnection returns singleton connection to EEPM D-Bus service
func GetConnection() (*Connection, error) {
connMu.Lock()
defer connMu.Unlock()
if 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)
if err != nil {
return nil, err
......
......@@ -5,14 +5,18 @@ import (
"SystemUpdater/model"
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/diamondburned/gotk4/pkg/gio/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 {
conn *dbus.Connection
......@@ -21,12 +25,15 @@ type Client struct {
subscriptions map[string]chan model.EventData
subID uint
// Tracks last signal time for timeout detection
lastSignalMu sync.Mutex
lastSignal time.Time
// Lifecycle
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new EEPM client with signal listener
func NewClient() (*Client, error) {
conn, err := dbus.GetConnection()
if err != nil {
......@@ -49,7 +56,27 @@ func NewClient() (*Client, error) {
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() {
c.cancel()
......@@ -60,16 +87,15 @@ func (c *Client) Close() {
log.Println("EEPM Client closed")
}
// SubscribeProgress subscribes to progress events for a transaction
func (c *Client) SubscribeProgress(transaction string, ch chan model.EventData) {
c.signalMu.Lock()
defer c.signalMu.Unlock()
c.subscriptions[transaction] = ch
c.touchLastSignal() // reset timer on subscribe
log.Printf("Subscribed to progress for transaction: %s", transaction)
}
// UnsubscribeProgress unsubscribes from progress events
func (c *Client) UnsubscribeProgress(transaction string) {
c.signalMu.Lock()
defer c.signalMu.Unlock()
......@@ -78,7 +104,28 @@ func (c *Client) UnsubscribeProgress(transaction string) {
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() {
c.subID = c.conn.Conn.SignalSubscribe(
dbus.ServiceName,
......@@ -100,7 +147,6 @@ func (c *Client) listenSignals() {
}
}
// handleSignal processes incoming D-Bus signals
func (c *Client) handleSignal(
conn *gio.DBusConnection,
senderName string,
......@@ -109,6 +155,8 @@ func (c *Client) handleSignal(
signalName string,
parameters *glib.Variant,
) {
c.touchLastSignal()
// Parse JSON from first parameter
raw := parameters.ChildValue(0).String()
......
......@@ -11,7 +11,6 @@ import (
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
// CheckKernelUpdates checks for available kernel updates
func (c *Client) CheckKernelUpdates(ctx context.Context) (*model.KernelUpdateInfo, error) {
resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1)
......@@ -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 {
errorCh := make(chan error, 1)
......
......@@ -11,7 +11,6 @@ import (
"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) {
resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1)
......@@ -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
func (c *Client) RunPlayUpdate(ctx context.Context, transaction string) error {
errorCh := make(chan error, 1)
......
......@@ -11,7 +11,6 @@ import (
"github.com/diamondburned/gotk4/pkg/glib/v2"
)
// CheckSystemUpdates checks for available system updates
func (c *Client) CheckSystemUpdates(ctx context.Context) (*model.PackageChanges, error) {
resultCh := make(chan *glib.Variant, 1)
errorCh := make(chan error, 1)
......@@ -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 {
errorCh := make(chan error, 1)
......
......@@ -2,45 +2,38 @@ package eepm
import "SystemUpdater/model"
// API Response wrapper matching eepm-dbus-rs ApiResponse<T>
type ApiResponse[T any] struct {
Data T `json:"data"`
Error bool `json:"error"`
Message *string `json:"message"`
}
// InfoResponse for package info
type InfoResponse struct {
Message string `json:"message"`
Package model.Package `json:"package"`
}
// CheckResponse for check-upgrade, check-install, check-remove
type CheckResponse struct {
Message string `json:"message"`
Info model.PackageChanges `json:"info"`
}
// UpgradeResponse for upgrade operations
type UpgradeResponse struct {
Message string `json:"message"`
Info model.PackageChanges `json:"info"`
}
// KernelUpdateResponse for kernel operations
type KernelUpdateResponse struct {
Message string `json:"message"`
Info model.KernelUpdateInfo `json:"info"`
}
// PlayListUpdatesResponse for play list-updates
type PlayListUpdatesResponse struct {
Message string `json:"message"`
Apps []model.PlayUpdateApp `json:"apps"`
TotalCount uint32 `json:"total_count"`
}
// Root response types (wrapped in ApiResponse)
type InfoRootResponse = ApiResponse[InfoResponse]
type CheckUpgradeRootResponse = ApiResponse[CheckResponse]
type UpgradeRootResponse = ApiResponse[UpgradeResponse]
......
package gtksbuilder
import (
"fmt"
"reflect"
"unsafe"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
......@@ -22,14 +26,51 @@ func NewBuilder(path string) *gtk.Builder {
return builder
}
func GetObject[T any](builder *gtk.Builder, name string) T {
return builder.GetObject(name).Cast().(T)
}
// Unmarshal fills struct fields from a GTK Builder using `gtk:"object_id"` tags.
//
// 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()
for i := range t.NumField() {
field := t.Field(i)
tag := field.Tag.Get("gtk")
if tag == "" || tag == "-" {
continue
}
func GetObjects[T any](builder *gtk.Builder, names ...string) map[string]T {
objects := make(map[string]T)
for _, name := range names {
objects[name] = GetObject[T](builder, name)
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 objects
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
import (
"SystemUpdater/backend/eepm"
_ "SystemUpdater/lib/gtks/logging"
"SystemUpdater/pkg/storage"
"SystemUpdater/service"
"SystemUpdater/store"
......@@ -16,9 +17,6 @@ import (
)
func main() {
// Disable debug logging for GTK
glib.LogSetDebugEnabled(false)
st := store.NewStore()
defer st.Close()
......@@ -45,7 +43,9 @@ func main() {
}
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)
updateSvc := service.NewUpdateService(st, eepmClient, historySvc)
schedulerSvc := service.NewSchedulerService(st, updateSvc, notificationSvc)
......@@ -54,7 +54,15 @@ func main() {
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() {
window := ui.NewWindow(app, st, updateSvc, historySvc)
......@@ -70,16 +78,17 @@ func main() {
app.ConnectCommandLine(func(cmdLine *gio.ApplicationCommandLine) int {
args := cmdLine.Arguments()
background := false
for _, arg := range args {
if arg == "--background" {
log.Println("Running in background mode")
app.Hold()
schedulerSvc.Start()
return 0
background = true
}
}
app.Activate()
if !background {
app.Activate()
}
return 0
})
......
package model
// HistoryEntry represents a single update history record
type HistoryEntry struct {
ID int `yaml:"id"`
Timestamp string `yaml:"timestamp"` // RFC3339
......
package model
// Package represents a package in EEPM responses
type Package struct {
Name string `json:"name"`
Version string `json:"version"`
......@@ -19,7 +18,6 @@ type Package struct {
InstalledVersion *string `json:"installed_version"`
}
// PackageChanges represents changes from check/simulate operations
type PackageChanges struct {
Install []Package `json:"install"`
Remove []Package `json:"remove"`
......@@ -34,7 +32,6 @@ type PackageChanges struct {
InstallSize int64 `json:"install_size"`
}
// HasChanges returns true if there are any package changes
func (pc *PackageChanges) HasChanges() bool {
return len(pc.Upgrade) > 0 || len(pc.Install) > 0 || len(pc.Remove) > 0 || len(pc.Downgrade) > 0
}
package model
// EventData represents progress event from D-Bus signal
type EventData struct {
Name string `json:"name"`
Message string `json:"message"`
......
package model
// UpdateCategory represents a category of updates (System, Kernel, Play)
type UpdateCategory struct {
Name string
Enabled bool // User can toggle this category
......@@ -11,7 +10,6 @@ type UpdateCategory struct {
LastChecked string // RFC3339 timestamp
}
// KernelUpdateInfo represents kernel update information
type KernelUpdateInfo struct {
RunningKernel string
AvailableKernel string
......@@ -26,14 +24,12 @@ type KernelUpdateInfo struct {
UpToDate bool
}
// PlayUpdateApp represents a play app with an available update
type PlayUpdateApp struct {
Name string `json:"name"`
InstalledVersion string `json:"installed_version"`
AvailableVersion string `json:"available_version"`
}
// PlayUpdateInfo represents play apps update information
type PlayUpdateInfo struct {
Available bool
Message string
......@@ -41,7 +37,6 @@ type PlayUpdateInfo struct {
TotalCount uint32
}
// ActiveUpdate represents an ongoing update operation
type ActiveUpdate struct {
Categories []string // Which categories are being updated
Transaction string
......
......@@ -9,18 +9,15 @@ import (
"gopkg.in/yaml.v3"
)
// HistoryFile manages history storage in YAML format
type HistoryFile struct {
path string
mu sync.RWMutex
}
// HistoryData represents the YAML file structure
type HistoryData struct {
Entries []model.HistoryEntry `yaml:"entries"`
}
// NewHistoryFile creates a new history file manager
func NewHistoryFile() (*HistoryFile, error) {
homeDir := os.Getenv("HOME")
if homeDir == "" {
......@@ -37,7 +34,6 @@ func NewHistoryFile() (*HistoryFile, error) {
return &HistoryFile{path: path}, nil
}
// Insert adds a new history entry
func (hf *HistoryFile) Insert(entry model.HistoryEntry) error {
hf.mu.Lock()
defer hf.mu.Unlock()
......@@ -61,7 +57,6 @@ func (hf *HistoryFile) Insert(entry model.HistoryEntry) error {
return hf.writeAllUnsafe(data)
}
// GetAll returns all history entries
func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) {
hf.mu.RLock()
defer hf.mu.RUnlock()
......@@ -70,7 +65,6 @@ func (hf *HistoryFile) GetAll() ([]model.HistoryEntry, error) {
return data.Entries, nil
}
// GetRecent returns N most recent entries
func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) {
hf.mu.RLock()
defer hf.mu.RUnlock()
......@@ -84,7 +78,6 @@ func (hf *HistoryFile) GetRecent(limit int) ([]model.HistoryEntry, error) {
return data.Entries[len(data.Entries)-limit:], nil
}
// readAllUnsafe reads all data without locking (internal use)
func (hf *HistoryFile) readAllUnsafe() HistoryData {
file, err := os.ReadFile(hf.path)
if err != nil {
......@@ -101,7 +94,6 @@ func (hf *HistoryFile) readAllUnsafe() HistoryData {
return data
}
// writeAllUnsafe writes all data without locking (internal use)
func (hf *HistoryFile) writeAllUnsafe(data HistoryData) error {
bytes, err := yaml.Marshal(data)
if err != nil {
......
......@@ -9,13 +9,11 @@ import (
"gopkg.in/yaml.v3"
)
// SettingsFile manages application settings
type SettingsFile struct {
path string
mu sync.RWMutex
}
// NewSettingsFile creates a new settings file manager
func NewSettingsFile() (*SettingsFile, error) {
homeDir := os.Getenv("HOME")
if homeDir == "" {
......@@ -32,7 +30,6 @@ func NewSettingsFile() (*SettingsFile, error) {
return &SettingsFile{path: path}, nil
}
// Load loads settings from file
func (sf *SettingsFile) Load() (store.AppSettings, error) {
sf.mu.RLock()
defer sf.mu.RUnlock()
......@@ -41,8 +38,8 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) {
if err != nil {
// File doesn't exist, return defaults
return store.AppSettings{
AutoCheckEnabled: false,
CheckInterval: 24,
AutoCheckEnabled: true,
CheckInterval: 1,
}, nil
}
......@@ -50,15 +47,14 @@ func (sf *SettingsFile) Load() (store.AppSettings, error) {
if err := yaml.Unmarshal(file, &settings); err != nil {
// Corrupted file, return defaults
return store.AppSettings{
AutoCheckEnabled: false,
CheckInterval: 24,
AutoCheckEnabled: true,
CheckInterval: 1,
}, nil
}
return settings, nil
}
// Save saves settings to file
func (sf *SettingsFile) Save(settings store.AppSettings) error {
sf.mu.Lock()
defer sf.mu.Unlock()
......
......@@ -7,13 +7,11 @@ import (
"log"
)
// HistoryService manages update history
type HistoryService struct {
store *store.Store
historyFile *storage.HistoryFile
}
// NewHistoryService creates a new history service
func NewHistoryService(st *store.Store, historyFile *storage.HistoryFile) *HistoryService {
return &HistoryService{
store: st,
......@@ -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 {
entries, err := hs.historyFile.GetAll()
if err != nil {
......@@ -35,7 +32,6 @@ func (hs *HistoryService) LoadHistory() error {
return nil
}
// RecordUpdate records a new update in history
func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error {
if err := hs.historyFile.Insert(entry); err != nil {
log.Printf("Failed to save history: %v", err)
......@@ -48,12 +44,10 @@ func (hs *HistoryService) RecordUpdate(entry model.HistoryEntry) error {
return nil
}
// GetHistory returns all history entries
func (hs *HistoryService) GetHistory() ([]model.HistoryEntry, error) {
return hs.historyFile.GetAll()
}
// GetRecent returns N most recent history entries
func (hs *HistoryService) GetRecent(limit int) ([]model.HistoryEntry, error) {
return hs.historyFile.GetRecent(limit)
}
......@@ -2,58 +2,47 @@ package service
import (
"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() *NotificationService {
return &NotificationService{}
func NewNotificationService(app *gio.Application) *NotificationService {
return &NotificationService{app: app}
}
// NotifyUpdatesAvailable sends a notification that updates are available
func (ns *NotificationService) NotifyUpdatesAvailable() {
// Use notify-send for system notifications
cmd := exec.Command(
"notify-send",
"--app-name=Ximper System Updater",
"--icon=system-software-update",
"System Updates Available",
"Updates are available for your system. Click to open System Updater.",
)
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
} else {
log.Println("Notification sent: updates available")
}
n := gio.NewNotification("Доступны обновления системы")
n.SetBody("Для обновления нажмите на уведомление или откройте программу.")
n.SetIcon(gio.NewThemedIcon("system-software-update"))
// Click on notification body → activate the app
n.SetDefaultAction("app.show-updates")
ns.app.SendNotification("updates-available", n)
log.Println("Notification sent: updates available")
}
// NotifyUpdateComplete sends a notification that update completed
func (ns *NotificationService) NotifyUpdateComplete(success bool) {
title := "System Update Complete"
message := "Your system has been successfully updated."
icon := "emblem-default"
var title, body, icon string
if !success {
title = "System Update Failed"
message = "The system update encountered an error. Please check the logs."
if success {
title = "Обновление завершено"
body = "Система успешно обновлена."
icon = "emblem-default"
} else {
title = "Ошибка обновления"
body = "При обновлении произошла ошибка. Проверьте журнал."
icon = "dialog-error"
}
cmd := exec.Command(
"notify-send",
"--app-name=Ximper System Updater",
"--icon="+icon,
title,
message,
)
n := gio.NewNotification(title)
n.SetBody(body)
n.SetIcon(gio.NewThemedIcon(icon))
if err := cmd.Run(); err != nil {
log.Printf("Failed to send notification: %v", err)
} else {
log.Printf("Notification sent: update complete (success=%v)", success)
}
ns.app.SendNotification("update-result", n)
log.Printf("Notification sent: update complete (success=%v)", success)
}
......@@ -7,7 +7,6 @@ import (
"time"
)
// SchedulerService handles scheduled update checks
type SchedulerService struct {
store *store.Store
updateSvc *UpdateService
......@@ -17,7 +16,6 @@ type SchedulerService struct {
cancel context.CancelFunc
}
// NewSchedulerService creates a new scheduler service
func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *NotificationService) *SchedulerService {
return &SchedulerService{
store: st,
......@@ -26,7 +24,6 @@ func NewSchedulerService(st *store.Store, updateSvc *UpdateService, notifySvc *N
}
}
// Start starts the scheduler
func (ss *SchedulerService) Start() {
state := ss.store.GetState()
settings := state.Settings
......@@ -46,7 +43,6 @@ func (ss *SchedulerService) Start() {
go ss.run()
}
// Stop stops the scheduler
func (ss *SchedulerService) Stop() {
if ss.cancel != nil {
ss.cancel()
......@@ -59,7 +55,6 @@ func (ss *SchedulerService) Stop() {
log.Println("Scheduler stopped")
}
// run is the main scheduler loop
func (ss *SchedulerService) run() {
// Run immediately on start
ss.checkUpdates()
......@@ -74,7 +69,6 @@ func (ss *SchedulerService) run() {
}
}
// checkUpdates performs scheduled update check
func (ss *SchedulerService) checkUpdates() {
log.Println("Scheduled update check started")
......
......@@ -11,14 +11,12 @@ import (
"time"
)
// UpdateService orchestrates update operations
type UpdateService struct {
store *store.Store
eepmClient *eepm.Client
history *HistoryService
}
// NewUpdateService creates a new update service
func NewUpdateService(st *store.Store, client *eepm.Client, history *HistoryService) *UpdateService {
return &UpdateService{
store: st,
......@@ -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 {
log.Println("Checking for updates in all categories...")
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 mu sync.Mutex
errors := []error{}
......@@ -90,16 +109,12 @@ func (us *UpdateService) CheckAllUpdates(ctx context.Context) error {
log.Printf("Update check error: %v", err)
errMsg += "; " + err.Error()
}
us.store.Dispatch(&store.SetErrorAction{Error: errMsg})
return fmt.Errorf("%s", errMsg)
}
us.store.Dispatch(&store.SetPhaseAction{Phase: store.PhaseReady})
log.Println("Update check completed")
return nil
}
// RunUpdates executes updates for selected categories
func (us *UpdateService) RunUpdates(ctx context.Context) error {
state := us.store.GetState()
......@@ -185,7 +200,6 @@ func (us *UpdateService) RunUpdates(ctx context.Context) error {
return nil
}
// runCategoryUpdate runs update for a specific category
func (us *UpdateService) runCategoryUpdate(ctx context.Context, category string, transaction string) error {
log.Printf("Updating %s...", category)
......@@ -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{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
......@@ -216,6 +232,16 @@ func (us *UpdateService) handleProgress(ctx context.Context, ch chan model.Event
Message: event.Message,
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 (
"time"
)
// Action represents a state modification action
type Action interface {
Apply(state *State) error
}
// SetPhaseAction sets the application phase
type SetPhaseAction struct {
Phase AppPhase
}
......@@ -20,7 +18,6 @@ func (a *SetPhaseAction) Apply(state *State) error {
return nil
}
// LoadSystemUpdatesAction loads system updates into state
type LoadSystemUpdatesAction struct {
Changes model.PackageChanges
}
......@@ -35,7 +32,6 @@ func (a *LoadSystemUpdatesAction) Apply(state *State) error {
return nil
}
// LoadKernelUpdatesAction loads kernel updates into state
type LoadKernelUpdatesAction struct {
Info model.KernelUpdateInfo
}
......@@ -50,7 +46,6 @@ func (a *LoadKernelUpdatesAction) Apply(state *State) error {
return nil
}
// LoadPlayUpdatesAction loads play apps updates into state
type LoadPlayUpdatesAction struct {
Info model.PlayUpdateInfo
}
......@@ -74,7 +69,6 @@ func (a *LoadPlayUpdatesAction) Apply(state *State) error {
return nil
}
// ToggleCategoryAction toggles a category's enabled status
type ToggleCategoryAction struct {
Category string // "System", "Kernel", "Play"
Enabled bool
......@@ -92,7 +86,6 @@ func (a *ToggleCategoryAction) Apply(state *State) error {
return nil
}
// StartUpdateAction starts an update operation
type StartUpdateAction struct {
Categories []string
Transaction string
......@@ -111,7 +104,6 @@ func (a *StartUpdateAction) Apply(state *State) error {
return nil
}
// UpdateProgressAction updates progress of active update
type UpdateProgressAction struct {
Transaction string
Progress float64
......@@ -131,7 +123,6 @@ func (a *UpdateProgressAction) Apply(state *State) error {
return nil
}
// FinishUpdateAction finishes an update operation
type FinishUpdateAction struct {
Success bool
Error string
......@@ -148,7 +139,6 @@ func (a *FinishUpdateAction) Apply(state *State) error {
return nil
}
// AddHistoryAction adds a history entry
type AddHistoryAction struct {
Entry model.HistoryEntry
}
......@@ -164,7 +154,6 @@ func (a *AddHistoryAction) Apply(state *State) error {
return nil
}
// LoadHistoryAction loads history from storage
type LoadHistoryAction struct {
Entries []model.HistoryEntry
}
......@@ -174,7 +163,6 @@ func (a *LoadHistoryAction) Apply(state *State) error {
return nil
}
// UpdateSettingsAction updates application settings
type UpdateSettingsAction struct {
Settings AppSettings
}
......@@ -184,7 +172,6 @@ func (a *UpdateSettingsAction) Apply(state *State) error {
return nil
}
// SetErrorAction sets an error state
type SetErrorAction struct {
Error string
}
......
package store
// Selectors provide convenient read access to state
// HasAnyUpdates returns true if any category has updates available
func HasAnyUpdates(state *State) bool {
return state.SystemUpdates.Available ||
state.KernelUpdates.Available ||
state.PlayUpdates.Available
}
// GetEnabledCategories returns list of enabled category names
func GetEnabledCategories(state *State) []string {
categories := []string{}
......@@ -26,7 +22,6 @@ func GetEnabledCategories(state *State) []string {
return categories
}
// CanStartUpdate returns true if update can be started
func CanStartUpdate(state *State) bool {
if state.Phase == PhaseUpdating {
return false
......@@ -35,12 +30,10 @@ func CanStartUpdate(state *State) bool {
return len(GetEnabledCategories(state)) > 0
}
// IsUpdating returns true if update is in progress
func IsUpdating(state *State) bool {
return state.Phase == PhaseUpdating && state.ActiveUpdate != nil
}
// GetTotalDownloadSize returns total download size for enabled categories
func GetTotalDownloadSize(state *State) uint64 {
var total uint64
......
......@@ -2,7 +2,6 @@ package store
import "SystemUpdater/model"
// AppPhase represents the current application phase
type AppPhase int
const (
......@@ -27,14 +26,12 @@ func (ap AppPhase) String() string {
}
}
// AppSettings holds application settings
type AppSettings struct {
AutoCheckEnabled bool
CheckInterval int // in hours
LastAutoCheck string // RFC3339 timestamp
}
// State represents the complete application state
type State struct {
Phase AppPhase
......@@ -56,7 +53,6 @@ type State struct {
LastError string
}
// NewState creates a new initial state
func NewState() *State {
return &State{
Phase: PhaseLoading,
......
......@@ -5,7 +5,6 @@ import (
"sync"
)
// StateChangeType represents type of state change
type StateChangeType string
const (
......@@ -22,13 +21,11 @@ const (
ChangeError StateChangeType = "ERROR"
)
// StateChange represents a change in application state
type StateChange struct {
Type StateChangeType
Data interface{}
}
// Store manages application state with thread-safety
type Store struct {
mu sync.RWMutex
state *State
......@@ -42,7 +39,6 @@ type Store struct {
cancel context.CancelFunc
}
// NewStore creates a new Store
func NewStore() *Store {
ctx, cancel := context.WithCancel(context.Background())
......@@ -54,7 +50,6 @@ func NewStore() *Store {
}
}
// GetState returns a copy of current state (thread-safe read)
func (s *Store) GetState() State {
s.mu.RLock()
defer s.mu.RUnlock()
......@@ -63,7 +58,6 @@ func (s *Store) GetState() State {
return *s.state
}
// Dispatch applies an action to state and notifies subscribers
func (s *Store) Dispatch(action Action) error {
s.mu.Lock()
err := action.Apply(s.state)
......@@ -85,7 +79,6 @@ func (s *Store) Dispatch(action Action) error {
return nil
}
// Subscribe adds a subscriber channel
func (s *Store) Subscribe(ch chan StateChange) {
s.subscribersMu.Lock()
defer s.subscribersMu.Unlock()
......@@ -93,7 +86,6 @@ func (s *Store) Subscribe(ch chan StateChange) {
s.subscribers = append(s.subscribers, ch)
}
// Unsubscribe removes a subscriber channel
func (s *Store) Unsubscribe(ch chan StateChange) {
s.subscribersMu.Lock()
defer s.subscribersMu.Unlock()
......@@ -106,7 +98,6 @@ func (s *Store) Unsubscribe(ch chan StateChange) {
}
}
// notifySubscribers sends state change to all subscribers
func (s *Store) notifySubscribers(change StateChange) {
s.subscribersMu.RLock()
defer s.subscribersMu.RUnlock()
......@@ -124,7 +115,6 @@ func (s *Store) notifySubscribers(change StateChange) {
}
}
// actionToChangeType maps action type to change type
func (s *Store) actionToChangeType(action Action) StateChangeType {
switch action.(type) {
case *SetPhaseAction:
......@@ -154,7 +144,6 @@ func (s *Store) actionToChangeType(action Action) StateChangeType {
}
}
// Close closes the store and cancels all operations
func (s *Store) Close() {
s.cancel()
}
This diff is collapsed. Click to expand it.
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
import (
_ "embed"
"fmt"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"SystemUpdater/store"
"fmt"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// CategoryRow represents a category update row with navigation
//go:generate blueprint-compiler compile category-row.blp --output category-row.ui
//go:embed category-row.ui
var categoryRowUI string
type CategoryRow struct {
*adw.ActionRow
category string
st *store.Store
Row *adw.ActionRow `gtk:"category_row"`
category string
st *store.Store
onNavigate func()
}
// NewCategoryRow creates a new category row
func NewCategoryRow(category string, st *store.Store, onNavigate func()) *CategoryRow {
row := adw.NewActionRow()
row.SetTitle(category)
row.SetActivatable(true)
chevron := gtk.NewImageFromIconName("go-next-symbolic")
row.AddSuffix(chevron)
b := gtksbuilder.New(categoryRowUI)
cr := &CategoryRow{
ActionRow: row,
category: category,
st: st,
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 {
cr.onNavigate()
}
......@@ -42,24 +45,22 @@ func NewCategoryRow(category string, st *store.Store, onNavigate func()) *Catego
return cr
}
// Update updates the row with category data
func (cr *CategoryRow) Update(cat *model.UpdateCategory) {
cr.SetTitle(cat.Name)
cr.Row.SetTitle(cat.Name)
if cat.Available {
subtitle := fmt.Sprintf("%d packages", len(cat.Packages))
if cat.DownloadSize > 0 {
subtitle += fmt.Sprintf(", %s", formatBytes(cat.DownloadSize))
}
cr.SetSubtitle(subtitle)
cr.SetVisible(true)
cr.SetSensitive(true)
cr.Row.SetSubtitle(subtitle)
cr.Row.SetVisible(true)
cr.Row.SetSensitive(true)
} else {
cr.SetVisible(false)
cr.Row.SetVisible(false)
}
}
// formatBytes formats byte size to human readable string
func formatBytes(bytes uint64) string {
const unit = 1024
if bytes < unit {
......
using Gtk 4.0;
using Adw 1;
Adw.ActionRow package_row {
[prefix]
Image {
icon-name: "package-x-generic-symbolic";
}
}
package components
import (
"SystemUpdater/model"
_ "embed"
"fmt"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"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 {
*adw.ActionRow
Row *adw.ActionRow `gtk:"package_row"`
}
// NewPackageRow creates a new package row
func NewPackageRow(pkg model.Package) *PackageRow {
row := adw.NewActionRow()
row.SetTitle(pkg.Name)
b := gtksbuilder.New(packageRowUI)
pr := &PackageRow{}
if err := gtksbuilder.Unmarshal(b, pr); err != nil {
panic(err)
}
pr.Row.SetTitle(pkg.Name)
subtitle := ""
if pkg.InstalledVersion != nil && *pkg.InstalledVersion != "" {
......@@ -31,25 +41,19 @@ func NewPackageRow(pkg model.Package) *PackageRow {
subtitle += fmt.Sprintf(" • %s", formatBytes(*pkg.Size))
}
row.SetSubtitle(subtitle)
pr.Row.SetSubtitle(subtitle)
if pkg.Summary != nil && *pkg.Summary != "" {
row.SetTooltipText(*pkg.Summary)
pr.Row.SetTooltipText(*pkg.Summary)
}
icon := gtk.NewImageFromIconName("package-x-generic-symbolic")
row.AddPrefix(icon)
return &PackageRow{ActionRow: row}
return pr
}
// stripVersionTimestamp removes @timestamp suffix from version string
func stripVersionTimestamp(version string) string {
if idx := len(version); idx > 0 {
for i := 0; i < len(version); i++ {
if version[i] == '@' {
return version[:i]
}
for i := range len(version) {
if version[i] == '@' {
return version[:i]
}
}
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
import (
"SystemUpdater/model"
_ "embed"
"fmt"
"strings"
"time"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"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 {
*adw.NavigationPage
listBox *gtk.ListBox
NavigationPage *adw.NavigationPage `gtk:"history_page"`
listBox *gtk.ListBox `gtk:"history_listbox"`
}
// NewHistoryPage creates a new history page
func NewHistoryPage() *HistoryPage {
toolbarView := adw.NewToolbarView()
// Header bar
headerBar := adw.NewHeaderBar()
toolbarView.AddTopBar(headerBar)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
toolbarView.SetContent(scrolled)
navPage := adw.NewNavigationPage(toolbarView, "History")
return &HistoryPage{
NavigationPage: navPage,
listBox: listBox,
b := gtksbuilder.New(historyUI)
page := &HistoryPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
return page
}
// Update updates the history list
func (p *HistoryPage) Update(entries []model.HistoryEntry) {
// Clear existing rows
for child := p.listBox.FirstChild(); child != nil; child = p.listBox.FirstChild() {
p.listBox.Remove(child)
}
// Add entries in reverse order (newest first)
for i := len(entries) - 1; i >= 0; i-- {
entry := entries[i]
row := p.createHistoryRow(entry)
row := p.createHistoryRow(entries[i])
p.listBox.Append(row)
}
}
// createHistoryRow creates a row for a history entry
func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRow {
row := adw.NewExpanderRow()
// Parse timestamp
timestamp, _ := time.Parse(time.RFC3339, entry.Timestamp)
timeStr := timestamp.Format("Jan 02, 2006 15:04")
// Title: timestamp
row.SetTitle(timeStr)
row.SetTitle(timestamp.Format("Jan 02, 2006 15:04"))
// Subtitle: categories and status
categoriesStr := strings.Join(entry.Categories, ", ")
status := "Success"
icon := "emblem-ok-symbolic"
......@@ -86,17 +58,11 @@ func (p *HistoryPage) createHistoryRow(entry model.HistoryEntry) *adw.ExpanderRo
icon = "dialog-error-symbolic"
}
subtitle := fmt.Sprintf("%s • %s • %d packages • %ds",
categoriesStr, status, entry.PackagesUpdated, entry.DurationSeconds)
row.SetSubtitle(subtitle)
// Icon prefix
image := gtk.NewImageFromIconName(icon)
row.AddPrefix(image)
row.SetSubtitle(fmt.Sprintf("%s • %s • %d packages • %ds",
categoriesStr, status, entry.PackagesUpdated, entry.DurationSeconds))
row.AddPrefix(gtk.NewImageFromIconName(icon))
// Add log entries as child rows
if len(entry.Log) > 0 {
// Show first 5 log lines
logLines := entry.Log
if len(logLines) > 5 {
logLines = logLines[:5]
......
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
import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"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 {
*adw.StatusPage
Page *adw.StatusPage `gtk:"loading_page"`
}
// NewLoadingPage creates a new loading page
func NewLoadingPage() *LoadingPage {
page := adw.NewStatusPage()
page.SetTitle("Loading…")
page.SetIconName("emblem-synchronizing-symbolic")
spinner := adw.NewSpinner()
spinner.SetSizeRequest(80, 80)
page.SetChild(spinner)
return &LoadingPage{StatusPage: page}
b := gtksbuilder.New(loadingUI)
page := &LoadingPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
return 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
import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"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 {
*adw.StatusPage
Page *adw.StatusPage `gtk:"noupdates_page"`
}
// NewNoUpdatesPage creates a new no updates page
func NewNoUpdatesPage() *NoUpdatesPage {
page := adw.NewStatusPage()
page.SetTitle("System is up to date")
page.SetIconName("emblem-ok-symbolic")
return &NoUpdatesPage{StatusPage: page}
b := gtksbuilder.New(noupdatesUI)
page := &NoUpdatesPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
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
import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"SystemUpdater/ui/components"
......@@ -8,49 +11,24 @@ import (
"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 {
*adw.NavigationPage
listBox *gtk.ListBox
category string
NavigationPage *adw.NavigationPage `gtk:"package_list_page"`
listBox *gtk.ListBox `gtk:"package_listbox"`
}
// NewPackageListPage creates a new package list page
func NewPackageListPage(category string, packages []model.Package) *PackageListPage {
toolbarView := adw.NewToolbarView()
// Header bar
headerBar := adw.NewHeaderBar()
toolbarView.AddTopBar(headerBar)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list-separate")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
toolbarView.SetContent(scrolled)
navPage := adw.NewNavigationPage(toolbarView, category)
page := &PackageListPage{
NavigationPage: navPage,
listBox: listBox,
category: category,
b := gtksbuilder.New(packageListUI)
page := &PackageListPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
// Populate with packages
page.NavigationPage.SetTitle(category)
page.SetPackages(packages)
return page
......@@ -62,7 +40,6 @@ func (p *PackageListPage) SetPackages(packages []model.Package) {
}
for _, pkg := range packages {
row := components.NewPackageRow(pkg)
p.listBox.Append(row)
p.listBox.Append(components.NewPackageRow(pkg).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'?>
<!-- Created with Cambalache 0.94.1 -->
<?xml version="1.0" encoding="UTF-8"?>
<!--
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-name process-page.ui -->
<requires lib="gtk" version="4.0"/>
<requires lib="libadwaita" version="1.4"/>
<object class="AdwNavigationPage" id="process_page">
<child>
<object class="AdwToolbarView" id="main_view">
<property name="content">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="hscrollbar-policy">2</property>
<property name="propagate-natural-height">true</property>
<child>
<object class="GtkStack" id="process_stack">
......@@ -23,27 +25,27 @@
<property name="maximum-size">500</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<property name="orientation">1</property>
<property name="spacing">12</property>
<child>
<object class="GtkProgressBar" id="progress_bar"/>
<object class="GtkProgressBar" id="progress_bar"></object>
</child>
<child>
<object class="GtkListBox">
<property name="selection-mode">none</property>
<property name="selection-mode">0</property>
<child>
<object class="AdwExpanderRow">
<property name="title" translatable="yes">Show more information</property>
<child>
<object class="GtkScrolledWindow">
<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>
<child>
<object class="GtkTextView" id="log_view">
<property name="cursor-visible">false</property>
<property name="editable">false</property>
<property name="wrap-mode">word-char</property>
<property name="wrap-mode">3</property>
</object>
</child>
</object>
......@@ -73,7 +75,7 @@
<property name="title" translatable="yes">Updating complete!</property>
<child>
<object class="GtkButton" id="restart_button">
<property name="halign">center</property>
<property name="halign">3</property>
<property name="hexpand">false</property>
<property name="label" translatable="yes">Reboot</property>
<style>
......@@ -103,4 +105,4 @@
</object>
</child>
</object>
</interface>
</interface>
\ No newline at end of file
package pages
import (
_ "embed"
"SystemUpdater/model"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
......@@ -9,26 +11,27 @@ import (
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 {
*adw.NavigationPage
stack *gtk.Stack
statusPage *adw.StatusPage
progressBar *gtk.ProgressBar
logView *gtk.TextView
logBuffer *gtk.TextBuffer
rebootBtn *gtk.Button
NavigationPage *adw.NavigationPage `gtk:"process_page"`
stack *gtk.Stack `gtk:"process_stack"`
statusPage *adw.StatusPage `gtk:"status_page"`
progressBar *gtk.ProgressBar `gtk:"progress_bar"`
logView *gtk.TextView `gtk:"log_view"`
logBuffer *gtk.TextBuffer
rebootBtn *gtk.Button `gtk:"restart_button"`
}
func NewUpdateProcessPage(onReboot func()) *UpdateProcessPage {
b := gtksbuilder.NewBuilder("process-page.ui")
page := &UpdateProcessPage{
NavigationPage: gtksbuilder.GetObject[*adw.NavigationPage](b, "process_page"),
stack: gtksbuilder.GetObject[*gtk.Stack](b, "process_stack"),
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"),
b := gtksbuilder.New(processPageUI)
page := &UpdateProcessPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
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
import (
_ "embed"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/store"
"SystemUpdater/ui/components"
......@@ -8,86 +11,51 @@ import (
"github.com/diamondburned/gotk4/pkg/gtk/v4"
)
// UpdatesListPage represents the main updates list page
type UpdatesListPage struct {
*gtk.Box
st *store.Store
//go:generate blueprint-compiler compile updates-list.blp --output updates-list.ui
//go:embed updates-list.ui
var updatesListUI string
listBox *gtk.ListBox
systemRow *components.CategoryRow
kernelRow *components.CategoryRow
playRow *components.CategoryRow
updateButton *adw.ButtonRow
type UpdatesListPage struct {
Box *gtk.Box `gtk:"updates_list_page"`
listBox *gtk.ListBox `gtk:"updates_listbox"`
updateButton *adw.ButtonRow `gtk:"apply_button"`
st *store.Store
systemRow *components.CategoryRow
kernelRow *components.CategoryRow
playRow *components.CategoryRow
}
// NewUpdatesListPage creates a new updates list page
func NewUpdatesListPage(st *store.Store, onUpdate func(), onNavigateToCategory func(category string)) *UpdatesListPage {
box := gtk.NewBox(gtk.OrientationVertical, 0)
// Scrolled window with list
scrolled := gtk.NewScrolledWindow()
scrolled.SetPolicy(gtk.PolicyNever, gtk.PolicyAutomatic)
scrolled.SetVExpand(true)
clamp := adw.NewClamp()
clamp.SetMarginTop(12)
clamp.SetMarginBottom(12)
clamp.SetMarginStart(12)
clamp.SetMarginEnd(12)
listBox := gtk.NewListBox()
listBox.SetSelectionMode(gtk.SelectionNone)
listBox.AddCSSClass("boxed-list-separate")
clamp.SetChild(listBox)
scrolled.SetChild(clamp)
box.Append(scrolled)
// Bottom bar with update button
bottomClamp := adw.NewClamp()
bottomClamp.SetMarginTop(12)
bottomClamp.SetMarginBottom(12)
bottomClamp.SetMarginStart(12)
bottomClamp.SetMarginEnd(12)
bottomClamp.SetMaximumSize(500)
buttonListBox := gtk.NewListBox()
updateButton := adw.NewButtonRow()
updateButton.SetTitle("Update")
updateButton.AddCSSClass("suggested-action")
buttonListBox.Append(updateButton)
buttonListBox.AddCSSClass("boxed-list")
bottomClamp.SetChild(buttonListBox)
box.Append(bottomClamp)
page := &UpdatesListPage{
Box: box,
st: st,
listBox: listBox,
systemRow: components.NewCategoryRow("System", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("System")
}
}),
kernelRow: components.NewCategoryRow("Kernel", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Kernel")
}
}),
playRow: components.NewCategoryRow("Play", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Play")
}
}),
updateButton: updateButton,
b := gtksbuilder.New(updatesListUI)
page := &UpdatesListPage{}
if err := gtksbuilder.Unmarshal(b, page); err != nil {
panic(err)
}
listBox.Append(page.systemRow)
listBox.Append(page.kernelRow)
listBox.Append(page.playRow)
page.st = st
page.systemRow = components.NewCategoryRow("System", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("System")
}
})
page.kernelRow = components.NewCategoryRow("Kernel", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Kernel")
}
})
page.playRow = components.NewCategoryRow("Play", st, func() {
if onNavigateToCategory != nil {
onNavigateToCategory("Play")
}
})
page.listBox.Append(page.systemRow.Row)
page.listBox.Append(page.kernelRow.Row)
page.listBox.Append(page.playRow.Row)
updateButton.ConnectActivated(func() {
page.updateButton.ConnectActivated(func() {
if onUpdate != nil {
onUpdate()
}
......@@ -103,6 +71,5 @@ func (p *UpdatesListPage) Update() {
p.kernelRow.Update(state.KernelUpdates)
p.playRow.Update(state.PlayUpdates)
canUpdate := store.CanStartUpdate(&state)
p.updateButton.SetSensitive(canUpdate)
p.updateButton.SetSensitive(store.CanStartUpdate(&state))
}
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
import (
_ "embed"
"context"
"fmt"
"log"
gtksbuilder "SystemUpdater/lib/gtks/builder"
"SystemUpdater/model"
"SystemUpdater/service"
"SystemUpdater/store"
"SystemUpdater/ui/pages"
"context"
"fmt"
"log"
"github.com/diamondburned/gotk4-adwaita/pkg/adw"
"github.com/diamondburned/gotk4/pkg/glib/v2"
"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 {
Window *adw.ApplicationWindow
st *store.Store
updateSvc *service.UpdateService
historySvc *service.HistoryService
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
updateSvc *service.UpdateService
historySvc *service.HistoryService
// State changes channel
stateChanges chan store.StateChange
ctx context.Context
cancel context.CancelFunc
// UI components
navView *adw.NavigationView
stack *gtk.Stack
headerBar *adw.HeaderBar
historyButton *gtk.Button
// Pages
loadingPage *pages.LoadingPage
noUpdatesPage *pages.NoUpdatesPage
updatesPage *pages.UpdatesListPage
......@@ -40,14 +45,10 @@ type Window struct {
historyPage *pages.HistoryPage
}
// NewWindow creates a new main window
func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateService, historySvc *service.HistoryService) *Window {
ctx, cancel := context.WithCancel(context.Background())
appWindow := adw.NewApplicationWindow(&app.Application)
w := &Window{
Window: appWindow,
st: st,
updateSvc: updateSvc,
historySvc: historySvc,
......@@ -56,38 +57,12 @@ func NewWindow(app *adw.Application, st *store.Store, updateSvc *service.UpdateS
cancel: cancel,
}
w.Window.SetDefaultSize(360, 500)
w.Window.SetTitle("System Updater")
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)
b := gtksbuilder.New(windowUI)
if err := gtksbuilder.Unmarshal(b, w); err != nil {
panic(err)
}
w.stack = gtk.NewStack()
w.stack.SetTransitionType(gtk.StackTransitionTypeCrossfade)
w.Window.SetApplication(&app.Application)
w.loadingPage = pages.NewLoadingPage()
w.noUpdatesPage = pages.NewNoUpdatesPage()
......@@ -95,40 +70,32 @@ func (w *Window) buildUI() {
w.processPage = pages.NewUpdateProcessPage(w.onRebootClicked)
w.historyPage = pages.NewHistoryPage()
w.stack.AddNamed(w.loadingPage, "loading")
w.stack.AddNamed(w.noUpdatesPage, "noupdates")
w.stack.AddNamed(w.updatesPage, "main")
toolbarView.SetContent(w.stack)
mainPage := adw.NewNavigationPage(toolbarView, "System Updater")
w.navView.Add(mainPage)
w.Window.SetContent(w.navView)
w.stack.AddNamed(w.loadingPage.Page, "loading")
w.stack.AddNamed(w.noUpdatesPage.Page, "noupdates")
w.stack.AddNamed(w.updatesPage.Box, "main")
w.stack.SetVisibleChildName("loading")
}
func (w *Window) connectSignals() {
w.historyButton.ConnectClicked(func() {
w.showHistory()
})
// Window close
w.Window.ConnectCloseRequest(func() bool {
w.Cleanup()
return false
})
st.Subscribe(w.stateChanges)
go w.handleStateChanges()
return w
}
// handleStateChanges handles state changes from store
func (w *Window) handleStateChanges() {
for {
select {
case <-w.ctx.Done():
return
case change := <-w.stateChanges:
// Update UI on main thread
glib.IdleAdd(func() {
w.onStateChange(change)
})
......@@ -136,7 +103,6 @@ func (w *Window) handleStateChanges() {
}
}
// onStateChange handles a state change
func (w *Window) onStateChange(change store.StateChange) {
state := w.st.GetState()
......@@ -145,8 +111,6 @@ func (w *Window) onStateChange(change store.StateChange) {
w.updatePhase(state.Phase)
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 {
w.updatesPage.Update()
}
......@@ -175,7 +139,6 @@ func (w *Window) onStateChange(change store.StateChange) {
}
case store.ChangeHistory:
// Refresh history page if visible
w.historyPage.Update(state.History)
case store.ChangeError:
......@@ -183,7 +146,6 @@ func (w *Window) onStateChange(change store.StateChange) {
}
}
// updatePhase updates UI based on phase
func (w *Window) updatePhase(phase store.AppPhase) {
switch phase {
case store.PhaseLoading:
......@@ -199,12 +161,10 @@ func (w *Window) updatePhase(phase store.AppPhase) {
}
}
// onUpdateClicked handles update button click
func (w *Window) onUpdateClicked() {
w.showUpdateDialog()
}
// showUpdateDialog shows dialog to select categories to update
func (w *Window) showUpdateDialog() {
state := w.st.GetState()
......@@ -302,7 +262,6 @@ func (w *Window) runUpdate(categories []string) {
}
}
// Run update in background
go func() {
if err := w.updateSvc.RunUpdates(w.ctx); err != nil {
log.Printf("Update failed: %v", err)
......@@ -313,7 +272,6 @@ func (w *Window) runUpdate(categories []string) {
}()
}
// onRebootClicked handles reboot button click
func (w *Window) onRebootClicked() {
dialog := adw.NewMessageDialog(
nil,
......@@ -327,7 +285,7 @@ func (w *Window) onRebootClicked() {
dialog.ConnectResponse(func(response string) {
if response == "reboot" {
// TODO: Implement reboot via systemd or similar
// TODO: reboot via logind
log.Println("Reboot requested")
}
})
......@@ -335,14 +293,12 @@ func (w *Window) onRebootClicked() {
dialog.Present()
}
// showHistory shows the history page
func (w *Window) showHistory() {
state := w.st.GetState()
w.historyPage.Update(state.History)
w.navView.Push(w.historyPage.NavigationPage)
}
// onNavigateToCategory navigates to package list for a category
func (w *Window) onNavigateToCategory(category string) {
state := w.st.GetState()
......@@ -353,15 +309,13 @@ func (w *Window) onNavigateToCategory(category string) {
case "Kernel":
packages = state.KernelUpdates.Packages
case "Play":
// Play apps don't have detailed list yet
packages = []model.Package{}
packages = state.PlayUpdates.Packages
}
packageListPage := pages.NewPackageListPage(category, packages)
w.navView.Push(packageListPage.NavigationPage)
}
// showError shows an error dialog
func (w *Window) showError(title, message string) {
dialog := adw.NewMessageDialog(nil, title, message)
dialog.AddResponse("ok", "OK")
......@@ -369,7 +323,6 @@ func (w *Window) showError(title, message string) {
dialog.Present()
}
// Cleanup cleans up resources
func (w *Window) Cleanup() {
w.cancel()
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