203 lines
6.9 KiB
Go
203 lines
6.9 KiB
Go
// Package updater performs the constrained on-disk activation of a prepared
|
|
// SoftBox self-update. It deliberately does not download, verify, or select an
|
|
// update package.
|
|
package updater
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// ProductExecutableName is the only executable the updater may start.
|
|
ProductExecutableName = "SoftBox.exe"
|
|
// InternalHealthFlag is accepted only by SoftBox itself after an update.
|
|
InternalHealthFlag = "--softbox-update-health"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidRequest = errors.New("invalid self-update request")
|
|
ErrUnsafeLayout = errors.New("unsafe self-update layout")
|
|
ErrStagingMissing = errors.New("self-update staging directory is missing")
|
|
ErrBackupExists = errors.New("self-update backup directory already exists")
|
|
ErrTransactionCorrupt = errors.New("self-update transaction is corrupt")
|
|
ErrRecoveryRequired = errors.New("self-update recovery is required")
|
|
ErrParentWait = errors.New("wait for SoftBox parent process")
|
|
ErrLaunch = errors.New("launch updated SoftBox")
|
|
ErrHealthTimeout = errors.New("updated SoftBox health confirmation timed out")
|
|
ErrHealthInvalid = errors.New("updated SoftBox health confirmation is invalid")
|
|
)
|
|
|
|
// Request identifies one prepared self-update. StagingDir and TargetDir are
|
|
// both checked against the fixed layout; callers cannot choose arbitrary move
|
|
// endpoints.
|
|
type Request struct {
|
|
ParentPID int
|
|
StagingDir string
|
|
TargetDir string
|
|
RequestID string
|
|
}
|
|
|
|
// StartCommand contains the only process launch allowed by this package.
|
|
// Platform adapters must pass precisely the fixed internal health flag.
|
|
type StartCommand struct {
|
|
Entrypoint string
|
|
WorkingDirectory string
|
|
HealthRequestID string
|
|
}
|
|
|
|
// ProcessWaiter waits for the old main-process PID to end naturally.
|
|
type ProcessWaiter interface {
|
|
WaitForProcessExit(context.Context, int, time.Duration) error
|
|
}
|
|
|
|
// Launcher starts the verified new main executable without a shell.
|
|
type Launcher interface {
|
|
StartSelfUpdate(StartCommand) (int, error)
|
|
}
|
|
|
|
// DirectorySyncer is implemented by the platform boundary. Directory flushing
|
|
// requires a Windows handle implementation and must not leak into core.
|
|
type DirectorySyncer interface {
|
|
SyncDirectory(path string) error
|
|
}
|
|
|
|
// HealthWaiter observes the minimal acknowledgement written by the new main
|
|
// process. It must only accept the exact request ID.
|
|
type HealthWaiter interface {
|
|
WaitForHealth(context.Context, string, string, time.Duration) error
|
|
}
|
|
|
|
// Timeouts controls externally-blocking update operations.
|
|
type Timeouts struct {
|
|
ParentExit time.Duration
|
|
Health time.Duration
|
|
}
|
|
|
|
// Service owns one constrained self-update orchestration.
|
|
type Service struct {
|
|
waiter ProcessWaiter
|
|
launcher Launcher
|
|
syncer DirectorySyncer
|
|
health HealthWaiter
|
|
timeouts Timeouts
|
|
}
|
|
|
|
// NewService constructs an updater. Missing dependencies are reported by
|
|
// Update, keeping command composition straightforward.
|
|
func NewService(
|
|
waiter ProcessWaiter,
|
|
launcher Launcher,
|
|
syncer DirectorySyncer,
|
|
health HealthWaiter,
|
|
timeouts Timeouts,
|
|
) *Service {
|
|
if timeouts.ParentExit <= 0 {
|
|
timeouts.ParentExit = 2 * time.Minute
|
|
}
|
|
if timeouts.Health <= 0 {
|
|
timeouts.Health = 45 * time.Second
|
|
}
|
|
return &Service{waiter: waiter, launcher: launcher, syncer: syncer, health: health, timeouts: timeouts}
|
|
}
|
|
|
|
// Update waits for the old process, recovers a previous interrupted switch if
|
|
// needed, and activates the prepared directory. It never kills a process.
|
|
func (service *Service) Update(ctx context.Context, request Request) error {
|
|
if service == nil || service.waiter == nil || service.launcher == nil || service.syncer == nil || service.health == nil {
|
|
return fmt.Errorf("%w: updater dependencies are incomplete", ErrInvalidRequest)
|
|
}
|
|
layout, err := inspectRequest(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := service.waiter.WaitForProcessExit(ctx, request.ParentPID, service.timeouts.ParentExit); err != nil {
|
|
return fmt.Errorf("%w: %w", ErrParentWait, err)
|
|
}
|
|
if err := recoverLayout(layout, service.syncer); err != nil {
|
|
return err
|
|
}
|
|
if err := layout.validateReady(service.syncer); err != nil {
|
|
return err
|
|
}
|
|
if err := removeHealth(layout, service.syncer); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := writeTransaction(layout, phasePrepared, service.syncer); err != nil {
|
|
return err
|
|
}
|
|
if err := renameDirectory(layout.target, layout.backup, layout, service.syncer, "back up current app"); err != nil {
|
|
return service.failBeforeActivation(layout, err)
|
|
}
|
|
if err := writeTransaction(layout, phaseTargetBackedUp, service.syncer); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
if err := renameDirectory(layout.staging, layout.target, layout, service.syncer, "activate staged app"); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
if err := writeTransaction(layout, phaseStagingActivated, service.syncer); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
|
|
entrypoint := layout.entrypoint()
|
|
if err := validateRegularFile(entrypoint); err != nil {
|
|
return service.rollback(layout, fmt.Errorf("%w: %v", ErrUnsafeLayout, err))
|
|
}
|
|
if _, err := service.launcher.StartSelfUpdate(StartCommand{
|
|
Entrypoint: entrypoint, WorkingDirectory: layout.target, HealthRequestID: layout.requestID,
|
|
}); err != nil {
|
|
return service.rollback(layout, fmt.Errorf("%w: %w", ErrLaunch, err))
|
|
}
|
|
if err := writeTransaction(layout, phaseLaunched, service.syncer); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
if err := service.health.WaitForHealth(ctx, layout.target, layout.requestID, service.timeouts.Health); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
if err := writeTransaction(layout, phaseCommitted, service.syncer); err != nil {
|
|
return service.rollback(layout, err)
|
|
}
|
|
if err := removeManagedTree(layout.backup, layout, service.syncer); err != nil {
|
|
return fmt.Errorf("commit self-update: %w", err)
|
|
}
|
|
if err := removeTransaction(layout, service.syncer); err != nil {
|
|
return err
|
|
}
|
|
if err := removeHealth(layout, service.syncer); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) failBeforeActivation(layout updateLayout, cause error) error {
|
|
prepareErr := fmt.Errorf("prepare self-update: %w", cause)
|
|
if recoveryErr := recoverLayout(layout, service.syncer); recoveryErr != nil {
|
|
return errors.Join(prepareErr, recoveryErr)
|
|
}
|
|
return prepareErr
|
|
}
|
|
|
|
func (service *Service) rollback(layout updateLayout, cause error) error {
|
|
rollbackErr := rollbackLayout(layout, service.syncer)
|
|
if rollbackErr != nil {
|
|
return errors.Join(cause, rollbackErr)
|
|
}
|
|
return cause
|
|
}
|
|
|
|
// Recover restores or finalizes a previously interrupted transaction for the
|
|
// fixed target directory. Callers must wait for any old parent process first.
|
|
func Recover(targetDir string, syncer DirectorySyncer) error {
|
|
if syncer == nil {
|
|
return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest)
|
|
}
|
|
layout, err := inspectTarget(targetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return recoverLayout(layout, syncer)
|
|
}
|