diff --git a/client/appmeta/meta.go b/client/appmeta/meta.go new file mode 100644 index 0000000..cecdfb5 --- /dev/null +++ b/client/appmeta/meta.go @@ -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" +) diff --git a/client/background.go b/client/background.go new file mode 100644 index 0000000..0fbcd5f --- /dev/null +++ b/client/background.go @@ -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) + } +} diff --git a/client/background_test.go b/client/background_test.go new file mode 100644 index 0000000..009155d --- /dev/null +++ b/client/background_test.go @@ -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) + }) + } +} diff --git a/client/client.go b/client/client.go index c912b45..0fc89fa 100644 --- a/client/client.go +++ b/client/client.go @@ -4,6 +4,7 @@ import ( "image" "sync" + "galaxy/client/updater" "galaxy/client/world" "galaxy/connector" mc "galaxy/model/client" @@ -67,6 +68,17 @@ type client struct { viewportH int 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) { @@ -82,6 +94,16 @@ func NewClient(s storage.UIStorage, conn connector.UIConnector, app fyne.App) (m }, lastCanvasScale: 1.0, 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 @@ -128,10 +150,6 @@ func (e *client) setReport(r report.Report) { e.loadWorld(w) } -func (e *client) handlerError(err error) { - -} - func (e *client) BuildUI(w fyne.Window) { mapCanvas := newInteractiveRaster(e, e.raster, e.onRasterWidgetLayout, e.onScrolled, e.onDragged, e.onDradEnd, e.onTapped) mapCanvas.SetMinSize(fyne.NewSize(292, 292)) @@ -201,19 +219,40 @@ func (e *client) loadWorld(w *world.World) { func (e *client) Run() error { e.BuildUI(e.window) e.window.SetMaster() - e.window.ShowAndRun() + e.startBackground() e.RequestRefresh() + e.window.ShowAndRun() + e.stopBackground() 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) 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) + } +} diff --git a/client/cmd/loader/main.go b/client/cmd/loader/main.go index 4710306..1f6e1bd 100644 --- a/client/cmd/loader/main.go +++ b/client/cmd/loader/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "galaxy/client/appmeta" "galaxy/client/loader" "galaxy/connector/http" "galaxy/storage/fs" @@ -30,12 +31,12 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() - app := app.NewWithID("GalaxyPlus") + app := app.NewWithID(appmeta.AppID) s, err := fs.NewFS(app.Storage().RootURI().Path()) if err != nil { return } - c, err := http.NewHttpConnector(ctx, "http://127.0.0.1:8080") + c, err := http.NewHttpConnector(ctx, appmeta.DefaultBackendURL) if err != nil { return } diff --git a/client/cmd/plugin/plugin.go b/client/cmd/plugin/plugin.go deleted file mode 100644 index 88864d1..0000000 --- a/client/cmd/plugin/plugin.go +++ /dev/null @@ -1,8 +0,0 @@ -package main - -import ( - "galaxy/client" - "galaxy/client/loader" -) - -var Factory loader.ClientInit = client.NewClient diff --git a/client/cmd/ui/main.go b/client/cmd/ui/main.go index 43d9dbb..1e83fb6 100644 --- a/client/cmd/ui/main.go +++ b/client/cmd/ui/main.go @@ -1,10 +1,15 @@ package main import ( + "context" "errors" "fmt" + "galaxy/client/appmeta" "galaxy/client" + "galaxy/connector/http" + "galaxy/storage/fs" "os" + "os/signal" "fyne.io/fyne/v2/app" ) @@ -22,8 +27,19 @@ func main() { os.Exit(1) } }() - app := app.New() - c, err := client.NewClient(nil, nil, app) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + 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 { return } diff --git a/client/go.mod b/client/go.mod index bba7629..a4fa64f 100644 --- a/client/go.mod +++ b/client/go.mod @@ -8,8 +8,6 @@ require ( github.com/stretchr/testify v1.11.1 ) -replace galaxy/loader v0.0.0 => ../loader/ - require ( fyne.io/systray v1.12.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/client/loader/client.go b/client/loader/client.go deleted file mode 100644 index 6adf64f..0000000 --- a/client/loader/client.go +++ /dev/null @@ -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) -} diff --git a/client/loader/download.go b/client/loader/download.go deleted file mode 100644 index e36fe49..0000000 --- a/client/loader/download.go +++ /dev/null @@ -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 -} diff --git a/client/loader/loader.go b/client/loader/loader.go index 24a519c..44f4391 100644 --- a/client/loader/loader.go +++ b/client/loader/loader.go @@ -1,259 +1,218 @@ -/* -Пакет loader - основная точка входа в клиенское приложение. - -Задачи: - - Загрузка и выполнение плагина, в котором сосредоточена логика основного UI, - - Выполнение операций с локальным Storage, - - Выполнение операций обмена данными с сервером. -*/ package loader import ( "context" "errors" "fmt" + "sync" + + "galaxy/client/updater" "galaxy/connector" - mc "galaxy/model/client" "galaxy/storage" - "plugin" - "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) -type ClientInit func(storage.UIStorage, connector.UIConnector, fyne.App) (mc.Client, error) +const ( + loaderLogViewportColumns = 80 + loaderLogViewportRows = 12 +) type loader struct { - app fyne.App - storage storage.Storage - connector connector.Connector - client mc.Client - window fyne.Window - textGrid *widget.TextGrid - btn *widget.Button - fatalError bool - loaded chan struct{} + app fyne.App + storage storage.Storage + connector connector.Connector + updater *updater.Manager + runner uiRunner + debugWindow fyne.Window + textGrid *widget.TextGrid + btn *widget.Button + + ctx context.Context + + resultMu sync.Mutex + result error + + closeMu sync.Mutex + closeQuits bool } -const ( - pluginInitSymbol = "Factory" - libUIPluginFile = "libui" -) +// loaderLogViewportMinSize derives a stable monospace TextGrid viewport size +// from the active Fyne text metrics. +func loaderLogViewportMinSize(app fyne.App) fyne.Size { + if app == nil || app.Driver() == nil { + return fyne.NewSize(0, 0) + } -var ( - checkConnectionTimeout = time.Second * 5 - checkVersionTimeout = time.Minute * 60 -) + cellSize, _ := app.Driver().RenderedTextSize( + "M", + theme.TextSize(), + fyne.TextStyle{Monospace: true}, + nil, + ) + + return fyne.NewSize( + cellSize.Width*loaderLogViewportColumns, + cellSize.Height*loaderLogViewportRows, + ) +} func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*loader, error) { l := &loader{ - app: app, - connector: conn, - storage: s, - textGrid: widget.NewTextGrid(), - window: app.NewWindow("Loader"), - loaded: make(chan struct{}), + app: app, + connector: conn, + storage: s, + updater: updater.NewManager(s, conn), + runner: execRunner{}, + textGrid: widget.NewTextGrid(), + debugWindow: app.NewWindow("Loader"), } - l.btn = widget.NewButton("OK", l.onButtonAction) + l.btn = widget.NewButton("Retry", l.onButtonAction) l.btn.Disable() + l.textGrid.Scroll = fyne.ScrollNone + l.debugWindow.SetCloseIntercept(l.onWindowClose) - content := container.NewStack( - l.textGrid, - container.NewHBox( - container.NewCenter( - l.btn, - ), - ), - ) - l.window.SetContent(content) + logScroll := container.NewScroll(l.textGrid) + logScroll.Direction = container.ScrollBoth + logScroll.SetMinSize(loaderLogViewportMinSize(app)) + + actionBar := container.NewCenter(container.NewHBox(l.btn)) + + content := container.NewBorder(nil, actionBar, nil, nil, logScroll) + l.debugWindow.SetContent(content) + l.debugWindow.Resize(content.MinSize()) + l.debugWindow.SetFixedSize(true) + l.debugWindow.CenterOnScreen() return l, nil } -// updatePluginFromVersion заменяет файл {libUIPluginFile} с плагином на новую версию, которая была ранее загружена и сохранена в State -func (l *loader) updatePluginFromVersion() error { - state, stateExists, err := l.loadCurrentState() - if err != nil { - return fmt.Errorf("Load State error: %w", err) - } - if !stateExists { - return nil - } - if state.ClientNextVersion == nil { - return nil - } - version := *state.ClientNextVersion - versionFileName := resolvePluginFile(version) - versionFileExists, _, err := l.storage.FileExists(versionFileName) - if err != nil { - return fmt.Errorf("Check plugin v%s exists error: %w", version, err) - } - if !versionFileExists { - return fmt.Errorf("Requested plugin v%s does not exists at local storage", version) - } - storedData, err := l.storage.ReadFile(versionFileName) +func (l *loader) runOnce(ctx context.Context) error { + target, err := l.updater.EnsureLaunchTarget() if err != nil { return err } - if err := l.storage.WriteFile(libUIPluginFile, storedData); err != nil { - return fmt.Errorf("Plugin file write error: %w", err) + + l.logText(fmt.Sprintf("Starting UI client v%s", target.Version)) + l.logText(fmt.Sprintf("Executable: %s", target.Path)) + + fyne.Do(func() { + l.debugWindow.Hide() + }) + + exitCode, runErr := l.runner.Run(ctx, target.Path) + markErr := l.updater.MarkLaunchResult(target.Version, exitCode, runErr) + + switch { + case runErr != nil: + return errors.Join(fmt.Errorf("launch UI client v%s: %w", target.Version, runErr), markErr) + case exitCode != 0: + return errors.Join(fmt.Errorf("UI client v%s exited with code %d", target.Version, exitCode), markErr) + default: + return markErr } - state.ClientCurrentVersion = version - state.ClientNextVersion = nil - if err := l.storage.SaveState(*state); err != nil { - return fmt.Errorf("State write error: %w", err) - } - return nil } -func (l *loader) initPlugin() (mc.Client, error) { - pluginExists, pluginPath, err := l.storage.FileExists(libUIPluginFile) - if err != nil { - l.appendFatalError(fmt.Errorf("Client plugin file lookup error: %w", err)) - return nil, err - } - - if !pluginExists { - l.logText("Client plugin file not found, fetching available versions") - err = l.checkAndDownloadPluginVersion() - if err != nil { - l.logError(err) - return nil, err - } - } - - err = l.updatePluginFromVersion() - if err != nil { - return nil, err - } - - l.logText(fmt.Sprintf("Loading client plugin from %s", pluginPath)) - cli, err := loadClientPlugin(l.storage, l.connector, l.app, pluginPath, pluginInitSymbol) - if err != nil { - l.appendFatalError(err) - return nil, err - } - - // need to store current plugin version for the very first app start - if err := l.updateCurrentVersion(cli.Version()); err != nil { - return nil, err - } - return cli, nil -} - -// init инициализирует плагин клиента и запускает его, либо отображает пользователю лог с ошибками -func (l *loader) init() { - l.fatalError = false +// init prepares and launches the standalone UI client, or shows a retry button on failure. +func (l *loader) init(ctx context.Context) { + l.setCloseQuits(false) fyne.Do(func() { l.textGrid.SetText("") l.btn.Hide() l.btn.Disable() + // show debugWindow can be done with future debug mode, e.g. with -debug flag + // l.window.Show() }) - var err error - l.client, err = l.initPlugin() - if err == nil { + + err := l.runOnce(ctx) + if err == nil || errors.Is(err, context.Canceled) { + l.setResult(nil) fyne.Do(func() { - l.window.Hide() - err = l.client.Run() + l.debugWindow.Hide() + l.app.Quit() }) + return } - if err != nil { - fyne.Do(func() { - if l.fatalError { - l.btn.SetText("Quit") - l.logText("Please re-install application.") - } else { - l.btn.SetText("Retry") - } - l.btn.Enable() - l.btn.Show() - l.window.Show() - }) - } else { - l.loaded <- struct{}{} - } + + l.setCloseQuits(true) + l.setResult(err) + l.logError(err) + fyne.Do(func() { + l.btn.SetText("Retry") + l.btn.Enable() + l.btn.Show() + l.debugWindow.Show() + }) } func (l *loader) onButtonAction() { - if l.fatalError { - l.app.Quit() - } else { - go l.init() + if l.ctx == nil { + return } + go l.init(l.ctx) +} + +func (l *loader) onWindowClose() { + if l.getCloseQuits() { + l.app.Quit() + return + } + + l.debugWindow.Hide() } func (l *loader) logText(v string) { + if l.textGrid == nil { + return + } fyne.Do(func() { l.textGrid.Append(v) }) } func (l *loader) logError(err error) { - l.logText(fmt.Sprintf("❌ %s", err)) + l.logText(fmt.Sprintf("ERROR: %s", err)) } -func (l *loader) appendFatalError(err error) { - l.logError(err) - l.fatalError = true +func (l *loader) setResult(err error) { + l.resultMu.Lock() + defer l.resultMu.Unlock() + l.result = err } -// Run is the main entry point for start Client +func (l *loader) getResult() error { + l.resultMu.Lock() + defer l.resultMu.Unlock() + return l.result +} + +func (l *loader) setCloseQuits(v bool) { + l.closeMu.Lock() + defer l.closeMu.Unlock() + l.closeQuits = v +} + +func (l *loader) getCloseQuits() bool { + l.closeMu.Lock() + defer l.closeMu.Unlock() + return l.closeQuits +} + +// Run starts the loader window, launches the standalone UI process, and returns +// the final launch result once the loader application exits. func (l *loader) Run(ctx context.Context) error { - // start initializing process - go l.init() + l.ctx = ctx + + go l.init(ctx) + go func() { + <-ctx.Done() + fyne.Do(l.app.Quit) + }() - // run UI engine, not the main Client app l.app.Run() - - // wait for successfull 'loaded' signal from init() - select { - case <-ctx.Done(): + if errors.Is(ctx.Err(), context.Canceled) { return nil - case <-l.loaded: - return l.runClient(ctx) } -} - -// runClient запусукает основного Client и блокирует выполнение в текущем потоке -func (l *loader) runClient(ctx context.Context) error { - if l.client == nil { - return errors.New("run: client wasn't initialized, this is an program fatal error.") - } - - bgSignal := make(chan struct{}, 1) - go l.backgroundLoop(ctx, bgSignal) - // stop backbround loop before exin func - defer func() { bgSignal <- struct{}{} }() - - // Client's Run() blocks execution until window is closed, or error is returned immediately - if err := l.client.Run(); err != nil { - return err - } - - return nil -} - -// loadClientPlugin loads a Client implementation from a shared plugin file at the specified path. -// It calls the constructor function by name, passing the necessary dependencies, and returns the initialized Client. -func loadClientPlugin(s storage.UIStorage, conn connector.UIConnector, app fyne.App, path, name string) (mc.Client, error) { - if path == "" { - return nil, errors.New("no plugin path given") - } - - plug, err := plugin.Open(path) - if err != nil { - return nil, fmt.Errorf("open plugin %q: %w", path, err) - } - - sym, err := plug.Lookup(name) - if err != nil { - return nil, fmt.Errorf("lookup symbol %q: %w", name, err) - } - - initializerPtr, ok := sym.(*ClientInit) - if !ok { - return nil, fmt.Errorf("unexpected type %T; want %T", sym, initializerPtr) - } - - return (*initializerPtr)(s, conn, app) + return l.getResult() } diff --git a/client/loader/loader_test.go b/client/loader/loader_test.go new file mode 100644 index 0000000..40121ea --- /dev/null +++ b/client/loader/loader_test.go @@ -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 +} diff --git a/client/loader/loader_ui_test.go b/client/loader/loader_ui_test.go new file mode 100644 index 0000000..a11a2f7 --- /dev/null +++ b/client/loader/loader_ui_test.go @@ -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) +} diff --git a/client/loader/runner.go b/client/loader/runner.go new file mode 100644 index 0000000..30f9705 --- /dev/null +++ b/client/loader/runner.go @@ -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 +} diff --git a/client/loader/service.go b/client/loader/service.go deleted file mode 100644 index bad6783..0000000 --- a/client/loader/service.go +++ /dev/null @@ -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) - } - } -} diff --git a/client/loader/state.go b/client/loader/state.go deleted file mode 100644 index 35af2ee..0000000 --- a/client/loader/state.go +++ /dev/null @@ -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 -} diff --git a/client/loader/storage.go b/client/loader/storage.go deleted file mode 100644 index 049a7f3..0000000 --- a/client/loader/storage.go +++ /dev/null @@ -1,8 +0,0 @@ -package loader - -func (l *loader) clientPluginVersionExists(version string) (bool, error) { - file := resolvePluginFile(version) - _ = file - // check file existence - return false, nil -} diff --git a/client/loader/util.go b/client/loader/util.go index 0f18904..83857f4 100644 --- a/client/loader/util.go +++ b/client/loader/util.go @@ -1,59 +1,14 @@ package loader -import ( - "crypto/sha256" - "fmt" - "galaxy/connector" - "galaxy/util" - "runtime" - "slices" -) - -func resolvePluginFile(version string) string { - return libUIPluginFile + "-" + version -} - -// latestVersion should return VersionInfo with the latest Version for the current OS -func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, bool, error) { - os := runtime.GOOS - versions = slices.DeleteFunc(versions, func(v connector.VersionInfo) bool { return v.OS != os }) - if len(versions) == 0 { - return connector.VersionInfo{}, false, nil - } - type v struct { - vi *connector.VersionInfo - sv *util.SemVer - } - semvers := make([]*v, len(versions)) - for i := range versions { - sv, err := util.ParseSemver(versions[i].Version) - if err != nil { - return connector.VersionInfo{}, false, fmt.Errorf("latest version: %w", err) - } - semvers[i] = &v{ - vi: &versions[i], - sv: &sv, - } - } - slices.SortFunc(semvers, func(a, b *v) int { return util.CompareSemver(*b.sv, *a.sv) }) - return *semvers[0].vi, true, nil -} +import "crypto/sha256" // SumSHA256 calculates SHA-256 for the provided byte slice and returns // the raw 32-byte digest as a fixed-size array. -// -// The function does not modify the input data. -// The returned value is the binary digest, not a hex string. -// Use digest[:] if a []byte is needed. func SumSHA256(data []byte) [32]byte { return sha256.Sum256(data) } // EqualSHA256 returns true when both SHA-256 digests are identical. -// -// Since SHA-256 digest is represented as a fixed-size array [32]byte, -// Go allows direct value comparison with ==. -// This is the simplest and fastest approach for ordinary equality checks. func EqualSHA256(a, b [32]byte) bool { return a == b } diff --git a/client/updater/manager.go b/client/updater/manager.go new file mode 100644 index 0000000..ffa890f --- /dev/null +++ b/client/updater/manager.go @@ -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 +} diff --git a/client/updater/manager_test.go b/client/updater/manager_test.go new file mode 100644 index 0000000..78efbf2 --- /dev/null +++ b/client/updater/manager_test.go @@ -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)) +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 0237750..62726e9 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -9,6 +9,11 @@ import ( "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. type Connector interface { UIConnector @@ -33,10 +38,12 @@ type UIConnector interface { } 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 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. diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index 6e286fa..12c67b2 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -70,6 +70,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) { original := VersionInfo{ OS: "linux", + Arch: "amd64", + Kind: ArtifactKindExecutable, Version: "1.2.3", URL: "http://server:8080", Checksum: NewSHA256Digest([]byte("payload")), @@ -83,6 +85,8 @@ func TestFileMetadataJSONRoundTrip(t *testing.T) { require.NoError(t, err) 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.URL, decoded.URL) require.True(t, original.Checksum.Equal(decoded.Checksum)) diff --git a/pkg/connector/http/http.go b/pkg/connector/http/http.go index 4f08455..43c8f07 100644 --- a/pkg/connector/http/http.go +++ b/pkg/connector/http/http.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "galaxy/connector" + gerr "galaxy/error" "galaxy/model/client" "galaxy/model/report" "io" @@ -59,7 +60,7 @@ type httpConnector struct { func NewHttpConnector(ctx context.Context, backendURL string) (*httpConnector, error) { u, err := url.Parse(backendURL) if err != nil { - return nil, err + return nil, gerr.WrapService(fmt.Errorf("parse backend URL %q: %w", backendURL, err)) } h := &httpConnector{ ctx: ctx, @@ -162,6 +163,58 @@ func isConnectTimeout(err error) bool { 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. func (h *httpConnector) CheckConnection() bool { resp, err := h.doRequest(h.requestContext(), checkConnectionPath) @@ -177,17 +230,17 @@ func (h *httpConnector) CheckConnection() bool { func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) { resp, err := h.doRequest(h.requestContext(), checkVersionPath) 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() 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 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 @@ -198,17 +251,17 @@ func (h *httpConnector) CheckVersion() ([]connector.VersionInfo, error) { func (h *httpConnector) DownloadVersion(urlOrPath string) ([]byte, error) { resp, err := h.doRequest(h.requestContext(), urlOrPath) 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() 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) 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 @@ -228,17 +281,17 @@ func (h *httpConnector) FetchReport(_ client.GameID, turn uint, callback func(re func (h *httpConnector) fetchReport(turn uint) (report.Report, error) { resp, err := h.doRequest(h.requestContext(), fetchReportRequestPath(turn)) 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() 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 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 diff --git a/pkg/connector/http/http_test.go b/pkg/connector/http/http_test.go index e903a61..c41c6c6 100644 --- a/pkg/connector/http/http_test.go +++ b/pkg/connector/http/http_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "galaxy/connector" + gerr "galaxy/error" "galaxy/model/report" "io" "net" @@ -151,12 +152,12 @@ func TestCheckVersion(t *testing.T) { t, context.Background(), 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{ - {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", }, @@ -215,12 +216,12 @@ func TestCheckVersion(t *testing.T) { t, context.Background(), 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", ) }, 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", }, @@ -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. func TestFetchReport(t *testing.T) { tests := []fetchReportCase{ diff --git a/pkg/error/class.go b/pkg/error/class.go new file mode 100644 index 0000000..568fb31 --- /dev/null +++ b/pkg/error/class.go @@ -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} +} diff --git a/pkg/error/class_test.go b/pkg/error/class_test.go new file mode 100644 index 0000000..268f0c5 --- /dev/null +++ b/pkg/error/class_test.go @@ -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()) +} diff --git a/pkg/error/generic.go b/pkg/error/generic.go index 6de99f7..f619fd2 100644 --- a/pkg/error/generic.go +++ b/pkg/error/generic.go @@ -192,6 +192,11 @@ func (ge GenericError) Error() string { 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 { e := &GenericError{Code: code} if len(arg) > 0 { @@ -207,7 +212,7 @@ func newGenericError(code int, arg ...any) error { e.subject = asString(arg[i]) } } - return *e + return e } func asString(v any) string { diff --git a/pkg/error/repo.go b/pkg/error/repo.go index 26c1f26..3afff30 100644 --- a/pkg/error/repo.go +++ b/pkg/error/repo.go @@ -1,5 +1,5 @@ package error func NewRepoError(arg ...any) error { - return newGenericError(ErrStorageFailure, arg...) + return WrapStorage(newGenericError(ErrStorageFailure, arg...)) } diff --git a/pkg/storage/fs/fs.go b/pkg/storage/fs/fs.go index a074e89..7519cb9 100644 --- a/pkg/storage/fs/fs.go +++ b/pkg/storage/fs/fs.go @@ -12,6 +12,7 @@ import ( "strings" "sync" + gerr "galaxy/error" "galaxy/model/client" "galaxy/model/order" "galaxy/model/report" @@ -77,17 +78,17 @@ func NewFS(storageRoot string) (*fsStorage, error) { fmt.Println("using fs root:", storageRoot) absRoot, err := filepath.Abs(storageRoot) 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 { - 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 { - 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 { - 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 { - 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{ @@ -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) { absPath, err := s.resolvePath(path) if err != nil { - return false, "", err + return false, "", classifyStorageError(err) } var exists bool @@ -176,7 +177,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) { return opErr }) if err != nil { - return false, "", err + return false, "", classifyStorageError(err) } if !exists { 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) { absPath, err := s.resolvePath(path) if err != nil { - return nil, err + return nil, classifyStorageError(err) } var data []byte @@ -196,27 +197,27 @@ func (s *fsStorage) ReadFile(path string) ([]byte, error) { data, opErr = s.readFileUnlocked(absPath) return opErr }) - return data, err + return data, classifyStorageError(err) } func (s *fsStorage) WriteFile(path string, data []byte) error { absPath, err := s.resolvePath(path) 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) - }) + })) } func (s *fsStorage) DeleteFile(path string) error { absPath, err := s.resolvePath(path) 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) if err != nil { return err @@ -228,7 +229,7 @@ func (s *fsStorage) DeleteFile(path string) error { return fmt.Errorf("delete file %q: %w", absPath, err) } return nil - }) + })) } func (s *fsStorage) ListFiles() ([]string, error) { @@ -252,7 +253,7 @@ func (s *fsStorage) ListFiles() ([]string, error) { return 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) @@ -261,29 +262,30 @@ func (s *fsStorage) ListFiles() ([]string, error) { func (s *fsStorage) StateExists() (bool, error) { exists, _, err := s.FileExists(stateFileName) - return exists, err + return exists, classifyStorageError(err) } func (s *fsStorage) LoadState() (client.State, error) { data, err := s.ReadFile(stateFileName) 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 { data, err := marshalState(state) 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) { gameData, err := s.loadGameDataSync(id, turn) if err != nil { - return report.Report{}, err + return report.Report{}, classifyStorageError(err) } 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 { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) 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) if err != nil { 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.Report = rep return s.writeGameDataUnlocked(absPath, gameData) - }) + })) } func (s *fsStorage) loadOrderSync(id client.GameID, turn uint) (order.Order, error) { gameData, err := s.loadGameDataSync(id, turn) if err != nil { - return order.Order{}, err + return order.Order{}, classifyStorageError(err) } 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 } @@ -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 { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) 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) if err != nil { 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.Order = &o return s.writeGameDataUnlocked(absPath, gameData) - }) + })) } func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameData, error) { absPath, err := s.resolvePath(gameTurnFilePath(id, turn)) if err != nil { - return client.GameData{}, err + return client.GameData{}, classifyStorageError(err) } var gameData client.GameData @@ -353,7 +355,7 @@ func (s *fsStorage) loadGameDataSync(id client.GameID, turn uint) (client.GameDa gameData, opErr = s.loadGameDataUnlocked(absPath) return opErr }) - return gameData, err + return gameData, classifyStorageError(err) } func (s *fsStorage) loadGameDataUnlocked(absPath string) (client.GameData, error) { @@ -713,3 +715,13 @@ func (s *fsStorage) ensureParentDir(absPath string) error { } return nil } + +func classifyStorageError(err error) error { + if err == nil { + return nil + } + if gerr.IsStorage(err) { + return err + } + return gerr.WrapStorage(err) +} diff --git a/pkg/storage/fs/fs_test.go b/pkg/storage/fs/fs_test.go index 6068249..efcd848 100644 --- a/pkg/storage/fs/fs_test.go +++ b/pkg/storage/fs/fs_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + gerr "galaxy/error" "galaxy/model/client" "galaxy/model/order" "galaxy/model/report" @@ -240,10 +241,44 @@ func TestPathTraversalRejected(t *testing.T) { if err == nil { 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) { s := newTestStorage(t) target := filepath.Join("turns", "12.bin")