| 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 |