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