diff --git a/README.md b/README.md index 007647f..2007795 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,8 @@ https://evilfox.win/ - трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии; - 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении; - 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone; -- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`). +- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`); +- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool. В 3.8.1: - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6c5368e..db31bfa 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { minSdk = 26 targetSdk = 35 // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build - versionCode = 3080204 - versionName = "3.8.2+4" + versionCode = 3080205 + versionName = "3.8.2+5" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index 63ea3d3..587df60 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -42,11 +42,11 @@ if errorlevel 1 exit /b 1 go build -o "tools\packmac\packmac.exe" .\tools\packmac if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.4 -arch arm64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.5 -arch arm64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.4 -arch amd64 +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.5 -arch amd64 if errorlevel 1 exit /b 1 -tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.4 -arch universal +tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.5 -arch universal if errorlevel 1 exit /b 1 echo Built Mac GUI + CLI: diff --git a/cmd/vpnapp/gui_desktop.go b/cmd/vpnapp/gui_desktop.go index 9549f2d..66af264 100644 --- a/cmd/vpnapp/gui_desktop.go +++ b/cmd/vpnapp/gui_desktop.go @@ -93,11 +93,13 @@ func main() { sig := make(chan os.Signal, 1) signal.Notify(sig, shutdownSignals()...) <-sig + a.Shutdown() _ = mgr.Disconnect() _ = srv.Close() return } defer func() { + a.Shutdown() _ = mgr.Disconnect() _ = srv.Close() w.Destroy() diff --git a/dist/navis-release/Navis-3.8.2.5.exe b/dist/navis-release/Navis-3.8.2.5.exe new file mode 100644 index 0000000..ed1c68d Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.5.exe differ diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 45601a1..ed1c68d 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index d6a6969..feae301 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,16 +1,16 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", + "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "os": "windows", "arch": "amd64" }, diff --git a/dist/navis-release/vpnclient-3.8.2.5.exe b/dist/navis-release/vpnclient-3.8.2.5.exe new file mode 100644 index 0000000..018d8a5 Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.5.exe differ diff --git a/dist/navis-release/vpnclient.exe b/dist/navis-release/vpnclient.exe index 459f29f..018d8a5 100644 Binary files a/dist/navis-release/vpnclient.exe and b/dist/navis-release/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index d6a6969..feae301 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,16 +1,16 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", + "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "os": "windows", "arch": "amd64" }, diff --git a/go.mod b/go.mod index 38781a2..edaeadc 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/diskfs/go-diskfs v1.9.4 github.com/ebitengine/purego v0.10.1 golang.org/x/image v0.44.0 + golang.org/x/sync v0.22.0 golang.org/x/sys v0.43.0 ) diff --git a/go.sum b/go.sum index ee5a5dc..a3d9d3b 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 03e5e29..7eb29d9 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -3,6 +3,7 @@ package apphost import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -39,6 +40,8 @@ type App struct { OpenURL func(string) error OnAfterUpdate func() APIToken string + ctx context.Context + cancel context.CancelFunc } type UIState struct { @@ -63,6 +66,8 @@ type UIState struct { ConnectOnLaunch bool `json:"connect_on_launch"` AutoReconnect bool `json:"auto_reconnect"` LogTail string `json:"log_tail,omitempty"` + Rev string `json:"rev,omitempty"` + Unchanged bool `json:"unchanged,omitempty"` } type PingBestResult struct { @@ -74,10 +79,13 @@ type PingBestResult struct { } func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App { + ctx, cancel := context.WithCancel(context.Background()) return &App{ Mgr: mgr, CfgPath: cfgPath, LogBuf: logBuf, + ctx: ctx, + cancel: cancel, UpdateStatus: update.Status{ Current: update.DisplayVersion(), ManifestURL: update.DefaultManifestURL, @@ -85,13 +93,40 @@ func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App { } } +// Shutdown cancels background loops (dock icon / watchdog). Safe to call more than once. +func (a *App) Shutdown() { + if a == nil || a.cancel == nil { + return + } + a.cancel() +} + func (a *App) GetState() (UIState, error) { - return a.getState(false) + return a.GetStateSince("") +} + +// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since +// matches the current fingerprint (cheap idle HTTP polls). +func (a *App) GetStateSince(since string) (UIState, error) { + out, err := a.getState(false) + if err != nil { + return out, err + } + out.Rev = fingerprintState(out) + if since != "" && since == out.Rev { + return UIState{Rev: out.Rev, Unchanged: true}, nil + } + return out, nil } // GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls). func (a *App) GetEditState() (UIState, error) { - return a.getState(true) + out, err := a.getState(true) + if err != nil { + return out, err + } + out.Rev = fingerprintState(out) + return out, nil } func (a *App) getState(includeSecrets bool) (UIState, error) { @@ -155,6 +190,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) { return out, nil } +func fingerprintState(s UIState) string { + var b strings.Builder + b.Grow(256) + fmt.Fprintf(&b, "%t|%s|%s|%s|%t|%s|%t|%t|%t|%s|", + s.Connected, s.Profile, s.ActiveProfile, s.Protocol, s.CoreReady, s.LastError, + s.SystemProxy, s.ConnectOnLaunch, s.AutoReconnect, s.Subscription) + fmt.Fprintf(&b, "%s|%s|%t|", s.Update.Latest, s.Update.Current, s.Update.Available) + for _, p := range s.Profiles { + fmt.Fprintf(&b, "%s,%s,%s,%t;", p.Name, p.Protocol, p.Host, p.Active) + } + for _, p := range s.Pings { + fmt.Fprintf(&b, "%s,%t,%t,%d,%s;", p.Name, p.OK, p.Soft, p.Ms, p.Error) + } + sum := sha256.Sum256([]byte(b.String())) + return hex.EncodeToString(sum[:8]) +} + func trimLogTail(s string, max int) string { if max <= 0 || len(s) <= max { return s @@ -318,7 +370,11 @@ func (a *App) StartBackground() { prefs := a.Mgr.Prefs() if prefs.ConnectOnLaunch { go func() { - time.Sleep(1200 * time.Millisecond) + select { + case <-a.ctx.Done(): + return + case <-time.After(1200 * time.Millisecond): + } if a.Mgr.Status().Connected { return } @@ -331,29 +387,45 @@ func (a *App) dockIconLoop() { dockicon.SetConnected(false) var last bool first := true + t := time.NewTicker(400 * time.Millisecond) + defer t.Stop() for { - time.Sleep(400 * time.Millisecond) - up := a.Mgr.Status().Connected - if first || up != last { - first = false - last = up - dockicon.SetConnected(up) + select { + case <-a.ctx.Done(): + return + case <-t.C: + up := a.Mgr.Status().Connected + if first || up != last { + first = false + last = up + dockicon.SetConnected(up) + } } } } func (a *App) watchdogLoop() { + t := time.NewTicker(2 * time.Second) + defer t.Stop() for { - time.Sleep(2 * time.Second) - if !a.Mgr.TakeUnexpectedDrop() { - continue + select { + case <-a.ctx.Done(): + return + case <-t.C: + if !a.Mgr.TakeUnexpectedDrop() { + continue + } + prefs := a.Mgr.Prefs() + if !prefs.AutoReconnect { + continue + } + select { + case <-a.ctx.Done(): + return + case <-time.After(800 * time.Millisecond): + } + _ = a.Connect() } - prefs := a.Mgr.Prefs() - if !prefs.AutoReconnect { - continue - } - time.Sleep(800 * time.Millisecond) - _ = a.Connect() } } @@ -602,7 +674,7 @@ func arg[T any](args []json.RawMessage, i int, def T) T { func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { switch name { case "getState": - return a.GetState() + return a.GetStateSince(arg(args, 0, "")) case "getEditState": return a.GetEditState() case "connect": diff --git a/internal/apphost/handler_test.go b/internal/apphost/handler_test.go index 6ceb0fc..0a453f8 100644 --- a/internal/apphost/handler_test.go +++ b/internal/apphost/handler_test.go @@ -50,6 +50,33 @@ func TestHTTPBridgeGetState(t *testing.T) { } } +func TestHTTPBridgeGetStateUnchanged(t *testing.T) { + cfg := &config.Config{ + Active: "demo", + Profiles: []config.Profile{ + {Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"}, + }, + } + mgr, err := core.NewManager("", cfg, logbuf.New(0)) + if err != nil { + t.Fatal(err) + } + a := New(mgr, "", logbuf.New(0)) + a.APIToken = "test-token" + + full, err := a.GetStateSince("") + if err != nil || full.Unchanged || full.Rev == "" { + t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err) + } + again, err := a.GetStateSince(full.Rev) + if err != nil || !again.Unchanged || again.Rev != full.Rev { + t.Fatalf("delta: %+v err=%v", again, err) + } + if len(again.Profiles) != 0 { + t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles)) + } +} + func TestHTTPBridgeRejectsBadToken(t *testing.T) { cfg := &config.Config{} mgr, err := core.NewManager("", cfg, logbuf.New(0)) diff --git a/internal/appui/app.js b/internal/appui/app.js index 06f7730..68424b6 100644 --- a/internal/appui/app.js +++ b/internal/appui/app.js @@ -1,4 +1,4 @@ - // HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives). + // HTTP bridge for glaze GUI (Windows + macOS): POST /api/. (function () { const methods = [ "getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile", @@ -208,6 +208,7 @@ } let listFP = ""; + let stateRev = ""; function profilesFingerprint(state) { const parts = [ @@ -444,13 +445,22 @@ } async function refresh(opts) { + const force = !!(opts && opts.force); + if (document.hidden && !force && !(opts && opts.syncForm)) { + return null; + } const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty); let state; if (needSecrets && typeof getEditState === "function") { state = await getEditState(); } else { - state = await getState(); + state = await getState(stateRev || ""); } + if (state && state.unchanged) { + if (state.rev) stateRev = state.rev; + return state; + } + if (state && state.rev) stateRev = state.rev; paint(state, opts); return state; } @@ -697,5 +707,11 @@ setMeta(String(e), "err"); } })(); - setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000); + setInterval(() => { + if (document.hidden || busy || modal.classList.contains("open")) return; + refresh().catch(() => {}); + }, 4000); + document.addEventListener("visibilitychange", () => { + if (!document.hidden && !busy) refresh({ force: true }).catch(() => {}); + }); diff --git a/internal/config/config.go b/internal/config/config.go index 0e3d7f1..0af68c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -327,6 +327,7 @@ func WriteExample(path string) error { // Save writes config as indented JSON (atomic replace). func Save(path string, cfg Config) error { + InvalidateHostCache() if err := cfg.Validate(); err != nil { return err } diff --git a/internal/config/profiles.go b/internal/config/profiles.go index c6ceb83..f29dd72 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -18,6 +18,8 @@ type ProfileInfo struct { // hostCache avoids re-parsing large AWG conf bodies on every UI poll. var hostCache sync.Map // proxy URI/conf → host string +const hostCacheMax = 2048 + func cachedProxyHost(proxy string) string { proxy = strings.TrimSpace(proxy) if proxy == "" { @@ -28,9 +30,32 @@ func cachedProxyHost(proxy string) string { } h := proxyHost(proxy) hostCache.Store(proxy, h) + // Soft bound: subscription churn can retain stale URI keys; drop all if oversized. + n := 0 + over := false + hostCache.Range(func(_, _ any) bool { + n++ + if n > hostCacheMax { + over = true + return false + } + return true + }) + if over { + InvalidateHostCache() + hostCache.Store(proxy, h) + } return h } +// InvalidateHostCache clears parsed host strings (call after profile mutations). +func InvalidateHostCache() { + hostCache.Range(func(k, _ any) bool { + hostCache.Delete(k) + return true + }) +} + // ListProfiles returns UI-friendly profile summaries. func (c *Config) ListProfiles() []ProfileInfo { out := make([]ProfileInfo, 0, len(c.Profiles)) diff --git a/internal/core/manager.go b/internal/core/manager.go index 0517789..c24fb1f 100644 --- a/internal/core/manager.go +++ b/internal/core/manager.go @@ -380,19 +380,20 @@ type UIPoll struct { AutoReconnect bool } -// PollUI gathers status + profile list under one lock without Config().Clone(). +// PollUI gathers status + profile list without Config().Clone(). +// Config/profile fields are copied under the manager lock; engine status is +// read after unlock so a slow Engine.Running cannot block config saves. func (m *Manager) PollUI(includeSecrets bool) UIPoll { m.mu.Lock() - defer m.mu.Unlock() - out := UIPoll{ - Status: Status{SystemProxy: m.sys.Enabled()}, BinDir: m.binDir, SystemProxy: false, Subscription: "", Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"}, LastError: m.lastError, } + var eng Engine + var profCopy *config.Profile if m.cfg != nil { out.Active = m.cfg.Active out.SystemProxy = m.cfg.SystemProxy @@ -424,16 +425,25 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll { } } } - if m.engine != nil && m.engine.Running() { + eng = m.engine + if m.profile != nil { + p := *m.profile + profCopy = &p + } + sys := m.sys + m.mu.Unlock() + + out.Status = Status{SystemProxy: sys.Enabled()} + if eng != nil && eng.Running() { out.Status.Connected = true - if m.profile != nil { - out.Status.Profile = m.profile.Name - out.Status.Protocol = m.profile.Protocol + if profCopy != nil { + out.Status.Profile = profCopy.Name + out.Status.Protocol = profCopy.Protocol } - if hp, ok := m.engine.LocalHTTPProxy(); ok { + if hp, ok := eng.LocalHTTPProxy(); ok { out.Status.HTTPProxy = hp } - if sp, ok := m.engine.LocalSOCKSProxy(); ok { + if sp, ok := eng.LocalSOCKSProxy(); ok { out.Status.SOCKSProxy = sp } } diff --git a/internal/corebin/cache.go b/internal/corebin/cache.go index 0370721..2f45642 100644 --- a/internal/corebin/cache.go +++ b/internal/corebin/cache.go @@ -3,11 +3,14 @@ package corebin import ( "strings" "sync" + + "golang.org/x/sync/singleflight" ) var ( mu sync.Mutex cache = map[string]string{} + group singleflight.Group ) // CacheKey identifies a resolved core binary for a binDir + protocol. @@ -48,15 +51,26 @@ func Invalidate() { // Resolve caches the result of fn under key. // Hot-path polls trust the cache until Invalidate (no per-poll os.Stat). // Embedded/virtual paths never touch the filesystem. +// Concurrent cold misses for the same key share one fn call (singleflight). func Resolve(key string, fn func() (string, error)) (string, error) { if path, ok := Get(key); ok { return path, nil } - path, err := fn() + v, err, _ := group.Do(key, func() (any, error) { + if path, ok := Get(key); ok { + return path, nil + } + path, err := fn() + if err != nil { + return "", err + } + Set(key, path) + return path, nil + }) if err != nil { return "", err } - Set(key, path) + path, _ := v.(string) return path, nil } diff --git a/internal/corebin/cache_test.go b/internal/corebin/cache_test.go index 349d858..4a1b78c 100644 --- a/internal/corebin/cache_test.go +++ b/internal/corebin/cache_test.go @@ -3,7 +3,10 @@ package corebin import ( "os" "path/filepath" + "sync" + "sync/atomic" "testing" + "time" ) func TestResolveTrustsCacheWithoutStat(t *testing.T) { @@ -35,6 +38,36 @@ func TestResolveTrustsCacheWithoutStat(t *testing.T) { } } +func TestResolveSingleflight(t *testing.T) { + Invalidate() + key := CacheKey(t.TempDir(), "naive") + var calls atomic.Int32 + var started sync.WaitGroup + started.Add(1) + fn := func() (string, error) { + calls.Add(1) + started.Wait() + time.Sleep(20 * time.Millisecond) + return "embedded-test-core", nil + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := Resolve(key, fn); err != nil { + t.Errorf("resolve: %v", err) + } + }() + } + time.Sleep(10 * time.Millisecond) + started.Done() + wg.Wait() + if calls.Load() != 1 { + t.Fatalf("expected 1 cold resolve, got %d", calls.Load()) + } +} + func TestVirtualEmbeddedAWG(t *testing.T) { Invalidate() key := CacheKey("/bin", "awg") diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go index f1bf8dd..e770a2c 100644 --- a/internal/netcheck/ping.go +++ b/internal/netcheck/ping.go @@ -57,20 +57,26 @@ func PingAll(ctx context.Context, targets []Target) []Result { if len(targets) < workers { workers = len(targets) } - sem := make(chan struct{}, workers) + jobs := make(chan int, len(targets)) + for i := range targets { + jobs <- i + } + close(jobs) + var wg sync.WaitGroup - for i, t := range targets { + for w := 0; w < workers; w++ { wg.Add(1) - go func(i int, t Target) { + go func() { defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - if strings.TrimSpace(t.Proxy) == "" { - out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} - return + for i := range jobs { + t := targets[i] + if strings.TrimSpace(t.Proxy) == "" { + out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} + continue + } + out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) } - out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) - }(i, t) + }() } wg.Wait() diff --git a/internal/update/update.go b/internal/update/update.go index deac06e..65d7769 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -22,7 +22,7 @@ const CurrentVersion = "3.8.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 = 4 +const BuildNumber = 5 // 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" diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh index 32017d2..82a6155 100755 --- a/scripts/build-macos-arm64.sh +++ b/scripts/build-macos-arm64.sh @@ -77,9 +77,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest() zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else "" dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else "" notes = ( - f"Navis {ver}+{build}: единый GUI Windows/macOS на glaze + HTTP /api " - f"(вместо WebView2 Bind); автоинкремент номера сборки при compile; " - f"артефакты Windows с версией в имени (Navis-x.y.z.b.exe)." + f"Navis {ver}+{build}: delta-poll UI (getState по rev); пауза опроса при скрытом окне; " + f"cancel lifecycle dock/watchdog; invalidate/лимит hostCache; " + f"PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool." ) paths = [ Path('dist/update.json'), diff --git a/server/update.json b/server/update.json index d6a6969..feae301 100644 --- a/server/update.json +++ b/server/update.json @@ -1,16 +1,16 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", + "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", + "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", "os": "windows", "arch": "amd64" }, diff --git a/versioninfo.json b/versioninfo.json index 6a6e18c..8778b48 100644 --- a/versioninfo.json +++ b/versioninfo.json @@ -4,13 +4,13 @@ "Major": 3, "Minor": 8, "Patch": 2, - "Build": 4 + "Build": 5 }, "ProductVersion": { "Major": 3, "Minor": 8, "Patch": 2, - "Build": 4 + "Build": 5 }, "FileFlagsMask": "3f", "FileFlags": "00", @@ -21,12 +21,12 @@ "StringFileInfo": { "CompanyName": "EvilFox", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", - "FileVersion": "3.8.2.4", + "FileVersion": "3.8.2.5", "InternalName": "Navis", "LegalCopyright": "Copyright (c) EvilFox", "OriginalFilename": "Navis.exe", "ProductName": "Navis", - "ProductVersion": "3.8.2.4", + "ProductVersion": "3.8.2.5", "Comments": "Open-source VPN/proxy client. https://evilfox.win/" }, "VarFileInfo": {