package update import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "time" ) // CurrentVersion is the shipped client version. const CurrentVersion = "1.3.0" // 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" // Manifest describes a remote release. type Manifest struct { Version string `json:"version"` URL string `json:"url"` SHA256 string `json:"sha256,omitempty"` Notes string `json:"notes,omitempty"` Mandatory bool `json:"mandatory,omitempty"` } // Status is returned to the UI. type Status struct { Current string `json:"current"` Latest string `json:"latest,omitempty"` Notes string `json:"notes,omitempty"` URL string `json:"url,omitempty"` Available bool `json:"available"` Mandatory bool `json:"mandatory"` Checking bool `json:"checking,omitempty"` Error string `json:"error,omitempty"` ManifestURL string `json:"manifest_url"` } // Check fetches the manifest and compares versions. func Check(ctx context.Context, manifestURL string) (Status, error) { if manifestURL == "" { manifestURL = DefaultManifestURL } st := Status{Current: CurrentVersion, ManifestURL: manifestURL} req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) if err != nil { return st, 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 { st.Error = err.Error() return st, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { err = fmt.Errorf("update feed HTTP %d", resp.StatusCode) st.Error = err.Error() return st, err } var m Manifest if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil { st.Error = err.Error() return st, err } st.Latest = strings.TrimSpace(m.Version) st.Notes = m.Notes st.URL = m.URL st.Mandatory = m.Mandatory st.Available = Compare(st.Latest, CurrentVersion) > 0 && m.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 } // strip pre-release suffix: 1.2.3-beta if i := strings.IndexAny(p, "-+"); i >= 0 { p = p[:i] } n, _ := strconv.Atoi(p) out = append(out, n) } return out } // Apply downloads the new exe and schedules replacement after exit. func Apply(ctx context.Context, manifestURL string) (string, error) { st, err := Check(ctx, manifestURL) if err != nil { return "", err } if !st.Available { 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 } dir := filepath.Dir(exe) tmp := filepath.Join(dir, "Navis.exe.new") bat := filepath.Join(dir, "navis-update.bat") if err := downloadFile(ctx, st.URL, tmp); err != nil { return "", err } man, err := fetchManifest(ctx, manifestURL) if err == nil && man.SHA256 != "" { sum, err := fileSHA256(tmp) if err != nil { return "", err } if !strings.EqualFold(sum, man.SHA256) { _ = os.Remove(tmp) return "", fmt.Errorf("checksum mismatch") } } // Important: do NOT self-delete the .bat at the end — that causes // "Не удается найти пакетный файл" on paths with spaces (e.g. D:\vpn navi). script := "@echo off\r\n" + "setlocal EnableExtensions\r\n" + "cd /d \"" + dir + "\"\r\n" + "set \"EXE=" + exe + "\"\r\n" + "set \"NEW=" + tmp + "\"\r\n" + ":wait\r\n" + "ping -n 2 127.0.0.1 >nul\r\n" + "del /f /q \"%EXE%\" >nul 2>&1\r\n" + "if exist \"%EXE%\" goto wait\r\n" + "move /y \"%NEW%\" \"%EXE%\" >nul\r\n" + "if not exist \"%EXE%\" exit /b 1\r\n" + "start \"\" \"%EXE%\"\r\n" + "exit /b 0\r\n" if err := os.WriteFile(bat, []byte(script), 0o755); err != nil { return "", err } // Detached cmd so Apply returns while updater keeps running. cmd := exec.Command("cmd.exe", "/C", bat) cmd.Dir = dir cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x00000008 | 0x00000200, // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP } if err := cmd.Start(); err != nil { return "", fmt.Errorf("start updater: %w", err) } return st.Latest, nil } 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) client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { return Manifest{}, err } defer resp.Body.Close() var m Manifest err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m) return m, err } 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 }