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