loader revisited

This commit is contained in:
Ilia Denisov
2026-03-16 19:52:02 +02:00
committed by GitHub
parent e6c6970947
commit 3f1776aa5f
30 changed files with 1581 additions and 527 deletions
+154 -195
View File
@@ -1,259 +1,218 @@
/*
Пакет loader - основная точка входа в клиенское приложение.
Задачи:
- Загрузка и выполнение плагина, в котором сосредоточена логика основного UI,
- Выполнение операций с локальным Storage,
- Выполнение операций обмена данными с сервером.
*/
package loader
import (
"context"
"errors"
"fmt"
"sync"
"galaxy/client/updater"
"galaxy/connector"
mc "galaxy/model/client"
"galaxy/storage"
"plugin"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
type ClientInit func(storage.UIStorage, connector.UIConnector, fyne.App) (mc.Client, error)
const (
loaderLogViewportColumns = 80
loaderLogViewportRows = 12
)
type loader struct {
app fyne.App
storage storage.Storage
connector connector.Connector
client mc.Client
window fyne.Window
textGrid *widget.TextGrid
btn *widget.Button
fatalError bool
loaded chan struct{}
app fyne.App
storage storage.Storage
connector connector.Connector
updater *updater.Manager
runner uiRunner
debugWindow fyne.Window
textGrid *widget.TextGrid
btn *widget.Button
ctx context.Context
resultMu sync.Mutex
result error
closeMu sync.Mutex
closeQuits bool
}
const (
pluginInitSymbol = "Factory"
libUIPluginFile = "libui"
)
// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size
// from the active Fyne text metrics.
func loaderLogViewportMinSize(app fyne.App) fyne.Size {
if app == nil || app.Driver() == nil {
return fyne.NewSize(0, 0)
}
var (
checkConnectionTimeout = time.Second * 5
checkVersionTimeout = time.Minute * 60
)
cellSize, _ := app.Driver().RenderedTextSize(
"M",
theme.TextSize(),
fyne.TextStyle{Monospace: true},
nil,
)
return fyne.NewSize(
cellSize.Width*loaderLogViewportColumns,
cellSize.Height*loaderLogViewportRows,
)
}
func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) {
l := &loader{
app: app,
connector: conn,
storage: s,
textGrid: widget.NewTextGrid(),
window: app.NewWindow("Loader"),
loaded: make(chan struct{}),
app: app,
connector: conn,
storage: s,
updater: updater.NewManager(s, conn),
runner: execRunner{},
textGrid: widget.NewTextGrid(),
debugWindow: app.NewWindow("Loader"),
}
l.btn = widget.NewButton("OK", l.onButtonAction)
l.btn = widget.NewButton("Retry", l.onButtonAction)
l.btn.Disable()
l.textGrid.Scroll = fyne.ScrollNone
l.debugWindow.SetCloseIntercept(l.onWindowClose)
content := container.NewStack(
l.textGrid,
container.NewHBox(
container.NewCenter(
l.btn,
),
),
)
l.window.SetContent(content)
logScroll := container.NewScroll(l.textGrid)
logScroll.Direction = container.ScrollBoth
logScroll.SetMinSize(loaderLogViewportMinSize(app))
actionBar := container.NewCenter(container.NewHBox(l.btn))
content := container.NewBorder(nil, actionBar, nil, nil, logScroll)
l.debugWindow.SetContent(content)
l.debugWindow.Resize(content.MinSize())
l.debugWindow.SetFixedSize(true)
l.debugWindow.CenterOnScreen()
return l, nil
}
// updatePluginFromVersion заменяет файл {libUIPluginFile} с плагином на новую версию, которая была ранее загружена и сохранена в State
func (l *loader) updatePluginFromVersion() error {
state, stateExists, err := l.loadCurrentState()
if err != nil {
return fmt.Errorf("Load State error: %w", err)
}
if !stateExists {
return nil
}
if state.ClientNextVersion == nil {
return nil
}
version := *state.ClientNextVersion
versionFileName := resolvePluginFile(version)
versionFileExists, _, err := l.storage.FileExists(versionFileName)
if err != nil {
return fmt.Errorf("Check plugin v%s exists error: %w", version, err)
}
if !versionFileExists {
return fmt.Errorf("Requested plugin v%s does not exists at local storage", version)
}
storedData, err := l.storage.ReadFile(versionFileName)
func (l *loader) runOnce(ctx context.Context) error {
target, err := l.updater.EnsureLaunchTarget()
if err != nil {
return err
}
if err := l.storage.WriteFile(libUIPluginFile, storedData); err != nil {
return fmt.Errorf("Plugin file write error: %w", err)
l.logText(fmt.Sprintf("Starting UI client v%s", target.Version))
l.logText(fmt.Sprintf("Executable: %s", target.Path))
fyne.Do(func() {
l.debugWindow.Hide()
})
exitCode, runErr := l.runner.Run(ctx, target.Path)
markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr)
switch {
case runErr != nil:
return errors.Join(fmt.Errorf("launch UI client v%s: %w", target.Version, runErr), markErr)
case exitCode != 0:
return errors.Join(fmt.Errorf("UI client v%s exited with code %d", target.Version, exitCode), markErr)
default:
return markErr
}
state.ClientCurrentVersion = version
state.ClientNextVersion = nil
if err := l.storage.SaveState(*state); err != nil {
return fmt.Errorf("State write error: %w", err)
}
return nil
}
func (l *loader) initPlugin() (mc.Client, error) {
pluginExists, pluginPath, err := l.storage.FileExists(libUIPluginFile)
if err != nil {
l.appendFatalError(fmt.Errorf("Client plugin file lookup error: %w", err))
return nil, err
}
if !pluginExists {
l.logText("Client plugin file not found, fetching available versions")
err = l.checkAndDownloadPluginVersion()
if err != nil {
l.logError(err)
return nil, err
}
}
err = l.updatePluginFromVersion()
if err != nil {
return nil, err
}
l.logText(fmt.Sprintf("Loading client plugin from %s", pluginPath))
cli, err := loadClientPlugin(l.storage, l.connector, l.app, pluginPath, pluginInitSymbol)
if err != nil {
l.appendFatalError(err)
return nil, err
}
// need to store current plugin version for the very first app start
if err := l.updateCurrentVersion(cli.Version()); err != nil {
return nil, err
}
return cli, nil
}
// init инициализирует плагин клиента и запускает его, либо отображает пользователю лог с ошибками
func (l *loader) init() {
l.fatalError = false
// init prepares and launches the standalone UI client, or shows a retry button on failure.
func (l *loader) init(ctx context.Context) {
l.setCloseQuits(false)
fyne.Do(func() {
l.textGrid.SetText("")
l.btn.Hide()
l.btn.Disable()
// show debugWindow can be done with future debug mode, e.g. with -debug flag
// l.window.Show()
})
var err error
l.client, err = l.initPlugin()
if err == nil {
err := l.runOnce(ctx)
if err == nil || errors.Is(err, context.Canceled) {
l.setResult(nil)
fyne.Do(func() {
l.window.Hide()
err = l.client.Run()
l.debugWindow.Hide()
l.app.Quit()
})
return
}
if err != nil {
fyne.Do(func() {
if l.fatalError {
l.btn.SetText("Quit")
l.logText("Please re-install application.")
} else {
l.btn.SetText("Retry")
}
l.btn.Enable()
l.btn.Show()
l.window.Show()
})
} else {
l.loaded <- struct{}{}
}
l.setCloseQuits(true)
l.setResult(err)
l.logError(err)
fyne.Do(func() {
l.btn.SetText("Retry")
l.btn.Enable()
l.btn.Show()
l.debugWindow.Show()
})
}
func (l *loader) onButtonAction() {
if l.fatalError {
l.app.Quit()
} else {
go l.init()
if l.ctx == nil {
return
}
go l.init(l.ctx)
}
func (l *loader) onWindowClose() {
if l.getCloseQuits() {
l.app.Quit()
return
}
l.debugWindow.Hide()
}
func (l *loader) logText(v string) {
if l.textGrid == nil {
return
}
fyne.Do(func() { l.textGrid.Append(v) })
}
func (l *loader) logError(err error) {
l.logText(fmt.Sprintf(" %s", err))
l.logText(fmt.Sprintf("ERROR: %s", err))
}
func (l *loader) appendFatalError(err error) {
l.logError(err)
l.fatalError = true
func (l *loader) setResult(err error) {
l.resultMu.Lock()
defer l.resultMu.Unlock()
l.result = err
}
// Run is the main entry point for start Client
func (l *loader) getResult() error {
l.resultMu.Lock()
defer l.resultMu.Unlock()
return l.result
}
func (l *loader) setCloseQuits(v bool) {
l.closeMu.Lock()
defer l.closeMu.Unlock()
l.closeQuits = v
}
func (l *loader) getCloseQuits() bool {
l.closeMu.Lock()
defer l.closeMu.Unlock()
return l.closeQuits
}
// Run starts the loader window, launches the standalone UI process, and returns
// the final launch result once the loader application exits.
func (l *loader) Run(ctx context.Context) error {
// start initializing process
go l.init()
l.ctx = ctx
go l.init(ctx)
go func() {
<-ctx.Done()
fyne.Do(l.app.Quit)
}()
// run UI engine, not the main Client app
l.app.Run()
// wait for successfull 'loaded' signal from init()
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
case <-l.loaded:
return l.runClient(ctx)
}
}
// runClient запусукает основного Client и блокирует выполнение в текущем потоке
func (l *loader) runClient(ctx context.Context) error {
if l.client == nil {
return errors.New("run: client wasn't initialized, this is an program fatal error.")
}
bgSignal := make(chan struct{}, 1)
go l.backgroundLoop(ctx, bgSignal)
// stop backbround loop before exin func
defer func() { bgSignal <- struct{}{} }()
// Client's Run() blocks execution until window is closed, or error is returned immediately
if err := l.client.Run(); err != nil {
return err
}
return nil
}
// loadClientPlugin loads a Client implementation from a shared plugin file at the specified path.
// It calls the constructor function by name, passing the necessary dependencies, and returns the initialized Client.
func loadClientPlugin(s storage.UIStorage, conn connector.UIConnector, app fyne.App, path, name string) (mc.Client, error) {
if path == "" {
return nil, errors.New("no plugin path given")
}
plug, err := plugin.Open(path)
if err != nil {
return nil, fmt.Errorf("open plugin %q: %w", path, err)
}
sym, err := plug.Lookup(name)
if err != nil {
return nil, fmt.Errorf("lookup symbol %q: %w", name, err)
}
initializerPtr, ok := sym.(*ClientInit)
if !ok {
return nil, fmt.Errorf("unexpected type %T; want %T", sym, initializerPtr)
}
return (*initializerPtr)(s, conn, app)
return l.getResult()
}