loader logic revised
This commit is contained in:
+8
-4
@@ -206,10 +206,14 @@ func (e *client) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *client) Shutdown() {
|
func (e *client) Shutdown() { e.window.Close() }
|
||||||
e.window.Close()
|
|
||||||
}
|
func (e *client) Version() string { return version }
|
||||||
|
|
||||||
func (e *client) OnConnection(bool) {}
|
func (e *client) OnConnection(bool) {}
|
||||||
|
|
||||||
func (e *client) Version() string { return version }
|
func (e *client) OnConnectionError(error) {}
|
||||||
|
|
||||||
|
func (e *client) OnStorageError(error) {}
|
||||||
|
|
||||||
|
func (e *client) OnServiceError(error) {}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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,19 +1,84 @@
|
|||||||
package loader
|
package loader
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l *loader) newerVersion(version string) bool {
|
func (l *loader) checkAndDownloadPluginVersion() error {
|
||||||
return util.CompareSemver(util.MustParseSemver(l.client.Version()), util.MustParseSemver(version)) > 0
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadVersion fetches given version artifact, when newer to the current version,
|
// preparePluginVersion fetches and stores given version of plugin artifact, if newer to the current known version.
|
||||||
// and stores at the App's local storage with a pre-defined name with semver suffix
|
func (l *loader) preparePluginVersion(v connector.VersionInfo) error {
|
||||||
func (l *loader) downloadVersion(v connector.VersionInfo) {
|
s, exists, err := l.loadCurrentState()
|
||||||
if !l.newerVersion(v.Version) {
|
if err != nil {
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
l.connector.DownloadVersion(v.URL)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-60
@@ -1,3 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Пакет loader - основная точка входа в клиенское приложение.
|
||||||
|
|
||||||
|
Задачи:
|
||||||
|
- Загрузка и выполнение плагина, в котором сосредоточена логика основного UI,
|
||||||
|
- Выполнение операций с локальным Storage,
|
||||||
|
- Выполнение операций обмена данными с сервером.
|
||||||
|
*/
|
||||||
package loader
|
package loader
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -64,50 +72,78 @@ func NewLoader(s storage.Storage, conn connector.Connector, app fyne.App) (*load
|
|||||||
return l, nil
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := l.storage.WriteFile(libUIPluginFile, storedData); err != nil {
|
||||||
|
return fmt.Errorf("Plugin file write error: %w", err)
|
||||||
|
}
|
||||||
|
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) {
|
func (l *loader) initPlugin() (mc.Client, error) {
|
||||||
exists, pluginPath, err := l.storage.FileExists(libUIPluginFile)
|
pluginExists, pluginPath, err := l.storage.FileExists(libUIPluginFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.appendFatalError(fmt.Errorf("Client plugin file lookup error: %w", err))
|
l.appendFatalError(fmt.Errorf("Client plugin file lookup error: %w", err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
|
if !pluginExists {
|
||||||
l.logText("Client plugin file not found, fetching available versions")
|
l.logText("Client plugin file not found, fetching available versions")
|
||||||
v, err := l.connector.CheckVersion()
|
err = l.checkAndDownloadPluginVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.logError(err)
|
l.logError(err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
l.logText(fmt.Sprintf("Received %d versions", len(v)))
|
|
||||||
latest, ok, err := latestVersion(v)
|
|
||||||
if err != nil {
|
|
||||||
l.logError(err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
l.logError(errors.New("Server did not responded with a suitable client version"))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
l.logText(fmt.Sprintf("Downloading version %s", latest.Version))
|
|
||||||
data, err := l.connector.DownloadVersion(latest.URL)
|
|
||||||
if err != nil {
|
|
||||||
l.logError(fmt.Errorf("Version %s download error: %w", latest.Version, err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = l.storage.WriteFile(libUIPluginFile, data)
|
|
||||||
if err != nil {
|
|
||||||
l.appendFatalError(fmt.Errorf("Plugin file write error: %w", err))
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = l.updatePluginFromVersion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
l.logText(fmt.Sprintf("Loading client plugin from %s", pluginPath))
|
l.logText(fmt.Sprintf("Loading client plugin from %s", pluginPath))
|
||||||
cli, err := loadClientPlugin(l.storage, l.connector, l.app, pluginPath, pluginInitSymbol)
|
cli, err := loadClientPlugin(l.storage, l.connector, l.app, pluginPath, pluginInitSymbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.appendFatalError(err)
|
l.appendFatalError(err)
|
||||||
return nil, 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
|
return cli, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// init инициализирует плагин клиента и запускает его, либо отображает пользователю лог с ошибками
|
||||||
func (l *loader) init() {
|
func (l *loader) init() {
|
||||||
l.fatalError = false
|
l.fatalError = false
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
@@ -161,9 +197,15 @@ func (l *loader) appendFatalError(err error) {
|
|||||||
l.fatalError = true
|
l.fatalError = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run is the main entry point for start Client
|
||||||
func (l *loader) Run(ctx context.Context) error {
|
func (l *loader) Run(ctx context.Context) error {
|
||||||
|
// start initializing process
|
||||||
go l.init()
|
go l.init()
|
||||||
|
|
||||||
|
// run UI engine, not the main Client app
|
||||||
l.app.Run()
|
l.app.Run()
|
||||||
|
|
||||||
|
// wait for successfull 'loaded' signal from init()
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
@@ -172,50 +214,23 @@ func (l *loader) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runClient запусукает основного Client и блокирует выполнение в текущем потоке
|
||||||
func (l *loader) runClient(ctx context.Context) error {
|
func (l *loader) runClient(ctx context.Context) error {
|
||||||
if l.client == nil {
|
if l.client == nil {
|
||||||
return errors.New("run: client wasn't initialized, this is an program fatal error.")
|
return errors.New("run: client wasn't initialized, this is an program fatal error.")
|
||||||
}
|
}
|
||||||
final := make(chan struct{}, 1)
|
|
||||||
go l.backgroundLoop(ctx, final)
|
bgSignal := make(chan struct{}, 1)
|
||||||
defer func() { final <- struct{}{} }()
|
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 {
|
if err := l.client.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
final <- struct{}{}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *loader) backgroundLoop(ctx context.Context, final <-chan struct{}) {
|
return nil
|
||||||
checkConnTimer := time.NewTimer(checkConnectionTimeout)
|
|
||||||
checkVersionTimer := time.NewTimer(checkVersionTimeout)
|
|
||||||
defer func() {
|
|
||||||
checkConnTimer.Stop()
|
|
||||||
checkVersionTimer.Stop()
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
l.client.Shutdown()
|
|
||||||
return
|
|
||||||
case <-final:
|
|
||||||
return
|
|
||||||
case <-checkConnTimer.C:
|
|
||||||
isGood := l.connector.CheckConnection()
|
|
||||||
l.client.OnConnection(isGood)
|
|
||||||
checkConnTimer.Reset(checkConnectionTimeout)
|
|
||||||
case <-checkVersionTimer.C:
|
|
||||||
versions, err := l.connector.CheckVersion()
|
|
||||||
if err != nil {
|
|
||||||
// propagate error to the UI
|
|
||||||
} else if latest, ok, err := latestVersion(versions); err != nil {
|
|
||||||
// propagate error to the UI
|
|
||||||
} else if ok {
|
|
||||||
l.downloadVersion(latest)
|
|
||||||
}
|
|
||||||
checkVersionTimer.Reset(checkVersionTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadClientPlugin loads a Client implementation from a shared plugin file at the specified path.
|
// loadClientPlugin loads a Client implementation from a shared plugin file at the specified path.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+21
-1
@@ -1,6 +1,7 @@
|
|||||||
package loader
|
package loader
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"fmt"
|
"fmt"
|
||||||
"galaxy/connector"
|
"galaxy/connector"
|
||||||
"galaxy/util"
|
"galaxy/util"
|
||||||
@@ -12,7 +13,7 @@ func resolvePluginFile(version string) string {
|
|||||||
return libUIPluginFile + "-" + version
|
return libUIPluginFile + "-" + version
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestVersion should return VersionInfo with the latest Version for the current OD
|
// latestVersion should return VersionInfo with the latest Version for the current OS
|
||||||
func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, bool, error) {
|
func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, bool, error) {
|
||||||
os := runtime.GOOS
|
os := runtime.GOOS
|
||||||
versions = slices.DeleteFunc(versions, func(v connector.VersionInfo) bool { return v.OS != os })
|
versions = slices.DeleteFunc(versions, func(v connector.VersionInfo) bool { return v.OS != os })
|
||||||
@@ -37,3 +38,22 @@ func latestVersion(versions []connector.VersionInfo) (connector.VersionInfo, boo
|
|||||||
slices.SortFunc(semvers, func(a, b *v) int { return util.CompareSemver(*b.sv, *a.sv) })
|
slices.SortFunc(semvers, func(a, b *v) int { return util.CompareSemver(*b.sv, *a.sv) })
|
||||||
return *semvers[0].vi, true, nil
|
return *semvers[0].vi, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package loader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSumSHA256 verifies that SumSHA256 returns the same digest
|
||||||
|
// as the standard library implementation for a non-empty payload.
|
||||||
|
func TestSumSHA256(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := []byte("hello world")
|
||||||
|
expected := sha256.Sum256(data)
|
||||||
|
|
||||||
|
actual := SumSHA256(data)
|
||||||
|
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSumSHA256Empty verifies that SumSHA256 correctly handles
|
||||||
|
// an empty byte slice.
|
||||||
|
func TestSumSHA256Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := []byte{}
|
||||||
|
expected := sha256.Sum256(data)
|
||||||
|
|
||||||
|
actual := SumSHA256(data)
|
||||||
|
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEqualSHA256Same verifies that two identical digests
|
||||||
|
// are considered equal.
|
||||||
|
func TestEqualSHA256Same(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data := []byte("hello")
|
||||||
|
digest := sha256.Sum256(data)
|
||||||
|
|
||||||
|
require.True(t, EqualSHA256(digest, digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEqualSHA256Different verifies that different digests
|
||||||
|
// are considered not equal.
|
||||||
|
func TestEqualSHA256Different(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
digestA := sha256.Sum256([]byte("hello"))
|
||||||
|
digestB := sha256.Sum256([]byte("world"))
|
||||||
|
|
||||||
|
require.False(t, EqualSHA256(digestA, digestB))
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package connector
|
package connector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"galaxy/model/client"
|
"galaxy/model/client"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
)
|
)
|
||||||
@@ -29,7 +33,72 @@ 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 (unix, darwin, windows, etc.)
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256Digest represents a SHA-256 digest in raw binary form.
|
||||||
|
//
|
||||||
|
// Internally it stores the exact 32-byte digest.
|
||||||
|
// In JSON it is encoded as a lowercase hexadecimal string of 64 characters.
|
||||||
|
type SHA256Digest [32]byte
|
||||||
|
|
||||||
|
// NewSHA256Digest calculates SHA-256 for the provided byte slice
|
||||||
|
// and returns the digest as SHA256Digest.
|
||||||
|
//
|
||||||
|
// The function does not modify the input data.
|
||||||
|
func NewSHA256Digest(data []byte) SHA256Digest {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return SHA256Digest(sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the lowercase hexadecimal representation
|
||||||
|
// of the digest.
|
||||||
|
//
|
||||||
|
// The returned string always contains exactly 64 characters.
|
||||||
|
func (d SHA256Digest) String() string {
|
||||||
|
return hex.EncodeToString(d[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON encodes the digest as a JSON string containing
|
||||||
|
// the lowercase hexadecimal SHA-256 value.
|
||||||
|
//
|
||||||
|
// Example JSON value:
|
||||||
|
//
|
||||||
|
// "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
func (d SHA256Digest) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(d.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a JSON string containing a lowercase or uppercase
|
||||||
|
// hexadecimal SHA-256 value into the digest.
|
||||||
|
//
|
||||||
|
// The input must be a JSON string with exactly 64 hexadecimal characters.
|
||||||
|
func (d *SHA256Digest) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return fmt.Errorf("sha256 digest must be a JSON string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s) != hex.EncodedLen(len(d)) {
|
||||||
|
return fmt.Errorf("invalid SHA-256 hex length: got %d, want %d", len(s), hex.EncodedLen(len(d)))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid SHA-256 hex value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(d[:], decoded)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal returns true when both digests are identical.
|
||||||
|
//
|
||||||
|
// Since SHA256Digest is based on a fixed-size array, direct value comparison
|
||||||
|
// is efficient and idiomatic for non-constant-time equality checks.
|
||||||
|
func (d SHA256Digest) Equal(other SHA256Digest) bool {
|
||||||
|
return d == other
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSHA256DigestMarshalJSON verifies that the digest is encoded
|
||||||
|
// as a lowercase hexadecimal JSON string.
|
||||||
|
func TestSHA256DigestMarshalJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
digest := NewSHA256Digest([]byte("hello world"))
|
||||||
|
|
||||||
|
data, err := json.Marshal(digest)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, `"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"`, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSHA256DigestUnmarshalJSON verifies that a valid hexadecimal JSON string
|
||||||
|
// is decoded back into the original digest.
|
||||||
|
func TestSHA256DigestUnmarshalJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var digest SHA256Digest
|
||||||
|
err := json.Unmarshal(
|
||||||
|
[]byte(`"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"`),
|
||||||
|
&digest,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := NewSHA256Digest([]byte("hello world"))
|
||||||
|
require.True(t, digest.Equal(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSHA256DigestUnmarshalJSONInvalidLength verifies that invalid digest length
|
||||||
|
// is rejected.
|
||||||
|
func TestSHA256DigestUnmarshalJSONInvalidLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var digest SHA256Digest
|
||||||
|
err := json.Unmarshal([]byte(`"abcd"`), &digest)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "invalid SHA-256 hex length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSHA256DigestUnmarshalJSONInvalidHex verifies that non-hexadecimal input
|
||||||
|
// is rejected.
|
||||||
|
func TestSHA256DigestUnmarshalJSONInvalidHex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var digest SHA256Digest
|
||||||
|
err := json.Unmarshal(
|
||||||
|
[]byte(`"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"`),
|
||||||
|
&digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "invalid SHA-256 hex value")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileMetadataJSONRoundTrip verifies that a struct containing the digest
|
||||||
|
// round-trips correctly through JSON.
|
||||||
|
func TestFileMetadataJSONRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
original := VersionInfo{
|
||||||
|
OS: "linux",
|
||||||
|
Version: "1.2.3",
|
||||||
|
URL: "http://server:8080",
|
||||||
|
Checksum: NewSHA256Digest([]byte("payload")),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(original)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var decoded VersionInfo
|
||||||
|
err = json.Unmarshal(data, &decoded)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, original.OS, decoded.OS)
|
||||||
|
require.Equal(t, original.Version, decoded.Version)
|
||||||
|
require.Equal(t, original.URL, decoded.URL)
|
||||||
|
require.True(t, original.Checksum.Equal(decoded.Checksum))
|
||||||
|
}
|
||||||
@@ -18,6 +18,15 @@ type Client interface {
|
|||||||
|
|
||||||
// OnConnection receives an event when connection with client's server may be established (true) or connectivity lost (false).
|
// OnConnection receives an event when connection with client's server may be established (true) or connectivity lost (false).
|
||||||
OnConnection(bool)
|
OnConnection(bool)
|
||||||
|
|
||||||
|
// OnConnectionError receives an event when background process catches an error related to the server connectivity.
|
||||||
|
OnConnectionError(error)
|
||||||
|
|
||||||
|
// OnStorageError receives an event when background process catches an error related to the app's local Storage.
|
||||||
|
OnStorageError(error)
|
||||||
|
|
||||||
|
// OnServiceError receives an event when background process catches an unexpected processing error.
|
||||||
|
OnServiceError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameID string
|
type GameID string
|
||||||
@@ -30,8 +39,8 @@ type State struct {
|
|||||||
// TODO: store user's login key
|
// TODO: store user's login key
|
||||||
ClientCurrentVersion string `json:"clientCurrentVersion"`
|
ClientCurrentVersion string `json:"clientCurrentVersion"`
|
||||||
ClientNextVersion *string `json:"clientNextVersion,omitempty"`
|
ClientNextVersion *string `json:"clientNextVersion,omitempty"`
|
||||||
GameState []GameState `json:"gameState"`
|
GameState []GameState `json:"gameState,omitempty"`
|
||||||
ActiveGameID GameID `json:"activeGameId"`
|
ActiveGameID *GameID `json:"activeGameId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameState struct {
|
type GameState struct {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ func (s *fsStorage) FileExists(path string) (bool, string, error) {
|
|||||||
return false, "", err
|
return false, "", err
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return false, "", nil
|
return false, absPath, nil
|
||||||
}
|
}
|
||||||
return true, absPath, nil
|
return true, absPath, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,8 +191,9 @@ func TestRawFileCRUDAndList(t *testing.T) {
|
|||||||
if missingExists {
|
if missingExists {
|
||||||
t.Fatal("missing.txt should not exist")
|
t.Fatal("missing.txt should not exist")
|
||||||
}
|
}
|
||||||
if missingPath != "" {
|
wantMissingPath := filepath.Join(s.storageRoot, "missing.txt")
|
||||||
t.Fatalf("missing file path = %q, want empty string", missingPath)
|
if wantMissingPath != missingPath {
|
||||||
|
t.Fatalf("missing file path = %q, want %q", missingPath, wantMissingPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
alphaData, err := s.ReadFile("nested/alpha.txt")
|
alphaData, err := s.ReadFile("nested/alpha.txt")
|
||||||
@@ -478,7 +479,7 @@ func sampleState() client.State {
|
|||||||
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
|
{ID: client.GameID("game-1"), LastTurn: 12, ActiveTurn: 11},
|
||||||
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
|
{ID: client.GameID("game-2"), LastTurn: 4, ActiveTurn: 4},
|
||||||
},
|
},
|
||||||
ActiveGameID: client.GameID("game-2"),
|
ActiveGameID: new(client.GameID("game-2")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user