Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

Faster unchanged polls; orphan-core cleanup on listen ports; clearer busy-port errors; cores only from binDir; incremental server list; CSP without Google Fonts; single-instance activates existing window; close-to-tray without kill-others or balloon.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 17:27:47 +03:00
co-authored by Cursor
parent fed1ace33e
commit 57d719f02f
45 changed files with 876 additions and 285 deletions
+5 -1
View File
@@ -257,7 +257,11 @@ https://evilfox.win/
- 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.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback;
- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS).
- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS);
- 3.8.2+8: дешёвый idle rev-cache; иконка без 400ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей.
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3080207
versionName = "3.8.2+7"
versionCode = 3080211
versionName = "3.8.2+11"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+3 -3
View File
@@ -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.7 -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.11 -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.7 -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.11 -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.7 -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.11 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
+20 -4
View File
@@ -17,6 +17,7 @@ import (
"vpnclient/internal/core"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf"
"vpnclient/internal/singleinstance"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
@@ -27,6 +28,16 @@ func main() {
}
update.CleanupStaleDownloads()
releaseInstance, err := singleinstance.Lock("Navis")
if err != nil {
if err == singleinstance.ErrAlreadyRunning {
// Existing window was activated when possible.
return
}
fatalf("Не удалось получить single-instance lock:\n%v", err)
}
defer releaseInstance()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
@@ -66,14 +77,18 @@ func main() {
fatalf("listen: %v", err)
}
a.APIToken = token
srv := &http.Server{Handler: a.Handler()}
srv := &http.Server{
Handler: a.Handler(),
ReadHeaderTimeout: 5 * time.Second,
// No WriteTimeout: SSE /api/events is long-lived.
}
go func() {
_ = srv.Serve(ln)
}()
a.StartBackground()
dockicon.OnIconChange = trayhost.SetConnectedIcon
trayhost.Start("Navis", trayhost.Hooks{
trayOK := trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Show: showMainWindow,
@@ -112,8 +127,9 @@ func main() {
w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone)
decorateWindow(w)
// Close→hide only when tray is alive; otherwise X would leave invisible zombies.
decorateWindow(w, trayOK)
w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL)
log.Printf("Navis UI: %s (tray=%v hideOnClose=%v)", uiURL, trayOK, trayOK)
w.Run()
}
+3 -2
View File
@@ -27,9 +27,10 @@ func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt, syscall.SIGTERM}
}
func decorateWindow(w glaze.WebView) {
// macOS Dock/app icon comes from the .app bundle; nothing to apply here.
func decorateWindow(w glaze.WebView, hideOnClose bool) {
// macOS Dock/app icon comes from the .app bundle; close-to-hide is Windows-only.
_ = w
_ = hideOnClose
}
func showMainWindow() {}
+3 -1
View File
@@ -64,14 +64,16 @@ func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
func decorateWindow(w glaze.WebView) {
func decorateWindow(w glaze.WebView, hideOnClose bool) {
if w == nil {
return
}
hwnd := w.Window()
applyWindowIcon(hwnd)
dockicon.BindWindow(hwnd)
if hideOnClose {
installCloseToHide(hwnd)
}
}
func installCloseToHide(hwnd unsafe.Pointer) {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+4 -4
View File
@@ -1,16 +1,16 @@
{
{
"version": "3.8.2",
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"os": "windows",
"arch": "amd64"
},
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -1,16 +1,16 @@
{
{
"version": "3.8.2",
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"os": "windows",
"arch": "amd64"
},
+66 -45
View File
@@ -44,6 +44,9 @@ type App struct {
ctx context.Context
cancel context.CancelFunc
events *eventBroker
revMu sync.Mutex
cachedRev string
cachedConn bool
}
type UIState struct {
@@ -111,17 +114,35 @@ func (a *App) GetState() (UIState, error) {
// 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) {
if since != "" {
a.revMu.Lock()
cachedRev, cachedConn := a.cachedRev, a.cachedConn
a.revMu.Unlock()
if cachedRev != "" && cachedRev == since && a.Mgr.Status().Connected == cachedConn {
return UIState{Rev: cachedRev, Unchanged: true}, nil
}
}
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
a.revMu.Lock()
a.cachedRev = out.Rev
a.cachedConn = out.Connected
a.revMu.Unlock()
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
}
func (a *App) invalidateRevCache() {
a.revMu.Lock()
a.cachedRev = ""
a.revMu.Unlock()
}
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
out, err := a.getState(true)
@@ -238,24 +259,34 @@ func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveActiveProfile(name, proxy)
err := a.Mgr.SaveActiveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveProfile(name, proxy)
err := a.Mgr.SaveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) SelectProfile(name string) error {
@@ -271,8 +302,12 @@ func (a *App) SelectProfile(name string) error {
func (a *App) DeleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.DeleteProfile(name)
err := a.Mgr.DeleteProfile(name)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) Connect() error { return a.ConnectProfile("") }
@@ -368,17 +403,21 @@ func (a *App) ProbeTunnel() error {
}
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
return a.Mgr.SavePrefs(core.Prefs{
err := a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect,
SystemProxy: systemProxy,
})
if err == nil {
a.NotifyState("prefs")
}
return err
}
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync.
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and core warmup.
func (a *App) StartBackground() {
go a.watchdogLoop()
go a.dockIconLoop()
dockicon.SetConnected(false)
go a.warmActiveCore()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
@@ -408,27 +447,7 @@ func (a *App) warmActiveCore() {
// Soft fail — Connect will surface the error if the user tries.
return
}
}
func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
t := time.NewTicker(400 * time.Millisecond)
defer t.Stop()
for {
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)
}
}
}
a.NotifyState("cores")
}
func (a *App) watchdogLoop() {
@@ -443,6 +462,8 @@ func (a *App) watchdogLoop() {
continue
}
prefs := a.Mgr.Prefs()
dockicon.SetConnected(false)
a.NotifyState("disconnected")
if !prefs.AutoReconnect {
continue
}
@@ -465,17 +486,26 @@ func (a *App) InstallCore() (string, error) {
for k, v := range paths {
parts = append(parts, k+": "+v)
}
a.NotifyState("cores")
return strings.Join(parts, " | "), nil
}
func (a *App) SaveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.SaveHy2Options(opts)
err := a.Mgr.SaveHy2Options(opts)
a.mu.Unlock()
if err == nil {
a.NotifyState("hy2")
}
return err
}
func (a *App) ImportSubscription(rawURL string) (int, error) {
return a.Mgr.ImportSubscription(rawURL)
n, err := a.Mgr.ImportSubscription(rawURL)
if err == nil {
a.NotifyState("subscription")
}
return n, err
}
func (a *App) PingServers() ([]netcheck.Result, error) {
@@ -540,24 +570,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
}
func (a *App) runPings() ([]netcheck.Result, error) {
a.mu.Lock()
list := a.Mgr.Profiles()
a.mu.Unlock()
targets := make([]netcheck.Target, 0, len(list))
for _, p := range list {
targets = append(targets, netcheck.Target{
Name: p.Name,
Protocol: config.Protocol(p.Protocol),
Proxy: p.Proxy,
})
}
targets := a.Mgr.PingTargets()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, targets)
a.mu.Lock()
a.Pings = out
a.mu.Unlock()
a.NotifyState("pings")
return out, nil
}
@@ -573,6 +593,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
}
a.UpdateStatus = st
a.mu.Unlock()
a.NotifyState("update")
return st, nil
}
+6 -2
View File
@@ -47,9 +47,13 @@ func (b *eventBroker) publish(payload []byte) {
}
}
// NotifyState pushes a small SSE payload so the UI can refresh immediately.
// NotifyState invalidates the idle rev cache and pushes a small SSE payload.
func (a *App) NotifyState(kind string) {
if a == nil || a.events == nil {
if a == nil {
return
}
a.invalidateRevCache()
if a.events == nil {
return
}
if kind == "" {
+9 -7
View File
@@ -42,6 +42,8 @@
--ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7);
--font-ui: "Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
--font-display: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif;
}
html[data-theme="dark"] {
--ink: #e6f4ee;
@@ -90,7 +92,7 @@
html, body {
margin: 0;
min-height: 100%;
font-family: Figtree, sans-serif;
font-family: var(--font-ui);
color: var(--ink);
background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease);
@@ -130,7 +132,7 @@
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Sora, sans-serif;
font-family: var(--font-display);
display: block;
margin-bottom: 4px;
font-size: .95rem;
@@ -199,7 +201,7 @@
flex-wrap: wrap;
}
.brand {
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.045em;
@@ -319,7 +321,7 @@
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
@@ -528,7 +530,7 @@
width: 100%;
min-height: 52px;
font-size: 1.02rem;
font-family: Sora, sans-serif;
font-family: var(--font-display);
}
.actions { display: grid; gap: 8px; }
@@ -749,7 +751,7 @@
background: var(--shop-bg);
}
.shop h3 {
font-family: Sora, sans-serif;
font-family: var(--font-display);
font-size: 1rem;
letter-spacing: -0.02em;
margin: 0 0 5px;
@@ -806,7 +808,7 @@
color: var(--ink);
}
.modal h2 {
font-family: Sora, sans-serif;
font-family: var(--font-display);
margin: 0 0 12px;
font-size: 1.2rem;
letter-spacing: -.02em;
+65 -40
View File
@@ -262,69 +262,94 @@
});
}
function renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const all = orderedProfiles();
const list = filteredProfiles();
if (!all.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку";
serverList.appendChild(empty);
return;
function pingLabel(pr) {
if (pr && pr.ok) {
return {
cls: "ms " + msClass(pr.ms, true),
text: (pr.soft ? "~" : "") + pr.ms + " ms",
title: pr.soft ? "soft-up (UDP без ответа) — не выбирается как «Лучший»" : ""
};
}
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Ничего не найдено";
serverList.appendChild(empty);
return;
if (pr && pr.error) {
return { cls: "ms bad", text: "—", title: pr.error };
}
return { cls: "ms", text: "…", title: "" };
}
list.forEach((p) => {
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (p.name === active ? " active" : "");
row.dataset.name = p.name;
function ensureServerRow(p) {
let row = serverList.querySelector('.server-row[data-name="' + CSS.escape(p.name) + '"]');
if (row) return row;
row = document.createElement("button");
row.type = "button";
row.className = "server-row";
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
left.appendChild(nameEl);
left.appendChild(sub);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
const ms = document.createElement("span");
const pr = pingMap[p.name];
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms";
if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "…";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
return row;
}
function renderServerList(activeName) {
const active = activeName || profile.value;
const all = orderedProfiles();
const list = filteredProfiles();
const keep = new Set();
if (!all.length || !list.length) {
serverList.innerHTML = "";
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = !all.length
? "Нет серверов — добавьте профиль или подписку"
: "Ничего не найдено";
serverList.appendChild(empty);
return;
}
// Drop empty-state nodes from prior renders.
serverList.querySelectorAll(".server-empty").forEach((el) => el.remove());
list.forEach((p) => {
keep.add(p.name);
const row = ensureServerRow(p);
row.className = "server-row" + (p.name === active ? " active" : "");
const nameEl = row.querySelector(".name");
const sub = row.querySelector(".sub");
const proto = row.querySelector(".proto");
const ms = row.querySelector(".ms");
if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name;
const hostText = p.host || "нет хоста";
if (sub && sub.textContent !== hostText) sub.textContent = hostText;
const protoText = (p.protocol || "?").toString();
if (proto && proto.textContent !== protoText) proto.textContent = protoText;
const pl = pingLabel(pingMap[p.name]);
if (ms) {
if (ms.className !== pl.cls) ms.className = pl.cls;
if (ms.textContent !== pl.text) ms.textContent = pl.text;
if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || "";
}
serverList.appendChild(row); // re-order if needed
});
serverList.querySelectorAll(".server-row").forEach((row) => {
if (!keep.has(row.dataset.name)) row.remove();
});
}
+1 -3
View File
@@ -3,10 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'none';" />
<title>Navis</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="app.css" />
<script>
+26 -1
View File
@@ -16,6 +16,8 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/corebin"
"vpnclient/internal/linknorm"
"vpnclient/internal/listenutil"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
@@ -108,12 +110,19 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
stderr := m.stderr
m.mu.Unlock()
socksHP, _ := profileCopy.SOCKSListenHostPort()
httpHP, _ := profileCopy.HTTPListenHostPort()
listenAddrs := listenutil.ListenAddrs(socksHP, httpHP)
if err := listenutil.EnsureFree(binDir, listenAddrs...); err != nil {
return err
}
engine, err := m.newEngine(profileCopy.Protocol)
if err != nil {
return err
}
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
return err
return listenutil.ClassifyStartFailure(string(profileCopy.Protocol), err, listenAddrs...)
}
m.mu.Lock()
@@ -626,6 +635,22 @@ func (m *Manager) Profiles() []config.ProfileInfo {
return m.cfg.ListProfiles()
}
// PingTargets returns name/protocol/host endpoints without copying proxy secrets.
func (m *Manager) PingTargets() []netcheck.Target {
m.mu.Lock()
defer m.mu.Unlock()
list := m.cfg.ListProfilesForPoll()
out := make([]netcheck.Target, 0, len(list))
for _, p := range list {
out = append(out, netcheck.Target{
Name: p.Name,
Protocol: config.Protocol(p.Protocol),
Host: p.Host,
})
}
return out
}
// Hy2Options are editable Hysteria 2 extras for the active profile.
type Hy2Options struct {
Congestion string `json:"congestion"`
+7
View File
@@ -0,0 +1,7 @@
package listenutil
import "net"
func splitAddr(addr string) (host, port string, err error) {
return net.SplitHostPort(addr)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package listenutil
// KillOrphanCores is best-effort on non-Windows (no-op).
func KillOrphanCores(binDir string, addrs ...string) error {
_ = binDir
_ = addrs
return nil
}
+120
View File
@@ -0,0 +1,120 @@
//go:build windows
package listenutil
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
var coreBasenames = map[string]struct{}{
"hysteria.exe": {},
"naive.exe": {},
"xray.exe": {},
"Xray.exe": {},
}
// KillOrphanCores terminates known VPN core processes holding listen addrs.
func KillOrphanCores(binDir string, addrs ...string) error {
_ = binDir // reserved for future path-scoped sweeps
wantPorts := map[string]struct{}{}
for _, addr := range ListenAddrs(addrs...) {
if _, port, err := splitAddr(addr); err == nil && port != "" {
wantPorts[port] = struct{}{}
}
}
if len(wantPorts) == 0 {
return nil
}
self := uint32(os.Getpid())
var last error
for pid, port := range listeningPIDs() {
if _, ok := wantPorts[port]; !ok {
continue
}
if pid == 0 || pid == self {
continue
}
path := queryImagePath(pid)
base := filepath.Base(path)
if _, ok := coreBasenames[base]; !ok {
continue
}
if err := terminatePID(pid); err != nil {
last = err
}
}
return last
}
func listeningPIDs() map[uint32]string {
out := map[uint32]string{}
cmd := exec.Command("netstat", "-ano", "-p", "tcp")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
b, err := cmd.Output()
if err != nil {
return out
}
sc := bufio.NewScanner(strings.NewReader(string(b)))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") {
continue
}
if !strings.EqualFold(fields[3], "LISTENING") {
continue
}
port := portOf(fields[1])
pid64, err := strconv.ParseUint(fields[4], 10, 32)
if err != nil || port == "" {
continue
}
out[uint32(pid64)] = port
}
return out
}
func portOf(local string) string {
if _, port, err := splitAddr(local); err == nil {
return port
}
if i := strings.LastIndex(local, ":"); i >= 0 {
return local[i+1:]
}
return ""
}
func queryImagePath(pid uint32) string {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
var buf [windows.MAX_PATH]uint16
size := uint32(len(buf))
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
return ""
}
return windows.UTF16ToString(buf[:size])
}
func terminatePID(pid uint32) error {
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return fmt.Errorf("open pid %d: %w", pid, err)
}
defer windows.CloseHandle(h)
if err := windows.TerminateProcess(h, 1); err != nil {
return fmt.Errorf("terminate pid %d: %w", pid, err)
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package listenutil
import (
"fmt"
"net"
"strings"
"time"
)
// ListenAddrs returns unique non-empty host:port listen targets.
func ListenAddrs(addrs ...string) []string {
seen := map[string]struct{}{}
var out []string
for _, hp := range addrs {
hp = strings.TrimSpace(hp)
if hp == "" {
continue
}
if _, ok := seen[hp]; ok {
continue
}
seen[hp] = struct{}{}
out = append(out, hp)
}
return out
}
// BusyError returns a user-facing error if any addr cannot be bound.
func BusyError(addrs ...string) error {
var busy []string
for _, addr := range addrs {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
if !canBindTCP(addr) {
busy = append(busy, addr)
}
}
if len(busy) == 0 {
return nil
}
return fmt.Errorf("порт %s занят — закройте другой Navis/ядро или смените listen в профиле", strings.Join(busy, ", "))
}
// EnsureFree tries to free orphan cores once, then verifies ports are bindable.
func EnsureFree(binDir string, addrs ...string) error {
addrs = ListenAddrs(addrs...)
if len(addrs) == 0 {
return nil
}
if err := BusyError(addrs...); err == nil {
return nil
}
_ = KillOrphanCores(binDir, addrs...)
time.Sleep(200 * time.Millisecond)
if err := BusyError(addrs...); err != nil {
return err
}
return nil
}
// ClassifyStartFailure rewrites a generic "exited immediately" into a port-busy
// message when listen addresses are occupied.
func ClassifyStartFailure(proto string, generic error, addrs ...string) error {
if err := BusyError(addrs...); err != nil {
return fmt.Errorf("%s: %w", proto, err)
}
return generic
}
func canBindTCP(addr string) bool {
ln, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = ln.Close()
return true
}
+49
View File
@@ -0,0 +1,49 @@
package listenutil
import (
"net"
"strings"
"testing"
)
func TestBusyErrorFreePort(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
if err := BusyError(addr); err != nil {
t.Fatalf("expected free: %v", err)
}
}
func TestBusyErrorTakenPort(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
addr := ln.Addr().String()
err = BusyError(addr)
if err == nil || !strings.Contains(err.Error(), "занят") {
t.Fatalf("got %v", err)
}
}
func TestClassifyStartFailure(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
generic := errString("exited immediately")
got := ClassifyStartFailure("hysteria2", generic, ln.Addr().String())
if got == nil || !strings.Contains(got.Error(), "занят") {
t.Fatalf("got %v", got)
}
}
type errString string
func (e errString) Error() string { return string(e) }
+66
View File
@@ -29,9 +29,12 @@ type Result struct {
}
// Target describes one profile to ping.
// Prefer Host (host or host:port) so large proxy/AWG bodies are not copied;
// Proxy is used only when Host is empty.
type Target struct {
Name string
Protocol config.Protocol
Host string
Proxy string
}
@@ -70,6 +73,10 @@ func PingAll(ctx context.Context, targets []Target) []Result {
defer wg.Done()
for i := range jobs {
t := targets[i]
if hp := strings.TrimSpace(t.Host); hp != "" {
out[i] = PingHost(ctx, t.Name, t.Protocol, hp)
continue
}
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
continue
@@ -114,6 +121,65 @@ func BestOK(results []Result) (Result, bool) {
return best, found
}
// PingHost probes a pre-parsed host or host:port (no proxy URI / secrets).
// Naive/Xray use TCP; Hysteria 2 / AWG use UDP.
func PingHost(ctx context.Context, name string, proto config.Protocol, hostPort string) Result {
res := Result{Name: name}
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
res.Error = "нет хоста"
return res
}
host, port := splitHostPort(hostPort, "443")
res.Host = host
res.Port = port
if host == "" {
res.Error = "нет хоста"
return res
}
udp := proto == config.ProtocolHysteria2 || proto == config.ProtocolAWG
var (
rtt time.Duration
soft bool
err error
)
if udp {
rtt, soft, err = probeUDP(ctx, host, port)
} else {
rtt, err = probeTCP(ctx, host, port)
}
if err != nil {
res.Error = friendlyDialError(err, udp)
return res
}
ms := rtt.Milliseconds()
if ms < 1 {
ms = 1
}
res.Ms = ms
res.OK = true
res.Soft = soft
return res
}
func splitHostPort(hostPort, defaultPort string) (host, port string) {
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
return "", defaultPort
}
if h, p, err := net.SplitHostPort(hostPort); err == nil {
return h, p
}
// bare IPv6 without port
if strings.Count(hostPort, ":") >= 2 && !strings.HasPrefix(hostPort, "[") {
return hostPort, defaultPort
}
if i := strings.LastIndex(hostPort, ":"); i >= 0 && !strings.Contains(hostPort[i+1:], "]") {
return hostPort[:i], hostPort[i+1:]
}
return hostPort, defaultPort
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
// When proto is set, Detect is skipped (trust stored protocol).
+24
View File
@@ -32,6 +32,30 @@ func TestPingProfileEmpty(t *testing.T) {
}
}
func TestSplitHostPort(t *testing.T) {
h, p := splitHostPort("example.com:8443", "443")
if h != "example.com" || p != "8443" {
t.Fatalf("%s %s", h, p)
}
h, p = splitHostPort("example.com", "443")
if h != "example.com" || p != "443" {
t.Fatalf("%s %s", h, p)
}
h, p = splitHostPort("[2001:db8::1]:443", "80")
if h != "2001:db8::1" || p != "443" {
t.Fatalf("%s %s", h, p)
}
}
func TestPingHostEmpty(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r := PingHost(ctx, "x", config.ProtocolNaive, "")
if r.OK || r.Error == "" {
t.Fatalf("%+v", r)
}
}
func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
+1 -6
View File
@@ -16,6 +16,7 @@ const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/
// ResolveBinary finds hysteria client binary.
func ResolveBinary(binDir string) (string, error) {
names := candidateNames()
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
candidates := []string{}
for _, name := range names {
candidates = append(candidates,
@@ -24,12 +25,6 @@ func ResolveBinary(binDir string) (string, error) {
filepath.Join(binDir, "hysteria", name),
)
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
for _, name := range names {
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
}
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/listenutil"
)
// Engine runs the official apernet/hysteria client (Hysteria 2).
@@ -102,7 +103,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
var startErr error
select {
case <-done:
startErr = fmt.Errorf("hysteria2: process exited immediately; check URI/password and binary")
socks, _ := profile.SOCKSListenHostPort()
httpL, _ := profile.HTTPListenHostPort()
startErr = listenutil.ClassifyStartFailure("hysteria2",
fmt.Errorf("hysteria2: process exited immediately; check URI/password and binary"),
socks, httpL)
case <-ctx.Done():
_ = e.Stop()
startErr = ctx.Err()
+1 -4
View File
@@ -21,14 +21,11 @@ func ResolveBinary(binDir string) (string, error) {
if runtime.GOOS == "windows" {
name = "naive.exe"
}
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
candidates := []string{
filepath.Join(binDir, name),
filepath.Join(binDir, "naiveproxy", name),
}
if exe, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exe), name))
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "bin", name))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/listenutil"
)
// Engine runs the official Chromium-based naive binary.
@@ -105,7 +106,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
var startErr error
select {
case <-done:
startErr = fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary")
socks, _ := profile.SOCKSListenHostPort()
httpL, _ := profile.HTTPListenHostPort()
startErr = listenutil.ClassifyStartFailure("naive",
fmt.Errorf("naive: process exited immediately; check proxy URI, credentials, and binary"),
socks, httpL)
case <-ctx.Done():
_ = e.Stop()
startErr = ctx.Err()
+1 -4
View File
@@ -17,15 +17,12 @@ const githubAPILatest = "https://api.github.com/repos/XTLS/Xray-core/releases/la
// ResolveBinary finds xray executable.
func ResolveBinary(binDir string) (string, error) {
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
for _, name := range candidateNames() {
candidates := []string{
filepath.Join(binDir, name),
filepath.Join(binDir, "xray", name),
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates, filepath.Join(dir, name), filepath.Join(dir, "bin", name))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil
+6 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/listenutil"
)
// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan.
@@ -106,7 +107,11 @@ func (e *Engine) Start(ctx context.Context, profile config.Profile, binDir strin
var startErr error
select {
case <-done:
startErr = fmt.Errorf("xray: process exited immediately; check link and install-core (xray)")
socks, _ := profile.SOCKSListenHostPort()
httpL, _ := profile.HTTPListenHostPort()
startErr = listenutil.ClassifyStartFailure("xray",
fmt.Errorf("xray: process exited immediately; check link and install-core (xray)"),
socks, httpL)
case <-ctx.Done():
_ = e.Stop()
startErr = ctx.Err()
+6
View File
@@ -0,0 +1,6 @@
package singleinstance
import "errors"
// ErrAlreadyRunning means another Navis GUI instance holds the lock.
var ErrAlreadyRunning = errors.New("Navis уже запущен")
+8
View File
@@ -0,0 +1,8 @@
//go:build plan9
package singleinstance
import "os"
func flockExclusive(f *os.File) error { return nil }
func funlock(f *os.File) error { return nil }
+16
View File
@@ -0,0 +1,16 @@
//go:build !windows && !plan9
package singleinstance
import (
"os"
"syscall"
)
func flockExclusive(f *os.File) error {
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
}
func funlock(f *os.File) error {
return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}
@@ -0,0 +1,36 @@
//go:build !windows
package singleinstance
import (
"fmt"
"os"
"path/filepath"
)
// Lock uses an exclusive lock file under the user config dir when available.
func Lock(appName string) (func(), error) {
if appName == "" {
appName = "Navis"
}
dir, err := os.UserConfigDir()
if err != nil {
return func() {}, nil
}
path := filepath.Join(dir, appName, "singleinstance.lock")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return func() {}, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return func() {}, nil
}
if err := flockExclusive(f); err != nil {
_ = f.Close()
return nil, fmt.Errorf("%w", ErrAlreadyRunning)
}
return func() {
_ = funlock(f)
_ = f.Close()
}, nil
}
@@ -0,0 +1,68 @@
//go:build windows
package singleinstance
import (
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procFindWindow = user32.NewProc("FindWindowW")
procShowWindow = user32.NewProc("ShowWindow")
procSetForeground = user32.NewProc("SetForegroundWindow")
procIsIconic = user32.NewProc("IsIconic")
)
const (
swShow = 5
swRestore = 9
)
// Lock acquires a per-user mutex. If another Navis holds it, activates that
// window (title "Navis") and returns ErrAlreadyRunning.
func Lock(appName string) (func(), error) {
if appName == "" {
appName = "Navis"
}
name, err := windows.UTF16PtrFromString("Local\\" + appName + "SingleInstance")
if err != nil {
return nil, err
}
handle, err := windows.CreateMutex(nil, false, name)
if err == windows.ERROR_ALREADY_EXISTS {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
activateWindow(appName)
return nil, ErrAlreadyRunning
}
if err != nil {
return nil, fmt.Errorf("singleinstance: %w", err)
}
return func() {
_ = windows.ReleaseMutex(handle)
_ = windows.CloseHandle(handle)
}, nil
}
func activateWindow(title string) {
t, err := windows.UTF16PtrFromString(title)
if err != nil {
return
}
hwnd, _, _ := procFindWindow.Call(0, uintptr(unsafe.Pointer(t)))
if hwnd == 0 {
return
}
iconic, _, _ := procIsIconic.Call(hwnd)
if iconic != 0 {
procShowWindow.Call(hwnd, swRestore)
} else {
procShowWindow.Call(hwnd, swShow)
}
procSetForeground.Call(hwnd)
}
+1 -1
View File
@@ -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 = 7
const BuildNumber = 11
// 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"
+4 -4
View File
@@ -1,16 +1,16 @@
{
{
"version": "3.8.2",
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
"notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
"os": "windows",
"arch": "amd64"
},
+4 -4
View File
@@ -4,13 +4,13 @@
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 7
"Build": 11
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
"Patch": 2,
"Build": 7
"Build": 11
},
"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.7",
"FileVersion": "3.8.2.11",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
"ProductVersion": "3.8.2.7",
"ProductVersion": "3.8.2.11",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {