Files
navi/internal/update/update.go
T

281 lines
7.6 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 shipped client version.
const CurrentVersion = "2.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<b, 0 if equal (semver-ish dotted ints).
func Compare(a, b string) int {
as := splitVer(a)
bs := splitVer(b)
n := len(as)
if len(bs) > 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
}
// prepareDownload fetches the update next to the running binary as "<exe>.new".
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 := 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
}