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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 17:51:38 +03:00
M4andCursor 57d719f02f Release 3.8.2.11: idle rev-cache, port-safe Connect, single-instance tray UX.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Faster unchanged polls; orphan-core cleanup on listen ports; clearer busy-port errors; cores only from binDir; incremental server list; CSP without Google Fonts; single-instance activates existing window; close-to-tray without kill-others or balloon.

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 03:02:19 +03:00
M4andCursor 838f9e340b Release 3.8.2.2: macOS Dock icon turns sea-wave N when connected.
Swap application icon on connect/disconnect via AppKit; ship arm64 build.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:52:55 +03:00
M4andCursor 621c847cb3 Release 3.8.2.1: reliability, UI probe/logs, CI, signing gates.
Clear sysproxy on core death; probe+reconnect; subscription prune;
Best ignores soft UDP; Windows tray; Android protocol gate; CI workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:44:48 +03:00
M4andCursor 6b6c13c933 Release 3.8.1.1: lighter idle UI poll, core cache, multicore ping.
Reduce idle CPU (cheap PollUI, binary cache, logbuf cap, DOM fingerprint),
speed ping/AWG HostPort, CopyBuffer pool; ship arm64 build and update notes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:27:17 +03:00
M4 bec6c8392d Release 3.8.0.3: fix Hy2/AWG UDP ping false negatives. 2026-07-30 01:49:19 +03:00
M4 6a7480dceb Release 3.8.0.2: app.zip updates, lock hygiene, secret-safe getState. 2026-07-29 22:00:39 +03:00
M4 54b5b87990 Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks. 2026-07-29 21:43:41 +03:00
M4 64c097d1e7 Release 2.7.3.3: dark theme and Amnezia vpn:// import fixes. 2026-07-29 19:49:48 +03:00
M4andCursor 041cbb1250 Release 2.7.3.1: harden proxy recovery, updates and local API.
Restore orphaned system proxy after crash, require update SHA-256, add macOS /api auth token, fix UDP ping false positives, HTTPS-only subscriptions, and keep the UI responsive during connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:48:54 +03:00
M4andCursor dc700f2bac Merge origin/main: Navis 2.7.2+2 with app icon and update UX.
Combine remote update check/skip flow with green N AppIcon packaging for macOS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:08:01 +03:00
M4andCursor 43882b95ad Release 2.7.2.4: macOS app icon, glaze UI and auto build numbers.
Add green N AppIcon to the .app bundle, keep WKWebView GUI stable, and stamp builds via BaseVersion/BuildNumber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:57:18 +03:00
166 changed files with 11743 additions and 2717 deletions
+26
View File
@@ -0,0 +1,26 @@
---
description: After every compile/bump, always write release notes for the new build
alwaysApply: true
---
# Описание изменений после компиляции
После любой успешной сборки с бампом `BuildNumber` (через `build.bat`, `build-macos.bat` / скрипты bump, или ручной bump) **обязательно** обновить описание сделанных изменений — не оставлять только бинарник.
## Что обновлять
1. `notes` в:
- `dist/update.json`
- `dist/navis-release/update.json`
- `server/update.json`
2. Краткую строку в changelog `README.md` (секция текущей версии, формат `x.y.z+N: …`).
3. При релизе Windows-артефакта — `sha256` для `Navis.exe` / `windows-amd64` в тех же `update.json`.
## Как писать notes
- На русском, в стиле существующих записей.
- Начинать с `Navis x.y.z+N:` (как в `DisplayVersion` / build).
- Перечислить суть изменений для пользователя и ключевые тех. точки (не дамп диффа).
- Синхронизировать смысл между `update.json` и `README.md` (README можно короче).
Не редактировать plan-файлы ради notes. Не коммитить и не пушить, пока пользователь явно не попросил.
+31
View File
@@ -0,0 +1,31 @@
name: ci
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Build GUI
shell: bash
run: |
if [[ "${{ runner.os }}" == "Windows" ]]; then
go build -o Navis.exe ./cmd/vpnapp
else
go build -o Navis ./cmd/vpnapp
fi
- name: Build CLI
run: go build -o Navis-cli ./cmd/vpnclient
+31
View File
@@ -0,0 +1,31 @@
name: ci
on:
push:
branches: [main, master]
pull_request:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test
run: go test ./...
- name: Build GUI
shell: bash
run: |
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
go build -o Navis.exe ./cmd/vpnapp
else
go build -o Navis ./cmd/vpnapp
fi
- name: Build CLI
run: go build -o Navis-cli ./cmd/vpnclient
+6
View File
@@ -25,4 +25,10 @@ android/app/src/main/assets/cores/**/hev-socks5-tunnel
*.apk
!dist/navis-release/android/*.apk
# Local backups (pre-release archives)
backups/
# Local Go toolchain
.tools/
+47 -2
View File
@@ -6,8 +6,8 @@
| OS | Артефакты | UI |
|----|----------|----|
| Windows amd64 | `Navis.exe` | GUI (WebView2) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) |
| Windows amd64 | `Navis.exe` | GUI (glaze → WebView2 + HTTP UI) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | GUI (glaze → WKWebView + HTTP UI) |
| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI |
| Android | `Navis.apk` | GUI (VpnService) |
@@ -192,10 +192,14 @@ cd ~/Navis
## Сборка Windows
GUI — тот же HTML UI, что и на macOS: локальный HTTP API + окно [glaze](https://github.com/crgimenes/glaze) (на Windows под капотом Edge WebView2 Runtime).
```bat
build.bat
```
Артефакты: `Navis-<version>.<build>.exe`, `vpnclient-<version>.<build>.exe` (и копии в `dist\navis-release\`).
или:
```bat
@@ -243,6 +247,47 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
В 3.8.2:
- при неожиданном падении ядра снимается системный прокси и sentinel;
- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone;
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool;
- 3.8.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback;
- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS);
- 3.8.2+8: дешёвый idle rev-cache; иконка без 400ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей;
- 3.8.2+12: экспорт/импорт списка серверов в JSON (замена при импорте);
- 3.8.2+13: **iOS** — первый device IPA (`Navis-3.8.2.13.ipa`): Packet Tunnel + LibXray + hev; Clash Meta URL-подписки (vless/trojan/hy2); без подмены выбранного сервера; старт ядра до VPN-маршрутов; hy2/naive на iOS пока недоступны; desktop — полный Clash→share разбор (не только hy2).
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
- список серверов в UI не пересобирается без изменений; интервал опроса 4 с;
- быстрее и точнее пинг (учитывается протокол профиля; лёгкий разбор AWG Endpoint);
- AWG-прокси копирует трафик через буферный pool; PingAll и установка cores используют несколько ядер CPU;
- 3.8.1+1: первая сборка линейки 3.8.1.
В 3.8.0:
- единый GUI-контроллер `apphost` для Windows и macOS (без дублирования логики);
- безопасный lifecycle Connect/Disconnect (ожидание Stop, mutex Windows sysproxy);
- SHA-256 проверка при установке cores (naive / hy2 / xray);
- snapshot конфига в UI (`Config().Clone`);
- 3.8.0+2: обновление Navis.app через zip+SHA; Start вне mutex; getState без секретов; подпись/notarize scripts; UI CSS/JS split;
- 3.8.0+3: пинг Hy2/AWG по UDP — soft-ok при тишине (без ложных «недоступен»).
В 2.7.3:
- восстановление системного прокси после аварийного завершения;
- auth-токен для локального macOS `/api`;
- обязательный SHA-256 при обновлении;
- исправлен ложный UDP ping; HTTPS-only подписки; UI не зависает на connect;
- 2.7.3+2: импорт Amnezia `vpn://` — очистка невидимых символов, создание профиля при активном подключении, ошибка в модалке;
- 2.7.3+3: тёмная тема интерфейса (переключатель в шапке, сохранение выбора).
В 2.7.2:
- убран вечный цикл обновления (проверка больше не устанавливает сама; только кнопка «Обновить»);
- номер сборки в версии (`2.7.2+N` / FileVersion `2.7.2.N`); сравнение обновлений только по product semver.
+4 -4
View File
@@ -1,8 +1,8 @@
# Navis Android
Клиент с UI как на Windows 2.2: список нод, пинг, «лучший», автоподключение, подписка.
Клиент с UI как на Desktop: список нод, пинг, «лучший», автоподключение, подписка.
Версия APK: **2.3.0**
Версия APK: **3.8.2+1** (синхронизируется с Desktop через `scripts/sync-version.py`).
## Протоколы
@@ -10,8 +10,8 @@
|----------|--------------|
| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
| Hysteria 2 | SOCKS core + VpnService |
| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
| NaiveProxy | импорт в UI; подключение отключено (только Desktop) |
| AmneziaWG | импорт в UI; подключение отключено (только Desktop) |
## Сборка
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 2_070_201
versionName = "2.7.2+1"
versionCode = 3080213
versionName = "3.8.2+13"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
@@ -24,6 +24,12 @@ enum class Proto(val label: String) {
}
}
}
/** Desktop has Naive/AWG; this APK only runs Xray + Hysteria2 in VpnService. */
fun supportedOnAndroid(): Boolean = when (this) {
VLESS, VMESS, TROJAN, HYSTERIA2 -> true
else -> false
}
}
data class Profile(
@@ -59,6 +59,10 @@ class MainActivity : ComponentActivity() {
vm.setMeta("Выберите сервер", true)
return
}
if (!p.protocol.supportedOnAndroid()) {
vm.setMeta("${p.protocol.label}: на Android недоступен — используйте VLESS/VMess/Trojan/Hy2", true)
return
}
prepareVpnThen {
startService(
Intent(this, NavisVpnService::class.java)
@@ -139,12 +139,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
val rows = store.pingAll(profiles)
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
val best = store.bestOf(rows) ?: run {
setMeta("нет доступных серверов", true)
val supported = profiles.filter { it.protocol.supportedOnAndroid() }
val supportedRows = rows.filter { r -> supported.any { it.id == r.id } }
val best = store.bestOf(supportedRows) ?: run {
setMeta("нет доступных серверов (Android: VLESS/VMess/Trojan/Hy2)", true)
return
}
store.setActiveId(best.id)
val profile = profiles.first { it.id == best.id }
if (!profile.protocol.supportedOnAndroid()) {
setMeta("${profile.protocol.label}: на Android недоступен", true)
return
}
_state.update { it.copy(activeId = best.id) }
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
if (connect && prepare != null) {
@@ -100,7 +100,7 @@ fun NavisScreen(
Text("2.6.1", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
}
}
Text("Android · Naive · Hy2 · AWG · Xray", color = Muted, fontSize = 13.sp)
Text("Android · Xray · Hy2 (Naive/AWG — только Desktop)", color = Muted, fontSize = 13.sp)
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+12 -4
View File
@@ -1,7 +1,15 @@
@echo off
setlocal
setlocal EnableExtensions
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-amd64" 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
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 2.7.2 -build 2.7.2.1 -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
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 2.7.2 -build 2.7.2.1 -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
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 2.7.2 -build 2.7.2.1 -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
echo Built Mac GUI + CLI:
+66 -6
View File
@@ -1,7 +1,24 @@
@echo off
setlocal
setlocal EnableExtensions
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...
go run ./tools/mkico
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
if errorlevel 1 exit /b 1
echo Building Navis GUI and CLI...
go build -ldflags="-H windowsgui -s -w" -trimpath -o Navis.exe ./cmd/vpnapp
echo Building GUI -^> %OUT_GUI%
go build -ldflags="-H windowsgui -s -w" -trimpath -o "%OUT_GUI%" ./cmd/vpnapp
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
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 Done:
echo Navis.exe
echo vpnclient.exe
echo %OUT_GUI%
echo %OUT_CLI%
echo %DIST%\%OUT_GUI%
echo %DIST%\%OUT_CLI%
echo %DIST%\Navis.exe (update alias)
echo assets\navis.ico
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
+135
View File
@@ -0,0 +1,135 @@
//go:build windows || darwin
package main
import (
"log"
"net/http"
"os"
"os/signal"
"runtime"
"time"
"github.com/crgimenes/glaze"
"vpnclient/internal/apphost"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/dockicon"
"vpnclient/internal/logbuf"
"vpnclient/internal/singleinstance"
"vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
releaseInstance, err := singleinstance.Lock("Navis")
if err != nil {
if err == singleinstance.ErrAlreadyRunning {
// Existing window was activated when possible.
return
}
fatalf("Не удалось получить single-instance lock:\n%v", err)
}
defer releaseInstance()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
fatalf("Не удалось найти путь конфига: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := config.WriteExample(cfgPath); err != nil {
fatalf("Не удалось создать конфиг: %v", err)
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := logbuf.New(0)
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalf("%v", err)
}
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = openExternal
a.OnAfterUpdate = func() {
// Hard-exit so the OS unlocks the binary for replacement.
go func() {
time.Sleep(300 * time.Millisecond)
_ = a.Disconnect()
os.Exit(0)
}()
}
ln, uiURL, token, err := apphost.ListenLocal()
if err != nil {
fatalf("listen: %v", err)
}
a.APIToken = token
srv := &http.Server{
Handler: a.Handler(),
ReadHeaderTimeout: 5 * time.Second,
// No WriteTimeout: SSE /api/events is long-lived.
}
go func() {
_ = srv.Serve(ln)
}()
a.StartBackground()
dockicon.OnIconChange = trayhost.SetConnectedIcon
trayOK := trayhost.Start("Navis", trayhost.Hooks{
Connect: func() { _ = a.Connect() },
Disconnect: func() { _ = a.Disconnect() },
Show: showMainWindow,
Quit: func() {
_ = a.Disconnect()
a.Shutdown()
closeMainWindow()
},
IsUp: func() bool { return a.Mgr.Status().Connected },
})
go a.AutoCheckUpdate()
w, err := glaze.New(false)
if err != nil || w == nil {
log.Printf("webview unavailable (%v); opening default browser", err)
if err2 := openExternal(uiURL); err2 != nil {
_ = srv.Close()
fatalf("Не удалось открыть интерфейс:\n%v\n%v\n%s", err, err2, uiURL)
}
log.Printf("Navis UI: %s", uiURL)
sig := make(chan os.Signal, 1)
signal.Notify(sig, shutdownSignals()...)
<-sig
a.Shutdown()
_ = mgr.Disconnect()
_ = srv.Close()
return
}
a.OnQuit = closeMainWindow
defer func() {
a.Shutdown()
_ = mgr.Disconnect()
_ = srv.Close()
w.Destroy()
}()
w.SetTitle("Navis")
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)
log.Printf("Navis UI: %s (tray=%v hideOnClose=%v)", uiURL, trayOK, trayOK)
w.Run()
}
-104
View File
@@ -1,104 +0,0 @@
//go:build darwin
package main
import (
"bytes"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"time"
"vpnclient/internal/apphost"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/update"
)
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
cfgPath, err := config.LocateConfig()
if err != nil {
fatalf("Не удалось найти путь конфига: %v", err)
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
if err := config.WriteExample(cfgPath); err != nil {
fatalf("Не удалось создать конфиг: %v", err)
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
fatalf("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := &bytes.Buffer{}
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalf("%v", err)
}
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = func(u string) error {
return exec.Command("open", u).Start()
}
a.OnAfterUpdate = func() {
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
}
ln, uiURL, err := apphost.ListenLocal()
if err != nil {
fatalf("listen: %v", err)
}
srv := &http.Server{Handler: a.Handler()}
go func() {
_ = srv.Serve(ln)
}()
go a.AutoCheckUpdate()
if err := openAppWindow(uiURL); err != nil {
log.Printf("open UI: %v — откройте вручную: %s", err, uiURL)
fmt.Println(uiURL)
} else {
log.Printf("Navis UI: %s", uiURL)
}
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
_ = mgr.Disconnect()
_ = srv.Close()
}
func openAppWindow(uiURL string) error {
// Prefer Chromium-based --app window (looks like a desktop client).
for _, app := range []string{"Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser", "Arc"} {
cmd := exec.Command("open", "-na", app, "--args", "--app="+uiURL, "--new-window")
if err := cmd.Start(); err == nil {
return nil
}
}
// Fallback: default browser
return exec.Command("open", uiURL).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)
}
// Ensure we keep a reference for potential future dock icon path helpers.
var _ = filepath.Separator
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
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")
os.Exit(1)
}
-545
View File
@@ -1,545 +0,0 @@
//go:build windows
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/update"
)
type app struct {
mu sync.Mutex
mgr *core.Manager
cfgPath string
logBuf *bytes.Buffer
updateStatus update.Status
pings []netcheck.Result
webview webview2.WebView
}
type uiState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
}
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
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 := &bytes.Buffer{}
mgr, err := core.NewManager(cfgPath, cfg, logBuf)
if err != nil {
fatalDialog("%v", err)
}
a := &app{
mgr: mgr,
cfgPath: cfgPath,
logBuf: logBuf,
updateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
},
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
// Do NOT auto-download cores on startup — silent network+write looks like malware to AV.
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis 2",
Width: 500,
Height: 900,
Center: true,
IconId: 1,
},
})
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
a.webview = w
defer func() {
_ = a.mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.getState)
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", openURL)
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)
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 (a *app) getState() (uiState, error) {
a.mu.Lock()
defer a.mu.Unlock()
st := a.mgr.Status()
cfg := a.mgr.Config()
proxy := ""
active := cfg.Active
if p, err := cfg.ActiveProfile(); err == nil {
proxy = p.Proxy
active = p.Name
}
corePath := ""
coreReady := false
if p, err := cfg.ActiveProfile(); err == nil {
switch p.Protocol {
case config.ProtocolHysteria2:
if path, err := hysteria2.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolAWG:
if path, err := awg.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
if path, err := xray.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
default:
if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
}
} else if path, err := naive.ResolveBinary(a.mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
out := uiState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: cfg.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.cfgPath,
Profiles: cfg.ListProfiles(),
Version: update.DisplayVersion(),
Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.mgr.ActiveHy2Options(),
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
out.Protocol = string(p.Protocol)
}
}
if st.Connected {
out.SystemProxy = st.SystemProxy
}
return out, nil
}
func (a *app) saveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveActiveProfile(name, proxy)
}
func (a *app) createProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("укажите название профиля")
}
a.mgr.SetSystemProxy(systemProxy)
return a.mgr.SaveProfile(name, proxy)
}
func (a *app) selectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SetActiveProfile(name)
}
func (a *app) deleteProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.DeleteProfile(name)
}
func (a *app) connect() error {
return a.connectProfile("")
}
// connectProfile switches to the named profile (if set) and connects.
// If already connected to another profile, disconnects first.
func (a *app) connectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
st := a.mgr.Status()
if st.Connected {
if name == "" || st.Profile == name {
return nil
}
if err := a.mgr.Disconnect(); err != nil {
return err
}
}
if name != "" {
if err := a.mgr.SetActiveProfile(name); err != nil {
return err
}
}
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
return err
} else if strings.TrimSpace(p.Proxy) == "" {
return fmt.Errorf("сначала вставьте ссылку сервера")
}
if _, err := a.mgr.EnsureCore(""); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
return a.mgr.Connect(ctx, "")
}
func (a *app) disconnect() error {
// Do not hold a.mu across engine teardown — getState polling would freeze the UI.
return a.mgr.Disconnect()
}
func (a *app) installCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.mgr.EnsureAllCores()
if err != nil {
return "", err
}
parts := make([]string, 0, len(paths))
for k, v := range paths {
parts = append(parts, k+": "+v)
}
return strings.Join(parts, " | "), nil
}
func (a *app) saveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.mgr.SaveHy2Options(opts)
}
func (a *app) importSubscription(rawURL string) (int, error) {
return a.mgr.ImportSubscription(rawURL)
}
type pingBestResult struct {
Pings []netcheck.Result `json:"pings"`
BestName string `json:"best_name,omitempty"`
BestMs int64 `json:"best_ms,omitempty"`
Selected bool `json:"selected"`
Connected bool `json:"connected"`
}
func (a *app) pingServers() ([]netcheck.Result, error) {
res, err := a.runPings()
return res, err
}
// pingBest measures all nodes, activates the fastest OK one, and optionally connects.
func (a *app) pingBest(autoConnect bool) (pingBestResult, error) {
pings, err := a.runPings()
out := pingBestResult{Pings: pings}
if err != nil {
return out, err
}
best, ok := netcheck.BestOK(pings)
if !ok {
return out, fmt.Errorf("нет доступных серверов")
}
out.BestName = best.Name
out.BestMs = best.Ms
a.mu.Lock()
defer a.mu.Unlock()
st := a.mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest {
if err := a.mgr.Disconnect(); err != nil {
return out, err
}
}
if !alreadyBest {
if err := a.mgr.SetActiveProfile(best.Name); err != nil {
return out, err
}
}
out.Selected = true
if !autoConnect {
return out, nil
}
if alreadyBest {
out.Connected = true
return out, nil
}
if p, err := a.mgr.Config().ActiveProfile(); err != nil {
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
if _, err := a.mgr.EnsureCore(""); err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := a.mgr.Connect(ctx, ""); err != nil {
return out, err
}
out.Connected = true
return out, nil
}
func (a *app) runPings() ([]netcheck.Result, error) {
a.mu.Lock()
list := a.mgr.Profiles()
a.mu.Unlock()
targets := make([]netcheck.Target, 0, len(list))
for _, p := range list {
targets = append(targets, netcheck.Target{
Name: p.Name,
Protocol: config.Protocol(p.Protocol),
Proxy: p.Proxy,
})
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, targets)
a.mu.Lock()
a.pings = out
a.mu.Unlock()
return out, nil
}
func (a *app) checkUpdate() (update.Status, error) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
st, err := update.Check(ctx, update.DefaultManifestURL)
a.mu.Lock()
if err != nil {
st.Current = update.DisplayVersion()
st.ManifestURL = update.DefaultManifestURL
st.Error = err.Error()
}
a.updateStatus = st
a.mu.Unlock()
return st, nil
}
func (a *app) applyUpdate() (string, error) {
// Explicit user action only — never called from autoCheckUpdate.
st, err := a.checkUpdate()
if err != nil {
return "", err
}
if !st.Available {
return "", fmt.Errorf("обновление не требуется (текущая %s)", update.DisplayVersion())
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
latest, err := update.Apply(ctx, update.DefaultManifestURL)
if err != nil {
return "", err
}
_ = a.mgr.Disconnect()
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate (can hang).
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
}
func (a *app) autoCheckUpdate() {
// Only refresh update status for the UI banner — never auto-install.
// (Previously auto-apply caused an infinite update loop when the feed version
// stayed ahead of the shipped CurrentVersion in the downloaded binary.)
time.Sleep(3 * time.Second)
_, _ = a.checkUpdate()
}
func openURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("пустая ссылка")
}
lower := strings.ToLower(raw)
switch {
case lower == "https://evilfox.win/" || lower == "https://evilfox.win" ||
lower == "http://evilfox.win/" || lower == "http://evilfox.win":
raw = "https://evilfox.win/"
case strings.HasPrefix(lower, "https://evilfox.win/") || strings.HasPrefix(lower, "http://evilfox.win/"):
// allow shop deep-links on evilfox.win only
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
return shellOpen(raw)
}
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
user32 := windows.NewLazySystemDLL("user32.dll")
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
func fatalDialog(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
+42
View File
@@ -0,0 +1,42 @@
//go:build darwin
package main
import (
"fmt"
"log"
"os"
"os/exec"
"syscall"
"github.com/crgimenes/glaze"
)
func openExternal(raw string) error {
return exec.Command("open", raw).Start()
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt, syscall.SIGTERM}
}
func decorateWindow(w glaze.WebView, hideOnClose bool) {
// macOS Dock/app icon comes from the .app bundle; close-to-hide is Windows-only.
_ = w
_ = hideOnClose
}
func showMainWindow() {}
func prepareWindowQuit() {}
func closeMainWindow() {
os.Exit(0)
}
+176
View File
@@ -0,0 +1,176 @@
//go:build windows
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"sync/atomic"
"unsafe"
"github.com/crgimenes/glaze"
"golang.org/x/sys/windows"
"vpnclient/internal/dockicon"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW")
procSetWindowLongPtr = user32.NewProc("SetWindowLongPtrW")
procCallWindowProc = user32.NewProc("CallWindowProcW")
procShowWindow = user32.NewProc("ShowWindow")
procSetForeground = user32.NewProc("SetForegroundWindow")
procPostMessage = user32.NewProc("PostMessageW")
procIsIconic = user32.NewProc("IsIconic")
mainHWND uintptr
origWndProc uintptr
closeHookCB uintptr
allowDestroy atomic.Bool
)
const (
// GWLP_WNDPROC as unsigned pointer-sized -4.
gwlpWndProc = ^uintptr(3)
wmClose = 0x0010
swHide = 0
swShow = 5
swRestore = 9
)
func openExternal(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
messageBox(msg)
os.Exit(1)
}
func shutdownSignals() []os.Signal {
return []os.Signal{os.Interrupt}
}
func decorateWindow(w glaze.WebView, hideOnClose bool) {
if w == nil {
return
}
hwnd := w.Window()
applyWindowIcon(hwnd)
dockicon.BindWindow(hwnd)
if hideOnClose {
installCloseToHide(hwnd)
}
}
func installCloseToHide(hwnd unsafe.Pointer) {
if hwnd == nil {
return
}
mainHWND = uintptr(hwnd)
allowDestroy.Store(false)
if closeHookCB == 0 {
closeHookCB = windows.NewCallback(closeToHideWndProc)
}
orig, _, _ := procGetWindowLongPtr.Call(mainHWND, gwlpWndProc)
if orig == 0 {
return
}
origWndProc = orig
procSetWindowLongPtr.Call(mainHWND, gwlpWndProc, closeHookCB)
}
func closeToHideWndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
if msg == wmClose && !allowDestroy.Load() {
procShowWindow.Call(hwnd, uintptr(swHide))
return 0
}
r, _, _ := procCallWindowProc.Call(origWndProc, hwnd, uintptr(msg), wParam, lParam)
return r
}
func showMainWindow() {
if mainHWND == 0 {
return
}
iconic, _, _ := procIsIconic.Call(mainHWND)
if iconic != 0 {
procShowWindow.Call(mainHWND, uintptr(swRestore))
} else {
procShowWindow.Call(mainHWND, uintptr(swShow))
}
procSetForeground.Call(mainHWND)
}
func prepareWindowQuit() {
allowDestroy.Store(true)
}
func closeMainWindow() {
prepareWindowQuit()
if mainHWND == 0 {
os.Exit(0)
return
}
procPostMessage.Call(mainHWND, uintptr(wmClose), 0, 0)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
ico := findIconPath()
if ico == "" || hwnd == nil {
return
}
pathPtr, err := windows.UTF16PtrFromString(ico)
if err != nil {
return
}
loadImage := user32.NewProc("LoadImageW")
sendMessage := user32.NewProc("SendMessageW")
const (
imageIcon = 1
lrLoadFromFile = 0x0010
lrDefaultSize = 0x0040
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
)
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
if h == 0 {
return
}
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
}
func findIconPath() string {
candidates := []string{}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(dir, "assets", "navis.ico"),
filepath.Join(dir, "navis.ico"),
)
}
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
3.8.2+3
+1
View File
@@ -0,0 +1 @@
3.8.2.3
Binary file not shown.
+48 -42
View File
@@ -1,42 +1,48 @@
{
"version": "2.7.2",
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
}
}
}
{
"version": "3.8.2",
"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",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48 -42
View File
@@ -1,42 +1,48 @@
{
"version": "2.7.2",
"notes": "2.7.2: исправлен вечный цикл обновления (только кнопка «Обновить», без автоустановки); номер сборки 2.7.2+N; можно «Пропустить эту версию».",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "9a64a3b059c53eb818ff696d5f446c39f52e243508f92d05365dd06a05c57a1e",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "8fa7fcb40d4165d20d3f81840c84f729af034b54dd4698ea144c7145c85872eb",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
}
}
}
{
"version": "3.8.2",
"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",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"mandatory": false,
"platforms": {
"windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "f95738051a705a4be51db81ceb437dd9bdb8b85a7f050c9cc78b6b4a56e50c1c",
"os": "windows",
"arch": "amd64"
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
"sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc",
"os": "darwin",
"arch": "arm64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip",
"zip_sha256": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67",
"dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
"sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d",
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
"zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
"dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
"sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5",
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
"zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
"zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
"dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
+49
View File
@@ -0,0 +1,49 @@
# Code signing (release)
## macOS
1. Create a **Developer ID Application** certificate in Apple Developer.
2. Store notary credentials once:
```bash
xcrun notarytool store-credentials "navis-notary" \
--apple-id "you@example.com" \
--team-id "TEAMID" \
--password "app-specific-password"
```
3. Build with identity (packmac picks it up):
```bash
export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
./scripts/build-macos-arm64.sh
```
4. Notarize the DMG:
```bash
export NAVIS_NOTARY_PROFILE="navis-notary"
./scripts/sign-macos.sh dist/navis-release/darwin-arm64/Navis.dmg
```
Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
For a **release gate** (fail if unsigned):
```bash
export REQUIRE_CODESIGN=1
export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
./scripts/build-macos-arm64.sh
```
In-app macOS updates **do not** re-sign with ad-hoc `-` (that would strip Developer ID). They only re-sign when `NAVIS_CODESIGN_IDENTITY` is set to a real identity.
## Windows
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
```bat
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a dist\navis-release\Navis.exe
signtool verify /pa dist\navis-release\Navis.exe
```
Then refresh `sha256` in `dist/update.json` (or re-run the Windows release script that stamps it).
+4 -3
View File
@@ -1,12 +1,14 @@
module vpnclient
go 1.25.0
go 1.26.5
require (
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/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/sync v0.22.0
golang.org/x/sys v0.43.0
)
@@ -16,7 +18,6 @@ require (
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect
github.com/google/btree v1.1.3 // 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/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/xattr v0.4.12 // indirect
+6 -6
View File
@@ -2,12 +2,16 @@ github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3Aaf
github.com/amnezia-vpn/amneziawg-go v0.2.19/go.mod h1:aMgOk9MuX0xI7b5TKAYp8pLM54RlXcOPzDvYw3YEO5A=
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/crgimenes/glaze v0.0.33 h1:XZm2cFTSFSY7UarC4w/ziCMJ7Zwkce5Dh1NaO1Koj5Q=
github.com/crgimenes/glaze v0.0.33/go.mod h1:ZuCIST0F5U6wJLw5ZtqfcTIQA7LI/m2MHv7iMOBmu6U=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc=
github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
@@ -18,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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
@@ -44,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/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
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/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
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-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+489 -131
View File
@@ -1,9 +1,12 @@
package apphost
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -16,6 +19,10 @@ import (
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/dockicon"
"vpnclient/internal/filedialog"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
@@ -26,34 +33,48 @@ import (
// App is the GUI controller shared by Windows WebView and macOS HTTP UI.
type App struct {
mu sync.Mutex
Mgr *core.Manager
CfgPath string
LogBuf *bytes.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
mu sync.Mutex
Mgr *core.Manager
CfgPath string
LogBuf *logbuf.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
OnAfterUpdate func()
OnQuit func()
APIToken string
ctx context.Context
cancel context.CancelFunc
events *eventBroker
revMu sync.Mutex
cachedRev string
cachedConn bool
}
type UIState struct {
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
Connected bool `json:"connected"`
Profile string `json:"profile,omitempty"`
ActiveProfile string `json:"active_profile,omitempty"`
Protocol string `json:"protocol,omitempty"`
HTTPProxy string `json:"http_proxy,omitempty"`
SOCKSProxy string `json:"socks_proxy,omitempty"`
SystemProxy bool `json:"system_proxy"`
Proxy string `json:"proxy"`
CoreReady bool `json:"core_ready"`
CorePath string `json:"core_path,omitempty"`
ConfigPath string `json:"config_path"`
Profiles []config.ProfileInfo `json:"profiles"`
Version string `json:"version"`
Update update.Status `json:"update"`
Pings []netcheck.Result `json:"pings"`
Subscription string `json:"subscription_url"`
Hy2 core.Hy2Options `json:"hy2"`
LastError string `json:"last_error,omitempty"`
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
}
type PingBestResult struct {
@@ -64,11 +85,15 @@ type PingBestResult struct {
Connected bool `json:"connected"`
}
func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{
Mgr: mgr,
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
events: newEventBroker(),
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
@@ -76,65 +101,114 @@ func New(mgr *core.Manager, cfgPath string, logBuf *bytes.Buffer) *App {
}
}
func (a *App) GetState() (UIState, error) {
a.mu.Lock()
defer a.mu.Unlock()
// 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()
}
st := a.Mgr.Status()
cfg := a.Mgr.Config()
proxy := ""
active := cfg.Active
if p, err := cfg.ActiveProfile(); err == nil {
proxy = p.Proxy
active = p.Name
}
corePath := ""
coreReady := false
if p, err := cfg.ActiveProfile(); err == nil {
switch p.Protocol {
case config.ProtocolHysteria2:
if path, err := hysteria2.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolAWG:
if path, err := awg.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
if path, err := xray.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
default:
if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
func (a *App) GetState() (UIState, error) {
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
}
} else if path, err := naive.ResolveBinary(a.Mgr.BinDir()); err == nil {
corePath, coreReady = path, true
}
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
a.revMu.Lock()
a.cachedRev = out.Rev
a.cachedConn = out.Connected
a.revMu.Unlock()
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
}
func (a *App) invalidateRevCache() {
a.revMu.Lock()
a.cachedRev = ""
a.revMu.Unlock()
}
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
out, err := a.getState(true)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
return out, nil
}
func (a *App) getState(includeSecrets bool) (UIState, error) {
// Hold App.mu only for fields mutated by update/ping handlers.
a.mu.Lock()
upd := a.UpdateStatus
pings := append([]netcheck.Result(nil), a.Pings...)
cfgPath := a.CfgPath
a.mu.Unlock()
poll := a.Mgr.PollUI(includeSecrets)
st := poll.Status
proxy := poll.ActiveProxy
if !includeSecrets {
proxy = config.RedactProxyURI(proxy)
}
hy2 := poll.Hy2
if !includeSecrets {
hy2.ObfsPassword = ""
}
corePath, coreReady := resolveCoreCached(poll.BinDir, poll.ActiveProtocol)
profiles := poll.Profiles
if includeSecrets {
// Editor needs state.Proxy; list still omits other profiles' URIs.
profiles = config.RedactProfileList(profiles)
}
out := UIState{
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: cfg.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: a.CfgPath,
Profiles: cfg.ListProfiles(),
Version: update.DisplayVersion(),
Update: a.UpdateStatus,
Pings: append([]netcheck.Result(nil), a.Pings...),
Subscription: cfg.SubscriptionURL,
Hy2: a.Mgr.ActiveHy2Options(),
Connected: st.Connected,
Profile: st.Profile,
ActiveProfile: poll.Active,
Protocol: string(st.Protocol),
HTTPProxy: st.HTTPProxy,
SOCKSProxy: st.SOCKSProxy,
SystemProxy: poll.SystemProxy,
Proxy: proxy,
CoreReady: coreReady,
CorePath: corePath,
ConfigPath: cfgPath,
Profiles: profiles,
Version: update.DisplayVersion(),
Update: upd,
Pings: pings,
Subscription: poll.Subscription,
Hy2: hy2,
LastError: poll.LastError,
ConnectOnLaunch: poll.ConnectOnLaunch,
AutoReconnect: poll.AutoReconnect,
}
if includeSecrets && a.LogBuf != nil {
out.LogTail = trimLogTail(a.LogBuf.String(), 4000)
}
if out.Protocol == "" {
if p, err := cfg.ActiveProfile(); err == nil {
out.Protocol = string(p.Protocol)
}
out.Protocol = string(poll.ActiveProtocol)
}
if st.Connected {
out.SystemProxy = st.SystemProxy
@@ -142,81 +216,270 @@ func (a *App) GetState() (UIState, error) {
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 {
if max <= 0 || len(s) <= max {
return s
}
return "…\n" + s[len(s)-max:]
}
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
var err error
switch proto {
case config.ProtocolHysteria2:
path, err = corebin.Resolve(key, func() (string, error) { return hysteria2.ResolveBinary(binDir) })
case config.ProtocolAWG:
path, err = corebin.Resolve(key, func() (string, error) { return awg.ResolveBinary(binDir) })
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
path, err = corebin.Resolve(key, func() (string, error) { return xray.ResolveBinary(binDir) })
default:
path, err = corebin.Resolve(key, func() (string, error) { return naive.ResolveBinary(binDir) })
}
if err != nil {
return "", false
}
return path, true
}
func (a *App) SaveProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveActiveProfile(name, proxy)
err := a.Mgr.SaveActiveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) CreateProfile(name, proxy string, systemProxy bool) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
if name == "" {
a.mu.Unlock()
return fmt.Errorf("укажите название профиля")
}
a.Mgr.SetSystemProxy(systemProxy)
return a.Mgr.SaveProfile(name, proxy)
err := a.Mgr.SaveProfile(name, proxy)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) SelectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.SetActiveProfile(name)
err := 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 {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.DeleteProfile(name)
err := a.Mgr.DeleteProfile(name)
a.mu.Unlock()
if err == nil {
a.NotifyState("profile")
}
return err
}
func (a *App) Connect() error { return a.ConnectProfile("") }
func (a *App) ConnectProfile(name string) error {
a.mu.Lock()
defer a.mu.Unlock()
name = strings.TrimSpace(name)
st := a.Mgr.Status()
if st.Connected {
if name == "" || st.Profile == name {
a.mu.Unlock()
return nil
}
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil {
return err
}
a.mu.Lock()
}
if name != "" {
if err := a.Mgr.SetActiveProfile(name); err != nil {
a.mu.Unlock()
return err
}
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
if _, err := a.Mgr.EnsureCore(""); err != nil {
return err
a.mu.Unlock()
var last error
for attempt := 0; attempt < 2; attempt++ {
if _, err := a.Mgr.EnsureCore(""); err != nil {
a.Mgr.SetLastError(err.Error())
a.NotifyState("error")
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
err := a.Mgr.Connect(ctx, "")
cancel()
if err != nil {
a.Mgr.SetLastError(err.Error())
last = err
continue
}
pctx, pcancel := context.WithTimeout(context.Background(), 12*time.Second)
perr := a.Mgr.Probe(pctx, "")
pcancel()
if perr == nil {
dockicon.SetConnected(true)
a.NotifyState("connected")
return nil
}
last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
a.Mgr.SetLastError(last.Error())
_ = a.Mgr.Disconnect()
dockicon.SetConnected(false)
time.Sleep(400 * time.Millisecond)
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
return a.Mgr.Connect(ctx, "")
dockicon.SetConnected(false)
a.NotifyState("error")
return last
}
func (a *App) Disconnect() error {
return a.Mgr.Disconnect()
err := a.Mgr.Disconnect()
dockicon.SetConnected(false)
a.NotifyState("disconnected")
return err
}
func (a *App) GetLogs() string {
if a.LogBuf == nil {
return ""
}
return trimLogTail(a.LogBuf.String(), 12000)
}
func (a *App) ProbeTunnel() error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
err := a.Mgr.Probe(ctx, "")
if err != nil {
a.Mgr.SetLastError(err.Error())
}
return err
}
func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
err := a.Mgr.SavePrefs(core.Prefs{
ConnectOnLaunch: connectOnLaunch,
AutoReconnect: autoReconnect,
SystemProxy: systemProxy,
})
if err == nil {
a.NotifyState("prefs")
}
return err
}
// StartBackground runs reconnect-on-crash, optional connect-on-launch, and core warmup.
func (a *App) StartBackground() {
go a.watchdogLoop()
dockicon.SetConnected(false)
go a.warmActiveCore()
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected {
return
}
_ = a.Connect()
}()
}
}
// warmActiveCore downloads/resolves the binary for the active profile protocol.
// Non-blocking; Connect still EnsureCore if this has not finished yet.
func (a *App) warmActiveCore() {
select {
case <-a.ctx.Done():
return
case <-time.After(350 * time.Millisecond):
}
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() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
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()
}
}
}
func (a *App) InstallCore() (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
paths, err := a.Mgr.EnsureAllCores()
if err != nil {
return "", err
@@ -225,17 +488,64 @@ func (a *App) InstallCore() (string, error) {
for k, v := range paths {
parts = append(parts, k+": "+v)
}
a.NotifyState("cores")
return strings.Join(parts, " | "), nil
}
func (a *App) SaveHy2(opts core.Hy2Options) error {
a.mu.Lock()
defer a.mu.Unlock()
return a.Mgr.SaveHy2Options(opts)
err := a.Mgr.SaveHy2Options(opts)
a.mu.Unlock()
if err == nil {
a.NotifyState("hy2")
}
return err
}
func (a *App) ImportSubscription(rawURL string) (int, error) {
return a.Mgr.ImportSubscription(rawURL)
n, err := a.Mgr.ImportSubscription(rawURL)
if err == nil {
a.NotifyState("subscription")
}
return n, err
}
// 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) {
@@ -256,38 +566,43 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
out.BestMs = best.Ms
a.mu.Lock()
defer a.mu.Unlock()
st := a.Mgr.Status()
alreadyBest := st.Connected && st.Profile == best.Name
if st.Connected && !alreadyBest {
a.mu.Unlock()
if err := a.Mgr.Disconnect(); err != nil {
return out, err
}
a.mu.Lock()
}
if !alreadyBest {
if err := a.Mgr.SetActiveProfile(best.Name); err != nil {
a.mu.Unlock()
return out, err
}
}
out.Selected = true
if !autoConnect {
a.mu.Unlock()
return out, nil
}
if alreadyBest {
a.mu.Unlock()
out.Connected = true
return out, nil
}
if p, err := a.Mgr.Config().ActiveProfile(); err != nil {
proxy, err := a.Mgr.ActiveProxyURI()
if err != nil {
a.mu.Unlock()
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
}
if strings.TrimSpace(proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
if _, err := a.Mgr.EnsureCore(""); err != nil {
return out, err
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := a.Mgr.Connect(ctx, ""); err != nil {
a.mu.Unlock()
if err := a.ConnectProfile(""); err != nil {
return out, err
}
out.Connected = true
@@ -295,24 +610,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
}
func (a *App) runPings() ([]netcheck.Result, error) {
a.mu.Lock()
list := a.Mgr.Profiles()
a.mu.Unlock()
targets := make([]netcheck.Target, 0, len(list))
for _, p := range list {
targets = append(targets, netcheck.Target{
Name: p.Name,
Protocol: config.Protocol(p.Protocol),
Proxy: p.Proxy,
})
}
targets := a.Mgr.PingTargets()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
out := netcheck.PingAll(ctx, targets)
a.mu.Lock()
a.Pings = out
a.mu.Unlock()
a.NotifyState("pings")
return out, nil
}
@@ -328,6 +633,7 @@ func (a *App) CheckUpdate() (update.Status, error) {
}
a.UpdateStatus = st
a.mu.Unlock()
a.NotifyState("update")
return st, nil
}
@@ -394,17 +700,39 @@ func (a *App) Handler() http.Handler {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, appui.IndexHTML)
html := appui.IndexHTML
if a.APIToken != "" {
// Inject session token for the HTTP bridge (macOS).
html = strings.Replace(html, "</head>",
"<script>window.__NAVIS_TOKEN__="+jsonString(a.APIToken)+";</script></head>", 1)
}
_, _ = io.WriteString(w, html)
})
mux.HandleFunc("/api/events", a.handleEvents)
mux.HandleFunc("/api/", a.handleAPI)
return mux
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func (a *App) handleAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST 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
}
}
name := strings.TrimPrefix(r.URL.Path, "/api/")
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var req struct {
@@ -435,7 +763,9 @@ func arg[T any](args []json.RawMessage, i int, def T) T {
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name {
case "getState":
return a.GetState()
return a.GetStateSince(arg(args, 0, ""))
case "getEditState":
return a.GetEditState()
case "connect":
return nil, a.Connect()
case "disconnect":
@@ -470,10 +800,25 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "exportServers":
return a.ExportServers()
case "importServers":
return a.ImportServers()
case "getLogs":
return a.GetLogs(), nil
case "probeTunnel":
return nil, a.ProbeTunnel()
case "savePrefs":
return nil, a.SavePrefs(arg(args, 0, false), arg(args, 1, false), arg(args, 2, true))
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
_ = a.Mgr.Disconnect()
a.Shutdown()
if a.OnQuit != nil {
a.OnQuit()
return
}
os.Exit(0)
}()
return "ok", nil
@@ -482,12 +827,25 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
}
}
// ListenLocal binds 127.0.0.1:0 and returns listener + URL.
func ListenLocal() (net.Listener, string, error) {
// ListenLocal binds 127.0.0.1:0, generates an API token, and returns listener + URL.
func ListenLocal() (net.Listener, string, string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, "", err
return nil, "", "", err
}
tok, err := randomToken(24)
if err != nil {
_ = ln.Close()
return nil, "", "", err
}
url := fmt.Sprintf("http://%s/", ln.Addr().String())
return ln, url, nil
return ln, url, tok, nil
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+121
View File
@@ -0,0 +1,121 @@
package apphost
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// eventBroker fans out lightweight UI notifications (SSE).
type eventBroker struct {
mu sync.Mutex
subs map[chan []byte]struct{}
}
func newEventBroker() *eventBroker {
return &eventBroker{subs: make(map[chan []byte]struct{})}
}
func (b *eventBroker) subscribe() chan []byte {
ch := make(chan []byte, 8)
b.mu.Lock()
b.subs[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *eventBroker) unsubscribe(ch chan []byte) {
b.mu.Lock()
if _, ok := b.subs[ch]; ok {
delete(b.subs, ch)
close(ch)
}
b.mu.Unlock()
}
func (b *eventBroker) publish(payload []byte) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- payload:
default:
// Drop if a slow client is behind; safety-net poll will catch up.
}
}
}
// NotifyState invalidates the idle rev cache and pushes a small SSE payload.
func (a *App) NotifyState(kind string) {
if a == nil {
return
}
a.invalidateRevCache()
if a.events == nil {
return
}
if kind == "" {
kind = "state"
}
b, err := json.Marshal(map[string]string{"type": kind})
if err != nil {
return
}
a.events.publish(b)
}
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "GET only", http.StatusMethodNotAllowed)
return
}
if a.APIToken != "" {
tok := r.Header.Get("X-Navis-Token")
if tok == "" {
tok = r.URL.Query().Get("t")
}
if tok != a.APIToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "stream unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := a.events.subscribe()
defer a.events.unsubscribe(ch)
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-a.ctx.Done():
return
case <-ping.C:
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
return
}
flusher.Flush()
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package apphost
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
)
func TestHTTPBridgeGetState(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
body, _ := json.Marshal(map[string]any{"args": []any{}})
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Navis-Token", "test-token")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
}
var resp struct {
Result UIState `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Error != "" {
t.Fatalf("api error: %s", resp.Error)
}
if resp.Result.ActiveProfile != "demo" {
t.Fatalf("active_profile=%q", resp.Result.ActiveProfile)
}
}
func TestHTTPBridgeGetStateUnchanged(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
full, err := a.GetStateSince("")
if err != nil || full.Unchanged || full.Rev == "" {
t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err)
}
again, err := a.GetStateSince(full.Rev)
if err != nil || !again.Unchanged || again.Rev != full.Rev {
t.Fatalf("delta: %+v err=%v", again, err)
}
if len(again.Profiles) != 0 {
t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles))
}
}
func TestHTTPBridgeRejectsBadToken(t *testing.T) {
cfg := &config.Config{}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "secret"
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader([]byte(`{"args":[]}`)))
req.Header.Set("X-Navis-Token", "wrong")
rr := httptest.NewRecorder()
a.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rr.Code)
}
}
+842
View File
@@ -0,0 +1,842 @@
:root {
--ink: #0b1c18;
--muted: #5a736b;
--line: rgba(11, 28, 24, 0.10);
--accent: #0d8a66;
--accent-deep: #086b4f;
--accent-soft: #d8f3e9;
--danger: #c0362c;
--ok: #0d8a66;
--surface: rgba(255, 255, 255, 0.82);
--surface-2: rgba(255, 255, 255, 0.62);
--shadow: 0 18px 50px rgba(8, 40, 32, 0.12);
--radius: 22px;
--ease: cubic-bezier(.22,.8,.24,1);
--page-bg:
radial-gradient(780px 420px at 8% -10%, #9fe0c8 0%, transparent 55%),
radial-gradient(640px 380px at 100% 0%, #b8d4e8 0%, transparent 48%),
radial-gradient(500px 320px at 50% 110%, #cfe8dc 0%, transparent 45%),
linear-gradient(165deg, #e7f6f0 0%, #f4faf7 48%, #eef4f8 100%);
--hero-bg: linear-gradient(160deg, rgba(255,255,255,.92), rgba(232,247,240,.88));
--hero-on-bg: linear-gradient(160deg, rgba(216,243,233,.95), rgba(255,255,255,.9));
--input-bg: rgba(255,255,255,.94);
--chip-bg: rgba(255,255,255,.75);
--row-bg: rgba(255,255,255,.72);
--row-active-bg: linear-gradient(135deg, rgba(216,243,233,.95), #fff);
--modal-bg: #f5fbf8;
--modal-scrim: rgba(8, 28, 24, .42);
--soft-btn: linear-gradient(180deg, #d2efe4, #bfe6d6);
--soft-btn-hover: linear-gradient(180deg, #c5e9db, #aedfcb);
--danger-btn: linear-gradient(180deg, #f8d8d4, #efc4bf);
--danger-btn-hover: linear-gradient(180deg, #f3c5bf, #e8aea7);
--danger-ink: #9e2a22;
--danger-ink-hover: #7f1f19;
--meta-bg: rgba(255,255,255,.35);
--meta-ok-bg: rgba(216,243,233,.65);
--meta-err-bg: rgba(255,236,234,.75);
--update-bg: linear-gradient(135deg, rgba(13,138,102,.14), rgba(255,255,255,.75));
--shop-bg: linear-gradient(135deg, rgba(13,138,102,.09), rgba(255,255,255,.55));
--switch-track: #c7d5cf;
--switch-knob: #fff;
--ms-good: #0a7a3e;
--ms-mid: #b8860b;
--focus-ring: rgba(13,138,102,.12);
--theme-btn-bg: rgba(255,255,255,.7);
--font-ui: "Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
--font-display: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif;
}
html[data-theme="dark"] {
--ink: #e6f4ee;
--muted: #8eaaa0;
--line: rgba(230, 244, 238, 0.12);
--accent: #2bbf8a;
--accent-deep: #7ee0b8;
--accent-soft: rgba(43, 191, 138, 0.16);
--danger: #f07167;
--ok: #2bbf8a;
--surface: rgba(18, 28, 26, 0.88);
--surface-2: rgba(28, 42, 38, 0.72);
--shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
--page-bg:
radial-gradient(720px 400px at 6% -12%, rgba(20, 90, 70, .55) 0%, transparent 55%),
radial-gradient(560px 340px at 100% 0%, rgba(30, 55, 80, .45) 0%, transparent 50%),
radial-gradient(480px 300px at 50% 110%, rgba(18, 70, 55, .4) 0%, transparent 45%),
linear-gradient(165deg, #0c1412 0%, #121c1a 48%, #101820 100%);
--hero-bg: linear-gradient(160deg, rgba(28, 42, 38, .95), rgba(22, 34, 31, .9));
--hero-on-bg: linear-gradient(160deg, rgba(24, 70, 55, .55), rgba(28, 42, 38, .95));
--input-bg: rgba(12, 20, 18, .92);
--chip-bg: rgba(12, 20, 18, .75);
--row-bg: rgba(22, 34, 31, .78);
--row-active-bg: linear-gradient(135deg, rgba(43, 191, 138, .18), rgba(28, 42, 38, .95));
--modal-bg: #182420;
--modal-scrim: rgba(0, 0, 0, .58);
--soft-btn: linear-gradient(180deg, #1f3d34, #18332b);
--soft-btn-hover: linear-gradient(180deg, #264d42, #1d3d34);
--danger-btn: linear-gradient(180deg, #4a2a28, #3d2220);
--danger-btn-hover: linear-gradient(180deg, #5a3230, #4a2826);
--danger-ink: #f0a39c;
--danger-ink-hover: #ffc4be;
--meta-bg: rgba(12, 20, 18, .45);
--meta-ok-bg: rgba(43, 191, 138, .14);
--meta-err-bg: rgba(240, 113, 103, .14);
--update-bg: linear-gradient(135deg, rgba(43,191,138,.16), rgba(22,34,31,.85));
--shop-bg: linear-gradient(135deg, rgba(43,191,138,.12), rgba(22,34,31,.7));
--switch-track: #3a4f48;
--switch-knob: #e6f4ee;
--ms-good: #4ad69a;
--ms-mid: #e0b84a;
--focus-ring: rgba(43,191,138,.2);
--theme-btn-bg: rgba(22, 34, 31, .85);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
font-family: var(--font-ui);
color: var(--ink);
background: var(--page-bg);
transition: color .2s var(--ease), background .25s var(--ease);
color-scheme: light;
}
html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
}
body {
display: grid;
place-items: start center;
padding: 16px 12px 20px;
}
.shell {
width: min(480px, 100%);
background: var(--surface);
backdrop-filter: blur(18px) saturate(1.15);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border-radius: 28px;
padding: 18px 18px 16px;
animation: rise .55s var(--ease) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(14px) scale(.985); }
to { opacity: 1; transform: none; }
}
.update-banner {
display: none;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(13, 138, 102, 0.28);
background: var(--update-bg);
animation: rise .4s var(--ease) both;
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: var(--font-display);
display: block;
margin-bottom: 4px;
font-size: .95rem;
}
.update-banner p {
margin: 0 0 10px;
color: var(--muted);
font-size: .84rem;
}
.update-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.update-actions .action {
flex: 1 1 auto;
min-width: 140px;
}
.top {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.logo {
width: 52px;
height: 52px;
border-radius: 16px;
overflow: hidden;
flex: 0 0 auto;
box-shadow: 0 10px 24px rgba(13, 138, 102, 0.28);
background: linear-gradient(145deg, #0d8a66, #1bb887);
display: grid;
place-items: center;
}
.logo svg { width: 30px; height: 30px; }
.brand-wrap { min-width: 0; flex: 1; }
.theme-btn {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--theme-btn-bg);
color: var(--ink);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, border-color .15s, transform .15s;
box-shadow: 0 4px 12px rgba(0,0,0,.06);
}
.theme-btn:hover {
border-color: rgba(13,138,102,.35);
transform: translateY(-1px);
}
.theme-btn svg { width: 18px; height: 18px; display: block; }
.theme-btn .icon-sun { display: none; }
.theme-btn .icon-moon { display: block; }
html[data-theme="dark"] .theme-btn .icon-sun { display: block; }
html[data-theme="dark"] .theme-btn .icon-moon { display: none; }
.brand-row {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.brand {
font-family: var(--font-display);
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
margin: 0;
}
.badge-ver {
font-size: .68rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: none;
color: var(--accent-deep);
background: var(--accent-soft);
border: 1px solid rgba(13,138,102,.18);
border-radius: 999px;
padding: 3px 8px;
}
.tagline {
margin: 5px 0 0;
color: var(--muted);
font-size: .84rem;
line-height: 1.35;
}
.hero {
position: relative;
overflow: hidden;
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--hero-bg);
padding: 16px 16px 14px;
margin-bottom: 14px;
transition: border-color .25s, box-shadow .25s, background .25s;
}
.hero.on {
border-color: rgba(13,138,102,.35);
box-shadow: 0 14px 36px rgba(13, 138, 102, 0.14);
background: var(--hero-on-bg);
}
.hero::after {
content: "";
position: absolute;
width: 160px; height: 160px;
right: -40px; top: -50px;
border-radius: 50%;
background: radial-gradient(circle, rgba(13,138,102,.18), transparent 70%);
pointer-events: none;
transition: opacity .3s;
opacity: .55;
}
.hero.on::after { opacity: 1; }
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
position: relative;
z-index: 1;
}
.status {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: .98rem;
}
.dot {
width: 11px; height: 11px; border-radius: 50%;
background: #9aaba4;
transition: background .2s;
}
.dot.on {
background: var(--ok);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(13,138,102,.4); }
70% { box-shadow: 0 0 0 12px rgba(13,138,102,0); }
100% { box-shadow: 0 0 0 0 rgba(13,138,102,0); }
}
.proto-chip {
font-size: .72rem;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
background: var(--chip-bg);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
max-width: 46%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.proto-chip.active {
color: var(--accent-deep);
background: var(--accent-soft);
border-color: rgba(13,138,102,.22);
}
.hero-actions { position: relative; z-index: 1; display: grid; gap: 8px; }
.hero-hint {
margin: 8px 0 0;
font-size: .8rem;
color: var(--muted);
min-height: 1.2em;
line-height: 1.35;
}
.section {
margin-bottom: 12px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
font-family: var(--font-display);
font-size: .78rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--muted);
}
label.field {
display: block;
font-size: .72rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 6px;
}
.profile-bar {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
margin-bottom: 10px;
}
.sub-bar {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
select, input[type="text"], textarea {
width: 100%;
border: 1px solid var(--line);
background: var(--input-bg);
border-radius: 14px;
padding: 11px 13px;
font: inherit;
color: var(--ink);
outline: none;
transition: border-color .15s, box-shadow .15s, background .15s;
}
textarea {
min-height: 78px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: .78rem;
line-height: 1.4;
}
select:focus, input[type="text"]:focus, textarea:focus {
border-color: rgba(13,138,102,.5);
box-shadow: 0 0 0 4px var(--focus-ring);
}
.icon-btn {
width: 44px;
padding: 0;
display: grid;
place-items: center;
border-radius: 14px;
border: 1px solid rgba(8, 90, 68, 0.22);
background: var(--soft-btn);
color: var(--accent-deep);
font-size: 1.15rem;
font-weight: 700;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
box-shadow: 0 4px 12px rgba(8, 90, 68, 0.08);
}
.icon-btn:hover {
background: var(--soft-btn-hover);
border-color: rgba(8, 90, 68, 0.38);
color: var(--accent-deep);
box-shadow: 0 6px 14px rgba(8, 90, 68, 0.14);
}
.icon-btn.danger {
color: var(--danger-ink);
background: var(--danger-btn);
border-color: rgba(158, 42, 34, 0.28);
}
.icon-btn.danger:hover {
background: var(--danger-btn-hover);
color: var(--danger-ink-hover);
}
.icon-btn.wide {
width: auto;
padding: 0 14px;
font-size: .82rem;
font-weight: 700;
}
.stack { display: grid; gap: 10px; }
details.panel {
margin-bottom: 10px;
border: 1px solid var(--line);
border-radius: 16px;
padding: 8px 12px 10px;
background: var(--surface-2);
}
details.panel summary {
cursor: pointer;
font-weight: 700;
font-size: .86rem;
color: var(--ink);
list-style: none;
padding: 6px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
details.panel summary::-webkit-details-marker { display: none; }
details.panel summary::after {
content: "+";
color: var(--muted);
font-weight: 700;
font-size: 1rem;
transition: transform .2s;
}
details.panel[open] summary::after { content: ""; }
details.panel .grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
details.panel label.field { margin-top: 4px; }
.check {
display: flex;
align-items: center;
gap: 8px;
font-size: .86rem;
margin-top: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 13px;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface-2);
margin-bottom: 12px;
font-weight: 600;
font-size: .9rem;
}
.switch { position: relative; width: 44px; height: 26px; flex: 0 0 auto; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: var(--switch-track);
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
content: "";
position: absolute;
width: 20px; height: 20px;
left: 3px; top: 3px;
background: var(--switch-knob);
border-radius: 50%;
transition: transform .15s;
box-shadow: 0 2px 6px rgba(0,0,0,.12);
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); }
button.action {
appearance: none;
border: 0;
border-radius: 15px;
padding: 13px 15px;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform .12s, background .15s, opacity .15s, box-shadow .15s, border-color .15s, color .15s;
letter-spacing: -0.01em;
}
button.action:active { transform: translateY(1px); }
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; box-shadow: none; }
.primary {
background: linear-gradient(145deg, var(--accent-deep), var(--accent));
color: #f3fff9;
box-shadow: 0 12px 28px rgba(13, 138, 102, 0.28);
}
.primary:hover:not(:disabled) {
background: linear-gradient(145deg, #0a7a59, #12a078);
box-shadow: 0 14px 30px rgba(13, 138, 102, 0.34);
}
.primary.danger {
background: linear-gradient(145deg, #a52c24, #d14a3f);
box-shadow: 0 12px 28px rgba(192, 54, 44, 0.24);
}
.primary.danger:hover:not(:disabled) {
background: linear-gradient(145deg, #b8342b, #e0574b);
}
.secondary {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border: 1px solid rgba(12, 48, 40, 0.35);
box-shadow: 0 8px 18px rgba(20, 70, 56, 0.18);
}
.secondary:hover:not(:disabled) {
background: linear-gradient(180deg, #37806d, #2c6757);
color: #ffffff;
border-color: rgba(12, 48, 40, 0.45);
box-shadow: 0 10px 22px rgba(20, 70, 56, 0.24);
}
.cta {
width: 100%;
min-height: 52px;
font-size: 1.02rem;
font-family: var(--font-display);
}
.actions { display: grid; gap: 8px; }
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.tools .action { min-height: 44px; font-size: .9rem; }
.server-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.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 {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
}
.server-actions .mini {
appearance: none;
border: 1px solid rgba(8, 90, 68, 0.22);
background: var(--soft-btn);
color: var(--accent-deep);
border-radius: 999px;
padding: 6px 10px;
font: inherit;
font-size: .72rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.server-actions .mini:hover { filter: brightness(.97); }
.server-actions .mini.accent {
background: linear-gradient(180deg, #2f6f5f, #25584b);
color: #f2fff9;
border-color: rgba(12, 48, 40, 0.35);
}
.auto-best {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: .82rem;
font-weight: 600;
color: var(--muted);
}
.server-list {
max-height: min(42vh, 360px);
overflow: auto;
display: grid;
gap: 3px;
margin-bottom: 10px;
padding: 4px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--surface-2);
}
.server-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid transparent;
background: var(--row-bg);
cursor: pointer;
transition: background .12s, border-color .12s, box-shadow .12s;
text-align: left;
width: 100%;
font: inherit;
color: inherit;
}
.server-row:hover {
border-color: rgba(13,138,102,.25);
background: var(--input-bg);
}
.server-row.active {
border-color: rgba(13,138,102,.4);
background: var(--row-active-bg);
box-shadow: 0 4px 14px rgba(13,138,102,.1);
}
.server-row .left { min-width: 0; }
.server-row .name {
font-weight: 700;
font-size: .84rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .sub {
margin-top: 2px;
font-size: .72rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-row .right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex: 0 0 auto;
}
.server-row .proto {
font-size: .62rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--accent-deep);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 6px;
}
.server-row .ms {
font-size: .78rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--muted);
}
.server-row .ms.ok { color: var(--ok); }
.server-row .ms.good { color: var(--ms-good); }
.server-row .ms.mid { color: var(--ms-mid); }
.server-row .ms.bad { color: var(--danger); }
.server-empty {
padding: 14px 10px;
text-align: center;
color: var(--muted);
font-size: .82rem;
}
.profile-edit summary {
cursor: pointer;
list-style: none;
font-size: .78rem;
font-weight: 700;
color: var(--muted);
padding: 4px 0;
}
.profile-edit summary::-webkit-details-marker { display: none; }
.meta {
margin: 10px 0 0;
font-size: .82rem;
color: var(--muted);
line-height: 1.45;
min-height: 2.4em;
padding: 8px 10px;
border-radius: 12px;
background: var(--meta-bg);
border: 1px solid transparent;
transition: color .15s, border-color .15s, background .15s;
}
.meta.ok {
color: var(--ok);
background: var(--meta-ok-bg);
border-color: rgba(13,138,102,.18);
}
.meta.err {
color: var(--danger);
background: var(--meta-err-bg);
border-color: rgba(192,54,44,.16);
}
.ping-list {
margin-top: 8px;
display: grid;
gap: 6px;
}
.ping-item {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--row-bg);
font-size: .82rem;
}
.ping-item .ms { font-weight: 700; }
.ping-item .ms.ok { color: var(--ok); }
.ping-item .ms.bad { color: var(--danger); }
.shop {
margin-top: 12px;
padding: 13px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(13, 138, 102, 0.16);
background: var(--shop-bg);
}
.shop h3 {
font-family: var(--font-display);
font-size: 1rem;
letter-spacing: -0.02em;
margin: 0 0 5px;
}
.shop p {
margin: 0 0 10px;
color: var(--muted);
font-size: .82rem;
line-height: 1.4;
}
.shop .action { width: 100%; }
.shop-link {
display: block;
width: 100%;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
text-align: center;
font: inherit;
font-size: .76rem;
color: var(--accent);
font-weight: 600;
cursor: pointer;
box-shadow: none;
}
.shop-link:hover { text-decoration: underline; }
.ver {
margin-top: 8px;
font-size: .72rem;
color: var(--muted);
text-align: center;
}
.modal-backdrop {
position: fixed; inset: 0;
background: var(--modal-scrim);
backdrop-filter: blur(4px);
display: none;
place-items: center;
padding: 18px;
z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%);
background: var(--modal-bg);
border-radius: 22px;
border: 1px solid var(--line);
box-shadow: var(--shadow);
padding: 18px;
animation: rise .35s var(--ease) both;
color: var(--ink);
}
.modal h2 {
font-family: var(--font-display);
margin: 0 0 12px;
font-size: 1.2rem;
letter-spacing: -.02em;
}
.modal .actions {
margin-top: 12px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 420px) {
.shell { padding: 14px 12px 12px; border-radius: 22px; }
.brand { font-size: 1.45rem; }
details.panel .grid2 { grid-template-columns: 1fr; }
.tools { grid-template-columns: 1fr; }
}
.log-tail {
max-height: 160px;
overflow: auto;
font-size: .72rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
background: var(--panel-bg, rgba(0,0,0,.04));
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px 12px;
margin: 8px 0;
}
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
+846
View File
@@ -0,0 +1,846 @@
// HTTP bridge for glaze GUI (Windows + macOS): POST /api/<method>.
(function () {
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","exportServers","importServers",
"getLogs","probeTunnel","savePrefs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
async function call(name, args) {
const headers = { "Content-Type": "application/json" };
if (window.__NAVIS_TOKEN__) headers["X-Navis-Token"] = window.__NAVIS_TOKEN__;
const r = await fetch("/api/" + name, {
method: "POST",
headers: headers,
body: JSON.stringify({ args: args || [] })
});
if (r.status === 401) throw "unauthorized";
const j = await r.json();
if (j && j.error) throw j.error;
return j ? j.result : null;
}
methods.forEach((name) => {
window[name] = function () {
return call(name, Array.prototype.slice.call(arguments));
};
});
})();
const $ = (id) => document.getElementById(id);
const meta = $("meta");
const btn = $("toggleBtn");
const coreBtn = $("coreBtn");
const saveBtn = $("saveBtn");
const addBtn = $("addBtn");
const delBtn = $("delBtn");
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
const bootConnect = $("bootConnect");
const autoReconnect = $("autoReconnect");
const logTail = $("logTail");
const logRefreshBtn = $("logRefreshBtn");
const probeBtn = $("probeBtn");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const exportServersBtn = $("exportServersBtn");
const importServersBtn = $("importServersBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
const serverSearch = $("serverSearch");
const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const skipUpdateBtn = $("skipUpdateBtn");
const verLabel = $("verLabel");
const SKIP_UPDATE_KEY = "navis.skipUpdateVersion";
function skippedVersion() {
try { return localStorage.getItem(SKIP_UPDATE_KEY) || ""; } catch (_) { return ""; }
}
function setSkippedVersion(v) {
try {
if (v) localStorage.setItem(SKIP_UPDATE_KEY, String(v));
else localStorage.removeItem(SKIP_UPDATE_KEY);
} catch (_) {}
}
const quitBtn = $("quitBtn");
if (window.__navisHttp && quitBtn) {
quitBtn.hidden = false;
quitBtn.style.display = "block";
quitBtn.addEventListener("click", () => {
withBusy(async () => {
setMeta("Выход…");
try { await quit(); } catch (_) {}
});
});
}
const subUrl = $("subUrl");
const subBtn = $("subBtn");
const hero = $("hero");
const protoChip = $("protoChip");
const heroHint = $("heroHint");
const SHOP_URL = "https://evilfox.win/";
const AUTO_BEST_KEY = "navis.autoBest";
const THEME_KEY = "navis.theme";
(function initThemeToggle() {
const btn = $("themeBtn");
if (!btn) return;
const apply = (theme) => {
document.documentElement.setAttribute("data-theme", theme);
try { localStorage.setItem(THEME_KEY, theme); } catch (_) {}
btn.title = theme === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
};
btn.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
apply(cur === "dark" ? "light" : "dark");
});
const cur = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
btn.title = cur === "dark" ? "Светлая тема" : "Тёмная тема";
btn.setAttribute("aria-label", btn.title);
})();
try {
const saved = localStorage.getItem(AUTO_BEST_KEY);
autoBest.checked = saved === "1";
} catch (_) {}
autoBest.addEventListener("change", () => {
try { localStorage.setItem(AUTO_BEST_KEY, autoBest.checked ? "1" : "0"); } catch (_) {}
});
function readHy2() {
return {
congestion: $("hy2Congestion").value,
bbr_profile: $("hy2Bbr").value,
bandwidth_up: $("hy2Up").value.trim(),
bandwidth_down: $("hy2Down").value.trim(),
obfs: $("hy2Obfs").value,
obfs_password: $("hy2ObfsPass").value.trim(),
sni: $("hy2Sni").value.trim(),
insecure: !!$("hy2Insecure").checked,
pin_sha256: $("hy2Pin").value.trim(),
fast_open: !!$("hy2Fast").checked,
lazy: !!$("hy2Lazy").checked,
hop_interval: $("hy2Hop").value.trim()
};
}
function fillHy2(h) {
if (!h) return;
if (h.congestion) $("hy2Congestion").value = h.congestion;
if (h.bbr_profile) $("hy2Bbr").value = h.bbr_profile;
$("hy2Up").value = h.bandwidth_up || "";
$("hy2Down").value = h.bandwidth_down || "";
$("hy2Obfs").value = h.obfs || "";
$("hy2ObfsPass").value = h.obfs_password || "";
$("hy2Sni").value = h.sni || "";
$("hy2Pin").value = h.pin_sha256 || "";
$("hy2Hop").value = h.hop_interval || "";
$("hy2Insecure").checked = !!h.insecure;
$("hy2Fast").checked = !!h.fast_open;
$("hy2Lazy").checked = !!h.lazy;
}
async function persistHy2IfNeeded(protocol) {
const p = (protocol || "").toLowerCase();
const link = proxy.value.trim().toLowerCase();
if (p === "hysteria2" || link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
await saveHy2(readHy2());
}
}
let busy = false;
let connected = false;
let formHydrated = false;
let dirty = false;
let profiles = [];
let pingMap = {};
let metaHoldUntil = 0;
function markDirty() { dirty = true; }
[
proxy, nameInput, sysproxy, subUrl,
$("hy2Congestion"), $("hy2Bbr"), $("hy2Up"), $("hy2Down"),
$("hy2Obfs"), $("hy2ObfsPass"), $("hy2Sni"), $("hy2Pin"), $("hy2Hop"),
$("hy2Insecure"), $("hy2Fast"), $("hy2Lazy")
].forEach((el) => {
if (!el) return;
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
meta.classList.remove("ok", "err");
if (kind === "ok") meta.classList.add("ok");
if (kind === "err") meta.classList.add("err");
if (kind === "ok" || kind === "err") metaHoldUntil = Date.now() + 8000;
}
function detectProtoLabel(state, active) {
const p = (state.protocol || active.protocol || "").toLowerCase();
const link = (typeof state.proxy === "string" ? state.proxy : (active.proxy || "")).trim().toLowerCase();
if (p) return p;
if (link.startsWith("vless://")) return "vless";
if (link.startsWith("vmess://")) return "vmess";
if (link.startsWith("trojan://")) return "trojan";
if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) return "hysteria2";
if (link.startsWith("awg://") || link.includes("[interface]")) return "awg";
if (link.startsWith("naive") || link.startsWith("https://") || link.startsWith("quic://")) return "naive";
return "протокол";
}
function msClass(ms, ok) {
if (!ok) return "bad";
if (ms < 80) return "good";
if (ms < 180) return "ok";
if (ms < 350) return "mid";
return "bad";
}
function rememberPings(pings) {
pingMap = {};
(pings || []).forEach((p) => { pingMap[p.name] = p; });
}
let listFP = "";
let stateRev = "";
function profilesFingerprint(state) {
const parts = [
state.connected ? "1" : "0",
state.active_profile || state.profile || "",
];
(state.profiles || []).forEach((p) => {
parts.push(p.name + "|" + (p.protocol || "") + "|" + (p.host || ""));
});
(state.pings || []).forEach((p) => {
parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.soft ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
});
return parts.join("\n");
}
function fillProfiles(list, active) {
profiles = list || [];
const cur = profile.value;
profile.innerHTML = "";
profiles.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name;
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
renderServerList(want);
}
function orderedProfiles() {
const list = profiles.slice();
list.sort((a, b) => {
const pa = pingMap[a.name], pb = pingMap[b.name];
const oa = pa && pa.ok, ob = pb && pb.ok;
if (oa !== ob) return oa ? -1 : 1;
if (oa && ob && pa.ms !== pb.ms) return pa.ms - pb.ms;
return String(a.name).localeCompare(String(b.name));
});
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) {
const active = activeName || profile.value;
const all = orderedProfiles();
const list = filteredProfiles();
const keep = new Set();
if (!all.length || !list.length) {
serverList.innerHTML = "";
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = !all.length
? "Нет серверов — добавьте профиль или подписку"
: "Ничего не найдено";
serverList.appendChild(empty);
return;
}
// Drop empty-state nodes from prior renders.
serverList.querySelectorAll(".server-empty").forEach((el) => el.remove());
list.forEach((p) => {
keep.add(p.name);
const row = ensureServerRow(p);
row.className = "server-row" + (p.name === active ? " active" : "");
const nameEl = row.querySelector(".name");
const sub = row.querySelector(".sub");
const proto = row.querySelector(".proto");
const ms = row.querySelector(".ms");
if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name;
const hostText = p.host || "нет хоста";
if (sub && sub.textContent !== hostText) sub.textContent = hostText;
const protoText = (p.protocol || "?").toString();
if (proto && proto.textContent !== protoText) proto.textContent = protoText;
const pl = pingLabel(pingMap[p.name]);
if (ms) {
if (ms.className !== pl.cls) ms.className = pl.cls;
if (ms.textContent !== pl.text) ms.textContent = pl.text;
if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || "";
}
serverList.appendChild(row); // re-order if needed
});
serverList.querySelectorAll(".server-row").forEach((row) => {
if (!keep.has(row.dataset.name)) row.remove();
});
}
async function onServerClick(name) {
if (busy) return;
if (connected) {
if (name !== profile.value) await onServerConnect(name);
return;
}
await withBusy(async () => {
await selectProfile(name);
dirty = false;
formHydrated = false;
});
}
async function onServerConnect(name) {
if (busy) return;
await withBusy(async () => {
setMeta("Подключение к «" + name + "»…");
await connectProfile(name);
setMeta("Подключено: " + name, "ok");
});
}
function renderUpdate(u, version) {
verLabel.textContent = "Navis v" + (version || "?");
const badge = $("badgeVer");
if (badge && version) badge.textContent = version;
if (!u) {
updateBanner.classList.remove("show");
return;
}
const latest = (u.latest || "").toString();
const skip = skippedVersion();
if (u.available && latest && skip === latest) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Доступно обновление " + latest;
$("updateNotes").textContent = u.notes || ("У вас " + (u.current || version || "?") + ". Обновление только по кнопке.");
updateBanner.dataset.latest = latest;
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
connected = !!state.connected;
dot.classList.toggle("on", connected);
hero.classList.toggle("on", connected);
statusText.textContent = connected
? ("Подключено" + (state.profile ? " · " + state.profile : ""))
: "Отключено";
btn.textContent = connected ? "Отключить" : "Подключить";
btn.classList.toggle("danger", connected);
const lock = connected || busy;
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
if (bootConnect) bootConnect.disabled = busy;
if (autoReconnect) autoReconnect.disabled = busy;
if (probeBtn) probeBtn.disabled = busy || !connected;
if (logRefreshBtn) logRefreshBtn.disabled = busy;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
bestBtn.disabled = busy;
if (exportServersBtn) exportServersBtn.disabled = busy;
if (importServersBtn) importServersBtn.disabled = busy || connected;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
subBtn.disabled = busy;
subUrl.disabled = busy;
btn.disabled = busy;
rememberPings(state.pings || []);
const fp = profilesFingerprint(state);
if (syncForm || fp !== listFP) {
listFP = fp;
fillProfiles(state.profiles || [], state.active_profile || state.profile);
}
renderUpdate(state.update, state.version);
if (typeof state.subscription_url === "string" && !dirty) {
subUrl.value = state.subscription_url;
}
if (syncForm || (!formHydrated && !dirty)) {
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
if (bootConnect && typeof state.connect_on_launch === "boolean") bootConnect.checked = state.connect_on_launch;
if (autoReconnect && typeof state.auto_reconnect === "boolean") autoReconnect.checked = state.auto_reconnect;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
}
const active = (state.profiles || []).find((p) => p.name === profile.value) || {};
const label = detectProtoLabel(state, active);
protoChip.textContent = label;
protoChip.classList.toggle("active", !!state.connected || label !== "протокол");
const hy2 = (label === "hysteria2" || label === "hy2");
if ($("hy2Box")) $("hy2Box").style.display = hy2 ? "" : "none";
let detail = "";
if (connected) {
const parts = [];
if (state.http_proxy) parts.push("HTTP " + state.http_proxy);
if (state.socks_proxy) parts.push("SOCKS " + state.socks_proxy);
detail = parts.join(" · ") || "Туннель активен";
heroHint.textContent = detail;
} else if (state.core_ready === false) {
detail = "Сначала установите cores в разделе «Сервис»";
heroHint.textContent = "Нужны cores для выбранного протокола";
} else {
const n = (state.profiles || []).length;
detail = n > 1 ? ("Серверов: " + n + " · нажмите Пинг или Лучший") : "Готово к подключению";
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
}
if (!busy && Date.now() > metaHoldUntil) {
if (!connected && state.last_error) setMeta(state.last_error, "err");
else setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
async function persistPrefs() {
if (typeof savePrefs !== "function") return;
await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked);
}
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);
let state;
if (needSecrets && typeof getEditState === "function") {
state = await getEditState();
} else {
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);
return state;
}
function fmtErr(e) {
if (e == null) return "ошибка";
if (typeof e === "string") return e;
if (typeof e === "object" && e.message) return String(e.message);
return String(e);
}
async function withBusy(fn) {
if (busy) return;
busy = true;
paintButtonsLocked(true);
try {
await fn();
} catch (e) {
setMeta(fmtErr(e), "err");
} finally {
busy = false;
try {
await refresh({ syncForm: true });
} catch (e) {
setMeta(fmtErr(e), "err");
paintButtonsLocked(false);
}
}
}
function paintButtonsLocked(locked) {
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, bestBtn, exportServersBtn, importServersBtn, updCheckBtn, updateBtn, skipUpdateBtn, subBtn, subUrl].forEach((b) => {
if (b) b.disabled = locked;
});
}
async function runBest(autoConnect) {
setMeta(autoConnect ? "Пинг и автоподключение…" : "Пинг и выбор лучшего…");
const res = await pingBest(!!autoConnect);
rememberPings(res.pings || []);
renderServerList(res.best_name || profile.value);
if (res.best_name) {
setMeta("Лучший: " + res.best_name + " · " + res.best_ms + " ms" + (res.connected ? " · подключено" : ""), "ok");
}
return res;
}
profile.addEventListener("change", () => withBusy(async () => {
await selectProfile(profile.value);
dirty = false;
formHydrated = false;
}));
btn.addEventListener("click", () => withBusy(async () => {
try {
if (connected) {
setMeta("Отключение…");
await disconnect();
setMeta("Отключено", "ok");
} else {
setMeta("Подключение…");
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
await connect();
setMeta("Туннель активен", "ok");
}
} catch (e) { setMeta(String(e), "err"); }
}));
saveBtn.addEventListener("click", () => withBusy(async () => {
try {
await saveProfile(nameInput.value.trim() || profile.value, proxy.value.trim(), !!sysproxy.checked);
await persistHy2IfNeeded();
setMeta("Профиль сохранён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
coreBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание official cores…");
const path = await installCore();
setMeta("Cores готовы: " + path, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
delBtn.addEventListener("click", () => withBusy(async () => {
try {
if (!confirm("Удалить профиль «" + profile.value + "»?")) return;
await deleteProfile(profile.value);
formHydrated = false;
dirty = false;
setMeta("Профиль удалён", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
addBtn.addEventListener("click", () => {
$("newName").value = "";
$("newProxy").value = "";
const me = $("modalErr");
if (me) { me.style.display = "none"; me.textContent = ""; me.className = "meta"; }
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.remove("open");
});
function sanitizeShare(s) {
return String(s || "")
.replace(/[\u200B-\u200D\u2060\uFEFF\u2066-\u2069\u200E\u200F\u202A-\u202E]/g, "")
.trim();
}
$("createNew").addEventListener("click", () => withBusy(async () => {
const me = $("modalErr");
const showModalErr = (msg) => {
if (!me) { setMeta(String(msg), "err"); return; }
me.style.display = "block";
me.className = "meta err";
me.textContent = String(msg);
};
try {
const n = $("newName").value.trim();
const p = sanitizeShare($("newProxy").value);
if (!n) throw "Укажите название профиля";
if (!p) throw "Вставьте ссылку или конфиг Amnezia/AWG";
$("newProxy").value = p;
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
if (me) { me.style.display = "none"; me.textContent = ""; }
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { showModalErr(e); }
}));
async function openShop(e) {
if (e) {
e.preventDefault();
e.stopPropagation();
}
try {
setMeta("Открываю evilfox.win…");
await openURL(SHOP_URL);
setMeta("Открыто в браузере", "ok");
} catch (err) {
setMeta("Не удалось открыть ссылку: " + String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
async function runImportSubscription() {
const url = subUrl.value.trim();
if (!url) {
setMeta("Вставьте URL подписки", "err");
return;
}
setMeta("Загрузка подписки…");
const n = await importSubscription(url);
formHydrated = false;
dirty = false;
setMeta("Импортировано: " + n + " · измеряю пинг…", "ok");
await runBest(!!autoBest.checked);
}
subBtn.addEventListener("click", () => withBusy(async () => {
try {
await runImportSubscription();
} catch (e) { setMeta(String(e), "err"); }
}));
subUrl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
subBtn.click();
}
});
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Пинг серверов…");
const rows = await pingServers();
rememberPings(rows);
renderServerList(profile.value);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(e), "err"); }
}));
bestBtn.addEventListener("click", () => withBusy(async () => {
try {
await runBest(!!autoBest.checked);
} 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 () => {
try {
setMeta("Проверка обновлений…");
const st = await checkUpdate();
renderUpdate(st, st.current);
if (st.available) setMeta("Доступна версия " + st.latest, "ok");
else if (st.error) setMeta("Обновление: " + st.error, "err");
else setMeta("У вас актуальная версия " + st.current, "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
updateBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Скачивание и установка обновления…");
const msg = await applyUpdate();
setMeta(String(msg || "Перезапуск…"), "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
skipUpdateBtn.addEventListener("click", () => {
const latest = updateBanner.dataset.latest || "";
if (latest) setSkippedVersion(latest);
updateBanner.classList.remove("show");
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
});
if (sysproxy) sysproxy.addEventListener("change", () => { persistPrefs().catch(() => {}); dirty = true; });
if (bootConnect) bootConnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
if (autoReconnect) autoReconnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
if (logRefreshBtn) logRefreshBtn.addEventListener("click", () => withBusy(async () => {
try {
const t = await getLogs();
if (logTail) logTail.textContent = t || "—";
setMeta("Лог обновлён", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
if (probeBtn) probeBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка туннеля…");
await probeTunnel();
setMeta("Туннель OK", "ok");
} catch (e) { setMeta(fmtErr(e), "err"); }
}));
(async () => {
try {
await refresh({ syncForm: true });
// Auto-best connect is opt-in via checkbox only — never on first paint.
} catch (e) {
setMeta(String(e), "err");
}
})();
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();
})();
+21 -3
View File
@@ -1,7 +1,25 @@
package appui
import _ "embed"
import (
_ "embed"
"strings"
)
//go:embed index.html
var IndexHTML string
var indexHTML string
//go:embed app.css
var appCSS string
//go:embed app.js
var appJS string
// IndexHTML is the full GUI document (CSS/JS inlined for glaze HTTP UI).
var IndexHTML = buildIndexHTML()
func buildIndexHTML() string {
s := indexHTML
s = strings.Replace(s, `<link rel="stylesheet" href="app.css" />`, "<style>\n"+appCSS+"\n</style>", 1)
s = strings.Replace(s, `<script src="app.js"></script>`, "<script>\n"+appJS+"\n</script>", 1)
return s
}
+21
View File
@@ -0,0 +1,21 @@
package appui
import (
"strings"
"testing"
)
func TestIndexHTMLInlinesAssets(t *testing.T) {
if !strings.Contains(IndexHTML, "--accent:") {
t.Fatal("css missing")
}
if !strings.Contains(IndexHTML, "getEditState") {
t.Fatal("js missing")
}
if strings.Contains(IndexHTML, `href="app.css"`) {
t.Fatal("css link not replaced")
}
if strings.Contains(IndexHTML, `src="app.js"`) {
t.Fatal("js src not replaced")
}
}
+61 -1307
View File
File diff suppressed because it is too large Load Diff
+79 -2
View File
@@ -3,8 +3,10 @@ package config
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
// Protocol identifies a VPN/proxy backend.
@@ -33,6 +35,15 @@ type Config struct {
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
// SubscriptionNames are profile names last imported from SubscriptionURL (for prune).
SubscriptionNames []string `json:"subscription_names,omitempty"`
// ConnectOnLaunch connects the active profile when the GUI starts.
ConnectOnLaunch bool `json:"connect_on_launch,omitempty"`
// AutoReconnect reconnects after an unexpected core crash.
AutoReconnect bool `json:"auto_reconnect,omitempty"`
Profiles []Profile `json:"profiles"`
}
@@ -142,6 +153,26 @@ func lastIndex(s, substr string) int {
return -1
}
// Clone returns a deep copy of the config (JSON round-trip).
func (c *Config) Clone() *Config {
if c == nil {
return &Config{}
}
data, err := json.Marshal(c)
if err != nil {
out := *c
out.Profiles = append([]Profile(nil), c.Profiles...)
return &out
}
var out Config
if err := json.Unmarshal(data, &out); err != nil {
cp := *c
cp.Profiles = append([]Profile(nil), c.Profiles...)
return &cp
}
return &out
}
// Load reads and validates a config file.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
@@ -192,10 +223,47 @@ func (c *Config) Validate() error {
default:
return fmt.Errorf("config: profile %q: unsupported protocol %q", p.Name, p.Protocol)
}
for _, listen := range p.Listen {
if err := validateListenURI(listen); err != nil {
return fmt.Errorf("config: profile %q: %w", p.Name, err)
}
}
}
return nil
}
func validateListenURI(uri string) error {
uri = strings.TrimSpace(uri)
if uri == "" {
return nil
}
var hostPort string
var ok bool
for _, scheme := range []string{"http", "socks", "socks5", "https"} {
if hostPort, ok = parseListenHostPort(uri, scheme); ok {
break
}
}
if !ok {
lower := strings.ToLower(uri)
if strings.Contains(lower, "127.0.0.1") || strings.Contains(lower, "localhost") || strings.Contains(lower, "[::1]") {
return nil
}
return fmt.Errorf("listen %q must bind to loopback", uri)
}
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
host = hostPort
}
host = strings.Trim(host, "[]")
switch strings.ToLower(host) {
case "127.0.0.1", "localhost", "::1", "":
return nil
default:
return fmt.Errorf("listen host %q is not loopback (only 127.0.0.1 / ::1)", host)
}
}
// ActiveProfile returns the selected profile.
func (c *Config) ActiveProfile() (*Profile, error) {
for i := range c.Profiles {
@@ -257,8 +325,9 @@ func WriteExample(path string) error {
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 {
InvalidateHostCache()
if err := cfg.Validate(); err != nil {
return err
}
@@ -273,7 +342,15 @@ func Save(path string, cfg Config) error {
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.
+20
View File
@@ -4,6 +4,7 @@ import "strings"
// DetectProtocol infers protocol from a share/proxy URI or config body.
func DetectProtocol(raw string) Protocol {
raw = stripShareNoise(raw)
lower := strings.ToLower(strings.TrimSpace(raw))
switch {
case strings.HasPrefix(lower, "hysteria2://"), strings.HasPrefix(lower, "hy2://"):
@@ -29,3 +30,22 @@ func DetectProtocol(raw string) Protocol {
return ""
}
}
func stripShareNoise(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
var b strings.Builder
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u2066', '\u2067', '\u2068', '\u2069',
'\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
+107 -3
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"strings"
"sync"
)
// 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"`
}
// 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.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
@@ -22,13 +64,61 @@ func (c *Config) ListProfiles() []ProfileInfo {
Name: p.Name,
Protocol: string(p.Protocol),
Proxy: p.Proxy,
Host: proxyHost(p.Proxy),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
return out
}
// ListProfilesForPoll is like ListProfiles but omits Proxy URIs (cheaper idle polls).
func (c *Config) ListProfilesForPoll() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
for _, p := range c.Profiles {
out = append(out, ProfileInfo{
Name: p.Name,
Protocol: string(p.Protocol),
Host: cachedProxyHost(p.Proxy),
Active: p.Name == c.Active,
})
}
return out
}
// RedactProfileList clears Proxy URIs from a profile list (host/protocol remain).
// Use for periodic UI polls so credentials are not echoed for every profile.
func RedactProfileList(in []ProfileInfo) []ProfileInfo {
out := make([]ProfileInfo, len(in))
for i, p := range in {
out[i] = p
out[i].Proxy = ""
}
return out
}
func RedactProxyURI(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
return ""
}
lower := strings.ToLower(proxy)
if strings.Contains(lower, "[interface]") || strings.Contains(lower, "privatekey") {
return "[конфиг скрыт — откройте редактор профиля]"
}
if i := strings.Index(proxy, "://"); i >= 0 {
scheme := proxy[:i+3]
rest := proxy[i+3:]
if at := strings.LastIndex(rest, "@"); at >= 0 {
return scheme + "***@" + rest[at+1:]
}
return scheme + rest
}
if at := strings.LastIndex(proxy, "@"); at >= 0 {
return "***@" + proxy[at+1:]
}
return proxyHost(proxy)
}
func proxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
@@ -79,7 +169,17 @@ func (c *Config) UpsertProfile(name, proxy string) error {
}
// UpsertProfileWithProtocol creates/updates a profile and optionally sets protocol.
// The upserted profile becomes Active.
func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, true)
}
// UpsertProfileKeepActive creates/updates a profile without changing Active.
func (c *Config) UpsertProfileKeepActive(name, proxy string, proto Protocol) error {
return c.upsertProfile(name, proxy, proto, false)
}
func (c *Config) upsertProfile(name, proxy string, proto Protocol, activate bool) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("имя профиля пустое")
@@ -99,7 +199,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen()
}
c.Active = name
if activate {
c.Active = name
}
return nil
}
}
@@ -110,7 +212,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
Proxy: proxy,
Listen: defaultListen(),
})
c.Active = name
if activate {
c.Active = name
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"strings"
"testing"
)
func TestRedactProxyURI(t *testing.T) {
got := RedactProxyURI("https://user:pass@example.com:443/?x=1")
if got != "https://***@example.com:443/?x=1" {
t.Fatalf("got %q", got)
}
if RedactProxyURI("") != "" {
t.Fatal("empty")
}
r := RedactProxyURI("[Interface]\nPrivateKey = abc\n")
if !strings.Contains(r, "скрыт") {
t.Fatalf("awg redact: %q", r)
}
}
+132
View File
@@ -0,0 +1,132 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"strings"
)
const (
ServersFileFormat = "navis-servers"
ServersFileVersion = 1
)
// ServersFile is the on-disk export/import format for the profile list.
type ServersFile struct {
Format string `json:"format,omitempty"`
Version int `json:"version,omitempty"`
Active string `json:"active,omitempty"`
Profiles []Profile `json:"profiles"`
}
// ExportServersFile builds a portable snapshot of profiles + active name.
func ExportServersFile(cfg *Config) ServersFile {
profiles := append([]Profile(nil), cfg.Profiles...)
return ServersFile{
Format: ServersFileFormat,
Version: ServersFileVersion,
Active: cfg.Active,
Profiles: profiles,
}
}
// WriteServersFile writes the export snapshot as indented JSON (0600).
func WriteServersFile(path string, cfg *Config) (int, error) {
doc := ExportServersFile(cfg)
data, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return 0, err
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o600); err != nil {
return 0, err
}
return len(doc.Profiles), nil
}
// ParseServersFile parses export JSON. Accepts:
// - { "format":"navis-servers", "profiles":[...], "active":"..." }
// - { "profiles":[...] }
// - a bare JSON array of profiles
func ParseServersFile(data []byte) (ServersFile, error) {
data = bytes.TrimSpace(stripUTF8BOM(data))
if len(data) == 0 {
return ServersFile{}, fmt.Errorf("пустой файл списка серверов")
}
var doc ServersFile
if data[0] == '[' {
var profiles []Profile
if err := json.Unmarshal(data, &profiles); err != nil {
return ServersFile{}, fmt.Errorf("разбор списка серверов: %w", err)
}
doc.Profiles = profiles
} else {
if err := json.Unmarshal(data, &doc); err != nil {
return ServersFile{}, fmt.Errorf("разбор файла серверов: %w", err)
}
}
if doc.Format != "" && doc.Format != ServersFileFormat {
return ServersFile{}, fmt.Errorf("неизвестный формат %q (ожидается %s)", doc.Format, ServersFileFormat)
}
if err := normalizeServersFile(&doc); err != nil {
return ServersFile{}, err
}
return doc, nil
}
// ReadServersFile loads and validates a servers export file.
func ReadServersFile(path string) (ServersFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return ServersFile{}, err
}
return ParseServersFile(data)
}
func normalizeServersFile(doc *ServersFile) error {
if len(doc.Profiles) == 0 {
return fmt.Errorf("в файле нет серверов")
}
seen := make(map[string]struct{}, len(doc.Profiles))
for i := range doc.Profiles {
p := &doc.Profiles[i]
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
return fmt.Errorf("профиль[%d]: пустое имя", i)
}
if _, ok := seen[p.Name]; ok {
return fmt.Errorf("дублируется имя профиля %q", p.Name)
}
seen[p.Name] = struct{}{}
switch p.Protocol {
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan:
case "":
p.Protocol = ProtocolNaive
default:
return fmt.Errorf("профиль %q: неподдерживаемый протокол %q", p.Name, p.Protocol)
}
}
doc.Active = strings.TrimSpace(doc.Active)
if doc.Active == "" {
doc.Active = doc.Profiles[0].Name
} else if _, ok := seen[doc.Active]; !ok {
doc.Active = doc.Profiles[0].Name
}
return nil
}
// ApplyServersReplace replaces profiles with the import document (prefs untouched).
func (c *Config) ApplyServersReplace(doc ServersFile) error {
if err := normalizeServersFile(&doc); err != nil {
return err
}
c.Profiles = append([]Profile(nil), doc.Profiles...)
c.Active = doc.Active
c.SubscriptionNames = nil
InvalidateHostCache()
return c.Validate()
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestServersFileRoundTrip(t *testing.T) {
cfg := &Config{
Active: "b",
Profiles: []Profile{
{Name: "a", Protocol: ProtocolNaive, Proxy: "https://u:p@a.example"},
{Name: "b", Protocol: ProtocolHysteria2, Proxy: "hy2://tok@b.example:443"},
},
SystemProxy: true,
ConnectOnLaunch: true,
SubscriptionURL: "https://sub.example/x",
}
dir := t.TempDir()
path := filepath.Join(dir, "servers.json")
n, err := WriteServersFile(path, cfg)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("exported %d, want 2", n)
}
other := &Config{
Active: "old",
SystemProxy: false,
ConnectOnLaunch: false,
SubscriptionURL: "https://keep.example",
SubscriptionNames: []string{"old"},
Profiles: []Profile{
{Name: "old", Protocol: ProtocolNaive, Proxy: "https://old"},
},
}
doc, err := ReadServersFile(path)
if err != nil {
t.Fatal(err)
}
if err := other.ApplyServersReplace(doc); err != nil {
t.Fatal(err)
}
if other.SubscriptionURL != "https://keep.example" {
t.Fatalf("prefs changed: %q", other.SubscriptionURL)
}
if other.SystemProxy {
t.Fatal("system_proxy should stay false")
}
if len(other.Profiles) != 2 || other.Active != "b" {
t.Fatalf("got active=%q profiles=%+v", other.Active, other.Profiles)
}
if other.SubscriptionNames != nil {
t.Fatalf("subscription_names should clear, got %v", other.SubscriptionNames)
}
}
func TestParseServersFileBareArray(t *testing.T) {
doc, err := ParseServersFile([]byte(`[{"name":"x","protocol":"naive","proxy":"https://x"}]`))
if err != nil {
t.Fatal(err)
}
if len(doc.Profiles) != 1 || doc.Active != "x" {
t.Fatalf("unexpected: %+v", doc)
}
}
func TestParseServersFileEmptyRejected(t *testing.T) {
if _, err := ParseServersFile([]byte(`{"profiles":[]}`)); err == nil {
t.Fatal("expected error")
}
if _, err := ParseServersFile([]byte(`[]`)); err == nil {
t.Fatal("expected error")
}
}
func TestWriteServersFilePermissions(t *testing.T) {
cfg := &Config{
Active: "a",
Profiles: []Profile{{Name: "a", Protocol: ProtocolNaive}},
}
path := filepath.Join(t.TempDir(), "out.json")
if _, err := WriteServersFile(path, cfg); err != nil {
t.Fatal(err)
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if st.Size() == 0 {
t.Fatal("empty file")
}
}
+470 -50
View File
@@ -14,7 +14,10 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/corebin"
"vpnclient/internal/linknorm"
"vpnclient/internal/listenutil"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
@@ -23,7 +26,7 @@ import (
"vpnclient/internal/sysproxy"
)
// Manager orchestrates protocol engines and Windows system proxy.
// Manager orchestrates protocol engines and OS system proxy.
type Manager struct {
mu sync.Mutex
cfgPath string
@@ -33,6 +36,12 @@ type Manager struct {
profile *config.Profile
stderr io.Writer
binDir string
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
lastStop sync.WaitGroup
lastError string
unexpectedDrop bool
watchGeneration uint64
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -43,85 +52,218 @@ func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager,
if err != nil {
return nil, err
}
return &Manager{
m := &Manager{
cfgPath: cfgPath,
cfg: cfg,
sys: sysproxy.New(),
stderr: stderr,
binDir: binDir,
}, nil
}
m.RecoverSystemProxy()
return m, nil
}
// RecoverSystemProxy clears a system proxy left on after an unclean exit.
func (m *Manager) RecoverSystemProxy() {
if m == nil || m.sys == nil {
return
}
if !sysproxy.HasSentinel(m.cfgPath) {
return
}
if err := m.sys.ForceDisable(); err != nil {
fmt.Fprintf(m.stderr, "recover system proxy: %v\n", err)
}
sysproxy.ClearSentinel(m.cfgPath)
}
func (m *Manager) waitEngineStopped() {
m.lastStop.Wait()
}
func (m *Manager) Connect(ctx context.Context, profileName string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
m.waitEngineStopped()
m.mu.Lock()
if m.engine != nil && m.engine.Running() {
m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if m.engine != nil {
m.engine = nil
m.profile = nil
}
if profileName != "" {
m.cfg.Active = profileName
}
profile, err := m.cfg.ActiveProfile()
if err != nil {
m.mu.Unlock()
return err
}
profileCopy := *profile
binDir := m.binDir
wantSysProxy := m.cfg.SystemProxy
cfgPath := m.cfgPath
sys := m.sys
stderr := m.stderr
m.mu.Unlock()
socksHP, _ := profileCopy.SOCKSListenHostPort()
httpHP, _ := profileCopy.HTTPListenHostPort()
listenAddrs := listenutil.ListenAddrs(socksHP, httpHP)
if err := listenutil.EnsureFree(binDir, listenAddrs...); err != nil {
return err
}
engine, err := m.newEngine(profile.Protocol)
engine, err := m.newEngine(profileCopy.Protocol)
if err != nil {
return err
}
if err := engine.Start(ctx, *profile, m.binDir); err != nil {
return err
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
return listenutil.ClassifyStartFailure(string(profileCopy.Protocol), err, listenAddrs...)
}
if m.cfg.SystemProxy {
m.mu.Lock()
if m.engine != nil && m.engine.Running() {
_ = engine.Stop()
m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if wantSysProxy {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
}
if err := m.sys.Enable(httpProxy); err != nil {
if err := sys.Enable(httpProxy); err != nil {
if errors.Is(err, sysproxy.ErrUnsupported) {
fmt.Fprintf(m.stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
}
} else {
sysproxy.WriteSentinel(cfgPath, httpProxy)
}
}
m.engine = engine
m.profile = profile
m.profile = &profileCopy
m.lastError = ""
m.unexpectedDrop = false
m.watchGeneration++
gen := m.watchGeneration
m.mu.Unlock()
go m.watchEngine(gen, engine)
return nil
}
// LastError returns the last connection / core failure message.
func (m *Manager) LastError() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastError
}
// SetLastError stores a UI-facing error string.
func (m *Manager) SetLastError(msg string) {
m.mu.Lock()
m.lastError = strings.TrimSpace(msg)
m.mu.Unlock()
}
// TakeUnexpectedDrop reports and clears a crash-driven disconnect.
func (m *Manager) TakeUnexpectedDrop() bool {
m.mu.Lock()
defer m.mu.Unlock()
if !m.unexpectedDrop {
return false
}
m.unexpectedDrop = false
return true
}
func (m *Manager) watchEngine(gen uint64, eng Engine) {
if eng == nil {
return
}
for {
time.Sleep(800 * time.Millisecond)
m.mu.Lock()
if m.watchGeneration != gen || m.engine != eng {
m.mu.Unlock()
return
}
if eng.Running() {
m.mu.Unlock()
continue
}
// Core died without Disconnect().
m.engine = nil
m.profile = nil
m.lastError = "процесс ядра завершился неожиданно"
m.unexpectedDrop = true
sys := m.sys
cfgPath := m.cfgPath
m.mu.Unlock()
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(cfgPath)) {
_ = sys.ForceDisable()
sysproxy.ClearSentinel(cfgPath)
fmt.Fprintf(m.stderr, "core exited: system proxy cleared\n")
}
return
}
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
m.engine = nil
m.profile = nil
m.watchGeneration++ // stop watcher
m.unexpectedDrop = false
if engine != nil {
m.lastStop.Add(1)
}
m.mu.Unlock()
var first error
if sys != nil && sys.Enabled() {
if err := sys.Disable(); err != nil && first == nil {
first = err
if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(m.cfgPath)) {
var err error
if sys.Enabled() {
err = sys.Disable()
} else {
err = sys.ForceDisable()
}
if err != nil {
if err2 := sys.ForceDisable(); err2 != nil && first == nil {
first = err2
} else if first == nil {
first = err
}
}
sysproxy.ClearSentinel(m.cfgPath)
}
if engine != nil {
done := make(chan error, 1)
go func() { done <- engine.Stop() }()
go func() {
defer m.lastStop.Done()
done <- engine.Stop()
}()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
// Engine Stop can block on userspace relays; don't freeze the UI.
// Stop continues in background; Connect will wait on lastStop.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
@@ -207,10 +349,114 @@ func (m *Manager) BinDir() string { return m.binDir }
func (m *Manager) ConfigPath() string { return m.cfgPath }
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
func (m *Manager) Config() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
if m.cfg == nil {
return &config.Config{}
}
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).
type UIPoll struct {
Status Status
Active string
SystemProxy bool
Subscription string
ActiveProxy string
ActiveProtocol config.Protocol
Profiles []config.ProfileInfo
Hy2 Hy2Options
BinDir string
LastError string
ConnectOnLaunch bool
AutoReconnect bool
}
// 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 {
m.mu.Lock()
out := UIPoll{
BinDir: m.binDir,
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError,
}
var eng Engine
var profCopy *config.Profile
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
out.Subscription = m.cfg.SubscriptionURL
out.ConnectOnLaunch = m.cfg.ConnectOnLaunch
out.AutoReconnect = m.cfg.AutoReconnect
if includeSecrets {
out.Profiles = m.cfg.ListProfiles()
} else {
out.Profiles = m.cfg.ListProfilesForPoll()
}
if p, err := m.cfg.ActiveProfile(); err == nil {
out.Active = p.Name
out.ActiveProxy = p.Proxy
out.ActiveProtocol = p.Protocol
out.Hy2 = Hy2Options{
Congestion: p.Hy2Congestion,
BBRProfile: p.Hy2BBRProfile,
BandwidthUp: p.Hy2BandwidthUp,
BandwidthDown: p.Hy2BandwidthDown,
Obfs: p.Hy2Obfs,
ObfsPassword: p.Hy2ObfsPassword,
SNI: p.Hy2SNI,
Insecure: p.Hy2Insecure,
PinSHA256: p.Hy2PinSHA256,
FastOpen: p.Hy2FastOpen,
Lazy: p.Hy2Lazy,
HopInterval: p.Hy2HopInterval,
}
}
}
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
if profCopy != nil {
out.Status.Profile = profCopy.Name
out.Status.Protocol = profCopy.Protocol
}
if hp, ok := eng.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp
}
if sp, ok := eng.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp
}
}
return out
}
func (m *Manager) SetSystemProxy(enabled bool) {
@@ -219,6 +465,38 @@ func (m *Manager) SetSystemProxy(enabled bool) {
m.cfg.SystemProxy = enabled
}
// Prefs are GUI toggles persisted in config.json.
type Prefs struct {
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
SystemProxy bool `json:"system_proxy"`
}
func (m *Manager) Prefs() Prefs {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return Prefs{}
}
return Prefs{
ConnectOnLaunch: m.cfg.ConnectOnLaunch,
AutoReconnect: m.cfg.AutoReconnect,
SystemProxy: m.cfg.SystemProxy,
}
}
func (m *Manager) SavePrefs(p Prefs) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.cfg == nil {
return fmt.Errorf("no config")
}
m.cfg.ConnectOnLaunch = p.ConnectOnLaunch
m.cfg.AutoReconnect = p.AutoReconnect
m.cfg.SystemProxy = p.SystemProxy
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) UpdateProxyURI(proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -254,8 +532,13 @@ func (m *Manager) SetActiveProfile(name string) error {
func (m *Manager) SaveProfile(name, proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь")
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("имя профиля пустое")
}
connected := m.engine != nil && m.engine.Running()
if connected && name == m.cfg.Active {
return fmt.Errorf("сначала отключитесь, чтобы изменить активный профиль")
}
proxy = strings.TrimSpace(proxy)
var proto config.Protocol
@@ -267,7 +550,13 @@ func (m *Manager) SaveProfile(name, proxy string) error {
proxy = normalized
proto = detected
}
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
var err error
if connected {
err = m.cfg.UpsertProfileKeepActive(name, proxy, proto)
} else {
err = m.cfg.UpsertProfileWithProtocol(name, proxy, proto)
}
if err != nil {
return err
}
if p, err := m.cfg.ActiveProfile(); err == nil {
@@ -340,12 +629,59 @@ func (m *Manager) DeleteProfile(name string) error {
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 {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.ListProfiles()
}
// PingTargets returns name/protocol/host endpoints without copying proxy secrets.
func (m *Manager) PingTargets() []netcheck.Target {
m.mu.Lock()
defer m.mu.Unlock()
list := m.cfg.ListProfilesForPoll()
out := make([]netcheck.Target, 0, len(list))
for _, p := range list {
out = append(out, netcheck.Target{
Name: p.Name,
Protocol: config.Protocol(p.Protocol),
Host: p.Host,
})
}
return out
}
// Hy2Options are editable Hysteria 2 extras for the active profile.
type Hy2Options struct {
Congestion string `json:"congestion"`
@@ -430,20 +766,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return 0, fmt.Errorf("сначала отключитесь")
}
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
newNames := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
name := strings.TrimSpace(it.Name)
if name == "" {
continue
}
_ = m.cfg.UpsertProfileKeepActive(name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name {
if m.cfg.Profiles[i].Name == name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
if _, ok := seen[name]; !ok {
seen[name] = struct{}{}
newNames = append(newNames, name)
}
}
prev := map[string]struct{}{}
for _, n := range m.cfg.SubscriptionNames {
prev[n] = struct{}{}
}
// Prune profiles that came from the previous subscription sync but are gone now.
if len(prev) > 0 {
kept := make([]config.Profile, 0, len(m.cfg.Profiles))
for _, p := range m.cfg.Profiles {
if _, wasSub := prev[p.Name]; wasSub {
if _, still := seen[p.Name]; !still {
continue
}
}
kept = append(kept, p)
}
if len(kept) == 0 {
// Safety: never wipe all profiles.
kept = append([]config.Profile(nil), m.cfg.Profiles...)
} else {
m.cfg.Profiles = kept
}
activeOK := false
for _, p := range m.cfg.Profiles {
if p.Name == m.cfg.Active {
activeOK = true
break
}
}
if !activeOK && len(m.cfg.Profiles) > 0 {
m.cfg.Active = m.cfg.Profiles[0].Name
}
}
m.cfg.SubscriptionNames = newNames
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(items), nil
return len(newNames), nil
}
func (m *Manager) SaveConfig() error {
@@ -488,45 +873,80 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
// EnsureCore downloads the binary required by the active (or given) protocol.
func (m *Manager) EnsureCore(proto config.Protocol) (string, error) {
m.mu.Lock()
binDir := m.binDir
if proto == "" {
if p, err := m.cfg.ActiveProfile(); err == nil {
proto = p.Protocol
}
}
m.mu.Unlock()
var (
path string
err error
)
switch proto {
case config.ProtocolHysteria2:
return hysteria2.EnsureBinary(m.binDir)
path, err = hysteria2.EnsureBinary(binDir)
case config.ProtocolAWG:
return awg.EnsureBinary(m.binDir)
path, err = awg.EnsureBinary(binDir)
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
return xray.EnsureBinary(m.binDir)
path, err = xray.EnsureBinary(binDir)
default:
return naive.EnsureBinary(m.binDir)
path, err = naive.EnsureBinary(binDir)
proto = config.ProtocolNaive
}
if err != nil {
return "", err
}
corebin.Set(corebin.CacheKey(binDir, corebin.ProtoKey(string(proto))), path)
return path, nil
}
// EnsureAllCores installs naive + hysteria2 + xray cores (AWG is embedded).
// Downloads run in parallel (one goroutine per core) to use multiple CPU cores / network.
func (m *Manager) EnsureAllCores() (map[string]string, error) {
m.mu.Lock()
binDir := m.binDir
m.mu.Unlock()
corebin.Invalidate()
type item struct {
key string
path string
err error
}
jobs := []struct {
key string
fn func() (string, error)
}{
{"naive", func() (string, error) { return naive.EnsureBinary(binDir) }},
{"hysteria2", func() (string, error) { return hysteria2.EnsureBinary(binDir) }},
{"awg", func() (string, error) { return awg.EnsureBinary(binDir) }},
{"xray", func() (string, error) { return xray.EnsureBinary(binDir) }},
}
ch := make(chan item, len(jobs))
for _, j := range jobs {
j := j
go func() {
path, err := j.fn()
ch <- item{key: j.key, path: path, err: err}
}()
}
out := map[string]string{}
p1, err := naive.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("naive: %w", err)
var first error
for range jobs {
it := <-ch
if it.err != nil {
if first == nil {
first = fmt.Errorf("%s: %w", it.key, it.err)
}
continue
}
out[it.key] = it.path
corebin.Set(corebin.CacheKey(binDir, it.key), it.path)
}
out["naive"] = p1
p2, err := hysteria2.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("hysteria2: %w", err)
}
out["hysteria2"] = p2
p3, err := awg.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("awg: %w", err)
}
out["awg"] = p3
p4, err := xray.EnsureBinary(m.binDir)
if err != nil {
return out, fmt.Errorf("xray: %w", err)
}
out["xray"] = p4
return out, nil
return out, first
}
+127
View File
@@ -0,0 +1,127 @@
package core
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"vpnclient/internal/config"
)
type fakeEngine struct {
mu sync.Mutex
running bool
stopDelay time.Duration
stops atomic.Int32
}
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.running = true
return nil
}
func (e *fakeEngine) Stop() error {
e.stops.Add(1)
if e.stopDelay > 0 {
time.Sleep(e.stopDelay)
}
e.mu.Lock()
e.running = false
e.mu.Unlock()
return nil
}
func (e *fakeEngine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
cfg := &config.Config{
Active: "t",
SystemProxy: false,
BinDir: t.TempDir(),
Profiles: []config.Profile{{
Name: "t",
Protocol: config.ProtocolNaive,
Proxy: "https://u:p@example.com",
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
m.mu.Lock()
m.engine = eng
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
start := time.Now()
_ = m.Disconnect()
// Connect must wait for Stop to finish even if Disconnect returned early...
// our Disconnect waits up to 3s, so with 200ms it should complete.
if time.Since(start) < 150*time.Millisecond {
t.Fatalf("disconnect returned too fast")
}
if eng.Running() {
t.Fatal("engine still running")
}
// Simulate slow stop that outlives Disconnect timeout.
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
m.mu.Lock()
m.engine = eng2
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
// Force short path: call the wait mechanism like Connect does.
m.mu.Lock()
engine := m.engine
m.engine = nil
m.profile = nil
m.lastStop.Add(1)
m.mu.Unlock()
go func() {
defer m.lastStop.Done()
_ = engine.Stop()
}()
waitStart := time.Now()
m.waitEngineStopped()
if time.Since(waitStart) < 400*time.Millisecond {
t.Fatalf("waitEngineStopped returned before stop finished")
}
if eng2.Running() {
t.Fatal("engine2 still running after wait")
}
}
func TestConfigReturnsClone(t *testing.T) {
cfg := &config.Config{
Active: "a",
Profiles: []config.Profile{{
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
snap := m.Config()
snap.Active = "mutated"
snap.Profiles[0].Proxy = "leaked"
if m.cfg.Active != "a" {
t.Fatal("Config() leaked mutable Active")
}
if m.cfg.Profiles[0].Proxy != "https://x" {
t.Fatal("Config() leaked mutable Profile")
}
}
+89
View File
@@ -0,0 +1,89 @@
package corebin
import (
"strings"
"sync"
"golang.org/x/sync/singleflight"
)
var (
mu sync.Mutex
cache = map[string]string{}
group singleflight.Group
)
// CacheKey identifies a resolved core binary for a binDir + protocol.
func CacheKey(binDir, proto string) string {
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.
func Get(key string) (string, bool) {
mu.Lock()
defer mu.Unlock()
p, ok := cache[key]
return p, ok
}
// Set stores a resolved path.
func Set(key, path string) {
if key == "" || path == "" {
return
}
mu.Lock()
cache[key] = path
mu.Unlock()
}
// Invalidate clears all cached paths (call after EnsureCore / binDir change / failed Start).
func Invalidate() {
mu.Lock()
cache = map[string]string{}
mu.Unlock()
}
// 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) {
if path, ok := Get(key); ok {
return path, nil
}
v, err, _ := group.Do(key, func() (any, error) {
if path, ok := Get(key); ok {
return path, nil
}
path, err := fn()
if err != nil {
return "", err
}
Set(key, path)
return path, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
return path, nil
}
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
func ProtoKey(proto string) string {
switch proto {
case "hysteria2":
return "hysteria2"
case "awg":
return "awg"
case "vless", "vmess", "trojan", "xray":
return "xray"
default:
return "naive"
}
}
+92
View File
@@ -0,0 +1,92 @@
package corebin
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestResolveTrustsCacheWithoutStat(t *testing.T) {
Invalidate()
dir := t.TempDir()
path := filepath.Join(dir, "core")
if err := os.WriteFile(path, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
key := CacheKey(dir, "naive")
calls := 0
fn := func() (string, error) {
calls++
return path, nil
}
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("first: %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 {
t.Fatalf("cached without Stat: %q %v calls=%d", p, err, calls)
}
Invalidate()
if _, err := Resolve(key, fn); err == nil && calls != 2 {
// fn returns deleted path; Resolve still succeeds
}
if calls != 2 {
t.Fatalf("after Invalidate expected refresh, calls=%d", calls)
}
}
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
}
var wg sync.WaitGroup
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)
}
}()
}
time.Sleep(10 * time.Millisecond)
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)
}
}
+214
View File
@@ -0,0 +1,214 @@
package coredl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
type ReleaseAsset struct {
Name string
BrowserDownloadURL string
SHA256 string // lowercase hex, empty if unknown
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
// FetchLatestAssets loads assets for a repo's latest GitHub release.
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
if err != nil {
return "", nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", nil, err
}
out := make([]ReleaseAsset, 0, len(rel.Assets))
for _, a := range rel.Assets {
out = append(out, ReleaseAsset{
Name: a.Name,
BrowserDownloadURL: a.BrowserDownloadURL,
SHA256: parseDigestSHA256(a.Digest),
})
}
return rel.TagName, out, nil
}
func parseDigestSHA256(digest string) string {
digest = strings.TrimSpace(strings.ToLower(digest))
if digest == "" {
return ""
}
if strings.HasPrefix(digest, "sha256:") {
return strings.TrimPrefix(digest, "sha256:")
}
return ""
}
// DownloadFile writes url to path (size-capped).
func DownloadFile(path, url string, maxBytes int64) error {
if maxBytes <= 0 {
maxBytes = 120 << 20
}
client := &http.Client{Timeout: 15 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
return err
}
// FileSHA256 returns lowercase hex digest of path.
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
func VerifySHA256(path, want string) error {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
}
got, err := FileSHA256(path)
if err != nil {
return err
}
if got != want {
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// RequireSHA returns sha or an error if the asset has none.
func RequireSHA(a ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
}
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
for _, name := range companions {
for _, o := range all {
if !strings.EqualFold(o.Name, name) {
continue
}
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
if err != nil {
return "", err
}
if sum != "" {
return sum, nil
}
}
}
return RequireSHA(a)
}
func fetchChecksumFile(url, assetName string) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum download: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
text := string(body)
lowerName := strings.ToLower(assetName)
lines := strings.Split(text, "\n")
var shaLine string
for _, line := range lines {
l := strings.TrimSpace(strings.ToLower(line))
if strings.HasPrefix(l, "sha256:") {
shaLine = strings.TrimSpace(line[len("sha256:"):])
// keep scanning — prefer line that also mentions the asset
if strings.Contains(l, lowerName) {
return strings.ToLower(strings.Fields(shaLine)[0]), nil
}
}
fields := strings.Fields(line)
if len(fields) >= 2 {
sum := strings.ToLower(fields[0])
file := strings.TrimPrefix(fields[len(fields)-1], "*")
if len(sum) == 64 && strings.EqualFold(file, assetName) {
return sum, nil
}
}
}
if shaLine != "" {
parts := strings.Fields(shaLine)
if len(parts) > 0 && len(parts[0]) == 64 {
return strings.ToLower(parts[0]), nil
}
}
return "", nil
}
// DownloadAndVerify downloads url to path and checks sha256.
func DownloadAndVerify(path, url, wantSHA256 string) error {
if err := DownloadFile(path, url, 0); err != nil {
return err
}
if err := VerifySHA256(path, wantSHA256); err != nil {
_ = os.Remove(path)
return err
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
package coredl
import "testing"
func TestParseDigestAndVerify(t *testing.T) {
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
t.Fatalf("parse: %q", got)
}
if parseDigestSHA256("md5:x") != "" {
t.Fatal("expected empty for non-sha256")
}
}
+112
View File
@@ -0,0 +1,112 @@
//go:build darwin
package dockicon
import (
_ "embed"
"log"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"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
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
once sync.Once
ready bool
mu sync.Mutex
last *bool
selSet objc.SEL
selPerf objc.SEL
selShared objc.SEL
selData objc.SEL
selAlloc objc.SEL
selInit objc.SEL
selRetain objc.SEL
clsApp objc.Class
clsData objc.Class
clsImage objc.Class
)
func initAppKit() {
once.Do(func() {
if _, err := purego.Dlopen("/System/Library/Frameworks/Foundation.framework/Foundation", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: Foundation: %v", err)
return
}
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_GLOBAL|purego.RTLD_NOW); err != nil {
log.Printf("dockicon: AppKit: %v", err)
return
}
clsApp = objc.GetClass("NSApplication")
clsData = objc.GetClass("NSData")
clsImage = objc.GetClass("NSImage")
if clsApp == 0 || clsData == 0 || clsImage == 0 {
log.Printf("dockicon: missing AppKit classes")
return
}
selShared = objc.RegisterName("sharedApplication")
selSet = objc.RegisterName("setApplicationIconImage:")
selPerf = objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:")
selData = objc.RegisterName("dataWithBytes:length:")
selAlloc = objc.RegisterName("alloc")
selInit = objc.RegisterName("initWithData:")
selRetain = objc.RegisterName("retain")
ready = true
})
}
// SetConnected swaps the Dock icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
initAppKit()
if !ready {
return
}
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
mu.Unlock()
png := iconIdlePNG
if connected {
png = iconConnectedPNG
}
if len(png) == 0 {
return
}
data := objc.ID(clsData).Send(selData, unsafe.Pointer(&png[0]), len(png))
if data == 0 {
return
}
img := objc.ID(clsImage).Send(selAlloc)
img = img.Send(selInit, data)
if img == 0 {
return
}
img.Send(selRetain) // keep alive across async main-thread call
app := objc.ID(clsApp).Send(selShared)
if app == 0 {
return
}
// AppKit must run on the main thread (glaze owns it).
app.Send(selPerf, selSet, img, false)
if OnIconChange != nil {
OnIconChange(connected)
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !darwin && !windows
package dockicon
import "unsafe"
// SetConnected is a no-op on platforms without a Dock/taskbar icon swap.
func SetConnected(connected bool) {}
// BindWindow is a no-op outside Windows.
func BindWindow(h unsafe.Pointer) { _ = h }
+295
View File
@@ -0,0 +1,295 @@
//go:build windows
package dockicon
import (
"bytes"
_ "embed"
"image"
"image/png"
"sync"
"unsafe"
"golang.org/x/image/draw"
"golang.org/x/sys/windows"
)
//go:embed icon_idle.png
var iconIdlePNG []byte
//go:embed icon_connected.png
var iconConnectedPNG []byte
var (
user32 = windows.NewLazySystemDLL("user32.dll")
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
procSendMessage = user32.NewProc("SendMessageW")
procCreateIconFromResource = user32.NewProc("CreateIconFromResourceEx")
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
procDestroyIcon = user32.NewProc("DestroyIcon")
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
procCreateBitmap = gdi32.NewProc("CreateBitmap")
procDeleteObject = gdi32.NewProc("DeleteObject")
mu sync.Mutex
hwnd uintptr
last *bool
idleSmall windows.Handle
idleBig windows.Handle
connSmall windows.Handle
connBig windows.Handle
iconsReady bool
)
const (
wmSetIcon = 0x0080
iconSmall = 0
iconBig = 1
biRGB = 0
dibRGBColors = 0
)
type iconInfo struct {
FIcon int32
XHotspot uint32
YHotspot uint32
HbmMask windows.Handle
HbmColor windows.Handle
}
type bitmapInfoHeader struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
type bitmapInfo struct {
Header bitmapInfoHeader
Colors [1]uint32
}
// BindWindow attaches the main HWND so SetConnected can update the taskbar icon.
func BindWindow(h unsafe.Pointer) {
mu.Lock()
defer mu.Unlock()
hwnd = uintptr(h)
if err := ensureIconsLocked(); err != nil {
return
}
connected := last != nil && *last
applyLocked(connected)
}
// SetConnected swaps the taskbar/window icon: sea-wave N when connected, original when idle.
func SetConnected(connected bool) {
mu.Lock()
if last != nil && *last == connected {
mu.Unlock()
return
}
v := connected
last = &v
if hwnd == 0 {
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
return
}
if err := ensureIconsLocked(); err != nil {
mu.Unlock()
return
}
applyLocked(connected)
mu.Unlock()
if OnIconChange != nil {
OnIconChange(connected)
}
}
// StatusIcon returns a shared HICON for the current connection state (do not DestroyIcon).
func StatusIcon(connected bool) windows.Handle {
mu.Lock()
defer mu.Unlock()
if err := ensureIconsLocked(); err != nil {
return 0
}
if connected {
return connBig
}
return idleBig
}
func applyLocked(connected bool) {
if hwnd == 0 {
return
}
small, big := idleSmall, idleBig
if connected {
small, big = connSmall, connBig
}
if small != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconSmall, uintptr(small))
}
if big != 0 {
procSendMessage.Call(hwnd, wmSetIcon, iconBig, uintptr(big))
}
}
func ensureIconsLocked() error {
if iconsReady {
return nil
}
var err error
idleSmall, err = hiconFromPNG(iconIdlePNG, 16)
if err != nil {
return err
}
idleBig, err = hiconFromPNG(iconIdlePNG, 32)
if err != nil {
return err
}
connSmall, err = hiconFromPNG(iconConnectedPNG, 16)
if err != nil {
return err
}
connBig, err = hiconFromPNG(iconConnectedPNG, 32)
if err != nil {
return err
}
iconsReady = true
return nil
}
func hiconFromPNG(src []byte, size int) (windows.Handle, error) {
resized, err := resizePNG(src, size)
if err != nil {
return 0, err
}
if h := createIconFromPNGResource(resized, size); h != 0 {
return h, nil
}
return createIconFromImage(resized, size)
}
func resizePNG(src []byte, size int) ([]byte, error) {
img, err := png.Decode(bytes.NewReader(src))
if err != nil {
return nil, err
}
dst := image.NewRGBA(image.Rect(0, 0, size, size))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func createIconFromPNGResource(pngBytes []byte, size int) windows.Handle {
if len(pngBytes) == 0 {
return 0
}
h, _, _ := procCreateIconFromResource.Call(
uintptr(unsafe.Pointer(&pngBytes[0])),
uintptr(len(pngBytes)),
1,
0x00030000,
uintptr(size),
uintptr(size),
0,
)
return windows.Handle(h)
}
func createIconFromImage(pngBytes []byte, size int) (windows.Handle, error) {
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return 0, err
}
rgba, ok := img.(*image.RGBA)
if !ok {
tmp := image.NewRGBA(img.Bounds())
draw.Draw(tmp, tmp.Bounds(), img, img.Bounds().Min, draw.Src)
rgba = tmp
}
hbmColor, err := dibFromRGBA(rgba)
if err != nil {
return 0, err
}
// Monochrome mask: all zeros => fully opaque where color has alpha handled by 32-bit DIB.
hbmMask, _, err2 := procCreateBitmap.Call(uintptr(size), uintptr(size), 1, 1, 0)
if hbmMask == 0 {
procDeleteObject.Call(uintptr(hbmColor))
if err2 != nil {
return 0, err2
}
return 0, windows.ERROR_INVALID_HANDLE
}
ii := iconInfo{
FIcon: 1,
XHotspot: 0,
YHotspot: 0,
HbmMask: windows.Handle(hbmMask),
HbmColor: hbmColor,
}
h, _, err3 := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&ii)))
procDeleteObject.Call(uintptr(hbmMask))
procDeleteObject.Call(uintptr(hbmColor))
if h == 0 {
if err3 != nil {
return 0, err3
}
return 0, windows.ERROR_INVALID_HANDLE
}
return windows.Handle(h), nil
}
func dibFromRGBA(src *image.RGBA) (windows.Handle, error) {
w := src.Bounds().Dx()
h := src.Bounds().Dy()
bi := bitmapInfo{
Header: bitmapInfoHeader{
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
BiWidth: int32(w),
BiHeight: -int32(h), // top-down
BiPlanes: 1,
BiBitCount: 32,
BiCompression: biRGB,
},
}
var bits unsafe.Pointer
hbm, _, err := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&bi)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
if hbm == 0 || bits == nil {
if err != nil {
return 0, err
}
return 0, windows.ERROR_INVALID_HANDLE
}
// Windows wants BGRA.
dst := unsafe.Slice((*byte)(bits), w*h*4)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := src.PixOffset(x, y)
o := (y*w + x) * 4
dst[o+0] = src.Pix[i+2] // B
dst[o+1] = src.Pix[i+1] // G
dst[o+2] = src.Pix[i+0] // R
dst[o+3] = src.Pix[i+3] // A
}
}
return windows.Handle(hbm), nil
}
+5
View File
@@ -0,0 +1,5 @@
package dockicon
// OnIconChange is invoked after SetConnected updates the platform icon.
// Used on Windows to keep the tray glyph in sync; unused on other platforms.
var OnIconChange func(connected bool)
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+9
View File
@@ -0,0 +1,9 @@
package filedialog
import "errors"
// ErrCanceled is returned when the user closes the dialog without choosing a file.
var ErrCanceled = errors.New("filedialog: canceled")
// ErrUnsupported is returned on platforms without a native picker.
var ErrUnsupported = errors.New("filedialog: not supported on this platform")
+79
View File
@@ -0,0 +1,79 @@
//go:build darwin
package filedialog
import (
"os/exec"
"strings"
)
func appleScriptQuote(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
return strings.ReplaceAll(s, `"`, `\"`)
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
script := `try
set thePath to POSIX path of (choose file name with prompt "` + appleScriptQuote(title) + `" default name "` + appleScriptQuote(defaultName) + `")
return thePath
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if strings.Contains(string(out), "canceled") || strings.Contains(err.Error(), "canceled") {
return "", ErrCanceled
}
// osascript writes AppleScript errors to stderr; treat cancel-ish exits as cancel.
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
if !strings.HasSuffix(strings.ToLower(path), ".json") {
path += ".json"
}
return path, nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
script := `try
set theFile to choose file with prompt "` + appleScriptQuote(title) + `" of type {"public.json", "json", "public.plain-text"}
return POSIX path of theFile
on error number -128
error "canceled"
end try`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
if exit, ok := err.(*exec.ExitError); ok {
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
return "", ErrCanceled
}
}
return "", err
}
path := strings.TrimSpace(string(out))
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows && !darwin
package filedialog
// SaveJSON is not available on this platform.
func SaveJSON(title, defaultName string) (string, error) {
return "", ErrUnsupported
}
// OpenJSON is not available on this platform.
func OpenJSON(title string) (string, error) {
return "", ErrUnsupported
}
+139
View File
@@ -0,0 +1,139 @@
//go:build windows
package filedialog
import (
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
var (
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
procGetOpenFileName = comdlg32.NewProc("GetOpenFileNameW")
procGetSaveFileName = comdlg32.NewProc("GetSaveFileNameW")
)
const (
ofnExplorer = 0x00080000
ofnFileMustExist = 0x00001000
ofnPathMustExist = 0x00000800
ofnOverwritePrompt = 0x00000002
ofnHideReadOnly = 0x00000004
maxPath = 32768
)
type openFileNameW struct {
LStructSize uint32
HwndOwner windows.HWND
HInstance windows.Handle
LpstrFilter *uint16
LpstrCustomFilter *uint16
NMaxCustFilter uint32
NFilterIndex uint32
LpstrFile *uint16
NMaxFile uint32
LpstrFileTitle *uint16
NMaxFileTitle uint32
LpstrInitialDir *uint16
LpstrTitle *uint16
Flags uint32
NFileOffset uint16
NFileExtension uint16
LpstrDefExt *uint16
LCustData uintptr
LpfnHook uintptr
LpTemplateName *uint16
PvReserved unsafe.Pointer
DwReserved uint32
FlagsEx uint32
}
func jsonFilter() *uint16 {
// Double-null terminated pairs: label\0pattern\0label\0pattern\0\0
s := "JSON (*.json)\x00*.json\x00All files\x00*.*\x00"
return &utf16FromString(s)[0]
}
func utf16FromString(s string) []uint16 {
u, err := syscall.UTF16FromString(s)
if err != nil {
return []uint16{0}
}
return u
}
func ensureJSONExt(path string) string {
if strings.EqualFold(filepath.Ext(path), ".json") {
return path
}
return path + ".json"
}
// SaveJSON shows a Save As dialog for a .json file.
func SaveJSON(title, defaultName string) (string, error) {
if title == "" {
title = "Сохранить список серверов"
}
if defaultName == "" {
defaultName = "navis-servers.json"
}
buf := make([]uint16, maxPath)
copy(buf, utf16FromString(defaultName))
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
defExt, _ := syscall.UTF16PtrFromString("json")
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnPathMustExist | ofnOverwritePrompt | ofnHideReadOnly,
LpstrDefExt: defExt,
}
r, _, _ := procGetSaveFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return ensureJSONExt(path), nil
}
// OpenJSON shows an Open dialog for a .json file.
func OpenJSON(title string) (string, error) {
if title == "" {
title = "Загрузить список серверов"
}
buf := make([]uint16, maxPath)
filter := jsonFilter()
titlePtr, _ := syscall.UTF16PtrFromString(title)
ofn := openFileNameW{
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
LpstrFilter: filter,
NFilterIndex: 1,
LpstrFile: &buf[0],
NMaxFile: uint32(len(buf)),
LpstrTitle: titlePtr,
Flags: ofnExplorer | ofnFileMustExist | ofnPathMustExist | ofnHideReadOnly,
}
r, _, _ := procGetOpenFileName.Call(uintptr(unsafe.Pointer(&ofn)))
if r == 0 {
return "", ErrCanceled
}
path := windows.UTF16ToString(buf)
if path == "" {
return "", ErrCanceled
}
return path, nil
}
+1 -2
View File
@@ -2,7 +2,6 @@ package linknorm
import (
"fmt"
"strings"
"vpnclient/internal/config"
"vpnclient/internal/protocols/awg"
@@ -13,7 +12,7 @@ import (
// Normalize normalizes a share/proxy URI for the given protocol (or auto-detect).
func Normalize(proto config.Protocol, raw string) (normalized string, detected config.Protocol, remark string, err error) {
raw = strings.TrimSpace(raw)
raw = SanitizeShareText(raw)
if raw == "" {
return "", proto, "", fmt.Errorf("пустая ссылка")
}
+35
View File
@@ -0,0 +1,35 @@
package linknorm
import (
"strings"
"unicode"
)
// SanitizeShareText strips BOM, bidi isolates and zero-width chars that break
// scheme detection (common when pasting from messengers / Amnezia share).
func SanitizeShareText(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', // zero-width / BOM
'\u2066', '\u2067', '\u2068', '\u2069', // LRI/RLI/FSI/PDI
'\u200e', '\u200f', // LRM/RLM
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e': // embedding overrides
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
if unicode.Is(unicode.Cf, r) { // other format controls
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
+42
View File
@@ -0,0 +1,42 @@
package linknorm
import "testing"
func TestSanitizeShareText_BidiWrappers(t *testing.T) {
raw := "\u2068vpn://abc\u2069"
got := SanitizeShareText(raw)
if got != "vpn://abc" {
t.Fatalf("got %q", got)
}
}
func TestNormalize_DirtyVPN(t *testing.T) {
raw := "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxLjEuMS4xClByaXZhdGVLZXkgPSBhSkpSYUFFY1RidWdJYjB2emFJcW5MQkZJQ0dqNlRkcXo1RC84OU13T0hZPQpKYyA9IDYKCltQZWVyXQpQdWJsaWNLZXkgPSBOUThJMVpPcUtxWmhGUXlxcXBuakRWc1poWXpkUk11Qm9tVXBzNWZPTTNzPQpFbmRwb2ludCA9IHJ1LmRlNGltYS51azo0MTQyMQpBbGxvd2VkSVBzID0gMC4wLjAuMC8w\u2069"
// Use full user link instead for reliable parse
raw = "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1\u2069"
u, proto, _, err := Normalize("", raw)
if err != nil {
t.Fatal(err)
}
if proto != "awg" {
t.Fatalf("proto %q", proto)
}
if u == "" || !containsFold(u, "[Interface]") {
t.Fatalf("normalized %q", u)
}
}
func containsFold(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
}
func stringIndexFold(s, sub string) int {
// simple contains for test
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+7
View File
@@ -0,0 +1,7 @@
package listenutil
import "net"
func splitAddr(addr string) (host, port string, err error) {
return net.SplitHostPort(addr)
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package listenutil
// KillOrphanCores is best-effort on non-Windows (no-op).
func KillOrphanCores(binDir string, addrs ...string) error {
_ = binDir
_ = addrs
return nil
}
+120
View File
@@ -0,0 +1,120 @@
//go:build windows
package listenutil
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/windows"
)
var coreBasenames = map[string]struct{}{
"hysteria.exe": {},
"naive.exe": {},
"xray.exe": {},
"Xray.exe": {},
}
// KillOrphanCores terminates known VPN core processes holding listen addrs.
func KillOrphanCores(binDir string, addrs ...string) error {
_ = binDir // reserved for future path-scoped sweeps
wantPorts := map[string]struct{}{}
for _, addr := range ListenAddrs(addrs...) {
if _, port, err := splitAddr(addr); err == nil && port != "" {
wantPorts[port] = struct{}{}
}
}
if len(wantPorts) == 0 {
return nil
}
self := uint32(os.Getpid())
var last error
for pid, port := range listeningPIDs() {
if _, ok := wantPorts[port]; !ok {
continue
}
if pid == 0 || pid == self {
continue
}
path := queryImagePath(pid)
base := filepath.Base(path)
if _, ok := coreBasenames[base]; !ok {
continue
}
if err := terminatePID(pid); err != nil {
last = err
}
}
return last
}
func listeningPIDs() map[uint32]string {
out := map[uint32]string{}
cmd := exec.Command("netstat", "-ano", "-p", "tcp")
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
b, err := cmd.Output()
if err != nil {
return out
}
sc := bufio.NewScanner(strings.NewReader(string(b)))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") {
continue
}
if !strings.EqualFold(fields[3], "LISTENING") {
continue
}
port := portOf(fields[1])
pid64, err := strconv.ParseUint(fields[4], 10, 32)
if err != nil || port == "" {
continue
}
out[uint32(pid64)] = port
}
return out
}
func portOf(local string) string {
if _, port, err := splitAddr(local); err == nil {
return port
}
if i := strings.LastIndex(local, ":"); i >= 0 {
return local[i+1:]
}
return ""
}
func queryImagePath(pid uint32) string {
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return ""
}
defer windows.CloseHandle(h)
var buf [windows.MAX_PATH]uint16
size := uint32(len(buf))
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
return ""
}
return windows.UTF16ToString(buf[:size])
}
func terminatePID(pid uint32) error {
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
if err != nil {
return fmt.Errorf("open pid %d: %w", pid, err)
}
defer windows.CloseHandle(h)
if err := windows.TerminateProcess(h, 1); err != nil {
return fmt.Errorf("terminate pid %d: %w", pid, err)
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package listenutil
import (
"fmt"
"net"
"strings"
"time"
)
// ListenAddrs returns unique non-empty host:port listen targets.
func ListenAddrs(addrs ...string) []string {
seen := map[string]struct{}{}
var out []string
for _, hp := range addrs {
hp = strings.TrimSpace(hp)
if hp == "" {
continue
}
if _, ok := seen[hp]; ok {
continue
}
seen[hp] = struct{}{}
out = append(out, hp)
}
return out
}
// BusyError returns a user-facing error if any addr cannot be bound.
func BusyError(addrs ...string) error {
var busy []string
for _, addr := range addrs {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
if !canBindTCP(addr) {
busy = append(busy, addr)
}
}
if len(busy) == 0 {
return nil
}
return fmt.Errorf("порт %s занят — закройте другой Navis/ядро или смените listen в профиле", strings.Join(busy, ", "))
}
// EnsureFree tries to free orphan cores once, then verifies ports are bindable.
func EnsureFree(binDir string, addrs ...string) error {
addrs = ListenAddrs(addrs...)
if len(addrs) == 0 {
return nil
}
if err := BusyError(addrs...); err == nil {
return nil
}
_ = KillOrphanCores(binDir, addrs...)
time.Sleep(200 * time.Millisecond)
if err := BusyError(addrs...); err != nil {
return err
}
return nil
}
// ClassifyStartFailure rewrites a generic "exited immediately" into a port-busy
// message when listen addresses are occupied.
func ClassifyStartFailure(proto string, generic error, addrs ...string) error {
if err := BusyError(addrs...); err != nil {
return fmt.Errorf("%s: %w", proto, err)
}
return generic
}
func canBindTCP(addr string) bool {
ln, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = ln.Close()
return true
}
+49
View File
@@ -0,0 +1,49 @@
package listenutil
import (
"net"
"strings"
"testing"
)
func TestBusyErrorFreePort(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
_ = ln.Close()
if err := BusyError(addr); err != nil {
t.Fatalf("expected free: %v", err)
}
}
func TestBusyErrorTakenPort(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
addr := ln.Addr().String()
err = BusyError(addr)
if err == nil || !strings.Contains(err.Error(), "занят") {
t.Fatalf("got %v", err)
}
}
func TestClassifyStartFailure(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
generic := errString("exited immediately")
got := ClassifyStartFailure("hysteria2", generic, ln.Addr().String())
if got == nil || !strings.Contains(got.Error(), "занят") {
t.Fatalf("got %v", got)
}
}
type errString string
func (e errString) Error() string { return string(e) }
+59
View File
@@ -0,0 +1,59 @@
package logbuf
import (
"bytes"
"sync"
)
const DefaultMax = 256 << 10 // 256 KiB
// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
type Buffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
}
func New(max int) *Buffer {
if max <= 0 {
max = DefaultMax
}
return &Buffer{max: max}
}
func (b *Buffer) Write(p []byte) (int, error) {
if b == nil {
return len(p), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.buf.Len()+len(p) > b.max {
// Drop oldest half to keep recent diagnostics.
keep := b.buf.Bytes()
if len(keep) > b.max/2 {
keep = keep[len(keep)-b.max/2:]
}
b.buf.Reset()
_, _ = b.buf.Write(keep)
_, _ = b.buf.WriteString("\n… log truncated …\n")
}
return b.buf.Write(p)
}
func (b *Buffer) String() string {
if b == nil {
return ""
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func (b *Buffer) Len() int {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Len()
}
+29
View File
@@ -0,0 +1,29 @@
package logbuf
import (
"strings"
"sync"
"testing"
)
func TestBufferConcurrentWriteAndCap(t *testing.T) {
b := New(64)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 40; j++ {
_, _ = b.Write([]byte("abcdefghij"))
}
}()
}
wg.Wait()
if b.Len() > 64+40 { // allow truncate marker overhead
t.Fatalf("len=%d exceeds soft cap", b.Len())
}
s := b.String()
if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
t.Fatalf("unexpected content %q", s)
}
}
+150 -34
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net"
"runtime"
"sort"
"strings"
"sync"
@@ -23,13 +24,17 @@ type Result struct {
Port string `json:"port"`
Ms int64 `json:"ms"`
OK bool `json:"ok"`
Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse)
Error string `json:"error,omitempty"`
}
// Target describes one profile to ping.
// Prefer Host (host or host:port) so large proxy/AWG bodies are not copied;
// Proxy is used only when Host is empty.
type Target struct {
Name string
Protocol config.Protocol
Host string
Proxy string
}
@@ -44,24 +49,41 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) == 0 {
return out
}
workers := 12
// Scale with available OS threads (GOMAXPROCS); keep a useful floor/ceiling.
workers := runtime.GOMAXPROCS(0) * 2
if workers < 8 {
workers = 8
}
if workers > 32 {
workers = 32
}
if len(targets) < workers {
workers = len(targets)
}
sem := make(chan struct{}, workers)
jobs := make(chan int, len(targets))
for i := range targets {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup
for i, t := range targets {
for w := 0; w < workers; w++ {
wg.Add(1)
go func(i int, t Target) {
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return
for i := range jobs {
t := targets[i]
if hp := strings.TrimSpace(t.Host); hp != "" {
out[i] = PingHost(ctx, t.Name, t.Protocol, hp)
continue
}
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
continue
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}(i, t)
}()
}
wg.Wait()
@@ -82,12 +104,13 @@ func PingAll(ctx context.Context, targets []Target) []Result {
return sorted
}
// BestOK returns the lowest-latency successful result, if any.
// BestOK returns the lowest-latency hard OK result (excludes UDP soft-up).
// Soft-up nodes stay visible in the list but are not chosen as «Лучший».
func BestOK(results []Result) (Result, bool) {
var best Result
found := false
for _, r := range results {
if !r.OK {
if !r.OK || r.Soft {
continue
}
if !found || r.Ms < best.Ms {
@@ -98,8 +121,68 @@ func BestOK(results []Result) (Result, bool) {
return best, found
}
// PingHost probes a pre-parsed host or host:port (no proxy URI / secrets).
// Naive/Xray use TCP; Hysteria 2 / AWG use UDP.
func PingHost(ctx context.Context, name string, proto config.Protocol, hostPort string) Result {
res := Result{Name: name}
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
res.Error = "нет хоста"
return res
}
host, port := splitHostPort(hostPort, "443")
res.Host = host
res.Port = port
if host == "" {
res.Error = "нет хоста"
return res
}
udp := proto == config.ProtocolHysteria2 || proto == config.ProtocolAWG
var (
rtt time.Duration
soft bool
err error
)
if udp {
rtt, soft, err = probeUDP(ctx, host, port)
} else {
rtt, err = probeTCP(ctx, host, port)
}
if err != nil {
res.Error = friendlyDialError(err, udp)
return res
}
ms := rtt.Milliseconds()
if ms < 1 {
ms = 1
}
res.Ms = ms
res.OK = true
res.Soft = soft
return res
}
func splitHostPort(hostPort, defaultPort string) (host, port string) {
hostPort = strings.TrimSpace(hostPort)
if hostPort == "" {
return "", defaultPort
}
if h, p, err := net.SplitHostPort(hostPort); err == nil {
return h, p
}
// bare IPv6 without port
if strings.Count(hostPort, ":") >= 2 && !strings.HasPrefix(hostPort, "[") {
return hostPort, defaultPort
}
if i := strings.LastIndex(hostPort, ":"); i >= 0 && !strings.Contains(hostPort[i+1:], "]") {
return hostPort[:i], hostPort[i+1:]
}
return hostPort, defaultPort
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 uses UDP (QUIC) — TCP would always look "refused".
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
// When proto is set, Detect is skipped (trust stored protocol).
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
res := Result{Name: name}
if strings.TrimSpace(proxyURI) == "" {
@@ -107,9 +190,22 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
hy2 := proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI)
isAWG := proto == config.ProtocolAWG || awg.Detect(proxyURI)
isXray := proto == config.ProtocolVLESS || proto == config.ProtocolVMess || proto == config.ProtocolTrojan || xray.Detect(proxyURI)
var hy2, isAWG, isXray bool
switch proto {
case config.ProtocolHysteria2:
hy2 = true
case config.ProtocolAWG:
isAWG = true
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
isXray = true
case config.ProtocolNaive:
// TCP host parse below
default:
hy2 = hysteria2.Detect(proxyURI)
isAWG = awg.Detect(proxyURI)
isXray = xray.Detect(proxyURI)
}
var host, port string
if isAWG {
h, p, err := awg.HostPort(proxyURI)
@@ -163,66 +259,86 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
start := time.Now()
var err error
var (
rtt time.Duration
soft bool
err error
)
if hy2 || isAWG {
err = probeUDP(ctx, host, port)
rtt, soft, err = probeUDP(ctx, host, port)
} else {
err = probeTCP(ctx, host, port)
rtt, err = probeTCP(ctx, host, port)
}
if err != nil {
res.Error = friendlyDialError(err, hy2 || isAWG)
return res
}
res.Ms = time.Since(start).Milliseconds()
ms := rtt.Milliseconds()
if ms < 1 {
ms = 1
}
res.Ms = ms
res.OK = true
res.Soft = soft
return res
}
func probeTCP(ctx context.Context, host, port string) error {
func probeTCP(ctx context.Context, host, port string) (time.Duration, error) {
start := time.Now()
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
if err != nil {
return err
return 0, err
}
_ = conn.Close()
return nil
return time.Since(start), nil
}
func probeUDP(ctx context.Context, host, port string) error {
// probeUDP checks Hy2/AWG endpoints.
//
// UDP VPN ports usually ignore a probe datagram (no reply). Treating read-timeout
// as failure marked live nodes as down. Semantics:
// - ICMP / connection refused → down
// - any reply → hard up (RTT to reply)
// - short read-timeout after a successful write → soft-up (display only; not for Best)
func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) {
start := time.Now()
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
if err != nil {
return err
return 0, false, err
}
defer conn.Close()
deadline := time.Now().Add(1200 * time.Millisecond)
icmpWait := 350 * time.Millisecond
deadline := time.Now().Add(icmpWait)
if dl, ok := ctx.Deadline(); ok && dl.Before(deadline) {
deadline = dl
}
_ = conn.SetDeadline(deadline)
if _, err := conn.Write([]byte{0}); err != nil {
return err
return 0, false, err
}
afterWrite := time.Now()
buf := make([]byte, 64)
_, err = conn.Read(buf)
if err == nil {
return nil
return time.Since(start), false, nil
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return nil
return afterWrite.Sub(start), true, nil
}
return err
return 0, false, err
}
func friendlyDialError(err error, hy2 bool) string {
func friendlyDialError(err error, udp bool) string {
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "refused"):
if hy2 {
if udp {
return "UDP порт недоступен (connection refused)"
}
return "порт закрыт (connection refused)"
+99 -2
View File
@@ -2,7 +2,7 @@ package netcheck
import (
"context"
"strings"
"net"
"testing"
"time"
@@ -12,7 +12,7 @@ import (
func TestFriendlyDialError(t *testing.T) {
err := &netError{s: "dial tcp 1.2.3.4:22514: connectex: No connection could be made because the target machine actively refused it."}
got := friendlyDialError(err, true)
if !strings.Contains(got, "UDP") {
if got == "" || got == err.Error() {
t.Fatalf("got %q", got)
}
}
@@ -31,3 +31,100 @@ func TestPingProfileEmpty(t *testing.T) {
t.Fatalf("%+v", r)
}
}
func TestSplitHostPort(t *testing.T) {
h, p := splitHostPort("example.com:8443", "443")
if h != "example.com" || p != "8443" {
t.Fatalf("%s %s", h, p)
}
h, p = splitHostPort("example.com", "443")
if h != "example.com" || p != "443" {
t.Fatalf("%s %s", h, p)
}
h, p = splitHostPort("[2001:db8::1]:443", "80")
if h != "2001:db8::1" || p != "443" {
t.Fatalf("%s %s", h, p)
}
}
func TestPingHostEmpty(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r := PingHost(ctx, "x", config.ProtocolNaive, "")
if r.OK || r.Error == "" {
t.Fatalf("%+v", r)
}
}
func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
// Intentionally do not Read — mimics Hy2/AWG ignoring garbage probes.
_, port, err := net.SplitHostPort(pc.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
start := time.Now()
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatalf("expected soft-ok, got err=%v", err)
}
if !soft {
t.Fatal("expected soft=true")
}
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
if time.Since(start) > 900*time.Millisecond {
t.Fatalf("soft-ok waited too long: %v (should not use long timeout)", time.Since(start))
}
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
if !res.OK || !res.Soft {
t.Fatalf("PingProfile soft-ok failed: %+v", res)
}
if res.Ms < 1 {
t.Fatalf("ms=%d", res.Ms)
}
if _, ok := BestOK([]Result{res}); ok {
t.Fatal("BestOK must ignore soft-up")
}
}
func TestProbeUDPReplyOK(t *testing.T) {
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer pc.Close()
go func() {
buf := make([]byte, 64)
n, addr, err := pc.ReadFrom(buf)
if err != nil || n == 0 {
return
}
_, _ = pc.WriteTo([]byte{1}, addr)
}()
_, port, err := net.SplitHostPort(pc.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatal(err)
}
if soft {
t.Fatal("reply should be hard-ok")
}
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
}
+87 -2
View File
@@ -35,7 +35,7 @@ type Conf struct {
// Detect reports whether raw looks like an AWG/WireGuard config or share link.
func Detect(raw string) bool {
raw = strings.TrimSpace(raw)
raw = sanitizeShare(raw)
if raw == "" {
return false
}
@@ -58,7 +58,7 @@ func Detect(raw string) bool {
// Parse accepts a WireGuard/AWG .conf body, awg:// URI, vpn:// blob, or Amnezia JSON.
func Parse(raw string) (Conf, error) {
raw = strings.TrimSpace(raw)
raw = sanitizeShare(raw)
if raw == "" {
return Conf{}, fmt.Errorf("пустой AWG конфиг")
}
@@ -83,6 +83,26 @@ func Parse(raw string) (Conf, error) {
}
}
func sanitizeShare(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',
'\u2066', '\u2067', '\u2068', '\u2069',
'\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
func parseINI(raw string) (Conf, error) {
var c Conf
section := "interface" // treat leading keys before [Interface] as Interface
@@ -434,7 +454,11 @@ func (c *Conf) validate() error {
}
// HostPort returns UDP endpoint host and port for ping.
// Prefers a light Endpoint=/URI extract; falls back to full Parse for JSON/vpn:// blobs.
func HostPort(raw string) (host, port string, err error) {
if h, p, ok := lightEndpoint(raw); ok {
return h, p, nil
}
c, err := Parse(raw)
if err != nil {
return "", "", err
@@ -446,6 +470,67 @@ func HostPort(raw string) (host, port string, err error) {
return h, p, nil
}
func lightEndpoint(raw string) (host, port string, ok bool) {
raw = sanitizeShare(raw)
if raw == "" {
return "", "", false
}
lower := strings.ToLower(raw)
switch {
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
strings.HasPrefix(lower, "wireguard://"):
// URI host:port only — no full config decode.
rest := raw
if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:]
}
if at := strings.Index(rest, "@"); at >= 0 {
rest = rest[at+1:]
}
if i := strings.IndexAny(rest, "/?#"); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", "", false
}
h, p, err := net.SplitHostPort(rest)
if err != nil {
return rest, "51820", true
}
return h, p, true
}
if strings.Contains(lower, "endpoint") {
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
low := strings.ToLower(line)
if !strings.HasPrefix(low, "endpoint") {
continue
}
_, val, cutOK := strings.Cut(line, "=")
if !cutOK {
_, val, cutOK = strings.Cut(line, ":")
}
if !cutOK {
continue
}
val = strings.TrimSpace(val)
if val == "" {
continue
}
h, p, err := net.SplitHostPort(val)
if err != nil {
return val, "51820", true
}
return h, p, true
}
}
return "", "", false
}
// lookupIP is overridable in tests.
var lookupIP = defaultLookupIP
+26
View File
@@ -236,3 +236,29 @@ func indexOf(s, sub string) int {
}
return -1
}
func TestHostPortLightINI(t *testing.T) {
raw := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
`
h, p, err := HostPort(raw)
if err != nil {
t.Fatal(err)
}
if h != "203.0.113.10" || p != "51820" {
t.Fatalf("got %s:%s", h, p)
}
h2, p2, err := HostPort("awg://AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=@9.9.9.9:41421")
if err != nil {
t.Fatal(err)
}
if h2 != "9.9.9.9" || p2 != "41421" {
t.Fatalf("uri got %s:%s", h2, p2)
}
}

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