loader revisited
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
package loader
|
||||
|
||||
// onConnectionError обёртка над событием OnConnectionError для UI
|
||||
func (l *loader) onConnectionError(err error) {
|
||||
if err == nil || l.client == nil {
|
||||
return
|
||||
}
|
||||
l.client.OnConnectionError(err)
|
||||
}
|
||||
|
||||
// onStorageError обёртка над событием OnStorageError для UI
|
||||
func (l *loader) onStorageError(err error) {
|
||||
if err == nil || l.client == nil {
|
||||
return
|
||||
}
|
||||
l.client.OnStorageError(err)
|
||||
}
|
||||
|
||||
// onServiceError обёртка над событием OnServiceError для UI
|
||||
func (l *loader) onServiceError(err error) {
|
||||
if err == nil || l.client == nil {
|
||||
return
|
||||
}
|
||||
l.client.OnServiceError(err)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"galaxy/connector"
|
||||
"galaxy/util"
|
||||
)
|
||||
|
||||
func (l *loader) checkAndDownloadPluginVersion() error {
|
||||
versions, err := l.connector.CheckVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if latest, ok, err := latestVersion(versions); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return l.preparePluginVersion(latest)
|
||||
} else {
|
||||
return errors.New("Server did not respond with a suitable client version")
|
||||
}
|
||||
}
|
||||
|
||||
// preparePluginVersion fetches and stores given version of plugin artifact, if newer to the current known version.
|
||||
func (l *loader) preparePluginVersion(v connector.VersionInfo) error {
|
||||
s, exists, err := l.loadCurrentState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version, err := util.ParseSemver(v.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
currentVersion, err := util.ParseSemver(s.ClientCurrentVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if util.CompareSemver(currentVersion, version) > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
err = l.downloadPlugin(v.URL, v.Version, v.Checksum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadPlugin downloads and stores at the App's local storage with a pre-defined name with semver suffix.
|
||||
func (l *loader) downloadPlugin(url, version string, checksum [32]byte) error {
|
||||
data, err := l.connector.DownloadVersion(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Plugin v%s download error: %w", version, err)
|
||||
}
|
||||
dataChecksum := SumSHA256(data)
|
||||
if !EqualSHA256(checksum, dataChecksum) {
|
||||
return errors.New("Downloaded pligin checksum incorrect")
|
||||
}
|
||||
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 {
|
||||
storedData, err := l.storage.ReadFile(versionFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if EqualSHA256(dataChecksum, SumSHA256(storedData)) {
|
||||
return nil
|
||||
}
|
||||
if err := l.storage.DeleteFile(versionFileName); err != nil {
|
||||
return fmt.Errorf("Plugin v%s delete error: %w", version, err)
|
||||
}
|
||||
}
|
||||
err = l.storage.WriteFile(versionFileName, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Plugin file write error: %w", err)
|
||||
}
|
||||
if err := l.setNextVersion(&version); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+154
-195
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"galaxy/client/updater"
|
||||
"galaxy/connector"
|
||||
mc "galaxy/model/client"
|
||||
"galaxy/model/report"
|
||||
"galaxy/storage"
|
||||
"galaxy/storage/fs"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubConnector struct {
|
||||
versions []connector.VersionInfo
|
||||
versionErr error
|
||||
downloads map[string][]byte
|
||||
downloadErr error
|
||||
}
|
||||
|
||||
func (c *stubConnector) CheckConnection() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *stubConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
||||
if c.versionErr != nil {
|
||||
return nil, c.versionErr
|
||||
}
|
||||
return c.versions, nil
|
||||
}
|
||||
|
||||
func (c *stubConnector) DownloadVersion(url string) ([]byte, error) {
|
||||
if c.downloadErr != nil {
|
||||
return nil, c.downloadErr
|
||||
}
|
||||
data, ok := c.downloads[url]
|
||||
if !ok {
|
||||
return nil, errors.New("missing download payload")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *stubConnector) FetchReport(mc.GameID, uint, func(report.Report, error)) {}
|
||||
|
||||
type stubRunner struct {
|
||||
paths []string
|
||||
exitCode int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(_ context.Context, path string) (int, error) {
|
||||
r.paths = append(r.paths, path)
|
||||
return r.exitCode, r.err
|
||||
}
|
||||
|
||||
func TestRunOnceFirstLaunchDownloadsAndPromotesVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
payload := []byte("ui-binary-1.2.3")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.2.3",
|
||||
URL: "https://example.com/ui-1.2.3.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
runner := &stubRunner{}
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64")),
|
||||
runner: runner,
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.2.3", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
expectedPath := filepath.Join(s.StorageRoot(), updater.ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.Equal(t, []string{expectedPath}, runner.paths)
|
||||
}
|
||||
|
||||
func TestRunOnceSpawnFailureClearsPendingAndKeepsCurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
|
||||
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
|
||||
|
||||
payload := []byte("ui-binary-1.1.0")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.1.0",
|
||||
URL: "https://example.com/ui-1.1.0.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
|
||||
require.NoError(t, manager.CheckAndPrepareLatest())
|
||||
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: manager,
|
||||
runner: &stubRunner{
|
||||
err: errors.New("spawn failed"),
|
||||
},
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.Error(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
currentExists, _, err := s.FileExists(currentPath)
|
||||
require.NoError(t, err)
|
||||
require.True(t, currentExists)
|
||||
|
||||
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.NoError(t, err)
|
||||
require.False(t, nextExists)
|
||||
}
|
||||
|
||||
func TestRunOnceNonZeroExitClearsPendingAndKeepsCurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newTestStorage(t)
|
||||
currentPath := updater.ArtifactPath("1.0.0", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||
require.NoError(t, s.WriteFile(currentPath, []byte("current")))
|
||||
require.NoError(t, s.SaveState(mc.State{ClientCurrentVersion: "1.0.0"}))
|
||||
|
||||
payload := []byte("ui-binary-1.1.0")
|
||||
info := connector.VersionInfo{
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
Kind: connector.ArtifactKindExecutable,
|
||||
Version: "1.1.0",
|
||||
URL: "https://example.com/ui-1.1.0.exe",
|
||||
Checksum: connector.NewSHA256Digest(payload),
|
||||
}
|
||||
conn := &stubConnector{
|
||||
versions: []connector.VersionInfo{info},
|
||||
downloads: map[string][]byte{info.URL: payload},
|
||||
}
|
||||
manager := updater.NewManager(s, conn, updater.WithPlatform("windows", "amd64"))
|
||||
require.NoError(t, manager.CheckAndPrepareLatest())
|
||||
|
||||
l := &loader{
|
||||
storage: s,
|
||||
connector: conn,
|
||||
updater: manager,
|
||||
runner: &stubRunner{
|
||||
exitCode: 23,
|
||||
},
|
||||
}
|
||||
|
||||
err := l.runOnce(context.Background())
|
||||
require.Error(t, err)
|
||||
|
||||
state, err := s.LoadState()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.0.0", state.ClientCurrentVersion)
|
||||
require.Nil(t, state.ClientNextVersion)
|
||||
|
||||
nextExists, _, err := s.FileExists(updater.ArtifactPath("1.1.0", "windows", "amd64", connector.ArtifactKindExecutable))
|
||||
require.NoError(t, err)
|
||||
require.False(t, nextExists)
|
||||
}
|
||||
|
||||
func newTestStorage(t *testing.T) *testStorage {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
s, err := fs.NewFS(root)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &testStorage{Storage: s, root: root}
|
||||
}
|
||||
|
||||
type testStorage struct {
|
||||
storage.Storage
|
||||
root string
|
||||
}
|
||||
|
||||
func (s *testStorage) StorageRoot() string {
|
||||
return s.root
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
fynetest "fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewLoaderConfiguresWindowGeometry(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, spy.window)
|
||||
require.Same(t, spy.window, l.debugWindow)
|
||||
require.True(t, spy.window.setContentCalled)
|
||||
require.True(t, spy.window.resizeCalled)
|
||||
require.Equal(t, spy.window.content.MinSize(), spy.window.resizeSize)
|
||||
require.True(t, spy.window.fixedSizeCalled)
|
||||
require.True(t, spy.window.fixedSize)
|
||||
require.True(t, spy.window.centerOnScreenCalled)
|
||||
}
|
||||
|
||||
func TestNewLoaderBuildsScrollableBorderLayout(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, app)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, ok := l.debugWindow.Content().(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "*layout.borderLayout", fmt.Sprintf("%T", content.Layout))
|
||||
require.Len(t, content.Objects, 2)
|
||||
|
||||
logScroll, ok := content.Objects[0].(*container.Scroll)
|
||||
require.True(t, ok)
|
||||
require.Same(t, l.textGrid, logScroll.Content)
|
||||
require.Equal(t, container.ScrollBoth, logScroll.Direction)
|
||||
require.Equal(t, loaderLogViewportMinSize(app), logScroll.MinSize())
|
||||
require.Equal(t, fyne.ScrollNone, l.textGrid.Scroll)
|
||||
|
||||
actionBar, ok := content.Objects[1].(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Len(t, actionBar.Objects, 1)
|
||||
|
||||
actionRow, ok := actionBar.Objects[0].(*fyne.Container)
|
||||
require.True(t, ok)
|
||||
require.Len(t, actionRow.Objects, 1)
|
||||
require.Same(t, l.btn, actionRow.Objects[0])
|
||||
|
||||
content.Resize(content.MinSize())
|
||||
|
||||
require.Equal(t, fyne.NewPos(0, 0), logScroll.Position())
|
||||
require.Equal(t, content.Size().Width, logScroll.Size().Width)
|
||||
require.Equal(
|
||||
t,
|
||||
content.Size().Height-actionBar.MinSize().Height-theme.Padding(),
|
||||
logScroll.Size().Height,
|
||||
)
|
||||
|
||||
require.Equal(
|
||||
t,
|
||||
fyne.NewPos(0, content.Size().Height-actionBar.MinSize().Height),
|
||||
actionBar.Position(),
|
||||
)
|
||||
require.Equal(t, content.Size().Width, actionBar.Size().Width)
|
||||
require.Equal(t, actionRow.MinSize().Width, actionRow.Size().Width)
|
||||
require.Equal(t, l.btn.MinSize().Width, l.btn.Size().Width)
|
||||
require.Equal(t, l.btn.MinSize().Height, l.btn.Size().Height)
|
||||
require.Equal(t, (content.Size().Width-actionRow.Size().Width)/2, actionRow.Position().X)
|
||||
}
|
||||
|
||||
func TestNewLoaderInterceptsWindowCloseByHidingWindow(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, spy.window)
|
||||
require.Same(t, spy.window, l.debugWindow)
|
||||
require.NotNil(t, spy.window.closeIntercept)
|
||||
|
||||
spy.window.closeIntercept()
|
||||
|
||||
require.Equal(t, 1, spy.window.hideCalls)
|
||||
require.Zero(t, spy.window.closeCalls)
|
||||
require.Zero(t, spy.quitCalls)
|
||||
}
|
||||
|
||||
func TestLoaderWindowCloseQuitsApplicationAfterLaunchFailure(t *testing.T) {
|
||||
app := fynetest.NewApp()
|
||||
spy := &spyApp{App: app}
|
||||
|
||||
l, err := NewLoader(newTestStorage(t), &stubConnector{}, spy)
|
||||
require.NoError(t, err)
|
||||
|
||||
l.setCloseQuits(true)
|
||||
spy.window.closeIntercept()
|
||||
|
||||
require.Zero(t, spy.window.hideCalls)
|
||||
require.Zero(t, spy.window.closeCalls)
|
||||
require.Equal(t, 1, spy.quitCalls)
|
||||
}
|
||||
|
||||
type spyApp struct {
|
||||
fyne.App
|
||||
window *spyWindow
|
||||
quitCalls int
|
||||
}
|
||||
|
||||
func (a *spyApp) NewWindow(title string) fyne.Window {
|
||||
a.window = &spyWindow{Window: a.App.NewWindow(title)}
|
||||
return a.window
|
||||
}
|
||||
|
||||
func (a *spyApp) Quit() {
|
||||
a.quitCalls++
|
||||
a.App.Quit()
|
||||
}
|
||||
|
||||
type spyWindow struct {
|
||||
fyne.Window
|
||||
|
||||
content fyne.CanvasObject
|
||||
closeIntercept func()
|
||||
resizeSize fyne.Size
|
||||
hideCalls int
|
||||
closeCalls int
|
||||
setContentCalled bool
|
||||
resizeCalled bool
|
||||
fixedSize bool
|
||||
fixedSizeCalled bool
|
||||
centerOnScreenCalled bool
|
||||
}
|
||||
|
||||
func (w *spyWindow) CenterOnScreen() {
|
||||
w.centerOnScreenCalled = true
|
||||
w.Window.CenterOnScreen()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Close() {
|
||||
w.closeCalls++
|
||||
w.Window.Close()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Hide() {
|
||||
w.hideCalls++
|
||||
w.Window.Hide()
|
||||
}
|
||||
|
||||
func (w *spyWindow) Resize(size fyne.Size) {
|
||||
w.resizeCalled = true
|
||||
w.resizeSize = size
|
||||
w.Window.Resize(size)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetContent(content fyne.CanvasObject) {
|
||||
w.setContentCalled = true
|
||||
w.content = content
|
||||
w.Window.SetContent(content)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetCloseIntercept(callback func()) {
|
||||
w.closeIntercept = callback
|
||||
w.Window.SetCloseIntercept(callback)
|
||||
}
|
||||
|
||||
func (w *spyWindow) SetFixedSize(fixed bool) {
|
||||
w.fixedSizeCalled = true
|
||||
w.fixedSize = fixed
|
||||
w.Window.SetFixedSize(fixed)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// uiRunner executes the standalone UI artifact and returns its exit code.
|
||||
type uiRunner interface {
|
||||
Run(context.Context, string) (int, error)
|
||||
}
|
||||
|
||||
type execRunner struct{}
|
||||
|
||||
func (execRunner) Run(ctx context.Context, path string) (int, error) {
|
||||
cmd := exec.CommandContext(ctx, path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return exitErr.ExitCode(), nil
|
||||
}
|
||||
|
||||
return -1, err
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// backgroundLoop периодически проверяет доступность соединения и наличие новых версий на сервере
|
||||
func (l *loader) backgroundLoop(ctx context.Context, stop <-chan struct{}) {
|
||||
checkConnTimer := time.NewTimer(checkConnectionTimeout)
|
||||
checkVersionTimer := time.NewTimer(checkVersionTimeout)
|
||||
defer func() {
|
||||
checkConnTimer.Stop()
|
||||
checkVersionTimer.Stop()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
l.client.Shutdown()
|
||||
return
|
||||
case <-stop:
|
||||
return
|
||||
case <-checkConnTimer.C:
|
||||
isGood := l.connector.CheckConnection()
|
||||
l.client.OnConnection(isGood)
|
||||
checkConnTimer.Reset(checkConnectionTimeout)
|
||||
case <-checkVersionTimer.C:
|
||||
if err := l.checkAndDownloadPluginVersion(); err != nil {
|
||||
l.onConnectionError(err) // TODO: separate error types for: onConnectionError / onStorageError / onServiceError
|
||||
}
|
||||
checkVersionTimer.Reset(checkVersionTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
mc "galaxy/model/client"
|
||||
)
|
||||
|
||||
func (l *loader) loadCurrentState() (*mc.State, bool, error) {
|
||||
stateExists, err := l.storage.StateExists()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("State file lookup error: %w", err)
|
||||
}
|
||||
if !stateExists {
|
||||
return nil, false, nil
|
||||
}
|
||||
if s, err := l.storage.LoadState(); err != nil {
|
||||
return nil, false, fmt.Errorf("State read error: %w", err)
|
||||
} else {
|
||||
return &s, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loader) updateCurrentVersion(v string) error {
|
||||
state, stateExists, err := l.loadCurrentState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Load State error: %w", err)
|
||||
}
|
||||
if !stateExists {
|
||||
state = &mc.State{}
|
||||
}
|
||||
if state.ClientCurrentVersion == v {
|
||||
return nil
|
||||
}
|
||||
state.ClientCurrentVersion = v
|
||||
if err := l.storage.SaveState(*state); err != nil {
|
||||
return fmt.Errorf("State write error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *loader) setNextVersion(v *string) error {
|
||||
state, stateExists, err := l.loadCurrentState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Load State error: %w", err)
|
||||
}
|
||||
if !stateExists {
|
||||
return errors.New("State was never saved, unable to set next version")
|
||||
}
|
||||
if state.ClientNextVersion != nil && v != nil && *state.ClientNextVersion == *v {
|
||||
return nil
|
||||
}
|
||||
if state.ClientNextVersion == v {
|
||||
return nil
|
||||
}
|
||||
state.ClientNextVersion = v
|
||||
if err := l.storage.SaveState(*state); err != nil {
|
||||
return fmt.Errorf("State write error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package loader
|
||||
|
||||
func (l *loader) clientPluginVersionExists(version string) (bool, error) {
|
||||
file := resolvePluginFile(version)
|
||||
_ = file
|
||||
// check file existence
|
||||
return false, nil
|
||||
}
|
||||
+1
-46
@@ -1,59 +1,14 @@
|
||||
package loader
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"galaxy/connector"
|
||||
"galaxy/util"
|
||||
"runtime"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func resolvePluginFile(version string) string {
|
||||
return libUIPluginFile + "-" + version
|
||||
}
|
||||
|
||||
// latestVersion should return VersionInfo with the latest Version for the current OS
|
||||
func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, bool, error) {
|
||||
os := runtime.GOOS
|
||||
versions = slices.DeleteFunc(versions, func(v connector.VersionInfo) bool { return v.OS != os })
|
||||
if len(versions) == 0 {
|
||||
return connector.VersionInfo{}, false, nil
|
||||
}
|
||||
type v struct {
|
||||
vi *connector.VersionInfo
|
||||
sv *util.SemVer
|
||||
}
|
||||
semvers := make([]*v, len(versions))
|
||||
for i := range versions {
|
||||
sv, err := util.ParseSemver(versions[i].Version)
|
||||
if err != nil {
|
||||
return connector.VersionInfo{}, false, fmt.Errorf("latest version: %w", err)
|
||||
}
|
||||
semvers[i] = &v{
|
||||
vi: &versions[i],
|
||||
sv: &sv,
|
||||
}
|
||||
}
|
||||
slices.SortFunc(semvers, func(a, b *v) int { return util.CompareSemver(*b.sv, *a.sv) })
|
||||
return *semvers[0].vi, true, nil
|
||||
}
|
||||
import "crypto/sha256"
|
||||
|
||||
// SumSHA256 calculates SHA-256 for the provided byte slice and returns
|
||||
// the raw 32-byte digest as a fixed-size array.
|
||||
//
|
||||
// The function does not modify the input data.
|
||||
// The returned value is the binary digest, not a hex string.
|
||||
// Use digest[:] if a []byte is needed.
|
||||
func SumSHA256(data []byte) [32]byte {
|
||||
return sha256.Sum256(data)
|
||||
}
|
||||
|
||||
// EqualSHA256 returns true when both SHA-256 digests are identical.
|
||||
//
|
||||
// Since SHA-256 digest is represented as a fixed-size array [32]byte,
|
||||
// Go allows direct value comparison with ==.
|
||||
// This is the simplest and fastest approach for ordinary equality checks.
|
||||
func EqualSHA256(a, b [32]byte) bool {
|
||||
return a == b
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user