Files
2026-07-13 13:00:08 +08:00

637 lines
20 KiB
Go

package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/minio/selfupdate"
"golang.org/x/mod/semver"
"reasonix/desktop/internal/update"
"reasonix/internal/config"
"reasonix/internal/netclient"
)
// updater.go is the transport-free core of the desktop auto-updater: manifest
// fetch, version comparison, signed download, and per-platform apply/relaunch. It
// has no Wails dependency so the logic is unit-tested directly; updater_app.go is
// the thin Wails binding that wires these into App methods and progress events.
// Manifest endpoints — R2 CDN first (fast, especially in CN), then the crash
// worker release gateway, then GitHub as the stable channel's last resort. The
// build channel picks the rolling pointer so a canary build polls the canary
// line and a stable build polls latest; the two never cross. The gateway still
// avoids GitHub's repository-wide /releases/latest shortcut so the app is not
// coupled to GitHub's homepage badge semantics.
const (
r2Base = "https://dl.reasonix.io"
releaseGatewayBase = "https://crash.reasonix.io/v1/desktop/releases"
downloadPageURL = "https://reasonix.io/#start"
httpTimeout = 15 * time.Second
)
// githubManifestFallback is the stable channel's last-resort manifest source.
// dl.reasonix.io and crash.reasonix.io share one Cloudflare zone, so bot
// protection that 403s a user's egress IP takes out both first-party endpoints
// at once (#6005); GitHub is separate infrastructure. Stable desktop releases
// own the repo-wide latest badge and publish latest.json directly, while
// release.yml also keeps a desktop-manifest mirror attached to stable CLI
// releases for older publishing windows. Canary has no GitHub release, so its
// chain stays two-deep.
const githubManifestFallback = "https://github.com/esengine/DeepSeek-Reasonix/releases/latest/download/latest.json"
// manifestEndpoints returns the manifest URLs for the running build's channel,
// in the order fetchManifest tries them.
func manifestEndpoints() []string {
if channel == "canary" {
return []string{
r2Base + "/canary/latest.json",
releaseGatewayBase + "/canary/latest.json",
}
}
return []string{
r2Base + "/latest/latest.json",
releaseGatewayBase + "/stable/latest.json",
githubManifestFallback,
}
}
// updaterUserAgent identifies updater traffic. Go's default Go-http-client UA
// is exactly what edge bot protection scores worst (#6005); a descriptive UA
// lets the release edge allowlist updater requests and makes them attributable
// in server logs.
func updaterUserAgent() string {
return fmt.Sprintf("Reasonix-Updater/%s (%s/%s; %s)", version, runtime.GOOS, runtime.GOARCH, channel)
}
// downloadPage is the human-facing releases page shown when self-update is
// unavailable (macOS) or the manifest omits its own link.
func downloadPage() string {
return downloadPageURL
}
// UpdateInfo is the CheckUpdate result that drives the frontend's update banner.
type UpdateInfo struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest"`
Notes string `json:"notes"`
Channel string `json:"channel"`
CanSelfUpdate bool `json:"canSelfUpdate"` // win/linux true; macOS true only for signed/notarized builds
ManualOnly bool `json:"manualOnly,omitempty"`
ManualReason string `json:"manualReason,omitempty"`
Downloaded bool `json:"downloaded"`
DownloadURL string `json:"downloadUrl"` // human-facing releases page (macOS path / fallback link)
AssetSize int64 `json:"assetSize"` // running platform's artifact size, for the progress bar
Err string `json:"err,omitempty"` // set when the check itself failed (both endpoints down)
}
// UpdateDownloadResult is returned after an artifact has been downloaded,
// verified, and stored in the local updater cache.
type UpdateDownloadResult struct {
Version string `json:"version"`
Channel string `json:"channel"`
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
// updateProgress is the payload of the "updater:progress" Wails event emitted
// throughout DownloadUpdate / InstallUpdate.
type updateProgress struct {
Phase string `json:"phase"` // downloading | verifying | downloaded | installing | done | error
Received int64 `json:"received"`
Total int64 `json:"total"`
Err string `json:"err,omitempty"`
}
func httpClient() (*http.Client, error) { return newHTTPClient(false) }
// httpClientIPv4 pins the dialer to IPv4 — the download fallback when the default
// (often IPv6-first) route to Cloudflare keeps resetting mid-transfer.
func httpClientIPv4() (*http.Client, error) { return newHTTPClient(true) }
func newHTTPClient(forceIPv4 bool) (*http.Client, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
return netclient.NewHTTPClient(cfg.NetworkProxySpec(), netclient.TransportOptions{ForceIPv4: forceIPv4})
}
// canSelfUpdate reports whether in-place update is possible. Windows and Linux
// can replace the verified artifact directly; macOS requires an explicitly
// signed/notarized build flag so local or ad-hoc builds stay manual.
func canSelfUpdate() bool {
return runtime.GOOS != "darwin" || macSelfUpdateAllowed()
}
func manualUpdateReason() string {
if runtime.GOOS == "darwin" && !macSelfUpdateAllowed() {
return "macOS automatic updates require a Developer ID signed and notarized build"
}
return ""
}
// normalizeVersion canonicalizes a version to semver "vX.Y.Z". It reports ok=false
// for the un-injected "dev" build (and anything not valid semver), so a dev build
// never prompts to update.
func normalizeVersion(v string) (string, bool) {
v = strings.TrimSpace(v)
if v == "" || v == "dev" {
return "", false
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
if !semver.IsValid(v) {
return "", false
}
return semver.Canonical(v), true
}
// fetchManifest pulls latest.json from each endpoint in order until one both
// responds and decodes. Every endpoint's failure is kept — a user staring at a
// gateway 403 (#6005) needs to see that the R2 pointer failed too, not just
// whichever endpoint happened to die last.
func fetchManifest(ctx context.Context, c *http.Client) (*update.Manifest, error) {
var errs []error
for _, url := range manifestEndpoints() {
b, err := fetchBytes(ctx, c, url)
if err != nil {
errs = append(errs, err)
continue
}
var m update.Manifest
if err := json.Unmarshal(b, &m); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", url, err))
continue
}
return &m, nil
}
return nil, fmt.Errorf("update: fetch manifest: %w", errors.Join(errs...))
}
// evaluate compares the running version against the manifest and builds the
// frontend-facing result. Pure (no I/O) so the comparison is unit-tested.
func evaluate(current string, m *update.Manifest) UpdateInfo {
page := m.DownloadPage
if page == "" {
page = downloadPage()
}
info := UpdateInfo{
Current: current,
Latest: m.Version,
Notes: m.Notes,
Channel: channel,
CanSelfUpdate: canSelfUpdate(),
ManualOnly: !canSelfUpdate(),
ManualReason: manualUpdateReason(),
DownloadURL: page,
}
cur, okCur := normalizeVersion(current)
latest, okLatest := normalizeVersion(m.Version)
if !okLatest {
info.Err = "manifest has no valid version"
return info
}
// A dev/invalid running version never auto-prompts.
if okCur && semver.Compare(latest, cur) > 0 {
info.Available = true
}
if a, ok := m.Asset(); ok {
info.AssetSize = a.Size
info.Downloaded = cachedUpdateMatches(m.Version, a)
}
return info
}
type cachedUpdate struct {
Version string `json:"version"`
Channel string `json:"channel"`
Platform string `json:"platform"`
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
DownloadedAt string `json:"downloadedAt"`
}
var updateCacheBaseDir = defaultUpdateCacheBaseDir
func defaultUpdateCacheBaseDir() (string, error) {
if cd := config.CacheDir(); cd != "" {
return filepath.Join(cd, "updates"), nil
}
base, err := os.UserCacheDir()
if err != nil {
base = os.TempDir()
}
return filepath.Join(base, "Reasonix", "updates"), nil
}
func updateCacheDir() (string, error) {
dir, err := updateCacheBaseDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
return dir, nil
}
func updateMetadataPath() (string, error) {
dir, err := updateCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "downloaded.json"), nil
}
func assetFileName(asset update.Asset, version string) string {
if u, err := url.Parse(asset.URL); err == nil {
if base := filepath.Base(u.Path); base != "." && base != "/" {
return base
}
}
clean := strings.NewReplacer("/", "-", "\\", "-", ":", "-", " ", "-").Replace(version)
return "Reasonix-" + clean + "-" + update.CurrentPlatform() + ".update"
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*")
if err != nil {
return err
}
name := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
func saveCachedUpdate(version string, asset update.Asset, data []byte) (*cachedUpdate, error) {
if err := checkSHA256(data, asset.SHA256); err != nil {
return nil, err
}
dir, err := updateCacheDir()
if err != nil {
return nil, err
}
path := filepath.Join(dir, assetFileName(asset, version))
if err := writeAtomic(path, data, 0o600); err != nil {
return nil, err
}
meta := &cachedUpdate{
Version: version,
Channel: channel,
Platform: update.CurrentPlatform(),
Path: path,
Size: int64(len(data)),
SHA256: asset.SHA256,
DownloadedAt: time.Now().UTC().Format(time.RFC3339),
}
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return nil, err
}
metadataPath, err := updateMetadataPath()
if err != nil {
return nil, err
}
if err := writeAtomic(metadataPath, append(raw, '\n'), 0o600); err != nil {
return nil, err
}
return meta, nil
}
func loadCachedUpdate() (*cachedUpdate, error) {
path, err := updateMetadataPath()
if err != nil {
return nil, err
}
raw, err := readFileUTF8(path)
if err != nil {
return nil, err
}
var meta cachedUpdate
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, err
}
if meta.Version == "" || meta.Channel == "" || meta.Platform == "" || meta.Path == "" || meta.SHA256 == "" {
return nil, fmt.Errorf("update: cached metadata is incomplete")
}
return &meta, nil
}
func cachedUpdateMatches(version string, asset update.Asset) bool {
meta, err := loadCachedUpdate()
if err != nil {
return false
}
return meta.Version == version &&
meta.Channel == channel &&
meta.Platform == update.CurrentPlatform() &&
strings.EqualFold(meta.SHA256, asset.SHA256) &&
meta.Size == asset.Size &&
fileSHA256Matches(meta.Path, meta.SHA256)
}
func fileSHA256Matches(path, want string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return false
}
return strings.EqualFold(hex.EncodeToString(h.Sum(nil)), want)
}
func readVerifiedCachedUpdate() (*cachedUpdate, []byte, error) {
meta, err := loadCachedUpdate()
if err != nil {
return nil, nil, err
}
if meta.Channel != channel {
return nil, nil, fmt.Errorf("update: cached update is for %s channel, current channel is %s", meta.Channel, channel)
}
if meta.Platform != update.CurrentPlatform() {
return nil, nil, fmt.Errorf("update: cached update is for %s, current platform is %s", meta.Platform, update.CurrentPlatform())
}
data, err := os.ReadFile(meta.Path)
if err != nil {
return nil, nil, err
}
if err := checkSHA256(data, meta.SHA256); err != nil {
return nil, nil, err
}
return meta, data, nil
}
// downloadAttempts caps how many times a transient transport failure (connection
// reset, read timeout, gateway 5xx) is retried before the update gives up. CN IPv6
// routes to Cloudflare reset mid-transfer often enough that a retry or two usually
// completes the download instead of surfacing a "forcibly closed" error.
const downloadAttempts = 3
// retryBackoff is the pause before the Nth retry; a package var so tests shrink it.
var retryBackoff = func(attempt int) time.Duration { return time.Duration(attempt) * 500 * time.Millisecond }
// retryTransient runs attempt 1..downloadAttempts of fetch, pausing between tries,
// until one succeeds. fetch receives the 1-based attempt number so a caller can
// switch transports on a retry. It stops early when ctx is cancelled (window closed
// / user cancelled). Only the transport is retried; the signature and sha256 checks
// run downstream in downloadVerify and are not retried.
func retryTransient(ctx context.Context, fetch func(attempt int) error) error {
var err error
for attempt := 1; attempt <= downloadAttempts; attempt++ {
if err = fetch(attempt); err == nil {
return nil
}
if ctx.Err() != nil || attempt == downloadAttempts {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(retryBackoff(attempt)):
}
}
return err
}
// fetchBytes GETs a URL fully into memory, retrying transient transport failures.
func fetchBytes(ctx context.Context, c *http.Client, url string) ([]byte, error) {
var data []byte
err := retryTransient(ctx, func(int) error {
var e error
data, e = fetchBytesOnce(ctx, c, url)
return e
})
return data, err
}
func fetchBytesOnce(ctx context.Context, c *http.Client, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", updaterUserAgent())
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
return io.ReadAll(resp.Body)
}
// download fetches url into memory, invoking onProgress as bytes arrive. A transient
// transport failure is retried; the retry resumes from the bytes already received
// via a Range request instead of restarting, and switches to the IPv4 fallback
// client (when provided) since a reset usually means the IPv6 route is the problem.
// total is the expected size for the progress denominator (refined from the response).
func download(ctx context.Context, c, fallback *http.Client, url string, total int64, onProgress func(received, total int64)) ([]byte, error) {
var buf bytes.Buffer
err := retryTransient(ctx, func(attempt int) error {
client := c
if attempt > 1 && fallback != nil {
client = fallback
}
return downloadInto(ctx, client, url, &buf, &total, onProgress)
})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// downloadInto appends url's body to buf, resuming from buf's current length via a
// Range request so a retry continues the partial download. A 206 carries the
// remaining bytes; a 200 means the server ignored Range, so buf is reset and the
// whole file re-downloaded. total is refined from the response for the progress
// denominator (Content-Length on 200, the size field of Content-Range on 206).
func downloadInto(ctx context.Context, c *http.Client, url string, buf *bytes.Buffer, total *int64, onProgress func(received, total int64)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", updaterUserAgent())
if buf.Len() > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", buf.Len()))
}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
buf.Reset()
if resp.ContentLength > 0 {
*total = resp.ContentLength
}
case http.StatusPartialContent:
if t := totalFromContentRange(resp.Header.Get("Content-Range")); t > 0 {
*total = t
}
default:
return fmt.Errorf("GET %s: %s", url, resp.Status)
}
have := int64(buf.Len())
pr := &progressReader{r: resp.Body, received: have, lastEmit: have, total: *total, onProgress: onProgress}
_, err = io.Copy(buf, pr)
return err
}
// totalFromContentRange parses the total size out of a "bytes 200-999/1000" header,
// returning 0 when it's absent or "*" (unknown).
func totalFromContentRange(v string) int64 {
i := strings.LastIndex(v, "/")
if i < 0 {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
if err != nil {
return 0
}
return n
}
// progressReader reports cumulative bytes read, throttled so the event channel
// isn't flooded.
type progressReader struct {
r io.Reader
received int64
total int64
lastEmit int64
onProgress func(received, total int64)
}
func (p *progressReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.received += int64(n)
// Emit roughly every 256 KiB, and always on the final read (io.EOF).
if p.onProgress != nil && (p.received-p.lastEmit >= 256<<10 || err == io.EOF) {
p.lastEmit = p.received
p.onProgress(p.received, p.total)
}
return n, err
}
// checkSHA256 verifies data's digest matches the lowercase-hex want.
func checkSHA256(data []byte, want string) error {
sum := sha256.Sum256(data)
if got := hex.EncodeToString(sum[:]); !strings.EqualFold(got, want) {
return fmt.Errorf("update: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// extractBinary pulls a single named regular file out of a .tar.gz blob.
func extractBinary(targz []byte, name string) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(targz))
if err != nil {
return nil, err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if h.Typeflag == tar.TypeReg && (h.Name == name || strings.HasSuffix(h.Name, "/"+name)) {
return io.ReadAll(tr)
}
}
return nil, fmt.Errorf("update: %q not found in archive", name)
}
// applyLinux replaces the running binary with the one inside the downloaded
// tar.gz; the caller relaunches afterwards.
func applyLinux(targz []byte) error {
bin, err := extractBinary(targz, "reasonix-desktop")
if err != nil {
return err
}
return selfupdate.Apply(bytes.NewReader(bin), selfupdate.Options{})
}
func applyWindowsFile(path string) error {
return startWindowsUpdateHandoff(path, currentInstallDir(), currentExecutablePath())
}
func currentExecutablePath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return exe
}
// currentInstallDir is the directory of the running executable — the location a
// Windows update must overwrite. Empty when it can't be resolved, in which case
// the installer falls back to its own InstallDir logic.
func currentInstallDir() string {
exe := currentExecutablePath()
if exe == "" {
return ""
}
return filepath.Dir(exe)
}
// relaunch starts a fresh copy of the (just-replaced) executable.
func relaunch() error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := exec.Command(exe)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
return cmd.Start()
}