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

226 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"os"
"runtime"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"reasonix/desktop/internal/update"
)
// updater_app.go is the auto-updater's bound command surface — the App methods the
// frontend calls — mirroring settings_app.go's "one file per concern" split. The
// transport-free logic lives in updater.go; this file is the Wails glue: it streams
// download progress as "updater:progress" events and routes macOS to the manual
// download path unless the macOS build was Developer ID signed and notarized.
// Version returns the build version injected via -ldflags (see main.go). The
// frontend displays it; CheckUpdate compares against it.
func (a *App) Version() string { return version }
// CheckUpdate fetches the manifest (R2, then GitHub) and reports whether a newer
// build is available for this platform. Safe to call on startup: a network error
// surfaces in UpdateInfo.Err rather than failing, so the UI can stay quiet.
func (a *App) CheckUpdate() (*UpdateInfo, error) {
c, err := httpClient()
if err != nil {
a.recordUpdateError(err)
return &UpdateInfo{
Current: version,
Channel: channel,
CanSelfUpdate: canSelfUpdate(),
ManualOnly: !canSelfUpdate(),
ManualReason: manualUpdateReason(),
DownloadURL: downloadPage(),
Err: err.Error(),
}, nil
}
ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
defer cancel()
m, err := fetchManifest(ctx, c)
if err != nil {
a.recordUpdateError(err)
return &UpdateInfo{
Current: version,
Channel: channel,
CanSelfUpdate: canSelfUpdate(),
ManualOnly: !canSelfUpdate(),
ManualReason: manualUpdateReason(),
DownloadURL: downloadPage(),
Err: err.Error(),
}, nil
}
info := evaluate(version, m)
return &info, nil
}
// OpenDownloadPage opens the install page in the browser — the macOS manual-update
// path and a fallback link elsewhere.
func (a *App) OpenDownloadPage() {
page := downloadPage()
if c, err := httpClient(); err == nil {
ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
defer cancel()
if m, err := fetchManifest(ctx, c); err == nil && m.DownloadPage != "" {
page = m.DownloadPage
}
}
if a.ctx != nil {
wruntime.BrowserOpenURL(a.ctx, page)
}
}
// DownloadUpdate downloads, verifies, and caches the latest build. Installation is
// deliberately a separate user action so the UI can show "downloaded" before the
// app quits to finish the update.
func (a *App) DownloadUpdate() (*UpdateDownloadResult, error) {
if !canSelfUpdate() {
a.OpenDownloadPage()
return nil, nil
}
c, err := httpClient()
if err != nil {
return nil, a.failUpdate(err)
}
ctx, cancel := context.WithTimeout(a.reqCtx(), httpTimeout)
defer cancel()
m, err := fetchManifest(ctx, c)
if err != nil {
return nil, a.failUpdate(err)
}
asset, ok := m.Asset()
if !ok {
return nil, a.failUpdate(fmt.Errorf("no update artifact for %s", update.CurrentPlatform()))
}
data, err := a.downloadVerify(asset)
if err != nil {
return nil, a.failUpdate(err)
}
meta, err := saveCachedUpdate(m.Version, asset, data)
if err != nil {
return nil, a.failUpdate(err)
}
a.emitProgress("downloaded", meta.Size, meta.Size, "")
return &UpdateDownloadResult{
Version: meta.Version,
Channel: meta.Channel,
Path: meta.Path,
Size: meta.Size,
SHA256: meta.SHA256,
}, nil
}
// InstallUpdate applies the cached, verified update and then exits/relaunches.
func (a *App) InstallUpdate() error {
if !canSelfUpdate() {
a.OpenDownloadPage()
return nil
}
meta, data, err := readVerifiedCachedUpdate()
if err != nil {
return a.failUpdate(err)
}
a.emitProgress("installing", meta.Size, meta.Size, "")
switch runtime.GOOS {
case "windows":
err = applyWindowsFile(meta.Path)
case "darwin":
err = applyMac(meta.Path)
case "linux":
err = applyLinux(data)
default:
err = fmt.Errorf("self-update unsupported on %s", runtime.GOOS)
}
if err != nil {
return a.failUpdate(err)
}
a.emitProgress("done", meta.Size, meta.Size, "")
// Persist the conversation and stop subprocesses before handing off (same as
// shutdown). On Linux the binary is now replaced, so relaunch it; on Windows and
// macOS the installer/helper we launched takes over once we exit.
a.shutdown(a.ctx)
if runtime.GOOS == "linux" {
_ = relaunch()
}
os.Exit(0)
return nil
}
// ApplyUpdate is kept for older frontend bindings and tests. New UI code uses the
// explicit download → install split.
func (a *App) ApplyUpdate() error {
if _, err := a.DownloadUpdate(); err != nil {
return err
}
return a.InstallUpdate()
}
// downloadVerify downloads the asset (streaming progress), verifies its minisign
// signature against the embedded public key, then its sha256. It returns the
// verified bytes and never touches disk on a bad signature.
func (a *App) downloadVerify(asset update.Asset) ([]byte, error) {
c, err := httpClient()
if err != nil {
return nil, err
}
v4, _ := httpClientIPv4() // best-effort IPv4 fallback; nil just means retries reuse c
data, err := download(a.reqCtx(), c, v4, asset.URL, asset.Size, func(rcv, total int64) {
a.emitProgress("downloading", rcv, total, "")
})
if err != nil {
return nil, err
}
a.emitProgress("verifying", asset.Size, asset.Size, "")
sig, err := fetchBytes(a.reqCtx(), c, asset.Sig)
if err != nil {
return nil, err
}
if err := update.Verify(data, sig); err != nil {
return nil, err
}
if err := checkSHA256(data, asset.SHA256); err != nil {
return nil, err
}
return data, nil
}
// reqCtx is the context for updater HTTP calls — the Wails context once startup has
// run, else Background (CheckUpdate may, in theory, be reached before startup).
func (a *App) reqCtx() context.Context {
if a.ctx != nil {
return a.ctx
}
return context.Background()
}
func (a *App) emitProgress(phase string, received, total int64, errMsg string) {
if a.ctx == nil {
return
}
wruntime.EventsEmit(a.ctx, "updater:progress", updateProgress{
Phase: phase, Received: received, Total: total, Err: errMsg,
})
}
// failUpdate emits an error progress event and returns the error to the caller.
func (a *App) failUpdate(err error) error {
a.recordUpdateError(err)
a.emitProgress("error", 0, 0, err.Error())
return err
}
func (a *App) recordUpdateError(err error) {
if err == nil || version == "dev" {
return
}
if m := a.metrics.Load(); m != nil {
m.inc("updater_error", errorClass(err.Error()))
}
}