368 lines
9.9 KiB
Go
368 lines
9.9 KiB
Go
// 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
|
|
}
|