Release 3.8.1.1: lighter idle UI poll, core cache, multicore ping.

Reduce idle CPU (cheap PollUI, binary cache, logbuf cap, DOM fingerprint),
speed ping/AWG HostPort, CopyBuffer pool; ship arm64 build and update notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-07-30 02:27:17 +03:00
co-authored by Cursor
parent bec6c8392d
commit 52594e7b95
29 changed files with 559 additions and 100 deletions
+7
View File
@@ -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);
+3 -3
View File
@@ -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:
+4 -2
View File
@@ -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)
+4 -2
View File
@@ -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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3.8.0+3
3.8.1+1
+1 -1
View File
@@ -1 +1 @@
3.8.0.3
3.8.1.1
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+49 -47
View File
@@ -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()
+22 -2
View File
@@ -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);
+1 -1
View File
@@ -46,7 +46,7 @@
<div class="brand-wrap">
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver" id="badgeVer">3.8.0</span>
<span class="badge-ver" id="badgeVer">3.8.1</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
+14
View File
@@ -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 {
+89 -4
View File
@@ -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
}
+73
View File
@@ -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"
}
}
+45
View File
@@ -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")
}
}
+59
View File
@@ -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()
}
+29
View File
@@ -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)
}
}
+27 -5
View File
@@ -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)
+65
View File
@@ -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
+26
View File
@@ -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)
}
}
+14 -7
View File
@@ -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
},
}
+2 -2
View File
@@ -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"
+3 -2
View File
@@ -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'),
+5 -5
View File
@@ -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",
+6 -6
View File
@@ -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": {