loader revisited

This commit is contained in:
Ilia Denisov
2026-03-16 19:52:02 +02:00
committed by GitHub
parent e6c6970947
commit 3f1776aa5f
30 changed files with 1581 additions and 527 deletions
+367
View File
@@ -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
}