diff --git a/README.md b/README.md
index c3ad878..e25f3e1 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,13 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
+В 3.8.1:
+- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+- список серверов в UI не пересобирается без изменений; интервал опроса 4 с;
+- быстрее и точнее пинг (учитывается протокол профиля; лёгкий разбор AWG Endpoint);
+- AWG-прокси копирует трафик через буферный pool; PingAll и установка cores используют несколько ядер CPU;
+- 3.8.1+1: первая сборка линейки 3.8.1.
+
В 3.8.0:
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
diff --git a/build-macos.bat b/build-macos.bat
index efa883c..ad3771e 100644
--- a/build-macos.bat
+++ b/build-macos.bat
@@ -34,11 +34,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.0 -build 3.8.0.3 -arch arm64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.1 -build 3.8.1.1 -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.0 -build 3.8.0.3 -arch amd64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.1 -build 3.8.1.1 -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.0 -build 3.8.0.3 -arch universal
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.1 -build 3.8.1.1 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
diff --git a/cmd/vpnapp/main_darwin.go b/cmd/vpnapp/main_darwin.go
index 50d0ccc..6a5e091 100644
--- a/cmd/vpnapp/main_darwin.go
+++ b/cmd/vpnapp/main_darwin.go
@@ -3,13 +3,13 @@
package main
import (
- "bytes"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
+ "runtime"
"syscall"
"time"
@@ -18,6 +18,7 @@ import (
"vpnclient/internal/apphost"
"vpnclient/internal/config"
"vpnclient/internal/core"
+ "vpnclient/internal/logbuf"
"vpnclient/internal/update"
)
@@ -27,6 +28,7 @@ func main() {
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
+ log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
@@ -41,7 +43,7 @@ func main() {
if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err)
}
- logBuf := &bytes.Buffer{}
+ logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalf("%v", err)
diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go
index 30ce66f..ffa0cb7 100644
--- a/cmd/vpnapp/main_windows.go
+++ b/cmd/vpnapp/main_windows.go
@@ -3,11 +3,11 @@
package main
import (
- "bytes"
"fmt"
"log"
"os"
"path/filepath"
+ "runtime"
"time"
"unsafe"
@@ -18,6 +18,7 @@ import (
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
+ "vpnclient/internal/logbuf"
"vpnclient/internal/update"
)
@@ -28,6 +29,7 @@ func main() {
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
+ log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
@@ -44,7 +46,7 @@ func main() {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
- logBuf := &bytes.Buffer{}
+ logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis
index 272f4ab..9627cd8 100755
Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ
diff --git a/dist/navis-release/darwin-arm64/Navis-cli b/dist/navis-release/darwin-arm64/Navis-cli
index 2ebd472..cbe97d3 100755
Binary files a/dist/navis-release/darwin-arm64/Navis-cli and b/dist/navis-release/darwin-arm64/Navis-cli differ
diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip
index 05f0dc7..769c57c 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ
diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg
index 59a595b..717bd22 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ
diff --git a/dist/navis-release/darwin-arm64/Navis.version b/dist/navis-release/darwin-arm64/Navis.version
index 1346430..e9781d3 100644
--- a/dist/navis-release/darwin-arm64/Navis.version
+++ b/dist/navis-release/darwin-arm64/Navis.version
@@ -1 +1 @@
-3.8.0+3
+3.8.1+1
diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION
index e63c5be..d37c27a 100644
--- a/dist/navis-release/darwin-arm64/VERSION
+++ b/dist/navis-release/darwin-arm64/VERSION
@@ -1 +1 @@
-3.8.0.3
+3.8.1.1
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index 8817460..1e4818b 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.0",
- "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
+ "version": "3.8.1",
+ "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
+ "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
- "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
- "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
+ "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05",
+ "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
diff --git a/dist/update.json b/dist/update.json
index 8817460..1e4818b 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.0",
- "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
+ "version": "3.8.1",
+ "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
+ "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
- "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
- "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
+ "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05",
+ "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
diff --git a/internal/apphost/app.go b/internal/apphost/app.go
index faa38a2..e779194 100644
--- a/internal/apphost/app.go
+++ b/internal/apphost/app.go
@@ -1,7 +1,6 @@
package apphost
import (
- "bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -18,6 +17,8 @@ import (
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
+ "vpnclient/internal/corebin"
+ "vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
@@ -31,7 +32,7 @@ type App struct {
mu sync.Mutex
Mgr *core.Manager
CfgPath string
- LogBuf *bytes.Buffer
+ LogBuf *logbuf.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
@@ -67,7 +68,7 @@ type PingBestResult struct {
Connected bool `json:"connected"`
}
-func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
+func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
return &App{
Mgr: mgr,
CfgPath: cfgPath,
@@ -89,71 +90,53 @@ func (a *App) GetEditState() (UIState, error) {
}
func (a *App) getState(includeSecrets bool) (UIState, error) {
+ // Hold App.mu only for fields mutated by update/ping handlers.
a.mu.Lock()
- defer a.mu.Unlock()
+ upd := a.UpdateStatus
+ pings := append([]netcheck.Result(nil), a.Pings...)
+ cfgPath := a.CfgPath
+ a.mu.Unlock()
- st := a.Mgr.Status()
- cfg := a.Mgr.Config()
- proxy := ""
- active := cfg.Active
- if p, err := cfg.ActiveProfile(); err == nil {
- proxy = p.Proxy
- active = p.Name
- }
+ poll := a.Mgr.PollUI(includeSecrets)
+ st := poll.Status
+ proxy := poll.ActiveProxy
if !includeSecrets {
proxy = config.RedactProxyURI(proxy)
}
- corePath := ""
- coreReady := false
- if p, err := cfg.ActiveProfile(); err == nil {
- switch p.Protocol {
- case config.ProtocolHysteria2:
- if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- case config.ProtocolAWG:
- if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
- if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- default:
- if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- }
- } else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
- corePath, coreReady = path, true
- }
- hy2 := a.Mgr.ActiveHy2Options()
+ hy2 := poll.Hy2
if !includeSecrets {
hy2.ObfsPassword = ""
}
+
+ corePath, coreReady := resolveCoreCached(poll.BinDir, poll.ActiveProtocol)
+
+ profiles := poll.Profiles
+ if includeSecrets {
+ // Editor needs state.Proxy; list still omits other profiles' URIs.
+ profiles = config.RedactProfileList(profiles)
+ }
+
out := UIState{
Connected: st.Connected,
Profile: st.Profile,
- ActiveProfile: active,
+ ActiveProfile: poll.Active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
- SystemProxy: cfg.SystemProxy,
+ SystemProxy: poll.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
- ConfigPath: a.CfgPath,
- Profiles: config.RedactProfileList(cfg.ListProfiles()),
+ ConfigPath: cfgPath,
+ Profiles: profiles,
Version: update.DisplayVersion(),
- Update: a.UpdateStatus,
- Pings: append([]netcheck.Result(nil), a.Pings...),
- Subscription: cfg.SubscriptionURL,
+ Update: upd,
+ Pings: pings,
+ Subscription: poll.Subscription,
Hy2: hy2,
}
if out.Protocol == "" {
- if p, err := cfg.ActiveProfile(); err == nil {
- out.Protocol = string(p.Protocol)
- }
+ out.Protocol = string(poll.ActiveProtocol)
}
if st.Connected {
out.SystemProxy = st.SystemProxy
@@ -161,6 +144,25 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
return out, nil
}
+func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
+ key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
+ var err error
+ switch proto {
+ case config.ProtocolHysteria2:
+ path, err = corebin.Resolve(key, func() (string, error) { return hysteria2.ResolveBinary(binDir) })
+ case config.ProtocolAWG:
+ path, err = corebin.Resolve(key, func() (string, error) { return awg.ResolveBinary(binDir) })
+ case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
+ path, err = corebin.Resolve(key, func() (string, error) { return xray.ResolveBinary(binDir) })
+ default:
+ path, err = corebin.Resolve(key, func() (string, error) { return naive.ResolveBinary(binDir) })
+ }
+ if err != nil {
+ return "", false
+ }
+ return path, true
+}
+
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
diff --git a/internal/appui/app.js b/internal/appui/app.js
index a6bf07c..f16876d 100644
--- a/internal/appui/app.js
+++ b/internal/appui/app.js
@@ -202,6 +202,22 @@
(pings || []).forEach((p) => { pingMap[p.name] = p; });
}
+ let listFP = "";
+
+ function profilesFingerprint(state) {
+ const parts = [
+ state.connected ? "1" : "0",
+ state.active_profile || state.profile || "",
+ ];
+ (state.profiles || []).forEach((p) => {
+ parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || ""));
+ });
+ (state.pings || []).forEach((p) => {
+ parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
+ });
+ return parts.join("\n");
+ }
+
function fillProfiles(list, active) {
profiles = list || [];
const cur = profile.value;
@@ -361,7 +377,11 @@
btn.disabled = busy;
rememberPings(state.pings || []);
- fillProfiles(state.profiles || [], state.active_profile || state.profile);
+ const fp = profilesFingerprint(state);
+ if (syncForm || fp !== listFP) {
+ listFP = fp;
+ fillProfiles(state.profiles || [], state.active_profile || state.profile);
+ }
renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
@@ -641,5 +661,5 @@
setMeta(String(e), "err");
}
})();
- setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
+ setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
diff --git a/internal/appui/index.html b/internal/appui/index.html
index 030769d..783722d 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -46,7 +46,7 @@
Navis
- 3.8.0
+ 3.8.1
Быстрый клиент · Naive · Hy2 · AWG · Xray
diff --git a/internal/config/profiles.go b/internal/config/profiles.go
index 07e9926..6b7afc3 100644
--- a/internal/config/profiles.go
+++ b/internal/config/profiles.go
@@ -29,6 +29,20 @@ func (c *Config) ListProfiles() []ProfileInfo {
return out
}
+// ListProfilesForPoll is like ListProfiles but omits Proxy URIs (cheaper idle polls).
+func (c *Config) ListProfilesForPoll() []ProfileInfo {
+ out := make([]ProfileInfo, 0, len(c.Profiles))
+ for _, p := range c.Profiles {
+ out = append(out, ProfileInfo{
+ Name: p.Name,
+ Protocol: string(p.Protocol),
+ Host: proxyHost(p.Proxy),
+ Active: p.Name == c.Active,
+ })
+ }
+ return out
+}
+
// RedactProfileList clears Proxy URIs from a profile list (host/protocol remain).
// Use for periodic UI polls so credentials are not echoed for every profile.
func RedactProfileList(in []ProfileInfo) []ProfileInfo {
diff --git a/internal/core/manager.go b/internal/core/manager.go
index 074be4f..3732b15 100644
--- a/internal/core/manager.go
+++ b/internal/core/manager.go
@@ -14,6 +14,7 @@ import (
"time"
"vpnclient/internal/config"
+ "vpnclient/internal/corebin"
"vpnclient/internal/linknorm"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
@@ -277,6 +278,76 @@ func (m *Manager) Config() *config.Config {
return m.cfg.Clone()
}
+// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
+type UIPoll struct {
+ Status Status
+ Active string
+ SystemProxy bool
+ Subscription string
+ ActiveProxy string
+ ActiveProtocol config.Protocol
+ Profiles []config.ProfileInfo
+ Hy2 Hy2Options
+ BinDir string
+}
+
+// PollUI gathers status + profile list under one lock without Config().Clone().
+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"},
+ }
+ if m.cfg != nil {
+ out.Active = m.cfg.Active
+ out.SystemProxy = m.cfg.SystemProxy
+ out.Subscription = m.cfg.SubscriptionURL
+ if includeSecrets {
+ out.Profiles = m.cfg.ListProfiles()
+ } else {
+ out.Profiles = m.cfg.ListProfilesForPoll()
+ }
+ if p, err := m.cfg.ActiveProfile(); err == nil {
+ out.Active = p.Name
+ out.ActiveProxy = p.Proxy
+ out.ActiveProtocol = p.Protocol
+ out.Hy2 = Hy2Options{
+ Congestion: p.Hy2Congestion,
+ BBRProfile: p.Hy2BBRProfile,
+ BandwidthUp: p.Hy2BandwidthUp,
+ BandwidthDown: p.Hy2BandwidthDown,
+ Obfs: p.Hy2Obfs,
+ ObfsPassword: p.Hy2ObfsPassword,
+ SNI: p.Hy2SNI,
+ Insecure: p.Hy2Insecure,
+ PinSHA256: p.Hy2PinSHA256,
+ FastOpen: p.Hy2FastOpen,
+ Lazy: p.Hy2Lazy,
+ HopInterval: p.Hy2HopInterval,
+ }
+ }
+ }
+ if m.engine != nil && m.engine.Running() {
+ out.Status.Connected = true
+ if m.profile != nil {
+ out.Status.Profile = m.profile.Name
+ out.Status.Protocol = m.profile.Protocol
+ }
+ if hp, ok := m.engine.LocalHTTPProxy(); ok {
+ out.Status.HTTPProxy = hp
+ }
+ if sp, ok := m.engine.LocalSOCKSProxy(); ok {
+ out.Status.SOCKSProxy = sp
+ }
+ }
+ return out
+}
+
func (m *Manager) SetSystemProxy(enabled bool) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -572,24 +643,37 @@ func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
}
m.mu.Unlock()
+ var (
+ path string
+ err error
+ )
switch proto {
case config.ProtocolHysteria2:
- return hysteria2.EnsureBinary(binDir)
+ path, err = hysteria2.EnsureBinary(binDir)
case config.ProtocolAWG:
- return awg.EnsureBinary(binDir)
+ path, err = awg.EnsureBinary(binDir)
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
- return xray.EnsureBinary(binDir)
+ path, err = xray.EnsureBinary(binDir)
default:
- return naive.EnsureBinary(binDir)
+ path, err = naive.EnsureBinary(binDir)
+ proto = config.ProtocolNaive
}
+ if err != nil {
+ return "", err
+ }
+ corebin.Set(corebin.CacheKey(binDir, corebin.ProtoKey(string(proto))), path)
+ return path, nil
}
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
+// Downloads run in parallel (one goroutine per core) to use multiple CPU cores / network.
func (m *Manager) EnsureAllCores() (map[string]string, error) {
m.mu.Lock()
binDir := m.binDir
m.mu.Unlock()
+ corebin.Invalidate()
+
type item struct {
key string
path string
@@ -623,6 +707,7 @@ func (m *Manager) EnsureAllCores() (map[string]string, error) {
continue
}
out[it.key] = it.path
+ corebin.Set(corebin.CacheKey(binDir, it.key), it.path)
}
return out, first
}
diff --git a/internal/corebin/cache.go b/internal/corebin/cache.go
new file mode 100644
index 0000000..7984f9e
--- /dev/null
+++ b/internal/corebin/cache.go
@@ -0,0 +1,73 @@
+package corebin
+
+import (
+ "os"
+ "sync"
+)
+
+var (
+ mu sync.Mutex
+ cache = map[string]string{}
+)
+
+// CacheKey identifies a resolved core binary for a binDir + protocol.
+func CacheKey(binDir, proto string) string {
+ return binDir + "\x00" + proto
+}
+
+// Get returns a cached absolute path if present.
+func Get(key string) (string, bool) {
+ mu.Lock()
+ defer mu.Unlock()
+ p, ok := cache[key]
+ return p, ok
+}
+
+// Set stores a resolved path.
+func Set(key, path string) {
+ if key == "" || path == "" {
+ return
+ }
+ mu.Lock()
+ cache[key] = path
+ mu.Unlock()
+}
+
+// Invalidate clears all cached paths (call after EnsureCore / binDir change).
+func Invalidate() {
+ mu.Lock()
+ cache = map[string]string{}
+ mu.Unlock()
+}
+
+// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed.
+func Resolve(key string, fn func() (string, error)) (string, error) {
+ if path, ok := Get(key); ok {
+ if _, err := os.Stat(path); err == nil {
+ return path, nil
+ }
+ mu.Lock()
+ delete(cache, key)
+ mu.Unlock()
+ }
+ path, err := fn()
+ if err != nil {
+ return "", err
+ }
+ Set(key, path)
+ return path, nil
+}
+
+// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
+func ProtoKey(proto string) string {
+ switch proto {
+ case "hysteria2":
+ return "hysteria2"
+ case "awg":
+ return "awg"
+ case "vless", "vmess", "trojan", "xray":
+ return "xray"
+ default:
+ return "naive"
+ }
+}
diff --git a/internal/corebin/cache_test.go b/internal/corebin/cache_test.go
new file mode 100644
index 0000000..abf1e34
--- /dev/null
+++ b/internal/corebin/cache_test.go
@@ -0,0 +1,45 @@
+package corebin
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveCacheAndStale(t *testing.T) {
+ Invalidate()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "core")
+ if err := os.WriteFile(path, []byte("x"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ key := CacheKey(dir, "naive")
+ calls := 0
+ fn := func() (string, error) {
+ calls++
+ return path, nil
+ }
+ if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
+ t.Fatalf("first: path=%q calls=%d err=%v", p, calls, err)
+ }
+ if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
+ t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err)
+ }
+ _ = os.Remove(path)
+ if _, err := Resolve(key, fn); err != nil {
+ // fn returns removed path; Resolve still succeeds but next Stat misses
+ t.Fatalf("unexpected err after delete before recreate: %v", err)
+ }
+ if calls != 2 {
+ t.Fatalf("expected refresh call, got %d", calls)
+ }
+ if err := os.WriteFile(path, []byte("y"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if p, err := Resolve(key, fn); err != nil || p != path {
+ t.Fatalf("after recreate: %q %v", p, err)
+ }
+ if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" {
+ t.Fatal("ProtoKey")
+ }
+}
\ No newline at end of file
diff --git a/internal/logbuf/buffer.go b/internal/logbuf/buffer.go
new file mode 100644
index 0000000..c10c814
--- /dev/null
+++ b/internal/logbuf/buffer.go
@@ -0,0 +1,59 @@
+package logbuf
+
+import (
+ "bytes"
+ "sync"
+)
+
+const DefaultMax = 256 << 10 // 256 KiB
+
+// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
+type Buffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+ max int
+}
+
+func New(max int) *Buffer {
+ if max <= 0 {
+ max = DefaultMax
+ }
+ return &Buffer{max: max}
+}
+
+func (b *Buffer) Write(p []byte) (int, error) {
+ if b == nil {
+ return len(p), nil
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.buf.Len()+len(p) > b.max {
+ // Drop oldest half to keep recent diagnostics.
+ keep := b.buf.Bytes()
+ if len(keep) > b.max/2 {
+ keep = keep[len(keep)-b.max/2:]
+ }
+ b.buf.Reset()
+ _, _ = b.buf.Write(keep)
+ _, _ = b.buf.WriteString("\n… log truncated …\n")
+ }
+ return b.buf.Write(p)
+}
+
+func (b *Buffer) String() string {
+ if b == nil {
+ return ""
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+func (b *Buffer) Len() int {
+ if b == nil {
+ return 0
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.Len()
+}
diff --git a/internal/logbuf/buffer_test.go b/internal/logbuf/buffer_test.go
new file mode 100644
index 0000000..c04322b
--- /dev/null
+++ b/internal/logbuf/buffer_test.go
@@ -0,0 +1,29 @@
+package logbuf
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestBufferConcurrentWriteAndCap(t *testing.T) {
+ b := New(64)
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 40; j++ {
+ _, _ = b.Write([]byte("abcdefghij"))
+ }
+ }()
+ }
+ wg.Wait()
+ if b.Len() > 64+40 { // allow truncate marker overhead
+ t.Fatalf("len=%d exceeds soft cap", b.Len())
+ }
+ s := b.String()
+ if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
+ t.Fatalf("unexpected content %q", s)
+ }
+}
diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go
index 60cbb74..96540fa 100644
--- a/internal/netcheck/ping.go
+++ b/internal/netcheck/ping.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
+ "runtime"
"sort"
"strings"
"sync"
@@ -44,7 +45,14 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) == 0 {
return out
}
- workers := 12
+ // Scale with available OS threads (GOMAXPROCS); keep a useful floor/ceiling.
+ workers := runtime.GOMAXPROCS(0) * 2
+ if workers < 8 {
+ workers = 8
+ }
+ if workers > 32 {
+ workers = 32
+ }
if len(targets) < workers {
workers = len(targets)
}
@@ -99,7 +107,8 @@ func BestOK(results []Result) (Result, bool) {
}
// PingProfile pings using protocol hints when available.
-// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
+// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
+// When proto is set, Detect is skipped (trust stored protocol).
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
res := Result{Name: name}
if strings.TrimSpace(proxyURI) == "" {
@@ -107,9 +116,22 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
- hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
- isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
- isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
+ var hy2, isAWG, isXray bool
+ switch proto {
+ case config.ProtocolHysteria2:
+ hy2 = true
+ case config.ProtocolAWG:
+ isAWG = true
+ case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
+ isXray = true
+ case config.ProtocolNaive:
+ // TCP host parse below
+ default:
+ hy2 = hysteria2.Detect(proxyURI)
+ isAWG = awg.Detect(proxyURI)
+ isXray = xray.Detect(proxyURI)
+ }
+
var host, port string
if isAWG {
h, p, err := awg.HostPort(proxyURI)
diff --git a/internal/protocols/awg/conf.go b/internal/protocols/awg/conf.go
index 2fda8d8..f3feeea 100644
--- a/internal/protocols/awg/conf.go
+++ b/internal/protocols/awg/conf.go
@@ -454,7 +454,11 @@ func (c *Conf) validate() error {
}
// HostPort returns UDP endpoint host and port for ping.
+// Prefers a light Endpoint=/URI extract; falls back to full Parse for JSON/vpn:// blobs.
func HostPort(raw string) (host, port string, err error) {
+ if h, p, ok := lightEndpoint(raw); ok {
+ return h, p, nil
+ }
c, err := Parse(raw)
if err != nil {
return "", "", err
@@ -466,6 +470,67 @@ func HostPort(raw string) (host, port string, err error) {
return h, p, nil
}
+func lightEndpoint(raw string) (host, port string, ok bool) {
+ raw = sanitizeShare(raw)
+ if raw == "" {
+ return "", "", false
+ }
+ lower := strings.ToLower(raw)
+ switch {
+ case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
+ strings.HasPrefix(lower, "wireguard://"):
+ // URI host:port only — no full config decode.
+ rest := raw
+ if i := strings.Index(rest, "://"); i >= 0 {
+ rest = rest[i+3:]
+ }
+ if at := strings.Index(rest, "@"); at >= 0 {
+ rest = rest[at+1:]
+ }
+ if i := strings.IndexAny(rest, "/?#"); i >= 0 {
+ rest = rest[:i]
+ }
+ rest = strings.TrimSpace(rest)
+ if rest == "" {
+ return "", "", false
+ }
+ h, p, err := net.SplitHostPort(rest)
+ if err != nil {
+ return rest, "51820", true
+ }
+ return h, p, true
+ }
+ if strings.Contains(lower, "endpoint") {
+ for _, line := range strings.Split(raw, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
+ continue
+ }
+ low := strings.ToLower(line)
+ if !strings.HasPrefix(low, "endpoint") {
+ continue
+ }
+ _, val, cutOK := strings.Cut(line, "=")
+ if !cutOK {
+ _, val, cutOK = strings.Cut(line, ":")
+ }
+ if !cutOK {
+ continue
+ }
+ val = strings.TrimSpace(val)
+ if val == "" {
+ continue
+ }
+ h, p, err := net.SplitHostPort(val)
+ if err != nil {
+ return val, "51820", true
+ }
+ return h, p, true
+ }
+ }
+ return "", "", false
+}
+
// lookupIP is overridable in tests.
var lookupIP = defaultLookupIP
diff --git a/internal/protocols/awg/conf_test.go b/internal/protocols/awg/conf_test.go
index ad25021..c9f597e 100644
--- a/internal/protocols/awg/conf_test.go
+++ b/internal/protocols/awg/conf_test.go
@@ -236,3 +236,29 @@ func indexOf(s, sub string) int {
}
return -1
}
+
+func TestHostPortLightINI(t *testing.T) {
+ raw := `[Interface]
+PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+Address = 10.8.0.2/32
+
+[Peer]
+PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
+Endpoint = 203.0.113.10:51820
+AllowedIPs = 0.0.0.0/0
+`
+ h, p, err := HostPort(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h != "203.0.113.10" || p != "51820" {
+ t.Fatalf("got %s:%s", h, p)
+ }
+ h2, p2, err := HostPort("awg://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=@9.9.9.9:41421")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h2 != "9.9.9.9" || p2 != "41421" {
+ t.Fatalf("uri got %s:%s", h2, p2)
+ }
+}
diff --git a/internal/protocols/awg/proxy.go b/internal/protocols/awg/proxy.go
index 69c8098..69416e3 100644
--- a/internal/protocols/awg/proxy.go
+++ b/internal/protocols/awg/proxy.go
@@ -268,17 +268,24 @@ func handleHTTPProxy(client net.Conn, dial DialFunc) error {
func relay(a, b net.Conn) error {
errc := make(chan error, 2)
- go func() {
- _, err := io.Copy(a, b)
+ copyOne := func(dst, src net.Conn) {
+ bufp := copyBufPool.Get().(*[]byte)
+ defer copyBufPool.Put(bufp)
+ _, err := io.CopyBuffer(dst, src, *bufp)
errc <- err
- }()
- go func() {
- _, err := io.Copy(b, a)
- errc <- err
- }()
+ }
+ go copyOne(a, b)
+ go copyOne(b, a)
err := <-errc
_ = a.Close()
_ = b.Close()
<-errc
return err
}
+
+var copyBufPool = sync.Pool{
+ New: func() any {
+ b := make([]byte, 32*1024)
+ return &b
+ },
+}
diff --git a/internal/update/update.go b/internal/update/update.go
index d2cbe4d..25da83b 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -18,11 +18,11 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
-const CurrentVersion = "3.8.0"
+const CurrentVersion = "3.8.1"
// 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 = 3
+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"
diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh
index 2022e5c..f974599 100755
--- a/scripts/build-macos-arm64.sh
+++ b/scripts/build-macos-arm64.sh
@@ -65,8 +65,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}: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); "
- f"Mac .app zip update; EnsureCore lock; getState без секретов."
+ f"Navis {ver}+{build}: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, "
+ f"список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; "
+ f"параллельная работа на нескольких ядрах CPU."
)
paths = [
Path('dist/update.json'),
diff --git a/server/update.json b/server/update.json
index 8817460..1e4818b 100644
--- a/server/update.json
+++ b/server/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.0",
- "notes": "Navis 3.8.0+3: UDP ping Hy2/AWG (soft-ok без ложных «недоступен»); Mac .app zip update; EnsureCore lock; getState без секретов.",
+ "version": "3.8.1",
+ "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "bd507de6f434f53f95954c8e411cc1f8bb526d5b5353b1242de5bc9fb673670f",
+ "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
- "zip_sha256": "2aaac597f7246c67fc825cd11f49d2b4fc0fa6bd9b89a1b1e4290e6a96c01de4",
- "dmg_sha256": "26ddacc3b3bc372aa4833336e60fb32862fb30ee555a8a6e460811f45f9f5cee"
+ "zip_sha256": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05",
+ "dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
diff --git a/versioninfo.json b/versioninfo.json
index b4b8007..dd1ef42 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -3,14 +3,14 @@
"FileVersion": {
"Major": 3,
"Minor": 8,
- "Patch": 0,
- "Build": 3
+ "Patch": 1,
+ "Build": 1
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
- "Patch": 0,
- "Build": 3
+ "Patch": 1,
+ "Build": 1
},
"FileFlagsMask": "3f",
"FileFlags": "00",
@@ -21,12 +21,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
- "FileVersion": "3.8.0.3",
+ "FileVersion": "3.8.1.1",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
- "ProductVersion": "3.8.0.3",
+ "ProductVersion": "3.8.1.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {