// 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 }