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 = 1 // 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 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 }