// Package update contains the core-only application update orchestration. // It never obtains downloads, controls a window, or terminates a process. package update import ( "context" "errors" "fmt" "os" "regexp" "time" "softbox.local/core/application/install" "softbox.local/core/domain" "softbox.local/core/internal/safepath" "softbox.local/core/storage" ) const ( minCloseTimeout = time.Second maxCloseTimeout = 10 * time.Minute ) var ( ErrUpdateConfig = errors.New("invalid update service configuration") ErrUpdateRequest = errors.New("invalid update request") ErrAppNotInstalled = errors.New("app is not installed for update") ErrUpdateMetadata = errors.New("installed update metadata is invalid") ErrUpdateTargetUnsafe = errors.New("installed update target is unsafe") ErrUpdateNotAvailable = errors.New("catalog target is not newer than installed version") ErrTargetStateCheck = errors.New("update target state check failed") ErrCloseConfirmation = errors.New("update close confirmation failed") ErrCloseDeclined = errors.New("update close request was declined") ErrExitWait = errors.New("wait for update target exit failed") updateAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`) ) // FailureCode is the stable, non-localized result of a failed update attempt. type FailureCode string const ( FailureCodeNotInstalled FailureCode = "not_installed" FailureCodeUpdateMetadataInvalid FailureCode = "update_metadata_invalid" FailureCodeUpdateTargetUnsafe FailureCode = "update_target_unsafe" FailureCodeUpdateNotAvailable FailureCode = "update_not_available" FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable" FailureCodeCloseConfirmationFailed FailureCode = "close_confirmation_unavailable" FailureCodeCloseDeclined FailureCode = "close_declined" FailureCodeExitWaitCanceled FailureCode = "exit_wait_canceled" FailureCodeExitWaitTimedOut FailureCode = "exit_wait_timeout" FailureCodeExitWaitFailed FailureCode = "exit_wait_unavailable" FailureCodeInstallFailed FailureCode = "install_failed" ) // Error preserves a stable update code and the diagnostic cause. Installation // errors remain in the unwrap chain so their stage and code stay observable. type Error struct { Code FailureCode Err error } func (err *Error) Error() string { return fmt.Sprintf("update (%s): %v", err.Code, err.Err) } func (err *Error) Unwrap() error { return err.Err } // Request carries the trusted Catalog selection and completed-file candidate // already required by install.InstallService. It accepts no process path, // command, URL, or UI-provided version. type Request struct { Install install.InstallRequest } // Result reports the version committed by the existing installer. type Result struct { AppID string Version string } // InstalledAppResolver supplies one verified record and its real current root. type InstalledAppResolver interface { ResolveCurrent(appID string) (storage.InstalledApp, string, error) } // TargetStateChecker observes the precise old entrypoint before a close // request. It must not turn a query failure into a stopped result. type TargetStateChecker interface { IsRunning(appID string, entrypointPath string) (bool, error) } // CloseConfirmer obtains the user's decision outside of Gio Layout. It must // not close or terminate the process itself. type CloseConfirmer interface { ConfirmClose(ctx context.Context, appID string) (bool, error) } // ExitWaiter waits only for natural exit of one precise entrypoint. A timeout // is reported as context.DeadlineExceeded and cancellation retains ctx.Err(). type ExitWaiter interface { WaitForExit(ctx context.Context, appID, entrypointPath string, timeout time.Duration) error } // InstallRunner is the verified installation boundary. The update service // delegates all staging, switch, rollback and second running-state checks to it. type InstallRunner interface { Install(request install.InstallRequest) (install.InstallResult, error) } // ServiceConfig makes the close confirmation and exit-wait policy explicit. type ServiceConfig struct { Records InstalledAppResolver TargetState TargetStateChecker Confirmation CloseConfirmer ExitWaiter ExitWaiter Installer InstallRunner CloseTimeout time.Duration } // Service implements a safe, non-destructive update flow. type Service struct { records InstalledAppResolver targetState TargetStateChecker confirmation CloseConfirmer exitWaiter ExitWaiter installer InstallRunner closeTimeout time.Duration } // NewService validates every dependency. There is deliberately no default // confirmation, wait policy, or installer implementation. func NewService(config ServiceConfig) (*Service, error) { if config.Records == nil { return nil, fmt.Errorf("%w: installed app resolver is required", ErrUpdateConfig) } if config.TargetState == nil { return nil, fmt.Errorf("%w: target state checker is required", ErrUpdateConfig) } if config.Confirmation == nil { return nil, fmt.Errorf("%w: close confirmer is required", ErrUpdateConfig) } if config.ExitWaiter == nil { return nil, fmt.Errorf("%w: exit waiter is required", ErrUpdateConfig) } if config.Installer == nil { return nil, fmt.Errorf("%w: install runner is required", ErrUpdateConfig) } if config.CloseTimeout < minCloseTimeout || config.CloseTimeout > maxCloseTimeout { return nil, fmt.Errorf("%w: close timeout must be between %s and %s", ErrUpdateConfig, minCloseTimeout, maxCloseTimeout) } return &Service{ records: config.Records, targetState: config.TargetState, confirmation: config.Confirmation, exitWaiter: config.ExitWaiter, installer: config.Installer, closeTimeout: config.CloseTimeout, }, nil } // Update confirms a running app may be closed, waits for its natural exit, and // then delegates to the verified installer. The installer repeats target-state // checks before extraction and immediately before replacing current. func (service *Service) Update(ctx context.Context, request Request) (Result, error) { if ctx == nil { return Result{}, updateError(ErrUpdateRequest) } if err := ctx.Err(); err != nil { return Result{}, updateError(err) } appID := request.Install.Entry.App.ID if !updateAppIDPattern.MatchString(appID) { return Result{}, updateError(ErrUpdateRequest) } record, current, err := service.records.ResolveCurrent(appID) if err != nil { return Result{}, updateResolverError(err) } entrypoint, err := updateEntrypoint(record, current) if err != nil { return Result{}, updateError(err) } if err := validateTargetVersion(record, request.Install); err != nil { return Result{}, updateError(err) } running, err := service.targetState.IsRunning(record.ID, entrypoint) if err != nil { return Result{}, updateError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err)) } if running { confirmed, err := service.confirmation.ConfirmClose(ctx, record.ID) if err != nil { return Result{}, updateError(fmt.Errorf("%w: %w", ErrCloseConfirmation, err)) } if !confirmed { return Result{}, updateError(ErrCloseDeclined) } if err := service.exitWaiter.WaitForExit(ctx, record.ID, entrypoint, service.closeTimeout); err != nil { return Result{}, updateError(fmt.Errorf("%w: %w", ErrExitWait, err)) } } installed, err := service.installer.Install(request.Install) if err != nil { return Result{}, updateError(err) } return Result{AppID: installed.AppID, Version: installed.Version}, nil } func validateTargetVersion(record storage.InstalledApp, request install.InstallRequest) error { app := request.Entry.App if request.DownloadPath == "" || app.ID != record.ID || request.Entry.Package == nil || !request.Entry.Installable { return ErrUpdateRequest } if request.Architecture != "386" && request.Architecture != "amd64" { return ErrUpdateRequest } publishedPackage, exists := app.Packages[request.Architecture] if !exists || publishedPackage != *request.Entry.Package { return ErrUpdateRequest } comparison, err := domain.CompareSemVer(app.Version, record.Version) if err != nil { return fmt.Errorf("%w: %v", ErrUpdateRequest, err) } if comparison <= 0 { return ErrUpdateNotAvailable } return nil } func updateEntrypoint(record storage.InstalledApp, current string) (string, error) { if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" { return "", ErrUpdateMetadata } if err := safepath.ValidateRelative(record.Entrypoint); err != nil { return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateMetadata, err) } if record.WorkingDirectory != "." { if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil { return "", fmt.Errorf("%w: working directory: %v", ErrUpdateMetadata, err) } } if !validMinOS(record.MinOS) { return "", ErrUpdateMetadata } if !containsEntrypoint(record.Files, record.Entrypoint) { return "", fmt.Errorf("%w: entrypoint is not in installed files", ErrUpdateMetadata) } entrypoint, err := safepath.JoinUnder(current, record.Entrypoint) if err != nil { return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateTargetUnsafe, err) } info, err := os.Lstat(entrypoint) if os.IsNotExist(err) { return "", fmt.Errorf("%w: entrypoint is missing", ErrUpdateTargetUnsafe) } if err != nil { return "", fmt.Errorf("%w: inspect entrypoint: %w", ErrUpdateTargetUnsafe, err) } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { return "", fmt.Errorf("%w: entrypoint is not a regular file", ErrUpdateTargetUnsafe) } return entrypoint, nil } func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool { for _, file := range files { if file.Path == entrypoint { return true } } return false } func validMinOS(minOS string) bool { return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11" } func updateResolverError(err error) error { if errors.Is(err, os.ErrNotExist) { return updateError(ErrAppNotInstalled) } if errors.Is(err, storage.ErrStorageLayoutUnsafe) { return updateError(fmt.Errorf("%w: %w", ErrUpdateTargetUnsafe, err)) } return updateError(fmt.Errorf("resolve installed app: %w", err)) } func updateError(err error) error { return &Error{Code: failureCodeFor(err), Err: err} } func failureCodeFor(err error) FailureCode { switch { case errors.Is(err, ErrAppNotInstalled): return FailureCodeNotInstalled case errors.Is(err, ErrUpdateMetadata): return FailureCodeUpdateMetadataInvalid case errors.Is(err, ErrUpdateTargetUnsafe): return FailureCodeUpdateTargetUnsafe case errors.Is(err, ErrUpdateNotAvailable): return FailureCodeUpdateNotAvailable case errors.Is(err, ErrTargetStateCheck): return FailureCodeTargetStateUnavailable case errors.Is(err, ErrCloseDeclined): return FailureCodeCloseDeclined case errors.Is(err, context.Canceled): return FailureCodeExitWaitCanceled case errors.Is(err, context.DeadlineExceeded): return FailureCodeExitWaitTimedOut case errors.Is(err, ErrCloseConfirmation): return FailureCodeCloseConfirmationFailed case errors.Is(err, ErrExitWait): return FailureCodeExitWaitFailed default: return FailureCodeInstallFailed } }