Files
aego/update/install.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

605 lines
17 KiB
Go

package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// Install is how the running copy was installed, which decides how an update replaces it.
type Install struct {
Kind Kind
// Exe is the running executable; Root is what an update replaces: share/aego, the AppImage, the install folder or the .app.
Exe string
Root string
// Launch is what starts the installed version after an update: bin/aego of the current version, the AppImage, the .app.
Launch string
// Folder is an exported game unpacked anywhere: an update swaps the whole folder and keeps the old one next to it.
Folder bool
// Keep is where the installer of the replaced version is kept, updates/previous, for an installer install to restore.
Keep string
}
// SetupFile is the copy of itself the Windows installer leaves in the install folder, so the next update can keep it.
const SetupFile = "setup.exe"
// DetectGame finds how an exported game was installed: an AppImage, a .app, or the folder its executable is in.
func DetectGame(exe string) Install {
if img := os.Getenv("APPIMAGE"); img != "" {
return Install{Kind: KindAppImage, Exe: exe, Root: img, Launch: img}
}
real, err := filepath.EvalSymlinks(exe)
if err != nil {
real = exe
}
if i := strings.Index(real, ".app/Contents/MacOS/"); i >= 0 {
app := real[:i+4]
return Install{Kind: KindApp, Exe: real, Root: app, Launch: app}
}
return Install{Kind: KindTarball, Exe: real, Root: filepath.Dir(real), Launch: real, Folder: true}
}
// Detect finds how the executable was installed: an AppImage, the versioned tarball layout of install.sh, the NSIS folder or a .app.
func Detect(exe string) (Install, error) {
if img := os.Getenv("APPIMAGE"); img != "" {
return Install{Kind: KindAppImage, Exe: exe, Root: img, Launch: img}, nil
}
real, err := filepath.EvalSymlinks(exe)
if err != nil {
real = exe
}
switch runtime.GOOS {
case "windows":
dir := filepath.Dir(real)
if _, err := os.Stat(filepath.Join(dir, "uninstall.exe")); err == nil {
return Install{Kind: KindInstaller, Exe: real, Root: dir, Launch: filepath.Join(dir, "aego-editor.exe")}, nil
}
case "darwin":
if i := strings.Index(real, ".app/Contents/MacOS/"); i >= 0 {
app := real[:i+4]
return Install{Kind: KindApp, Exe: real, Root: app, Launch: app}, nil
}
default:
ver := filepath.Dir(filepath.Dir(real))
root := filepath.Dir(ver)
if filepath.Base(filepath.Dir(real)) == "bin" {
if _, err := os.Lstat(filepath.Join(root, "current")); err == nil {
return Install{Kind: KindTarball, Exe: real, Root: root, Launch: filepath.Join(root, "current", "bin", filepath.Base(real))}, nil
}
}
}
return Install{Exe: real}, ErrNoInstall
}
// Applied is a finished update: what starts the new version and what the trial run needs to roll it back.
type Applied struct {
Version string
Previous string
// Exit is a command to run as the editor exits, for an install that cannot replace a running program (Windows).
Exit *exec.Cmd
}
// Apply installs a downloaded asset over the install; the running program goes on until it restarts.
func (in Install) Apply(file, version string) (Applied, error) {
switch {
case in.Folder:
return in.applyFolder(file, version)
}
switch in.Kind {
case KindTarball:
return in.applyTarball(file, version)
case KindAppImage:
return in.applyAppImage(file, version)
case KindApp:
return in.applyApp(file, version)
case KindInstaller:
return in.applyInstaller(file, version)
}
return Applied{}, ErrNoInstall
}
var errNoPrevious = errors.New("there is no previous version to restore")
// Restore switches back to the version before the last update; an installer install gets the kept installer to run at exit, as an update does.
func (in Install) Restore() (Applied, error) {
if in.Folder {
prev := previousOf(in.Root)
if _, err := os.Stat(prev); err != nil {
return Applied{}, errNoPrevious
}
return Applied{}, swap(in.Root, prev)
}
switch in.Kind {
case KindTarball:
prev, err := os.Readlink(filepath.Join(in.Root, "previous"))
if err != nil {
return Applied{}, errNoPrevious
}
cur, _ := os.Readlink(filepath.Join(in.Root, "current"))
if err := relink(in.Root, "current", prev); err != nil {
return Applied{}, err
}
if cur != "" {
return Applied{Version: filepath.Base(prev)}, relink(in.Root, "previous", cur)
}
return Applied{Version: filepath.Base(prev)}, nil
case KindAppImage, KindApp:
prev := previousOf(in.Root)
if _, err := os.Stat(prev); err != nil {
return Applied{}, errNoPrevious
}
return Applied{}, swap(in.Root, prev)
case KindInstaller:
return in.restoreInstaller()
}
return Applied{}, ErrNoInstall
}
// HasPrevious reports whether Restore has something to go back to.
func (in Install) HasPrevious() bool {
if in.Folder {
_, err := os.Stat(previousOf(in.Root))
return err == nil
}
switch in.Kind {
case KindTarball:
_, err := os.Readlink(filepath.Join(in.Root, "previous"))
return err == nil
case KindAppImage, KindApp:
_, err := os.Stat(previousOf(in.Root))
return err == nil
case KindInstaller:
_, err := os.Stat(filepath.Join(in.Keep, SetupFile))
return in.Keep != "" && err == nil
}
return false
}
// previousOf is where the replaced AppImage or .app is kept: next to it, hidden.
func previousOf(path string) string {
return filepath.Join(filepath.Dir(path), "."+filepath.Base(path)+".previous")
}
// swap exchanges a file or folder with its kept previous one.
func swap(cur, prev string) error {
tmp := cur + ".swap"
if err := os.Rename(cur, tmp); err != nil {
return err
}
if err := os.Rename(prev, cur); err != nil {
os.Rename(tmp, cur)
return err
}
return os.Rename(tmp, prev)
}
// relink points a symlink in root at target atomically: a new link renamed over the old one.
func relink(root, name, target string) error {
tmp := filepath.Join(root, "."+name+".new")
os.Remove(tmp)
if err := os.Symlink(target, tmp); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(root, name))
}
func (in Install) applyTarball(file, version string) (Applied, error) {
dir := filepath.Join(in.Root, safe(version))
tmp := dir + ".unpack"
os.RemoveAll(tmp)
if err := untar(file, tmp); err != nil {
os.RemoveAll(tmp)
return Applied{}, err
}
top, err := single(tmp)
if err != nil {
os.RemoveAll(tmp)
return Applied{}, err
}
if _, err := os.Stat(filepath.Join(top, "bin")); err != nil {
os.RemoveAll(tmp)
return Applied{}, errors.New("the archive has no bin folder")
}
os.RemoveAll(dir)
if err := os.Rename(top, dir); err != nil {
os.RemoveAll(tmp)
return Applied{}, err
}
os.RemoveAll(tmp)
prev, _ := os.Readlink(filepath.Join(in.Root, "current"))
if err := relink(in.Root, "current", safe(version)); err != nil {
return Applied{}, err
}
if prev != "" && prev != safe(version) {
if err := relink(in.Root, "previous", prev); err != nil {
return Applied{}, err
}
in.prune(prev, safe(version))
}
return Applied{Version: version, Previous: prev}, nil
}
// prune removes versions other than the current and the previous one.
func (in Install) prune(keep ...string) {
list, err := os.ReadDir(in.Root)
if err != nil {
return
}
for _, e := range list {
name := e.Name()
if !e.IsDir() || strings.HasPrefix(name, ".") || name == "current" || name == "previous" {
continue
}
drop := true
for _, k := range keep {
if name == k {
drop = false
}
}
if drop {
os.RemoveAll(filepath.Join(in.Root, name))
}
}
}
// applyFolder unpacks a game's archive next to its folder and swaps the folders; the running game keeps its open files on Linux and macOS.
func (in Install) applyFolder(file, version string) (Applied, error) {
if runtime.GOOS == "windows" {
return Applied{}, errors.New("a game folder updates on Windows through its installer")
}
tmp := in.Root + ".unpack"
os.RemoveAll(tmp)
unpack := untar
if strings.HasSuffix(strings.ToLower(file), ".zip") {
unpack = unzip
}
if err := unpack(file, tmp); err != nil {
os.RemoveAll(tmp)
return Applied{}, err
}
defer os.RemoveAll(tmp)
top, err := single(tmp)
if err != nil {
return Applied{}, err
}
if _, err := os.Stat(filepath.Join(top, filepath.Base(in.Exe))); err != nil {
return Applied{}, fmt.Errorf("the archive has no %s", filepath.Base(in.Exe))
}
prev := previousOf(in.Root)
os.RemoveAll(prev)
if err := os.Rename(in.Root, prev); err != nil {
return Applied{}, err
}
if err := os.Rename(top, in.Root); err != nil {
os.Rename(prev, in.Root)
return Applied{}, err
}
return Applied{Version: version, Previous: prev}, nil
}
func (in Install) applyAppImage(file, version string) (Applied, error) {
next := in.Root + ".new"
if err := copyFile(file, next, 0o755); err != nil {
return Applied{}, err
}
prev := previousOf(in.Root)
os.Remove(prev)
if err := os.Rename(in.Root, prev); err != nil {
os.Remove(next)
return Applied{}, err
}
if err := os.Rename(next, in.Root); err != nil {
os.Rename(prev, in.Root)
return Applied{}, err
}
return Applied{Version: version, Previous: prev}, nil
}
func (in Install) applyApp(file, version string) (Applied, error) {
tmp := in.Root + ".unpack"
os.RemoveAll(tmp)
if err := unzip(file, tmp); err != nil {
os.RemoveAll(tmp)
return Applied{}, err
}
defer os.RemoveAll(tmp)
app, err := single(tmp)
if err != nil || !strings.HasSuffix(app, ".app") {
return Applied{}, errors.New("the archive has no .app")
}
prev := previousOf(in.Root)
os.RemoveAll(prev)
if err := os.Rename(in.Root, prev); err != nil {
return Applied{}, err
}
if err := os.Rename(app, in.Root); err != nil {
os.Rename(prev, in.Root)
return Applied{}, err
}
return Applied{Version: version, Previous: prev}, nil
}
// applyInstaller keeps the installed version's installer and runs the new one silently as the editor exits: Windows keeps the running program locked.
func (in Install) applyInstaller(file, version string) (Applied, error) {
prev := ""
if in.Keep != "" {
if err := in.keepSetup(); err != nil {
return Applied{}, err
}
if b, err := os.ReadFile(filepath.Join(in.Root, "VERSION")); err == nil {
prev = strings.TrimSpace(string(b))
}
}
return Applied{Version: version, Previous: prev, Exit: exec.Command(file, "/S", "/UPDATE")}, nil
}
// keepSetup copies the installed version's setup.exe to Keep; an install made before installers kept themselves has none, and then nothing is kept.
func (in Install) keepSetup() error {
src := filepath.Join(in.Root, SetupFile)
if _, err := os.Stat(src); err != nil {
return nil
}
if err := os.MkdirAll(in.Keep, 0o755); err != nil {
return err
}
return copyFile(src, filepath.Join(in.Keep, SetupFile), 0o755)
}
// restoreInstaller runs the kept installer at exit from a copy, and keeps the current one in its place so a second Restore goes forward again.
func (in Install) restoreInstaller() (Applied, error) {
kept := filepath.Join(in.Keep, SetupFile)
if in.Keep == "" {
return Applied{}, errNoPrevious
}
if _, err := os.Stat(kept); err != nil {
return Applied{}, errNoPrevious
}
run := filepath.Join(filepath.Dir(in.Keep), "restore", SetupFile)
if err := os.MkdirAll(filepath.Dir(run), 0o755); err != nil {
return Applied{}, err
}
if err := copyFile(kept, run, 0o755); err != nil {
return Applied{}, err
}
if err := in.keepSetup(); err != nil {
return Applied{}, err
}
return Applied{Exit: exec.Command(run, "/S", "/UPDATE")}, nil
}
func safe(version string) string {
return strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' {
return '_'
}
return r
}, version)
}
// single is the one folder an archive unpacked to.
func single(dir string) (string, error) {
list, err := os.ReadDir(dir)
if err != nil {
return "", err
}
if len(list) != 1 || !list[0].IsDir() {
return "", errors.New("the archive should hold one folder")
}
return filepath.Join(dir, list[0].Name()), nil
}
func inside(dir, name string) (string, error) {
p := filepath.Join(dir, filepath.FromSlash(name))
if !strings.HasPrefix(p, filepath.Clean(dir)+string(filepath.Separator)) {
return "", fmt.Errorf("the archive has a path outside it: %s", name)
}
return p, nil
}
func untar(file, dir string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
p, err := inside(dir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(p, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777|0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
if err := os.Symlink(h.Linkname, p); err != nil {
return err
}
}
}
}
func unzip(file, dir string) error {
r, err := zip.OpenReader(file)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
p, err := inside(dir, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(p, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
in, err := f.Open()
if err != nil {
return err
}
mode := f.Mode()
if mode&os.ModeSymlink != 0 {
target, _ := io.ReadAll(in)
in.Close()
if err := os.Symlink(string(target), p); err != nil {
return err
}
continue
}
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm()|0o600)
if err != nil {
in.Close()
return err
}
_, err = io.Copy(out, in)
in.Close()
if cerr := out.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// TrialTime is how long a new version must run before its update is kept; a crash sooner rolls it back (as D74 does for native rebuilds).
const TrialTime = 10 * time.Second
// trial is the record of an update that has not proved itself yet.
type trial struct {
Version string `json:"version"`
Previous string `json:"previous"`
Started time.Time `json:"started,omitempty"`
}
func trialFile(dir string) string {
return filepath.Join(dir, "trial.json")
}
// BeginTrial records an applied update; the new version's first run proves it or rolls it back.
func BeginTrial(dir string, a Applied) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
b, _ := json.Marshal(trial{Version: a.Version, Previous: a.Previous})
return os.WriteFile(trialFile(dir), b, 0o644)
}
// Start runs first thing in the new version: a trial that started before and was never confirmed means that run crashed, so it rolls back and reports the version it left; exit is the installer to run for that on Windows.
func Start(dir string, in Install, running string, now time.Time) (rolledBack string, exit *exec.Cmd, err error) {
data, err := os.ReadFile(trialFile(dir))
if err != nil {
return "", nil, nil
}
var t trial
if json.Unmarshal(data, &t) != nil || Compare(t.Version, running) != 0 {
os.Remove(trialFile(dir))
return "", nil, nil
}
if !t.Started.IsZero() {
os.Remove(trialFile(dir))
a, err := in.Restore()
if err != nil {
return "", nil, err
}
return t.Version, a.Exit, nil
}
t.Started = now
b, _ := json.Marshal(t)
return "", nil, os.WriteFile(trialFile(dir), b, 0o644)
}
// Clean removes downloads of versions not newer than the running one and the copy a restore ran from; previous keeps its one installer.
func Clean(dir, running string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if !e.IsDir() || name == "previous" || name == "cache" {
continue
}
if name == "restore" || Compare(name, running) <= 0 {
os.RemoveAll(filepath.Join(dir, name))
}
}
}
// Confirm keeps the update once its version has run for TrialTime or exited cleanly; a trial of another version is left alone.
func Confirm(dir, running string) {
data, err := os.ReadFile(trialFile(dir))
if err != nil {
return
}
var t trial
if json.Unmarshal(data, &t) != nil || Compare(t.Version, running) == 0 && !t.Started.IsZero() {
os.Remove(trialFile(dir))
}
}