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