package updates import ( "context" "fmt" "log/slog" "os" "sync" "time" ) type Controller struct { app AppDescriptor provider ReleaseProvider downloader Downloader store SnapshotStore platform PlatformInstaller logger *slog.Logger mu sync.Mutex snapshot Snapshot busy bool listeners map[int]chan Snapshot nextListener int } func NewController(cfg Config) (*Controller, error) { if cfg.Provider == nil || cfg.Downloader == nil || cfg.Store == nil || cfg.Platform == nil { return nil, ErrInvalidConfig } if _, err := NormalizeVersion(cfg.App.CurrentVersion); err != nil { return nil, err } if cfg.App.WorkingDirectory == "" { workingDirectory, err := os.Getwd() if err == nil { cfg.App.WorkingDirectory = workingDirectory } } logger := cfg.Logger if logger == nil { logger = slog.Default() } snapshot, err := cfg.Store.Load(context.Background()) if err != nil { return nil, err } snapshot.CurrentVersion = cfg.App.CurrentVersion snapshot.Channel = cfg.App.Channel if snapshot.State == "" { snapshot.State = StateIdle } controller := &Controller{ app: cfg.App, provider: cfg.Provider, downloader: cfg.Downloader, store: cfg.Store, platform: cfg.Platform, logger: logger, snapshot: snapshot, listeners: make(map[int]chan Snapshot), } if snapshot.Staged != nil { comparison, err := CompareVersions(snapshot.Staged.Release.Version, cfg.App.CurrentVersion) if err == nil && comparison == 0 { snapshot.State = StateUpToDate snapshot.Staged = nil snapshot.Candidate = nil snapshot.LastError = nil controller.snapshot = snapshot if err := controller.store.Save(context.Background(), snapshot); err != nil { return nil, err } } } return controller, nil } func (controller *Controller) Snapshot() Snapshot { controller.mu.Lock() defer controller.mu.Unlock() return controller.snapshot } func (controller *Controller) Subscribe(buffer int) (<-chan Snapshot, func()) { if buffer < 1 { buffer = 1 } controller.mu.Lock() defer controller.mu.Unlock() controller.nextListener++ id := controller.nextListener channel := make(chan Snapshot, buffer) channel <- controller.snapshot controller.listeners[id] = channel return channel, func() { controller.mu.Lock() defer controller.mu.Unlock() if _, ok := controller.listeners[id]; ok { delete(controller.listeners, id) } } } func (controller *Controller) Check(ctx context.Context, _ CheckRequest) (Snapshot, error) { if !controller.beginOperation() { return Snapshot{}, ErrBusy } defer controller.endOperation() now := time.Now().UTC() controller.transition(StateChecking, nil, nil) release, err := controller.provider.Resolve(ctx, ResolveRequest{App: controller.app}) if err != nil { return controller.fail("resolve_failed", err) } if release == nil { snapshot := controller.currentSnapshot() snapshot.State = StateUpToDate snapshot.LastCheckedAt = &now snapshot.Candidate = nil snapshot.LastError = nil if err := controller.save(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } comparison, err := CompareVersions(release.Version, controller.app.CurrentVersion) if err != nil { return controller.fail("invalid_release_version", err) } if comparison <= 0 { snapshot := controller.currentSnapshot() snapshot.State = StateUpToDate snapshot.LastCheckedAt = &now snapshot.Candidate = nil snapshot.LastError = nil if err := controller.save(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } snapshot := controller.currentSnapshot() snapshot.State = StateUpdateAvailable snapshot.LastCheckedAt = &now snapshot.Candidate = release snapshot.Staged = nil snapshot.LastError = nil if err := controller.save(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } func (controller *Controller) Download(ctx context.Context) (Snapshot, error) { if !controller.beginOperation() { return Snapshot{}, ErrBusy } defer controller.endOperation() snapshot := controller.currentSnapshot() if snapshot.Candidate == nil { return Snapshot{}, ErrNoUpdate } controller.transition(StateDownloading, snapshot.Candidate, nil) downloadedFile, err := controller.downloader.Download(ctx, snapshot.Candidate.Artifact) if err != nil { return controller.fail("download_failed", err) } if downloadedFile.SHA256 != "" && snapshot.Candidate.Artifact.SHA256 != "" && downloadedFile.SHA256 != snapshot.Candidate.Artifact.SHA256 { return controller.fail("checksum_mismatch", ErrChecksumMismatch) } controller.transition(StateVerifying, snapshot.Candidate, nil) root, err := controller.platform.DetectInstallRoot(controller.app) if err != nil { return controller.fail("detect_root_failed", err) } stagedArtifact, err := controller.platform.Stage(ctx, root, downloadedFile, *snapshot.Candidate) if err != nil { return controller.fail("stage_failed", err) } nextSnapshot := controller.currentSnapshot() nextSnapshot.State = StateReadyToApply nextSnapshot.Staged = &stagedArtifact nextSnapshot.Candidate = &stagedArtifact.Release nextSnapshot.LastError = nil if err := controller.save(nextSnapshot); err != nil { return Snapshot{}, err } return nextSnapshot, nil } func (controller *Controller) ApplyAndRestart(ctx context.Context) error { if !controller.beginOperation() { return ErrBusy } defer controller.endOperation() snapshot := controller.currentSnapshot() if snapshot.Staged == nil { return ErrNoStagedUpdate } controller.transition(StateApplying, snapshot.Candidate, snapshot.Staged) if err := controller.platform.SpawnApplyAndRestart(ctx, ApplyRequest{ App: controller.app, Root: snapshot.Staged.Root, Staged: *snapshot.Staged, }); err != nil { _, failErr := controller.fail("apply_failed", err) return failErr } nextSnapshot := controller.currentSnapshot() nextSnapshot.State = StateRestarting nextSnapshot.LastError = nil return controller.save(nextSnapshot) } func (controller *Controller) beginOperation() bool { controller.mu.Lock() defer controller.mu.Unlock() if controller.busy { return false } controller.busy = true return true } func (controller *Controller) endOperation() { controller.mu.Lock() defer controller.mu.Unlock() controller.busy = false } func (controller *Controller) currentSnapshot() Snapshot { controller.mu.Lock() defer controller.mu.Unlock() return controller.snapshot } func (controller *Controller) transition(state State, candidate *Release, staged *StagedArtifact) { snapshot := controller.currentSnapshot() snapshot.State = state snapshot.Candidate = candidate snapshot.Staged = staged snapshot.LastError = nil _ = controller.save(snapshot) } func (controller *Controller) save(snapshot Snapshot) error { snapshot.CurrentVersion = controller.app.CurrentVersion snapshot.Channel = controller.app.Channel if err := controller.store.Save(context.Background(), snapshot); err != nil { return err } controller.mu.Lock() controller.snapshot = snapshot listeners := make([]chan Snapshot, 0, len(controller.listeners)) for _, listener := range controller.listeners { listeners = append(listeners, listener) } controller.mu.Unlock() for _, listener := range listeners { select { case listener <- snapshot: default: select { case <-listener: default: } listener <- snapshot } } return nil } func (controller *Controller) fail(code string, err error) (Snapshot, error) { controller.logger.Error("update flow failed", "code", code, "error", err) snapshot := controller.currentSnapshot() snapshot.State = StateFailed snapshot.LastError = &ErrorInfo{ Code: code, Message: err.Error(), } if saveErr := controller.save(snapshot); saveErr != nil { return Snapshot{}, fmt.Errorf("%w: %w", err, saveErr) } return snapshot, err }