返回 DeepSeek-Reasonix
updater_app.go
根目录 / desktop / updater_app.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "regexp"
9 "runtime"
10 "strings"
11
12 "reasonix/desktop/internal/update"
13 "reasonix/internal/installlayout"
14 "reasonix/internal/repair"
15 )
16
17 // updater_app.go is the auto-updater's bound command surface — the App methods the
18 // frontend calls — mirroring settings_app.go's "one file per concern" split. The
19 // transport-free logic lives in updater.go; this file is the Wails glue: it streams
20 // download progress as "updater:progress" events and routes macOS to the manual
21 // download path unless the macOS build was Developer ID signed and notarized.
22
23 var errUpdateDisabled = errors.New("update: disabled for this build")
24 var errUpdateManualRequired = errors.New("update: manual update required")
25 var errUpdateInProgress = errors.New("update: another download or install is already in progress")
26
27 var updaterRequestIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
28
29 var (
30 pendingUpdateExistsForInstall = repair.PendingUpdateExists
31 archiveSupersededPendingUpdateForInstall = archiveSupersededPendingUpdateAfterReady
32 reconcilePendingUpdateForInstall = repair.ReconcilePendingUpdate
33 readPendingUpdateForHealth = repair.ReadPendingUpdate
34 markPendingUpdateHealthyAfterReady = repair.MarkUpdateHealthyExact
35 updaterHTTPClient = httpClient
36 updaterHTTPClientIPv4 = httpClientIPv4
37 )
38
39 func validateUpdaterRequest(requestID, selectedChannel, expectedVersion string) (string, string, string, error) {
40 requestID = strings.TrimSpace(requestID)
41 if !updaterRequestIDRE.MatchString(requestID) {
42 return "", "", "", fmt.Errorf("update: invalid request id")
43 }
44 selectedChannel = targetUpdateChannel(selectedChannel)
45 expectedVersion = strings.TrimSpace(expectedVersion)
46 if !stableDesktopVersionRE.MatchString(expectedVersion) {
47 return "", "", "", fmt.Errorf("update: invalid %s version %q", selectedChannel, expectedVersion)
48 }
49 return requestID, selectedChannel, expectedVersion, nil
50 }
51
52 func (a *App) beginUpdaterOperation(requestID string) (func(), error) {
53 a.updaterOperationMu.Lock()
54 defer a.updaterOperationMu.Unlock()
55 if a.updaterOperationID != "" {
56 return nil, errUpdateInProgress
57 }
58 a.updaterOperationID = requestID
59 return func() {
60 a.updaterOperationMu.Lock()
61 if a.updaterOperationID == requestID {
62 a.updaterOperationID = ""
63 }
64 a.updaterOperationMu.Unlock()
65 }, nil
66 }
67
68 func ensureExpectedUpdateVersion(selectedChannel, expectedVersion, actualVersion string) error {
69 if actualVersion == expectedVersion {
70 return nil
71 }
72 return fmt.Errorf(
73 "update: %s pointer changed from %s to %s; check again before downloading",
74 selectedChannel,
75 expectedVersion,
76 actualVersion,
77 )
78 }
79
80 // Version returns the build version injected via -ldflags (see main.go). The
81 // frontend displays it; CheckUpdate compares against it.
82 func (a *App) Version() string { return version }
83
84 // CheckUpdate fetches the manifest (R2, then GitHub) and reports whether a newer
85 // build is available for this platform. Safe to call on startup: a network error
86 // surfaces in UpdateInfo.Err rather than failing, so the UI can stay quiet.
87 func (a *App) CheckUpdate(selectedChannel string) (*UpdateInfo, error) {
88 if !desktopUpdaterEnabled() {
89 return &UpdateInfo{Current: version, Channel: "stable"}, nil
90 }
91 selectedChannel = targetUpdateChannel(selectedChannel)
92 profile := detectInstallProfile()
93 c, err := updaterHTTPClient()
94 if err != nil {
95 a.recordUpdateError(err)
96 return &UpdateInfo{
97 Current: version,
98 Channel: selectedChannel,
99 CanSelfUpdate: profile.CanSelfUpdate && canSelfUpdate(),
100 ManualOnly: !(profile.CanSelfUpdate && canSelfUpdate()),
101 ManualReason: firstNonEmptyStr(profile.ManualReason, manualUpdateReason()),
102 InstallMode: profile.Mode,
103 RequiresElevation: profile.RequiresElev,
104 DownloadURL: downloadPage(selectedChannel),
105 Err: err.Error(),
106 }, nil
107 }
108 ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
109 defer cancel()
110 v4, _ := updaterHTTPClientIPv4()
111 m, err := fetchManifest(ctx, c, v4, selectedChannel)
112 if err != nil {
113 a.recordUpdateError(err)
114 return &UpdateInfo{
115 Current: version,
116 Channel: selectedChannel,
117 CanSelfUpdate: profile.CanSelfUpdate && canSelfUpdate(),
118 ManualOnly: !(profile.CanSelfUpdate && canSelfUpdate()),
119 ManualReason: firstNonEmptyStr(profile.ManualReason, manualUpdateReason()),
120 InstallMode: profile.Mode,
121 RequiresElevation: profile.RequiresElev,
122 DownloadURL: downloadPage(selectedChannel),
123 Err: err.Error(),
124 }, nil
125 }
126 info := evaluateForChannel(version, selectedChannel, m)
127 return &info, nil
128 }
129
130 // OpenDownloadPage opens the install page in the browser — the macOS manual-update
131 // path and a fallback link elsewhere.
132 func (a *App) OpenDownloadPage() {
133 if !desktopUpdaterEnabled() {
134 return
135 }
136 a.openDownloadPage(targetUpdateChannel(""))
137 }
138
139 func (a *App) openDownloadPage(selectedChannel string) {
140 selectedChannel = targetUpdateChannel(selectedChannel)
141 page := downloadPage(selectedChannel)
142 if c, err := updaterHTTPClient(); err == nil {
143 ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
144 defer cancel()
145 v4, _ := updaterHTTPClientIPv4()
146 if m, err := fetchManifest(ctx, c, v4, selectedChannel); err == nil {
147 page = manifestDownloadPage(selectedChannel, m.DownloadPage)
148 }
149 }
150 if a.ctx != nil {
151 a.nativeHost().OpenExternal(a.ctx, page)
152 }
153 }
154
155 // downloadUpdateRequest downloads, verifies, and caches the exact version bound
156 // to a request. Used only by ApplyUpdateRequest; not exposed as a Wails binding.
157 func (a *App) downloadUpdateRequest(selectedChannel, expectedVersion, requestID string) (*UpdateDownloadResult, error) {
158 requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
159 if err != nil {
160 return nil, err
161 }
162 profile := detectInstallProfile()
163 if !profile.CanSelfUpdate || !canSelfUpdate() {
164 return nil, a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
165 }
166 c, err := updaterHTTPClient()
167 if err != nil {
168 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
169 }
170 ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
171 defer cancel()
172 v4, _ := updaterHTTPClientIPv4()
173 m, err := fetchManifest(ctx, c, v4, selectedChannel)
174 if err != nil {
175 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
176 }
177 if err := ensureExpectedUpdateVersion(selectedChannel, expectedVersion, m.Version); err != nil {
178 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
179 }
180 profile = profileForManifest(profile, m)
181 if !profile.CanSelfUpdate {
182 return nil, a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
183 }
184 asset, kind, ok := selectUpdateAsset(m, profile)
185 if !ok {
186 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, fmt.Errorf("no update artifact for %s", update.CurrentPlatform()))
187 }
188
189 data, sig, err := a.downloadVerify(requestID, selectedChannel, expectedVersion, asset)
190 if err != nil {
191 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
192 }
193 meta, err := saveCachedUpdateForChannel(selectedChannel, m.Version, asset, data, kind, sig)
194 if err != nil {
195 return nil, a.failUpdate(requestID, selectedChannel, expectedVersion, err)
196 }
197 a.emitProgress(requestID, selectedChannel, meta.Version, "downloaded", meta.Size, meta.Size, "")
198 return &UpdateDownloadResult{
199 RequestID: requestID,
200 Version: meta.Version,
201 Channel: meta.Channel,
202 Path: meta.Path,
203 Size: meta.Size,
204 SHA256: meta.SHA256,
205 }, nil
206 }
207
208 // installUpdateRequest applies the exact cached, verified update bound to a
209 // request and then exits/relaunches. Used only by ApplyUpdateRequest.
210 func (a *App) installUpdateRequest(selectedChannel, expectedVersion, requestID string) error {
211 requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
212 if err != nil {
213 return err
214 }
215 profile := detectInstallProfile()
216 if !profile.CanSelfUpdate || !canSelfUpdate() {
217 return a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
218 }
219 meta, data, err := readVerifiedCachedUpdateForChannel(selectedChannel)
220 if err != nil {
221 return a.failUpdate(requestID, selectedChannel, expectedVersion, err)
222 }
223 if meta.Version != expectedVersion {
224 return a.failUpdate(requestID, selectedChannel, expectedVersion, fmt.Errorf(
225 "update: cached version %s does not match checked version %s",
226 meta.Version,
227 expectedVersion,
228 ))
229 }
230 // Re-detect install type at install time so a path change between download
231 // and install cannot apply the wrong artifact kind.
232 if c, err := updaterHTTPClient(); err == nil {
233 ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
234 defer cancel()
235 v4, _ := updaterHTTPClientIPv4()
236 if m, err := fetchManifest(ctx, c, v4, selectedChannel); err == nil {
237 profile = profileForManifest(detectInstallProfile(), m)
238 } else {
239 profile = detectInstallProfile()
240 }
241 } else {
242 profile = detectInstallProfile()
243 }
244 if !profile.CanSelfUpdate {
245 return a.requireManualUpdate(requestID, selectedChannel, expectedVersion, profile)
246 }
247 if err := ensureDebCacheMatchesProfile(meta, profile); err != nil {
248 return a.failUpdate(requestID, selectedChannel, expectedVersion, err)
249 }
250 // Portable cache vs deb profile (and the reverse) are also rejected when
251 // artifact kinds disagree with the active mode.
252 wantKind := profile.ArtifactKind
253 if wantKind == "" {
254 wantKind = artifactKindTarball
255 }
256 if artifactKindFromMeta(meta.ArtifactKind) != artifactKindFromMeta(wantKind) {
257 return a.failUpdate(requestID, selectedChannel, expectedVersion, errUpdateCacheMismatch)
258 }
259 if err := a.reconcilePendingUpdateForRequest(requestID, meta); err != nil {
260 return err
261 }
262
263 switch profile.Mode {
264 case installModeDeb:
265 return a.installDebUpdate(requestID, meta)
266 default:
267 return a.installPortableUpdate(requestID, meta, data)
268 }
269 }
270
271 // reconcilePendingUpdateForRequest runs before download and again before
272 // install-mode dispatch. The early pass avoids paying download and verification
273 // costs for a blocked update; the second pass prevents a profile change or a
274 // concurrent process from bypassing an unfinished release-unit transaction.
275 func (a *App) reconcilePendingUpdateForRequest(requestID string, meta *cachedUpdate) error {
276 if pendingUpdateExistsForInstall() {
277 a.emitProgress(requestID, meta.Channel, meta.Version, "recovering", meta.Size, meta.Size, "")
278 // A user-initiated update proves the desktop reached a usable UI. Retire
279 // an eligible superseded app-bundle or flat-layout transaction here as
280 // well as in the delayed post-DOM health task, so an immediate click never
281 // has to fail once and ask the user to retry.
282 if archived, archiveErr := archiveSupersededPendingUpdateForInstall(); archiveErr != nil {
283 slog.Debug("desktop: superseded update was not eligible for automatic archival", "err", archiveErr)
284 } else if archived {
285 slog.Info("desktop: archived superseded update before install")
286 }
287 // Visible UI at the pending target is health evidence — heal before
288 // reconcile so a missed post-DOM task does not block the next update.
289 // Exact and probationary commits are independent best-effort paths.
290 refreshPendingUpdateHealthIdentity(a)
291 if err := a.commitPendingUpdateHealth(); err != nil {
292 slog.Debug("desktop: commit healthy update before install", "err", err)
293 }
294 if committed, err := repair.CommitProbationaryPendingUpdate(version); err != nil {
295 slog.Debug("desktop: probationary update commit before install", "err", err)
296 } else if committed {
297 slog.Info("desktop: committed probationary update before install")
298 }
299 }
300 if _, err := reconcilePendingUpdateForInstall(version); err != nil {
301 if errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
302 // Retry heal after identity refresh, then always re-reconcile.
303 refreshPendingUpdateHealthIdentity(a)
304 if commitErr := a.commitPendingUpdateHealth(); commitErr != nil {
305 slog.Debug("desktop: commit healthy update on awaiting-health retry", "err", commitErr)
306 }
307 if committed, commitErr := repair.CommitProbationaryPendingUpdate(version); commitErr != nil {
308 slog.Debug("desktop: probationary update commit on awaiting-health retry", "err", commitErr)
309 } else if committed {
310 slog.Info("desktop: committed probationary update on awaiting-health retry")
311 }
312 if _, retryErr := reconcilePendingUpdateForInstall(version); retryErr == nil {
313 return nil
314 } else {
315 err = retryErr
316 }
317 }
318 if errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
319 err = fmt.Errorf("update recovery: the previous update is still completing its startup health check; wait briefly and try again, or discard the previous update")
320 } else {
321 err = fmt.Errorf("update recovery: could not safely finish the previous update: %w", err)
322 }
323 return a.failUpdate(requestID, meta.Channel, meta.Version, err)
324 }
325 return nil
326 }
327
328 // AbandonPendingUpdate is a user-facing recovery action for stuck in-app
329 // updates. It commits a still-running probationary target when possible,
330 // otherwise cancels or rolls back the unfinished transaction, and as a last
331 // resort force-retires a probationary marker that already owns the install.
332 func (a *App) AbandonPendingUpdate() error {
333 if !desktopUpdaterEnabled() {
334 return errUpdateDisabled
335 }
336 if !pendingUpdateExistsForInstall() {
337 return nil
338 }
339 refreshPendingUpdateHealthIdentity(a)
340 if err := a.commitPendingUpdateHealth(); err != nil {
341 slog.Debug("desktop: commit healthy update during abandon", "err", err)
342 }
343 if archived, err := archiveSupersededPendingUpdateForInstall(); err != nil {
344 slog.Debug("desktop: archive superseded update during abandon", "err", err)
345 } else if archived {
346 return nil
347 }
348 if _, err := repair.AbandonPendingUpdate(version); err != nil {
349 return fmt.Errorf("could not discard the previous update: %w", err)
350 }
351 return nil
352 }
353
354 func (a *App) installDebUpdate(requestID string, meta *cachedUpdate) error {
355 // authorizing = Polkit password dialog. The helper streams
356 // REASONIX_UPDATE_PHASE=installing on stderr after validation and before
357 // apt-get, so the UI can leave authorizing while the package manager runs.
358 a.emitProgress(requestID, meta.Channel, meta.Version, "authorizing", meta.Size, meta.Size, "")
359 err := applyDebLinux(meta.Path, meta.SignaturePath, func(phase string) {
360 if phase == "installing" {
361 a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
362 }
363 })
364 if isAuthCancelled(err) {
365 // User dismissed the Polkit dialog: keep the verified cache and return to
366 // the downloaded state so they can retry. Do not count as an update error.
367 a.recordUpdateEvent("authorization_cancelled")
368 a.emitProgress(requestID, meta.Channel, meta.Version, "downloaded", meta.Size, meta.Size, "")
369 return nil
370 }
371 if err != nil {
372 if errors.Is(err, errUpdateAuthFailed) {
373 // Surface a manual-install hint without writing /usr/bin ourselves.
374 return a.failUpdate(requestID, meta.Channel, meta.Version, fmt.Errorf("%w. %s", err, manualDebInstallHint()))
375 }
376 return a.failUpdate(requestID, meta.Channel, meta.Version, err)
377 }
378 // Ensure installing was shown even if a phase line was missed (older helper).
379 a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
380 a.emitProgress(requestID, meta.Channel, meta.Version, "done", meta.Size, meta.Size, "")
381 a.relaunchDesktop(true)
382 return nil
383 }
384
385 func (a *App) installPortableUpdate(requestID string, meta *cachedUpdate, data []byte) error {
386 a.emitProgress(requestID, meta.Channel, meta.Version, "installing", meta.Size, meta.Size, "")
387 var preparedUpdate *repair.UpdateTransaction
388 versionedPortable := (runtime.GOOS == "windows" || runtime.GOOS == "linux") && installlayout.HasCurrent(currentInstallDir())
389 if runtime.GOOS == "windows" && !versionedPortable {
390 // Back up the complete legacy release unit (main binary plus launcher
391 // and migration siblings) so rollback never leaves a mixed-version
392 // install. Deb installs deliberately skip this because package-manager
393 // state owns /usr/bin.
394 var err error
395 preparedUpdate, err = repair.PrepareFileUpdate(version, meta.Version, currentExecutablePath(), updateSiblingArtifacts()...)
396 if err != nil {
397 return a.failUpdate(requestID, meta.Channel, meta.Version, err)
398 }
399 }
400 var err error
401 switch runtime.GOOS {
402 case "windows":
403 err = applyWindowsFile(meta.Path, meta.SHA256, meta.Version, preparedUpdate)
404 case "darwin":
405 err = applyMac(meta.Path, meta.Version, a.updateHandoffOwnerPID())
406 case "linux":
407 err = applyLinuxVersioned(data, meta.Version)
408 default:
409 err = fmt.Errorf("self-update unsupported on %s", runtime.GOOS)
410 }
411 if err != nil {
412 if runtime.GOOS == "linux" {
413 // applyLinux replaces the legacy migration member before the main
414 // binary swap, so a failure can already have produced a mixed
415 // install. Restore the recorded release unit immediately; if that
416 // fails, retain the transaction for explicit repair/reconciliation.
417 if preparedUpdate != nil {
418 if _, rollbackErr := repair.RollbackPendingUpdateExact(preparedUpdate); rollbackErr != nil {
419 err = errors.Join(err, fmt.Errorf("restore prepared release unit: %w", rollbackErr))
420 } else if clearErr := repair.ClearUpdateApplyFailureExact(preparedUpdate); clearErr != nil {
421 err = errors.Join(err, fmt.Errorf("clear update recovery marker: %w", clearErr))
422 }
423 }
424 } else if runtime.GOOS == "windows" {
425 // The helper may fail to start after another same-version attempt
426 // has prepared a newer transaction. Cancel only this attempt.
427 if preparedUpdate != nil {
428 if cancelErr := repair.CancelPendingUpdateExact(preparedUpdate); cancelErr != nil {
429 err = errors.Join(err, fmt.Errorf("cancel prepared update: %w", cancelErr))
430 }
431 }
432 } else if runtime.GOOS != "darwin" {
433 if preparedUpdate != nil {
434 if cancelErr := repair.CancelPendingUpdateExact(preparedUpdate); cancelErr != nil {
435 err = errors.Join(err, fmt.Errorf("cancel prepared update: %w", cancelErr))
436 }
437 }
438 }
439 return a.failUpdate(requestID, meta.Channel, meta.Version, err)
440 }
441
442 a.emitProgress(requestID, meta.Channel, meta.Version, "done", meta.Size, meta.Size, "")
443
444 // Persist the conversation and stop subprocesses before handing off (same as
445 // shutdown). On Linux the binary is now replaced, so relaunch it; on Windows and
446 // macOS the installer/helper we launched takes over once we exit.
447 a.relaunchAfterPortableUpdate()
448 return nil
449 }
450
451 // ApplyUpdateRequest downloads, verifies, installs, and relaunches the exact
452 // version bound to a frontend request. This is the v1.20+ single-action update
453 // path ("更新并重启"); there is no durable cross-restart pending state when the
454 // operation fails — the user simply retries.
455 func (a *App) ApplyUpdateRequest(selectedChannel, expectedVersion, requestID string) error {
456 if !desktopUpdaterEnabled() {
457 return errUpdateDisabled
458 }
459 requestID, selectedChannel, expectedVersion, err := validateUpdaterRequest(requestID, selectedChannel, expectedVersion)
460 if err != nil {
461 return err
462 }
463 finish, err := a.beginUpdaterOperation(requestID)
464 if err != nil {
465 return err
466 }
467 // One owner covers the complete download -> verify -> install -> relaunch
468 // sequence, so another request cannot slip into the former phase gap.
469 defer finish()
470
471 if err := a.reconcilePendingUpdateForRequest(requestID, &cachedUpdate{
472 Channel: selectedChannel,
473 Version: expectedVersion,
474 }); err != nil {
475 return err
476 }
477 if _, err := a.downloadUpdateRequest(selectedChannel, expectedVersion, requestID); err != nil {
478 return err
479 }
480 a.emitProgress(requestID, selectedChannel, expectedVersion, "installing", 0, 0, "")
481 if err := a.installUpdateRequest(selectedChannel, expectedVersion, requestID); err != nil {
482 return err
483 }
484 a.emitProgress(requestID, selectedChannel, expectedVersion, "relaunching", 0, 0, "")
485 return nil
486 }
487
488 // downloadVerify downloads the asset (streaming progress), verifies its minisign
489 // signature against the embedded public key, then its sha256. It returns the
490 // verified bytes and the raw signature (needed for deb helper re-verification).
491 func (a *App) downloadVerify(requestID, selectedChannel, expectedVersion string, asset update.Asset) (data, sig []byte, err error) {
492 c, err := updaterHTTPClient()
493 if err != nil {
494 return nil, nil, err
495 }
496 v4, _ := updaterHTTPClientIPv4() // best-effort IPv4 fallback; nil just means retries reuse c
497 data, err = downloadForChannel(a.reqCtx(), c, v4, selectedChannel, asset.URL, asset.Size, func(rcv, total int64) {
498 a.emitProgress(requestID, selectedChannel, expectedVersion, "downloading", rcv, total, "")
499 })
500 if err != nil {
501 return nil, nil, err
502 }
503 a.emitProgress(requestID, selectedChannel, expectedVersion, "verifying", asset.Size, asset.Size, "")
504 sig, err = fetchBytesFallbackForChannelSized(
505 a.reqCtx(),
506 c,
507 v4,
508 selectedChannel,
509 asset.Sig,
510 maxDesktopSignatureSize,
511 )
512 if err != nil {
513 return nil, nil, err
514 }
515 if err := update.Verify(data, sig); err != nil {
516 return nil, nil, err
517 }
518 if err := checkSHA256(data, asset.SHA256); err != nil {
519 return nil, nil, err
520 }
521 return data, sig, nil
522 }
523
524 // reqCtx is the context for updater HTTP calls — the Wails context once startup has
525 // run, else Background (CheckUpdate may, in theory, be reached before startup).
526 func (a *App) reqCtx() context.Context {
527 if a.ctx != nil {
528 return a.ctx
529 }
530 return context.Background()
531 }
532
533 func (a *App) emitProgress(requestID, selectedChannel, expectedVersion, phase string, received, total int64, errMsg string) {
534 a.emitRuntimeEvent("updater:progress", updateProgress{
535 RequestID: requestID,
536 Version: expectedVersion,
537 Channel: normalizeUpdateChannel(selectedChannel),
538 Phase: phase, Received: received, Total: total, Err: errMsg,
539 })
540 }
541
542 // failUpdate emits an error progress event and returns the error to the caller.
543 func (a *App) failUpdate(requestID, selectedChannel, expectedVersion string, err error) error {
544 a.recordUpdateError(err)
545 a.emitProgress(requestID, selectedChannel, expectedVersion, "error", 0, 0, err.Error())
546 return err
547 }
548
549 // requireManualUpdate moves the frontend out of its busy state before opening
550 // the download page. Install mode and manifest availability are re-checked at
551 // each updater boundary, so either can legitimately change after the frontend
552 // started downloading or authorizing.
553 func (a *App) requireManualUpdate(requestID, selectedChannel, expectedVersion string, profile installProfile) error {
554 err := a.failUpdate(requestID, selectedChannel, expectedVersion, manualUpdateRequiredError(profile))
555 a.openDownloadPage(selectedChannel)
556 return err
557 }
558
559 func manualUpdateRequiredError(profile installProfile) error {
560 reason := firstNonEmptyStr(profile.ManualReason, manualUpdateReason(), "automatic update is unavailable for this install")
561 return fmt.Errorf("%w: %s", errUpdateManualRequired, reason)
562 }
563
564 func (a *App) recordUpdateError(err error) {
565 if err == nil || version == "dev" {
566 return
567 }
568 if isAuthCancelled(err) {
569 // Cancellation is an expected user action, not a failure rate signal.
570 return
571 }
572 if m := a.metrics.Load(); m != nil {
573 m.inc("updater_error", errorClass(err.Error()))
574 }
575 }
576
577 // recordUpdateEvent records a non-failure updater signal (e.g. auth cancelled).
578 func (a *App) recordUpdateEvent(bucket string) {
579 if version == "dev" {
580 return
581 }
582 if m := a.metrics.Load(); m != nil {
583 m.inc("updater_event", bucket)
584 }
585 }
586
587 func firstNonEmptyStr(values ...string) string {
588 for _, v := range values {
589 if v != "" {
590 return v
591 }
592 }
593 return ""
594 }
595
595 lines GO