package update import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "time" ) // CurrentVersion is the shipped client version. const CurrentVersion = "1.6.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" // 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: CurrentVersion, 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 st.Available = Compare(st.Latest, CurrentVersion) > 0 && url != "" return st, nil } // Compare returns 1 if a>b, -1 if a n { n = len(bs) } for i := 0; i < n; i++ { var ai, bi int if i < len(as) { ai = as[i] } if i < len(bs) { bi = bs[i] } if ai > bi { return 1 } if ai < bi { return -1 } } return 0 } func splitVer(v string) []int { v = strings.TrimPrefix(strings.TrimSpace(v), "v") 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 } if i := strings.IndexAny(p, "-+"); i >= 0 { p = p[:i] } 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/"+CurrentVersion) 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/"+CurrentVersion) 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 } 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("обновление не найдено") } 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 } tmp := 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 }