Release 2.7.2+1: stop auto-apply update loop; check-only on start, apply on button with skip/opt-in.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+14
-9
@@ -70,7 +70,7 @@ func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
|
||||
CfgPath: cfgPath,
|
||||
LogBuf: logBuf,
|
||||
UpdateStatus: update.Status{
|
||||
Current: update.CurrentVersion,
|
||||
Current: update.DisplayVersion(),
|
||||
ManifestURL: update.DefaultManifestURL,
|
||||
},
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (a *App) GetState() (UIState, error) {
|
||||
CorePath: corePath,
|
||||
ConfigPath: a.CfgPath,
|
||||
Profiles: cfg.ListProfiles(),
|
||||
Version: update.CurrentVersion,
|
||||
Version: update.DisplayVersion(),
|
||||
Update: a.UpdateStatus,
|
||||
Pings: append([]netcheck.Result(nil), a.Pings...),
|
||||
Subscription: cfg.SubscriptionURL,
|
||||
@@ -322,7 +322,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
|
||||
st, err := update.Check(ctx, update.DefaultManifestURL)
|
||||
a.mu.Lock()
|
||||
if err != nil {
|
||||
st.Current = update.CurrentVersion
|
||||
st.Current = update.DisplayVersion()
|
||||
st.ManifestURL = update.DefaultManifestURL
|
||||
st.Error = err.Error()
|
||||
}
|
||||
@@ -332,6 +332,14 @@ func (a *App) CheckUpdate() (update.Status, error) {
|
||||
}
|
||||
|
||||
func (a *App) ApplyUpdate() (string, error) {
|
||||
// Explicit user action only — never called from AutoCheckUpdate.
|
||||
st, err := a.CheckUpdate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !st.Available {
|
||||
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
latest, err := update.Apply(ctx, update.DefaultManifestURL)
|
||||
@@ -351,13 +359,10 @@ func (a *App) ApplyUpdate() (string, error) {
|
||||
}
|
||||
|
||||
func (a *App) AutoCheckUpdate() {
|
||||
// Only check and populate status for the banner — never auto-apply.
|
||||
// Auto-apply previously caused endless download→relaunch loops on Mac/Windows.
|
||||
time.Sleep(3 * time.Second)
|
||||
st, err := a.CheckUpdate()
|
||||
if err != nil || !st.Available {
|
||||
return
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
_, _ = a.ApplyUpdate()
|
||||
_, _ = a.CheckUpdate()
|
||||
}
|
||||
|
||||
func (a *App) OpenShopURL(raw string) error {
|
||||
|
||||
@@ -76,6 +76,15 @@
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.update-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.update-actions .action {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
@@ -712,7 +721,10 @@
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<strong id="updateTitle">Доступно обновление</strong>
|
||||
<p id="updateNotes"></p>
|
||||
<button class="action primary" id="updateBtn" type="button">Обновить и перезапустить</button>
|
||||
<div class="update-actions">
|
||||
<button class="action primary" id="updateBtn" type="button">Обновить</button>
|
||||
<button class="action secondary" id="skipUpdateBtn" type="button">Пропустить эту версию</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="top">
|
||||
@@ -725,7 +737,7 @@
|
||||
<div class="brand-wrap">
|
||||
<div class="brand-row">
|
||||
<h1 class="brand">Navis</h1>
|
||||
<span class="badge-ver">2.7</span>
|
||||
<span class="badge-ver">2.7.1</span>
|
||||
</div>
|
||||
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
|
||||
</div>
|
||||
@@ -938,7 +950,18 @@
|
||||
const updCheckBtn = $("updCheckBtn");
|
||||
const updateBanner = $("updateBanner");
|
||||
const updateBtn = $("updateBtn");
|
||||
const skipUpdateBtn = $("skipUpdateBtn");
|
||||
const verLabel = $("verLabel");
|
||||
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
|
||||
function skippedVersion() {
|
||||
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
|
||||
}
|
||||
function setSkippedVersion(v) {
|
||||
try {
|
||||
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
|
||||
else localStorage.removeItem(SKIP_UPDATE_KEY);
|
||||
} catch (_) {}
|
||||
}
|
||||
const quitBtn = $("quitBtn");
|
||||
if (window.__navisHttp && quitBtn) {
|
||||
quitBtn.hidden = false;
|
||||
@@ -1173,10 +1196,17 @@
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
const latest = (u.latest || "").toString();
|
||||
const skip = skippedVersion();
|
||||
if (u.available && latest && skip === latest) {
|
||||
updateBanner.classList.remove("show");
|
||||
return;
|
||||
}
|
||||
if (u.available) {
|
||||
updateBanner.classList.add("show");
|
||||
$("updateTitle").textContent = "Обновление " + u.latest;
|
||||
$("updateNotes").textContent = u.notes || ("Текущая версия " + u.current);
|
||||
$("updateTitle").textContent = "Доступно обновление " + latest;
|
||||
$("updateNotes").textContent = u.notes || ("У вас " + (u.current || version || "?") + ". Обновление только по кнопке.");
|
||||
updateBanner.dataset.latest = latest;
|
||||
} else {
|
||||
updateBanner.classList.remove("show");
|
||||
}
|
||||
@@ -1279,7 +1309,7 @@
|
||||
}
|
||||
|
||||
function paintButtonsLocked(locked) {
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, subBtn, subUrl].forEach((b) => {
|
||||
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
|
||||
if (b) b.disabled = locked;
|
||||
});
|
||||
}
|
||||
@@ -1444,6 +1474,13 @@
|
||||
} catch (e) { setMeta(String(e), "err"); }
|
||||
}));
|
||||
|
||||
skipUpdateBtn.addEventListener("click", () => {
|
||||
const latest = updateBanner.dataset.latest || "";
|
||||
if (latest) setSkippedVersion(latest);
|
||||
updateBanner.classList.remove("show");
|
||||
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await refresh({ syncForm: true });
|
||||
|
||||
+51
-27
@@ -16,12 +16,27 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CurrentVersion is the shipped client version.
|
||||
const CurrentVersion = "2.7.1"
|
||||
// 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"`
|
||||
@@ -90,7 +105,7 @@ func Check(ctx context.Context, manifestURL string) (Status, error) {
|
||||
manifestURL = DefaultManifestURL
|
||||
}
|
||||
key := PlatformKey()
|
||||
st := Status{Current: CurrentVersion, ManifestURL: manifestURL, Platform: key}
|
||||
st := Status{Current: DisplayVersion(), ManifestURL: manifestURL, Platform: key}
|
||||
|
||||
m, err := fetchManifest(ctx, manifestURL)
|
||||
if err != nil {
|
||||
@@ -109,38 +124,46 @@ func Check(ctx context.Context, manifestURL string) (Status, error) {
|
||||
}
|
||||
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 (semver-ish dotted ints).
|
||||
// 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 := 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 {
|
||||
as := productParts(a)
|
||||
bs := productParts(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if as[i] > bs[i] {
|
||||
return 1
|
||||
}
|
||||
if ai < bi {
|
||||
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 {
|
||||
@@ -149,9 +172,6 @@ func splitVer(v string) []int {
|
||||
out = append(out, 0)
|
||||
continue
|
||||
}
|
||||
if i := strings.IndexAny(p, "-+"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
out = append(out, n)
|
||||
}
|
||||
@@ -172,7 +192,7 @@ func fetchManifest(ctx context.Context, manifestURL string) (Manifest, error) {
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+CurrentVersion)
|
||||
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)
|
||||
@@ -195,7 +215,7 @@ func downloadFile(ctx context.Context, url, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Navis/"+CurrentVersion)
|
||||
req.Header.Set("User-Agent", "Navis/"+DisplayVersion())
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -227,7 +247,8 @@ func fileSHA256(path string) (string, error) {
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// prepareDownload fetches the update next to the running binary as "<exe>.new".
|
||||
// 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 {
|
||||
@@ -237,7 +258,7 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
if st.Error != "" {
|
||||
return "", "", "", "", "", fmt.Errorf("%s", st.Error)
|
||||
}
|
||||
return "", "", "", "", "", fmt.Errorf("обновление не найдено")
|
||||
return "", "", "", "", "", fmt.Errorf("уже актуальная версия %s (канал %s)", DisplayVersion(), st.Latest)
|
||||
}
|
||||
if !allowedDownloadURL(st.URL) {
|
||||
return "", "", "", "", "", fmt.Errorf("URL обновления должен быть на git.evilfox.cc или files.de4ima.uk")
|
||||
@@ -250,6 +271,9 @@ func prepareDownload(ctx context.Context, manifestURL string) (latest, url, sha,
|
||||
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.
|
||||
|
||||
@@ -15,6 +15,19 @@ func TestCompare(t *testing.T) {
|
||||
if Compare("1.1.0", "1.1.0") != 0 {
|
||||
t.Fatal("expected equal")
|
||||
}
|
||||
// Build must not make same product look outdated or newer for update checks.
|
||||
if Compare("2.7.2", "2.7.2+9") != 0 {
|
||||
t.Fatal("expected equal ignoring +build")
|
||||
}
|
||||
if Compare("2.7.2", "2.7.2.99") != 0 {
|
||||
t.Fatal("expected equal ignoring 4th component")
|
||||
}
|
||||
if Compare("2.7.3", "2.7.2+1") <= 0 {
|
||||
t.Fatal("expected product bump to win")
|
||||
}
|
||||
if DisplayVersion() == "" || !strings.Contains(DisplayVersion(), "+") {
|
||||
t.Fatal("DisplayVersion should include build")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestAssetFor(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user