loader revisited
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
// Package appmeta provides shared application metadata used by both the
|
||||||
|
// bootstrap loader process and the standalone UI client process.
|
||||||
|
package appmeta
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AppID is the shared Fyne application identifier used for a common storage root.
|
||||||
|
AppID = "GalaxyPlus"
|
||||||
|
// DefaultBackendURL is the default backend HTTP endpoint used by local runs.
|
||||||
|
DefaultBackendURL = "http://127.0.0.1:8080"
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
checkConnectionInterval = 5 * time.Second
|
||||||
|
checkVersionInterval = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *client) startBackground() {
|
||||||
|
if e.fullConnector == nil && e.updater == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go e.backgroundLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) stopBackground() {
|
||||||
|
e.backgroundOnce.Do(func() {
|
||||||
|
close(e.backgroundStop)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) backgroundLoop() {
|
||||||
|
checkConnTimer := time.NewTimer(checkConnectionInterval)
|
||||||
|
checkVersionTimer := time.NewTimer(checkVersionInterval)
|
||||||
|
defer func() {
|
||||||
|
checkConnTimer.Stop()
|
||||||
|
checkVersionTimer.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.backgroundStop:
|
||||||
|
return
|
||||||
|
case <-checkConnTimer.C:
|
||||||
|
if e.fullConnector != nil {
|
||||||
|
e.OnConnection(e.fullConnector.CheckConnection())
|
||||||
|
}
|
||||||
|
checkConnTimer.Reset(checkConnectionInterval)
|
||||||
|
case <-checkVersionTimer.C:
|
||||||
|
if e.updater != nil {
|
||||||
|
if err := e.updater.CheckAndPrepareLatest(); err != nil {
|
||||||
|
e.handlerError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkVersionTimer.Reset(checkVersionInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *client) handlerError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case gerr.IsConnection(err):
|
||||||
|
e.OnConnectionError(err)
|
||||||
|
case gerr.IsStorage(err):
|
||||||
|
e.OnStorageError(err)
|
||||||
|
default:
|
||||||
|
e.OnServiceError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandlerErrorDispatchesByClass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantEvent string
|
||||||
|
}{
|
||||||
|
{name: "connection", err: gerr.WrapConnection(errors.New("dial")), wantEvent: "connection"},
|
||||||
|
{name: "storage", err: gerr.WrapStorage(errors.New("write file")), wantEvent: "storage"},
|
||||||
|
{name: "service", err: gerr.WrapService(errors.New("bad response")), wantEvent: "service"},
|
||||||
|
{name: "unclassified defaults to service", err: errors.New("plain"), wantEvent: "service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
c := &client{
|
||||||
|
onConnectionErrFn: func(error) { got = "connection" },
|
||||||
|
onStorageErrFn: func(error) { got = "storage" },
|
||||||
|
onServiceErrFn: func(error) { got = "service" },
|
||||||
|
}
|
||||||
|
|
||||||
|
c.handlerError(tt.err)
|
||||||
|
|
||||||
|
require.Equal(t, tt.wantEvent, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-10
@@ -4,6 +4,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"galaxy/client/updater"
|
||||||
"galaxy/client/world"
|
"galaxy/client/world"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
mc "galaxy/model/client"
|
mc "galaxy/model/client"
|
||||||
@@ -67,6 +68,17 @@ type client struct {
|
|||||||
viewportH int
|
viewportH int
|
||||||
|
|
||||||
hits []world.Hit
|
hits []world.Hit
|
||||||
|
|
||||||
|
fullStorage storage.Storage
|
||||||
|
fullConnector connector.Connector
|
||||||
|
updater *updater.Manager
|
||||||
|
backgroundStop chan struct{}
|
||||||
|
backgroundOnce sync.Once
|
||||||
|
|
||||||
|
onConnectionFn func(bool)
|
||||||
|
onConnectionErrFn func(error)
|
||||||
|
onStorageErrFn func(error)
|
||||||
|
onServiceErrFn func(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (mc.Client, error) {
|
func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (mc.Client, error) {
|
||||||
@@ -82,6 +94,16 @@ func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (m
|
|||||||
},
|
},
|
||||||
lastCanvasScale: 1.0,
|
lastCanvasScale: 1.0,
|
||||||
hits: make([]world.Hit, 5),
|
hits: make([]world.Hit, 5),
|
||||||
|
backgroundStop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if fullStorage, ok := s.(storage.Storage); ok {
|
||||||
|
e.fullStorage = fullStorage
|
||||||
|
}
|
||||||
|
if fullConnector, ok := conn.(connector.Connector); ok {
|
||||||
|
e.fullConnector = fullConnector
|
||||||
|
}
|
||||||
|
if e.fullStorage != nil && e.fullConnector != nil {
|
||||||
|
e.updater = updater.NewManager(e.fullStorage, e.fullConnector)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.loadReportFunc = e.loadReport
|
e.loadReportFunc = e.loadReport
|
||||||
@@ -128,10 +150,6 @@ func (e *client) setReport(r report.Report) {
|
|||||||
e.loadWorld(w)
|
e.loadWorld(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *client) handlerError(err error) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *client) BuildUI(w fyne.Window) {
|
func (e *client) BuildUI(w fyne.Window) {
|
||||||
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped)
|
||||||
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
|
mapCanvas.SetMinSize(fyne.NewSize(292, 292))
|
||||||
@@ -201,19 +219,40 @@ func (e *client) loadWorld(w *world.World) {
|
|||||||
func (e *client) Run() error {
|
func (e *client) Run() error {
|
||||||
e.BuildUI(e.window)
|
e.BuildUI(e.window)
|
||||||
e.window.SetMaster()
|
e.window.SetMaster()
|
||||||
e.window.ShowAndRun()
|
e.startBackground()
|
||||||
e.RequestRefresh()
|
e.RequestRefresh()
|
||||||
|
e.window.ShowAndRun()
|
||||||
|
e.stopBackground()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *client) Shutdown() { e.window.Close() }
|
func (e *client) Shutdown() {
|
||||||
|
e.stopBackground()
|
||||||
|
e.window.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func (e *client) Version() string { return version }
|
func (e *client) Version() string { return version }
|
||||||
|
|
||||||
func (e *client) OnConnection(bool) {}
|
func (e *client) OnConnection(isGood bool) {
|
||||||
|
if e.onConnectionFn != nil {
|
||||||
|
e.onConnectionFn(isGood)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *client) OnConnectionError(error) {}
|
func (e *client) OnConnectionError(err error) {
|
||||||
|
if e.onConnectionErrFn != nil {
|
||||||
|
e.onConnectionErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *client) OnStorageError(error) {}
|
func (e *client) OnStorageError(err error) {
|
||||||
|
if e.onStorageErrFn != nil {
|
||||||
|
e.onStorageErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *client) OnServiceError(error) {}
|
func (e *client) OnServiceError(err error) {
|
||||||
|
if e.onServiceErrFn != nil {
|
||||||
|
e.onServiceErrFn(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"galaxy/client/appmeta"
|
||||||
"galaxy/client/loader"
|
"galaxy/client/loader"
|
||||||
"galaxy/connector/http"
|
"galaxy/connector/http"
|
||||||
"galaxy/storage/fs"
|
"galaxy/storage/fs"
|
||||||
@@ -30,12 +31,12 @@ func main() {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
app := app.NewWithID("GalaxyPlus")
|
app := app.NewWithID(appmeta.AppID)
|
||||||
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c, err := http.NewHttpConnector(ctx, "http://127.0.0.1:8080")
|
c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"galaxy/client"
|
|
||||||
"galaxy/client/loader"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Factory loader.ClientInit = client.NewClient
|
|
||||||
+18
-2
@@ -1,10 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"galaxy/client/appmeta"
|
||||||
"galaxy/client"
|
"galaxy/client"
|
||||||
|
"galaxy/connector/http"
|
||||||
|
"galaxy/storage/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
"fyne.io/fyne/v2/app"
|
"fyne.io/fyne/v2/app"
|
||||||
)
|
)
|
||||||
@@ -22,8 +27,19 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
app := app.New()
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
c, err := client.NewClient(nil, nil, app)
|
defer cancel()
|
||||||
|
|
||||||
|
app := app.NewWithID(appmeta.AppID)
|
||||||
|
s, err := fs.NewFS(app.Storage().RootURI().Path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, err := client.NewClient(s, conn, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
replace galaxy/loader v0.0.0 => ../loader/
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.0 // indirect
|
fyne.io/systray v1.12.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
+140
-181
@@ -1,259 +1,218 @@
|
|||||||
/*
|
|
||||||
Пакет loader - основная точка входа в клиенское приложение.
|
|
||||||
|
|
||||||
Задачи:
|
|
||||||
- Загрузка и выполнение плагина, в котором сосредоточена логика основного UI,
|
|
||||||
- Выполнение операций с локальным Storage,
|
|
||||||
- Выполнение операций обмена данными с сервером.
|
|
||||||
*/
|
|
||||||
package loader
|
package loader
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"galaxy/client/updater"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
mc "galaxy/model/client"
|
|
||||||
"galaxy/storage"
|
"galaxy/storage"
|
||||||
"plugin"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"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 {
|
type loader struct {
|
||||||
app fyne.App
|
app fyne.App
|
||||||
storage storage.Storage
|
storage storage.Storage
|
||||||
connector connector.Connector
|
connector connector.Connector
|
||||||
client mc.Client
|
updater *updater.Manager
|
||||||
window fyne.Window
|
runner uiRunner
|
||||||
|
debugWindow fyne.Window
|
||||||
textGrid *widget.TextGrid
|
textGrid *widget.TextGrid
|
||||||
btn *widget.Button
|
btn *widget.Button
|
||||||
fatalError bool
|
|
||||||
loaded chan struct{}
|
ctx context.Context
|
||||||
|
|
||||||
|
resultMu sync.Mutex
|
||||||
|
result error
|
||||||
|
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closeQuits bool
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size
|
||||||
pluginInitSymbol = "Factory"
|
// from the active Fyne text metrics.
|
||||||
libUIPluginFile = "libui"
|
func loaderLogViewportMinSize(app fyne.App) fyne.Size {
|
||||||
)
|
if app == nil || app.Driver() == nil {
|
||||||
|
return fyne.NewSize(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
cellSize, _ := app.Driver().RenderedTextSize(
|
||||||
checkConnectionTimeout = time.Second * 5
|
"M",
|
||||||
checkVersionTimeout = time.Minute * 60
|
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) {
|
func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) {
|
||||||
l := &loader{
|
l := &loader{
|
||||||
app: app,
|
app: app,
|
||||||
connector: conn,
|
connector: conn,
|
||||||
storage: s,
|
storage: s,
|
||||||
|
updater: updater.NewManager(s, conn),
|
||||||
|
runner: execRunner{},
|
||||||
textGrid: widget.NewTextGrid(),
|
textGrid: widget.NewTextGrid(),
|
||||||
window: app.NewWindow("Loader"),
|
debugWindow: app.NewWindow("Loader"),
|
||||||
loaded: make(chan struct{}),
|
|
||||||
}
|
}
|
||||||
l.btn = widget.NewButton("OK", l.onButtonAction)
|
l.btn = widget.NewButton("Retry", l.onButtonAction)
|
||||||
l.btn.Disable()
|
l.btn.Disable()
|
||||||
|
l.textGrid.Scroll = fyne.ScrollNone
|
||||||
|
l.debugWindow.SetCloseIntercept(l.onWindowClose)
|
||||||
|
|
||||||
content := container.NewStack(
|
logScroll := container.NewScroll(l.textGrid)
|
||||||
l.textGrid,
|
logScroll.Direction = container.ScrollBoth
|
||||||
container.NewHBox(
|
logScroll.SetMinSize(loaderLogViewportMinSize(app))
|
||||||
container.NewCenter(
|
|
||||||
l.btn,
|
actionBar := container.NewCenter(container.NewHBox(l.btn))
|
||||||
),
|
|
||||||
),
|
content := container.NewBorder(nil, actionBar, nil, nil, logScroll)
|
||||||
)
|
l.debugWindow.SetContent(content)
|
||||||
l.window.SetContent(content)
|
l.debugWindow.Resize(content.MinSize())
|
||||||
|
l.debugWindow.SetFixedSize(true)
|
||||||
|
l.debugWindow.CenterOnScreen()
|
||||||
|
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updatePluginFromVersion заменяет файл {libUIPluginFile} с плагином на новую версию, которая была ранее загружена и сохранена в State
|
func (l *loader) runOnce(ctx context.Context) error {
|
||||||
func (l *loader) updatePluginFromVersion() error {
|
target, err := l.updater.EnsureLaunchTarget()
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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) {
|
// init prepares and launches the standalone UI client, or shows a retry button on failure.
|
||||||
pluginExists, pluginPath, err := l.storage.FileExists(libUIPluginFile)
|
func (l *loader) init(ctx context.Context) {
|
||||||
if err != nil {
|
l.setCloseQuits(false)
|
||||||
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
|
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
l.textGrid.SetText("")
|
l.textGrid.SetText("")
|
||||||
l.btn.Hide()
|
l.btn.Hide()
|
||||||
l.btn.Disable()
|
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()
|
err := l.runOnce(ctx)
|
||||||
if err == nil {
|
if err == nil || errors.Is(err, context.Canceled) {
|
||||||
|
l.setResult(nil)
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
l.window.Hide()
|
l.debugWindow.Hide()
|
||||||
err = l.client.Run()
|
l.app.Quit()
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
|
l.setCloseQuits(true)
|
||||||
|
l.setResult(err)
|
||||||
|
l.logError(err)
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
if l.fatalError {
|
|
||||||
l.btn.SetText("Quit")
|
|
||||||
l.logText("Please re-install application.")
|
|
||||||
} else {
|
|
||||||
l.btn.SetText("Retry")
|
l.btn.SetText("Retry")
|
||||||
}
|
|
||||||
l.btn.Enable()
|
l.btn.Enable()
|
||||||
l.btn.Show()
|
l.btn.Show()
|
||||||
l.window.Show()
|
l.debugWindow.Show()
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
l.loaded <- struct{}{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loader) onButtonAction() {
|
func (l *loader) onButtonAction() {
|
||||||
if l.fatalError {
|
if l.ctx == nil {
|
||||||
l.app.Quit()
|
return
|
||||||
} else {
|
|
||||||
go l.init()
|
|
||||||
}
|
}
|
||||||
|
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) {
|
func (l *loader) logText(v string) {
|
||||||
|
if l.textGrid == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
fyne.Do(func() { l.textGrid.Append(v) })
|
fyne.Do(func() { l.textGrid.Append(v) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loader) logError(err error) {
|
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) {
|
func (l *loader) setResult(err error) {
|
||||||
l.logError(err)
|
l.resultMu.Lock()
|
||||||
l.fatalError = true
|
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 {
|
func (l *loader) Run(ctx context.Context) error {
|
||||||
// start initializing process
|
l.ctx = ctx
|
||||||
go l.init()
|
|
||||||
|
go l.init(ctx)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
fyne.Do(l.app.Quit)
|
||||||
|
}()
|
||||||
|
|
||||||
// run UI engine, not the main Client app
|
|
||||||
l.app.Run()
|
l.app.Run()
|
||||||
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
// wait for successfull 'loaded' signal from init()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
return nil
|
||||||
case <-l.loaded:
|
|
||||||
return l.runClient(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
return l.getResult()
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package loader
|
||||||
|
|
||||||
import (
|
import "crypto/sha256"
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
||||||
// SumSHA256 calculates SHA-256 for the provided byte slice and returns
|
// SumSHA256 calculates SHA-256 for the provided byte slice and returns
|
||||||
// the raw 32-byte digest as a fixed-size array.
|
// 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 {
|
func SumSHA256(data []byte) [32]byte {
|
||||||
return sha256.Sum256(data)
|
return sha256.Sum256(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EqualSHA256 returns true when both SHA-256 digests are identical.
|
// 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 {
|
func EqualSHA256(a, b [32]byte) bool {
|
||||||
return a == b
|
return a == b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// Package updater manages standalone UI client artifacts, version selection,
|
||||||
|
// and persisted update state shared by the loader and the UI process.
|
||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"galaxy/connector"
|
||||||
|
gerr "galaxy/error"
|
||||||
|
mc "galaxy/model/client"
|
||||||
|
"galaxy/storage"
|
||||||
|
"galaxy/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ArtifactDir keeps versioned UI executables isolated from user data files.
|
||||||
|
ArtifactDir = "ui"
|
||||||
|
// ArtifactPrefix is the file name prefix used for all managed UI artifacts.
|
||||||
|
ArtifactPrefix = "client-ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LaunchTarget describes the executable artifact selected for the next UI run.
|
||||||
|
type LaunchTarget struct {
|
||||||
|
Version string
|
||||||
|
Path string
|
||||||
|
Pending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager coordinates client update state, artifact downloads, and cleanup.
|
||||||
|
type Manager struct {
|
||||||
|
storage storage.Storage
|
||||||
|
connector connector.Connector
|
||||||
|
goos string
|
||||||
|
goarch string
|
||||||
|
kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option customizes Manager construction.
|
||||||
|
type Option func(*Manager)
|
||||||
|
|
||||||
|
// WithPlatform overrides the runtime platform used for version matching.
|
||||||
|
func WithPlatform(goos, goarch string) Option {
|
||||||
|
return func(m *Manager) {
|
||||||
|
if goos != "" {
|
||||||
|
m.goos = goos
|
||||||
|
}
|
||||||
|
if goarch != "" {
|
||||||
|
m.goarch = goarch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithArtifactKind overrides the artifact kind accepted by the manager.
|
||||||
|
func WithArtifactKind(kind string) Option {
|
||||||
|
return func(m *Manager) {
|
||||||
|
if kind != "" {
|
||||||
|
m.kind = kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager constructs an update manager for standalone executable artifacts.
|
||||||
|
func NewManager(s storage.Storage, c connector.Connector, opts ...Option) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
storage: s,
|
||||||
|
connector: c,
|
||||||
|
goos: runtime.GOOS,
|
||||||
|
goarch: runtime.GOARCH,
|
||||||
|
kind: connector.ArtifactKindExecutable,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtifactPath returns the deterministic local storage path for the given versioned artifact.
|
||||||
|
func ArtifactPath(version, goos, goarch, kind string) string {
|
||||||
|
name := fmt.Sprintf("%s-%s-%s-%s-%s", ArtifactPrefix, version, goos, goarch, kind)
|
||||||
|
if goos == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
return filepath.Join(ArtifactDir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestCompatibleVersion returns the latest supported version for the selected platform and kind.
|
||||||
|
func LatestCompatibleVersion(versions []connector.VersionInfo, goos, goarch, kind string) (connector.VersionInfo, bool, error) {
|
||||||
|
platformMatches := make([]connector.VersionInfo, 0, len(versions))
|
||||||
|
for _, version := range versions {
|
||||||
|
if version.OS == goos && version.Arch == goarch {
|
||||||
|
platformMatches = append(platformMatches, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(platformMatches) == 0 {
|
||||||
|
return connector.VersionInfo{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make([]connector.VersionInfo, 0, len(platformMatches))
|
||||||
|
unsupportedKinds := make(map[string]struct{})
|
||||||
|
seenVersion := make(map[string]struct{})
|
||||||
|
for _, version := range platformMatches {
|
||||||
|
if version.Kind != kind {
|
||||||
|
unsupportedKinds[version.Kind] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seenVersion[version.Version]; ok {
|
||||||
|
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||||
|
fmt.Errorf("ambiguous client artifact version %q for %s/%s", version.Version, goos, goarch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
seenVersion[version.Version] = struct{}{}
|
||||||
|
candidates = append(candidates, version)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
kinds := make([]string, 0, len(unsupportedKinds))
|
||||||
|
for kind := range unsupportedKinds {
|
||||||
|
kinds = append(kinds, kind)
|
||||||
|
}
|
||||||
|
slices.Sort(kinds)
|
||||||
|
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||||
|
fmt.Errorf("unsupported client artifact kind(s) for %s/%s: %s", goos, goarch, strings.Join(kinds, ", ")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type semVersion struct {
|
||||||
|
info connector.VersionInfo
|
||||||
|
sem util.SemVer
|
||||||
|
}
|
||||||
|
semvers := make([]semVersion, len(candidates))
|
||||||
|
for i, candidate := range candidates {
|
||||||
|
semver, err := util.ParseSemver(candidate.Version)
|
||||||
|
if err != nil {
|
||||||
|
return connector.VersionInfo{}, false, gerr.WrapService(
|
||||||
|
fmt.Errorf("parse client version %q: %w", candidate.Version, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
semvers[i] = semVersion{info: candidate, sem: semver}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(semvers, func(a, b semVersion) int {
|
||||||
|
return util.CompareSemver(a.sem, b.sem)
|
||||||
|
})
|
||||||
|
return semvers[0].info, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureLaunchTarget returns the versioned executable that should be launched next.
|
||||||
|
// On the very first run, when no current or pending version exists yet, it downloads
|
||||||
|
// the latest compatible artifact and marks it as pending.
|
||||||
|
func (m *Manager) EnsureLaunchTarget() (LaunchTarget, error) {
|
||||||
|
state, err := m.ensureState()
|
||||||
|
if err != nil {
|
||||||
|
return LaunchTarget{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.ClientNextVersion != nil {
|
||||||
|
return m.launchTargetForVersion(*state.ClientNextVersion, true)
|
||||||
|
}
|
||||||
|
if state.ClientCurrentVersion != "" {
|
||||||
|
return m.launchTargetForVersion(state.ClientCurrentVersion, false)
|
||||||
|
}
|
||||||
|
if err := m.CheckAndPrepareLatest(); err != nil {
|
||||||
|
return LaunchTarget{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err = m.ensureState()
|
||||||
|
if err != nil {
|
||||||
|
return LaunchTarget{}, err
|
||||||
|
}
|
||||||
|
if state.ClientNextVersion == nil {
|
||||||
|
return LaunchTarget{}, gerr.WrapService(errors.New("latest client version was not prepared for launch"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.launchTargetForVersion(*state.ClientNextVersion, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndPrepareLatest checks the backend manifest and downloads a newer compatible
|
||||||
|
// artifact when one exists.
|
||||||
|
func (m *Manager) CheckAndPrepareLatest() error {
|
||||||
|
if m.connector == nil {
|
||||||
|
return gerr.WrapService(errors.New("client updater connector is not configured"))
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := m.connector.CheckVersion()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
latest, ok, err := LatestCompatibleVersion(versions, m.goos, m.goarch, m.kind)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return gerr.WrapService(
|
||||||
|
fmt.Errorf("server did not provide a compatible %s client for %s/%s", m.kind, m.goos, m.goarch),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := m.ensureState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
latestSemver, err := util.ParseSemver(latest.Version)
|
||||||
|
if err != nil {
|
||||||
|
return gerr.WrapService(fmt.Errorf("parse latest client version %q: %w", latest.Version, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.ClientCurrentVersion != "" {
|
||||||
|
currentSemver, err := util.ParseSemver(state.ClientCurrentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return gerr.WrapService(fmt.Errorf("parse current client version %q: %w", state.ClientCurrentVersion, err))
|
||||||
|
}
|
||||||
|
if util.CompareSemver(currentSemver, latestSemver) >= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if state.ClientNextVersion != nil {
|
||||||
|
nextSemver, err := util.ParseSemver(*state.ClientNextVersion)
|
||||||
|
if err != nil {
|
||||||
|
return gerr.WrapService(fmt.Errorf("parse pending client version %q: %w", *state.ClientNextVersion, err))
|
||||||
|
}
|
||||||
|
if util.CompareSemver(nextSemver, latestSemver) >= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.downloadArtifact(latest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ClientNextVersion = &latest.Version
|
||||||
|
return m.saveState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkLaunchResult records the outcome of a launched artifact and promotes
|
||||||
|
// pending versions to current only after a successful run.
|
||||||
|
func (m *Manager) MarkLaunchResult(version string, exitCode int, runErr error) error {
|
||||||
|
state, err := m.ensureState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.ClientNextVersion != nil && *state.ClientNextVersion == version {
|
||||||
|
if runErr == nil && exitCode == 0 {
|
||||||
|
state.ClientCurrentVersion = version
|
||||||
|
}
|
||||||
|
state.ClientNextVersion = nil
|
||||||
|
if err := m.saveState(state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.cleanupArtifacts(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if runErr == nil && exitCode == 0 {
|
||||||
|
return m.cleanupArtifacts(state)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) launchTargetForVersion(version string, pending bool) (LaunchTarget, error) {
|
||||||
|
path := ArtifactPath(version, m.goos, m.goarch, m.kind)
|
||||||
|
exists, absPath, err := m.storage.FileExists(path)
|
||||||
|
if err != nil {
|
||||||
|
return LaunchTarget{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return LaunchTarget{}, gerr.WrapStorage(
|
||||||
|
fmt.Errorf("client artifact for version %q not found at %q", version, path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return LaunchTarget{
|
||||||
|
Version: version,
|
||||||
|
Path: absPath,
|
||||||
|
Pending: pending,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ensureState() (mc.State, error) {
|
||||||
|
if m.storage == nil {
|
||||||
|
return mc.State{}, gerr.WrapStorage(errors.New("client updater storage is not configured"))
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := m.storage.StateExists()
|
||||||
|
if err != nil {
|
||||||
|
return mc.State{}, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
state := mc.State{}
|
||||||
|
if err := m.storage.SaveState(state); err != nil {
|
||||||
|
return mc.State{}, err
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
return m.storage.LoadState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) saveState(state mc.State) error {
|
||||||
|
return m.storage.SaveState(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) downloadArtifact(version connector.VersionInfo) error {
|
||||||
|
data, err := m.connector.DownloadVersion(version.URL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digest := connector.NewSHA256Digest(data)
|
||||||
|
if !digest.Equal(version.Checksum) {
|
||||||
|
return gerr.WrapService(fmt.Errorf("downloaded client artifact checksum mismatch for version %s", version.Version))
|
||||||
|
}
|
||||||
|
|
||||||
|
path := ArtifactPath(version.Version, version.OS, version.Arch, version.Kind)
|
||||||
|
exists, _, err := m.storage.FileExists(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
storedData, err := m.storage.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if connector.NewSHA256Digest(storedData).Equal(version.Checksum) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := m.storage.DeleteFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.storage.WriteFile(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) cleanupArtifacts(state mc.State) error {
|
||||||
|
files, err := m.storage.ListFiles()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
retain := make(map[string]struct{}, 2)
|
||||||
|
if state.ClientCurrentVersion != "" {
|
||||||
|
retain[ArtifactPath(state.ClientCurrentVersion, m.goos, m.goarch, m.kind)] = struct{}{}
|
||||||
|
}
|
||||||
|
if state.ClientNextVersion != nil {
|
||||||
|
retain[ArtifactPath(*state.ClientNextVersion, m.goos, m.goarch, m.kind)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := filepath.ToSlash(ArtifactDir) + "/"
|
||||||
|
for _, file := range files {
|
||||||
|
slashed := filepath.ToSlash(file)
|
||||||
|
if !strings.HasPrefix(slashed, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(filepath.Base(file), ArtifactPrefix+"-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := retain[file]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.storage.DeleteFile(file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package updater
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/connector"
|
||||||
|
gerr "galaxy/error"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArtifactPathWindowsAddsExe(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := ArtifactPath("1.2.3", "windows", "amd64", connector.ArtifactKindExecutable)
|
||||||
|
require.Equal(t, `ui\client-ui-1.2.3-windows-amd64-executable.exe`, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestCompatibleVersionSelectsPlatformExecutable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
versions := []connector.VersionInfo{
|
||||||
|
{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||||
|
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.0"},
|
||||||
|
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.3.0"},
|
||||||
|
{OS: "windows", Arch: "arm64", Kind: connector.ArtifactKindExecutable, Version: "9.9.9"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, "1.3.0", got.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestCompatibleVersionRejectsUnsupportedKinds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
versions := []connector.VersionInfo{
|
||||||
|
{OS: "windows", Arch: "amd64", Kind: "shared-library", Version: "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.True(t, gerr.IsService(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestCompatibleVersionRejectsAmbiguousVersions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
versions := []connector.VersionInfo{
|
||||||
|
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||||
|
{OS: "windows", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.0.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok, err := LatestCompatibleVersion(versions, "windows", "amd64", connector.ArtifactKindExecutable)
|
||||||
|
require.False(t, ok)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.True(t, gerr.IsService(err))
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ArtifactKindExecutable identifies a downloadable standalone executable artifact.
|
||||||
|
ArtifactKindExecutable = "executable"
|
||||||
|
)
|
||||||
|
|
||||||
// Connector is a main interface to provide connectivity with app's server.
|
// Connector is a main interface to provide connectivity with app's server.
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
UIConnector
|
UIConnector
|
||||||
@@ -33,10 +38,12 @@ type UIConnector interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VersionInfo struct {
|
type VersionInfo struct {
|
||||||
OS string `json:"os"` // Operating System name (unix, darwin, windows, etc.)
|
OS string `json:"os"` // Operating System name (linux, darwin, windows, etc.)
|
||||||
|
Arch string `json:"arch"` // Target CPU architecture name (amd64, arm64, etc.)
|
||||||
|
Kind string `json:"kind"` // Artifact kind, currently [ArtifactKindExecutable].
|
||||||
Version string `json:"version"` // Semver format: X.Y.Z
|
Version string `json:"version"` // Semver format: X.Y.Z
|
||||||
URL string `json:"url"` // Artifact download URL for this version
|
URL string `json:"url"` // Artifact download URL for this version
|
||||||
Checksum SHA256Digest `json:"sha256"` // Base64 SHA-256 checksum for artifact binary data
|
Checksum SHA256Digest `json:"sha256"` // Hex SHA-256 checksum for artifact binary data
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA256Digest represents a SHA-256 digest in raw binary form.
|
// SHA256Digest represents a SHA-256 digest in raw binary form.
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) {
|
|||||||
|
|
||||||
original := VersionInfo{
|
original := VersionInfo{
|
||||||
OS: "linux",
|
OS: "linux",
|
||||||
|
Arch: "amd64",
|
||||||
|
Kind: ArtifactKindExecutable,
|
||||||
Version: "1.2.3",
|
Version: "1.2.3",
|
||||||
URL: "http://server:8080",
|
URL: "http://server:8080",
|
||||||
Checksum: NewSHA256Digest([]byte("payload")),
|
Checksum: NewSHA256Digest([]byte("payload")),
|
||||||
@@ -83,6 +85,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.Equal(t, original.OS, decoded.OS)
|
require.Equal(t, original.OS, decoded.OS)
|
||||||
|
require.Equal(t, original.Arch, decoded.Arch)
|
||||||
|
require.Equal(t, original.Kind, decoded.Kind)
|
||||||
require.Equal(t, original.Version, decoded.Version)
|
require.Equal(t, original.Version, decoded.Version)
|
||||||
require.Equal(t, original.URL, decoded.URL)
|
require.Equal(t, original.URL, decoded.URL)
|
||||||
require.True(t, original.Checksum.Equal(decoded.Checksum))
|
require.True(t, original.Checksum.Equal(decoded.Checksum))
|
||||||
|
|||||||
+63
-10
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
|
gerr "galaxy/error"
|
||||||
"galaxy/model/client"
|
"galaxy/model/client"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
"io"
|
"io"
|
||||||
@@ -59,7 +60,7 @@ type httpConnector struct {
|
|||||||
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
|
func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) {
|
||||||
u, err := url.Parse(backendURL)
|
u, err := url.Parse(backendURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, gerr.WrapService(fmt.Errorf("parse backend URL %q: %w", backendURL, err))
|
||||||
}
|
}
|
||||||
h := &httpConnector{
|
h := &httpConnector{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
@@ -162,6 +163,58 @@ func isConnectTimeout(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isConnectionError reports transport-level connectivity failures that should
|
||||||
|
// be surfaced as connection errors instead of service contract errors.
|
||||||
|
func isConnectionError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isConnectTimeout(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) {
|
||||||
|
err = urlErr.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyConnectorError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if gerr.IsConnection(err) || gerr.IsService(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isConnectionError(err) {
|
||||||
|
return gerr.WrapConnection(err)
|
||||||
|
}
|
||||||
|
return gerr.WrapService(err)
|
||||||
|
}
|
||||||
|
|
||||||
// CheckConnection probes backend status endpoint and reports whether server is reachable.
|
// CheckConnection probes backend status endpoint and reports whether server is reachable.
|
||||||
func (h *httpConnector) CheckConnection() bool {
|
func (h *httpConnector) CheckConnection() bool {
|
||||||
resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
|
resp, err := h.doRequest(h.requestContext(), checkConnectionPath)
|
||||||
@@ -177,17 +230,17 @@ func (h *httpConnector) CheckConnection() bool {
|
|||||||
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
||||||
resp, err := h.doRequest(h.requestContext(), checkVersionPath)
|
resp, err := h.doRequest(h.requestContext(), checkVersionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request versions from backend: %w", err)
|
return nil, classifyConnectorError(fmt.Errorf("request versions from backend: %w", err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode)
|
return nil, classifyConnectorError(fmt.Errorf("request versions from backend: unexpected status code %d", resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
var versions []connector.VersionInfo
|
var versions []connector.VersionInfo
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&versions); err != nil {
|
||||||
return nil, fmt.Errorf("decode versions response: %w", err)
|
return nil, classifyConnectorError(fmt.Errorf("decode versions response: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return versions, nil
|
return versions, nil
|
||||||
@@ -198,17 +251,17 @@ func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) {
|
|||||||
func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) {
|
func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) {
|
||||||
resp, err := h.doRequest(h.requestContext(), urlOrPath)
|
resp, err := h.doRequest(h.requestContext(), urlOrPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download version artifact: %w", err)
|
return nil, classifyConnectorError(fmt.Errorf("download version artifact: %w", err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode)
|
return nil, classifyConnectorError(fmt.Errorf("download version artifact: unexpected status code %d", resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read version artifact body: %w", err)
|
return nil, classifyConnectorError(fmt.Errorf("read version artifact body: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return body, nil
|
return body, nil
|
||||||
@@ -228,17 +281,17 @@ func (h *httpConnector) FetchReport(_ client.GameID, turn uint, callback func(re
|
|||||||
func (h *httpConnector) fetchReport(turn uint) (report.Report, error) {
|
func (h *httpConnector) fetchReport(turn uint) (report.Report, error) {
|
||||||
resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn))
|
resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Report{}, fmt.Errorf("request report from backend: %w", err)
|
return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: %w", err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return report.Report{}, fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode)
|
return report.Report{}, classifyConnectorError(fmt.Errorf("request report from backend: unexpected status code %d", resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
var rep report.Report
|
var rep report.Report
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
|
||||||
return report.Report{}, fmt.Errorf("decode report response: %w", err)
|
return report.Report{}, classifyConnectorError(fmt.Errorf("decode report response: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return rep, nil
|
return rep, nil
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
|
gerr "galaxy/error"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -151,12 +152,12 @@ func TestCheckVersion(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
context.Background(),
|
context.Background(),
|
||||||
stdhttp.StatusOK,
|
stdhttp.StatusOK,
|
||||||
`[{"os":"darwin","version":"1.2.3","url":"https://example.com/darwin"}]`,
|
`[{"os":"darwin","arch":"amd64","kind":"executable","version":"1.2.3","url":"https://example.com/darwin"}]`,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
want: []connector.VersionInfo{
|
want: []connector.VersionInfo{
|
||||||
{OS: "darwin", Version: "1.2.3", URL: "https://example.com/darwin"},
|
{OS: "darwin", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "1.2.3", URL: "https://example.com/darwin"},
|
||||||
},
|
},
|
||||||
wantPath: "/api/v1/versions",
|
wantPath: "/api/v1/versions",
|
||||||
},
|
},
|
||||||
@@ -215,12 +216,12 @@ func TestCheckVersion(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
context.Background(),
|
context.Background(),
|
||||||
stdhttp.StatusOK,
|
stdhttp.StatusOK,
|
||||||
`[{"os":"linux","version":"2.0.0","url":"https://example.com/linux"}]`,
|
`[{"os":"linux","arch":"amd64","kind":"executable","version":"2.0.0","url":"https://example.com/linux"}]`,
|
||||||
"/base",
|
"/base",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
want: []connector.VersionInfo{
|
want: []connector.VersionInfo{
|
||||||
{OS: "linux", Version: "2.0.0", URL: "https://example.com/linux"},
|
{OS: "linux", Arch: "amd64", Kind: connector.ArtifactKindExecutable, Version: "2.0.0", URL: "https://example.com/linux"},
|
||||||
},
|
},
|
||||||
wantPath: "/base/api/v1/versions",
|
wantPath: "/base/api/v1/versions",
|
||||||
},
|
},
|
||||||
@@ -260,6 +261,65 @@ func TestCheckVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckVersionClassifiesTransportFailure verifies transport failures are surfaced as connection errors.
|
||||||
|
func TestCheckVersionClassifiesTransportFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
conn := newUnreachableConnector(t, context.Background())
|
||||||
|
|
||||||
|
_, err := conn.CheckVersion()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CheckVersion() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !gerr.IsConnection(err) {
|
||||||
|
t.Fatalf("CheckVersion() error = %v, want connection classified error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckVersionClassifiesInvalidJSON verifies malformed backend payloads are surfaced as service errors.
|
||||||
|
func TestCheckVersionClassifiesInvalidJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
conn, _ := newVersionServerConnector(
|
||||||
|
t,
|
||||||
|
context.Background(),
|
||||||
|
stdhttp.StatusOK,
|
||||||
|
`{"versions":`,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := conn.CheckVersion()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("CheckVersion() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !gerr.IsService(err) {
|
||||||
|
t.Fatalf("CheckVersion() error = %v, want service classified error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownloadVersionClassifiesUnexpectedStatus verifies HTTP protocol failures are surfaced as service errors.
|
||||||
|
func TestDownloadVersionClassifiesUnexpectedStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
server := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||||
|
w.WriteHeader(stdhttp.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
conn, err := NewHttpConnector(context.Background(), server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHttpConnector() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.DownloadVersion("downloads/client.bin")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("DownloadVersion() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !gerr.IsService(err) {
|
||||||
|
t.Fatalf("DownloadVersion() error = %v, want service classified error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFetchReport verifies asynchronous report retrieval behavior.
|
// TestFetchReport verifies asynchronous report retrieval behavior.
|
||||||
func TestFetchReport(t *testing.T) {
|
func TestFetchReport(t *testing.T) {
|
||||||
tests := []fetchReportCase{
|
tests := []fetchReportCase{
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package error
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Class describes a top-level operational error class that can be used by
|
||||||
|
// higher layers to route failures to the appropriate UI or transport handler.
|
||||||
|
type Class string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClassConnection marks connectivity and transport failures talking to remote services.
|
||||||
|
ClassConnection Class = "connection"
|
||||||
|
// ClassStorage marks local persistence and filesystem related failures.
|
||||||
|
ClassStorage Class = "storage"
|
||||||
|
// ClassService marks remote service contract and processing failures.
|
||||||
|
ClassService Class = "service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifiedError wraps another error with a top-level error class.
|
||||||
|
//
|
||||||
|
// The wrapped error remains available through Unwrap so existing callers may
|
||||||
|
// continue using errors.Is or errors.As against the original cause.
|
||||||
|
type ClassifiedError struct {
|
||||||
|
class Class
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error returns either the wrapped error text or, when no wrapped error is available,
|
||||||
|
// the textual representation of the class itself.
|
||||||
|
func (e *ClassifiedError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.err != nil {
|
||||||
|
return e.err.Error()
|
||||||
|
}
|
||||||
|
return string(e.class)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap exposes the wrapped cause for standard Go error inspection.
|
||||||
|
func (e *ClassifiedError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class returns the top-level class recorded on this error.
|
||||||
|
func (e *ClassifiedError) Class() Class {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return e.class
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is reports class equality so errors.Is(err, ErrConnection) style checks remain possible.
|
||||||
|
func (e *ClassifiedError) Is(target error) bool {
|
||||||
|
t, ok := target.(*ClassifiedError)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.class != "" && e.class == t.class
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrConnection is a class sentinel for connection related failures.
|
||||||
|
ErrConnection = &ClassifiedError{class: ClassConnection}
|
||||||
|
// ErrStorage is a class sentinel for storage related failures.
|
||||||
|
ErrStorage = &ClassifiedError{class: ClassStorage}
|
||||||
|
// ErrService is a class sentinel for service related failures.
|
||||||
|
ErrService = &ClassifiedError{class: ClassService}
|
||||||
|
)
|
||||||
|
|
||||||
|
// WrapConnection wraps err with the connection class unless it is already classified.
|
||||||
|
func WrapConnection(err error) error {
|
||||||
|
return wrapClass(ClassConnection, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapStorage wraps err with the storage class unless it is already classified.
|
||||||
|
func WrapStorage(err error) error {
|
||||||
|
return wrapClass(ClassStorage, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapService wraps err with the service class unless it is already classified.
|
||||||
|
func WrapService(err error) error {
|
||||||
|
return wrapClass(ClassService, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsConnection reports whether err is classified as a connection failure.
|
||||||
|
func IsConnection(err error) bool {
|
||||||
|
return errors.Is(err, ErrConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsStorage reports whether err is classified as a storage failure.
|
||||||
|
func IsStorage(err error) bool {
|
||||||
|
return errors.Is(err, ErrStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsService reports whether err is classified as a service failure.
|
||||||
|
func IsService(err error) bool {
|
||||||
|
return errors.Is(err, ErrService)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapClass(class Class, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if existing, ok := errors.AsType[*ClassifiedError](err); ok && existing.class == class {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return &ClassifiedError{class: class, err: err}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package error
|
||||||
|
|
||||||
|
import (
|
||||||
|
stderrors "errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifiedErrorWrapPreservesCause(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cause := stderrors.New("dial tcp: connection refused")
|
||||||
|
err := WrapConnection(cause)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, cause)
|
||||||
|
require.True(t, IsConnection(err))
|
||||||
|
require.False(t, IsStorage(err))
|
||||||
|
require.False(t, IsService(err))
|
||||||
|
|
||||||
|
classified, ok := stderrors.AsType[*ClassifiedError](err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, ClassConnection, classified.Class())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifiedErrorDoesNotDoubleWrapSameClass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cause := stderrors.New("write file")
|
||||||
|
first := WrapStorage(cause)
|
||||||
|
second := WrapStorage(first)
|
||||||
|
|
||||||
|
require.Same(t, first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenericErrorSupportsAsAndUnwrap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cause := stderrors.New("root cause")
|
||||||
|
err := newGenericError(ErrDummy, cause, "subject")
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, cause)
|
||||||
|
|
||||||
|
generic, ok := stderrors.AsType[*GenericError](err)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, ErrDummy, generic.Code)
|
||||||
|
require.Equal(t, "Dummy: subject: root cause", err.Error())
|
||||||
|
}
|
||||||
@@ -192,6 +192,11 @@ func (ge GenericError) Error() string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying error wrapped by GenericError, if any.
|
||||||
|
func (ge GenericError) Unwrap() error {
|
||||||
|
return ge.err
|
||||||
|
}
|
||||||
|
|
||||||
func newGenericError(code int, arg ...any) error {
|
func newGenericError(code int, arg ...any) error {
|
||||||
e := &GenericError{Code: code}
|
e := &GenericError{Code: code}
|
||||||
if len(arg) > 0 {
|
if len(arg) > 0 {
|
||||||
@@ -207,7 +212,7 @@ func newGenericError(code int, arg ...any) error {
|
|||||||
e.subject = asString(arg[i])
|
e.subject = asString(arg[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return *e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
func asString(v any) string {
|
func asString(v any) string {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
package error
|
package error
|
||||||
|
|
||||||
func NewRepoError(arg ...any) error {
|
func NewRepoError(arg ...any) error {
|
||||||
return newGenericError(ErrStorageFailure, arg...)
|
return WrapStorage(newGenericError(ErrStorageFailure, arg...))
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-32
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
"galaxy/model/client"
|
"galaxy/model/client"
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
@@ -77,17 +78,17 @@ func NewFS(storageRoot string) (*fsStorage, error) {
|
|||||||
fmt.Println("using fs root:", storageRoot)
|
fmt.Println("using fs root:", storageRoot)
|
||||||
absRoot, err := filepath.Abs(storageRoot)
|
absRoot, err := filepath.Abs(storageRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err)
|
return nil, classifyStorageError(fmt.Errorf("new fs storage: resolve absolute path for %q: %w", storageRoot, err))
|
||||||
}
|
}
|
||||||
if ok, err := util.PathExists(absRoot, true); err != nil {
|
if ok, err := util.PathExists(absRoot, true); err != nil {
|
||||||
return nil, fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err)
|
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q exists: %w", absRoot, err))
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, fmt.Errorf("new fs storage: path %q does not exist", absRoot)
|
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q does not exist", absRoot))
|
||||||
}
|
}
|
||||||
if ok, err := util.Writable(absRoot); err != nil {
|
if ok, err := util.Writable(absRoot); err != nil {
|
||||||
return nil, fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err)
|
return nil, classifyStorageError(fmt.Errorf("new fs storage: check path %q writable: %w", absRoot, err))
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
return nil, fmt.Errorf("new fs storage: path %q is not writable", absRoot)
|
return nil, classifyStorageError(fmt.Errorf("new fs storage: path %q is not writable", absRoot))
|
||||||
}
|
}
|
||||||
|
|
||||||
return &fsStorage{
|
return &fsStorage{
|
||||||
@@ -166,7 +167,7 @@ func (s *fsStorage) SaveOrderAsync(id client.GameID, turn uint, o order.Order, c
|
|||||||
func (s *fsStorage) FileExists(path string) (bool, string, error) {
|
func (s *fsStorage) FileExists(path string) (bool, string, error) {
|
||||||
absPath, err := s.resolvePath(path)
|
absPath, err := s.resolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return false, "", classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var exists bool
|
var exists bool
|
||||||
@@ -176,7 +177,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) {
|
|||||||
return opErr
|
return opErr
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return false, "", classifyStorageError(err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return false, absPath, nil
|
return false, absPath, nil
|
||||||
@@ -187,7 +188,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) {
|
|||||||
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
|
func (s *fsStorage) ReadFile(path string) ([]byte, error) {
|
||||||
absPath, err := s.resolvePath(path)
|
absPath, err := s.resolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var data []byte
|
var data []byte
|
||||||
@@ -196,27 +197,27 @@ func (s *fsStorage) ReadFile(path string) ([]byte, error) {
|
|||||||
data, opErr = s.readFileUnlocked(absPath)
|
data, opErr = s.readFileUnlocked(absPath)
|
||||||
return opErr
|
return opErr
|
||||||
})
|
})
|
||||||
return data, err
|
return data, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) WriteFile(path string, data []byte) error {
|
func (s *fsStorage) WriteFile(path string, data []byte) error {
|
||||||
absPath, err := s.resolvePath(path)
|
absPath, err := s.resolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.withPathLock(absPath, func() error {
|
return classifyStorageError(s.withPathLock(absPath, func() error {
|
||||||
return s.writeFileUnlocked(absPath, data)
|
return s.writeFileUnlocked(absPath, data)
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) DeleteFile(path string) error {
|
func (s *fsStorage) DeleteFile(path string) error {
|
||||||
absPath, err := s.resolvePath(path)
|
absPath, err := s.resolvePath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.withPathLock(absPath, func() error {
|
return classifyStorageError(s.withPathLock(absPath, func() error {
|
||||||
exists, err := s.fileExistsUnlocked(absPath)
|
exists, err := s.fileExistsUnlocked(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -228,7 +229,7 @@ func (s *fsStorage) DeleteFile(path string) error {
|
|||||||
return fmt.Errorf("delete file %q: %w", absPath, err)
|
return fmt.Errorf("delete file %q: %w", absPath, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) ListFiles() ([]string, error) {
|
func (s *fsStorage) ListFiles() ([]string, error) {
|
||||||
@@ -252,7 +253,7 @@ func (s *fsStorage) ListFiles() ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list files under %q: %w", s.storageRoot, err)
|
return nil, classifyStorageError(fmt.Errorf("list files under %q: %w", s.storageRoot, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
slices.Sort(files)
|
slices.Sort(files)
|
||||||
@@ -261,29 +262,30 @@ func (s *fsStorage) ListFiles() ([]string, error) {
|
|||||||
|
|
||||||
func (s *fsStorage) StateExists() (bool, error) {
|
func (s *fsStorage) StateExists() (bool, error) {
|
||||||
exists, _, err := s.FileExists(stateFileName)
|
exists, _, err := s.FileExists(stateFileName)
|
||||||
return exists, err
|
return exists, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) LoadState() (client.State, error) {
|
func (s *fsStorage) LoadState() (client.State, error) {
|
||||||
data, err := s.ReadFile(stateFileName)
|
data, err := s.ReadFile(stateFileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return client.State{}, err
|
return client.State{}, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
return unmarshalState(data)
|
state, err := unmarshalState(data)
|
||||||
|
return state, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) SaveState(state client.State) error {
|
func (s *fsStorage) SaveState(state client.State) error {
|
||||||
data, err := marshalState(state)
|
data, err := marshalState(state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return classifyStorageError(err)
|
||||||
}
|
}
|
||||||
return s.WriteFile(stateFileName, data)
|
return classifyStorageError(s.WriteFile(stateFileName, data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
|
func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report, error) {
|
||||||
gameData, err := s.loadGameDataSync(id, turn)
|
gameData, err := s.loadGameDataSync(id, turn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Report{}, err
|
return report.Report{}, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
return gameData.Report, nil
|
return gameData.Report, nil
|
||||||
}
|
}
|
||||||
@@ -291,10 +293,10 @@ func (s *fsStorage) loadReportSync(id client.GameID, turn uint) (report.Report,
|
|||||||
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
|
func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Report) error {
|
||||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.withPathLock(absPath, func() error {
|
return classifyStorageError(s.withPathLock(absPath, func() error {
|
||||||
gameData, err := s.loadGameDataUnlocked(absPath)
|
gameData, err := s.loadGameDataUnlocked(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -306,16 +308,16 @@ func (s *fsStorage) saveReportSync(id client.GameID, turn uint, rep report.Repor
|
|||||||
gameData.Turn = turn
|
gameData.Turn = turn
|
||||||
gameData.Report = rep
|
gameData.Report = rep
|
||||||
return s.writeGameDataUnlocked(absPath, gameData)
|
return s.writeGameDataUnlocked(absPath, gameData)
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
|
func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) {
|
||||||
gameData, err := s.loadGameDataSync(id, turn)
|
gameData, err := s.loadGameDataSync(id, turn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return order.Order{}, err
|
return order.Order{}, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
if gameData.Order == nil {
|
if gameData.Order == nil {
|
||||||
return order.Order{}, fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist)
|
return order.Order{}, classifyStorageError(fmt.Errorf("load order for game %q turn %d: %w", id, turn, os.ErrNotExist))
|
||||||
}
|
}
|
||||||
return *gameData.Order, nil
|
return *gameData.Order, nil
|
||||||
}
|
}
|
||||||
@@ -323,10 +325,10 @@ func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, err
|
|||||||
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
|
func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) error {
|
||||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.withPathLock(absPath, func() error {
|
return classifyStorageError(s.withPathLock(absPath, func() error {
|
||||||
gameData, err := s.loadGameDataUnlocked(absPath)
|
gameData, err := s.loadGameDataUnlocked(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -338,13 +340,13 @@ func (s *fsStorage) saveOrderSync(id client.GameID, turn uint, o order.Order) er
|
|||||||
gameData.Turn = turn
|
gameData.Turn = turn
|
||||||
gameData.Order = &o
|
gameData.Order = &o
|
||||||
return s.writeGameDataUnlocked(absPath, gameData)
|
return s.writeGameDataUnlocked(absPath, gameData)
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
|
func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) {
|
||||||
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
absPath, err := s.resolvePath(gameTurnFilePath(id, turn))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return client.GameData{}, err
|
return client.GameData{}, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var gameData client.GameData
|
var gameData client.GameData
|
||||||
@@ -353,7 +355,7 @@ func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameDa
|
|||||||
gameData, opErr = s.loadGameDataUnlocked(absPath)
|
gameData, opErr = s.loadGameDataUnlocked(absPath)
|
||||||
return opErr
|
return opErr
|
||||||
})
|
})
|
||||||
return gameData, err
|
return gameData, classifyStorageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
|
func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) {
|
||||||
@@ -713,3 +715,13 @@ func (s *fsStorage) ensureParentDir(absPath string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyStorageError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if gerr.IsStorage(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gerr.WrapStorage(err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
gerr "galaxy/error"
|
||||||
"galaxy/model/client"
|
"galaxy/model/client"
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
@@ -240,10 +241,44 @@ func TestPathTraversalRejected(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("write %q unexpectedly succeeded", path)
|
t.Fatalf("write %q unexpectedly succeeded", path)
|
||||||
}
|
}
|
||||||
|
if !gerr.IsStorage(err) {
|
||||||
|
t.Fatalf("write %q error = %v, want storage classified error", path, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteFileClassifiesAndPreservesNotExist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestStorage(t)
|
||||||
|
|
||||||
|
err := s.DeleteFile("missing.txt")
|
||||||
|
if !gerr.IsStorage(err) {
|
||||||
|
t.Fatalf("DeleteFile() error = %v, want storage classified error", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("DeleteFile() error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadStateClassifiesDecodeErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newTestStorage(t)
|
||||||
|
if err := os.WriteFile(filepath.Join(s.storageRoot, stateFileName), []byte("{"), 0o644); err != nil {
|
||||||
|
t.Fatalf("seed invalid state file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.LoadState()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("LoadState() error = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if !gerr.IsStorage(err) {
|
||||||
|
t.Fatalf("LoadState() error = %v, want storage classified error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
|
func TestAtomicWriteFirstAndOverwrite(t *testing.T) {
|
||||||
s := newTestStorage(t)
|
s := newTestStorage(t)
|
||||||
target := filepath.Join("turns", "12.bin")
|
target := filepath.Join("turns", "12.bin")
|
||||||
|
|||||||
Reference in New Issue
Block a user