You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

266 lines
7.3 KiB

package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"syscall"
"time"
semver "github.com/Masterminds/semver/v3"
"github.com/mholt/archiver"
"github.com/pkg/errors"
)
// Updater updates an app.
type Updater struct {
// CurrentVersion is the current install version.
CurrentVersion string
// LatestReleaseGitHubEndpoint is the URL of the API to get latest release data.
// For example, https://api.github.com/repos/matryer/xbar/releases/latest.
LatestReleaseGitHubEndpoint string
// Client is the HTTP client to use to access the
// API and download the assets.
Client *http.Client
// SelectAsset selects the Asset to install.
SelectAsset SelectAssetFunc
// DownloadBytesLimit is the maximum number of bytes to download.
DownloadBytesLimit int64
// GetExecutable is the function that gets the current
// executable. If nil, os.Executable will be used.
GetExecutable func() (string, error)
}
// SelectAssetFunc selects the Asset to install.
type SelectAssetFunc func(release Release, asset Asset) bool
// Update checks and installs the update.
// Returns nil, nil if no update is required.
func (u *Updater) Update() (*Release, error) {
if u.DownloadBytesLimit == 0 {
return nil, errors.New("must set DownloadBytesLimit")
}
if u.SelectAsset == nil {
return nil, errors.New("missing SelectAsset func")
}
if u.GetExecutable == nil {
u.GetExecutable = os.Executable
}
latest, err := u.getLatestRelease()
if err != nil {
return nil, err
}
hasUpdate := hasUpdate(u.CurrentVersion, latest.TagName)
if !hasUpdate {
return nil, nil
}
var selectedAsset *Asset
for _, asset := range latest.Assets {
if u.SelectAsset(*latest, asset) {
selectedAsset = &asset
break
}
}
if selectedAsset == nil {
return nil, errors.New("no asset selected, use SelectAssetFunc to select an asset")
}
fmt.Println(selectedAsset)
err = u.downloadAndReplaceApp(*selectedAsset)
if err != nil {
return nil, errors.Wrap(err, "download update")
}
return latest, nil
}
// Restart spawns the current executable again, and terminates
// the running one.
func (u *Updater) Restart() error {
time.Sleep(1 * time.Second)
thisExecuable, err := os.Executable()
if err != nil {
return errors.Wrap(err, "get executable")
}
log.Println("restarting", thisExecuable)
cmd := exec.Command(thisExecuable)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: false,
Credential: &syscall.Credential{
NoSetGroups: true,
},
}
cmd.Dir = filepath.Dir(thisExecuable)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "XBAR_UPDATE_RESTART_COUNTER=1")
cmd.Args = os.Args
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Start()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return errors.Wrapf(err, "starting new app failed: exit code %d", exitErr.ExitCode())
}
return errors.Wrap(err, "starting new app failed")
}
log.Println("waiting before terminating after update...")
time.Sleep(1 * time.Second)
log.Println("terminating after update.")
os.Exit(0)
return nil
}
// getLatestRelease gets the latest release.
func (u *Updater) getLatestRelease() (*Release, error) {
resp, err := u.Client.Get(u.LatestReleaseGitHubEndpoint)
if err != nil {
return nil, errors.Wrap(err, "get latest release")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("failed to check for updates: got %s", resp.Status)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, u.DownloadBytesLimit))
if err != nil {
return nil, errors.Wrap(err, "read body")
}
var latestReleases []Release
err = json.Unmarshal(b, &latestReleases)
if err != nil {
return nil, errors.Wrap(err, "marshal")
}
latestReleases[0].CreatedAt, err = time.Parse(time.RFC3339Nano, latestReleases[0].CreatedAtString)
if err != nil {
return nil, errors.Wrap(err, "time.Parse: created_at")
}
return &latestReleases[0], nil
}
// HasUpdate checks whether there's an update or not.
func (u *Updater) HasUpdate() (*Release, bool, error) {
latest, err := u.getLatestRelease()
fmt.Println(latest)
if err != nil {
return nil, false, err
}
hasUpdate := hasUpdate(u.CurrentVersion, latest.TagName)
return latest, hasUpdate, nil
}
// hasUpdate compares the current and latest version strings to
// see if there is an update.
// Returns false if the versions match.
// Returns false is current is in front of latest.
// If semver checking fails, direct string comparison is used.
func hasUpdate(current, latest string) bool {
semverValid := true
currentV, err := semver.NewVersion(current)
if err != nil {
fmt.Println("CURRENT SEMVER NON VALID")
semverValid = false
}
latestV, err := semver.NewVersion(latest)
if err != nil {
fmt.Println("LATEST SEMVER NON VALID")
semverValid = false
}
if semverValid {
if currentV.Equal(latestV) {
return false // up-to-date
}
if currentV.GreaterThan(latestV) {
return false // local version is higher
}
} else {
// semver failed - just check tags
if latest == current {
return false
}
}
return true
}
func (u *Updater) downloadAndReplaceApp(asset Asset) error {
filename := path.Base(asset.BrowserDownloadURL)
switch {
case strings.HasSuffix(filename, ".zip"):
// fine
default:
return errors.Errorf("file not supported: %s", filename)
}
resp, err := u.Client.Get(asset.BrowserDownloadURL)
if err != nil {
return errors.Wrap(err, "download asset")
}
defer resp.Body.Close()
fmt.Println(resp.Status)
const defaultTempDir = ""
f, err := os.CreateTemp(defaultTempDir, "*-"+filename)
if err != nil {
return errors.Wrap(err, "create temp file")
}
_, err = io.Copy(f, io.LimitReader(resp.Body, u.DownloadBytesLimit))
if err != nil {
f.Close()
return errors.Wrap(err, "download asset")
}
f.Close()
executable, err := u.GetExecutable()
if err != nil {
return errors.Wrap(err, "get executable")
}
appPath, err := appPathFromExecutable(executable)
if err != nil {
return errors.Wrap(err, "find app path")
}
appPathDir := filepath.Dir(appPath)
appPreviousPath := appPath + ".previous"
err = os.Rename(appPath, appPreviousPath)
if err != nil {
_, statErr := os.Stat(appPath)
// not exist is ok, just ignore it
if !os.IsNotExist(statErr) {
return errors.Wrap(err, "rename existing app")
}
}
fmt.Println("UPDATERRRRR", filename, f.Name(), appPath, appPathDir, appPreviousPath)
err = archiver.Unarchive(f.Name(), appPathDir)
if err != nil {
return errors.Wrap(err, "unarchive")
}
err = os.RemoveAll(appPreviousPath)
if err != nil {
return errors.Wrap(err, "remove previous")
}
return nil
}
// Release is a GitHub release.
type Release struct {
TagName string `json:"tag_name"`
Assets []Asset `json:"assets"`
Body string `json:"body"`
CreatedAtString string `json:"created_at"`
CreatedAt time.Time `json:"created_at_time"`
}
// Asset is a file within a Release on GitHub.
type Asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// appPathFromExecutable gets the .app path from the currently
// running executable.
func appPathFromExecutable(p string) (string, error) {
if !strings.HasSuffix(p, "/Contents/MacOS/ytd") {
return "", errors.New("executable not where it should be")
}
return strings.TrimSuffix(p, "/Contents/MacOS/ytd"), nil
}