Files
navi/internal/update/update.go
T
M4andCursor 2ad376b49e
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00

402 lines
11 KiB
Go

package update
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 5
// DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
// DisplayVersion is shown in the UI (product + build), e.g. "2.7.2+1".
func DisplayVersion() string {
return fmt.Sprintf("%s+%d", CurrentVersion, BuildNumber)
}
// FileVersionDots is Major.Minor.Patch.Build for Windows resources / CFBundleVersion.
func FileVersionDots() string {
return fmt.Sprintf("%s.%d", CurrentVersion, BuildNumber)
}
// PlatformAsset is one OS/arch binary in the feed.
type PlatformAsset struct {
URL string `json:"url"`
SHA256 string `json:"sha256,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
DmgURL string `json:"dmg_url,omitempty"`
DmgSHA256 string `json:"dmg_sha256,omitempty"`
ZipURL string `json:"zip_url,omitempty"`
ZipSHA256 string `json:"zip_sha256,omitempty"`
}
// Manifest describes a remote release (multi-platform + legacy single URL).
type Manifest struct {
Version string `json:"version"`
URL string `json:"url,omitempty"` // legacy windows-amd64
SHA256 string `json:"sha256,omitempty"`
Notes string `json:"notes,omitempty"`
Mandatory bool `json:"mandatory,omitempty"`
Platform string `json:"platform,omitempty"` // legacy
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Platforms map[string]PlatformAsset `json:"platforms,omitempty"`
}
// Status is returned to the UI / CLI.
type Status struct {
Current string `json:"current"`
Latest string `json:"latest,omitempty"`
Notes string `json:"notes,omitempty"`
URL string `json:"url,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Platform string `json:"platform,omitempty"`
Available bool `json:"available"`
Mandatory bool `json:"mandatory"`
Checking bool `json:"checking,omitempty"`
Error string `json:"error,omitempty"`
ManifestURL string `json:"manifest_url"`
}
// PlatformKey is "GOOS-GOARCH", e.g. windows-amd64, darwin-arm64.
func PlatformKey() string {
return runtime.GOOS + "-" + runtime.GOARCH
}
// Asset kinds returned by ResolveAsset.
const (
AssetKindBinary = "bin"
AssetKindAppZip = "appzip"
)
// AssetFor returns Mach-O / exe URL + checksum for the platform key.
func (m Manifest) AssetFor(key string) (url, sha string, ok bool) {
url, sha, _, ok = m.ResolveAsset(key, false)
return url, sha, ok
}
// ResolveAsset picks binary or .app.zip. PreferAppZip when running inside Navis.app.
func (m Manifest) ResolveAsset(key string, preferAppZip bool) (url, sha, kind string, ok bool) {
if key == "" {
key = PlatformKey()
}
if m.Platforms != nil {
if a, found := m.Platforms[key]; found {
if preferAppZip {
zu := strings.TrimSpace(a.ZipURL)
zs := strings.TrimSpace(a.ZipSHA256)
if zu != "" && zs != "" {
return zu, zs, AssetKindAppZip, true
}
}
u := strings.TrimSpace(a.URL)
s := strings.TrimSpace(a.SHA256)
if u != "" {
return u, s, AssetKindBinary, true
}
}
}
// Legacy single-asset manifests are windows-amd64.
if (key == "windows-amd64" || key == "windows-386") && strings.TrimSpace(m.URL) != "" {
return strings.TrimSpace(m.URL), strings.TrimSpace(m.SHA256), AssetKindBinary, true
}
return "", "", "", false
}
// Check fetches the manifest and compares versions for this platform.
func Check(ctx context.Context, manifestURL string) (Status, error) {
if manifestURL == "" {
manifestURL = DefaultManifestURL
}
key := PlatformKey()
st := Status{Current: DisplayVersion(), ManifestURL: manifestURL, Platform: key}
m, err := fetchManifest(ctx, manifestURL)
if err != nil {
st.Error = err.Error()
return st, err
}
st.Latest = strings.TrimSpace(m.Version)
st.Notes = m.Notes
st.Mandatory = m.Mandatory
preferZip := false
if runtime.GOOS == "darwin" {
if exe, err := os.Executable(); err == nil {
if _, ok := AppBundleRoot(exe); ok {
preferZip = true
}
}
}
url, sha, _, ok := m.ResolveAsset(key, preferZip)
if !ok {
st.Error = fmt.Sprintf("нет сборки для %s", key)
st.Available = false
return st, nil
}
st.URL = url
st.SHA256 = sha
// Product-only compare: build numbers must not keep offering an update after install
// when feed version equals CurrentVersion (e.g. 2.7.2 vs local 2.7.2+1).
st.Available = Compare(st.Latest, CurrentVersion) > 0 && url != ""
return st, nil
}
// Compare returns 1 if a>b, -1 if a<b, 0 if equal.
//
// Update rule: only product semver (major.minor.patch) counts. Build metadata
// ("+N", "-suffix", or a 4th dotted component) is ignored so a just-updated app
// on the same product version is never treated as outdated vs the same feed.
func Compare(a, b string) int {
as := productParts(a)
bs := productParts(b)
for i := 0; i < 3; i++ {
if as[i] > bs[i] {
return 1
}
if as[i] < bs[i] {
return -1
}
}
return 0
}
func productParts(v string) [3]int {
parts := splitVer(v)
var out [3]int
for i := 0; i < 3 && i < len(parts); i++ {
out[i] = parts[i]
}
return out
}
func splitVer(v string) []int {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
// Strip build metadata: 2.7.2+1 / 2.7.2-beta → compare product only.
if i := strings.IndexAny(v, "+-"); i >= 0 {
v = v[:i]
}
parts := strings.Split(v, ".")
out := make([]int, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
out = append(out, 0)
continue
}
n, _ := strconv.Atoi(p)
out = append(out, n)
}
return out
}
func allowedDownloadURL(u string) bool {
u = strings.ToLower(strings.TrimSpace(u))
return strings.HasPrefix(u, "https://git.evilfox.cc/") ||
strings.HasPrefix(u, "https://files.de4ima.uk/")
}
func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) {
if manifestURL == "" {
manifestURL = DefaultManifestURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return Manifest{}, err
}
req.Header.Set("User-Agent", "Navis/"+DisplayVersion())
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(req)
if err != nil {
return Manifest{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Manifest{}, fmt.Errorf("update feed HTTP %d", resp.StatusCode)
}
var m Manifest
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil {
return Manifest{}, err
}
return m, nil
}
func downloadFile(ctx context.Context, url, dest string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Navis/"+DisplayVersion())
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download HTTP %d", resp.StatusCode)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, 200<<20))
return err
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// preparedUpdate is a verified download ready for platform Apply.
type preparedUpdate struct {
Latest string
URL string
SHA256 string
ExePath string
TmpPath string
Kind string // AssetKindBinary | AssetKindAppZip
AppRoot string // set when Kind is appzip
}
// AppBundleRoot returns the .app path when exe lives in Contents/MacOS.
func AppBundleRoot(exe string) (string, bool) {
exe = filepath.Clean(exe)
const marker = ".app" + string(filepath.Separator) + "Contents" + string(filepath.Separator) + "MacOS"
idx := strings.Index(exe, marker)
if idx < 0 {
// Also accept forward-slash form after EvalSymlinks on some volumes.
idx = strings.Index(exe, ".app/Contents/MacOS")
if idx < 0 {
return "", false
}
return exe[:idx+len(".app")], true
}
return exe[:idx+len(".app")], true
}
// prepareDownload fetches the update next to the running binary as temp download.
// Refuses when already on/newer than the feed product version (no re-apply loop).
func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha, exePath, tmpPath string, err error) {
p, err := prepareUpdate(ctx, manifestURL)
if err != nil {
return "", "", "", "", "", err
}
return p.Latest, p.URL, p.SHA256, p.ExePath, p.TmpPath, nil
}
func prepareUpdate(ctx context.Context, manifestURL string) (preparedUpdate, error) {
st, err := Check(ctx, manifestURL)
if err != nil {
return preparedUpdate{}, err
}
if !st.Available {
if st.Error != "" {
return preparedUpdate{}, fmt.Errorf("%s", st.Error)
}
return preparedUpdate{}, fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
}
exe, err := os.Executable()
if err != nil {
return preparedUpdate{}, err
}
exe, err = filepath.Abs(exe)
if err != nil {
return preparedUpdate{}, err
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" {
exe = resolved
}
preferZip := false
appRoot := ""
if runtime.GOOS == "darwin" {
if root, ok := AppBundleRoot(exe); ok {
preferZip = true
appRoot = root
}
}
man, err := fetchManifest(ctx, manifestURL)
if err != nil {
return preparedUpdate{}, err
}
url, wantSHA, kind, ok := man.ResolveAsset(PlatformKey(), preferZip)
if !ok || url == "" {
return preparedUpdate{}, fmt.Errorf("нет ассета обновления для %s", PlatformKey())
}
if !allowedDownloadURL(url) {
return preparedUpdate{}, fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
}
if wantSHA == "" {
return preparedUpdate{}, fmt.Errorf("в манифесте нет sha256 — обновление отклонено")
}
// Prefer .app.zip when available; otherwise replace Mach-O inside the bundle (legacy).
if kind != AssetKindAppZip {
appRoot = ""
}
dir := filepath.Dir(exe)
if appRoot != "" {
dir = filepath.Dir(appRoot) // sibling of .app
}
tmpName := ".navis-update-download"
if kind == AssetKindAppZip {
tmpName = ".navis-update-app.zip"
}
tmp := filepath.Join(dir, tmpName)
_ = os.Remove(tmp)
_ = os.Remove(exe + ".new")
if err := downloadFile(ctx, url, tmp); err != nil {
return preparedUpdate{}, err
}
sum, err := fileSHA256(tmp)
if err != nil {
_ = os.Remove(tmp)
return preparedUpdate{}, err
}
if !strings.EqualFold(sum, wantSHA) {
_ = os.Remove(tmp)
return preparedUpdate{}, fmt.Errorf("checksum mismatch")
}
return preparedUpdate{
Latest: st.Latest,
URL: url,
SHA256: wantSHA,
ExePath: exe,
TmpPath: tmp,
Kind: kind,
AppRoot: appRoot,
}, nil
}