返回 DeepSeek-Reasonix
updater.go
根目录 / desktop / updater.go
1 package main
2
3 import (
4 "archive/tar"
5 "bytes"
6 "compress/gzip"
7 "context"
8 "crypto/sha256"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "net/url"
16 "os"
17 "path"
18 "path/filepath"
19 "regexp"
20 "runtime"
21 "strconv"
22 "strings"
23 "time"
24
25 "golang.org/x/mod/semver"
26
27 "reasonix/desktop/internal/update"
28 "reasonix/internal/config"
29 "reasonix/internal/installlayout"
30 "reasonix/internal/netclient"
31 "reasonix/internal/repair"
32 )
33
34 // updater.go is the transport-free core of the desktop auto-updater: manifest
35 // fetch, version comparison, signed download, and per-platform apply/relaunch. It
36 // has no shell dependency so the logic is unit-tested directly; updater_app.go is
37 // the thin bridge binding that wires these into App methods and progress events.
38
39 // Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
40 // worker release gateway, then GitHub as the stable channel's last resort. The
41 // selected update channel picks the rolling pointer; it is user-configurable and
42 // independent from the build channel embedded for diagnostics/backcompat. The
43 // gateway still avoids GitHub's repository-wide /releases/latest shortcut so the
44 // app is not coupled to GitHub's homepage badge semantics.
45 const (
46 r2Base = "https://dl.reasonix.io"
47 releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
48 downloadPageURL = "https://reasonix.io/#start"
49 manifestDownloadPageURL = "https://reasonix.io/?download=desktop#start"
50 httpTimeout = 15 * time.Second
51 manifestEndpointTimeout = 5 * time.Second
52 maxDesktopReleaseAssetSize = int64(1 << 30)
53 maxDesktopManifestSize = int64(1 << 20)
54 maxDesktopSignatureSize = int64(64 << 10)
55 )
56
57 var fetchAttemptTimeout = 5 * time.Second
58
59 var (
60 stableDesktopVersionRE = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
61 sha256RE = regexp.MustCompile(`^[0-9a-f]{64}$`)
62 )
63
64 // githubManifestFallback is the stable channel's last-resort manifest source.
65 // dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
66 // protection that 403s a user's egress IP takes out both first-party endpoints
67 // at once (#6005); GitHub is separate infrastructure. Stable desktop releases
68 // own the repo-wide latest badge and publish latest.json directly, while
69 // The unified official Release carries the desktop manifest as a final fallback
70 // when both first-party endpoints are unavailable.
71 const githubManifestFallback = "https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json"
72
73 func normalizeUpdateChannel(ch string) string {
74 return config.NormalizeDesktopUpdateChannel(ch)
75 }
76
77 func configuredUpdateChannel() string {
78 cfg, err := config.Load()
79 if err != nil {
80 return "stable"
81 }
82 return cfg.DesktopUpdateChannel()
83 }
84
85 func targetUpdateChannel(selected string) string {
86 _ = selected
87 return configuredUpdateChannel()
88 }
89
90 func runningUpdateChannel() string {
91 return normalizeUpdateChannel(channel)
92 }
93
94 // manifestEndpoints returns the manifest URLs for the selected update channel,
95 // in the order fetchManifest tries them.
96 func manifestEndpoints(selected string) []string {
97 _ = selected
98 return []string{
99 r2Base + "/latest/latest.json",
100 releaseGatewayBase + "/stable/latest.json",
101 githubManifestFallback,
102 }
103 }
104
105 // updaterUserAgent identifies updater traffic. Go's default Go-http-client UA
106 // is exactly what edge bot protection scores worst (#6005); a descriptive UA
107 // lets the release edge allowlist updater requests and makes them attributable
108 // in server logs.
109 func updaterUserAgent(selected string) string {
110 return fmt.Sprintf("Reasonix-Updater/%s (%s/%s; build=%s; update=%s)", version, runtime.GOOS, runtime.GOARCH, channel, normalizeUpdateChannel(selected))
111 }
112
113 // downloadPage is the human-facing releases page shown when self-update is
114 // unavailable (macOS) or the manifest omits its own link.
115 func downloadPage(selected string) string {
116 _ = selected
117 u, _ := url.Parse(downloadPageURL)
118 query := u.Query()
119 query.Set("download", "desktop")
120 query.Del("channel")
121 u.RawQuery = query.Encode()
122 return u.String()
123 }
124
125 func manifestDownloadPage(selected, manifestPage string) string {
126 manifestPage = strings.TrimSpace(manifestPage)
127 if manifestPage == "" {
128 return downloadPage(selected)
129 }
130 u, err := url.Parse(manifestPage)
131 if err != nil ||
132 u.Scheme != "https" ||
133 u.Hostname() == "" ||
134 u.User != nil {
135 return downloadPage(selected)
136 }
137 host := strings.ToLower(u.Hostname())
138 if host != "reasonix.io" && !strings.HasSuffix(host, ".reasonix.io") {
139 return u.String()
140 }
141 query := u.Query()
142 query.Set("download", "desktop")
143 query.Del("channel")
144 u.RawQuery = query.Encode()
145 u.Fragment = "start"
146 return u.String()
147 }
148
149 // UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
150 type UpdateInfo struct {
151 Available bool `json:"available"`
152 Current string `json:"current"`
153 Latest string `json:"latest"`
154 Notes string `json:"notes"`
155 Channel string `json:"channel"`
156 CanSelfUpdate bool `json:"canSelfUpdate"` // win/linux true; macOS true only for signed/notarized builds
157 ManualOnly bool `json:"manualOnly,omitempty"`
158 ManualReason string `json:"manualReason,omitempty"`
159 InstallMode string `json:"installMode"` // portable | deb | manual
160 RequiresElevation bool `json:"requiresElevation,omitempty"` // deb/Polkit path
161 Downloaded bool `json:"downloaded"`
162 DownloadURL string `json:"downloadUrl"` // human-facing releases page (macOS path / fallback link)
163 AssetSize int64 `json:"assetSize"` // running platform's artifact size, for the progress bar
164 Err string `json:"err,omitempty"` // set when the check itself failed (both endpoints down)
165 }
166
167 // UpdateDownloadResult is returned after an artifact has been downloaded,
168 // verified, and stored in the local updater cache.
169 type UpdateDownloadResult struct {
170 RequestID string `json:"requestId"`
171 Version string `json:"version"`
172 Channel string `json:"channel"`
173 Path string `json:"path"`
174 Size int64 `json:"size"`
175 SHA256 string `json:"sha256"`
176 }
177
178 // updateProgress is the payload of the "updater:progress" bridge event emitted
179 // throughout DownloadUpdate / InstallUpdate.
180 type updateProgress struct {
181 RequestID string `json:"requestId"`
182 Version string `json:"version"`
183 Channel string `json:"channel"`
184 Phase string `json:"phase"` // downloading | verifying | downloaded | authorizing | recovering | installing | done | error
185 Received int64 `json:"received"`
186 Total int64 `json:"total"`
187 Err string `json:"err,omitempty"`
188 }
189
190 func httpClient() (*http.Client, error) { return newHTTPClient(false) }
191
192 // httpClientIPv4 pins the dialer to IPv4 — the download fallback when the default
193 // (often IPv6-first) route to Cloudflare keeps resetting mid-transfer.
194 func httpClientIPv4() (*http.Client, error) { return newHTTPClient(true) }
195
196 func newHTTPClient(forceIPv4 bool) (*http.Client, error) {
197 cfg, err := config.Load()
198 if err != nil {
199 return nil, err
200 }
201 c, err := netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{ForceIPv4: forceIPv4})
202 if err != nil {
203 return nil, err
204 }
205 c.CheckRedirect = validateUpdateRedirect
206 return c, nil
207 }
208
209 func validateUpdateRedirect(req *http.Request, via []*http.Request) error {
210 if len(via) >= 10 {
211 return errors.New("update: stopped after 10 redirects")
212 }
213 if req == nil || req.URL == nil {
214 return errors.New("update: redirect has no target URL")
215 }
216 if !strings.EqualFold(req.URL.Scheme, "https") {
217 return fmt.Errorf("update: refusing redirect to non-HTTPS URL %q", req.URL.String())
218 }
219 if req.URL.Hostname() == "" {
220 return fmt.Errorf("update: refusing redirect without a hostname %q", req.URL.String())
221 }
222 if req.URL.User != nil {
223 return fmt.Errorf("update: refusing redirect with userinfo %q", req.URL.String())
224 }
225 if req.URL.Port() != "" || !isTrustedUpdateRedirectHost(req.URL.Hostname()) {
226 return fmt.Errorf("update: refusing redirect to untrusted host %q", req.URL.Host)
227 }
228 return nil
229 }
230
231 func isTrustedUpdateRedirectHost(host string) bool {
232 host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
233 return host == "reasonix.io" ||
234 strings.HasSuffix(host, ".reasonix.io") ||
235 host == "github.com" ||
236 strings.HasSuffix(host, ".githubusercontent.com")
237 }
238
239 // canSelfUpdate reports whether in-place update is possible. Windows and Linux
240 // can replace the verified artifact directly; macOS requires an explicitly
241 // signed/notarized build flag so local or ad-hoc builds stay manual.
242 func canSelfUpdate() bool {
243 return runtime.GOOS != "darwin" || macSelfUpdateAllowed()
244 }
245
246 func manualUpdateReason() string {
247 if runtime.GOOS == "darwin" && !macSelfUpdateAllowed() {
248 return "macOS automatic updates require a Developer ID signed and notarized build"
249 }
250 return ""
251 }
252
253 // normalizeVersion canonicalizes a version to semver "vX.Y.Z". It reports ok=false
254 // for the un-injected "dev" build (and anything not valid semver), so a dev build
255 // never prompts to update.
256 func normalizeVersion(v string) (string, bool) {
257 v = strings.TrimSpace(v)
258 if v == "" || v == "dev" {
259 return "", false
260 }
261 if !strings.HasPrefix(v, "v") {
262 v = "v" + v
263 }
264 if !semver.IsValid(v) {
265 return "", false
266 }
267 return semver.Canonical(v), true
268 }
269
270 // validateManifestChannel rejects every prerelease. The selected value remains
271 // in the signature for compatibility with existing callers.
272 func validateManifestChannel(selected string, m *update.Manifest) error {
273 _ = selected
274 if !stableDesktopVersionRE.MatchString(m.Version) {
275 return fmt.Errorf("official manifest has invalid release version %q", m.Version)
276 }
277 return nil
278 }
279
280 func desktopReleaseTag(_ string, version string) string {
281 return "desktop-" + version
282 }
283
284 func desktopAssetBases(selected, version string, allowLegacyPreview bool) []string {
285 _ = selected
286 _ = allowLegacyPreview
287 tag := desktopReleaseTag(selected, version)
288 return []string{
289 fmt.Sprintf("%s/%s/", r2Base, tag),
290 fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", tag),
291 fmt.Sprintf("https://github.com/esengine/DeepSeek-Reasonix/releases/download/%s/", version),
292 }
293 }
294
295 func validateManifestAsset(selected, version, filename string, asset update.Asset, allowLegacyPreview bool) (string, error) {
296 base := ""
297 for _, candidate := range desktopAssetBases(selected, version, allowLegacyPreview) {
298 if asset.URL == candidate+filename {
299 base = candidate
300 break
301 }
302 }
303 if base == "" {
304 return "", fmt.Errorf("asset URL %q is not the official %s path for %s", asset.URL, normalizeUpdateChannel(selected), filename)
305 }
306 if asset.Sig != asset.URL+".minisig" {
307 return "", fmt.Errorf("asset signature URL %q does not match %q", asset.Sig, asset.URL+".minisig")
308 }
309 if asset.Size <= 0 || asset.Size > maxDesktopReleaseAssetSize {
310 return "", fmt.Errorf("asset %s has invalid size %d", filename, asset.Size)
311 }
312 if !sha256RE.MatchString(asset.SHA256) {
313 return "", fmt.Errorf("asset %s has invalid SHA-256 %q", filename, asset.SHA256)
314 }
315 if err := validateAssetInstallLayout(asset.InstallLayout); err != nil {
316 return "", err
317 }
318 return base, nil
319 }
320
321 // validateAssetInstallLayout accepts the pre-v1.20 empty layout (flat install)
322 // and the v1.20+ versioned-v1 layout. Unknown values must fail closed so a new
323 // client never partially installs an unrecognized package shape.
324 func validateAssetInstallLayout(layout string) error {
325 switch strings.TrimSpace(layout) {
326 case "", installlayout.InstallLayoutVersionedV1, update.ElectronInstallLayout:
327 return nil
328 default:
329 return fmt.Errorf("unsupported install_layout %q (keeping current version)", layout)
330 }
331 }
332
333 func validateDesktopManifest(selected string, m *update.Manifest) error {
334 selected = normalizeUpdateChannel(selected)
335 if err := validateManifestChannel(selected, m); err != nil {
336 return err
337 }
338 if m.DownloadPage != manifestDownloadPageURL {
339 return fmt.Errorf("%s manifest has invalid download page %q", selected, m.DownloadPage)
340 }
341 // Historical manifests either omitted website downloads or carried only the
342 // Universal DMG and Windows portable ZIP. New manifests add both native-arch
343 // DMGs. Seeing either new key switches validation to the complete new set so a
344 // partially published architecture matrix cannot reach the website.
345 legacyManifest := m.Downloads == nil
346 requiredAssets := append([]requiredDesktopAsset(nil), requiredDesktopUpdaterAssets...)
347 if !legacyManifest {
348 downloadAssets := legacyDesktopDownloadAssets
349 if _, arm := m.Downloads["Reasonix-darwin-arm64.dmg"]; arm {
350 downloadAssets = requiredDesktopDownloadAssets
351 } else if _, intel := m.Downloads["Reasonix-darwin-amd64.dmg"]; intel {
352 downloadAssets = requiredDesktopDownloadAssets
353 }
354 requiredAssets = append(requiredAssets, downloadAssets...)
355 }
356 base := ""
357 for _, required := range requiredAssets {
358 var assets map[string]update.Asset
359 switch required.group {
360 case "platforms":
361 assets = m.Platforms
362 case "native_packages":
363 assets = m.NativePackages
364 case "downloads":
365 assets = m.Downloads
366 default:
367 return fmt.Errorf("unsupported manifest asset group %q", required.group)
368 }
369 asset, ok := assets[required.key]
370 if !ok {
371 return fmt.Errorf("%s manifest has no %s asset for %s", selected, required.group, required.key)
372 }
373 assetBase, err := validateManifestAsset(selected, m.Version, required.filename, asset, legacyManifest)
374 if err != nil {
375 return fmt.Errorf("%s %s asset: %w", required.group, required.key, err)
376 }
377 if base != "" && assetBase != base {
378 return fmt.Errorf("%s manifest mixes asset bases %q and %q", selected, base, assetBase)
379 }
380 base = assetBase
381 }
382 return nil
383 }
384
385 // fetchManifest pulls latest.json from each endpoint in order until one both
386 // responds, decodes, and matches an official release. Every endpoint's
387 // failure is kept — a user staring at a gateway 403 (#6005) needs to see that
388 // the R2 pointer failed too, not just whichever endpoint happened to die last.
389 func fetchManifest(ctx context.Context, c, fallback *http.Client, selected string) (*update.Manifest, error) {
390 var errs []error
391 selected = normalizeUpdateChannel(selected)
392 for _, url := range manifestEndpoints(selected) {
393 endpointCtx, cancel := context.WithTimeout(ctx, manifestEndpointTimeout)
394 b, err := fetchManifestBytes(endpointCtx, c, fallback, selected, url)
395 cancel()
396 if err != nil {
397 errs = append(errs, err)
398 continue
399 }
400 var m update.Manifest
401 if err := json.Unmarshal(b, &m); err != nil {
402 errs = append(errs, fmt.Errorf("%s: %w", url, err))
403 continue
404 }
405 if err := validateDesktopManifest(selected, &m); err != nil {
406 errs = append(errs, fmt.Errorf("%s: %w", url, err))
407 continue
408 }
409 return &m, nil
410 }
411 return nil, fmt.Errorf("update: fetch manifest: %w", errors.Join(errs...))
412 }
413
414 // fetchManifestBytes gives the default and IPv4 transports separate halves of
415 // the endpoint budget. A stalled IPv6 dial must not consume the whole timeout
416 // before the IPv4 fallback gets a chance to run (#6713).
417 func fetchManifestBytes(ctx context.Context, c, fallback *http.Client, selected, url string) ([]byte, error) {
418 attemptTimeout := manifestEndpointTimeout / 2
419 attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
420 data, err := fetchBytesOnce(attemptCtx, c, selected, url, maxDesktopManifestSize)
421 cancel()
422 if err == nil || !isTransientFetchError(err) || fallback == nil {
423 return data, err
424 }
425 attemptCtx, cancel = context.WithTimeout(ctx, attemptTimeout)
426 fallbackData, fallbackErr := fetchBytesOnce(attemptCtx, fallback, selected, url, maxDesktopManifestSize)
427 cancel()
428 if fallbackErr == nil {
429 return fallbackData, nil
430 }
431 return nil, errors.Join(err, fallbackErr)
432 }
433
434 // evaluateForChannel compares the running version against the selected channel's
435 // manifest and builds the frontend-facing result. I/O is limited to install-profile
436 // detection and cache probes so tests can inject a fixed profile below.
437 func evaluateForChannel(current, selected string, m *update.Manifest) UpdateInfo {
438 return evaluateWithProfileForChannel(current, selected, m, profileForManifest(detectInstallProfile(), m))
439 }
440
441 func evaluateWithProfile(current string, m *update.Manifest, profile installProfile) UpdateInfo {
442 return evaluateWithProfileForChannel(current, runningUpdateChannel(), m, profile)
443 }
444
445 // evaluateWithProfileForChannel is the pure comparison core once the install
446 // profile and selected update channel are known.
447 func evaluateWithProfileForChannel(current, selected string, m *update.Manifest, profile installProfile) UpdateInfo {
448 selected = normalizeUpdateChannel(selected)
449 page := manifestDownloadPage(selected, m.DownloadPage)
450 info := UpdateInfo{
451 Current: current,
452 Latest: m.Version,
453 Notes: m.Notes,
454 Channel: selected,
455 CanSelfUpdate: profile.CanSelfUpdate,
456 ManualOnly: !profile.CanSelfUpdate,
457 ManualReason: profile.ManualReason,
458 InstallMode: profile.Mode,
459 RequiresElevation: profile.RequiresElev,
460 DownloadURL: page,
461 }
462 // Preserve the pre-existing macOS gate when profile detection would otherwise
463 // claim portable self-update on an unsigned build.
464 if runtime.GOOS == "darwin" && !canSelfUpdate() {
465 info.CanSelfUpdate = false
466 info.ManualOnly = true
467 info.RequiresElevation = false
468 info.InstallMode = installModeManual
469 if info.ManualReason == "" {
470 info.ManualReason = manualUpdateReason()
471 }
472 }
473 cur, okCur := normalizeVersion(current)
474 latest, okLatest := normalizeVersion(m.Version)
475 if !okLatest {
476 info.Err = "manifest has no valid version"
477 return info
478 }
479 // A dev/invalid running version never auto-prompts. Within a channel, only a
480 // newer semver is an update. Across channels, a different target latest is an
481 // explicit channel switch, so allow installing stable over a newer preview.
482 if okCur {
483 if selected != runningUpdateChannel() {
484 info.Available = latest != cur
485 } else if semver.Compare(latest, cur) > 0 {
486 info.Available = true
487 }
488 }
489 if a, kind, ok := selectUpdateAsset(m, profile); ok {
490 info.AssetSize = a.Size
491 info.Downloaded = cachedUpdateMatchesForChannel(selected, m.Version, a, kind)
492 } else if a, ok := m.Asset(); ok {
493 // Manual installs (or a missing native package) still surface the portable
494 // artifact size so the UI can show how large the download is on the page.
495 info.AssetSize = a.Size
496 }
497 return info
498 }
499
500 type cachedUpdate struct {
501 Version string `json:"version"`
502 Channel string `json:"channel"`
503 Platform string `json:"platform"`
504 Path string `json:"path"`
505 Size int64 `json:"size"`
506 SHA256 string `json:"sha256"`
507 DownloadedAt string `json:"downloadedAt"`
508 ArtifactKind string `json:"artifactKind,omitempty"` // tarball | deb
509 SignaturePath string `json:"signaturePath,omitempty"` // required for deb
510 }
511
512 var updateCacheBaseDir = defaultUpdateCacheBaseDir
513
514 func defaultUpdateCacheBaseDir() (string, error) {
515 if cd := config.CacheDir(); cd != "" {
516 return filepath.Join(cd, "updates"), nil
517 }
518 base, err := os.UserCacheDir()
519 if err != nil {
520 base = os.TempDir()
521 }
522 return filepath.Join(base, "Reasonix", "updates"), nil
523 }
524
525 func updateCacheDir() (string, error) {
526 dir, err := updateCacheBaseDir()
527 if err != nil {
528 return "", err
529 }
530 if err := os.MkdirAll(dir, 0o700); err != nil {
531 return "", err
532 }
533 return dir, nil
534 }
535
536 func updateMetadataPath() (string, error) {
537 dir, err := updateCacheDir()
538 if err != nil {
539 return "", err
540 }
541 return filepath.Join(dir, "downloaded.json"), nil
542 }
543
544 func assetFileName(asset update.Asset, version string) string {
545 if u, err := url.Parse(asset.URL); err == nil {
546 if base := filepath.Base(u.Path); base != "." && base != "/" {
547 return base
548 }
549 }
550 clean := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-").Replace(version)
551 return "Reasonix-" + clean + "-" + update.CurrentPlatform() + ".update"
552 }
553
554 func writeAtomic(path string, data []byte, mode os.FileMode) error {
555 tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
556 if err != nil {
557 return err
558 }
559 name := tmp.Name()
560 if _, err := tmp.Write(data); err != nil {
561 tmp.Close()
562 _ = os.Remove(name)
563 return err
564 }
565 if err := tmp.Sync(); err != nil {
566 tmp.Close()
567 _ = os.Remove(name)
568 return err
569 }
570 if err := tmp.Chmod(mode); err != nil {
571 tmp.Close()
572 _ = os.Remove(name)
573 return err
574 }
575 if err := tmp.Close(); err != nil {
576 _ = os.Remove(name)
577 return err
578 }
579 if err := os.Rename(name, path); err != nil {
580 _ = os.Remove(name)
581 return err
582 }
583 return nil
584 }
585
586 func saveCachedUpdate(version string, asset update.Asset, data []byte, kind string, signature []byte) (*cachedUpdate, error) {
587 return saveCachedUpdateForChannel(runningUpdateChannel(), version, asset, data, kind, signature)
588 }
589
590 func saveCachedUpdateForChannel(selected, version string, asset update.Asset, data []byte, kind string, signature []byte) (*cachedUpdate, error) {
591 selected = normalizeUpdateChannel(selected)
592 if err := checkSHA256(data, asset.SHA256); err != nil {
593 return nil, err
594 }
595 kind = artifactKindFromMeta(kind)
596 dir, err := updateCacheDir()
597 if err != nil {
598 return nil, err
599 }
600 path := filepath.Join(dir, assetFileName(asset, version))
601 if err := writeAtomic(path, data, 0o600); err != nil {
602 return nil, err
603 }
604 meta := &cachedUpdate{
605 Version: version,
606 Channel: selected,
607 Platform: update.CurrentPlatform(),
608 Path: path,
609 Size: int64(len(data)),
610 SHA256: asset.SHA256,
611 DownloadedAt: time.Now().UTC().Format(time.RFC3339),
612 ArtifactKind: kind,
613 }
614 if kind == artifactKindDeb {
615 if len(signature) == 0 {
616 return nil, fmt.Errorf("update: deb cache requires a signature")
617 }
618 sigPath := path + ".minisig"
619 if err := writeAtomic(sigPath, signature, 0o600); err != nil {
620 return nil, err
621 }
622 meta.SignaturePath = sigPath
623 }
624 raw, err := json.MarshalIndent(meta, "", " ")
625 if err != nil {
626 return nil, err
627 }
628 metadataPath, err := updateMetadataPath()
629 if err != nil {
630 return nil, err
631 }
632 if err := writeAtomic(metadataPath, append(raw, '\n'), 0o600); err != nil {
633 return nil, err
634 }
635 return meta, nil
636 }
637
638 func loadCachedUpdate() (*cachedUpdate, error) {
639 path, err := updateMetadataPath()
640 if err != nil {
641 return nil, err
642 }
643 raw, err := readFileUTF8(path)
644 if err != nil {
645 return nil, err
646 }
647 var meta cachedUpdate
648 if err := json.Unmarshal(raw, &meta); err != nil {
649 return nil, err
650 }
651 if meta.Version == "" || meta.Channel == "" || meta.Platform == "" || meta.Path == "" || meta.SHA256 == "" {
652 return nil, fmt.Errorf("update: cached metadata is incomplete")
653 }
654 return &meta, nil
655 }
656
657 func cachedUpdateMatches(version string, asset update.Asset, kind string) bool {
658 return cachedUpdateMatchesForChannel(runningUpdateChannel(), version, asset, kind)
659 }
660
661 func cachedUpdateMatchesForChannel(selected, version string, asset update.Asset, kind string) bool {
662 selected = normalizeUpdateChannel(selected)
663 meta, err := loadCachedUpdate()
664 if err != nil {
665 return false
666 }
667 kind = artifactKindFromMeta(kind)
668 metaKind := artifactKindFromMeta(meta.ArtifactKind)
669 // Legacy portable caches omit artifactKind and remain valid for tarball only.
670 // Deb installs never reuse a cache that lacks a matching signature file.
671 if kind == artifactKindDeb {
672 if metaKind != artifactKindDeb || meta.SignaturePath == "" {
673 return false
674 }
675 if _, err := os.Stat(meta.SignaturePath); err != nil {
676 return false
677 }
678 } else if metaKind != artifactKindTarball {
679 return false
680 }
681 return meta.Version == version &&
682 meta.Channel == selected &&
683 meta.Platform == update.CurrentPlatform() &&
684 strings.EqualFold(meta.SHA256, asset.SHA256) &&
685 meta.Size == asset.Size &&
686 fileSHA256Matches(meta.Path, meta.SHA256)
687 }
688
689 func fileSHA256Matches(path, want string) bool {
690 f, err := os.Open(path)
691 if err != nil {
692 return false
693 }
694 defer f.Close()
695 h := sha256.New()
696 if _, err := io.Copy(h, f); err != nil {
697 return false
698 }
699 return strings.EqualFold(hex.EncodeToString(h.Sum(nil)), want)
700 }
701
702 func readVerifiedCachedUpdate() (*cachedUpdate, []byte, error) {
703 return readVerifiedCachedUpdateForChannel(runningUpdateChannel())
704 }
705
706 func readVerifiedCachedUpdateForChannel(selected string) (*cachedUpdate, []byte, error) {
707 selected = normalizeUpdateChannel(selected)
708 meta, err := loadCachedUpdate()
709 if err != nil {
710 return nil, nil, err
711 }
712 if meta.Channel != selected {
713 return nil, nil, fmt.Errorf("update: cached update is for %s channel, selected channel is %s", meta.Channel, selected)
714 }
715 if meta.Platform != update.CurrentPlatform() {
716 return nil, nil, fmt.Errorf("update: cached update is for %s, current platform is %s", meta.Platform, update.CurrentPlatform())
717 }
718 data, err := os.ReadFile(meta.Path)
719 if err != nil {
720 return nil, nil, err
721 }
722 if err := checkSHA256(data, meta.SHA256); err != nil {
723 return nil, nil, err
724 }
725 meta.ArtifactKind = artifactKindFromMeta(meta.ArtifactKind)
726 if meta.ArtifactKind == artifactKindDeb {
727 if meta.SignaturePath == "" {
728 return nil, nil, fmt.Errorf("update: cached deb is missing its signature")
729 }
730 if _, err := os.Stat(meta.SignaturePath); err != nil {
731 return nil, nil, fmt.Errorf("update: cached deb signature is missing")
732 }
733 }
734 return meta, data, nil
735 }
736
737 // downloadAttempts caps how many times a transient transport failure (connection
738 // reset, read timeout, gateway 5xx) is retried before the update gives up. CN IPv6
739 // routes to Cloudflare reset mid-transfer often enough that a retry or two usually
740 // completes the download instead of surfacing a "forcibly closed" error.
741 const downloadAttempts = 3
742
743 // retryBackoff is the pause before the Nth retry; a package var so tests shrink it.
744 var retryBackoff = func(attempt int) time.Duration { return time.Duration(attempt) * 500 * time.Millisecond }
745
746 // retryTransient runs attempt 1..downloadAttempts of fetch, pausing between tries,
747 // until one succeeds. fetch receives the 1-based attempt number so a caller can
748 // switch transports on a retry. It stops early when ctx is cancelled (window closed
749 // / user cancelled). Only the transport is retried; the signature and sha256 checks
750 // run downstream in downloadVerify and are not retried.
751 func retryTransient(ctx context.Context, fetch func(attempt int) error) error {
752 var err error
753 for attempt := 1; attempt <= downloadAttempts; attempt++ {
754 if err = fetch(attempt); err == nil {
755 return nil
756 }
757 if !isTransientFetchError(err) {
758 break
759 }
760 if ctx.Err() != nil || attempt == downloadAttempts {
761 break
762 }
763 select {
764 case <-ctx.Done():
765 return ctx.Err()
766 case <-time.After(retryBackoff(attempt)):
767 }
768 }
769 return err
770 }
771
772 type httpStatusError struct {
773 url string
774 status string
775 code int
776 }
777
778 func (e *httpStatusError) Error() string { return fmt.Sprintf("GET %s: %s", e.url, e.status) }
779
780 func isTransientFetchError(err error) bool {
781 if errors.Is(err, errUpdateResponseTooLarge) {
782 return false
783 }
784 var statusErr *httpStatusError
785 if !errors.As(err, &statusErr) {
786 return true
787 }
788 return statusErr.code == http.StatusRequestTimeout || statusErr.code == http.StatusTooManyRequests || statusErr.code >= 500
789 }
790
791 // fetchBytes GETs a URL fully into memory, retrying transient transport failures.
792 func fetchBytes(ctx context.Context, c *http.Client, url string) ([]byte, error) {
793 return fetchBytesFallbackForChannel(ctx, c, nil, runningUpdateChannel(), url)
794 }
795
796 // fetchBytesFallback retries transport failures with the IPv4-pinned client.
797 // This covers small manifest/signature requests as well as the artifact body;
798 // previously only the large artifact download escaped a broken IPv6 route.
799 func fetchBytesFallback(ctx context.Context, c, fallback *http.Client, url string) ([]byte, error) {
800 return fetchBytesFallbackForChannel(ctx, c, fallback, runningUpdateChannel(), url)
801 }
802
803 func fetchBytesFallbackForChannel(ctx context.Context, c, fallback *http.Client, selected, url string) ([]byte, error) {
804 return fetchBytesFallbackForChannelSized(ctx, c, fallback, selected, url, maxDesktopManifestSize)
805 }
806
807 func fetchBytesFallbackForChannelSized(
808 ctx context.Context,
809 c, fallback *http.Client,
810 selected, url string,
811 maxBytes int64,
812 ) ([]byte, error) {
813 selected = normalizeUpdateChannel(selected)
814 var data []byte
815 err := retryTransient(ctx, func(attempt int) error {
816 client := c
817 if attempt > 1 && fallback != nil {
818 client = fallback
819 }
820 var e error
821 attemptCtx, cancel := context.WithTimeout(ctx, fetchAttemptTimeout)
822 data, e = fetchBytesOnce(attemptCtx, client, selected, url, maxBytes)
823 cancel()
824 return e
825 })
826 return data, err
827 }
828
829 var errUpdateResponseTooLarge = errors.New("update: response exceeds allowed size")
830
831 func fetchBytesOnce(ctx context.Context, c *http.Client, selected, url string, maxBytes int64) ([]byte, error) {
832 if maxBytes <= 0 {
833 return nil, fmt.Errorf("update: invalid response size limit %d", maxBytes)
834 }
835 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
836 if err != nil {
837 return nil, err
838 }
839 req.Header.Set("User-Agent", updaterUserAgent(selected))
840 resp, err := c.Do(req)
841 if err != nil {
842 return nil, err
843 }
844 defer resp.Body.Close()
845 if resp.StatusCode != http.StatusOK {
846 return nil, &httpStatusError{url: url, status: resp.Status, code: resp.StatusCode}
847 }
848 if resp.ContentLength > maxBytes {
849 return nil, fmt.Errorf("%w: GET %s declared %d bytes, maximum is %d", errUpdateResponseTooLarge, url, resp.ContentLength, maxBytes)
850 }
851 data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
852 if err != nil {
853 return nil, err
854 }
855 if int64(len(data)) > maxBytes {
856 return nil, fmt.Errorf("%w: GET %s exceeded %d bytes", errUpdateResponseTooLarge, url, maxBytes)
857 }
858 return data, nil
859 }
860
861 // download fetches url into memory, invoking onProgress as bytes arrive. A transient
862 // transport failure is retried; the retry resumes from the bytes already received
863 // via a Range request instead of restarting, and switches to the IPv4 fallback
864 // client (when provided) since a reset usually means the IPv6 route is the problem.
865 // total is the expected size for the progress denominator (refined from the response).
866 func download(ctx context.Context, c, fallback *http.Client, url string, total int64, onProgress func(received, total int64)) ([]byte, error) {
867 return downloadForChannel(ctx, c, fallback, runningUpdateChannel(), url, total, onProgress)
868 }
869
870 func downloadForChannel(ctx context.Context, c, fallback *http.Client, selected, url string, total int64, onProgress func(received, total int64)) ([]byte, error) {
871 selected = normalizeUpdateChannel(selected)
872 if total < 0 || total > maxDesktopReleaseAssetSize {
873 return nil, fmt.Errorf("update: invalid expected asset size %d", total)
874 }
875 expectedSize := total
876 var buf bytes.Buffer
877 err := retryTransient(ctx, func(attempt int) error {
878 client := c
879 if attempt > 1 && fallback != nil {
880 client = fallback
881 }
882 return downloadInto(ctx, client, selected, url, expectedSize, &buf, &total, onProgress)
883 })
884 if err != nil {
885 return nil, err
886 }
887 if expectedSize > 0 && int64(buf.Len()) != expectedSize {
888 return nil, fmt.Errorf("update: downloaded size mismatch: got %d want %d", buf.Len(), expectedSize)
889 }
890 return buf.Bytes(), nil
891 }
892
893 // downloadInto appends url's body to buf, resuming from buf's current length via a
894 // Range request so a retry continues the partial download. A 206 carries the
895 // remaining bytes; a 200 means the server ignored Range, so buf is reset and the
896 // whole file re-downloaded. total is refined from the response for the progress
897 // denominator (Content-Length on 200, the size field of Content-Range on 206).
898 func downloadInto(ctx context.Context, c *http.Client, selected, url string, expectedSize int64, buf *bytes.Buffer, total *int64, onProgress func(received, total int64)) error {
899 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
900 if err != nil {
901 return err
902 }
903 req.Header.Set("User-Agent", updaterUserAgent(selected))
904 if buf.Len() > 0 {
905 req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
906 }
907 resp, err := c.Do(req)
908 if err != nil {
909 return err
910 }
911 defer resp.Body.Close()
912 switch resp.StatusCode {
913 case http.StatusOK:
914 buf.Reset()
915 if resp.ContentLength > 0 {
916 if resp.ContentLength > maxDesktopReleaseAssetSize {
917 return fmt.Errorf("update: response size %d exceeds maximum %d", resp.ContentLength, maxDesktopReleaseAssetSize)
918 }
919 *total = resp.ContentLength
920 }
921 case http.StatusPartialContent:
922 if t := totalFromContentRange(resp.Header.Get("Content-Range")); t > 0 {
923 if t > maxDesktopReleaseAssetSize {
924 return fmt.Errorf("update: response size %d exceeds maximum %d", t, maxDesktopReleaseAssetSize)
925 }
926 *total = t
927 }
928 default:
929 return fmt.Errorf("GET %s: %s", url, resp.Status)
930 }
931 have := int64(buf.Len())
932 if expectedSize > 0 && have > expectedSize {
933 return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", have, expectedSize)
934 }
935 limit := maxDesktopReleaseAssetSize - have + 1
936 if expectedSize > 0 {
937 limit = expectedSize - have + 1
938 }
939 body := io.LimitReader(resp.Body, limit)
940 pr := &progressReader{r: body, received: have, lastEmit: have, total: *total, onProgress: onProgress}
941 _, err = io.Copy(buf, pr)
942 if err == nil && expectedSize > 0 && int64(buf.Len()) > expectedSize {
943 return fmt.Errorf("update: downloaded size exceeds manifest: got at least %d want %d", buf.Len(), expectedSize)
944 }
945 if err == nil && int64(buf.Len()) > maxDesktopReleaseAssetSize {
946 return fmt.Errorf("update: downloaded size exceeds maximum %d", maxDesktopReleaseAssetSize)
947 }
948 return err
949 }
950
951 // totalFromContentRange parses the total size out of a "bytes 200-999/1000" header,
952 // returning 0 when it's absent or "*" (unknown).
953 func totalFromContentRange(v string) int64 {
954 i := strings.LastIndex(v, "/")
955 if i < 0 {
956 return 0
957 }
958 n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
959 if err != nil {
960 return 0
961 }
962 return n
963 }
964
965 // progressReader reports cumulative bytes read, throttled so the event channel
966 // isn't flooded.
967 type progressReader struct {
968 r io.Reader
969 received int64
970 total int64
971 lastEmit int64
972 onProgress func(received, total int64)
973 }
974
975 func (p *progressReader) Read(b []byte) (int, error) {
976 n, err := p.r.Read(b)
977 p.received += int64(n)
978 // Emit roughly every 256 KiB, and always on the final read (io.EOF).
979 if p.onProgress != nil && (p.received-p.lastEmit >= 256<<10 || err == io.EOF) {
980 p.lastEmit = p.received
981 p.onProgress(p.received, p.total)
982 }
983 return n, err
984 }
985
986 // checkSHA256 verifies data's digest matches the lowercase-hex want.
987 func checkSHA256(data []byte, want string) error {
988 sum := sha256.Sum256(data)
989 if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, want) {
990 return fmt.Errorf("update: sha256 mismatch: got %s want %s", got, want)
991 }
992 return nil
993 }
994
995 // extractBinary pulls a single named regular file out of a .tar.gz blob.
996 func extractBinary(targz []byte, name string) ([]byte, error) {
997 gz, err := gzip.NewReader(bytes.NewReader(targz))
998 if err != nil {
999 return nil, err
1000 }
1001 defer gz.Close()
1002 tr := tar.NewReader(gz)
1003 for {
1004 h, err := tr.Next()
1005 if err == io.EOF {
1006 break
1007 }
1008 if err != nil {
1009 return nil, err
1010 }
1011 if h.Typeflag == tar.TypeReg && (h.Name == name || strings.HasSuffix(h.Name, "/"+name)) {
1012 return io.ReadAll(tr)
1013 }
1014 }
1015 return nil, fmt.Errorf("update: %q not found in archive", name)
1016 }
1017
1018 func extractLinuxReleaseUnit(targz []byte) (map[string][]byte, error) {
1019 const (
1020 desktop = "reasonix-desktop"
1021 guard = "reasonix-guard"
1022 cli = "reasonix"
1023 )
1024 want := map[string]struct{}{desktop: {}, guard: {}, cli: {}}
1025 found := make(map[string][]byte, len(want))
1026 gz, err := gzip.NewReader(bytes.NewReader(targz))
1027 if err != nil {
1028 return nil, err
1029 }
1030 defer gz.Close()
1031 tr := tar.NewReader(gz)
1032 for {
1033 h, err := tr.Next()
1034 if errors.Is(err, io.EOF) {
1035 break
1036 }
1037 if err != nil {
1038 return nil, err
1039 }
1040 name := path.Base(strings.TrimSpace(h.Name))
1041 if _, ok := want[name]; !ok {
1042 continue
1043 }
1044 if h.Typeflag != tar.TypeReg || h.Size < 0 {
1045 return nil, fmt.Errorf("update: release member %q is not a regular file", name)
1046 }
1047 if _, duplicate := found[name]; duplicate {
1048 return nil, fmt.Errorf("update: release member %q appears more than once", name)
1049 }
1050 body, err := io.ReadAll(tr)
1051 if err != nil {
1052 return nil, err
1053 }
1054 found[name] = body
1055 }
1056 if len(found) != len(want) {
1057 for name := range want {
1058 if _, ok := found[name]; !ok {
1059 return nil, fmt.Errorf("update: release member %q not found in archive", name)
1060 }
1061 }
1062 }
1063 return found, nil
1064 }
1065
1066 // applyLinux replaces the running binary with the one inside the downloaded
1067 // tar.gz; the caller relaunches afterwards.
1068 func applyLinux(targz []byte, prepared *repair.UpdateTransaction) error {
1069 release, err := extractLinuxReleaseUnit(targz)
1070 if err != nil {
1071 return err
1072 }
1073 bin := release["reasonix-desktop"]
1074 guard := release["reasonix-guard"]
1075 cli := release["reasonix"]
1076 exe := currentExecutablePathForLinux()
1077 if exe == "" {
1078 return fmt.Errorf("update: current executable path is unavailable")
1079 }
1080 releasePaths := releaseUnitPathsFor(filepath.Dir(exe), "linux")
1081 if prepared == nil {
1082 return fmt.Errorf("update: prepared transaction is unavailable")
1083 }
1084 claimed, releaseClaim, err := repair.ClaimPendingFileUpdateExact(
1085 prepared.ToVersion,
1086 prepared.CreatedAt,
1087 repair.UpdateTransactionID(prepared),
1088 exe,
1089 releasePaths,
1090 2*time.Minute,
1091 )
1092 if err != nil {
1093 return fmt.Errorf("update: claim prepared transaction: %w", err)
1094 }
1095 defer releaseClaim()
1096 if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
1097 return fmt.Errorf("update: record recovery intent: %w", err)
1098 }
1099 receipts, err := applyLinuxReleaseUnit(claimed, exe, bin, guard, cli)
1100 if err != nil {
1101 return err
1102 }
1103 if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
1104 return fmt.Errorf("update: record installed release unit: %w", err)
1105 }
1106 // pending-update.json remains immutable; the transaction-unique sidecar now
1107 // binds every installed member. A crash before marker cleanup is safe:
1108 // startup correlates the exact transaction and rolls the release unit back.
1109 _ = repair.ClearUpdateApplyFailureExact(claimed)
1110 return nil
1111 }
1112
1113 // applyLinuxVersioned publishes a verified compatibility tarball into a new
1114 // version directory and swaps current.json last. The tar still contains the
1115 // one-shot reasonix-guard member for v1.18-v1.19 updaters, but v1.20+ ignores
1116 // that member and never persists it again.
1117 func applyLinuxVersioned(targz []byte, targetVersion string) error {
1118 return activateLinuxShellRelease(targz, targetVersion, currentInstallDirForLinuxUpdate())
1119 }
1120
1121 var currentExecutablePathForLinux = currentExecutablePath
1122 var currentInstallDirForLinuxUpdate = currentInstallDir
1123
1124 var applyLinuxReleaseUnit = func(
1125 claimed *repair.UpdateTransaction,
1126 exe string,
1127 bin, guard, cli []byte,
1128 ) ([]repair.FileUpdateInstallReceipt, error) {
1129 receipts := make([]repair.FileUpdateInstallReceipt, 0, 3)
1130 receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, filepath.Join(filepath.Dir(exe), "reasonix"), cli, 0o700)
1131 if err != nil {
1132 return receipts, fmt.Errorf("update CLI sidecar: %w", err)
1133 }
1134 receipts = append(receipts, receipt)
1135 receipt, err = repair.PublishClaimedFileUpdateMemberExact(claimed, filepath.Join(filepath.Dir(exe), "reasonix-guard"), guard, 0o700)
1136 if err != nil {
1137 return receipts, fmt.Errorf("update Guard: %w", err)
1138 }
1139 receipts = append(receipts, receipt)
1140 receipt, err = repair.PublishClaimedFileUpdateMemberExact(claimed, exe, bin, 0o700)
1141 if err != nil {
1142 return receipts, fmt.Errorf("update desktop: %w", err)
1143 }
1144 receipts = append(receipts, receipt)
1145 return receipts, nil
1146 }
1147
1148 func applyWindowsFile(path, expectedSHA256, targetVersion string, prepared *repair.UpdateTransaction) error {
1149 installDir := currentInstallDir()
1150 if installlayout.HasCurrent(installDir) {
1151 return startWindowsVersionedUpdateHandoff(
1152 path,
1153 expectedSHA256,
1154 installDir,
1155 currentLauncherPath(),
1156 targetVersion,
1157 )
1158 }
1159 if prepared == nil {
1160 return fmt.Errorf("update: prepared transaction is unavailable")
1161 }
1162 return startWindowsUpdateHandoff(
1163 path,
1164 expectedSHA256,
1165 installDir,
1166 currentLauncherPath(),
1167 prepared,
1168 )
1169 }
1170
1171 func currentExecutablePath() string {
1172 exe, err := os.Executable()
1173 if err != nil {
1174 return ""
1175 }
1176 if resolved, err := filepath.EvalSymlinks(exe); err == nil {
1177 exe = resolved
1178 }
1179 return exe
1180 }
1181
1182 // currentInstallDir is the InstallRoot for updates. For the versioned layout it
1183 // is the directory that owns current.json (not versions/<ver>/). For flat
1184 // installs it is the directory of the running executable.
1185 func currentInstallDir() string {
1186 exe := currentExecutablePath()
1187 if exe == "" {
1188 return ""
1189 }
1190 if root, err := installlayout.ResolveInstallRoot(exe); err == nil && root != "" {
1191 return root
1192 }
1193 return filepath.Dir(exe)
1194 }
1195
1196 // archiveSupersededPendingUpdateAfterReady retires a transaction only after the
1197 // current desktop has shown a usable UI. App-bundle recovery handles interrupted
1198 // macOS generations; the versioned-layout branch handles older flat Windows and
1199 // Linux transactions.
1200 func archiveSupersededPendingUpdateAfterReady() (bool, error) {
1201 exe := currentExecutablePath()
1202 if exe == "" || version == "" || version == "dev" {
1203 return false, nil
1204 }
1205 if archived, err := repair.ArchiveSupersededPendingAppBundleUpdate(version); err != nil || archived {
1206 return archived, err
1207 }
1208 if runtime.GOOS == "darwin" {
1209 return false, nil
1210 }
1211 root, err := installlayout.ResolveInstallRoot(exe)
1212 if err != nil {
1213 return false, err
1214 }
1215 ptr, err := installlayout.ReadCurrent(root)
1216 if err != nil {
1217 // Package-managed and legacy flat installs have no versioned pointer and
1218 // therefore are not authorized to retire a transaction.
1219 if os.IsNotExist(err) {
1220 return false, nil
1221 }
1222 return false, err
1223 }
1224 running := strings.TrimSpace(version)
1225 if !strings.HasPrefix(running, "v") {
1226 running = "v" + running
1227 }
1228 if ptr.ActiveVersion != running {
1229 return false, fmt.Errorf("active install version %s does not match running version %s", ptr.ActiveVersion, running)
1230 }
1231 return repair.ArchiveSupersededPendingFileUpdate(running, root)
1232 }
1233
1234 func capturePendingUpdateHealthIdentity(app *App) {
1235 if app == nil {
1236 return
1237 }
1238 tx, err := readPendingUpdateForHealth()
1239 if err != nil || tx == nil || !repair.UpdateVersionsEqual(tx.ToVersion, version) {
1240 return
1241 }
1242 app.healthyUpdateCreatedAt = tx.CreatedAt
1243 app.healthyUpdateTransactionID = repair.UpdateTransactionID(tx)
1244 }
1245
1246 // refreshPendingUpdateHealthIdentity re-reads the current probationary
1247 // transaction so a user-initiated update can commit health even when the
1248 // process started without a matching identity (for example a historical
1249 // version-prefix mismatch).
1250 func refreshPendingUpdateHealthIdentity(app *App) {
1251 capturePendingUpdateHealthIdentity(app)
1252 }
1253
1254 // updateSiblingArtifacts lists the packaged binaries an update replaces beside
1255 // the main executable, so PrepareFileUpdate can snapshot the complete release
1256 // unit. Paths that do not exist on disk are skipped by the backup.
1257 func updateSiblingArtifacts() []string {
1258 dir := currentInstallDir()
1259 if dir == "" {
1260 return nil
1261 }
1262 paths := releaseUnitPathsFor(dir, runtime.GOOS)
1263 if len(paths) <= 1 {
1264 return nil
1265 }
1266 return paths[1:]
1267 }
1268
1269 func releaseUnitPathsFor(dir, goos string) []string {
1270 if dir == "" {
1271 return nil
1272 }
1273 // Versioned-v1 layout: primary is the active desktop under versions/.
1274 if goos == "windows" && installlayout.HasCurrent(dir) {
1275 paths := make([]string, 0, 6)
1276 if desktop, err := installlayout.ActiveDesktopPath(dir); err == nil {
1277 paths = append(paths, desktop)
1278 } else {
1279 paths = append(paths, filepath.Join(dir, "reasonix-desktop.exe"))
1280 }
1281 if helper, err := installlayout.ActiveUpdateHelperPath(dir); err == nil {
1282 paths = append(paths, helper)
1283 }
1284 if cli, err := installlayout.ActiveCLIPath(dir); err == nil {
1285 paths = append(paths, cli)
1286 }
1287 for _, name := range []string{"reasonix-launcher.exe", "reasonix-cli.exe", "Reasonix.exe"} {
1288 paths = append(paths, filepath.Join(dir, name))
1289 }
1290 return paths
1291 }
1292 names := updateSiblingNames(goos)
1293 paths := make([]string, 0, len(names)+1)
1294 switch goos {
1295 case "linux":
1296 paths = append(paths, filepath.Join(dir, "reasonix-desktop"))
1297 case "windows":
1298 paths = append(paths, filepath.Join(dir, "reasonix-desktop.exe"))
1299 }
1300 if len(names) == 0 {
1301 return paths
1302 }
1303 for _, name := range names {
1304 paths = append(paths, filepath.Join(dir, name))
1305 }
1306 return paths
1307 }
1308
1309 func updateSiblingNames(goos string) []string {
1310 switch goos {
1311 case "windows":
1312 // Legacy flat release unit. reasonix-guard.exe may still exist on disk
1313 // during migration from 1.18–1.19.1; the new layout omits it.
1314 return []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "Reasonix.exe"}
1315 case "linux":
1316 return []string{"reasonix-guard", "reasonix"}
1317 default:
1318 return nil
1319 }
1320 }
1321
1322 func currentLauncherPath() string {
1323 return launcherPathForExecutable(currentExecutablePath())
1324 }
1325
1326 func launcherPathForExecutable(exe string) string {
1327 if exe == "" {
1328 return ""
1329 }
1330 root := filepath.Dir(exe)
1331 if resolved, err := installlayout.ResolveInstallRoot(exe); err == nil && resolved != "" {
1332 root = resolved
1333 }
1334 if path, err := installlayout.StableRelaunchPath(root); err == nil {
1335 return path
1336 }
1337 if installlayout.IsSupersededVersionedDesktop(root, exe) {
1338 return filepath.Join(root, installlayout.LauncherBinaryName())
1339 }
1340 return exe
1341 }
1342
1342 lines GO