9 Commits
Author SHA1 Message Date
M4andCursor 7ceb3a5148 Release 3.8.2.13: iOS Packet Tunnel client and Clash URL subscriptions.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Add SwiftUI + Network Extension (LibXray/hev), parse Clash Meta YAML from subscription URLs into vless/trojan/hy2 share links, keep the selected server on connect, and ship Navis-3.8.2.13.ipa with versioned changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 22:46:32 +03:00
M4andCursor 8b564110d5 Release 3.8.2.12: export/import full server list as JSON.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Save and replace profiles via system file dialog; buttons after Best; prefs unchanged on import.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:51:38 +03:00
M4andCursor 57d719f02f 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>
2026-08-01 17:27:47 +03:00
M4andCursor fed1ace33e Release 3.8.2.7: Windows taskbar/tray sea-wave icon when connected.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Match macOS Dock behavior using the same idle/connected glyph assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:45:55 +03:00
M4andCursor 5d7c96f7fc Release 3.8.2.6: perceived-speed UX — search, tray hide, core warm, SSE.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Server search/hotkeys; Windows close-to-tray; background EnsureCore; SSE status with rare poll fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:32:19 +03:00
M4andCursor 2ad376b49e Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:13:59 +03:00
M4andCursor ff5d87ab9f Docs: describe 3.8.2+4 glaze GUI unify and build auto-bump in release notes.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:05:02 +03:00
M4andCursor 464a61aebf Release 3.8.2.4: unify Windows/macOS GUI on glaze HTTP host.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Auto-bump build number on each compile; ship versioned Windows artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 16:01:25 +03:00
M4andCursor d1570b23d3 Release 3.8.2.3: cheaper poll I/O, batched macOS sysproxy, no Connect Clone.
Trust corebin cache without Stat; batch networksetup; ActiveProxyURI instead of Config.Clone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 03:02:19 +03:00
126 changed files with 7120 additions and 686 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: After every compile/bump, always write release notes for the new build
alwaysApply: true
---
# Описание изменений после компиляции
После любой успешной сборки с бампом `BuildNumber` (через `build.bat`, `build-macos.bat` / скрипты bump, или ручной bump) **обязательно** обновить описание сделанных изменений — не оставлять только бинарник.
## Что обновлять
1. `notes` в:
- `dist/update.json`
- `dist/navis-release/update.json`
- `server/update.json`
2. Краткую строку в changelog `README.md` (секция текущей версии, формат `x.y.z+N: …`).
3. При релизе Windows-артефакта — `sha256` для `Navis.exe` / `windows-amd64` в тех же `update.json`.
## Как писать notes
- На русском, в стиле существующих записей.
- Начинать с `Navis x.y.z+N:` (как в `DisplayVersion` / build).
- Перечислить суть изменений для пользователя и ключевые тех. точки (не дамп диффа).
- Синхронизировать смысл между `update.json` и `README.md` (README можно короче).
Не редактировать plan-файлы ради notes. Не коммитить и не пушить, пока пользователь явно не попросил.
+18 -3
View File
@@ -6,8 +6,8 @@
| OS | Артефакты | UI | | OS | Артефакты | UI |
|----|----------|----| |----|----------|----|
| Windows amd64 | `Navis.exe` | GUI (WebView2) | | Windows amd64 | `Navis.exe` | GUI (glaze → WebView2 + HTTP UI) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) | | macOS universal | `Navis.dmg` · `Navis.app.zip` | GUI (glaze → WKWebView + HTTP UI) |
| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI | | macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI |
| Android | `Navis.apk` | GUI (VpnService) | | Android | `Navis.apk` | GUI (VpnService) |
@@ -192,10 +192,14 @@ cd ~/Navis
## Сборка Windows ## Сборка Windows
GUI — тот же HTML UI, что и на macOS: локальный HTTP API + окно [glaze](https://github.com/crgimenes/glaze) (на Windows под капотом Edge WebView2 Runtime).
```bat ```bat
build.bat build.bat
``` ```
Артефакты: `Navis-<version>.<build>.exe`, `vpnclient-<version>.<build>.exe` (и копии в `dist\navis-release\`).
или: или:
```bat ```bat
@@ -248,7 +252,18 @@ https://evilfox.win/
- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение; - логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP; - подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии; - трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении. - 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone;
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
- 3.8.2+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+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.2+12: экспорт/импорт списка серверов в JSON (замена при импорте);
- 3.8.2+13: **iOS** — первый device IPA (`Navis-3.8.2.13.ipa`): Packet Tunnel + LibXray + hev; Clash Meta URL-подписки (vless/trojan/hy2); без подмены выбранного сервера; старт ядра до VPN-маршрутов; hy2/naive на iOS пока недоступны; desktop — полный Clash→share разбор (не только hy2).
В 3.8.1: В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3080202 versionCode = 3080213
versionName = "3.8.2+2" versionName = "3.8.2+13"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+12 -4
View File
@@ -1,7 +1,15 @@
@echo off @echo off
setlocal setlocal EnableExtensions
cd /d "%~dp0" cd /d "%~dp0"
echo Bumping build number...
where py >nul 2>&1 && py -3 scripts\bump-build.py && goto :bumped
where python >nul 2>&1 && python scripts\bump-build.py && goto :bumped
where python3 >nul 2>&1 && python3 scripts\bump-build.py && goto :bumped
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\bump-build.ps1"
if errorlevel 1 exit /b 1
:bumped
mkdir "dist\navis-release\darwin-arm64" 2>nul mkdir "dist\navis-release\darwin-arm64" 2>nul
mkdir "dist\navis-release\darwin-amd64" 2>nul mkdir "dist\navis-release\darwin-amd64" 2>nul
mkdir "dist\navis-release\darwin-universal" 2>nul mkdir "dist\navis-release\darwin-universal" 2>nul
@@ -34,11 +42,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1 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.2 -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.13 -arch arm64
if errorlevel 1 exit /b 1 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.2 -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.13 -arch amd64
if errorlevel 1 exit /b 1 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.2 -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.13 -arch universal
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI: echo Built Mac GUI + CLI:
+66 -6
View File
@@ -1,7 +1,24 @@
@echo off @echo off
setlocal setlocal EnableExtensions
cd /d "%~dp0" cd /d "%~dp0"
echo Bumping build number...
call :bump_build
if errorlevel 1 exit /b 1
REM Full version = CurrentVersion.BuildNumber from versioninfo.json (e.g. 3.8.2.4)
set "VER="
for /f "usebackq delims=" %%v in (`powershell -NoProfile -Command "(Get-Content -Raw 'versioninfo.json' | ConvertFrom-Json).StringFileInfo.FileVersion"`) do set "VER=%%v"
if not defined VER (
echo Failed to read FileVersion from versioninfo.json
exit /b 1
)
set "OUT_GUI=Navis-%VER%.exe"
set "OUT_CLI=vpnclient-%VER%.exe"
set "DIST=dist\navis-release"
echo Building Navis %VER% for Windows...
echo Generating Windows icon resources... echo Generating Windows icon resources...
go run ./tools/mkico go run ./tools/mkico
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
@@ -14,16 +31,59 @@ if errorlevel 1 (
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
echo Building Navis GUI and CLI... echo Building GUI -^> %OUT_GUI%
go build -ldflags="-H windowsgui -s -w" -trimpath -o Navis.exe ./cmd/vpnapp go build -ldflags="-H windowsgui -s -w" -trimpath -o "%OUT_GUI%" ./cmd/vpnapp
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
go build -ldflags="-s -w" -trimpath -o vpnclient.exe ./cmd/vpnclient echo Building CLI -^> %OUT_CLI%
go build -ldflags="-s -w" -trimpath -o "%OUT_CLI%" ./cmd/vpnclient
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
mkdir "%DIST%" 2>nul
copy /Y "%OUT_GUI%" "%DIST%\%OUT_GUI%" >nul
if errorlevel 1 exit /b 1
copy /Y "%OUT_CLI%" "%DIST%\%OUT_CLI%" >nul
if errorlevel 1 exit /b 1
REM Stable names for the update feed / installers (best-effort if file is locked)
call :copy_alias "%OUT_GUI%" "%DIST%\Navis.exe"
call :copy_alias "%OUT_CLI%" "%DIST%\vpnclient.exe"
echo. echo.
echo Done: echo Done:
echo Navis.exe echo %OUT_GUI%
echo vpnclient.exe echo %OUT_CLI%
echo %DIST%\%OUT_GUI%
echo %DIST%\%OUT_CLI%
echo %DIST%\Navis.exe (update alias)
echo assets\navis.ico echo assets\navis.ico
endlocal endlocal
exit /b 0
:bump_build
where py >nul 2>&1
if not errorlevel 1 (
py -3 scripts\bump-build.py
exit /b %ERRORLEVEL%
)
where python >nul 2>&1
if not errorlevel 1 (
python scripts\bump-build.py
exit /b %ERRORLEVEL%
)
where python3 >nul 2>&1
if not errorlevel 1 (
python3 scripts\bump-build.py
exit /b %ERRORLEVEL%
)
REM PowerShell fallback when Python is not installed
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\bump-build.ps1"
exit /b %ERRORLEVEL%
:copy_alias
if exist "%~2" del /F /Q "%~2" >nul 2>&1
copy /Y "%~1" "%~2" >nul 2>&1
if errorlevel 1 (
echo Warning: could not refresh %~2 ^(file in use^). Versioned build is still ready.
exit /b 0
)
exit /b 0
@@ -1,16 +1,13 @@
//go:build darwin //go:build windows || darwin
package main package main
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"runtime" "runtime"
"syscall"
"time" "time"
"github.com/crgimenes/glaze" "github.com/crgimenes/glaze"
@@ -18,7 +15,9 @@ import (
"vpnclient/internal/apphost" "vpnclient/internal/apphost"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/core" "vpnclient/internal/core"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf" "vpnclient/internal/logbuf"
"vpnclient/internal/singleinstance"
"vpnclient/internal/trayhost" "vpnclient/internal/trayhost"
"vpnclient/internal/update" "vpnclient/internal/update"
) )
@@ -28,6 +27,17 @@ func main() {
return return
} }
update.CleanupStaleDownloads() 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.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU()) log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
@@ -44,6 +54,7 @@ func main() {
if err != nil { if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err) fatalf("Ошибка конфига %s: %v", cfgPath, err)
} }
logBuf := logbuf.New(0) logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf) mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil { if err != nil {
@@ -51,10 +62,9 @@ func main() {
} }
a := apphost.New(mgr, cfgPath, logBuf) a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = func(u string) error { a.OpenURL = openExternal
return exec.Command("open", u).Start()
}
a.OnAfterUpdate = func() { a.OnAfterUpdate = func() {
// Hard-exit so the OS unlocks the binary for replacement.
go func() { go func() {
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
_ = a.Disconnect() _ = a.Disconnect()
@@ -67,17 +77,25 @@ func main() {
fatalf("listen: %v", err) fatalf("listen: %v", err)
} }
a.APIToken = token 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() { go func() {
_ = srv.Serve(ln) _ = srv.Serve(ln)
}() }()
a.StartBackground() a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{ dockicon.OnIconChange = trayhost.SetConnectedIcon
trayOK := trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() }, Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() }, Disconnect: func() { _ = a.Disconnect() },
Show: showMainWindow,
Quit: func() { Quit: func() {
_ = a.Disconnect() _ = a.Disconnect()
os.Exit(0) a.Shutdown()
closeMainWindow()
}, },
IsUp: func() bool { return a.Mgr.Status().Connected }, IsUp: func() bool { return a.Mgr.Status().Connected },
}) })
@@ -86,18 +104,22 @@ func main() {
w, err := glaze.New(false) w, err := glaze.New(false)
if err != nil || w == nil { if err != nil || w == nil {
log.Printf("webview unavailable (%v); opening default browser", err) log.Printf("webview unavailable (%v); opening default browser", err)
if err2 := exec.Command("open", uiURL).Run(); err2 != nil { if err2 := openExternal(uiURL); err2 != nil {
_ = srv.Close()
fatalf("Не удалось открыть интерфейс:\n%v\n%v\n%s", err, err2, uiURL) fatalf("Не удалось открыть интерфейс:\n%v\n%v\n%s", err, err2, uiURL)
} }
log.Printf("Navis UI: %s", uiURL) log.Printf("Navis UI: %s", uiURL)
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM) signal.Notify(sig, shutdownSignals()...)
<-sig <-sig
a.Shutdown()
_ = mgr.Disconnect() _ = mgr.Disconnect()
_ = srv.Close() _ = srv.Close()
return return
} }
a.OnQuit = closeMainWindow
defer func() { defer func() {
a.Shutdown()
_ = mgr.Disconnect() _ = mgr.Disconnect()
_ = srv.Close() _ = srv.Close()
w.Destroy() w.Destroy()
@@ -105,14 +127,9 @@ func main() {
w.SetTitle("Navis") w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone) w.SetSize(500, 900, glaze.HintNone)
// Close→hide only when tray is alive; otherwise X would leave invisible zombies.
decorateWindow(w, trayOK)
w.Navigate(uiURL) w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL) log.Printf("Navis UI: %s (tray=%v hideOnClose=%v)", uiURL, trayOK, trayOK)
w.Run() w.Run()
} }
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func main() { func main() {
fmt.Fprintln(os.Stderr, "Navis GUI supports Windows (WebView2) and macOS.") fmt.Fprintln(os.Stderr, "Navis GUI supports Windows and macOS (glaze + local HTTP UI).")
fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update") fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update")
os.Exit(1) os.Exit(1)
} }
-202
View File
@@ -1,202 +0,0 @@
//go:build windows
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"vpnclient/internal/apphost"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
fatalDialog("Не удалось найти путь конфига: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := config.WriteExample(cfgPath); err != nil {
fatalDialog("Не удалось создать конфиг: %v", err)
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
}
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = shellOpen
a.OnAfterUpdate = func() {
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
go func() {
time.Sleep(300 * time.Millisecond)
_ = a.Disconnect()
os.Exit(0)
}()
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis",
Width: 500,
Height: 900,
Center: true,
IconId: 1,
},
})
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
defer func() {
_ = mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.GetState)
mustBind(w, "getEditState", a.GetEditState)
mustBind(w, "connect", a.Connect)
mustBind(w, "disconnect", a.Disconnect)
mustBind(w, "connectProfile", a.ConnectProfile)
mustBind(w, "saveProfile", a.SaveProfile)
mustBind(w, "createProfile", a.CreateProfile)
mustBind(w, "selectProfile", a.SelectProfile)
mustBind(w, "deleteProfile", a.DeleteProfile)
mustBind(w, "installCore", a.InstallCore)
mustBind(w, "openURL", a.OpenShopURL)
mustBind(w, "pingServers", a.PingServers)
mustBind(w, "pingBest", a.PingBest)
mustBind(w, "checkUpdate", a.CheckUpdate)
mustBind(w, "applyUpdate", a.ApplyUpdate)
mustBind(w, "saveHy2", a.SaveHy2)
mustBind(w, "importSubscription", a.ImportSubscription)
mustBind(w, "getLogs", a.GetLogs)
mustBind(w, "probeTunnel", a.ProbeTunnel)
mustBind(w, "savePrefs", a.SavePrefs)
a.StartBackground()
trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Quit: func() {
_ = a.Disconnect()
os.Exit(0)
},
IsUp: func() bool { return a.Mgr.Status().Connected },
})
go a.AutoCheckUpdate()
w.SetHtml(appui.IndexHTML)
w.Run()
}
func mustBind(w webview2.WebView, name string, fn interface{}) {
if err := w.Bind(name, fn); err != nil {
fatalDialog("bind %s: %v", name, err)
}
}
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
user32 := windows.NewLazySystemDLL("user32.dll")
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func fatalDialog(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
+42
View File
@@ -0,0 +1,42 @@
//go:build darwin
package main
import (
"fmt"
"log"
"os"
"os/exec"
"syscall"
"github.com/crgimenes/glaze"
)
func openExternal(raw string) error {
return exec.Command("open", raw).Start()
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt, syscall.SIGTERM}
}
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() {}
func prepareWindowQuit() {}
func closeMainWindow() {
os.Exit(0)
}
+176
View File
@@ -0,0 +1,176 @@
//go:build windows
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"sync/atomic"
"unsafe"
"github.com/crgimenes/glaze"
"golang.org/x/sys/windows"
"vpnclient/internal/dockicon"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW")
procSetWindowLongPtr = user32.NewProc("SetWindowLongPtrW")
procCallWindowProc = user32.NewProc("CallWindowProcW")
procShowWindow = user32.NewProc("ShowWindow")
procSetForeground = user32.NewProc("SetForegroundWindow")
procPostMessage = user32.NewProc("PostMessageW")
procIsIconic = user32.NewProc("IsIconic")
mainHWND uintptr
origWndProc uintptr
closeHookCB uintptr
allowDestroy atomic.Bool
)
const (
// GWLP_WNDPROC as unsigned pointer-sized -4.
gwlpWndProc = ^uintptr(3)
wmClose = 0x0010
swHide = 0
swShow = 5
swRestore = 9
)
func openExternal(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
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) {
if hwnd == nil {
return
}
mainHWND = uintptr(hwnd)
allowDestroy.Store(false)
if closeHookCB == 0 {
closeHookCB = windows.NewCallback(closeToHideWndProc)
}
orig, _, _ := procGetWindowLongPtr.Call(mainHWND, gwlpWndProc)
if orig == 0 {
return
}
origWndProc = orig
procSetWindowLongPtr.Call(mainHWND, gwlpWndProc, closeHookCB)
}
func closeToHideWndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
if msg == wmClose && !allowDestroy.Load() {
procShowWindow.Call(hwnd, uintptr(swHide))
return 0
}
r, _, _ := procCallWindowProc.Call(origWndProc, hwnd, uintptr(msg), wParam, lParam)
return r
}
func showMainWindow() {
if mainHWND == 0 {
return
}
iconic, _, _ := procIsIconic.Call(mainHWND)
if iconic != 0 {
procShowWindow.Call(mainHWND, uintptr(swRestore))
} else {
procShowWindow.Call(mainHWND, uintptr(swShow))
}
procSetForeground.Call(mainHWND)
}
func prepareWindowQuit() {
allowDestroy.Store(true)
}
func closeMainWindow() {
prepareWindowQuit()
if mainHWND == 0 {
os.Exit(0)
return
}
procPostMessage.Call(mainHWND, uintptr(wmClose), 0, 0)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3.8.2+2 3.8.2+3
+1 -1
View File
@@ -1 +1 @@
3.8.2.2 3.8.2.3
Binary file not shown.
+47 -47
View File
@@ -1,48 +1,48 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+2: иконка Dock — бирюзовая N при подключении; при падении ядра снимается системный прокси; логи и проверка туннеля; автопереподключение / connect-on-launch; подписка чистит ноды; «Лучший» без soft-ok UDP.", "notes": "Navis 3.8.2+13: iOS Packet Tunnel IPA (VLESS/VMess/Trojan + LibXray/hev); Clash Meta URL-подписки; выбор сервера без подмены; desktop — полный Clash→share (vless/trojan/hy2).",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e", "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e", "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "bea9d47f04a6ad7212ea2ca25e26d62a5f0d364c0978b1118e95837f740ef335", "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "dffafe8e292006c9a1d4912fd4c715085ba21a71fd19d663ac3c0613b56f9909", "zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "d31a4c63fb9f26372f7a89eff5cb03638d9f88cbd1929eb1e5525d5fdcffc8fe" "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin", "os": "darwin",
"arch": "amd64", "arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
}, },
"darwin-universal": { "darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin", "os": "darwin",
"arch": "universal", "arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
} }
} }
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47 -47
View File
@@ -1,48 +1,48 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+2: иконка Dock — бирюзовая N при подключении; при падении ядра снимается системный прокси; логи и проверка туннеля; автопереподключение / connect-on-launch; подписка чистит ноды; «Лучший» без soft-ok UDP.", "notes": "Navis 3.8.2+13: iOS Packet Tunnel IPA (VLESS/VMess/Trojan + LibXray/hev); Clash Meta URL-подписки; выбор сервера без подмены; desktop — полный Clash→share (vless/trojan/hy2).",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e", "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e", "sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
"darwin-arm64": { "darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "bea9d47f04a6ad7212ea2ca25e26d62a5f0d364c0978b1118e95837f740ef335", "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin", "os": "darwin",
"arch": "arm64", "arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "dffafe8e292006c9a1d4912fd4c715085ba21a71fd19d663ac3c0613b56f9909", "zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "d31a4c63fb9f26372f7a89eff5cb03638d9f88cbd1929eb1e5525d5fdcffc8fe" "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
}, },
"darwin-amd64": { "darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin", "os": "darwin",
"arch": "amd64", "arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
}, },
"darwin-universal": { "darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin", "os": "darwin",
"arch": "universal", "arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", "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_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
} }
} }
} }
+3 -4
View File
@@ -4,21 +4,20 @@ go 1.26.5
require ( require (
github.com/amnezia-vpn/amneziawg-go v0.2.19 github.com/amnezia-vpn/amneziawg-go v0.2.19
github.com/crgimenes/glaze v0.0.33
github.com/diskfs/go-diskfs v1.9.4 github.com/diskfs/go-diskfs v1.9.4
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 github.com/ebitengine/purego v0.10.1
golang.org/x/image v0.44.0 golang.org/x/image v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
) )
require ( require (
github.com/anchore/go-lzo v0.1.0 // indirect github.com/anchore/go-lzo v0.1.0 // indirect
github.com/crgimenes/glaze v0.0.33 // indirect
github.com/djherbis/times v1.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/xattr v0.4.12 // indirect github.com/pkg/xattr v0.4.12 // indirect
+2 -6
View File
@@ -22,10 +22,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo=
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
@@ -48,8 +44,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+231 -57
View File
@@ -3,8 +3,10 @@ package apphost
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -19,6 +21,7 @@ import (
"vpnclient/internal/core" "vpnclient/internal/core"
"vpnclient/internal/corebin" "vpnclient/internal/corebin"
"vpnclient/internal/dockicon" "vpnclient/internal/dockicon"
"vpnclient/internal/filedialog"
"vpnclient/internal/logbuf" "vpnclient/internal/logbuf"
"vpnclient/internal/netcheck" "vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/awg"
@@ -38,7 +41,14 @@ type App struct {
Pings []netcheck.Result Pings []netcheck.Result
OpenURL func(string) error OpenURL func(string) error
OnAfterUpdate func() OnAfterUpdate func()
OnQuit func()
APIToken string APIToken string
ctx context.Context
cancel context.CancelFunc
events *eventBroker
revMu sync.Mutex
cachedRev string
cachedConn bool
} }
type UIState struct { type UIState struct {
@@ -63,6 +73,8 @@ type UIState struct {
ConnectOnLaunch bool `json:"connect_on_launch"` ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"` AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"` LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
} }
type PingBestResult struct { type PingBestResult struct {
@@ -74,10 +86,14 @@ type PingBestResult struct {
} }
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App { func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{ return &App{
Mgr: mgr, Mgr: mgr,
CfgPath: cfgPath, CfgPath: cfgPath,
LogBuf: logBuf, LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
events: newEventBroker(),
UpdateStatus: update.Status{ UpdateStatus: update.Status{
Current: update.DisplayVersion(), Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL, ManifestURL: update.DefaultManifestURL,
@@ -85,13 +101,58 @@ func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
} }
} }
// Shutdown cancels background loops (dock icon / watchdog). Safe to call more than once.
func (a *App) Shutdown() {
if a == nil || a.cancel == nil {
return
}
a.cancel()
}
func (a *App) GetState() (UIState, error) { func (a *App) GetState() (UIState, error) {
return a.getState(false) return a.GetStateSince("")
}
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
// matches the current fingerprint (cheap idle HTTP polls).
func (a *App) GetStateSince(since string) (UIState, error) {
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). // GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) { func (a *App) GetEditState() (UIState, error) {
return a.getState(true) out, err := a.getState(true)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
return out, nil
} }
func (a *App) getState(includeSecrets bool) (UIState, error) { func (a *App) getState(includeSecrets bool) (UIState, error) {
@@ -155,6 +216,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
return out, nil return out, nil
} }
func fingerprintState(s UIState) string {
var b strings.Builder
b.Grow(256)
fmt.Fprintf(&b, "%t|%s|%s|%s|%t|%s|%t|%t|%t|%s|",
s.Connected, s.Profile, s.ActiveProfile, s.Protocol, s.CoreReady, s.LastError,
s.SystemProxy, s.ConnectOnLaunch, s.AutoReconnect, s.Subscription)
fmt.Fprintf(&b, "%s|%s|%t|", s.Update.Latest, s.Update.Current, s.Update.Available)
for _, p := range s.Profiles {
fmt.Fprintf(&b, "%s,%s,%s,%t;", p.Name, p.Protocol, p.Host, p.Active)
}
for _, p := range s.Pings {
fmt.Fprintf(&b, "%s,%t,%t,%d,%s;", p.Name, p.OK, p.Soft, p.Ms, p.Error)
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:8])
}
func trimLogTail(s string, max int) string { func trimLogTail(s string, max int) string {
if max <= 0 || len(s) <= max { if max <= 0 || len(s) <= max {
return s return s
@@ -183,36 +261,55 @@ func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error { func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля") return fmt.Errorf("укажите название профиля")
} }
a.Mgr.SetSystemProxy(systemProxy) 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 { func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля") return fmt.Errorf("укажите название профиля")
} }
a.Mgr.SetSystemProxy(systemProxy) 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 { func (a *App) SelectProfile(name string) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() err := a.Mgr.SetActiveProfile(name)
return a.Mgr.SetActiveProfile(name) a.mu.Unlock()
if err == nil {
go a.warmActiveCore()
a.NotifyState("profile")
}
return err
} }
func (a *App) DeleteProfile(name string) error { func (a *App) DeleteProfile(name string) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() err := a.Mgr.DeleteProfile(name)
return a.Mgr.DeleteProfile(name) a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
} }
func (a *App) Connect() error { return a.ConnectProfile("") } func (a *App) Connect() error { return a.ConnectProfile("") }
@@ -238,10 +335,12 @@ func (a *App) ConnectProfile(name string) error {
return err return err
} }
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock() a.mu.Unlock()
return err return err
} else if strings.TrimSpace(p.Proxy) == "" { }
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock() a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера") return fmt.Errorf("сначала вставьте ссылку сервера")
} }
@@ -251,6 +350,7 @@ func (a *App) ConnectProfile(name string) error {
for attempt := 0; attempt < 2; attempt++ { for attempt := 0; attempt < 2; attempt++ {
if _, err := a.Mgr.EnsureCore(""); err != nil { if _, err := a.Mgr.EnsureCore(""); err != nil {
a.Mgr.SetLastError(err.Error()) a.Mgr.SetLastError(err.Error())
a.NotifyState("error")
return err return err
} }
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
@@ -266,6 +366,7 @@ func (a *App) ConnectProfile(name string) error {
pcancel() pcancel()
if perr == nil { if perr == nil {
dockicon.SetConnected(true) dockicon.SetConnected(true)
a.NotifyState("connected")
return nil return nil
} }
last = fmt.Errorf("туннель не прошёл проверку: %w", perr) last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
@@ -275,12 +376,14 @@ func (a *App) ConnectProfile(name string) error {
time.Sleep(400 * time.Millisecond) time.Sleep(400 * time.Millisecond)
} }
dockicon.SetConnected(false) dockicon.SetConnected(false)
a.NotifyState("error")
return last return last
} }
func (a *App) Disconnect() error { func (a *App) Disconnect() error {
err := a.Mgr.Disconnect() err := a.Mgr.Disconnect()
dockicon.SetConnected(false) dockicon.SetConnected(false)
a.NotifyState("disconnected")
return err return err
} }
@@ -302,21 +405,30 @@ func (a *App) ProbeTunnel() error {
} }
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error { func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
return a.Mgr.SavePrefs(core.Prefs{ err := a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch, ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect, AutoReconnect: autoReconnect,
SystemProxy: systemProxy, 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() { func (a *App) StartBackground() {
go a.watchdogLoop() go a.watchdogLoop()
go a.dockIconLoop() dockicon.SetConnected(false)
go a.warmActiveCore()
prefs := a.Mgr.Prefs() prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch { if prefs.ConnectOnLaunch {
go func() { go func() {
time.Sleep(1200 * time.Millisecond) select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected { if a.Mgr.Status().Connected {
return return
} }
@@ -325,33 +437,45 @@ func (a *App) StartBackground() {
} }
} }
func (a *App) dockIconLoop() { // warmActiveCore downloads/resolves the binary for the active profile protocol.
dockicon.SetConnected(false) // Non-blocking; Connect still EnsureCore if this has not finished yet.
var last bool func (a *App) warmActiveCore() {
first := true select {
for { case <-a.ctx.Done():
time.Sleep(400 * time.Millisecond) return
up := a.Mgr.Status().Connected case <-time.After(350 * time.Millisecond):
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
} }
if _, err := a.Mgr.EnsureCore(""); err != nil {
// Soft fail — Connect will surface the error if the user tries.
return
}
a.NotifyState("cores")
} }
func (a *App) watchdogLoop() { func (a *App) watchdogLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for { for {
time.Sleep(2 * time.Second) select {
if !a.Mgr.TakeUnexpectedDrop() { case <-a.ctx.Done():
continue return
case <-t.C:
if !a.Mgr.TakeUnexpectedDrop() {
continue
}
prefs := a.Mgr.Prefs()
dockicon.SetConnected(false)
a.NotifyState("disconnected")
if !prefs.AutoReconnect {
continue
}
select {
case <-a.ctx.Done():
return
case <-time.After(800 * time.Millisecond):
}
_ = a.Connect()
} }
prefs := a.Mgr.Prefs()
if !prefs.AutoReconnect {
continue
}
time.Sleep(800 * time.Millisecond)
_ = a.Connect()
} }
} }
@@ -364,17 +488,64 @@ func (a *App) InstallCore() (string, error) {
for k, v := range paths { for k, v := range paths {
parts = append(parts, k+": "+v) parts = append(parts, k+": "+v)
} }
a.NotifyState("cores")
return strings.Join(parts, " | "), nil return strings.Join(parts, " | "), nil
} }
func (a *App) SaveHy2(opts core.Hy2Options) error { func (a *App) SaveHy2(opts core.Hy2Options) error {
a.mu.Lock() a.mu.Lock()
defer a.mu.Unlock() err := a.Mgr.SaveHy2Options(opts)
return a.Mgr.SaveHy2Options(opts) a.mu.Unlock()
if err == nil {
a.NotifyState("hy2")
}
return err
} }
func (a *App) ImportSubscription(rawURL string) (int, error) { 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
}
// ExportServers opens a Save dialog and writes the full profile list.
func (a *App) ExportServers() (int, error) {
path, err := filedialog.SaveJSON("Сохранить список серверов", "navis-servers.json")
if err != nil {
if errors.Is(err, filedialog.ErrCanceled) {
return 0, nil
}
return 0, err
}
n, err := a.Mgr.ExportServers(path)
if err != nil {
return 0, err
}
a.NotifyState("profile")
return n, nil
}
// ImportServers opens an Open dialog and replaces the profile list.
func (a *App) ImportServers() (int, error) {
path, err := filedialog.OpenJSON("Загрузить список серверов")
if err != nil {
if errors.Is(err, filedialog.ErrCanceled) {
return 0, nil
}
return 0, err
}
n, err := a.Mgr.ImportServersReplace(path)
if err != nil {
return 0, err
}
a.mu.Lock()
a.Pings = nil
a.mu.Unlock()
a.NotifyState("profile")
go a.warmActiveCore()
return n, nil
} }
func (a *App) PingServers() ([]netcheck.Result, error) { func (a *App) PingServers() ([]netcheck.Result, error) {
@@ -420,10 +591,12 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.Connected = true out.Connected = true
return out, nil return out, nil
} }
if p, err := a.Mgr.Config().ActiveProfile(); err != nil { proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock() a.mu.Unlock()
return out, err return out, err
} else if strings.TrimSpace(p.Proxy) == "" { }
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock() a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера") return out, fmt.Errorf("пустая ссылка у лучшего сервера")
} }
@@ -437,24 +610,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
} }
func (a *App) runPings() ([]netcheck.Result, error) { func (a *App) runPings() ([]netcheck.Result, error) {
a.mu.Lock() targets := a.Mgr.PingTargets()
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,
})
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel() defer cancel()
out := netcheck.PingAll(ctx, targets) out := netcheck.PingAll(ctx, targets)
a.mu.Lock() a.mu.Lock()
a.Pings = out a.Pings = out
a.mu.Unlock() a.mu.Unlock()
a.NotifyState("pings")
return out, nil return out, nil
} }
@@ -470,6 +633,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
} }
a.UpdateStatus = st a.UpdateStatus = st
a.mu.Unlock() a.mu.Unlock()
a.NotifyState("update")
return st, nil return st, nil
} }
@@ -544,6 +708,7 @@ func (a *App) Handler() http.Handler {
} }
_, _ = io.WriteString(w, html) _, _ = io.WriteString(w, html)
}) })
mux.HandleFunc("/api/events", a.handleEvents)
mux.HandleFunc("/api/", a.handleAPI) mux.HandleFunc("/api/", a.handleAPI)
return mux return mux
} }
@@ -598,7 +763,7 @@ func arg[T any](args []json.RawMessage, i int, def T) T {
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name { switch name {
case "getState": case "getState":
return a.GetState() return a.GetStateSince(arg(args, 0, ""))
case "getEditState": case "getEditState":
return a.GetEditState() return a.GetEditState()
case "connect": case "connect":
@@ -635,6 +800,10 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts) return nil, a.SaveHy2(opts)
case "importSubscription": case "importSubscription":
return a.ImportSubscription(arg(args, 0, "")) return a.ImportSubscription(arg(args, 0, ""))
case "exportServers":
return a.ExportServers()
case "importServers":
return a.ImportServers()
case "getLogs": case "getLogs":
return a.GetLogs(), nil return a.GetLogs(), nil
case "probeTunnel": case "probeTunnel":
@@ -645,6 +814,11 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
go func() { go func() {
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
_ = a.Mgr.Disconnect() _ = a.Mgr.Disconnect()
a.Shutdown()
if a.OnQuit != nil {
a.OnQuit()
return
}
os.Exit(0) os.Exit(0)
}() }()
return "ok", nil return "ok", nil
+121
View File
@@ -0,0 +1,121 @@
package apphost
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// eventBroker fans out lightweight UI notifications (SSE).
type eventBroker struct {
mu sync.Mutex
subs map[chan []byte]struct{}
}
func newEventBroker() *eventBroker {
return &eventBroker{subs: make(map[chan []byte]struct{})}
}
func (b *eventBroker) subscribe() chan []byte {
ch := make(chan []byte, 8)
b.mu.Lock()
b.subs[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBroker) unsubscribe(ch chan []byte) {
b.mu.Lock()
if _, ok := b.subs[ch]; ok {
delete(b.subs, ch)
close(ch)
}
b.mu.Unlock()
}
func (b *eventBroker) publish(payload []byte) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- payload:
default:
// Drop if a slow client is behind; safety-net poll will catch up.
}
}
}
// NotifyState invalidates the idle rev cache and pushes a small SSE payload.
func (a *App) NotifyState(kind string) {
if a == nil {
return
}
a.invalidateRevCache()
if a.events == nil {
return
}
if kind == "" {
kind = "state"
}
b, err := json.Marshal(map[string]string{"type": kind})
if err != nil {
return
}
a.events.publish(b)
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
if a.APIToken != "" {
tok := r.Header.Get("X-Navis-Token")
if tok == "" {
tok = r.URL.Query().Get("t")
}
if tok != a.APIToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := a.events.subscribe()
defer a.events.unsubscribe(ch)
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-a.ctx.Done():
return
case <-ping.C:
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
return
}
flusher.Flush()
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package apphost
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
)
func TestHTTPBridgeGetState(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
body, _ := json.Marshal(map[string]any{"args": []any{}})
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Navis-Token", "test-token")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
}
var resp struct {
Result UIState `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Error != "" {
t.Fatalf("api error: %s", resp.Error)
}
if resp.Result.ActiveProfile != "demo" {
t.Fatalf("active_profile=%q", resp.Result.ActiveProfile)
}
}
func TestHTTPBridgeGetStateUnchanged(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
full, err := a.GetStateSince("")
if err != nil || full.Unchanged || full.Rev == "" {
t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err)
}
again, err := a.GetStateSince(full.Rev)
if err != nil || !again.Unchanged || again.Rev != full.Rev {
t.Fatalf("delta: %+v err=%v", again, err)
}
if len(again.Profiles) != 0 {
t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles))
}
}
func TestHTTPBridgeRejectsBadToken(t *testing.T) {
cfg := &config.Config{}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "secret"
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader([]byte(`{"args":[]}`)))
req.Header.Set("X-Navis-Token", "wrong")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rr.Code)
}
}
+31 -7
View File
@@ -42,6 +42,8 @@
--ms-mid: #b8860b; --ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12); --focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7); --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"] { html[data-theme="dark"] {
--ink: #e6f4ee; --ink: #e6f4ee;
@@ -90,7 +92,7 @@
html, body { html, body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
font-family: Figtree, sans-serif; font-family: var(--font-ui);
color: var(--ink); color: var(--ink);
background: var(--page-bg); background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease); transition: color .2s var(--ease), background .25s var(--ease);
@@ -130,7 +132,7 @@
} }
.update-banner.show { display: block; } .update-banner.show { display: block; }
.update-banner strong { .update-banner strong {
font-family: Sora, sans-serif; font-family: var(--font-display);
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
font-size: .95rem; font-size: .95rem;
@@ -199,7 +201,7 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.brand { .brand {
font-family: Sora, sans-serif; font-family: var(--font-display);
font-size: 1.7rem; font-size: 1.7rem;
font-weight: 800; font-weight: 800;
letter-spacing: -0.045em; letter-spacing: -0.045em;
@@ -319,7 +321,7 @@
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin: 0 0 8px; margin: 0 0 8px;
font-family: Sora, sans-serif; font-family: var(--font-display);
font-size: .78rem; font-size: .78rem;
font-weight: 700; font-weight: 700;
letter-spacing: .04em; letter-spacing: .04em;
@@ -528,7 +530,7 @@
width: 100%; width: 100%;
min-height: 52px; min-height: 52px;
font-size: 1.02rem; font-size: 1.02rem;
font-family: Sora, sans-serif; font-family: var(--font-display);
} }
.actions { display: grid; gap: 8px; } .actions { display: grid; gap: 8px; }
@@ -548,6 +550,28 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.server-toolbar .section-title { margin: 0; } .server-toolbar .section-title { margin: 0; }
.server-search-wrap {
margin: 0 0 8px;
}
.server-search-wrap input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(8, 90, 68, 0.18);
background: var(--field-bg, rgba(255,255,255,.55));
color: inherit;
border-radius: 12px;
padding: 9px 12px;
font: inherit;
font-size: .88rem;
outline: none;
}
.server-search-wrap input:focus {
border-color: rgba(37, 88, 75, 0.45);
}
[data-theme="dark"] .server-search-wrap input {
background: rgba(0,0,0,.22);
border-color: rgba(180, 220, 200, 0.14);
}
.server-actions { .server-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -727,7 +751,7 @@
background: var(--shop-bg); background: var(--shop-bg);
} }
.shop h3 { .shop h3 {
font-family: Sora, sans-serif; font-family: var(--font-display);
font-size: 1rem; font-size: 1rem;
letter-spacing: -0.02em; letter-spacing: -0.02em;
margin: 0 0 5px; margin: 0 0 5px;
@@ -784,7 +808,7 @@
color: var(--ink); color: var(--ink);
} }
.modal h2 { .modal h2 {
font-family: Sora, sans-serif; font-family: var(--font-display);
margin: 0 0 12px; margin: 0 0 12px;
font-size: 1.2rem; font-size: 1.2rem;
letter-spacing: -.02em; letter-spacing: -.02em;
+194 -49
View File
@@ -1,9 +1,10 @@
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives). // HTTP bridge for glaze GUI (Windows + macOS): POST /api/<method>.
(function () { (function () {
const methods = [ const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile", "getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest", "selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit" "checkUpdate","applyUpdate","saveHy2","importSubscription","exportServers","importServers",
"getLogs","probeTunnel","savePrefs","quit"
]; ];
if (typeof window.getState === "function") return; if (typeof window.getState === "function") return;
window.__navisHttp = true; window.__navisHttp = true;
@@ -49,8 +50,11 @@
const shopLink = $("shopLink"); const shopLink = $("shopLink");
const pingBtn = $("pingBtn"); const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn"); const bestBtn = $("bestBtn");
const exportServersBtn = $("exportServersBtn");
const importServersBtn = $("importServersBtn");
const autoBest = $("autoBest"); const autoBest = $("autoBest");
const serverList = $("serverList"); const serverList = $("serverList");
const serverSearch = $("serverSearch");
const updCheckBtn = $("updCheckBtn"); const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner"); const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn"); const updateBtn = $("updateBtn");
@@ -208,6 +212,7 @@
} }
let listFP = ""; let listFP = "";
let stateRev = "";
function profilesFingerprint(state) { function profilesFingerprint(state) {
const parts = [ const parts = [
@@ -250,61 +255,104 @@
return list; return list;
} }
function filteredProfiles() {
const q = (serverSearch && serverSearch.value || "").trim().toLowerCase();
const list = orderedProfiles();
if (!q) return list;
return list.filter((p) => {
const hay = [p.name, p.host, p.protocol].map((x) => String(x || "").toLowerCase()).join(" ");
return hay.includes(q);
});
}
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) { function renderServerList(activeName) {
const active = activeName || profile.value; const active = activeName || profile.value;
serverList.innerHTML = ""; const all = orderedProfiles();
const list = orderedProfiles(); const list = filteredProfiles();
if (!list.length) { const keep = new Set();
if (!all.length || !list.length) {
serverList.innerHTML = "";
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "server-empty"; empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку"; empty.textContent = !all.length
? "Нет серверов — добавьте профиль или подписку"
: "Ничего не найдено";
serverList.appendChild(empty); serverList.appendChild(empty);
return; return;
} }
// Drop empty-state nodes from prior renders.
serverList.querySelectorAll(".server-empty").forEach((el) => el.remove());
list.forEach((p) => { list.forEach((p) => {
const row = document.createElement("button"); keep.add(p.name);
row.type = "button"; const row = ensureServerRow(p);
row.className = "server-row" + (p.name === active ? " active" : ""); row.className = "server-row" + (p.name === active ? " active" : "");
row.dataset.name = p.name; const nameEl = row.querySelector(".name");
const sub = row.querySelector(".sub");
const left = document.createElement("div"); const proto = row.querySelector(".proto");
left.className = "left"; const ms = row.querySelector(".ms");
const nameEl = document.createElement("div"); if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name;
nameEl.className = "name"; const hostText = p.host || "нет хоста";
nameEl.textContent = p.name; if (sub && sub.textContent !== hostText) sub.textContent = hostText;
const sub = document.createElement("div"); const protoText = (p.protocol || "?").toString();
sub.className = "sub"; if (proto && proto.textContent !== protoText) proto.textContent = protoText;
sub.textContent = p.host || "нет хоста"; const pl = pingLabel(pingMap[p.name]);
left.appendChild(nameEl); if (ms) {
left.appendChild(sub); if (ms.className !== pl.cls) ms.className = pl.cls;
if (ms.textContent !== pl.text) ms.textContent = pl.text;
const right = document.createElement("div"); if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || "";
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); serverList.appendChild(row); // re-order if needed
right.appendChild(ms); });
row.appendChild(left); serverList.querySelectorAll(".server-row").forEach((row) => {
row.appendChild(right); if (!keep.has(row.dataset.name)) row.remove();
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
}); });
} }
@@ -380,6 +428,8 @@
coreBtn.disabled = busy; coreBtn.disabled = busy;
pingBtn.disabled = busy; pingBtn.disabled = busy;
bestBtn.disabled = busy; bestBtn.disabled = busy;
if (exportServersBtn) exportServersBtn.disabled = busy;
if (importServersBtn) importServersBtn.disabled = busy || connected;
updCheckBtn.disabled = busy; updCheckBtn.disabled = busy;
updateBtn.disabled = busy; updateBtn.disabled = busy;
subBtn.disabled = busy; subBtn.disabled = busy;
@@ -444,13 +494,22 @@
} }
async function refresh(opts) { async function refresh(opts) {
const force = !!(opts && opts.force);
if (document.hidden && !force && !(opts && opts.syncForm)) {
return null;
}
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty); const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state; let state;
if (needSecrets && typeof getEditState === "function") { if (needSecrets && typeof getEditState === "function") {
state = await getEditState(); state = await getEditState();
} else { } else {
state = await getState(); state = await getState(stateRev || "");
} }
if (state && state.unchanged) {
if (state.rev) stateRev = state.rev;
return state;
}
if (state && state.rev) stateRev = state.rev;
paint(state, opts); paint(state, opts);
return state; return state;
} }
@@ -482,7 +541,7 @@
} }
function paintButtonsLocked(locked) { function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => { [btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, exportServersBtn, importServersBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
if (b) b.disabled = locked; if (b) b.disabled = locked;
}); });
} }
@@ -645,6 +704,37 @@
} catch (e) { setMeta(String(e), "err"); } } catch (e) { setMeta(String(e), "err"); }
})); }));
if (exportServersBtn) exportServersBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Экспорт списка…");
const n = await exportServers();
if (!n) {
setMeta("Экспорт отменён");
return;
}
setMeta("Экспортировано серверов: " + n, "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
if (importServersBtn) importServersBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Заменить весь список серверами из файла? Текущие профили будут удалены.")) {
return;
}
setMeta("Импорт списка…");
const n = await importServers();
if (!n) {
setMeta("Импорт отменён");
return;
}
pingMap = {};
dirty = false;
formHydrated = false;
await refresh({ syncForm: true, force: true });
setMeta("Импортировано серверов: " + n + " (список заменён)", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
updCheckBtn.addEventListener("click", () => withBusy(async () => { updCheckBtn.addEventListener("click", () => withBusy(async () => {
try { try {
setMeta("Проверка обновлений…"); setMeta("Проверка обновлений…");
@@ -697,5 +787,60 @@
setMeta(String(e), "err"); setMeta(String(e), "err");
} }
})(); })();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000); if (serverSearch) {
serverSearch.addEventListener("input", () => renderServerList(profile.value));
}
function typingInField(el) {
if (!el) return false;
const tag = (el.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable;
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("open")) {
modal.classList.remove("open");
e.preventDefault();
return;
}
if (typingInField(document.activeElement)) return;
if ((e.key === "/" || (e.key === "f" && (e.ctrlKey || e.metaKey))) && serverSearch) {
e.preventDefault();
serverSearch.focus();
serverSearch.select();
return;
}
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
btn.click();
}
});
// Safety-net poll; primary updates come from SSE /api/events when available.
setInterval(() => {
if (document.hidden || busy || modal.classList.contains("open")) return;
refresh().catch(() => {});
}, 12000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
});
(function startEventStream() {
if (!window.__navisHttp || typeof EventSource === "undefined") return;
const tok = window.__NAVIS_TOKEN__ || "";
const url = "/api/events" + (tok ? ("?t=" + encodeURIComponent(tok)) : "");
let es;
const connectES = () => {
try { if (es) es.close(); } catch (_) {}
es = new EventSource(url);
es.onmessage = () => {
if (!busy) refresh({ force: true }).catch(() => {});
};
es.onerror = () => {
try { es.close(); } catch (_) {}
setTimeout(connectES, 2500);
};
};
connectES();
})();
+1 -1
View File
@@ -14,7 +14,7 @@ var appCSS string
//go:embed app.js //go:embed app.js
var appJS string var appJS string
// IndexHTML is the full GUI document (CSS/JS inlined for WebView2 / glaze). // IndexHTML is the full GUI document (CSS/JS inlined for glaze HTTP UI).
var IndexHTML = buildIndexHTML() var IndexHTML = buildIndexHTML()
func buildIndexHTML() string { func buildIndexHTML() string {
+6 -3
View File
@@ -3,10 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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> <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" /> <link rel="stylesheet" href="app.css" />
<script> <script>
@@ -81,10 +79,15 @@
<div class="server-actions"> <div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button> <button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button> <button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="mini" id="exportServersBtn" type="button" title="Сохранить список в файл">Экспорт</button>
<button class="mini" id="importServersBtn" type="button" title="Заменить список из файла">Импорт</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button> <button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button> <button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
</div> </div>
</div> </div>
<div class="server-search-wrap">
<input id="serverSearch" type="search" placeholder="Поиск по имени, хосту, протоколу…" spellcheck="false" autocomplete="off" />
</div>
<label class="auto-best"> <label class="auto-best">
<input id="autoBest" type="checkbox" /> <input id="autoBest" type="checkbox" />
Авто: пинг + подключение к лучшему Авто: пинг + подключение к лучшему
+11 -2
View File
@@ -325,8 +325,9 @@ func WriteExample(path string) error {
return Save(path, Example()) return Save(path, Example())
} }
// Save writes config as indented JSON. // Save writes config as indented JSON (atomic replace).
func Save(path string, cfg Config) error { func Save(path string, cfg Config) error {
InvalidateHostCache()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return err return err
} }
@@ -341,7 +342,15 @@ func Save(path string, cfg Config) error {
return err return err
} }
} }
return os.WriteFile(path, data, 0o600) tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
} }
// SetActiveProxy updates the active profile proxy URI. // SetActiveProxy updates the active profile proxy URI.
+44 -2
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"strings" "strings"
"sync"
) )
// ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns). // ProfileInfo is a safe summary for UI lists (no secrets beyond proxy URI the user already owns).
@@ -14,6 +15,47 @@ type ProfileInfo struct {
Active bool `json:"active"` Active bool `json:"active"`
} }
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
const hostCacheMax = 2048
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
if v, ok := hostCache.Load(proxy); ok {
return v.(string)
}
h := proxyHost(proxy)
hostCache.Store(proxy, h)
// Soft bound: subscription churn can retain stale URI keys; drop all if oversized.
n := 0
over := false
hostCache.Range(func(_, _ any) bool {
n++
if n > hostCacheMax {
over = true
return false
}
return true
})
if over {
InvalidateHostCache()
hostCache.Store(proxy, h)
}
return h
}
// InvalidateHostCache clears parsed host strings (call after profile mutations).
func InvalidateHostCache() {
hostCache.Range(func(k, _ any) bool {
hostCache.Delete(k)
return true
})
}
// ListProfiles returns UI-friendly profile summaries. // ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo { func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles)) out := make([]ProfileInfo, 0, len(c.Profiles))
@@ -22,7 +64,7 @@ func (c *Config) ListProfiles() []ProfileInfo {
Name: p.Name, Name: p.Name,
Protocol: string(p.Protocol), Protocol: string(p.Protocol),
Proxy: p.Proxy, Proxy: p.Proxy,
Host: proxyHost(p.Proxy), Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active, Active: p.Name == c.Active,
}) })
} }
@@ -36,7 +78,7 @@ func (c *Config) ListProfilesForPoll() []ProfileInfo {
out = append(out, ProfileInfo{ out = append(out, ProfileInfo{
Name: p.Name, Name: p.Name,
Protocol: string(p.Protocol), Protocol: string(p.Protocol),
Host: proxyHost(p.Proxy), Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active, Active: p.Name == c.Active,
}) })
} }
+132
View File
@@ -0,0 +1,132 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
)
const (
ServersFileFormat = "navis-servers"
ServersFileVersion = 1
)
// ServersFile is the on-disk export/import format for the profile list.
type ServersFile struct {
Format string `json:"format,omitempty"`
Version int `json:"version,omitempty"`
Active string `json:"active,omitempty"`
Profiles []Profile `json:"profiles"`
}
// ExportServersFile builds a portable snapshot of profiles + active name.
func ExportServersFile(cfg *Config) ServersFile {
profiles := append([]Profile(nil), cfg.Profiles...)
return ServersFile{
Format: ServersFileFormat,
Version: ServersFileVersion,
Active: cfg.Active,
Profiles: profiles,
}
}
// WriteServersFile writes the export snapshot as indented JSON (0600).
func WriteServersFile(path string, cfg *Config) (int, error) {
doc := ExportServersFile(cfg)
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return 0, err
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o600); err != nil {
return 0, err
}
return len(doc.Profiles), nil
}
// ParseServersFile parses export JSON. Accepts:
// - { "format":"navis-servers", "profiles":[...], "active":"..." }
// - { "profiles":[...] }
// - a bare JSON array of profiles
func ParseServersFile(data []byte) (ServersFile, error) {
data = bytes.TrimSpace(stripUTF8BOM(data))
if len(data) == 0 {
return ServersFile{}, fmt.Errorf("пустой файл списка серверов")
}
var doc ServersFile
if data[0] == '[' {
var profiles []Profile
if err := json.Unmarshal(data, &profiles); err != nil {
return ServersFile{}, fmt.Errorf("разбор списка серверов: %w", err)
}
doc.Profiles = profiles
} else {
if err := json.Unmarshal(data, &doc); err != nil {
return ServersFile{}, fmt.Errorf("разбор файла серверов: %w", err)
}
}
if doc.Format != "" && doc.Format != ServersFileFormat {
return ServersFile{}, fmt.Errorf("неизвестный формат %q (ожидается %s)", doc.Format, ServersFileFormat)
}
if err := normalizeServersFile(&doc); err != nil {
return ServersFile{}, err
}
return doc, nil
}
// ReadServersFile loads and validates a servers export file.
func ReadServersFile(path string) (ServersFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return ServersFile{}, err
}
return ParseServersFile(data)
}
func normalizeServersFile(doc *ServersFile) error {
if len(doc.Profiles) == 0 {
return fmt.Errorf("в файле нет серверов")
}
seen := make(map[string]struct{}, len(doc.Profiles))
for i := range doc.Profiles {
p := &doc.Profiles[i]
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
return fmt.Errorf("профиль[%d]: пустое имя", i)
}
if _, ok := seen[p.Name]; ok {
return fmt.Errorf("дублируется имя профиля %q", p.Name)
}
seen[p.Name] = struct{}{}
switch p.Protocol {
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan:
case "":
p.Protocol = ProtocolNaive
default:
return fmt.Errorf("профиль %q: неподдерживаемый протокол %q", p.Name, p.Protocol)
}
}
doc.Active = strings.TrimSpace(doc.Active)
if doc.Active == "" {
doc.Active = doc.Profiles[0].Name
} else if _, ok := seen[doc.Active]; !ok {
doc.Active = doc.Profiles[0].Name
}
return nil
}
// ApplyServersReplace replaces profiles with the import document (prefs untouched).
func (c *Config) ApplyServersReplace(doc ServersFile) error {
if err := normalizeServersFile(&doc); err != nil {
return err
}
c.Profiles = append([]Profile(nil), doc.Profiles...)
c.Active = doc.Active
c.SubscriptionNames = nil
InvalidateHostCache()
return c.Validate()
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestServersFileRoundTrip(t *testing.T) {
cfg := &Config{
Active: "b",
Profiles: []Profile{
{Name: "a", Protocol: ProtocolNaive, Proxy: "https://u:p@a.example"},
{Name: "b", Protocol: ProtocolHysteria2, Proxy: "hy2://tok@b.example:443"},
},
SystemProxy: true,
ConnectOnLaunch: true,
SubscriptionURL: "https://sub.example/x",
}
dir := t.TempDir()
path := filepath.Join(dir, "servers.json")
n, err := WriteServersFile(path, cfg)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("exported %d, want 2", n)
}
other := &Config{
Active: "old",
SystemProxy: false,
ConnectOnLaunch: false,
SubscriptionURL: "https://keep.example",
SubscriptionNames: []string{"old"},
Profiles: []Profile{
{Name: "old", Protocol: ProtocolNaive, Proxy: "https://old"},
},
}
doc, err := ReadServersFile(path)
if err != nil {
t.Fatal(err)
}
if err := other.ApplyServersReplace(doc); err != nil {
t.Fatal(err)
}
if other.SubscriptionURL != "https://keep.example" {
t.Fatalf("prefs changed: %q", other.SubscriptionURL)
}
if other.SystemProxy {
t.Fatal("system_proxy should stay false")
}
if len(other.Profiles) != 2 || other.Active != "b" {
t.Fatalf("got active=%q profiles=%+v", other.Active, other.Profiles)
}
if other.SubscriptionNames != nil {
t.Fatalf("subscription_names should clear, got %v", other.SubscriptionNames)
}
}
func TestParseServersFileBareArray(t *testing.T) {
doc, err := ParseServersFile([]byte(`[{"name":"x","protocol":"naive","proxy":"https://x"}]`))
if err != nil {
t.Fatal(err)
}
if len(doc.Profiles) != 1 || doc.Active != "x" {
t.Fatalf("unexpected: %+v", doc)
}
}
func TestParseServersFileEmptyRejected(t *testing.T) {
if _, err := ParseServersFile([]byte(`{"profiles":[]}`)); err == nil {
t.Fatal("expected error")
}
if _, err := ParseServersFile([]byte(`[]`)); err == nil {
t.Fatal("expected error")
}
}
func TestWriteServersFilePermissions(t *testing.T) {
cfg := &Config{
Active: "a",
Profiles: []Profile{{Name: "a", Protocol: ProtocolNaive}},
}
path := filepath.Join(t.TempDir(), "out.json")
if _, err := WriteServersFile(path, cfg); err != nil {
t.Fatal(err)
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Size() == 0 {
t.Fatal("empty file")
}
}
+91 -11
View File
@@ -16,6 +16,8 @@ import (
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/corebin" "vpnclient/internal/corebin"
"vpnclient/internal/linknorm" "vpnclient/internal/linknorm"
"vpnclient/internal/listenutil"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg" "vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive" "vpnclient/internal/protocols/naive"
@@ -108,12 +110,19 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
stderr := m.stderr stderr := m.stderr
m.mu.Unlock() 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) engine, err := m.newEngine(profileCopy.Protocol)
if err != nil { if err != nil {
return err return err
} }
if err := engine.Start(ctx, profileCopy, binDir); err != nil { if err := engine.Start(ctx, profileCopy, binDir); err != nil {
return err return listenutil.ClassifyStartFailure(string(profileCopy.Protocol), err, listenAddrs...)
} }
m.mu.Lock() m.mu.Lock()
@@ -350,6 +359,20 @@ func (m *Manager) Config() *config.Config {
return m.cfg.Clone() return m.cfg.Clone()
} }
// ActiveProxyURI returns the active profile proxy without cloning the whole config.
func (m *Manager) ActiveProxyURI() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return "", fmt.Errorf("no config")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return "", err
}
return p.Proxy, nil
}
// UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies). // UIPoll is a cheap manager snapshot for GUI polling (no JSON deep-clone of all proxies).
type UIPoll struct { type UIPoll struct {
Status Status Status Status
@@ -366,19 +389,20 @@ type UIPoll struct {
AutoReconnect bool AutoReconnect bool
} }
// PollUI gathers status + profile list under one lock without Config().Clone(). // PollUI gathers status + profile list without Config().Clone().
// Config/profile fields are copied under the manager lock; engine status is
// read after unlock so a slow Engine.Running cannot block config saves.
func (m *Manager) PollUI(includeSecrets bool) UIPoll { func (m *Manager) PollUI(includeSecrets bool) UIPoll {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{ out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir, BinDir: m.binDir,
SystemProxy: false, SystemProxy: false,
Subscription: "", Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"}, Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError, LastError: m.lastError,
} }
var eng Engine
var profCopy *config.Profile
if m.cfg != nil { if m.cfg != nil {
out.Active = m.cfg.Active out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy out.SystemProxy = m.cfg.SystemProxy
@@ -410,16 +434,25 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
} }
} }
} }
if m.engine != nil && m.engine.Running() { eng = m.engine
if m.profile != nil {
p := *m.profile
profCopy = &p
}
sys := m.sys
m.mu.Unlock()
out.Status = Status{SystemProxy: sys.Enabled()}
if eng != nil && eng.Running() {
out.Status.Connected = true out.Status.Connected = true
if m.profile != nil { if profCopy != nil {
out.Status.Profile = m.profile.Name out.Status.Profile = profCopy.Name
out.Status.Protocol = m.profile.Protocol out.Status.Protocol = profCopy.Protocol
} }
if hp, ok := m.engine.LocalHTTPProxy(); ok { if hp, ok := eng.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp out.Status.HTTPProxy = hp
} }
if sp, ok := m.engine.LocalSOCKSProxy(); ok { if sp, ok := eng.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp out.Status.SOCKSProxy = sp
} }
} }
@@ -596,12 +629,59 @@ func (m *Manager) DeleteProfile(name string) error {
return config.Save(m.cfgPath, *m.cfg) return config.Save(m.cfgPath, *m.cfg)
} }
// ExportServers writes the current profile list (with secrets) to path.
func (m *Manager) ExportServers(path string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return config.WriteServersFile(path, m.cfg)
}
// ImportServersReplace replaces all profiles with those from path.
// Prefs (system proxy, reconnect, subscription URL) are preserved.
func (m *Manager) ImportServersReplace(path string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return 0, fmt.Errorf("сначала отключитесь")
}
doc, err := config.ReadServersFile(path)
if err != nil {
return 0, err
}
if err := m.cfg.ApplyServersReplace(doc); err != nil {
return 0, err
}
for i := range m.cfg.Profiles {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
}
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(m.cfg.Profiles), nil
}
func (m *Manager) Profiles() []config.ProfileInfo { func (m *Manager) Profiles() []config.ProfileInfo {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
return m.cfg.ListProfiles() 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. // Hy2Options are editable Hysteria 2 extras for the active profile.
type Hy2Options struct { type Hy2Options struct {
Congestion string `json:"congestion"` Congestion string `json:"congestion"`
+26 -10
View File
@@ -1,13 +1,16 @@
package corebin package corebin
import ( import (
"os" "strings"
"sync" "sync"
"golang.org/x/sync/singleflight"
) )
var ( var (
mu sync.Mutex mu sync.Mutex
cache = map[string]string{} cache = map[string]string{}
group singleflight.Group
) )
// CacheKey identifies a resolved core binary for a binDir + protocol. // CacheKey identifies a resolved core binary for a binDir + protocol.
@@ -15,6 +18,11 @@ func CacheKey(binDir, proto string) string {
return binDir + "\x00" + proto return binDir + "\x00" + proto
} }
// VirtualPath reports paths that are not on-disk binaries (e.g. embedded AWG).
func VirtualPath(path string) bool {
return strings.HasPrefix(path, "embedded-")
}
// Get returns a cached absolute path if present. // Get returns a cached absolute path if present.
func Get(key string) (string, bool) { func Get(key string) (string, bool) {
mu.Lock() mu.Lock()
@@ -33,28 +41,36 @@ func Set(key, path string) {
mu.Unlock() mu.Unlock()
} }
// Invalidate clears all cached paths (call after EnsureCore / binDir change). // Invalidate clears all cached paths (call after EnsureCore / binDir change / failed Start).
func Invalidate() { func Invalidate() {
mu.Lock() mu.Lock()
cache = map[string]string{} cache = map[string]string{}
mu.Unlock() mu.Unlock()
} }
// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed. // Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// Embedded/virtual paths never touch the filesystem.
// Concurrent cold misses for the same key share one fn call (singleflight).
func Resolve(key string, fn func() (string, error)) (string, error) { func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok { if path, ok := Get(key); ok {
if _, err := os.Stat(path); err == nil { return path, nil
}
v, err, _ := group.Do(key, func() (any, error) {
if path, ok := Get(key); ok {
return path, nil return path, nil
} }
mu.Lock() path, err := fn()
delete(cache, key) if err != nil {
mu.Unlock() return "", err
} }
path, err := fn() Set(key, path)
return path, nil
})
if err != nil { if err != nil {
return "", err return "", err
} }
Set(key, path) path, _ := v.(string)
return path, nil return path, nil
} }
+61 -14
View File
@@ -3,10 +3,13 @@ package corebin
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"testing" "testing"
"time"
) )
func TestResolveCacheAndStale(t *testing.T) { func TestResolveTrustsCacheWithoutStat(t *testing.T) {
Invalidate() Invalidate()
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "core") path := filepath.Join(dir, "core")
@@ -20,26 +23,70 @@ func TestResolveCacheAndStale(t *testing.T) {
return path, nil return path, nil
} }
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 { if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("first: path=%q calls=%d err=%v", p, calls, err) t.Fatalf("first: %q %v calls=%d", p, err, calls)
} }
_ = os.Remove(path) // missing on disk — cache still trusted until Invalidate
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 { if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err) t.Fatalf("cached without Stat: %q %v calls=%d", p, err, calls)
} }
_ = os.Remove(path) Invalidate()
if _, err := Resolve(key, fn); err != nil { if _, err := Resolve(key, fn); err == nil && calls != 2 {
// fn returns removed path; Resolve still succeeds but next Stat misses // fn returns deleted path; Resolve still succeeds
t.Fatalf("unexpected err after delete before recreate: %v", err)
} }
if calls != 2 { if calls != 2 {
t.Fatalf("expected refresh call, got %d", calls) t.Fatalf("after Invalidate expected refresh, calls=%d", calls)
} }
if err := os.WriteFile(path, []byte("y"), 0o755); err != nil { }
t.Fatal(err)
func TestResolveSingleflight(t *testing.T) {
Invalidate()
key := CacheKey(t.TempDir(), "naive")
var calls atomic.Int32
var started sync.WaitGroup
started.Add(1)
fn := func() (string, error) {
calls.Add(1)
started.Wait()
time.Sleep(20 * time.Millisecond)
return "embedded-test-core", nil
} }
if p, err := Resolve(key, fn); err != nil || p != path { var wg sync.WaitGroup
t.Fatalf("after recreate: %q %v", p, err) for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := Resolve(key, fn); err != nil {
t.Errorf("resolve: %v", err)
}
}()
} }
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" { time.Sleep(10 * time.Millisecond)
t.Fatal("ProtoKey") started.Done()
wg.Wait()
if calls.Load() != 1 {
t.Fatalf("expected 1 cold resolve, got %d", calls.Load())
}
}
func TestVirtualEmbeddedAWG(t *testing.T) {
Invalidate()
key := CacheKey("/bin", "awg")
calls := 0
p, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p != "embedded-amneziawg-go" || calls != 1 {
t.Fatalf("%q %v calls=%d", p, err, calls)
}
if !VirtualPath(p) {
t.Fatal("expected virtual")
}
p2, err := Resolve(key, func() (string, error) {
calls++
return "embedded-amneziawg-go", nil
})
if err != nil || p2 != p || calls != 1 {
t.Fatalf("second: %q calls=%d", p2, calls)
} }
} }
+6
View File
@@ -12,6 +12,9 @@ import (
"github.com/ebitengine/purego/objc" "github.com/ebitengine/purego/objc"
) )
// BindWindow is a no-op on macOS (Dock uses NSApplication icon image).
func BindWindow(h unsafe.Pointer) { _ = h }
//go:embed icon_idle.png //go:embed icon_idle.png
var iconIdlePNG []byte var iconIdlePNG []byte
@@ -103,4 +106,7 @@ func SetConnected(connected bool) {
} }
// AppKit must run on the main thread (glaze owns it). // AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false) app.Send(selPerf, selSet, img, false)
if OnIconChange != nil {
OnIconChange(connected)
}
} }
+7 -2
View File
@@ -1,6 +1,11 @@
//go:build !darwin //go:build !darwin && !windows
package dockicon package dockicon
// SetConnected is a no-op outside macOS (Dock icons are a Darwin feature). import "unsafe"
// SetConnected is a no-op on platforms without a Dock/taskbar icon swap.
func SetConnected(connected bool) {} func SetConnected(connected bool) {}
// BindWindow is a no-op outside Windows.
func BindWindow(h unsafe.Pointer) { _ = h }
+295
View File
@@ -0,0 +1,295 @@
//go:build windows
package dockicon
import (
"bytes"
_ "embed"
"image"
"image/png"
"sync"
"unsafe"
"golang.org/x/image/draw"
"golang.org/x/sys/windows"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
user32 = windows.NewLazySystemDLL("user32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
procSendMessage = user32.NewProc("SendMessageW")
procCreateIconFromResource = user32.NewProc("CreateIconFromResourceEx")
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
procDestroyIcon = user32.NewProc("DestroyIcon")
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
procCreateBitmap = gdi32.NewProc("CreateBitmap")
procDeleteObject = gdi32.NewProc("DeleteObject")
mu sync.Mutex
hwnd uintptr
last *bool
idleSmall windows.Handle
idleBig windows.Handle
connSmall windows.Handle
connBig windows.Handle
iconsReady bool
)
const (
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
biRGB = 0
dibRGBColors = 0
)
type iconInfo struct {
FIcon int32
XHotspot uint32
YHotspot uint32
HbmMask windows.Handle
HbmColor windows.Handle
}
type bitmapInfoHeader struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
type bitmapInfo struct {
Header bitmapInfoHeader
Colors [1]uint32
}
// BindWindow attaches the main HWND so SetConnected can update the taskbar icon.
func BindWindow(h unsafe.Pointer) {
mu.Lock()
defer mu.Unlock()
hwnd = uintptr(h)
if err := ensureIconsLocked(); err != nil {
return
}
connected := last != nil && *last
applyLocked(connected)
}
// SetConnected swaps the taskbar/window icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
if hwnd == 0 {
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
return
}
if err := ensureIconsLocked(); err != nil {
mu.Unlock()
return
}
applyLocked(connected)
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
}
// StatusIcon returns a shared HICON for the current connection state (do not DestroyIcon).
func StatusIcon(connected bool) windows.Handle {
mu.Lock()
defer mu.Unlock()
if err := ensureIconsLocked(); err != nil {
return 0
}
if connected {
return connBig
}
return idleBig
}
func applyLocked(connected bool) {
if hwnd == 0 {
return
}
small, big := idleSmall, idleBig
if connected {
small, big = connSmall, connBig
}
if small != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconSmall, uintptr(small))
}
if big != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconBig, uintptr(big))
}
}
func ensureIconsLocked() error {
if iconsReady {
return nil
}
var err error
idleSmall, err = hiconFromPNG(iconIdlePNG, 16)
if err != nil {
return err
}
idleBig, err = hiconFromPNG(iconIdlePNG, 32)
if err != nil {
return err
}
connSmall, err = hiconFromPNG(iconConnectedPNG, 16)
if err != nil {
return err
}
connBig, err = hiconFromPNG(iconConnectedPNG, 32)
if err != nil {
return err
}
iconsReady = true
return nil
}
func hiconFromPNG(src []byte, size int) (windows.Handle, error) {
resized, err := resizePNG(src, size)
if err != nil {
return 0, err
}
if h := createIconFromPNGResource(resized, size); h != 0 {
return h, nil
}
return createIconFromImage(resized, size)
}
func resizePNG(src []byte, size int) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(src))
if err != nil {
return nil, err
}
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func createIconFromPNGResource(pngBytes []byte, size int) windows.Handle {
if len(pngBytes) == 0 {
return 0
}
h, _, _ := procCreateIconFromResource.Call(
uintptr(unsafe.Pointer(&pngBytes[0])),
uintptr(len(pngBytes)),
1,
0x00030000,
uintptr(size),
uintptr(size),
0,
)
return windows.Handle(h)
}
func createIconFromImage(pngBytes []byte, size int) (windows.Handle, error) {
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return 0, err
}
rgba, ok := img.(*image.RGBA)
if !ok {
tmp := image.NewRGBA(img.Bounds())
draw.Draw(tmp, tmp.Bounds(), img, img.Bounds().Min, draw.Src)
rgba = tmp
}
hbmColor, err := dibFromRGBA(rgba)
if err != nil {
return 0, err
}
// Monochrome mask: all zeros => fully opaque where color has alpha handled by 32-bit DIB.
hbmMask, _, err2 := procCreateBitmap.Call(uintptr(size), uintptr(size), 1, 1, 0)
if hbmMask == 0 {
procDeleteObject.Call(uintptr(hbmColor))
if err2 != nil {
return 0, err2
}
return 0, windows.ERROR_INVALID_HANDLE
}
ii := iconInfo{
FIcon: 1,
XHotspot: 0,
YHotspot: 0,
HbmMask: windows.Handle(hbmMask),
HbmColor: hbmColor,
}
h, _, err3 := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&ii)))
procDeleteObject.Call(uintptr(hbmMask))
procDeleteObject.Call(uintptr(hbmColor))
if h == 0 {
if err3 != nil {
return 0, err3
}
return 0, windows.ERROR_INVALID_HANDLE
}
return windows.Handle(h), nil
}
func dibFromRGBA(src *image.RGBA) (windows.Handle, error) {
w := src.Bounds().Dx()
h := src.Bounds().Dy()
bi := bitmapInfo{
Header: bitmapInfoHeader{
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
BiWidth: int32(w),
BiHeight: -int32(h), // top-down
BiPlanes: 1,
BiBitCount: 32,
BiCompression: biRGB,
},
}
var bits unsafe.Pointer
hbm, _, err := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&bi)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
if hbm == 0 || bits == nil {
if err != nil {
return 0, err
}
return 0, windows.ERROR_INVALID_HANDLE
}
// Windows wants BGRA.
dst := unsafe.Slice((*byte)(bits), w*h*4)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := src.PixOffset(x, y)
o := (y*w + x) * 4
dst[o+0] = src.Pix[i+2] // B
dst[o+1] = src.Pix[i+1] // G
dst[o+2] = src.Pix[i+0] // R
dst[o+3] = src.Pix[i+3] // A
}
}
return windows.Handle(hbm), nil
}
+5
View File
@@ -0,0 +1,5 @@
package dockicon
// OnIconChange is invoked after SetConnected updates the platform icon.
// Used on Windows to keep the tray glyph in sync; unused on other platforms.
var OnIconChange func(connected bool)
+9
View File
@@ -0,0 +1,9 @@
package filedialog
import "errors"
// ErrCanceled is returned when the user closes the dialog without choosing a file.
var ErrCanceled = errors.New("filedialog: canceled")
// ErrUnsupported is returned on platforms without a native picker.
var ErrUnsupported = errors.New("filedialog: not supported on this platform")
+79
View File
@@ -0,0 +1,79 @@
//go:build darwin
package filedialog
import (
"os/exec"
"strings"
)
func appleScriptQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
script := `try
set thePath to POSIX path of (choose file name with prompt "` + appleScriptQuote(title) + `" default name "` + appleScriptQuote(defaultName) + `")
return thePath
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if strings.Contains(string(out), "canceled") || strings.Contains(err.Error(), "canceled") {
return "", ErrCanceled
}
// osascript writes AppleScript errors to stderr; treat cancel-ish exits as cancel.
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
if !strings.HasSuffix(strings.ToLower(path), ".json") {
path += ".json"
}
return path, nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
script := `try
set theFile to choose file with prompt "` + appleScriptQuote(title) + `" of type {"public.json", "json", "public.plain-text"}
return POSIX path of theFile
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if exit, ok := err.(*exec.ExitError); ok {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !darwin
package filedialog
// SaveJSON is not available on this platform.
func SaveJSON(title, defaultName string) (string, error) {
return "", ErrUnsupported
}
// OpenJSON is not available on this platform.
func OpenJSON(title string) (string, error) {
return "", ErrUnsupported
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows
package filedialog
import (
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
procGetOpenFileName = comdlg32.NewProc("GetOpenFileNameW")
procGetSaveFileName = comdlg32.NewProc("GetSaveFileNameW")
)
const (
ofnExplorer = 0x00080000
ofnFileMustExist = 0x00001000
ofnPathMustExist = 0x00000800
ofnOverwritePrompt = 0x00000002
ofnHideReadOnly = 0x00000004
maxPath = 32768
)
type openFileNameW struct {
LStructSize uint32
HwndOwner windows.HWND
HInstance windows.Handle
LpstrFilter *uint16
LpstrCustomFilter *uint16
NMaxCustFilter uint32
NFilterIndex uint32
LpstrFile *uint16
NMaxFile uint32
LpstrFileTitle *uint16
NMaxFileTitle uint32
LpstrInitialDir *uint16
LpstrTitle *uint16
Flags uint32
NFileOffset uint16
NFileExtension uint16
LpstrDefExt *uint16
LCustData uintptr
LpfnHook uintptr
LpTemplateName *uint16
PvReserved unsafe.Pointer
DwReserved uint32
FlagsEx uint32
}
func jsonFilter() *uint16 {
// Double-null terminated pairs: label\0pattern\0label\0pattern\0\0
s := "JSON (*.json)\x00*.json\x00All files\x00*.*\x00"
return &utf16FromString(s)[0]
}
func utf16FromString(s string) []uint16 {
u, err := syscall.UTF16FromString(s)
if err != nil {
return []uint16{0}
}
return u
}
func ensureJSONExt(path string) string {
if strings.EqualFold(filepath.Ext(path), ".json") {
return path
}
return path + ".json"
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
buf := make([]uint16, maxPath)
copy(buf, utf16FromString(defaultName))
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
defExt, _ := syscall.UTF16PtrFromString("json")
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnPathMustExist | ofnOverwritePrompt | ofnHideReadOnly,
LpstrDefExt: defExt,
}
r, _, _ := procGetSaveFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return ensureJSONExt(path), nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
buf := make([]uint16, maxPath)
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnFileMustExist | ofnPathMustExist | ofnHideReadOnly,
}
r, _, _ := procGetOpenFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+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) }
+82 -10
View File
@@ -29,9 +29,12 @@ type Result struct {
} }
// Target describes one profile to ping. // 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 { type Target struct {
Name string Name string
Protocol config.Protocol Protocol config.Protocol
Host string
Proxy string Proxy string
} }
@@ -57,20 +60,30 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) < workers { if len(targets) < workers {
workers = len(targets) workers = len(targets)
} }
sem := make(chan struct{}, workers) jobs := make(chan int, len(targets))
for i := range targets {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup var wg sync.WaitGroup
for i, t := range targets { for w := 0; w < workers; w++ {
wg.Add(1) wg.Add(1)
go func(i int, t Target) { go func() {
defer wg.Done() defer wg.Done()
sem <- struct{}{} for i := range jobs {
defer func() { <-sem }() t := targets[i]
if strings.TrimSpace(t.Proxy) == "" { if hp := strings.TrimSpace(t.Host); hp != "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} out[i] = PingHost(ctx, t.Name, t.Protocol, hp)
return continue
}
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
continue
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
} }
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) }()
}(i, t)
} }
wg.Wait() wg.Wait()
@@ -108,6 +121,65 @@ func BestOK(results []Result) (Result, bool) {
return best, found 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. // PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused". // Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
// When proto is set, Detect is skipped (trust stored protocol). // 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) { func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0") pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil { 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. // ResolveBinary finds hysteria client binary.
func ResolveBinary(binDir string) (string, error) { func ResolveBinary(binDir string) (string, error) {
names := candidateNames() names := candidateNames()
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
candidates := []string{} candidates := []string{}
for _, name := range names { for _, name := range names {
candidates = append(candidates, candidates = append(candidates,
@@ -24,12 +25,6 @@ func ResolveBinary(binDir string) (string, error) {
filepath.Join(binDir, "hysteria", name), 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 { for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() { if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil return c, nil
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/listenutil"
) )
// Engine runs the official apernet/hysteria client (Hysteria 2). // 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 var startErr error
select { select {
case <-done: 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(): case <-ctx.Done():
_ = e.Stop() _ = e.Stop()
startErr = ctx.Err() startErr = ctx.Err()
+1 -4
View File
@@ -21,14 +21,11 @@ func ResolveBinary(binDir string) (string, error) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
name = "naive.exe" name = "naive.exe"
} }
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
candidates := []string{ candidates := []string{
filepath.Join(binDir, name), filepath.Join(binDir, name),
filepath.Join(binDir, "naiveproxy", 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 { for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() { if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil return c, nil
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/listenutil"
) )
// Engine runs the official Chromium-based naive binary. // 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 var startErr error
select { select {
case <-done: 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(): case <-ctx.Done():
_ = e.Stop() _ = e.Stop()
startErr = ctx.Err() 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. // ResolveBinary finds xray executable.
func ResolveBinary(binDir string) (string, error) { func ResolveBinary(binDir string) (string, error) {
// Only trusted binDir (not beside the app exe) — avoids local binary plant.
for _, name := range candidateNames() { for _, name := range candidateNames() {
candidates := []string{ candidates := []string{
filepath.Join(binDir, name), filepath.Join(binDir, name),
filepath.Join(binDir, "xray", 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 { for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() { if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c, nil return c, nil
+3
View File
@@ -189,6 +189,9 @@ func buildStreamSettings(link Link) (map[string]any, error) {
if link.Host != "" { if link.Host != "" {
xh["host"] = link.Host xh["host"] = link.Host
} }
if link.Mode != "" {
xh["mode"] = link.Mode
}
stream["xhttpSettings"] = xh stream["xhttpSettings"] = xh
default: // tcp default: // tcp
stream["network"] = "tcp" stream["network"] = "tcp"
+6 -1
View File
@@ -11,6 +11,7 @@ import (
"time" "time"
"vpnclient/internal/config" "vpnclient/internal/config"
"vpnclient/internal/listenutil"
) )
// Engine runs official XTLS/Xray-core for VLESS / VMess / Trojan. // 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 var startErr error
select { select {
case <-done: 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(): case <-ctx.Done():
_ = e.Stop() _ = e.Stop()
startErr = ctx.Err() 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)
}
+349 -51
View File
@@ -95,7 +95,7 @@ func ParseBody(body string) ([]Item, error) {
if lastErr != nil { if lastErr != nil {
return nil, lastErr return nil, lastErr
} }
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)") return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
} }
func parseText(text string) ([]Item, error) { func parseText(text string) ([]Item, error) {
@@ -105,7 +105,7 @@ func parseText(text string) ([]Item, error) {
for _, m := range extracted { for _, m := range extracted {
lines = append(lines, strings.TrimRight(m, ".,;)]}\"'")) lines = append(lines, strings.TrimRight(m, ".,;)]}\"'"))
} }
for _, block := range extractClashHY2(text) { for _, block := range extractClashProxies(text) {
lines = append(lines, block) lines = append(lines, block)
} }
@@ -123,6 +123,7 @@ func parseText(text string) ([]Item, error) {
continue continue
} }
// Skip bare https:// without credentials (common noise in HTML/YAML). // Skip bare https:// without credentials (common noise in HTML/YAML).
// Auth must be in the URI authority (before '/'), not path like Qure@master.
if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") { if proto == config.ProtocolNaive && !naiveLinkHasAuth(line) && !strings.HasPrefix(strings.ToLower(line), "naive+") {
continue continue
} }
@@ -149,7 +150,7 @@ func parseText(text string) ([]Item, error) {
out = append(out, Item{Name: name, Protocol: detected, URI: normalized}) out = append(out, Item{Name: name, Protocol: detected, URI: normalized})
} }
if len(out) == 0 { if len(out) == 0 {
return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)") return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок")
} }
return out, nil return out, nil
} }
@@ -159,70 +160,321 @@ func naiveLinkHasAuth(line string) bool {
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} { for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
if strings.HasPrefix(lower, p) { if strings.HasPrefix(lower, p) {
rest := line[len(p):] rest := line[len(p):]
at := strings.IndexByte(rest, '@') authEnd := strings.IndexByte(rest, '/')
authority := rest
if authEnd >= 0 {
authority = rest[:authEnd]
}
at := strings.IndexByte(authority, '@')
return at > 0 return at > 0
} }
} }
return false return false
} }
func extractClashHY2(text string) []string { var reClashProxyName = regexp.MustCompile(`(?m)^[ \t]*-\s+name\s*:\s*`)
// Very small helper for Clash YAML hysteria2 blocks when share URI is absent.
// extractClashProxies converts Clash Meta YAML proxy blocks into share URIs
// (vless / trojan / hysteria2) when the subscription has no share-link lines.
func extractClashProxies(text string) []string {
lower := strings.ToLower(text) lower := strings.ToLower(text)
if !strings.Contains(lower, "type:") || !strings.Contains(lower, "hysteria2") { if !strings.Contains(lower, "type:") {
return nil return nil
} }
if !strings.Contains(lower, "vless") && !strings.Contains(lower, "hysteria2") && !strings.Contains(lower, "trojan") {
return nil
}
scope := text
if sec := yamlTopSection(text, "proxies"); sec != "" {
scope = sec
}
var out []string var out []string
// Split roughly by list items. for _, chunk := range splitClashProxyBlocks(scope) {
chunks := strings.Split(text, "\n- ") if link := clashBlockToShareURI(chunk); link != "" {
for _, chunk := range chunks { out = append(out, link)
cl := strings.ToLower(chunk)
if !strings.Contains(cl, "type:") || !strings.Contains(cl, "hysteria2") {
continue
} }
server := yamlField(chunk, "server")
port := yamlField(chunk, "port")
pass := yamlField(chunk, "password")
if pass == "" {
pass = yamlField(chunk, "auth")
}
if server == "" || port == "" || pass == "" {
continue
}
name := yamlField(chunk, "name")
sni := yamlField(chunk, "sni")
obfs := yamlField(chunk, "obfs")
obfsPass := yamlField(chunk, "obfs-password")
if obfsPass == "" {
obfsPass = yamlField(chunk, "obfs_password")
}
u := url.URL{
Scheme: "hysteria2",
User: url.User(pass),
Host: server + ":" + port,
}
q := url.Values{}
if sni != "" {
q.Set("sni", sni)
}
if obfs != "" {
q.Set("obfs", obfs)
}
if obfsPass != "" {
q.Set("obfs-password", obfsPass)
}
u.RawQuery = q.Encode()
link := u.String()
if name != "" {
link += "#" + url.PathEscape(name)
}
out = append(out, link)
} }
return out return out
} }
func splitClashProxyBlocks(text string) []string {
idxs := reClashProxyName.FindAllStringIndex(text, -1)
if len(idxs) == 0 {
// Legacy fallback used by older Clash dumps.
parts := strings.Split(text, "\n- ")
out := make([]string, 0, len(parts))
for i, p := range parts {
if i == 0 {
continue
}
out = append(out, "name: "+p)
}
return out
}
out := make([]string, 0, len(idxs))
for i, idx := range idxs {
start := idx[0]
end := len(text)
if i+1 < len(idxs) {
end = idxs[i+1][0]
}
out = append(out, text[start:end])
}
return out
}
func clashBlockToShareURI(block string) string {
switch strings.ToLower(yamlField(block, "type")) {
case "hysteria2", "hy2":
return clashHY2URI(block)
case "vless":
return clashVLESSURI(block)
case "trojan":
return clashTrojanURI(block)
default:
return ""
}
}
func clashHY2URI(block string) string {
server := yamlField(block, "server")
port := yamlField(block, "port")
pass := yamlField(block, "password")
if pass == "" {
pass = yamlField(block, "auth")
}
if server == "" || port == "" || pass == "" {
return ""
}
u := url.URL{Scheme: "hysteria2", User: url.User(pass), Host: server + ":" + port}
q := url.Values{}
if sni := yamlField(block, "sni"); sni != "" {
q.Set("sni", sni)
}
if obfs := yamlField(block, "obfs"); obfs != "" {
q.Set("obfs", obfs)
}
obfsPass := yamlField(block, "obfs-password")
if obfsPass == "" {
obfsPass = yamlField(block, "obfs_password")
}
if obfsPass != "" {
q.Set("obfs-password", obfsPass)
}
u.RawQuery = q.Encode()
return withClashName(u.String(), yamlField(block, "name"))
}
func clashVLESSURI(block string) string {
server := yamlField(block, "server")
port := yamlField(block, "port")
uuid := yamlField(block, "uuid")
if server == "" || port == "" || uuid == "" {
return ""
}
network := strings.ToLower(yamlField(block, "network"))
if network == "" {
network = "tcp"
}
if network == "websocket" {
network = "ws"
}
reality := yamlSection(block, "reality-opts")
hasReality := reality != "" || strings.Contains(strings.ToLower(block), "reality-opts")
tlsFlag := strings.ToLower(yamlField(block, "tls"))
security := "none"
switch {
case hasReality:
security = "reality"
case tlsFlag == "true" || tlsFlag == "1":
security = "tls"
case tlsFlag == "false" || tlsFlag == "0":
security = "none"
default:
if s := strings.ToLower(yamlField(block, "security")); s != "" {
security = s
}
}
q := url.Values{}
q.Set("type", network)
q.Set("security", security)
if sni := firstNonEmpty(yamlField(block, "servername"), yamlField(block, "sni")); sni != "" {
q.Set("sni", sni)
}
if fp := yamlField(block, "client-fingerprint"); fp != "" {
q.Set("fp", fp)
}
if flow := yamlField(block, "flow"); flow != "" {
q.Set("flow", flow)
}
if alpn := yamlStringOrList(block, "alpn"); alpn != "" {
q.Set("alpn", alpn)
}
if pe := yamlField(block, "packet-encoding"); pe != "" {
q.Set("packetEncoding", pe)
}
switch network {
case "ws":
ws := yamlSection(block, "ws-opts")
src := ws
if src == "" {
src = block
}
if path := yamlField(src, "path"); path != "" {
q.Set("path", path)
}
if host := clashWSHost(ws); host != "" {
q.Set("host", host)
}
case "grpc", "gun":
grpc := yamlSection(block, "grpc-opts")
if sn := firstNonEmpty(yamlField(grpc, "grpc-service-name"), yamlField(block, "servername")); sn != "" {
q.Set("serviceName", sn)
}
case "xhttp", "splithttp":
xh := yamlSection(block, "xhttp-opts")
src := xh
if src == "" {
src = block
}
if path := yamlField(src, "path"); path != "" {
q.Set("path", path)
}
if host := yamlField(xh, "host"); host != "" {
q.Set("host", host)
}
if mode := yamlField(xh, "mode"); mode != "" {
q.Set("mode", mode)
}
}
if security == "reality" {
if pbk := firstNonEmpty(yamlField(reality, "public-key"), yamlField(block, "public-key")); pbk != "" {
q.Set("pbk", pbk)
}
sid := firstNonEmpty(yamlField(reality, "short-id"), yamlField(block, "short-id"))
q.Set("sid", sid)
if spx := yamlField(reality, "spider-x"); spx != "" {
q.Set("spx", spx)
}
}
if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" {
q.Set("allowInsecure", "1")
}
u := url.URL{Scheme: "vless", User: url.User(uuid), Host: server + ":" + port, RawQuery: q.Encode()}
return withClashName(u.String(), yamlField(block, "name"))
}
func clashTrojanURI(block string) string {
server := yamlField(block, "server")
port := yamlField(block, "port")
pass := yamlField(block, "password")
if server == "" || port == "" || pass == "" {
return ""
}
network := strings.ToLower(yamlField(block, "network"))
if network == "" {
network = "tcp"
}
if network == "websocket" {
network = "ws"
}
security := "tls"
if tlsFlag := strings.ToLower(yamlField(block, "tls")); tlsFlag == "false" || tlsFlag == "0" {
security = "none"
}
q := url.Values{}
q.Set("type", network)
q.Set("security", security)
if sni := firstNonEmpty(yamlField(block, "sni"), yamlField(block, "servername")); sni != "" {
q.Set("sni", sni)
}
if fp := yamlField(block, "client-fingerprint"); fp != "" {
q.Set("fp", fp)
}
if alpn := yamlStringOrList(block, "alpn"); alpn != "" {
q.Set("alpn", alpn)
}
if network == "ws" {
ws := yamlSection(block, "ws-opts")
src := ws
if src == "" {
src = block
}
if path := yamlField(src, "path"); path != "" {
q.Set("path", path)
}
if host := clashWSHost(ws); host != "" {
q.Set("host", host)
}
}
if skip := strings.ToLower(yamlField(block, "skip-cert-verify")); skip == "true" || skip == "1" {
q.Set("allowInsecure", "1")
}
u := url.URL{Scheme: "trojan", User: url.User(pass), Host: server + ":" + port, RawQuery: q.Encode()}
return withClashName(u.String(), yamlField(block, "name"))
}
func clashWSHost(wsOpts string) string {
headers := yamlSection(wsOpts, "headers")
return firstNonEmpty(yamlField(headers, "Host"), yamlField(headers, "host"), yamlField(wsOpts, "host"))
}
func withClashName(link, name string) string {
if name == "" {
return link
}
return link + "#" + url.PathEscape(name)
}
func yamlTopSection(text, key string) string {
re := regexp.MustCompile(`(?im)^` + regexp.QuoteMeta(key) + `\s*:\s*(?:#.*)?$`)
loc := re.FindStringIndex(text)
if loc == nil {
return ""
}
rest := text[loc[1]:]
end := regexp.MustCompile(`(?m)^[A-Za-z0-9_-]+\s*:`).FindStringIndex(rest)
if end == nil {
return rest
}
return rest[:end[0]]
}
func yamlSection(block, key string) string {
lines := strings.Split(block, "\n")
keyLower := strings.ToLower(key) + ":"
capturing := false
parentIndent := 0
var out []string
for _, line := range lines {
indent := 0
for _, r := range line {
if r == ' ' || r == '\t' {
indent++
} else {
break
}
}
trimmed := strings.TrimSpace(line)
if !capturing {
if strings.HasPrefix(strings.ToLower(trimmed), keyLower) {
capturing = true
parentIndent = indent
}
continue
}
if trimmed != "" && indent <= parentIndent {
break
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func yamlField(block, key string) string { func yamlField(block, key string) string {
re := regexp.MustCompile(`(?im)^\s*` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`) // Allow list marker: " - name: …"
// Use [ \t]* after ':' so we do NOT consume newlines (or else "alpn:\n - h2" becomes "- h2").
re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `:[ \t]*["']?([^"'#\n\r]+)["']?`)
m := re.FindStringSubmatch(block) m := re.FindStringSubmatch(block)
if len(m) < 2 { if len(m) < 2 {
return "" return ""
@@ -230,6 +482,52 @@ func yamlField(block, key string) string {
return strings.TrimSpace(m[1]) return strings.TrimSpace(m[1])
} }
func yamlStringOrList(block, key string) string {
single := strings.TrimSpace(yamlField(block, key))
// Scalar must not look like a YAML list item pulled from the next line.
if single != "" && single != "|" && single != ">" && !strings.HasPrefix(single, "-") {
if strings.HasPrefix(single, "[") {
inner := strings.Trim(single, "[]")
parts := strings.Split(inner, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.Trim(strings.TrimSpace(p), `"'`)
if p != "" {
out = append(out, p)
}
}
return strings.Join(out, ",")
}
return single
}
section := yamlSection(block, key)
var items []string
for _, line := range strings.Split(section, "\n") {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "-") {
continue
}
v := strings.TrimSpace(strings.TrimPrefix(t, "-"))
v = strings.Trim(v, `"'`)
if i := strings.IndexByte(v, '#'); i >= 0 {
v = strings.TrimSpace(v[:i])
}
if v != "" {
items = append(items, v)
}
}
return strings.Join(items, ",")
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func decodeMaybeBase64(s string) (string, error) { func decodeMaybeBase64(s string) (string, error) {
s = strings.Map(func(r rune) rune { s = strings.Map(func(r rune) rune {
if unicode.IsSpace(r) { if unicode.IsSpace(r) {
+90 -6
View File
@@ -2,6 +2,8 @@ package subscription
import ( import (
"encoding/base64" "encoding/base64"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -43,18 +45,100 @@ proxies:
sni: de.example.com sni: de.example.com
obfs: salamander obfs: salamander
obfs-password: obfspass obfs-password: obfspass
- name: CZ Vless
type: vless
server: cz.example.com
port: 443
uuid: 7e1c0fbf-1758-4781-97f6-ae8715ed3f60
network: xhttp
tls: true
servername: cz.example.com
alpn:
- h2
- http/1.1
xhttp-opts:
path: /xhttp
host: cz.example.com
mode: auto
client-fingerprint: chrome
- name: CZ Trojan
type: trojan
server: cz.example.com
port: 20000
password: secretpass
network: ws
tls: true
sni: cz.example.com
ws-opts:
path: /ws
proxy-groups:
- name: Auto
icon: https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Proxy.png
type: select
` `
items, err := ParseBody(body) items, err := ParseBody(body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 1 { if len(items) != 3 {
t.Fatalf("got %d", len(items)) t.Fatalf("got %d items: %+v", len(items), items)
} }
if items[0].Protocol != "hysteria2" { byProto := map[string]int{}
t.Fatalf("proto %s", items[0].Protocol) var vlessURI string
for _, it := range items {
byProto[string(it.Protocol)]++
if it.Protocol == "naive" {
t.Fatalf("unexpected naive: %s %s", it.Name, it.URI)
}
if it.Protocol == "vless" {
vlessURI = it.URI
}
} }
if !strings.Contains(items[0].URI, "de.example.com:443") { if byProto["hysteria2"] != 1 || byProto["vless"] != 1 || byProto["trojan"] != 1 {
t.Fatalf("uri %s", items[0].URI) t.Fatalf("protos %+v", byProto)
}
if !strings.Contains(items[0].URI+items[1].URI+items[2].URI, "de.example.com:443") {
t.Fatalf("missing hy2 host in %+v", items)
}
if !strings.Contains(vlessURI, "alpn=h2") {
t.Fatalf("alpn list broken in %s", vlessURI)
}
if strings.Contains(vlessURI, "alpn=-") {
t.Fatalf("alpn bled list marker in %s", vlessURI)
}
if !strings.Contains(vlessURI, "mode=auto") {
t.Fatalf("xhttp mode missing in %s", vlessURI)
}
}
func TestNaiveAuthIgnoresPathAt(t *testing.T) {
if naiveLinkHasAuth("https://cdn.jsdelivr.net/gh/Koolson/Qure@master/IconSet/Color/Proxy.png") {
t.Fatal("path @master must not count as auth")
}
if !naiveLinkHasAuth("https://user:pass@naive.example.com") {
t.Fatal("real auth required")
}
}
func TestParseBodyRealClashSubscriptionFile(t *testing.T) {
path := filepath.Join("testdata", "evilfox_clash.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Skip("optional fixture missing:", path)
}
items, err := ParseBody(string(raw))
if err != nil {
t.Fatal(err)
}
if len(items) < 80 {
t.Fatalf("expected ~89 proxies, got %d", len(items))
}
for _, it := range items {
if it.Protocol == "naive" {
t.Fatalf("naive noise: %s %s", it.Name, it.URI)
}
if strings.EqualFold(it.Name, "master") || strings.HasPrefix(strings.ToLower(it.Name), "master-") {
t.Fatalf("icon false-positive name: %s", it.Name)
}
} }
} }
+40 -40
View File
@@ -13,12 +13,12 @@ import (
) )
type darwinController struct { type darwinController struct {
mu sync.Mutex mu sync.Mutex
enabled bool enabled bool
host string host string
httpPort string httpPort string
socksPort string socksPort string
services []string services []string
} }
func newPlatform() Controller { return &darwinController{} } func newPlatform() Controller { return &darwinController{} }
@@ -47,32 +47,25 @@ func (c *darwinController) Enable(httpHostPort string) error {
return fmt.Errorf("sysproxy: no network services") return fmt.Errorf("sysproxy: no network services")
} }
// One bash process for all networksetup calls (was 6 execs × N services).
// setwebproxy / setsecurewebproxy / setsocksfirewallproxy also turn the proxy on.
var b strings.Builder
b.WriteString("set -e\n")
for _, svc := range services { for _, svc := range services {
if err := runNetworkSetup("-setwebproxy", svc, host, port); err != nil { q := shellQuote(svc)
return err fmt.Fprintf(&b, "networksetup -setwebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
} fmt.Fprintf(&b, "networksetup -setsecurewebproxy %s %s %s\n", q, shellQuote(host), shellQuote(port))
if err := runNetworkSetup("-setsecurewebproxy", svc, host, port); err != nil { fmt.Fprintf(&b, "networksetup -setsocksfirewallproxy %s %s %s\n", q, shellQuote(host), shellQuote(socks))
return err }
} if err := runBash(b.String()); err != nil {
if err := runNetworkSetup("-setsocksfirewallproxy", svc, host, socks); err != nil { return err
return err
}
if err := runNetworkSetup("-setwebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsecurewebproxystate", svc, "on"); err != nil {
return err
}
if err := runNetworkSetup("-setsocksfirewallproxystate", svc, "on"); err != nil {
return err
}
} }
c.enabled = true c.enabled = true
c.host = host c.host = host
c.httpPort = port c.httpPort = port
c.socksPort = socks c.socksPort = socks
c.services = services c.services = append([]string(nil), services...)
return nil return nil
} }
@@ -88,7 +81,8 @@ func (c *darwinController) Disable() error {
func (c *darwinController) ForceDisable() error { func (c *darwinController) ForceDisable() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.disableServices(nil) svcs := c.services
return c.disableServices(svcs)
} }
func (c *darwinController) disableServices(services []string) error { func (c *darwinController) disableServices(services []string) error {
@@ -99,21 +93,23 @@ func (c *darwinController) disableServices(services []string) error {
return err return err
} }
} }
var first error if len(services) == 0 {
for _, svc := range services { c.enabled = false
for _, args := range [][]string{ c.services = nil
{"-setwebproxystate", svc, "off"}, return nil
{"-setsecurewebproxystate", svc, "off"},
{"-setsocksfirewallproxystate", svc, "off"},
} {
if err := runNetworkSetup(args...); err != nil && first == nil {
first = err
}
}
} }
var b strings.Builder
b.WriteString("set +e\n") // best-effort: turn off what we can
for _, svc := range services {
q := shellQuote(svc)
fmt.Fprintf(&b, "networksetup -setwebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsecurewebproxystate %s off\n", q)
fmt.Fprintf(&b, "networksetup -setsocksfirewallproxystate %s off\n", q)
}
err := runBash(b.String())
c.enabled = false c.enabled = false
c.services = nil c.services = nil
return first return err
} }
func (c *darwinController) Enabled() bool { func (c *darwinController) Enabled() bool {
@@ -142,11 +138,15 @@ func listNetworkServices() ([]string, error) {
return services, sc.Err() return services, sc.Err()
} }
func runNetworkSetup(args ...string) error { func runBash(script string) error {
cmd := exec.Command("networksetup", args...) cmd := exec.Command("/bin/bash", "-c", script)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return fmt.Errorf("networksetup %v: %w (%s)", args, err, strings.TrimSpace(string(out))) return fmt.Errorf("networksetup batch: %w (%s)", err, strings.TrimSpace(string(out)))
} }
return nil return nil
} }
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+1
View File
@@ -4,6 +4,7 @@ package trayhost
type Hooks struct { type Hooks struct {
Connect func() Connect func()
Disconnect func() Disconnect func()
Show func()
Quit func() Quit func()
IsUp func() bool IsUp func() bool
} }
+3
View File
@@ -8,3 +8,6 @@ func start(appName string, h Hooks) bool {
_ = h _ = h
return false return false
} }
// SetConnectedIcon is a no-op when tray is unsupported.
func SetConnectedIcon(connected bool) { _ = connected }
+69 -3
View File
@@ -4,8 +4,11 @@ package trayhost
import ( import (
"runtime" "runtime"
"sync"
"unsafe" "unsafe"
"vpnclient/internal/dockicon"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
@@ -47,9 +50,11 @@ const (
tpmRight = 0x0020 tpmRight = 0x0020
tpmBottom = 0x0020 tpmBottom = 0x0020
idiApplication = 32512 idiApplication = 32512
idOpen = 1000
idConnect = 1001 idConnect = 1001
idDisconnect = 1002 idDisconnect = 1002
idQuit = 1003 idQuit = 1003
wmLButtonDblClk = 0x0203
) )
type notifyIconData struct { type notifyIconData struct {
@@ -88,6 +93,12 @@ type msg struct {
Pt point Pt point
} }
var (
trayMu sync.Mutex
trayNID *notifyIconData
trayLastUp *bool
)
func start(appName string, h Hooks) bool { func start(appName string, h Hooks) bool {
if h.Quit == nil { if h.Quit == nil {
return false return false
@@ -100,6 +111,35 @@ func start(appName string, h Hooks) bool {
return true return true
} }
// SetConnectedIcon updates the tray glyph (sea-wave N when connected).
func SetConnectedIcon(connected bool) {
trayMu.Lock()
nid := trayNID
if trayLastUp != nil && *trayLastUp == connected {
trayMu.Unlock()
return
}
v := connected
trayLastUp = &v
trayMu.Unlock()
if nid == nil {
return
}
icon := dockicon.StatusIcon(connected)
if icon == 0 {
return
}
trayMu.Lock()
if trayNID == nil {
trayMu.Unlock()
return
}
trayNID.HIcon = icon
trayNID.UFlags = nifMessage | nifIcon | nifTip
procShellNotify.Call(nimModify, uintptr(unsafe.Pointer(trayNID)))
trayMu.Unlock()
}
func runTray(appName string, h Hooks) { func runTray(appName string, h Hooks) {
className, _ := windows.UTF16PtrFromString("NavisTrayClass") className, _ := windows.UTF16PtrFromString("NavisTrayClass")
title, _ := windows.UTF16PtrFromString(appName) title, _ := windows.UTF16PtrFromString(appName)
@@ -107,12 +147,21 @@ func runTray(appName string, h Hooks) {
var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr { var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
switch msgU { switch msgU {
case wmTray: case wmTray:
if lParam == wmRButtonUp || lParam == wmLButtonUp { switch lParam {
case wmLButtonDblClk:
if h.Show != nil {
go h.Show()
}
case wmRButtonUp, wmLButtonUp:
showMenu(hwnd, h) showMenu(hwnd, h)
} }
return 0 return 0
case wmCommand: case wmCommand:
switch int(wParam & 0xffff) { switch int(wParam & 0xffff) {
case idOpen:
if h.Show != nil {
go h.Show()
}
case idConnect: case idConnect:
if h.Connect != nil { if h.Connect != nil {
go h.Connect() go h.Connect()
@@ -144,17 +193,27 @@ func runTray(appName string, h Hooks) {
return return
} }
icon, _, _ := procLoadIcon.Call(0, uintptr(idiApplication)) icon := dockicon.StatusIcon(false)
if icon == 0 {
h, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
icon = windows.Handle(h)
}
var nid notifyIconData var nid notifyIconData
nid.CbSize = uint32(unsafe.Sizeof(nid)) nid.CbSize = uint32(unsafe.Sizeof(nid))
nid.Hwnd = windows.Handle(hwnd) nid.Hwnd = windows.Handle(hwnd)
nid.UID = 1 nid.UID = 1
nid.UFlags = nifMessage | nifIcon | nifTip nid.UFlags = nifMessage | nifIcon | nifTip
nid.UCallbackMessage = wmTray nid.UCallbackMessage = wmTray
nid.HIcon = windows.Handle(icon) nid.HIcon = icon
tip, _ := windows.UTF16FromString(appName) tip, _ := windows.UTF16FromString(appName)
copy(nid.SzTip[:], tip) copy(nid.SzTip[:], tip)
procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid))) procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid)))
trayMu.Lock()
trayNID = &nid
trayMu.Unlock()
if h.IsUp != nil {
SetConnectedIcon(h.IsUp())
}
var m msg var m msg
for { for {
@@ -165,6 +224,9 @@ func runTray(appName string, h Hooks) {
procTranslate.Call(uintptr(unsafe.Pointer(&m))) procTranslate.Call(uintptr(unsafe.Pointer(&m)))
procDispatch.Call(uintptr(unsafe.Pointer(&m))) procDispatch.Call(uintptr(unsafe.Pointer(&m)))
} }
trayMu.Lock()
trayNID = nil
trayMu.Unlock()
procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid))) procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid)))
} }
@@ -178,6 +240,10 @@ func showMenu(hwnd windows.Handle, h Hooks) {
p, _ := windows.UTF16PtrFromString(text) p, _ := windows.UTF16PtrFromString(text)
procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p))) procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
} }
if h.Show != nil {
add(idOpen, "Открыть")
procAppendMenu.Call(menu, mfSeparator, 0, 0)
}
up := h.IsUp != nil && h.IsUp() up := h.IsUp != nil && h.IsUp()
if up { if up {
add(idDisconnect, "Отключить") add(idDisconnect, "Отключить")
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part, // BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build. // macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 2 const BuildNumber = 13
// DefaultManifestURL is the update feed (hosted in the project git repo). // 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" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+3
View File
@@ -0,0 +1,3 @@
# iOS build artifacts / vendored cores (fetched by scripts/fetch-ios-cores.sh)
Vendor/*.xcframework/
Vendor/*.zip
+502
View File
@@ -0,0 +1,502 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
501BA90A5D5C47249B656C8E /* NavisApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */; };
29889AC5400E4B18A45868CE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F938564D384446A8F846EE /* ContentView.swift */; };
CAD64F5897EE48A1B8131B93 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D1813E91824E3F84A8215A /* AppModel.swift */; };
B3EB850AF0DA4691BAC8523B /* VPNManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ABE496B5F8C4989A0255C39 /* VPNManager.swift */; };
17983FE70A7E47D4B491427A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FC28394BB0E342E3B3545269 /* Assets.xcassets */; };
19D693E2EB4B4837823679C6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */; };
BFD986863DAA4B6181E82F12 /* LibXrayCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */; };
A58C6181B0BE46E5A01F0BAF /* HevBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7C3869B0494261AC7A120B /* HevBridge.swift */; };
99DADE6FA1E04772A1EEDC55 /* TunFileDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */; };
FE37B785DD854B0DA186435B /* Hy2CoreRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */; };
B8009DB719094E40BA6251C1 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78875E829DBA4B06BE893CC9 /* AppConstants.swift */; };
F2B4BCA66E5C400A9AF657CF /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78875E829DBA4B06BE893CC9 /* AppConstants.swift */; };
C94C978BF6B6476AA8045ADB /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2CB1D931A94BC1935E05AD /* Models.swift */; };
4BE777FCB70740A0A09597C7 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED2CB1D931A94BC1935E05AD /* Models.swift */; };
6F35B04C0CD649269631C533 /* LinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58367830ACE545ABB8911A29 /* LinkParser.swift */; };
48C772EDB8004B0CBA837136 /* LinkParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58367830ACE545ABB8911A29 /* LinkParser.swift */; };
3A336046480E4355A4A99EC4 /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D925646CEFA349C491515E42 /* ProfileStore.swift */; };
63308964F6F743118AE95EEB /* ProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D925646CEFA349C491515E42 /* ProfileStore.swift */; };
9906112BB8774DCD8FB3460B /* XrayConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */; };
5F4E3042271545159594346C /* XrayConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */; };
2E8DBDF81D5946C889DDF83B /* Hy2ConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */; };
D771934DFBBB4179A7F66859 /* Hy2ConfigBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */; };
123C88B88F37424081CFC711 /* NavisTunnel.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
45C1C2A9B8F348878DB2C9ED /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */; };
1A51105FD4FE4B1A992BC5F7 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */; };
3701534926EF48B688AFDF44 /* Network.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23490141FD0E4F8898217B74 /* Network.framework */; };
A412B72FF20E4CA08FD1EA4B /* Network.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23490141FD0E4F8898217B74 /* Network.framework */; };
4174897D45564998905F36B3 /* LibXray.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */; };
B058F0D033E8462C8694EAD8 /* HevSocks5Tunnel.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
053168C0782B4A6DB933DAF8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E92EE454FC4F46C1A341FDD0 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 9A86040240754D0BA7D26D9C;
remoteInfo = NavisTunnel;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
128CA5EEC4FD4932991762DC /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
123C88B88F37424081CFC711 /* NavisTunnel.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavisApp.swift; sourceTree = "<group>"; };
A7F938564D384446A8F846EE /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
F4D1813E91824E3F84A8215A /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
6ABE496B5F8C4989A0255C39 /* VPNManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNManager.swift; sourceTree = "<group>"; };
96E3096272794C55A39F63DE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C16CF76BAE864AC1AAF3FA1E /* Navis.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Navis.entitlements; sourceTree = "<group>"; };
FC28394BB0E342E3B3545269 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibXrayCore.swift; sourceTree = "<group>"; };
BB7C3869B0494261AC7A120B /* HevBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HevBridge.swift; sourceTree = "<group>"; };
765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunFileDescriptor.swift; sourceTree = "<group>"; };
8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2CoreRunner.swift; sourceTree = "<group>"; };
D5BB903F8B1240ED8679CF53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9B1C066864904580ABAEE9A2 /* NavisTunnel.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NavisTunnel.entitlements; sourceTree = "<group>"; };
78875E829DBA4B06BE893CC9 /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = "<group>"; };
ED2CB1D931A94BC1935E05AD /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
58367830ACE545ABB8911A29 /* LinkParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkParser.swift; sourceTree = "<group>"; };
D925646CEFA349C491515E42 /* ProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStore.swift; sourceTree = "<group>"; };
3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XrayConfigBuilder.swift; sourceTree = "<group>"; };
C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Hy2ConfigBuilder.swift; sourceTree = "<group>"; };
C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Navis.app; sourceTree = BUILT_PRODUCTS_DIR; };
DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NavisTunnel.appex; sourceTree = BUILT_PRODUCTS_DIR; };
DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
23490141FD0E4F8898217B74 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = System/Library/Frameworks/Network.framework; sourceTree = SDKROOT; };
D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = LibXray.xcframework; sourceTree = "<group>"; };
B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = HevSocks5Tunnel.xcframework; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
212D7469897B4901B8CFD1B1 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
45C1C2A9B8F348878DB2C9ED /* NetworkExtension.framework in Frameworks */,
3701534926EF48B688AFDF44 /* Network.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
0A9449D72F944ABFAFDC733A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1A51105FD4FE4B1A992BC5F7 /* NetworkExtension.framework in Frameworks */,
A412B72FF20E4CA08FD1EA4B /* Network.framework in Frameworks */,
4174897D45564998905F36B3 /* LibXray.xcframework in Frameworks */,
B058F0D033E8462C8694EAD8 /* HevSocks5Tunnel.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
82F1E8BC8B0640589B57B727 = {
isa = PBXGroup;
children = (
2893F6F9622B453F95CF5CBD /* Navis */,
4ADF5EAB51C84F4598CB252E /* NavisTunnel */,
3DA4771E5F0A4119A5FA1CEE /* Shared */,
A7ABA672EF12436BBA8E987F /* Vendor */,
A11DCC2FCC0F4FD78C684784 /* Frameworks */,
6C7E4BA41F5F41AA82BB0592 /* Products */,
);
sourceTree = "<group>";
};
2893F6F9622B453F95CF5CBD /* Navis */ = {
isa = PBXGroup;
children = (
A8C862AA0DF14A52BFAA7DA4 /* NavisApp.swift */,
A7F938564D384446A8F846EE /* ContentView.swift */,
F4D1813E91824E3F84A8215A /* AppModel.swift */,
6ABE496B5F8C4989A0255C39 /* VPNManager.swift */,
96E3096272794C55A39F63DE /* Info.plist */,
C16CF76BAE864AC1AAF3FA1E /* Navis.entitlements */,
FC28394BB0E342E3B3545269 /* Assets.xcassets */,
);
path = Navis;
sourceTree = "<group>";
};
4ADF5EAB51C84F4598CB252E /* NavisTunnel */ = {
isa = PBXGroup;
children = (
8B72E16D41D54F6387EFB252 /* PacketTunnelProvider.swift */,
BAB193EB4D4C4514A7173D5D /* LibXrayCore.swift */,
BB7C3869B0494261AC7A120B /* HevBridge.swift */,
765826485F7C4B62B380EEAB /* TunFileDescriptor.swift */,
8792AB00E67748C1AFC6C4E2 /* Hy2CoreRunner.swift */,
D5BB903F8B1240ED8679CF53 /* Info.plist */,
9B1C066864904580ABAEE9A2 /* NavisTunnel.entitlements */,
);
path = NavisTunnel;
sourceTree = "<group>";
};
3DA4771E5F0A4119A5FA1CEE /* Shared */ = {
isa = PBXGroup;
children = (
78875E829DBA4B06BE893CC9 /* AppConstants.swift */,
ED2CB1D931A94BC1935E05AD /* Models.swift */,
58367830ACE545ABB8911A29 /* LinkParser.swift */,
D925646CEFA349C491515E42 /* ProfileStore.swift */,
3403FBCAA64B46F984F46B86 /* XrayConfigBuilder.swift */,
C87757B7BC3041478F3DC12B /* Hy2ConfigBuilder.swift */,
);
path = Shared;
sourceTree = "<group>";
};
A7ABA672EF12436BBA8E987F /* Vendor */ = {
isa = PBXGroup;
children = (
D56C90149F6E4CD2BF7A8212 /* LibXray.xcframework */,
B77B1677E4FC4E26B5E426CF /* HevSocks5Tunnel.xcframework */,
);
path = Vendor;
sourceTree = "<group>";
};
A11DCC2FCC0F4FD78C684784 /* Frameworks */ = {
isa = PBXGroup;
children = (
DC814C24C0FA4F52A743A692 /* NetworkExtension.framework */,
23490141FD0E4F8898217B74 /* Network.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
6C7E4BA41F5F41AA82BB0592 /* Products */ = {
isa = PBXGroup;
children = (
C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */,
DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1AFC19A4F55C418C868BE1EF /* Navis */ = {
isa = PBXNativeTarget;
buildConfigurationList = C7D1851855154C3BBF498516 /* Build configuration list for PBXNativeTarget "Navis" */;
buildPhases = (
E0195926F50848DAABD5204E /* Sources */,
212D7469897B4901B8CFD1B1 /* Frameworks */,
71A7509E96244B5FA8DB7AAF /* Resources */,
128CA5EEC4FD4932991762DC /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
F1C7CE9231CC407584F72403 /* PBXTargetDependency */,
);
name = Navis;
productName = Navis;
productReference = C4C0B8F1FA9C4BACA4ACBF65 /* Navis.app */;
productType = "com.apple.product-type.application";
};
9A86040240754D0BA7D26D9C /* NavisTunnel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 80004BE6B7BB4FEAB286C65A /* Build configuration list for PBXNativeTarget "NavisTunnel" */;
buildPhases = (
F97950818F2F4B608848DA63 /* Sources */,
0A9449D72F944ABFAFDC733A /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = NavisTunnel;
productName = NavisTunnel;
productReference = DE0AF5CC391F43FD82A92ACC /* NavisTunnel.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E92EE454FC4F46C1A341FDD0 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
};
buildConfigurationList = BBD23656423B456683626FCB /* Build configuration list for PBXProject "Navis" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 82F1E8BC8B0640589B57B727;
productRefGroup = 6C7E4BA41F5F41AA82BB0592 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1AFC19A4F55C418C868BE1EF /* Navis */,
9A86040240754D0BA7D26D9C /* NavisTunnel */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
71A7509E96244B5FA8DB7AAF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
17983FE70A7E47D4B491427A /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
E0195926F50848DAABD5204E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
501BA90A5D5C47249B656C8E /* NavisApp.swift in Sources */,
29889AC5400E4B18A45868CE /* ContentView.swift in Sources */,
CAD64F5897EE48A1B8131B93 /* AppModel.swift in Sources */,
B3EB850AF0DA4691BAC8523B /* VPNManager.swift in Sources */,
B8009DB719094E40BA6251C1 /* AppConstants.swift in Sources */,
C94C978BF6B6476AA8045ADB /* Models.swift in Sources */,
6F35B04C0CD649269631C533 /* LinkParser.swift in Sources */,
3A336046480E4355A4A99EC4 /* ProfileStore.swift in Sources */,
9906112BB8774DCD8FB3460B /* XrayConfigBuilder.swift in Sources */,
2E8DBDF81D5946C889DDF83B /* Hy2ConfigBuilder.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F97950818F2F4B608848DA63 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
19D693E2EB4B4837823679C6 /* PacketTunnelProvider.swift in Sources */,
BFD986863DAA4B6181E82F12 /* LibXrayCore.swift in Sources */,
A58C6181B0BE46E5A01F0BAF /* HevBridge.swift in Sources */,
99DADE6FA1E04772A1EEDC55 /* TunFileDescriptor.swift in Sources */,
FE37B785DD854B0DA186435B /* Hy2CoreRunner.swift in Sources */,
F2B4BCA66E5C400A9AF657CF /* AppConstants.swift in Sources */,
4BE777FCB70740A0A09597C7 /* Models.swift in Sources */,
48C772EDB8004B0CBA837136 /* LinkParser.swift in Sources */,
63308964F6F743118AE95EEB /* ProfileStore.swift in Sources */,
5F4E3042271545159594346C /* XrayConfigBuilder.swift in Sources */,
D771934DFBBB4179A7F66859 /* Hy2ConfigBuilder.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
F1C7CE9231CC407584F72403 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 9A86040240754D0BA7D26D9C /* NavisTunnel */;
targetProxy = 053168C0782B4A6DB933DAF8 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
B7DFA3633E1D4218BD22BAD3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
A49702A686F64D78845646BC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 5.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
7CA659263B144171A9384BCA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Navis/Navis.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "3.8.2.12";
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Navis/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.8.2;
PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
DC73F6B1F44A43E6AA1FCFBA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Navis/Navis.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "3.8.2.12";
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Navis/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.8.2;
PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
A759D328CCC84C0EA622A049 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = NavisTunnel/NavisTunnel.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "3.8.2.12";
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-lresolv",
);
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NavisTunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.8.2;
PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis.tunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
9FD69653B379421BB58E9AF0 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = NavisTunnel/NavisTunnel.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "3.8.2.12";
DEVELOPMENT_TEAM = "";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor",
);
OTHER_LDFLAGS = (
"$(inherited)",
"-lresolv",
);
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = NavisTunnel/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 3.8.2;
PRODUCT_BUNDLE_IDENTIFIER = win.evilfox.navis.tunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BBD23656423B456683626FCB /* Build configuration list for PBXProject "Navis" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B7DFA3633E1D4218BD22BAD3 /* Debug */,
A49702A686F64D78845646BC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C7D1851855154C3BBF498516 /* Build configuration list for PBXNativeTarget "Navis" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7CA659263B144171A9384BCA /* Debug */,
DC73F6B1F44A43E6AA1FCFBA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
80004BE6B7BB4FEAB286C65A /* Build configuration list for PBXNativeTarget "NavisTunnel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A759D328CCC84C0EA622A049 /* Debug */,
9FD69653B379421BB58E9AF0 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = E92EE454FC4F46C1A341FDD0 /* Project object */;
}
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
BuildableName = "Navis.app"
BlueprintName = "Navis"
ReferencedContainer = "container:Navis.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
BuildableName = "Navis.app"
BlueprintName = "Navis"
ReferencedContainer = "container:Navis.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1AFC19A4F55C418C868BE1EF"
BuildableName = "Navis.app"
BlueprintName = "Navis"
ReferencedContainer = "container:Navis.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+182
View File
@@ -0,0 +1,182 @@
import Foundation
import Combine
@MainActor
final class AppModel: ObservableObject {
@Published var profiles: [Profile] = []
@Published var pings: [String: PingResult] = [:]
@Published var activeID: String?
@Published var subURL: String = ""
@Published var autoBest: Bool = false
@Published var busy: Bool = false
@Published var meta: String = "Готово"
@Published var metaErr: Bool = false
@Published var importText: String = ""
let store = ProfileStore()
let vpn = VPNManager()
func refresh() {
profiles = store.loadProfiles()
activeID = store.getActiveID()
subURL = store.getSubURL()
autoBest = store.autoBest()
ensureDefaultSelectionIfNeeded()
if let err = store.tunnelError() {
setMeta(err, err: true)
}
}
/// Only pick a default when nothing valid is selected never override user pick.
func ensureDefaultSelectionIfNeeded() {
if let id = activeID, profiles.contains(where: { $0.id == id }) {
return
}
if let first = profiles.first(where: { $0.protocolKind.supportedOnIOS }) {
select(first.id)
} else if let first = profiles.first {
select(first.id)
}
}
func setMeta(_ msg: String, err: Bool = false) {
meta = msg
metaErr = err
}
func select(_ id: String) {
activeID = id
store.setActiveID(id)
defaultsHintSelected(id)
}
private func defaultsHintSelected(_ id: String) {
if let p = profiles.first(where: { $0.id == id }), !p.protocolKind.supportedOnIOS {
setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол недоступен на iOS", err: true)
} else if metaErr {
setMeta("Выбран сервер")
}
}
func setAutoBest(_ v: Bool) {
autoBest = v
store.setAutoBest(v)
}
func importSubscription() async {
busy = true
defer { busy = false }
do {
let items = try await store.importSubscription(url: subURL)
profiles = items
// Keep previous selection if it still exists; otherwise default.
if let id = activeID, items.contains(where: { $0.id == id }) {
// keep
} else {
activeID = nil
ensureDefaultSelectionIfNeeded()
}
let usable = items.filter { $0.protocolKind.supportedOnIOS }.count
if items.isEmpty {
setMeta("В подписке нет поддерживаемых ссылок", err: true)
} else if usable == 0 {
setMeta("Импортировано \(items.count), но на iOS пока только VLESS/VMess/Trojan", err: true)
} else {
setMeta("Импортировано: \(items.count) (на iOS: \(usable) VLESS/Trojan)")
}
} catch {
setMeta(error.localizedDescription, err: true)
}
}
func importPastedLinks() {
let items = store.importLinks(importText)
if items.isEmpty {
setMeta("Не найдено ссылок", err: true)
} else {
profiles = items
importText = ""
activeID = nil
ensureDefaultSelectionIfNeeded()
let usable = items.filter { $0.protocolKind.supportedOnIOS }.count
setMeta("Импортировано: \(items.count)" + (usable > 0 ? " (iOS: \(usable))" : ""))
}
}
func pingAll() async {
busy = true
defer { busy = false }
setMeta("Пинг…")
let results = await store.pingAll(profiles)
var map: [String: PingResult] = [:]
for r in results { map[r.id] = r }
pings = map
let ok = results.filter(\.ok).count
setMeta("Пинг: \(ok)/\(results.count)")
if autoBest {
let usable = results.filter { r in
profiles.first(where: { $0.id == r.id })?.protocolKind.supportedOnIOS == true
}
if let best = store.bestOf(usable) {
select(best.id)
setMeta("Лучший: \(profiles.first(where: { $0.id == best.id })?.name ?? best.id) \(best.ms) ms")
}
}
}
func connectSelected() async {
guard let id = activeID, let p = profiles.first(where: { $0.id == id }) else {
setMeta("Выберите сервер", err: true)
return
}
// Do NOT silently swap to the first VLESS that looked like «всегда первый».
guard p.protocolKind.supportedOnIOS else {
setMeta(p.protocolKind.iosUnsupportedReason ?? "Протокол не поддержан на iOS", err: true)
return
}
busy = true
defer { busy = false }
do {
setMeta("Подключение \(p.name) (\(p.protocolKind.label))…")
try await vpn.connect(name: p.name, uri: p.uri, store: store)
// Wait for extension: connected, or error in App Group, or timeout.
for _ in 0..<40 {
try? await Task.sleep(nanoseconds: 250_000_000)
await vpn.refreshStatus()
if vpn.status == .connected {
setMeta("Подключено: \(p.name)")
return
}
if let err = store.tunnelError(), !err.isEmpty {
setMeta(err, err: true)
return
}
if vpn.status == .disconnected && !vpn.isStarting {
// Give App Group a moment to flush error from appex.
try? await Task.sleep(nanoseconds: 300_000_000)
if let err = store.tunnelError(), !err.isEmpty {
setMeta(err, err: true)
} else {
setMeta("Не удалось подключить VPN", err: true)
}
return
}
}
if vpn.status == .connected {
setMeta("Подключено: \(p.name)")
} else {
setMeta(store.tunnelError() ?? "Таймаут подключения — проверьте VPN в Настройках", err: true)
}
} catch {
setMeta(error.localizedDescription, err: true)
}
}
func disconnect() async {
busy = false
await vpn.disconnect(store: store)
setMeta("Отключено")
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,20 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+170
View File
@@ -0,0 +1,170 @@
import SwiftUI
import NetworkExtension
struct ContentView: View {
@EnvironmentObject private var model: AppModel
var body: some View {
NavigationStack {
VStack(spacing: 0) {
header
controls
Divider()
profileList
footer
}
.background(Color(uiColor: .systemGroupedBackground))
.navigationTitle("Navis")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Text(
Bundle.main.infoDictionary?["CFBundleVersion"] as? String
?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
?? "3.8.2"
)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("URL подписки", text: $model.subURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
TextField("Или вставьте vless/hy2/… ссылки", text: $model.importText, axis: .vertical)
.lineLimit(2...4)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
}
.padding()
}
private var controls: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
Button("Подписка") {
Task { await model.importSubscription() }
}
.buttonStyle(.borderedProminent)
.disabled(model.busy || model.subURL.trimmingCharacters(in: .whitespaces).isEmpty)
Button("Ссылки") {
model.importPastedLinks()
}
.buttonStyle(.bordered)
.disabled(model.busy || model.importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("Пинг") {
Task { await model.pingAll() }
}
.buttonStyle(.bordered)
.disabled(model.busy || model.profiles.isEmpty)
}
HStack {
Toggle("Автолучший", isOn: Binding(
get: { model.autoBest },
set: { model.setAutoBest($0) }
))
.disabled(model.busy)
Spacer()
vpnButtons
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
@ViewBuilder
private var vpnButtons: some View {
if model.vpn.canDisconnect || model.busy {
Button("Отключить") {
Task { await model.disconnect() }
}
.buttonStyle(.borderedProminent)
.tint(.red)
} else {
Button("Подключить") {
Task { await model.connectSelected() }
}
.buttonStyle(.borderedProminent)
.disabled(model.activeID == nil)
}
}
private var profileList: some View {
List(model.profiles) { p in
Button {
model.select(p.id)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(p.name)
.foregroundStyle(p.protocolKind.supportedOnIOS ? .primary : .secondary)
.lineLimit(1)
Text(subtitle(for: p))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if let ping = model.pings[p.id] {
Text(ping.ok ? "\(ping.ms) ms" : "")
.font(.caption.monospaced())
.foregroundStyle(ping.ok ? .green : .secondary)
}
if model.activeID == p.id {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.blue)
}
}
}
}
.listStyle(.plain)
}
private func subtitle(for p: Profile) -> String {
let base = "\(p.protocolKind.label) · \(p.host)"
if p.protocolKind.supportedOnIOS { return base }
return base + " · недоступен на iOS"
}
private var footer: some View {
VStack(alignment: .leading, spacing: 4) {
Text(statusLine)
.font(.caption)
.foregroundStyle(.secondary)
Text(model.meta)
.font(.footnote)
.foregroundStyle(model.metaErr ? .red : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
.background(Color(uiColor: .secondarySystemBackground))
}
private var statusLine: String {
switch model.vpn.status {
case .invalid:
return "VPN: ожидает разрешение — нажмите Подключить (нужен VLESS/Trojan)"
case .disconnected:
return "VPN: отключён"
case .connecting:
return "VPN: подключение…"
case .connected:
return "VPN: подключён"
case .reasserting:
return "VPN: переподключение…"
case .disconnecting:
return "VPN: отключение…"
@unknown default:
return "VPN: ?"
}
}
}
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Navis</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>3.8.2</string>
<key>CFBundleVersion</key>
<string>3.8.2.13</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.win.evilfox.navis</string>
</array>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More