305 lines
9.0 KiB
Go
305 lines
9.0 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 = "2.7.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
|
|
}
|
|
|
|
// AssetFor returns download URL + checksum for the given platform key.
|
|
func (m Manifest) AssetFor(key string) (url, sha string, ok bool) {
|
|
if key == "" {
|
|
key = PlatformKey()
|
|
}
|
|
if m.Platforms != nil {
|
|
if a, found := m.Platforms[key]; found && strings.TrimSpace(a.URL) != "" {
|
|
return strings.TrimSpace(a.URL), strings.TrimSpace(a.SHA256), 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), 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
|
|
|
|
url, sha, ok := m.AssetFor(key)
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
st, err := Check(ctx, manifestURL)
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
if !st.Available {
|
|
if st.Error != "" {
|
|
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
|
}
|
|
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
|
}
|
|
if !allowedDownloadURL(st.URL) {
|
|
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
exe, err = filepath.Abs(exe)
|
|
if err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(exe); err == nil && resolved != "" {
|
|
exe = resolved
|
|
}
|
|
tmp := filepath.Join(filepath.Dir(exe), ".navis-update-download")
|
|
_ = os.Remove(tmp)
|
|
// Also clear legacy leftover from older updaters.
|
|
_ = os.Remove(exe + ".new")
|
|
if err := downloadFile(ctx, st.URL, tmp); err != nil {
|
|
return "", "", "", "", "", err
|
|
}
|
|
wantSHA := st.SHA256
|
|
if wantSHA == "" {
|
|
if man, err := fetchManifest(ctx, manifestURL); err == nil {
|
|
if _, sha2, ok := man.AssetFor(PlatformKey()); ok {
|
|
wantSHA = sha2
|
|
}
|
|
}
|
|
}
|
|
if wantSHA != "" {
|
|
sum, err := fileSHA256(tmp)
|
|
if err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", "", "", "", "", err
|
|
}
|
|
if !strings.EqualFold(sum, wantSHA) {
|
|
_ = os.Remove(tmp)
|
|
return "", "", "", "", "", fmt.Errorf("checksum mismatch")
|
|
}
|
|
}
|
|
return st.Latest, st.URL, wantSHA, exe, tmp, nil
|
|
}
|