arblant
firefox-vpn-client / core / internal / update / apply.go
apply.go160 lines · 4.9 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160
package update

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/logx"
)

// exitWait bounds the wait for the app and the core to exit.
const exitWait = 2 * time.Minute

// The processes an update starts and waits for, and Windows' list of
// installed apps (process_*.go); tests replace them.
var (
	launchUpdater = startUpdater
	waitExited    = waitForExit
	launchApp     = startApp
	markInstalled = setInstalledVersion
)

// Apply is the -update run of the copy of the core that Install started:
// it waits until the app and its core have exited, swaps the app's files
// for the new version's, writes the result for the next start, and starts
// the app again. On any failure the old version is put back and starts,
// and the result says why.
func Apply(dataDir string) error {
	dir := filepath.Join(dataDir, "update")
	data, err := os.ReadFile(filepath.Join(dir, "pending.json"))
	if err != nil {
		return fmt.Errorf("nothing to install: %w", err)
	}
	var p pending
	if err := json.Unmarshal(data, &p); err != nil {
		return fmt.Errorf("unreadable instructions: %w", err)
	}
	if p.AppDir == "" || p.Package == "" {
		return errors.New("incomplete instructions")
	}
	logx.Info("update: installing from=%s to=%s app=%s", p.From, p.To, p.AppDir)
	unlock, err := lockApp(p.AppDir)
	if err != nil {
		return err
	}
	result := Result{From: p.From, To: p.To, On: p.On}
	waitErr := waitExited(p.Wait, exitWait)
	err = waitErr
	if err == nil {
		err = swap(p.AppDir, p.Package, filepath.Join(dir, "backup"))
	}
	result.OK = err == nil
	if err != nil {
		result.Error = err.Error()
		logx.Error("update: failed err=%s", logx.Err(err))
	} else {
		logx.Info("update: installed version=%s", p.To)
		// A copy Setup installed: Windows' list shows the new version.
		if merr := markInstalled(p.AppDir, p.To); merr != nil {
			logx.Warn("update: setting the version in Windows' list of installed apps failed err=%s", logx.Err(merr))
		}
	}
	if out, merr := json.MarshalIndent(result, "", "  "); merr == nil {
		if werr := os.WriteFile(filepath.Join(dir, "result.json"), out, 0o644); werr != nil {
			logx.Error("update: writing the result failed err=%s", logx.Err(werr))
		}
	}
	os.Remove(filepath.Join(dir, "pending.json"))
	// The next core cleans up only once this run is over.
	unlock()
	if waitErr != nil {
		// The app still runs; it is not started twice.
		return err
	}
	if serr := launchApp(p.AppDir, p.Args); serr != nil {
		logx.Error("update: starting the app failed err=%s", logx.Err(serr))
		return errors.Join(err, serr)
	}
	return err
}

// renameWait is how long a rename is tried again while a file is in use
// (a virus scanner reading a new file, say); tests shorten it.
var renameWait = 10 * time.Second

// rename is os.Rename; tests replace it to fail.
var rename = os.Rename

// renameRetry renames from to to, trying again for renameWait.
func renameRetry(from, to string) error {
	deadline := time.Now().Add(renameWait)
	for {
		err := rename(from, to)
		if err == nil || time.Now().After(deadline) {
			return err
		}
		time.Sleep(200 * time.Millisecond)
	}
}

// kept are the names in the app's folder that an update never replaces:
// the data, the portable marker, and Setup's uninstaller.
var kept = map[string]bool{"user-data": true, "portable": true, "unins000.exe": true, "unins000.dat": true}

// swap puts each file and folder of newDir in appDir in place of the one of
// the same name, which moves to backupDir; anything else in appDir stays.
// All are renames on one drive, so a swap takes a moment and the old files
// stay whole. On a failure everything is moved back, and the error says
// what failed.
func swap(appDir, newDir, backupDir string) (err error) {
	entries, err := os.ReadDir(newDir)
	if err != nil {
		return err
	}
	if len(entries) == 0 {
		return errors.New("the new version is empty")
	}
	if err := os.RemoveAll(backupDir); err != nil {
		return err
	}
	if err := os.MkdirAll(backupDir, 0o755); err != nil {
		return err
	}
	type move struct{ from, to string }
	var done []move
	defer func() {
		if err == nil {
			return
		}
		logx.Warn("update: putting the old version back err=%s", logx.Err(err))
		for i := len(done) - 1; i >= 0; i-- {
			if rerr := renameRetry(done[i].to, done[i].from); rerr != nil {
				err = fmt.Errorf("%w; putting %s back failed too: %v", err, filepath.Base(done[i].from), rerr)
			}
		}
	}()
	for _, e := range entries {
		name := e.Name()
		if kept[name] {
			continue
		}
		target := filepath.Join(appDir, name)
		if _, statErr := os.Lstat(target); statErr == nil {
			aside := filepath.Join(backupDir, name)
			if err = renameRetry(target, aside); err != nil {
				return fmt.Errorf("moving the old %s aside: %w", name, err)
			}
			done = append(done, move{target, aside})
		}
		from := filepath.Join(newDir, name)
		if err = renameRetry(from, target); err != nil {
			return fmt.Errorf("putting the new %s in place: %w", name, err)
		}
		done = append(done, move{from, target})
	}
	return nil
}