Files
wails_tools/pkg/wails3kit/updates/controller.go
2026-03-12 22:16:34 +01:00

316 lines
7.8 KiB
Go

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
}