Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ceb3a5148 | ||
|
|
8b564110d5 | ||
|
|
57d719f02f | ||
|
|
fed1ace33e | ||
|
|
5d7c96f7fc | ||
|
|
2ad376b49e | ||
|
|
ff5d87ab9f | ||
|
|
464a61aebf | ||
|
|
d1570b23d3 | ||
|
|
838f9e340b | ||
|
|
621c847cb3 | ||
|
|
6b6c13c933 | ||
|
|
bec6c8392d | ||
|
|
6a7480dceb | ||
|
|
54b5b87990 | ||
|
|
64c097d1e7 | ||
|
|
041cbb1250 | ||
|
|
dc700f2bac | ||
|
|
43882b95ad |
@@ -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. Не коммитить и не пушить, пока пользователь явно не попросил.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
|
||||
|
||||
@@ -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; иконка без 400 ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout;
|
||||
- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»;
|
||||
- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив;
|
||||
- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей;
|
||||
- 3.8.2+12: экспорт/импорт списка серверов в JSON (замена при импорте);
|
||||
- 3.8.2+13: **iOS** — первый device IPA (`Navis-3.8.2.13.ipa`): Packet Tunnel + LibXray + hev; Clash Meta URL-подписки (vless/trojan/hy2); без подмены выбранного сервера; старт ядра до VPN-маршрутов; hy2/naive на iOS пока недоступны; desktop — полный Clash→share разбор (не только hy2).
|
||||
|
||||
В 3.8.1:
|
||||
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
|
||||
- список серверов в 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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
## Сборка
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 18 KiB |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/crgimenes/glaze"
|
||||
)
|
||||
|
||||
func openExternal(raw string) error {
|
||||
return exec.Command("open", raw).Start()
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func shutdownSignals() []os.Signal {
|
||||
return []os.Signal{os.Interrupt, syscall.SIGTERM}
|
||||
}
|
||||
|
||||
func decorateWindow(w glaze.WebView, hideOnClose bool) {
|
||||
// macOS Dock/app icon comes from the .app bundle; close-to-hide is Windows-only.
|
||||
_ = w
|
||||
_ = hideOnClose
|
||||
}
|
||||
|
||||
func showMainWindow() {}
|
||||
|
||||
func prepareWindowQuit() {}
|
||||
|
||||
func closeMainWindow() {
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
|
||||
"github.com/crgimenes/glaze"
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"vpnclient/internal/dockicon"
|
||||
)
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW")
|
||||
procSetWindowLongPtr = user32.NewProc("SetWindowLongPtrW")
|
||||
procCallWindowProc = user32.NewProc("CallWindowProcW")
|
||||
procShowWindow = user32.NewProc("ShowWindow")
|
||||
procSetForeground = user32.NewProc("SetForegroundWindow")
|
||||
procPostMessage = user32.NewProc("PostMessageW")
|
||||
procIsIconic = user32.NewProc("IsIconic")
|
||||
|
||||
mainHWND uintptr
|
||||
origWndProc uintptr
|
||||
closeHookCB uintptr
|
||||
allowDestroy atomic.Bool
|
||||
)
|
||||
|
||||
const (
|
||||
// GWLP_WNDPROC as unsigned pointer-sized -4.
|
||||
gwlpWndProc = ^uintptr(3)
|
||||
wmClose = 0x0010
|
||||
swHide = 0
|
||||
swShow = 5
|
||||
swRestore = 9
|
||||
)
|
||||
|
||||
func openExternal(raw string) error {
|
||||
verb, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
log.Println(msg)
|
||||
messageBox(msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func shutdownSignals() []os.Signal {
|
||||
return []os.Signal{os.Interrupt}
|
||||
}
|
||||
|
||||
func decorateWindow(w glaze.WebView, hideOnClose bool) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
hwnd := w.Window()
|
||||
applyWindowIcon(hwnd)
|
||||
dockicon.BindWindow(hwnd)
|
||||
if hideOnClose {
|
||||
installCloseToHide(hwnd)
|
||||
}
|
||||
}
|
||||
|
||||
func installCloseToHide(hwnd unsafe.Pointer) {
|
||||
if hwnd == nil {
|
||||
return
|
||||
}
|
||||
mainHWND = uintptr(hwnd)
|
||||
allowDestroy.Store(false)
|
||||
if closeHookCB == 0 {
|
||||
closeHookCB = windows.NewCallback(closeToHideWndProc)
|
||||
}
|
||||
orig, _, _ := procGetWindowLongPtr.Call(mainHWND, gwlpWndProc)
|
||||
if orig == 0 {
|
||||
return
|
||||
}
|
||||
origWndProc = orig
|
||||
procSetWindowLongPtr.Call(mainHWND, gwlpWndProc, closeHookCB)
|
||||
}
|
||||
|
||||
func closeToHideWndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr {
|
||||
if msg == wmClose && !allowDestroy.Load() {
|
||||
procShowWindow.Call(hwnd, uintptr(swHide))
|
||||
return 0
|
||||
}
|
||||
r, _, _ := procCallWindowProc.Call(origWndProc, hwnd, uintptr(msg), wParam, lParam)
|
||||
return r
|
||||
}
|
||||
|
||||
func showMainWindow() {
|
||||
if mainHWND == 0 {
|
||||
return
|
||||
}
|
||||
iconic, _, _ := procIsIconic.Call(mainHWND)
|
||||
if iconic != 0 {
|
||||
procShowWindow.Call(mainHWND, uintptr(swRestore))
|
||||
} else {
|
||||
procShowWindow.Call(mainHWND, uintptr(swShow))
|
||||
}
|
||||
procSetForeground.Call(mainHWND)
|
||||
}
|
||||
|
||||
func prepareWindowQuit() {
|
||||
allowDestroy.Store(true)
|
||||
}
|
||||
|
||||
func closeMainWindow() {
|
||||
prepareWindowQuit()
|
||||
if mainHWND == 0 {
|
||||
os.Exit(0)
|
||||
return
|
||||
}
|
||||
procPostMessage.Call(mainHWND, uintptr(wmClose), 0, 0)
|
||||
}
|
||||
|
||||
func applyWindowIcon(hwnd unsafe.Pointer) {
|
||||
ico := findIconPath()
|
||||
if ico == "" || hwnd == nil {
|
||||
return
|
||||
}
|
||||
pathPtr, err := windows.UTF16PtrFromString(ico)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loadImage := user32.NewProc("LoadImageW")
|
||||
sendMessage := user32.NewProc("SendMessageW")
|
||||
const (
|
||||
imageIcon = 1
|
||||
lrLoadFromFile = 0x0010
|
||||
lrDefaultSize = 0x0040
|
||||
wmSetIcon = 0x0080
|
||||
iconSmall = 0
|
||||
iconBig = 1
|
||||
)
|
||||
h, _, _ := loadImage.Call(0, uintptr(unsafe.Pointer(pathPtr)), imageIcon, 0, 0, lrLoadFromFile|lrDefaultSize)
|
||||
if h == 0 {
|
||||
return
|
||||
}
|
||||
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconSmall, h)
|
||||
sendMessage.Call(uintptr(hwnd), wmSetIcon, iconBig, h)
|
||||
}
|
||||
|
||||
func findIconPath() string {
|
||||
candidates := []string{}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, "assets", "navis.ico"),
|
||||
filepath.Join(dir, "navis.ico"),
|
||||
)
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "assets", "navis.ico"))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && !st.IsDir() {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3.8.2+3
|
||||
@@ -0,0 +1 @@
|
||||
3.8.2.3
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package apphost
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// eventBroker fans out lightweight UI notifications (SSE).
|
||||
type eventBroker struct {
|
||||
mu sync.Mutex
|
||||
subs map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func newEventBroker() *eventBroker {
|
||||
return &eventBroker{subs: make(map[chan []byte]struct{})}
|
||||
}
|
||||
|
||||
func (b *eventBroker) subscribe() chan []byte {
|
||||
ch := make(chan []byte, 8)
|
||||
b.mu.Lock()
|
||||
b.subs[ch] = struct{}{}
|
||||
b.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (b *eventBroker) unsubscribe(ch chan []byte) {
|
||||
b.mu.Lock()
|
||||
if _, ok := b.subs[ch]; ok {
|
||||
delete(b.subs, ch)
|
||||
close(ch)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *eventBroker) publish(payload []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for ch := range b.subs {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
// Drop if a slow client is behind; safety-net poll will catch up.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyState invalidates the idle rev cache and pushes a small SSE payload.
|
||||
func (a *App) NotifyState(kind string) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.invalidateRevCache()
|
||||
if a.events == nil {
|
||||
return
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "state"
|
||||
}
|
||||
b, err := json.Marshal(map[string]string{"type": kind})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.events.publish(b)
|
||||
}
|
||||
|
||||
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if a.APIToken != "" {
|
||||
tok := r.Header.Get("X-Navis-Token")
|
||||
if tok == "" {
|
||||
tok = r.URL.Query().Get("t")
|
||||
}
|
||||
if tok != a.APIToken {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "stream unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
ch := a.events.subscribe()
|
||||
defer a.events.unsubscribe(ch)
|
||||
|
||||
ping := time.NewTicker(20 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-ping.C:
|
||||
if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package apphost
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
"vpnclient/internal/core"
|
||||
"vpnclient/internal/logbuf"
|
||||
)
|
||||
|
||||
func TestHTTPBridgeGetState(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "demo",
|
||||
Profiles: []config.Profile{
|
||||
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
|
||||
},
|
||||
}
|
||||
mgr, err := core.NewManager("", cfg, logbuf.New(0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(mgr, "", logbuf.New(0))
|
||||
a.APIToken = "test-token"
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"args": []any{}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Navis-Token", "test-token")
|
||||
rr := httptest.NewRecorder()
|
||||
a.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Result UIState `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
t.Fatalf("api error: %s", resp.Error)
|
||||
}
|
||||
if resp.Result.ActiveProfile != "demo" {
|
||||
t.Fatalf("active_profile=%q", resp.Result.ActiveProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPBridgeGetStateUnchanged(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "demo",
|
||||
Profiles: []config.Profile{
|
||||
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
|
||||
},
|
||||
}
|
||||
mgr, err := core.NewManager("", cfg, logbuf.New(0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(mgr, "", logbuf.New(0))
|
||||
a.APIToken = "test-token"
|
||||
|
||||
full, err := a.GetStateSince("")
|
||||
if err != nil || full.Unchanged || full.Rev == "" {
|
||||
t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err)
|
||||
}
|
||||
again, err := a.GetStateSince(full.Rev)
|
||||
if err != nil || !again.Unchanged || again.Rev != full.Rev {
|
||||
t.Fatalf("delta: %+v err=%v", again, err)
|
||||
}
|
||||
if len(again.Profiles) != 0 {
|
||||
t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPBridgeRejectsBadToken(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
mgr, err := core.NewManager("", cfg, logbuf.New(0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(mgr, "", logbuf.New(0))
|
||||
a.APIToken = "secret"
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/getState", bytes.NewReader([]byte(`{"args":[]}`)))
|
||||
req.Header.Set("X-Navis-Token", "wrong")
|
||||
rr := httptest.NewRecorder()
|
||||
a.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ServersFileFormat = "navis-servers"
|
||||
ServersFileVersion = 1
|
||||
)
|
||||
|
||||
// ServersFile is the on-disk export/import format for the profile list.
|
||||
type ServersFile struct {
|
||||
Format string `json:"format,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Active string `json:"active,omitempty"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
}
|
||||
|
||||
// ExportServersFile builds a portable snapshot of profiles + active name.
|
||||
func ExportServersFile(cfg *Config) ServersFile {
|
||||
profiles := append([]Profile(nil), cfg.Profiles...)
|
||||
return ServersFile{
|
||||
Format: ServersFileFormat,
|
||||
Version: ServersFileVersion,
|
||||
Active: cfg.Active,
|
||||
Profiles: profiles,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteServersFile writes the export snapshot as indented JSON (0600).
|
||||
func WriteServersFile(path string, cfg *Config) (int, error) {
|
||||
doc := ExportServersFile(cfg)
|
||||
data, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(doc.Profiles), nil
|
||||
}
|
||||
|
||||
// ParseServersFile parses export JSON. Accepts:
|
||||
// - { "format":"navis-servers", "profiles":[...], "active":"..." }
|
||||
// - { "profiles":[...] }
|
||||
// - a bare JSON array of profiles
|
||||
func ParseServersFile(data []byte) (ServersFile, error) {
|
||||
data = bytes.TrimSpace(stripUTF8BOM(data))
|
||||
if len(data) == 0 {
|
||||
return ServersFile{}, fmt.Errorf("пустой файл списка серверов")
|
||||
}
|
||||
|
||||
var doc ServersFile
|
||||
if data[0] == '[' {
|
||||
var profiles []Profile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
return ServersFile{}, fmt.Errorf("разбор списка серверов: %w", err)
|
||||
}
|
||||
doc.Profiles = profiles
|
||||
} else {
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return ServersFile{}, fmt.Errorf("разбор файла серверов: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if doc.Format != "" && doc.Format != ServersFileFormat {
|
||||
return ServersFile{}, fmt.Errorf("неизвестный формат %q (ожидается %s)", doc.Format, ServersFileFormat)
|
||||
}
|
||||
if err := normalizeServersFile(&doc); err != nil {
|
||||
return ServersFile{}, err
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// ReadServersFile loads and validates a servers export file.
|
||||
func ReadServersFile(path string) (ServersFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ServersFile{}, err
|
||||
}
|
||||
return ParseServersFile(data)
|
||||
}
|
||||
|
||||
func normalizeServersFile(doc *ServersFile) error {
|
||||
if len(doc.Profiles) == 0 {
|
||||
return fmt.Errorf("в файле нет серверов")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(doc.Profiles))
|
||||
for i := range doc.Profiles {
|
||||
p := &doc.Profiles[i]
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("профиль[%d]: пустое имя", i)
|
||||
}
|
||||
if _, ok := seen[p.Name]; ok {
|
||||
return fmt.Errorf("дублируется имя профиля %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = struct{}{}
|
||||
switch p.Protocol {
|
||||
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan:
|
||||
case "":
|
||||
p.Protocol = ProtocolNaive
|
||||
default:
|
||||
return fmt.Errorf("профиль %q: неподдерживаемый протокол %q", p.Name, p.Protocol)
|
||||
}
|
||||
}
|
||||
doc.Active = strings.TrimSpace(doc.Active)
|
||||
if doc.Active == "" {
|
||||
doc.Active = doc.Profiles[0].Name
|
||||
} else if _, ok := seen[doc.Active]; !ok {
|
||||
doc.Active = doc.Profiles[0].Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyServersReplace replaces profiles with the import document (prefs untouched).
|
||||
func (c *Config) ApplyServersReplace(doc ServersFile) error {
|
||||
if err := normalizeServersFile(&doc); err != nil {
|
||||
return err
|
||||
}
|
||||
c.Profiles = append([]Profile(nil), doc.Profiles...)
|
||||
c.Active = doc.Active
|
||||
c.SubscriptionNames = nil
|
||||
InvalidateHostCache()
|
||||
return c.Validate()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServersFileRoundTrip(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Active: "b",
|
||||
Profiles: []Profile{
|
||||
{Name: "a", Protocol: ProtocolNaive, Proxy: "https://u:p@a.example"},
|
||||
{Name: "b", Protocol: ProtocolHysteria2, Proxy: "hy2://tok@b.example:443"},
|
||||
},
|
||||
SystemProxy: true,
|
||||
ConnectOnLaunch: true,
|
||||
SubscriptionURL: "https://sub.example/x",
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "servers.json")
|
||||
n, err := WriteServersFile(path, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("exported %d, want 2", n)
|
||||
}
|
||||
|
||||
other := &Config{
|
||||
Active: "old",
|
||||
SystemProxy: false,
|
||||
ConnectOnLaunch: false,
|
||||
SubscriptionURL: "https://keep.example",
|
||||
SubscriptionNames: []string{"old"},
|
||||
Profiles: []Profile{
|
||||
{Name: "old", Protocol: ProtocolNaive, Proxy: "https://old"},
|
||||
},
|
||||
}
|
||||
doc, err := ReadServersFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := other.ApplyServersReplace(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if other.SubscriptionURL != "https://keep.example" {
|
||||
t.Fatalf("prefs changed: %q", other.SubscriptionURL)
|
||||
}
|
||||
if other.SystemProxy {
|
||||
t.Fatal("system_proxy should stay false")
|
||||
}
|
||||
if len(other.Profiles) != 2 || other.Active != "b" {
|
||||
t.Fatalf("got active=%q profiles=%+v", other.Active, other.Profiles)
|
||||
}
|
||||
if other.SubscriptionNames != nil {
|
||||
t.Fatalf("subscription_names should clear, got %v", other.SubscriptionNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServersFileBareArray(t *testing.T) {
|
||||
doc, err := ParseServersFile([]byte(`[{"name":"x","protocol":"naive","proxy":"https://x"}]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(doc.Profiles) != 1 || doc.Active != "x" {
|
||||
t.Fatalf("unexpected: %+v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseServersFileEmptyRejected(t *testing.T) {
|
||||
if _, err := ParseServersFile([]byte(`{"profiles":[]}`)); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if _, err := ParseServersFile([]byte(`[]`)); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteServersFilePermissions(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Active: "a",
|
||||
Profiles: []Profile{{Name: "a", Protocol: ProtocolNaive}},
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "out.json")
|
||||
if _, err := WriteServersFile(path, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Size() == 0 {
|
||||
t.Fatal("empty file")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,295 @@
|
||||
//go:build windows
|
||||
|
||||
package dockicon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"image"
|
||||
"image/png"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
//go:embed icon_idle.png
|
||||
var iconIdlePNG []byte
|
||||
|
||||
//go:embed icon_connected.png
|
||||
var iconConnectedPNG []byte
|
||||
|
||||
var (
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||
|
||||
procSendMessage = user32.NewProc("SendMessageW")
|
||||
procCreateIconFromResource = user32.NewProc("CreateIconFromResourceEx")
|
||||
procCreateIconIndirect = user32.NewProc("CreateIconIndirect")
|
||||
procDestroyIcon = user32.NewProc("DestroyIcon")
|
||||
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
|
||||
procCreateBitmap = gdi32.NewProc("CreateBitmap")
|
||||
procDeleteObject = gdi32.NewProc("DeleteObject")
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
hwnd uintptr
|
||||
last *bool
|
||||
|
||||
idleSmall windows.Handle
|
||||
idleBig windows.Handle
|
||||
connSmall windows.Handle
|
||||
connBig windows.Handle
|
||||
iconsReady bool
|
||||
)
|
||||
|
||||
const (
|
||||
wmSetIcon = 0x0080
|
||||
iconSmall = 0
|
||||
iconBig = 1
|
||||
biRGB = 0
|
||||
dibRGBColors = 0
|
||||
)
|
||||
|
||||
type iconInfo struct {
|
||||
FIcon int32
|
||||
XHotspot uint32
|
||||
YHotspot uint32
|
||||
HbmMask windows.Handle
|
||||
HbmColor windows.Handle
|
||||
}
|
||||
|
||||
type bitmapInfoHeader struct {
|
||||
BiSize uint32
|
||||
BiWidth int32
|
||||
BiHeight int32
|
||||
BiPlanes uint16
|
||||
BiBitCount uint16
|
||||
BiCompression uint32
|
||||
BiSizeImage uint32
|
||||
BiXPelsPerMeter int32
|
||||
BiYPelsPerMeter int32
|
||||
BiClrUsed uint32
|
||||
BiClrImportant uint32
|
||||
}
|
||||
|
||||
type bitmapInfo struct {
|
||||
Header bitmapInfoHeader
|
||||
Colors [1]uint32
|
||||
}
|
||||
|
||||
// BindWindow attaches the main HWND so SetConnected can update the taskbar icon.
|
||||
func BindWindow(h unsafe.Pointer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
hwnd = uintptr(h)
|
||||
if err := ensureIconsLocked(); err != nil {
|
||||
return
|
||||
}
|
||||
connected := last != nil && *last
|
||||
applyLocked(connected)
|
||||
}
|
||||
|
||||
// SetConnected swaps the taskbar/window icon: sea-wave N when connected, original when idle.
|
||||
func SetConnected(connected bool) {
|
||||
mu.Lock()
|
||||
if last != nil && *last == connected {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
v := connected
|
||||
last = &v
|
||||
if hwnd == 0 {
|
||||
mu.Unlock()
|
||||
if OnIconChange != nil {
|
||||
OnIconChange(connected)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := ensureIconsLocked(); err != nil {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
applyLocked(connected)
|
||||
mu.Unlock()
|
||||
if OnIconChange != nil {
|
||||
OnIconChange(connected)
|
||||
}
|
||||
}
|
||||
|
||||
// StatusIcon returns a shared HICON for the current connection state (do not DestroyIcon).
|
||||
func StatusIcon(connected bool) windows.Handle {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err := ensureIconsLocked(); err != nil {
|
||||
return 0
|
||||
}
|
||||
if connected {
|
||||
return connBig
|
||||
}
|
||||
return idleBig
|
||||
}
|
||||
|
||||
func applyLocked(connected bool) {
|
||||
if hwnd == 0 {
|
||||
return
|
||||
}
|
||||
small, big := idleSmall, idleBig
|
||||
if connected {
|
||||
small, big = connSmall, connBig
|
||||
}
|
||||
if small != 0 {
|
||||
procSendMessage.Call(hwnd, wmSetIcon, iconSmall, uintptr(small))
|
||||
}
|
||||
if big != 0 {
|
||||
procSendMessage.Call(hwnd, wmSetIcon, iconBig, uintptr(big))
|
||||
}
|
||||
}
|
||||
|
||||
func ensureIconsLocked() error {
|
||||
if iconsReady {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
idleSmall, err = hiconFromPNG(iconIdlePNG, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idleBig, err = hiconFromPNG(iconIdlePNG, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connSmall, err = hiconFromPNG(iconConnectedPNG, 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connBig, err = hiconFromPNG(iconConnectedPNG, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iconsReady = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func hiconFromPNG(src []byte, size int) (windows.Handle, error) {
|
||||
resized, err := resizePNG(src, size)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if h := createIconFromPNGResource(resized, size); h != 0 {
|
||||
return h, nil
|
||||
}
|
||||
return createIconFromImage(resized, size)
|
||||
}
|
||||
|
||||
func resizePNG(src []byte, size int) ([]byte, error) {
|
||||
img, err := png.Decode(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func createIconFromPNGResource(pngBytes []byte, size int) windows.Handle {
|
||||
if len(pngBytes) == 0 {
|
||||
return 0
|
||||
}
|
||||
h, _, _ := procCreateIconFromResource.Call(
|
||||
uintptr(unsafe.Pointer(&pngBytes[0])),
|
||||
uintptr(len(pngBytes)),
|
||||
1,
|
||||
0x00030000,
|
||||
uintptr(size),
|
||||
uintptr(size),
|
||||
0,
|
||||
)
|
||||
return windows.Handle(h)
|
||||
}
|
||||
|
||||
func createIconFromImage(pngBytes []byte, size int) (windows.Handle, error) {
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rgba, ok := img.(*image.RGBA)
|
||||
if !ok {
|
||||
tmp := image.NewRGBA(img.Bounds())
|
||||
draw.Draw(tmp, tmp.Bounds(), img, img.Bounds().Min, draw.Src)
|
||||
rgba = tmp
|
||||
}
|
||||
|
||||
hbmColor, err := dibFromRGBA(rgba)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Monochrome mask: all zeros => fully opaque where color has alpha handled by 32-bit DIB.
|
||||
hbmMask, _, err2 := procCreateBitmap.Call(uintptr(size), uintptr(size), 1, 1, 0)
|
||||
if hbmMask == 0 {
|
||||
procDeleteObject.Call(uintptr(hbmColor))
|
||||
if err2 != nil {
|
||||
return 0, err2
|
||||
}
|
||||
return 0, windows.ERROR_INVALID_HANDLE
|
||||
}
|
||||
|
||||
ii := iconInfo{
|
||||
FIcon: 1,
|
||||
XHotspot: 0,
|
||||
YHotspot: 0,
|
||||
HbmMask: windows.Handle(hbmMask),
|
||||
HbmColor: hbmColor,
|
||||
}
|
||||
h, _, err3 := procCreateIconIndirect.Call(uintptr(unsafe.Pointer(&ii)))
|
||||
procDeleteObject.Call(uintptr(hbmMask))
|
||||
procDeleteObject.Call(uintptr(hbmColor))
|
||||
if h == 0 {
|
||||
if err3 != nil {
|
||||
return 0, err3
|
||||
}
|
||||
return 0, windows.ERROR_INVALID_HANDLE
|
||||
}
|
||||
return windows.Handle(h), nil
|
||||
}
|
||||
|
||||
func dibFromRGBA(src *image.RGBA) (windows.Handle, error) {
|
||||
w := src.Bounds().Dx()
|
||||
h := src.Bounds().Dy()
|
||||
bi := bitmapInfo{
|
||||
Header: bitmapInfoHeader{
|
||||
BiSize: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||
BiWidth: int32(w),
|
||||
BiHeight: -int32(h), // top-down
|
||||
BiPlanes: 1,
|
||||
BiBitCount: 32,
|
||||
BiCompression: biRGB,
|
||||
},
|
||||
}
|
||||
var bits unsafe.Pointer
|
||||
hbm, _, err := procCreateDIBSection.Call(0, uintptr(unsafe.Pointer(&bi)), dibRGBColors, uintptr(unsafe.Pointer(&bits)), 0, 0)
|
||||
if hbm == 0 || bits == nil {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return 0, windows.ERROR_INVALID_HANDLE
|
||||
}
|
||||
// Windows wants BGRA.
|
||||
dst := unsafe.Slice((*byte)(bits), w*h*4)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := src.PixOffset(x, y)
|
||||
o := (y*w + x) * 4
|
||||
dst[o+0] = src.Pix[i+2] // B
|
||||
dst[o+1] = src.Pix[i+1] // G
|
||||
dst[o+2] = src.Pix[i+0] // R
|
||||
dst[o+3] = src.Pix[i+3] // A
|
||||
}
|
||||
}
|
||||
return windows.Handle(hbm), nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dockicon
|
||||
|
||||
// OnIconChange is invoked after SetConnected updates the platform icon.
|
||||
// Used on Windows to keep the tray glyph in sync; unused on other platforms.
|
||||
var OnIconChange func(connected bool)
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,9 @@
|
||||
package filedialog
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrCanceled is returned when the user closes the dialog without choosing a file.
|
||||
var ErrCanceled = errors.New("filedialog: canceled")
|
||||
|
||||
// ErrUnsupported is returned on platforms without a native picker.
|
||||
var ErrUnsupported = errors.New("filedialog: not supported on this platform")
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build darwin
|
||||
|
||||
package filedialog
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func appleScriptQuote(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
|
||||
// SaveJSON shows a Save As dialog for a .json file.
|
||||
func SaveJSON(title, defaultName string) (string, error) {
|
||||
if title == "" {
|
||||
title = "Сохранить список серверов"
|
||||
}
|
||||
if defaultName == "" {
|
||||
defaultName = "navis-servers.json"
|
||||
}
|
||||
script := `try
|
||||
set thePath to POSIX path of (choose file name with prompt "` + appleScriptQuote(title) + `" default name "` + appleScriptQuote(defaultName) + `")
|
||||
return thePath
|
||||
on error number -128
|
||||
error "canceled"
|
||||
end try`
|
||||
out, err := exec.Command("osascript", "-e", script).Output()
|
||||
if err != nil {
|
||||
if strings.Contains(string(out), "canceled") || strings.Contains(err.Error(), "canceled") {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
// osascript writes AppleScript errors to stderr; treat cancel-ish exits as cancel.
|
||||
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 {
|
||||
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
|
||||
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(string(out))
|
||||
if path == "" {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// OpenJSON shows an Open dialog for a .json file.
|
||||
func OpenJSON(title string) (string, error) {
|
||||
if title == "" {
|
||||
title = "Загрузить список серверов"
|
||||
}
|
||||
script := `try
|
||||
set theFile to choose file with prompt "` + appleScriptQuote(title) + `" of type {"public.json", "json", "public.plain-text"}
|
||||
return POSIX path of theFile
|
||||
on error number -128
|
||||
error "canceled"
|
||||
end try`
|
||||
out, err := exec.Command("osascript", "-e", script).Output()
|
||||
if err != nil {
|
||||
if exit, ok := err.(*exec.ExitError); ok {
|
||||
msg := strings.ToLower(string(exit.Stderr) + string(out) + err.Error())
|
||||
if strings.Contains(msg, "cancel") || strings.Contains(msg, "-128") {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
path := strings.TrimSpace(string(out))
|
||||
if path == "" {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package filedialog
|
||||
|
||||
// SaveJSON is not available on this platform.
|
||||
func SaveJSON(title, defaultName string) (string, error) {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
|
||||
// OpenJSON is not available on this platform.
|
||||
func OpenJSON(title string) (string, error) {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//go:build windows
|
||||
|
||||
package filedialog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
comdlg32 = windows.NewLazySystemDLL("comdlg32.dll")
|
||||
procGetOpenFileName = comdlg32.NewProc("GetOpenFileNameW")
|
||||
procGetSaveFileName = comdlg32.NewProc("GetSaveFileNameW")
|
||||
)
|
||||
|
||||
const (
|
||||
ofnExplorer = 0x00080000
|
||||
ofnFileMustExist = 0x00001000
|
||||
ofnPathMustExist = 0x00000800
|
||||
ofnOverwritePrompt = 0x00000002
|
||||
ofnHideReadOnly = 0x00000004
|
||||
maxPath = 32768
|
||||
)
|
||||
|
||||
type openFileNameW struct {
|
||||
LStructSize uint32
|
||||
HwndOwner windows.HWND
|
||||
HInstance windows.Handle
|
||||
LpstrFilter *uint16
|
||||
LpstrCustomFilter *uint16
|
||||
NMaxCustFilter uint32
|
||||
NFilterIndex uint32
|
||||
LpstrFile *uint16
|
||||
NMaxFile uint32
|
||||
LpstrFileTitle *uint16
|
||||
NMaxFileTitle uint32
|
||||
LpstrInitialDir *uint16
|
||||
LpstrTitle *uint16
|
||||
Flags uint32
|
||||
NFileOffset uint16
|
||||
NFileExtension uint16
|
||||
LpstrDefExt *uint16
|
||||
LCustData uintptr
|
||||
LpfnHook uintptr
|
||||
LpTemplateName *uint16
|
||||
PvReserved unsafe.Pointer
|
||||
DwReserved uint32
|
||||
FlagsEx uint32
|
||||
}
|
||||
|
||||
func jsonFilter() *uint16 {
|
||||
// Double-null terminated pairs: label\0pattern\0label\0pattern\0\0
|
||||
s := "JSON (*.json)\x00*.json\x00All files\x00*.*\x00"
|
||||
return &utf16FromString(s)[0]
|
||||
}
|
||||
|
||||
func utf16FromString(s string) []uint16 {
|
||||
u, err := syscall.UTF16FromString(s)
|
||||
if err != nil {
|
||||
return []uint16{0}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func ensureJSONExt(path string) string {
|
||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
||||
return path
|
||||
}
|
||||
return path + ".json"
|
||||
}
|
||||
|
||||
// SaveJSON shows a Save As dialog for a .json file.
|
||||
func SaveJSON(title, defaultName string) (string, error) {
|
||||
if title == "" {
|
||||
title = "Сохранить список серверов"
|
||||
}
|
||||
if defaultName == "" {
|
||||
defaultName = "navis-servers.json"
|
||||
}
|
||||
buf := make([]uint16, maxPath)
|
||||
copy(buf, utf16FromString(defaultName))
|
||||
|
||||
filter := jsonFilter()
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
||||
defExt, _ := syscall.UTF16PtrFromString("json")
|
||||
|
||||
ofn := openFileNameW{
|
||||
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
|
||||
LpstrFilter: filter,
|
||||
NFilterIndex: 1,
|
||||
LpstrFile: &buf[0],
|
||||
NMaxFile: uint32(len(buf)),
|
||||
LpstrTitle: titlePtr,
|
||||
Flags: ofnExplorer | ofnPathMustExist | ofnOverwritePrompt | ofnHideReadOnly,
|
||||
LpstrDefExt: defExt,
|
||||
}
|
||||
r, _, _ := procGetSaveFileName.Call(uintptr(unsafe.Pointer(&ofn)))
|
||||
if r == 0 {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
path := windows.UTF16ToString(buf)
|
||||
if path == "" {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
return ensureJSONExt(path), nil
|
||||
}
|
||||
|
||||
// OpenJSON shows an Open dialog for a .json file.
|
||||
func OpenJSON(title string) (string, error) {
|
||||
if title == "" {
|
||||
title = "Загрузить список серверов"
|
||||
}
|
||||
buf := make([]uint16, maxPath)
|
||||
filter := jsonFilter()
|
||||
titlePtr, _ := syscall.UTF16PtrFromString(title)
|
||||
|
||||
ofn := openFileNameW{
|
||||
LStructSize: uint32(unsafe.Sizeof(openFileNameW{})),
|
||||
LpstrFilter: filter,
|
||||
NFilterIndex: 1,
|
||||
LpstrFile: &buf[0],
|
||||
NMaxFile: uint32(len(buf)),
|
||||
LpstrTitle: titlePtr,
|
||||
Flags: ofnExplorer | ofnFileMustExist | ofnPathMustExist | ofnHideReadOnly,
|
||||
}
|
||||
r, _, _ := procGetOpenFileName.Call(uintptr(unsafe.Pointer(&ofn)))
|
||||
if r == 0 {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
path := windows.UTF16ToString(buf)
|
||||
if path == "" {
|
||||
return "", ErrCanceled
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -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("пустая ссылка")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package listenutil
|
||||
|
||||
import "net"
|
||||
|
||||
func splitAddr(addr string) (host, port string, err error) {
|
||||
return net.SplitHostPort(addr)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package listenutil
|
||||
|
||||
// KillOrphanCores is best-effort on non-Windows (no-op).
|
||||
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||
_ = binDir
|
||||
_ = addrs
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build windows
|
||||
|
||||
package listenutil
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var coreBasenames = map[string]struct{}{
|
||||
"hysteria.exe": {},
|
||||
"naive.exe": {},
|
||||
"xray.exe": {},
|
||||
"Xray.exe": {},
|
||||
}
|
||||
|
||||
// KillOrphanCores terminates known VPN core processes holding listen addrs.
|
||||
func KillOrphanCores(binDir string, addrs ...string) error {
|
||||
_ = binDir // reserved for future path-scoped sweeps
|
||||
wantPorts := map[string]struct{}{}
|
||||
for _, addr := range ListenAddrs(addrs...) {
|
||||
if _, port, err := splitAddr(addr); err == nil && port != "" {
|
||||
wantPorts[port] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(wantPorts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
self := uint32(os.Getpid())
|
||||
var last error
|
||||
for pid, port := range listeningPIDs() {
|
||||
if _, ok := wantPorts[port]; !ok {
|
||||
continue
|
||||
}
|
||||
if pid == 0 || pid == self {
|
||||
continue
|
||||
}
|
||||
path := queryImagePath(pid)
|
||||
base := filepath.Base(path)
|
||||
if _, ok := coreBasenames[base]; !ok {
|
||||
continue
|
||||
}
|
||||
if err := terminatePID(pid); err != nil {
|
||||
last = err
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func listeningPIDs() map[uint32]string {
|
||||
out := map[uint32]string{}
|
||||
cmd := exec.Command("netstat", "-ano", "-p", "tcp")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
b, err := cmd.Output()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
sc := bufio.NewScanner(strings.NewReader(string(b)))
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 || !strings.EqualFold(fields[0], "TCP") {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(fields[3], "LISTENING") {
|
||||
continue
|
||||
}
|
||||
port := portOf(fields[1])
|
||||
pid64, err := strconv.ParseUint(fields[4], 10, 32)
|
||||
if err != nil || port == "" {
|
||||
continue
|
||||
}
|
||||
out[uint32(pid64)] = port
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func portOf(local string) string {
|
||||
if _, port, err := splitAddr(local); err == nil {
|
||||
return port
|
||||
}
|
||||
if i := strings.LastIndex(local, ":"); i >= 0 {
|
||||
return local[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func queryImagePath(pid uint32) string {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var buf [windows.MAX_PATH]uint16
|
||||
size := uint32(len(buf))
|
||||
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||
return ""
|
||||
}
|
||||
return windows.UTF16ToString(buf[:size])
|
||||
}
|
||||
|
||||
func terminatePID(pid uint32) error {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open pid %d: %w", pid, err)
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
if err := windows.TerminateProcess(h, 1); err != nil {
|
||||
return fmt.Errorf("terminate pid %d: %w", pid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package listenutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ListenAddrs returns unique non-empty host:port listen targets.
|
||||
func ListenAddrs(addrs ...string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
for _, hp := range addrs {
|
||||
hp = strings.TrimSpace(hp)
|
||||
if hp == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[hp]; ok {
|
||||
continue
|
||||
}
|
||||
seen[hp] = struct{}{}
|
||||
out = append(out, hp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BusyError returns a user-facing error if any addr cannot be bound.
|
||||
func BusyError(addrs ...string) error {
|
||||
var busy []string
|
||||
for _, addr := range addrs {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if !canBindTCP(addr) {
|
||||
busy = append(busy, addr)
|
||||
}
|
||||
}
|
||||
if len(busy) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("порт %s занят — закройте другой Navis/ядро или смените listen в профиле", strings.Join(busy, ", "))
|
||||
}
|
||||
|
||||
// EnsureFree tries to free orphan cores once, then verifies ports are bindable.
|
||||
func EnsureFree(binDir string, addrs ...string) error {
|
||||
addrs = ListenAddrs(addrs...)
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := BusyError(addrs...); err == nil {
|
||||
return nil
|
||||
}
|
||||
_ = KillOrphanCores(binDir, addrs...)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if err := BusyError(addrs...); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClassifyStartFailure rewrites a generic "exited immediately" into a port-busy
|
||||
// message when listen addresses are occupied.
|
||||
func ClassifyStartFailure(proto string, generic error, addrs ...string) error {
|
||||
if err := BusyError(addrs...); err != nil {
|
||||
return fmt.Errorf("%s: %w", proto, err)
|
||||
}
|
||||
return generic
|
||||
}
|
||||
|
||||
func canBindTCP(addr string) bool {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = ln.Close()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package listenutil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBusyErrorFreePort(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
_ = ln.Close()
|
||||
if err := BusyError(addr); err != nil {
|
||||
t.Fatalf("expected free: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyErrorTakenPort(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
addr := ln.Addr().String()
|
||||
err = BusyError(addr)
|
||||
if err == nil || !strings.Contains(err.Error(), "занят") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyStartFailure(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
generic := errString("exited immediately")
|
||||
got := ClassifyStartFailure("hysteria2", generic, ln.Addr().String())
|
||||
if got == nil || !strings.Contains(got.Error(), "занят") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||