Release 1.4.0: macOS CLI builds and multi-platform update channel.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+116
-110
@@ -9,35 +9,48 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "1.3.1"
|
||||
const CurrentVersion = "1.4.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"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// Status is returned to the UI.
|
||||
// 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"`
|
||||
@@ -45,42 +58,54 @@ type Status struct {
|
||||
ManifestURL string `json:"manifest_url"`
|
||||
}
|
||||
|
||||
// Check fetches the manifest and compares versions.
|
||||
// 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
|
||||
}
|
||||
st := Status{Current: CurrentVersion, ManifestURL: manifestURL}
|
||||
key := PlatformKey()
|
||||
st := Status{Current: CurrentVersion, ManifestURL: manifestURL, Platform: key}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
|
||||
m, err := fetchManifest(ctx, manifestURL)
|
||||
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 != ""
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -120,7 +145,6 @@ func splitVer(v string) []int {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
// strip pre-release suffix: 1.2.3-beta
|
||||
if i := strings.IndexAny(p, "-+"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
@@ -130,78 +154,6 @@ func splitVer(v string) []int {
|
||||
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/") ||
|
||||
@@ -217,15 +169,21 @@ func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) {
|
||||
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
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m)
|
||||
return m, err
|
||||
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 {
|
||||
@@ -264,3 +222,51 @@ func fileSHA256(path string) (string, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build darwin
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Apply downloads the new binary and schedules replacement after exit (macOS).
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = os.Chmod(tmp, 0o755)
|
||||
dir := filepath.Dir(exe)
|
||||
scriptPath := filepath.Join(dir, "navis-update.sh")
|
||||
pid := os.Getpid()
|
||||
|
||||
script := "#!/bin/bash\n" +
|
||||
"set -e\n" +
|
||||
"EXE=" + shellQuote(exe) + "\n" +
|
||||
"NEW=" + shellQuote(tmp) + "\n" +
|
||||
"PID=" + strconv.Itoa(pid) + "\n" +
|
||||
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
|
||||
"sleep 0.5\n" +
|
||||
"mv -f \"$NEW\" \"$EXE\"\n" +
|
||||
"chmod +x \"$EXE\"\n" +
|
||||
"rm -f " + shellQuote(scriptPath) + "\n"
|
||||
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", scriptPath)
|
||||
cmd.Dir = dir
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("start updater: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Apply is not implemented on this OS yet.
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
_ = ctx
|
||||
_ = manifestURL
|
||||
return "", fmt.Errorf("автообновление пока только для Windows и macOS")
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package update
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompare(t *testing.T) {
|
||||
if Compare("1.1.0", "1.0.0") <= 0 {
|
||||
@@ -13,3 +16,28 @@ func TestCompare(t *testing.T) {
|
||||
t.Fatal("expected equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestAssetFor(t *testing.T) {
|
||||
m := Manifest{
|
||||
URL: "https://git.evilfox.cc/legacy.exe",
|
||||
SHA256: "abc",
|
||||
Platforms: map[string]PlatformAsset{
|
||||
"darwin-arm64": {
|
||||
URL: "https://git.evilfox.cc/arm64/Navis",
|
||||
SHA256: "def",
|
||||
},
|
||||
},
|
||||
}
|
||||
u, s, ok := m.AssetFor("darwin-arm64")
|
||||
if !ok || u == "" || s != "def" {
|
||||
t.Fatalf("darwin: %v %q %q", ok, u, s)
|
||||
}
|
||||
u, s, ok = m.AssetFor("windows-amd64")
|
||||
if !ok || !strings.Contains(u, "legacy") || s != "abc" {
|
||||
t.Fatalf("windows legacy: %v %q %q", ok, u, s)
|
||||
}
|
||||
_, _, ok = m.AssetFor("linux-amd64")
|
||||
if ok {
|
||||
t.Fatal("expected missing linux")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Apply downloads the new exe and schedules replacement after exit (Windows).
|
||||
func Apply(ctx context.Context, manifestURL string) (string, error) {
|
||||
latest, _, _, exe, tmp, err := prepareDownload(ctx, manifestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
bat := filepath.Join(dir, "navis-update.bat")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 latest, nil
|
||||
}
|
||||
Reference in New Issue
Block a user