Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
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:
@@ -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; иконка без 400 ms 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:
|
||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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)
|
||||
installCloseToHide(hwnd)
|
||||
if hideOnClose {
|
||||
installCloseToHide(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
func installCloseToHide(hwnd unsafe.Pointer) {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+47
-47
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"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": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+47
-47
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"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": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
-45
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+75
-50
@@ -262,69 +262,94 @@
|
||||
});
|
||||
}
|
||||
|
||||
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 (pr && pr.error) {
|
||||
return { cls: "ms bad", text: "—", title: pr.error };
|
||||
}
|
||||
return { cls: "ms", text: "…", title: "" };
|
||||
}
|
||||
|
||||
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";
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "sub";
|
||||
left.appendChild(nameEl);
|
||||
left.appendChild(sub);
|
||||
const right = document.createElement("div");
|
||||
right.className = "right";
|
||||
const proto = document.createElement("span");
|
||||
proto.className = "proto";
|
||||
const ms = document.createElement("span");
|
||||
ms.className = "ms";
|
||||
right.appendChild(proto);
|
||||
right.appendChild(ms);
|
||||
row.appendChild(left);
|
||||
row.appendChild(right);
|
||||
row.addEventListener("click", () => onServerClick(p.name));
|
||||
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderServerList(activeName) {
|
||||
const active = activeName || profile.value;
|
||||
serverList.innerHTML = "";
|
||||
const all = orderedProfiles();
|
||||
const list = filteredProfiles();
|
||||
if (!all.length) {
|
||||
const keep = new Set();
|
||||
|
||||
if (!all.length || !list.length) {
|
||||
serverList.innerHTML = "";
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "server-empty";
|
||||
empty.textContent = "Нет серверов — добавьте профиль или подписку";
|
||||
serverList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (!list.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "server-empty";
|
||||
empty.textContent = "Ничего не найдено";
|
||||
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) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
keep.add(p.name);
|
||||
const row = ensureServerRow(p);
|
||||
row.className = "server-row" + (p.name === active ? " active" : "");
|
||||
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 = "…";
|
||||
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 || "";
|
||||
}
|
||||
right.appendChild(proto);
|
||||
right.appendChild(ms);
|
||||
serverList.appendChild(row); // re-order if needed
|
||||
});
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(right);
|
||||
row.addEventListener("click", () => onServerClick(p.name));
|
||||
row.addEventListener("dblclick", () => onServerConnect(p.name));
|
||||
serverList.appendChild(row);
|
||||
serverList.querySelectorAll(".server-row").forEach((row) => {
|
||||
if (!keep.has(row.dataset.name)) row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package listenutil
|
||||
|
||||
import "net"
|
||||
|
||||
func splitAddr(addr string) (host, port string, err error) {
|
||||
return net.SplitHostPort(addr)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package singleinstance
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrAlreadyRunning means another Navis GUI instance holds the lock.
|
||||
var ErrAlreadyRunning = errors.New("Navis уже запущен")
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+47
-47
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.",
|
||||
"platform": "windows-amd64",
|
||||
"os": "windows",
|
||||
"arch": "amd64",
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
{
|
||||
"version": "3.8.2",
|
||||
"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": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"mandatory": false,
|
||||
"platforms": {
|
||||
"windows-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
|
||||
"sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83",
|
||||
"os": "windows",
|
||||
"arch": "amd64"
|
||||
},
|
||||
"darwin-arm64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
|
||||
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
|
||||
"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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
|
||||
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
|
||||
},
|
||||
"darwin-amd64": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
|
||||
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
|
||||
"os": "darwin",
|
||||
"arch": "amd64",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
|
||||
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
|
||||
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
|
||||
},
|
||||
"darwin-universal": {
|
||||
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
|
||||
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
|
||||
"os": "darwin",
|
||||
"arch": "universal",
|
||||
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
|
||||
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
|
||||
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
|
||||
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user