Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ceb3a5148 | ||
|
|
8b564110d5 | ||
|
|
57d719f02f | ||
|
|
fed1ace33e | ||
|
|
5d7c96f7fc | ||
|
|
2ad376b49e | ||
|
|
ff5d87ab9f | ||
|
|
464a61aebf | ||
|
|
d1570b23d3 |
@@ -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. Не коммитить и не пушить, пока пользователь явно не попросил.
|
||||||
@@ -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; иконка без 400 ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
|
||||||
|
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
|
||||||
|
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
|
||||||
|
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей;
|
||||||
|
- 3.8.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:
|
||||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 18 KiB |
+12
-4
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
3.8.2+2
|
3.8.2+3
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.8.2.2
|
3.8.2.3
|
||||||
|
|||||||
BIN
Binary file not shown.
Vendored
+47
-47
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+47
-47
@@ -1,48 +1,48 @@
|
|||||||
{
|
{
|
||||||
"version": "3.8.2",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
Авто: пинг + подключение к лучшему
|
Авто: пинг + подключение к лучшему
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
func splitAddr(addr string) (host, port string, err error) {
|
||||||
|
return net.SplitHostPort(addr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package listenutil
|
||||||
|
|
||||||
|
// KillOrphanCores is best-effort on non-Windows (no-op).
|
||||||
|
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||||
|
_ = binDir
|
||||||
|
_ = addrs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var coreBasenames = map[string]struct{}{
|
||||||
|
"hysteria.exe": {},
|
||||||
|
"naive.exe": {},
|
||||||
|
"xray.exe": {},
|
||||||
|
"Xray.exe": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillOrphanCores terminates known VPN core processes holding listen addrs.
|
||||||
|
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||||
|
_ = binDir // reserved for future path-scoped sweeps
|
||||||
|
wantPorts := map[string]struct{}{}
|
||||||
|
for _, addr := range ListenAddrs(addrs...) {
|
||||||
|
if _, port, err := splitAddr(addr); err == nil && port != "" {
|
||||||
|
wantPorts[port] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(wantPorts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self := uint32(os.Getpid())
|
||||||
|
var last error
|
||||||
|
for pid, port := range listeningPIDs() {
|
||||||
|
if _, ok := wantPorts[port]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pid == 0 || pid == self {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := queryImagePath(pid)
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if _, ok := coreBasenames[base]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := terminatePID(pid); err != nil {
|
||||||
|
last = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func listeningPIDs() map[uint32]string {
|
||||||
|
out := map[uint32]string{}
|
||||||
|
cmd := exec.Command("netstat", "-ano", "-p", "tcp")
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||||
|
b, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(strings.NewReader(string(b)))
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(fields[3], "LISTENING") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
port := portOf(fields[1])
|
||||||
|
pid64, err := strconv.ParseUint(fields[4], 10, 32)
|
||||||
|
if err != nil || port == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[uint32(pid64)] = port
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func portOf(local string) string {
|
||||||
|
if _, port, err := splitAddr(local); err == nil {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
if i := strings.LastIndex(local, ":"); i >= 0 {
|
||||||
|
return local[i+1:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryImagePath(pid uint32) string {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
var buf [windows.MAX_PATH]uint16
|
||||||
|
size := uint32(len(buf))
|
||||||
|
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return windows.UTF16ToString(buf[:size])
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminatePID(pid uint32) error {
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
if err := windows.TerminateProcess(h, 1); err != nil {
|
||||||
|
return fmt.Errorf("terminate pid %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListenAddrs returns unique non-empty host:port listen targets.
|
||||||
|
func ListenAddrs(addrs ...string) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var out []string
|
||||||
|
for _, hp := range addrs {
|
||||||
|
hp = strings.TrimSpace(hp)
|
||||||
|
if hp == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[hp]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[hp] = struct{}{}
|
||||||
|
out = append(out, hp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// BusyError returns a user-facing error if any addr cannot be bound.
|
||||||
|
func BusyError(addrs ...string) error {
|
||||||
|
var busy []string
|
||||||
|
for _, addr := range addrs {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !canBindTCP(addr) {
|
||||||
|
busy = append(busy, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(busy) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("порт %s занят — закройте другой Navis/ядро или смените listen в профиле", strings.Join(busy, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureFree tries to free orphan cores once, then verifies ports are bindable.
|
||||||
|
func EnsureFree(binDir string, addrs ...string) error {
|
||||||
|
addrs = ListenAddrs(addrs...)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := BusyError(addrs...); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = KillOrphanCores(binDir, addrs...)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if err := BusyError(addrs...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyStartFailure rewrites a generic "exited immediately" into a port-busy
|
||||||
|
// message when listen addresses are occupied.
|
||||||
|
func ClassifyStartFailure(proto string, generic error, addrs ...string) error {
|
||||||
|
if err := BusyError(addrs...); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", proto, err)
|
||||||
|
}
|
||||||
|
return generic
|
||||||
|
}
|
||||||
|
|
||||||
|
func canBindTCP(addr string) bool {
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = ln.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package listenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBusyErrorFreePort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
_ = ln.Close()
|
||||||
|
if err := BusyError(addr); err != nil {
|
||||||
|
t.Fatalf("expected free: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyErrorTakenPort(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
err = BusyError(addr)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "занят") {
|
||||||
|
t.Fatalf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyStartFailure(t *testing.T) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
generic := errString("exited immediately")
|
||||||
|
got := ClassifyStartFailure("hysteria2", generic, ln.Addr().String())
|
||||||
|
if got == nil || !strings.Contains(got.Error(), "занят") {
|
||||||
|
t.Fatalf("got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
+82
-10
@@ -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).
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrAlreadyRunning means another Navis GUI instance holds the lock.
|
||||||
|
var ErrAlreadyRunning = errors.New("Navis уже запущен")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build plan9
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
func flockExclusive(f *os.File) error { return nil }
|
||||||
|
func funlock(f *os.File) error { return nil }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !windows && !plan9
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func flockExclusive(f *os.File) error {
|
||||||
|
return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func funlock(f *os.File) error {
|
||||||
|
return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lock uses an exclusive lock file under the user config dir when available.
|
||||||
|
func Lock(appName string) (func(), error) {
|
||||||
|
if appName == "" {
|
||||||
|
appName = "Navis"
|
||||||
|
}
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, appName, "singleinstance.lock")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
if err := flockExclusive(f); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return nil, fmt.Errorf("%w", ErrAlreadyRunning)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = funlock(f)
|
||||||
|
_ = f.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package singleinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||||
|
procFindWindow = user32.NewProc("FindWindowW")
|
||||||
|
procShowWindow = user32.NewProc("ShowWindow")
|
||||||
|
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||||
|
procIsIconic = user32.NewProc("IsIconic")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
swShow = 5
|
||||||
|
swRestore = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lock acquires a per-user mutex. If another Navis holds it, activates that
|
||||||
|
// window (title "Navis") and returns ErrAlreadyRunning.
|
||||||
|
func Lock(appName string) (func(), error) {
|
||||||
|
if appName == "" {
|
||||||
|
appName = "Navis"
|
||||||
|
}
|
||||||
|
name, err := windows.UTF16PtrFromString("Local\\" + appName + "SingleInstance")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
handle, err := windows.CreateMutex(nil, false, name)
|
||||||
|
if err == windows.ERROR_ALREADY_EXISTS {
|
||||||
|
if handle != 0 {
|
||||||
|
_ = windows.CloseHandle(handle)
|
||||||
|
}
|
||||||
|
activateWindow(appName)
|
||||||
|
return nil, ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("singleinstance: %w", err)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
_ = windows.ReleaseMutex(handle)
|
||||||
|
_ = windows.CloseHandle(handle)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateWindow(title string) {
|
||||||
|
t, err := windows.UTF16PtrFromString(title)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hwnd, _, _ := procFindWindow.Call(0, uintptr(unsafe.Pointer(t)))
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconic, _, _ := procIsIconic.Call(hwnd)
|
||||||
|
if iconic != 0 {
|
||||||
|
procShowWindow.Call(hwnd, swRestore)
|
||||||
|
} else {
|
||||||
|
procShowWindow.Call(hwnd, swShow)
|
||||||
|
}
|
||||||
|
procSetForeground.Call(hwnd)
|
||||||
|
}
|
||||||
+349
-51
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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, "Отключить")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# iOS build artifacts / vendored cores (fetched by scripts/fetch-ios-cores.sh)
|
||||||
|
Vendor/*.xcframework/
|
||||||
|
Vendor/*.zip
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user