feat: initial commit
This commit is contained in:
315
pkg/wails3kit/updates/controller.go
Normal file
315
pkg/wails3kit/updates/controller.go
Normal file
@@ -0,0 +1,315 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user