github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/updater/updater.go (about)

     1  // Copyright 2015 Keybase, Inc. All rights reserved. Use of
     2  // this source code is governed by the included BSD license.
     3  
     4  package updater
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/keybase/client/go/updater/util"
    17  )
    18  
    19  // Version is the updater version
    20  const Version = "0.3.8"
    21  
    22  // Updater knows how to find and apply updates
    23  type Updater struct {
    24  	source       UpdateSource
    25  	config       Config
    26  	log          Log
    27  	guiBusyCount int
    28  	tickDuration time.Duration
    29  }
    30  
    31  // UpdateSource defines where the updater can find updates
    32  type UpdateSource interface {
    33  	// Description is a short description about the update source
    34  	Description() string
    35  	// FindUpdate finds an update given options
    36  	FindUpdate(options UpdateOptions) (*Update, error)
    37  }
    38  
    39  // Context defines options, UI and hooks for the updater.
    40  // This is where you can define custom behavior specific to your apps.
    41  type Context interface {
    42  	GetUpdateUI() UpdateUI
    43  	UpdateOptions() UpdateOptions
    44  	Verify(update Update) error
    45  	BeforeUpdatePrompt(update Update, options UpdateOptions) error
    46  	BeforeApply(update Update) error
    47  	Apply(update Update, options UpdateOptions, tmpDir string) error
    48  	AfterApply(update Update) error
    49  	ReportError(err error, update *Update, options UpdateOptions)
    50  	ReportAction(updatePromptResponse UpdatePromptResponse, update *Update, options UpdateOptions)
    51  	ReportSuccess(update *Update, options UpdateOptions)
    52  	AfterUpdateCheck(update *Update)
    53  	GetAppStatePath() string
    54  	IsCheckCommand() bool
    55  	DeepClean()
    56  }
    57  
    58  // Config defines configuration for the Updater
    59  type Config interface {
    60  	GetUpdateAuto() (bool, bool)
    61  	SetUpdateAuto(b bool) error
    62  	GetUpdateAutoOverride() bool
    63  	SetUpdateAutoOverride(bool) error
    64  	GetInstallID() string
    65  	SetInstallID(installID string) error
    66  	IsLastUpdateCheckTimeRecent(d time.Duration) bool
    67  	SetLastUpdateCheckTime()
    68  	SetLastAppliedVersion(string) error
    69  	GetLastAppliedVersion() string
    70  }
    71  
    72  // Log is the logging interface for this package
    73  type Log interface {
    74  	Debug(...interface{})
    75  	Info(...interface{})
    76  	Debugf(s string, args ...interface{})
    77  	Infof(s string, args ...interface{})
    78  	Warningf(s string, args ...interface{})
    79  	Errorf(s string, args ...interface{})
    80  }
    81  
    82  // NewUpdater constructs an Updater
    83  func NewUpdater(source UpdateSource, config Config, log Log) *Updater {
    84  	return &Updater{
    85  		source:       source,
    86  		config:       config,
    87  		log:          log,
    88  		tickDuration: DefaultTickDuration,
    89  	}
    90  }
    91  
    92  func (u *Updater) SetTickDuration(dur time.Duration) {
    93  	u.tickDuration = dur
    94  }
    95  
    96  // Update checks, downloads and performs an update
    97  func (u *Updater) Update(ctx Context) (*Update, error) {
    98  	options := ctx.UpdateOptions()
    99  	update, err := u.update(ctx, options)
   100  	report(ctx, err, update, options)
   101  	return update, err
   102  }
   103  
   104  // update returns the update received, and an error if the update was not
   105  // performed. The error with be of type Error. The error may be due to the user
   106  // (or system) canceling an update, in which case error.IsCancel() will be true.
   107  func (u *Updater) update(ctx Context, options UpdateOptions) (*Update, error) {
   108  	update, err := u.checkForUpdate(ctx, options)
   109  	if err != nil {
   110  		return nil, findErr(err)
   111  	}
   112  	if update == nil || !update.NeedUpdate {
   113  		// No update available
   114  		return nil, nil
   115  	}
   116  	u.log.Infof("Got update with version: %s", update.Version)
   117  
   118  	if update.missingAsset() {
   119  		return update, nil
   120  	}
   121  
   122  	if err := u.CleanupPreviousUpdates(); err != nil {
   123  		u.log.Infof("Error cleaning up previous downloads: %v", err)
   124  	}
   125  
   126  	tmpDir := u.tempDir()
   127  	defer u.Cleanup(tmpDir)
   128  	if err := u.downloadAsset(update.Asset, tmpDir, options); err != nil {
   129  		return update, downloadErr(err)
   130  	}
   131  
   132  	err = ctx.BeforeUpdatePrompt(*update, options)
   133  	if err != nil {
   134  		return update, err
   135  	}
   136  
   137  	// Prompt for update
   138  	updatePromptResponse, err := u.promptForUpdateAction(ctx, *update, options)
   139  	if err != nil {
   140  		return update, promptErr(err)
   141  	}
   142  	switch updatePromptResponse.Action {
   143  	case UpdateActionApply:
   144  		ctx.ReportAction(updatePromptResponse, update, options)
   145  	case UpdateActionAuto:
   146  		ctx.ReportAction(updatePromptResponse, update, options)
   147  	case UpdateActionSnooze:
   148  		ctx.ReportAction(updatePromptResponse, update, options)
   149  		return update, CancelErr(fmt.Errorf("Snoozed update"))
   150  	case UpdateActionCancel:
   151  		ctx.ReportAction(updatePromptResponse, update, options)
   152  		return update, CancelErr(fmt.Errorf("Canceled"))
   153  	case UpdateActionError:
   154  		return update, promptErr(fmt.Errorf("Unknown prompt error"))
   155  	case UpdateActionContinue:
   156  		// Continue
   157  	case UpdateActionUIBusy:
   158  		// Return nil so that AfterUpdateCheck won't exit the service
   159  		return nil, guiBusyErr(fmt.Errorf("User active, retrying later"))
   160  	}
   161  
   162  	// If we are auto-updating, do a final check if the user is active before
   163  	// killing the app. Note this can cause some churn with re-downloading the
   164  	// update on the next attempt.
   165  	if updatePromptResponse.Action == UpdateActionAuto && !ctx.IsCheckCommand() {
   166  		isActive, err := u.checkUserActive(ctx)
   167  		if err == nil && isActive {
   168  			return nil, guiBusyErr(fmt.Errorf("User active, retrying later"))
   169  		}
   170  	}
   171  
   172  	u.log.Infof("Verify asset: %s", update.Asset.LocalPath)
   173  	if err := ctx.Verify(*update); err != nil {
   174  		return update, verifyErr(err)
   175  	}
   176  
   177  	if err := u.apply(ctx, *update, options, tmpDir); err != nil {
   178  		return update, err
   179  	}
   180  
   181  	return update, nil
   182  }
   183  
   184  func (u *Updater) ApplyDownloaded(ctx Context) (bool, error) {
   185  	options := ctx.UpdateOptions()
   186  
   187  	// 1. check with the api server again for the latest update to be sure that a
   188  	// new update has not come out since our last call to CheckAndDownload
   189  	u.log.Infof("Attempting to apply previously downloaded update")
   190  	update, err := u.checkForUpdate(ctx, options)
   191  	if err != nil {
   192  		return false, findErr(err)
   193  	}
   194  
   195  	// Only report apply success/failure
   196  	applied, err := u.applyDownloaded(ctx, update, options)
   197  	defer report(ctx, err, update, options)
   198  	if err != nil {
   199  		return false, err
   200  	}
   201  	return applied, nil
   202  
   203  }
   204  
   205  // ApplyDownloaded will look for an previously downloaded update and attempt to apply it without prompting.
   206  // CheckAndDownload must be called first so that we have a download asset available to apply.
   207  func (u *Updater) applyDownloaded(ctx Context, update *Update, options UpdateOptions) (applied bool, err error) {
   208  	if update == nil || !update.NeedUpdate {
   209  		return false, fmt.Errorf("No previously downloaded update to apply since client is update to date")
   210  	}
   211  	u.log.Infof("Got update with version: %s", update.Version)
   212  
   213  	if update.missingAsset() {
   214  		return false, fmt.Errorf("Update contained no asset to apply. Update version: %s", update.Version)
   215  	}
   216  
   217  	// 2. check the disk via FindDownloadedAsset. Compare our API result to this
   218  	// result. If the downloaded update is stale, clear it and start over.
   219  	downloadedAssetPath, err := u.FindDownloadedAsset(update.Asset.Name)
   220  	if err != nil {
   221  		return false, err
   222  	}
   223  	defer func() {
   224  		if err := u.CleanupPreviousUpdates(); err != nil {
   225  			u.log.Infof("Error cleaning up previous downloads: %v", err)
   226  		}
   227  	}()
   228  	if downloadedAssetPath == "" {
   229  		return false, fmt.Errorf("No downloaded asset found for version: %s", update.Version)
   230  	}
   231  	update.Asset.LocalPath = downloadedAssetPath
   232  
   233  	// 3. otherwise use the update on disk and apply it.
   234  	if err = util.CheckDigest(update.Asset.Digest, downloadedAssetPath, u.log); err != nil {
   235  		return false, verifyErr(err)
   236  	}
   237  	u.log.Infof("Verify asset: %s", downloadedAssetPath)
   238  	if err := ctx.Verify(*update); err != nil {
   239  		return false, verifyErr(err)
   240  	}
   241  
   242  	tmpDir := os.TempDir()
   243  	if err := u.apply(ctx, *update, options, tmpDir); err != nil {
   244  		return false, err
   245  	}
   246  
   247  	return true, nil
   248  }
   249  
   250  func (u *Updater) apply(ctx Context, update Update, options UpdateOptions, tmpDir string) error {
   251  	u.log.Info("Before apply")
   252  	if err := ctx.BeforeApply(update); err != nil {
   253  		return applyErr(err)
   254  	}
   255  
   256  	u.log.Info("Applying update")
   257  	if err := ctx.Apply(update, options, tmpDir); err != nil {
   258  		u.log.Info("Apply error: %v", err)
   259  		return applyErr(err)
   260  	}
   261  
   262  	u.log.Info("After apply")
   263  	if err := ctx.AfterApply(update); err != nil {
   264  		return applyErr(err)
   265  	}
   266  
   267  	return nil
   268  }
   269  
   270  // downloadAsset will download the update to a temporary path (if not cached),
   271  // check the digest, and set the LocalPath property on the asset.
   272  func (u *Updater) downloadAsset(asset *Asset, tmpDir string, options UpdateOptions) error {
   273  	if asset == nil {
   274  		return fmt.Errorf("No asset to download")
   275  	}
   276  	downloadOptions := util.DownloadURLOptions{
   277  		Digest:        asset.Digest,
   278  		RequireDigest: true,
   279  		UseETag:       true,
   280  		Log:           u.log,
   281  	}
   282  
   283  	downloadPath := filepath.Join(tmpDir, asset.Name)
   284  	// If asset had a file extension, lets add it back on
   285  	if err := util.DownloadURL(asset.URL, downloadPath, downloadOptions); err != nil {
   286  		return err
   287  	}
   288  
   289  	asset.LocalPath = downloadPath
   290  	return nil
   291  }
   292  
   293  // checkForUpdate checks a update source (like a remote API) for an update.
   294  // It may set an InstallID, if the server tells us to.
   295  func (u *Updater) checkForUpdate(ctx Context, options UpdateOptions) (*Update, error) {
   296  	u.log.Infof("Checking for update, current version is %s", options.Version)
   297  	u.log.Infof("Using updater source: %s", u.source.Description())
   298  	u.log.Debugf("Using options: %#v", options)
   299  
   300  	update, findErr := u.source.FindUpdate(options)
   301  	if findErr != nil {
   302  		return nil, findErr
   303  	}
   304  	if update == nil {
   305  		return nil, nil
   306  	}
   307  
   308  	// Save InstallID if we received one
   309  	if update.InstallID != "" && u.config.GetInstallID() != update.InstallID {
   310  		u.log.Debugf("Saving install ID: %s", update.InstallID)
   311  		if err := u.config.SetInstallID(update.InstallID); err != nil {
   312  			u.log.Warningf("Error saving install ID: %s", err)
   313  			ctx.ReportError(configErr(fmt.Errorf("Error saving install ID: %s", err)), update, options)
   314  		}
   315  	}
   316  
   317  	return update, nil
   318  }
   319  
   320  // NeedUpdate returns true if we are out-of-date.
   321  func (u *Updater) NeedUpdate(ctx Context) (upToDate bool, err error) {
   322  	update, err := u.checkForUpdate(ctx, ctx.UpdateOptions())
   323  	if err != nil {
   324  		return false, err
   325  	}
   326  	return update.NeedUpdate, nil
   327  }
   328  
   329  func (u *Updater) CheckAndDownload(ctx Context) (updateAvailable, updateWasDownloaded bool, err error) {
   330  	options := ctx.UpdateOptions()
   331  	update, err := u.checkForUpdate(ctx, options)
   332  	if err != nil {
   333  		return false, false, err
   334  	}
   335  
   336  	if !update.NeedUpdate || update.missingAsset() {
   337  		return false, false, nil
   338  	}
   339  
   340  	var tmpDir string
   341  	defer func() {
   342  		// If anything in this process errors cleanup the downloaded asset
   343  		if err != nil {
   344  			if err := u.CleanupPreviousUpdates(); err != nil {
   345  				u.log.Infof("Error cleaning up previous downloads: %v", err)
   346  			}
   347  		}
   348  		if tmpDir != "" {
   349  			u.Cleanup(tmpDir)
   350  		}
   351  	}()
   352  	var digestChecked bool
   353  	downloadedAssetPath, err := u.FindDownloadedAsset(update.Asset.Name)
   354  	if downloadedAssetPath == "" || err != nil {
   355  		u.log.Infof("Could not find existing download asset for version: %s. Downloading new asset.", update.Version)
   356  		tmpDir = u.tempDir()
   357  		// This will set update.Asset.LocalPath
   358  		if err := u.downloadAsset(update.Asset, tmpDir, options); err != nil {
   359  			return false, false, downloadErr(err)
   360  		}
   361  		updateWasDownloaded = true
   362  		digestChecked = true
   363  		downloadedAssetPath = update.Asset.LocalPath
   364  	}
   365  	// Verify depends on LocalPath being set to the downloaded asset
   366  	update.Asset.LocalPath = downloadedAssetPath
   367  
   368  	u.log.Infof("Verify asset: %s", downloadedAssetPath)
   369  	if err := ctx.Verify(*update); err != nil {
   370  		return false, false, verifyErr(err)
   371  	}
   372  
   373  	if !digestChecked {
   374  		if err = util.CheckDigest(update.Asset.Digest, downloadedAssetPath, u.log); err != nil {
   375  			return false, false, verifyErr(err)
   376  		}
   377  	}
   378  
   379  	return true, updateWasDownloaded, nil
   380  }
   381  
   382  // promptForUpdateAction prompts the user for permission to apply an update
   383  func (u *Updater) promptForUpdateAction(ctx Context, update Update, options UpdateOptions) (UpdatePromptResponse, error) {
   384  	u.log.Debug("Prompt for update")
   385  
   386  	auto, autoSet := u.config.GetUpdateAuto()
   387  	autoOverride := u.config.GetUpdateAutoOverride()
   388  	u.log.Debugf("Auto update: %s (set=%s autoOverride=%s)", strconv.FormatBool(auto), strconv.FormatBool(autoSet), strconv.FormatBool(autoOverride))
   389  	if auto && !autoOverride {
   390  		if !ctx.IsCheckCommand() {
   391  			// If there's an error getting active status, we'll just update
   392  			isActive, err := u.checkUserActive(ctx)
   393  			if err == nil && isActive {
   394  				return UpdatePromptResponse{UpdateActionUIBusy, false, 0}, nil
   395  			}
   396  			u.guiBusyCount = 0
   397  		}
   398  		return UpdatePromptResponse{UpdateActionAuto, false, 0}, nil
   399  	}
   400  
   401  	updateUI := ctx.GetUpdateUI()
   402  
   403  	// If auto update never set, default to true
   404  	autoUpdate := auto || !autoSet
   405  	promptOptions := UpdatePromptOptions{AutoUpdate: autoUpdate}
   406  	updatePromptResponse, err := updateUI.UpdatePrompt(update, options, promptOptions)
   407  	if err != nil {
   408  		return UpdatePromptResponse{UpdateActionError, false, 0}, err
   409  	}
   410  	if updatePromptResponse == nil {
   411  		return UpdatePromptResponse{UpdateActionError, false, 0}, fmt.Errorf("No response")
   412  	}
   413  
   414  	if updatePromptResponse.Action != UpdateActionContinue {
   415  		u.log.Debugf("Update prompt response: %#v", updatePromptResponse)
   416  		if err := u.config.SetUpdateAuto(updatePromptResponse.AutoUpdate); err != nil {
   417  			u.log.Warningf("Error setting auto preference: %s", err)
   418  			ctx.ReportError(configErr(fmt.Errorf("Error setting auto preference: %s", err)), &update, options)
   419  		}
   420  	}
   421  
   422  	return *updatePromptResponse, nil
   423  }
   424  
   425  type guiAppState struct {
   426  	IsUserActive bool  `json:"isUserActive"`
   427  	ChangedAtMs  int64 `json:"changedAtMs"`
   428  }
   429  
   430  func (u *Updater) checkUserActive(ctx Context) (bool, error) {
   431  	if time.Duration(u.guiBusyCount)*u.tickDuration >= time.Hour*6 { // Allow the update through after 6 hours
   432  		u.log.Warningf("Waited for GUI %d times - ignoring busy", u.guiBusyCount)
   433  		return false, nil
   434  	}
   435  
   436  	// Read app-state.json, written by the GUI
   437  	rawState, err := util.ReadFile(ctx.GetAppStatePath())
   438  	if err != nil {
   439  		u.log.Warningf("Error reading GUI state - proceeding", err)
   440  		return false, err
   441  	}
   442  
   443  	guistate := guiAppState{}
   444  	if err = json.Unmarshal(rawState, &guistate); err != nil {
   445  		u.log.Warningf("Error parsing GUI state - proceeding", err)
   446  		return false, err
   447  	}
   448  	// check if the user is currently active or was active in the last 5
   449  	// minutes.
   450  	isActive := guistate.IsUserActive || time.Since(time.Unix(guistate.ChangedAtMs/1000, 0)) <= time.Minute*5
   451  	if isActive {
   452  		u.guiBusyCount++
   453  		u.log.Infof("GUI busy on attempt %d", u.guiBusyCount)
   454  	}
   455  
   456  	return isActive, nil
   457  }
   458  
   459  func report(ctx Context, err error, update *Update, options UpdateOptions) {
   460  	if err != nil {
   461  		// Don't report cancels or GUI busy
   462  		if e, ok := err.(Error); ok {
   463  			if e.IsCancel() || e.IsGUIBusy() {
   464  				return
   465  			}
   466  		}
   467  		ctx.ReportError(err, update, options)
   468  	} else if update != nil {
   469  		ctx.ReportSuccess(update, options)
   470  	}
   471  }
   472  
   473  // tempDir, if specified, will contain files that were replaced during an update
   474  // and will be removed after an update. The temp dir should already exist.
   475  func (u *Updater) tempDir() string {
   476  	tmpDir := util.TempPath("", "KeybaseUpdater.")
   477  	if err := util.MakeDirs(tmpDir, 0700, u.log); err != nil {
   478  		u.log.Warningf("Error trying to create temp dir: %s", err)
   479  		return ""
   480  	}
   481  	return tmpDir
   482  }
   483  
   484  var tempDirRE = regexp.MustCompile(`^KeybaseUpdater.([ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]{52}|\d{18,})$`)
   485  
   486  // CleanupPreviousUpdates removes temporary files from previous updates.
   487  func (u *Updater) CleanupPreviousUpdates() (err error) {
   488  	parent := os.TempDir()
   489  	if parent == "" || parent == "." {
   490  		return fmt.Errorf("temp directory is '%v'", parent)
   491  	}
   492  	files, err := os.ReadDir(parent)
   493  	if err != nil {
   494  		return fmt.Errorf("listing parent directory: %v", err)
   495  	}
   496  	for _, fi := range files {
   497  		if !fi.IsDir() {
   498  			continue
   499  		}
   500  		if tempDirRE.MatchString(fi.Name()) {
   501  			targetPath := filepath.Join(parent, fi.Name())
   502  			u.log.Debugf("Cleaning old download: %v", targetPath)
   503  			err = os.RemoveAll(targetPath)
   504  			if err != nil {
   505  				u.log.Infof("Error deleting old temp dir %v: %v", fi.Name(), err)
   506  			}
   507  		}
   508  	}
   509  	return nil
   510  }
   511  
   512  // Cleanup removes temporary files from this update
   513  func (u *Updater) Cleanup(tmpDir string) {
   514  	if tmpDir != "" {
   515  		u.log.Debugf("Remove temporary directory: %q", tmpDir)
   516  		if err := os.RemoveAll(tmpDir); err != nil {
   517  			u.log.Warningf("Error removing temporary directory %q: %s", tmpDir, err)
   518  		}
   519  	}
   520  }
   521  
   522  // Inspect previously downloaded updates to avoid redownloading
   523  func (u *Updater) FindDownloadedAsset(assetName string) (matchingAssetPath string, err error) {
   524  	if assetName == "" {
   525  		return "", fmt.Errorf("No asset name provided")
   526  	}
   527  	parent := os.TempDir()
   528  	if parent == "" || parent == "." {
   529  		return matchingAssetPath, fmt.Errorf("temp directory is %v", parent)
   530  	}
   531  
   532  	files, err := os.ReadDir(parent)
   533  	if err != nil {
   534  		return matchingAssetPath, fmt.Errorf("listing parent directory: %v", err)
   535  	}
   536  
   537  	for _, fi := range files {
   538  		if !fi.IsDir() || !tempDirRE.MatchString(fi.Name()) {
   539  			continue
   540  		}
   541  
   542  		keybaseTempDirAbs := filepath.Join(parent, fi.Name())
   543  		walkErr := filepath.Walk(keybaseTempDirAbs, func(fullPath string, info os.FileInfo, inErr error) (err error) {
   544  			if inErr != nil {
   545  				return inErr
   546  			}
   547  
   548  			if info.IsDir() {
   549  				if fullPath == keybaseTempDirAbs {
   550  					return nil
   551  				}
   552  				return filepath.SkipDir
   553  			}
   554  
   555  			path := strings.TrimPrefix(fullPath, keybaseTempDirAbs+string(filepath.Separator))
   556  			if path == assetName {
   557  				matchingAssetPath = fullPath
   558  				return filepath.SkipDir
   559  			}
   560  
   561  			return nil
   562  		})
   563  
   564  		if walkErr != nil {
   565  			return "", walkErr
   566  		}
   567  	}
   568  	return matchingAssetPath, nil
   569  }