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 }