返回 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 "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
544 lines GO