Files
aego/app/updates/updates.go
T
sanspie d436311f69 Stage 11i4: signed self-updates for Aego and exported games (D362)
update core: signed release.json (Ed25519) per channel, ETag check, resumable download with sha256, Auto/Ask/Off policy, appliers for the install.sh layout, AppImage, NSIS installer (keeps the replaced setup.exe for Restore) and .app, trial run with rollback.
Editor and launcher: banner, status bar, Help commands, Task Center download, install on restart.
CLI: aego update, aego release keygen|manifest, aego export --release-json.
Games: modules/update with update { url publicKey }, settings and the aego/update script API.
release.sh refuses to publish without AEGO_RELEASE_KEY and AEGO_UPDATE_KEY; install.sh lays out versions; make test-update runs a real v1 -> v2 update.
2026-10-26 19:36:15 +03:00

151 lines
4.8 KiB
Go

// Package updates keeps Aego itself up to date with the update library: where its releases are, what this build is, the trial after an update (D362).
package updates
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"time"
"aego/engine"
"aego/update"
)
const (
// DefaultBase is Aego's releases on Gitea: <base>/<channel>/release.json, where the channel is the stable or nightly release tag.
DefaultBase = "https://git.akarpov.ru/sanspie/aego/releases/download"
// EnvBase points the editor at another release server, for tests and mirrors; the signature is checked all the same.
EnvBase = "AEGO_UPDATE_URL"
// EnvRolledBack tells a restored version which newer one crashed, so it can say so.
EnvRolledBack = "AEGO_UPDATE_ROLLED_BACK"
)
// Dir keeps downloads, the release cache and the trial record: UserCacheDir/aego/updates.
func Dir() string {
dir, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(dir, "aego", "updates")
}
// Build is this copy: its version and, for nightlies, when its commit was made.
func Build() update.Build {
b := update.Build{Version: engine.Version}
if info, ok := debug.ReadBuildInfo(); ok {
for _, s := range info.Settings {
if s.Key == "vcs.time" {
b.Time, _ = time.Parse(time.RFC3339, s.Value)
}
}
}
return b
}
// Source is where this copy looks for releases of a channel.
func Source(ch update.Channel) *update.Source {
base := os.Getenv(EnvBase)
if base == "" {
base = DefaultBase
}
return &update.Source{BaseURL: base, Channel: ch, Cache: filepath.Join(Dir(), "cache")}
}
// Installed is how this copy was installed.
func Installed() (update.Install, error) {
exe, err := os.Executable()
if err != nil {
return update.Install{}, err
}
in, err := update.Detect(exe)
in.Keep = filepath.Join(Dir(), "previous")
return in, err
}
// assetKind is the asset an install updates from.
func assetKind(in update.Install) update.Kind {
return in.Kind
}
// Fetch downloads the release's asset for this install into Dir()/<version>.
func Fetch(ctx context.Context, r *update.Release, in update.Install, progress update.Progress) (string, error) {
if !r.Allowed(Build()) {
return "", update.ErrTooOld
}
a, err := r.Asset(runtime.GOOS, runtime.GOARCH, assetKind(in))
if err != nil {
return "", err
}
return update.Download(ctx, nil, a, filepath.Join(Dir(), r.Version), progress)
}
// Apply installs a fetched release and starts its trial: the new version's first run keeps it, a crash in the first seconds rolls it back.
func Apply(in update.Install, file string, r *update.Release) (update.Applied, error) {
a, err := in.Apply(file, r.Version)
if err != nil {
return a, err
}
return a, update.BeginTrial(Dir(), a)
}
// Boot runs first thing in the editor and the CLI: it rolls back a new version that crashed in its trial, and confirms one that has run long enough; exit is the Windows installer that does the rollback.
func Boot() (rolledBack string, exit *exec.Cmd, confirm func(), err error) {
confirm = func() {}
in, ierr := Installed()
if ierr != nil {
return "", nil, confirm, nil
}
back, exit, err := update.Start(Dir(), in, engine.Version, time.Now())
if err != nil || back != "" {
return back, exit, confirm, err
}
update.Clean(Dir(), engine.Version)
timer := time.AfterFunc(update.TrialTime, func() { update.Confirm(Dir(), engine.Version) })
return "", nil, func() {
timer.Stop()
update.Confirm(Dir(), engine.Version)
}, nil
}
// ErrNotInstalled says why a copy cannot update itself, for the editor to show.
var ErrNotInstalled = errors.New("this copy of Aego was not installed by its installer, install.sh or the AppImage, so it cannot update itself; download the new version from the install page")
// Guard runs first in main: a version that crashed in its trial restores the previous one and runs it with the same arguments.
func Guard(args []string) (confirm func(), code int, done bool) {
back, exit, confirm, err := Boot()
if err != nil || back == "" {
return confirm, 0, false
}
if exit != nil {
// the previous version's installer replaces this one and starts the editor itself
if err := exit.Start(); err != nil {
return confirm, 0, false
}
return confirm, 0, true
}
in, err := Installed()
if err != nil {
return confirm, 0, false
}
cmd := exec.Command(in.Launch, args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Env = append(os.Environ(), EnvRolledBack+"="+back)
if err := cmd.Run(); err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return confirm, exit.ExitCode(), true
}
return confirm, 1, true
}
return confirm, 0, true
}
// RolledBack is the version that crashed and was undone before this run, for a message.
func RolledBack() string {
return os.Getenv(EnvRolledBack)
}