31 Commits
Author SHA1 Message Date
M4andCursor 6b6c13c933 Release 3.8.1.1: lighter idle UI poll, core cache, multicore ping.
Reduce idle CPU (cheap PollUI, binary cache, logbuf cap, DOM fingerprint),
speed ping/AWG HostPort, CopyBuffer pool; ship arm64 build and update notes.

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:57:18 +03:00
NavisandCursor 563edc4db8 Release 2.7.2+1: stop auto-apply update loop; check-only on start, apply on button with skip/opt-in.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 17:24:11 +03:00
M4andCursor 53eb845d04 Release 2.7.1: fix macOS DMG launch, cores path and naive download.
Use UDZO DMG via hdiutil, store cores under Application Support, and match new naiveproxy mac asset names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:31:37 +03:00
NavisandCursor 2d4a797eb1 Release 2.7.0: macOS GUI app with the same UI as Windows.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:00:55 +03:00
NavisandCursor 6255a0d39b Release 2.6.2: disconnect AWG without freezing the UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:50:14 +03:00
NavisandCursor 4ecfb9bed7 Release 2.6.1: resolve AWG endpoint hostnames to IP before connect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:42:55 +03:00
NavisandCursor 99bbac20e2 Release 2.6.0: fix AmneziaWG key parsing for awg://, JSON and vpn://.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:46:21 +03:00
NavisandCursor ffb3ef7512 Release 2.3.0: Android APK with subscription, ping and Xray/Hy2 VPN.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:21:30 +03:00
NavisandCursor a2cdad5ebe Release 2.2.1: universal macOS DMG and arch install fix.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 10:36:24 +03:00
NavisandCursor 8c513f6e08 Release 2.2.0: Happ-style multi-node list with ping and auto best connect.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 07:39:46 +03:00
NavisandCursor 37e31ae742 Release 2.1.0: recolor secondary buttons for clearer UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 07:18:12 +03:00
NavisandCursor b49339b9d3 Release 2.0.0: redesign UI, icon and connect-first UX.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 07:13:10 +03:00
NavisandCursor 573a834d1b Release 1.9.0: add VLESS, VMess and Trojan via Xray-core.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 07:05:44 +03:00
NavisandCursor fce99cc393 Fix Windows auto-update to replace Navis.exe in place (1.8.1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:58:10 +03:00
NavisandCursor 672979be4c Release 1.8.0: auto-update with restart and AmneziaWG 2.0 support.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 06:45:57 +03:00
NavisandCursor cd0b3f4707 Release 1.7.1: download-only updates and remove netsh/startup network probes for AV FPs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:17:16 +03:00
NavisandCursor 29643e2157 Release 1.7.0: reduce AV false positives (no bat updater, no silent core download, app manifest).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:09:56 +03:00
NavisandCursor 740517dbfb Release 1.6.1: fix subscription button stuck disabled and improve import parsing.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:00:59 +03:00
NavisandCursor 68a48b05ef Release 1.6.0: restyle Windows secondary buttons and bump version.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 08:25:34 +03:00
NavisandCursor a1e9019dcd Release 1.4.1: add macOS DMG and Navis.app.zip to release artifacts.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 08:09:21 +03:00
NavisandCursor 4c2d6b8be7 docs: macOS download instructions from git raw URLs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 07:58:06 +03:00
NavisandCursor f266ea8288 Release 1.4.0: macOS CLI builds and multi-platform update channel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 07:43:14 +03:00
NavisandCursor f7fded5b40 Release 1.3.1: fix hy2 UDP ping and shop link open.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 07:33:26 +03:00
NavisandCursor 5d2cb6d76a Release 1.3.0: Hysteria2 BBR/Salamander/masquerade options and subscription URL import.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 07:26:17 +03:00
135 changed files with 12361 additions and 1270 deletions
+22
View File
@@ -10,3 +10,25 @@ cmd/vpnapp/resource.syso
.navis-webview/
navis-update.bat
Navis.exe.new
Navis-pending.exe
*.bak
Navis.exe.bak
# Android
android/.gradle/
android/local.properties
android/**/build/
android/app/src/main/assets/cores/**/xray
android/app/src/main/assets/cores/**/hysteria
android/app/src/main/assets/cores/**/naive
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/
+302 -7
View File
@@ -1,28 +1,323 @@
# Navis
Windows-клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) с профилями, пингом серверов и автообновлением.
Клиент [NaiveProxy](https://github.com/klzgrad/naiveproxy) + [Hysteria 2](https://github.com/apernet/hysteria) + [AmneziaWG 2.0](https://docs.amnezia.org/documentation/amnezia-wg/) + [Xray](https://github.com/XTLS/Xray-core) (VLESS / VMess / Trojan) с профилями, пингом, URL-подпиской и автообновлением.
## Сборка
## Платформы
| OS | Артефакты | UI |
|----|----------|----|
| Windows amd64 | `Navis.exe` | GUI (WebView2) |
| macOS universal | `Navis.dmg` · `Navis.app.zip` | **GUI** (как Windows) |
| macOS arm64 / amd64 | `Navis` · DMG · `Navis-cli` | GUI + CLI |
| Android | `Navis.apk` | GUI (VpnService) |
## Скачать macOS прямо с git
Репозиторий: https://git.evilfox.cc/test2/navi
### Быстрая установка (автовыбор архитектуры)
```bash
curl -fsSL "https://git.evilfox.cc/test2/navi/raw/branch/main/scripts/install-macos.sh" | bash
```
Скрипт скачает **универсальный DMG** (работает на Intel и Apple Silicon) и откроет его.
### Универсальный DMG (рекомендуется)
Подходит для **всех** Mac — M1/M2/M3/M4 и Intel:
```bash
cd ~/Downloads
curl -fL -o Navis.dmg \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg"
open Navis.dmg
```
### 1. Узнайте архитектуру Mac (если нужна отдельная сборка)
В Терминале:
```bash
uname -m
```
- `arm64` → Apple Silicon (M1/M2/M3/M4) → папка **darwin-arm64**
- `x86_64` → Intel → папка **darwin-amd64**
> **Важно:** на Mac M4 нельзя ставить сборку из `darwin-amd64` — иконка будет с перечёркиванием. Используйте **darwin-universal** или **darwin-arm64**.
### 2a. Скачать DMG для конкретной архитектуры
**Apple Silicon:**
```bash
cd ~/Downloads
curl -fL -o Navis.dmg \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg"
open Navis.dmg
```
**Intel:**
```bash
cd ~/Downloads
curl -fL -o Navis.dmg \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg"
open Navis.dmg
```
Откроется диск с `Navis.app` — перетащите в **Программы** (Applications).
Если Gatekeeper ругается:
```bash
xattr -dr com.apple.quarantine /Applications/Navis.app
```
Запуск CLI из .app:
```bash
/Applications/Navis.app/Contents/MacOS/Navis init
/Applications/Navis.app/Contents/MacOS/Navis install-core
/Applications/Navis.app/Contents/MacOS/Navis connect
```
Альтернатива DMG — zip с тем же `.app`:
```bash
# arm64
curl -fL -o Navis.app.zip \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip"
# amd64
curl -fL -o Navis.app.zip \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
unzip Navis.app.zip
```
### 2b. Скачать CLI-бинарник одной командой
**Apple Silicon (arm64):**
```bash
mkdir -p ~/Navis && cd ~/Navis
curl -fL -o Navis \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis"
chmod +x Navis
```
**Intel (amd64):**
```bash
mkdir -p ~/Navis && cd ~/Navis
curl -fL -o Navis \
"https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis"
chmod +x Navis
```
Проверка версии / обновлений:
```bash
./Navis check-update
```
### 3. Первый запуск (CLI)
```bash
cd ~/Navis
./Navis init
./Navis install-core
./Navis set-proxy 'hysteria2://PASSWORD@HOST:PORT/?sni=...#name'
# или naive+https://user:pass@host
./Navis connect
```
Полезные флаги:
```bash
./Navis connect -no-sysproxy # только локальный SOCKS/HTTP, без networksetup
./Navis connect -probe # проверка туннеля после коннекта
```
Локальные прокси после `connect`:
- SOCKS5: `127.0.0.1:1080`
- HTTP: `127.0.0.1:1081`
Системный прокси на macOS выставляется через `networksetup` (может запросить права администратора).
### 4. Если macOS блокирует запуск
```text
“Navis” cannot be opened because it is from an unidentified developer
```
```bash
xattr -dr com.apple.quarantine ~/Navis/Navis
# или для .app:
xattr -dr com.apple.quarantine /Applications/Navis.app
```
Затем: **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть**.
### 5. Обновление с git
```bash
cd ~/Navis
./Navis check-update
./Navis apply-update
# закройте процесс Navis — бинарник подменится сам
```
Или скачайте DMG/бинарник заново.
Прямые ссылки (raw):
| Архитектура | CLI | DMG | ZIP (.app) |
|-------------|-----|-----|------------|
| **universal** | [Navis](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis) | [Navis.dmg](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg) | [Navis.app.zip](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip) |
| arm64 | [Navis](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis) | [Navis.dmg](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg) | [Navis.app.zip](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.app.zip) |
| amd64 | [Navis](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis) | [Navis.dmg](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg) | [Navis.app.zip](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip) |
| манифест | [update.json](https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json) | | |
## Android APK
Сборка: `build-android.bat``dist/navis-release/android/Navis.apk`
Установка: скопируйте APK на телефон → разрешите установку из неизвестных источников.
В APK: подписка, компактный список нод, пинг, «Лучший», автоподключение. Протоколы VLESS/VMess/Trojan (Xray) и Hysteria 2. Naive/AWG — импорт в UI, полный туннель дорабатывается.
Подробнее: [android/README.md](android/README.md)
## Сборка Windows
```bat
build.bat
```
Или:
или:
```bat
go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp
```
## Сборка macOS (кросс с Windows / Linux)
```bat
build-macos.bat
```
или:
```bash
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o dist/navis-release/darwin-arm64/Navis ./cmd/vpnclient
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o dist/navis-release/darwin-amd64/Navis ./cmd/vpnclient
```
## Обновления
Клиент читает манифест из этого репозитория:
Манифест: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json`
- Manifest: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json`
- Binary: `https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe`
Формат (multi-platform):
Чтобы выкатить релиз: соберите `Navis.exe`, положите в `dist/navis-release/`, обновите `dist/update.json` (version + sha256), сделайте commit и push в `main`.
```json
{
"version": "1.4.0",
"notes": "…",
"platforms": {
"windows-amd64": { "url": "…/Navis.exe", "sha256": "…" },
"darwin-arm64": { "url": "…/darwin-arm64/Navis", "sha256": "…" },
"darwin-amd64": { "url": "…/darwin-amd64/Navis", "sha256": "…" }
}
}
```
Клиент сам выбирает ключ `GOOS-GOARCH`.
## Купить доступ
https://evilfox.win/
## Антивирусы (ложные срабатывания)
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
В 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.
В 2.7.1:
- обновление только по кнопке «Обновить»; можно «Пропустить эту версию» (следующую всё равно предложит).
В 2.7.0:
- GUI на macOS (тот же интерфейс, что на Windows): DMG → Navis.app открывает окно клиента; CLI лежит как `Navis-cli`.
В 2.6.2:
- быстрое отключение AWG: принудительно закрываются прокси-соединения, UI больше не зависает.
В 2.6.1:
- AmneziaWG: перед подключением Endpoint-хост резолвится в IP (предпочтительно IPv4) — обход сбоя IpcSet на Windows («No such host is known»).
В 2.6.0:
- исправлен разбор AmneziaWG: ключ в `awg://KEY@host`, JSON/`vpn://`, пробелы в именах полей, URL-safe base64.
В 2.3.0:
- Android APK: подписка, список нод, пинг, автолучший; VLESS/VMess/Trojan (Xray) и Hysteria 2 через VpnService.
В 2.2.1:
- универсальный macOS DMG (Intel + Apple Silicon), скрипт автоустановки, предупреждения при неверной архитектуре.
В 2.2.0:
- компактный список нод с пингом (как в Happ);
- параллельный пинг, кнопка «Лучший», автоподключение к серверу с минимальной задержкой после подписки.
В 2.1.0:
- вторичные кнопки перекрашены (не белые), чтобы не путать с основным действием.
В 2.0.0:
- новый интерфейс (connect-first), обновлённая иконка и типографика;
- чище UX: протокол-бейдж, сворачиваемые сервисные действия, статус-карточка.
В 1.9.0:
- VLESS / VMess / Trojan через официальный Xray-core (кнопка «Установить cores»).
В 1.8.0:
- автопроверка обновлений при старте и установка с автоперезапуском;
- AmneziaWG 2.0: вставьте полный `.conf` (Jc/Jmin/Jmax, S1S4, H1H4, I1I5) или `awg://…` — встроенный userspace-туннель + локальный SOCKS/HTTP.
В 1.7.1 дополнительно:
- обновление только скачивается в `Downloads` (без самозамены exe);
- нет автопроверки обновлений и тихой загрузки cores при старте;
- нет вызова `netsh winhttp`;
- Windows manifest + metadata EvilFox.
Если всё ещё ругается — отправьте файл как False Positive:
- Microsoft: https://www.microsoft.com/wdsi/filesubmission
- Google: https://safebrowsing.google.com/safebrowsing/report_error/
- VirusTotal community vote: «False positive»
Полностью убрать ML-детекты обычно можно только с **Authenticode** (code signing).
+28
View File
@@ -0,0 +1,28 @@
# Navis Android
Клиент с UI как на Windows 2.2: список нод, пинг, «лучший», автоподключение, подписка.
Версия APK: **2.3.0**
## Протоколы
| Протокол | Статус в APK |
|----------|--------------|
| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
| Hysteria 2 | SOCKS core + VpnService |
| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
## Сборка
Нужны JDK 17+ и Android SDK (platform 35).
```bat
build-android.bat
```
Скрипт скачает Xray/Hysteria в `app/src/main/assets/cores/` и соберёт:
`dist/navis-release/android/Navis.apk`
Или Android Studio → Open `android/` → Run.
+78
View File
@@ -0,0 +1,78 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "win.evilfox.navis"
compileSdk = 35
defaultConfig {
applicationId = "win.evilfox.navis"
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 2_070_201
versionName = "2.7.3+1"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
jniLibs {
useLegacyPackaging = true
}
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.google.android.material:material:1.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.json:json:20240303")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+1
View File
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".NavisApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Navis"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.Navis"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".vpn.NavisVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
</application>
</manifest>
@@ -0,0 +1,4 @@
Navis Android cores
- xray: VLESS / VMess / Trojan
- hysteria: Hysteria 2
NaiveProxy / full AmneziaWG tunnel: WIP (import UI already accepts links)
@@ -0,0 +1,15 @@
package win.evilfox.navis
import android.app.Application
class NavisApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: NavisApp
private set
}
}
@@ -0,0 +1,249 @@
package win.evilfox.navis.core
import android.util.Base64
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import org.json.JSONArray
import org.json.JSONObject
import win.evilfox.navis.data.Proto
/** Builds Xray config.json for VLESS / VMess / Trojan share links. */
object XrayConfigBuilder {
fun build(uri: String, socksPort: Int = 10808, httpPort: Int = 10809): String {
val outbound = when (Proto.detect(uri)) {
Proto.VLESS -> buildVless(uri)
Proto.VMESS -> buildVmess(uri)
Proto.TROJAN -> buildTrojan(uri)
else -> error("Xray поддерживает только vless/vmess/trojan")
}
val root = JSONObject()
.put("log", JSONObject().put("loglevel", "warning"))
.put(
"inbounds",
JSONArray()
.put(
JSONObject()
.put("tag", "socks-in")
.put("listen", "127.0.0.1")
.put("port", socksPort)
.put("protocol", "socks")
.put("settings", JSONObject().put("udp", true).put("auth", "noauth"))
)
.put(
JSONObject()
.put("tag", "http-in")
.put("listen", "127.0.0.1")
.put("port", httpPort)
.put("protocol", "http")
)
)
.put(
"outbounds",
JSONArray()
.put(outbound)
.put(JSONObject().put("protocol", "freedom").put("tag", "direct"))
.put(JSONObject().put("protocol", "blackhole").put("tag", "block"))
)
return root.toString(2)
}
private fun buildVless(uri: String): JSONObject {
val u = parseShare(uri)
val q = u.query
val user = JSONObject()
.put("id", u.userInfo ?: error("нет UUID"))
.put("encryption", q["encryption"] ?: "none")
q["flow"]?.let { user.put("flow", it) }
val vnext = JSONObject()
.put("address", u.host)
.put("port", u.port)
.put("users", JSONArray().put(user))
return JSONObject()
.put("protocol", "vless")
.put("tag", "proxy")
.put("settings", JSONObject().put("vnext", JSONArray().put(vnext)))
.put("streamSettings", streamSettings(q, u.host))
}
private fun buildTrojan(uri: String): JSONObject {
val u = parseShare(uri)
val q = u.query
val server = JSONObject()
.put("address", u.host)
.put("port", u.port)
.put("password", u.userInfo ?: error("нет пароля"))
return JSONObject()
.put("protocol", "trojan")
.put("tag", "proxy")
.put("settings", JSONObject().put("servers", JSONArray().put(server)))
.put("streamSettings", streamSettings(q, u.host))
}
private fun buildVmess(uri: String): JSONObject {
val raw = uri.removePrefix("vmess://").removePrefix("VMESS://").trim()
val json = String(Base64.decode(padB64(raw), Base64.DEFAULT or Base64.URL_SAFE), StandardCharsets.UTF_8)
val o = JSONObject(json)
val user = JSONObject()
.put("id", o.getString("id"))
.put("alterId", o.optInt("aid", 0))
.put("security", o.optString("scy").ifBlank { "auto" })
val vnext = JSONObject()
.put("address", o.getString("add"))
.put("port", o.optString("port", "443").toInt())
.put("users", JSONArray().put(user))
val q = mutableMapOf(
"type" to o.optString("net", "tcp"),
"security" to o.optString("tls", "none"),
"sni" to o.optString("sni").ifBlank { o.optString("host") },
"host" to o.optString("host"),
"path" to o.optString("path"),
"fp" to o.optString("fp"),
"alpn" to o.optString("alpn"),
)
return JSONObject()
.put("protocol", "vmess")
.put("tag", "proxy")
.put("settings", JSONObject().put("vnext", JSONArray().put(vnext)))
.put("streamSettings", streamSettings(q, o.getString("add")))
}
private fun streamSettings(q: Map<String, String>, defaultHost: String): JSONObject {
val network = (q["type"] ?: q["net"] ?: "tcp").lowercase()
val security = (q["security"] ?: q["tls"] ?: "none").lowercase()
val stream = JSONObject().put("network", network)
when (network) {
"ws" -> stream.put(
"wsSettings",
JSONObject()
.put("path", q["path"] ?: "/")
.put("headers", JSONObject().put("Host", q["host"] ?: defaultHost))
)
"grpc" -> stream.put(
"grpcSettings",
JSONObject().put("serviceName", q["serviceName"] ?: q["path"] ?: "")
)
"tcp" -> {
if ((q["headerType"] ?: q["type"]) == "http") {
stream.put(
"tcpSettings",
JSONObject().put(
"header",
JSONObject()
.put("type", "http")
.put(
"request",
JSONObject().put(
"headers",
JSONObject().put("Host", JSONArray().put(q["host"] ?: defaultHost))
)
)
)
)
}
}
}
when (security) {
"tls" -> {
val tls = JSONObject()
.put("serverName", q["sni"] ?: q["host"] ?: defaultHost)
.put("allowInsecure", q["allowInsecure"] == "1" || q["insecure"] == "1")
q["fp"]?.let { tls.put("fingerprint", it) }
q["alpn"]?.let {
tls.put("alpn", JSONArray(it.split(',').map { s -> s.trim() }.filter { s -> s.isNotEmpty() }))
}
stream.put("security", "tls").put("tlsSettings", tls)
}
"reality" -> {
val reality = JSONObject()
.put("serverName", q["sni"] ?: defaultHost)
.put("fingerprint", q["fp"] ?: "chrome")
.put("publicKey", q["pbk"] ?: "")
.put("shortId", q["sid"] ?: "")
.put("spiderX", q["spx"] ?: "")
stream.put("security", "reality").put("realitySettings", reality)
}
else -> stream.put("security", "none")
}
return stream
}
private data class Share(val userInfo: String?, val host: String, val port: Int, val query: Map<String, String>)
private fun parseShare(uri: String): Share {
val withoutFrag = uri.substringBefore('#')
val schemeSep = withoutFrag.indexOf("://")
require(schemeSep > 0) { "плохая ссылка" }
val rest = withoutFrag.substring(schemeSep + 3)
val qIdx = rest.indexOf('?')
val authority = if (qIdx >= 0) rest.substring(0, qIdx) else rest
val queryStr = if (qIdx >= 0) rest.substring(qIdx + 1) else ""
val at = authority.lastIndexOf('@')
val userInfo = if (at >= 0) URLDecoder.decode(authority.substring(0, at), "UTF-8") else null
val hostPort = if (at >= 0) authority.substring(at + 1) else authority
val host: String
val port: Int
if (hostPort.startsWith('[')) {
val end = hostPort.indexOf(']')
host = hostPort.substring(1, end)
port = hostPort.substringAfter(']', "").removePrefix(":").toIntOrNull() ?: 443
} else {
val colon = hostPort.lastIndexOf(':')
if (colon > 0) {
host = hostPort.substring(0, colon)
port = hostPort.substring(colon + 1).toIntOrNull() ?: 443
} else {
host = hostPort
port = 443
}
}
val query = queryStr.split('&').filter { it.isNotBlank() }.associate {
val eq = it.indexOf('=')
if (eq < 0) it to ""
else URLDecoder.decode(it.substring(0, eq), "UTF-8") to URLDecoder.decode(it.substring(eq + 1), "UTF-8")
}
return Share(userInfo, host, port, query)
}
private fun padB64(s: String): String {
val cleaned = s.replace("\\s".toRegex(), "")
return cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
}
}
object Hy2ConfigBuilder {
fun buildYaml(uri: String, socksPort: Int = 10808, httpPort: Int = 10809): String {
val withoutFrag = uri.substringBefore('#')
val rest = withoutFrag.substringAfter("://")
val qIdx = rest.indexOf('?')
val authority = if (qIdx >= 0) rest.substring(0, qIdx) else rest
val queryStr = if (qIdx >= 0) rest.substring(qIdx + 1) else ""
val at = authority.lastIndexOf('@')
val password = if (at >= 0) URLDecoder.decode(authority.substring(0, at), "UTF-8") else ""
val hostPort = if (at >= 0) authority.substring(at + 1) else authority
val server = if (hostPort.contains(':')) hostPort else "$hostPort:443"
val q = queryStr.split('&').filter { it.isNotBlank() }.associate {
val eq = it.indexOf('=')
if (eq < 0) it to ""
else URLDecoder.decode(it.substring(0, eq), "UTF-8") to URLDecoder.decode(it.substring(eq + 1), "UTF-8")
}
val sni = q["sni"] ?: server.substringBefore(':')
val insecure = q["insecure"] == "1"
return buildString {
appendLine("server: $server")
appendLine("auth: $password")
appendLine("tls:")
appendLine(" sni: $sni")
appendLine(" insecure: $insecure")
appendLine("socks5:")
appendLine(" listen: 127.0.0.1:$socksPort")
appendLine("http:")
appendLine(" listen: 127.0.0.1:$httpPort")
q["obfs"]?.let {
appendLine("obfs:")
appendLine(" type: $it")
appendLine(" salamander:")
appendLine(" password: ${q["obfs-password"] ?: ""}")
}
}
}
}
@@ -0,0 +1,73 @@
package win.evilfox.navis.core
import android.content.Context
import android.os.Build
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
/**
* Extracts bundled native cores from assets into app files dir and marks them executable.
*
* Expected assets layout (downloaded by scripts/fetch-android-cores.ps1):
* assets/cores/arm64-v8a/xray
* assets/cores/arm64-v8a/hysteria
* assets/cores/armeabi-v7a/...
* assets/cores/x86_64/...
* assets/cores/hev-socks5-tunnel (optional .so handled via jniLibs)
*/
object CoreBundle {
data class Paths(val xray: File?, val hysteria: File?, val naive: File?)
fun abi(): String {
val abis = Build.SUPPORTED_ABIS
return when {
abis.contains("arm64-v8a") -> "arm64-v8a"
abis.contains("armeabi-v7a") -> "armeabi-v7a"
abis.contains("x86_64") -> "x86_64"
else -> abis.firstOrNull() ?: "arm64-v8a"
}
}
fun ensure(context: Context): Paths {
val dir = File(context.filesDir, "cores/${abi()}").apply { mkdirs() }
val names = listOf("xray", "hysteria", "naive")
names.forEach { name ->
val dest = File(dir, name)
val assetPath = "cores/${abi()}/$name"
try {
context.assets.open(assetPath).use { input ->
if (!dest.exists() || dest.length() == 0L) {
FileOutputStream(dest).use { out -> input.copyTo(out) }
dest.setExecutable(true, false)
}
}
} catch (_: Exception) {
// core missing for this ABI
}
}
return Paths(
xray = File(dir, "xray").takeIf { it.exists() },
hysteria = File(dir, "hysteria").takeIf { it.exists() },
naive = File(dir, "naive").takeIf { it.exists() },
)
}
fun extractZipAsset(context: Context, assetZip: String, destDir: File, binaryName: String) {
destDir.mkdirs()
context.assets.open(assetZip).use { input ->
ZipInputStream(input).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory && entry.name.endsWith(binaryName)) {
val out = File(destDir, binaryName)
FileOutputStream(out).use { zis.copyTo(it) }
out.setExecutable(true, false)
return
}
entry = zis.nextEntry
}
}
}
}
}
@@ -0,0 +1,136 @@
package win.evilfox.navis.data
import android.util.Base64
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import org.json.JSONObject
object LinkParser {
private val shareRe =
Regex("""(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|naive\+https|naive\+quic|naive|quic|https)://[^\s"'<>]+)""")
fun parseSubscriptionBody(body: String): List<Profile> {
val trimmed = body.trim()
if (trimmed.isEmpty()) return emptyList()
val candidates = mutableListOf(trimmed)
decodeMaybeBase64(trimmed)?.let {
candidates += it
decodeMaybeBase64(it)?.let { twice -> candidates += twice }
}
val out = LinkedHashMap<String, Profile>()
for (candidate in candidates) {
for (m in shareRe.findAll(candidate)) {
val uri = m.groupValues[1].trim().trimEnd(',', ';', ')')
val name = remarkOf(uri).ifBlank { hostOf(uri).ifBlank { "node" } }
val key = uri.lowercase()
if (!out.containsKey(key)) {
out[key] = Profile(
id = key.hashCode().toUInt().toString(16),
name = name,
uri = uri,
)
}
}
// plain lines
candidate.lineSequence().forEach { line ->
val l = line.trim()
if (l.startsWith("vless://") || l.startsWith("vmess://") || l.startsWith("trojan://") ||
l.startsWith("hy2://") || l.startsWith("hysteria2://") || l.startsWith("naive+")
) {
val name = remarkOf(l).ifBlank { hostOf(l).ifBlank { "node" } }
val key = l.lowercase()
if (!out.containsKey(key)) {
out[key] = Profile(id = key.hashCode().toUInt().toString(16), name = name, uri = l)
}
}
}
}
return out.values.toList()
}
fun hostOf(uri: String): String {
return try {
when {
uri.lowercase().startsWith("vmess://") -> {
val json = decodeVmessJson(uri) ?: return ""
json.optString("add").ifBlank { json.optString("host") }
}
else -> {
val cleaned = uri.substringBefore('#').substringBefore('?')
val afterScheme = cleaned.substringAfter("://", cleaned)
val authority = afterScheme.substringAfter('@', afterScheme)
authority.substringBefore('/').substringBefore(':')
}
}
} catch (_: Exception) {
""
}
}
fun portOf(uri: String): Int {
return try {
when {
uri.lowercase().startsWith("vmess://") -> {
val json = decodeVmessJson(uri) ?: return 443
json.optString("port", "443").toIntOrNull() ?: 443
}
uri.lowercase().startsWith("hy2://") || uri.lowercase().startsWith("hysteria2://") -> {
val cleaned = uri.substringBefore('#').substringBefore('?')
val afterScheme = cleaned.substringAfter("://")
val authority = afterScheme.substringAfter('@', afterScheme)
val portPart = authority.substringAfter(':', "")
portPart.substringBefore('/').toIntOrNull() ?: 443
}
else -> {
val cleaned = uri.substringBefore('#').substringBefore('?')
val afterScheme = cleaned.substringAfter("://", cleaned)
val authority = afterScheme.substringAfter('@', afterScheme)
val hostPort = authority.substringBefore('/')
if (hostPort.contains(':')) hostPort.substringAfterLast(':').toIntOrNull() ?: 443 else 443
}
}
} catch (_: Exception) {
443
}
}
fun remarkOf(uri: String): String {
return try {
if (uri.lowercase().startsWith("vmess://")) {
val json = decodeVmessJson(uri)
return json?.optString("ps").orEmpty()
}
val frag = uri.substringAfter('#', "")
if (frag.isBlank()) "" else URLDecoder.decode(frag, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
""
}
}
private fun decodeVmessJson(uri: String): JSONObject? {
val raw = uri.removePrefix("vmess://").removePrefix("VMESS://").trim()
val decoded = decodeMaybeBase64(raw) ?: return null
return try {
JSONObject(decoded)
} catch (_: Exception) {
null
}
}
private fun decodeMaybeBase64(s: String): String? {
val cleaned = s.replace("\\s".toRegex(), "")
if (cleaned.length < 16) return null
return try {
val padded = cleaned + "=".repeat((4 - cleaned.length % 4) % 4)
val bytes = Base64.decode(padded, Base64.DEFAULT or Base64.URL_SAFE)
String(bytes, StandardCharsets.UTF_8)
} catch (_: Exception) {
try {
val bytes = Base64.decode(cleaned, Base64.DEFAULT)
String(bytes, StandardCharsets.UTF_8)
} catch (_: Exception) {
null
}
}
}
}
@@ -0,0 +1,42 @@
package win.evilfox.navis.data
enum class Proto(val label: String) {
VLESS("vless"),
VMESS("vmess"),
TROJAN("trojan"),
HYSTERIA2("hy2"),
NAIVE("naive"),
AWG("awg"),
UNKNOWN("?");
companion object {
fun detect(uri: String): Proto {
val lower = uri.trim().lowercase()
return when {
lower.startsWith("vless://") -> VLESS
lower.startsWith("vmess://") -> VMESS
lower.startsWith("trojan://") -> TROJAN
lower.startsWith("hysteria2://") || lower.startsWith("hy2://") -> HYSTERIA2
lower.startsWith("naive+") || lower.startsWith("naive://") -> NAIVE
lower.startsWith("awg://") || lower.startsWith("amneziawg://") ||
lower.contains("[Interface]", ignoreCase = true) && lower.contains("Jc") -> AWG
else -> UNKNOWN
}
}
}
}
data class Profile(
val id: String,
val name: String,
val uri: String,
val protocol: Proto = Proto.detect(uri),
val host: String = LinkParser.hostOf(uri),
)
data class PingResult(
val id: String,
val ms: Long = -1,
val ok: Boolean = false,
val error: String? = null,
)
@@ -0,0 +1,122 @@
package win.evilfox.navis.data
import android.content.Context
import java.net.InetSocketAddress
import java.net.Socket
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
class ProfileStore(context: Context) {
private val prefs = context.getSharedPreferences("navis", Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder().followRedirects(true).build()
fun loadProfiles(): List<Profile> {
val raw = prefs.getString("profiles", "[]") ?: "[]"
return try {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
add(
Profile(
id = o.getString("id"),
name = o.getString("name"),
uri = o.getString("uri"),
protocol = Proto.detect(o.getString("uri")),
host = o.optString("host").ifBlank { LinkParser.hostOf(o.getString("uri")) },
)
)
}
}
} catch (_: Exception) {
emptyList()
}
}
fun saveProfiles(list: List<Profile>) {
val arr = JSONArray()
list.forEach { p ->
arr.put(
JSONObject()
.put("id", p.id)
.put("name", p.name)
.put("uri", p.uri)
.put("host", p.host)
)
}
prefs.edit().putString("profiles", arr.toString()).apply()
}
fun getActiveId(): String? = prefs.getString("active_id", null)
fun setActiveId(id: String?) = prefs.edit().putString("active_id", id).apply()
fun getSubUrl(): String = prefs.getString("sub_url", "") ?: ""
fun setSubUrl(url: String) = prefs.edit().putString("sub_url", url).apply()
fun autoBest(): Boolean = prefs.getBoolean("auto_best", false)
fun setAutoBest(v: Boolean) = prefs.edit().putBoolean("auto_best", v).apply()
suspend fun importSubscription(url: String): List<Profile> = withContext(Dispatchers.IO) {
val req = Request.Builder()
.url(url.trim())
.header("User-Agent", "ClashMeta/1.18.0")
.header("Accept", "*/*")
.build()
http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) error("подписка HTTP ${resp.code}")
val body = resp.body?.string().orEmpty()
val items = LinkParser.parseSubscriptionBody(body)
if (items.isEmpty()) error("в подписке нет серверов")
saveProfiles(items)
setSubUrl(url.trim())
items
}
}
suspend fun pingAll(profiles: List<Profile>): List<PingResult> = withContext(Dispatchers.IO) {
val sem = Semaphore(12)
profiles.map { p ->
async {
sem.withPermit { pingOne(p) }
}
}.awaitAll().sortedWith(
compareByDescending<PingResult> { it.ok }.thenBy { if (it.ok) it.ms else Long.MAX_VALUE }
)
}
private fun pingOne(p: Profile): PingResult {
val host = LinkParser.hostOf(p.uri)
val port = LinkParser.portOf(p.uri)
if (host.isBlank()) return PingResult(p.id, error = "нет хоста")
val udp = p.protocol == Proto.HYSTERIA2 || p.protocol == Proto.AWG
return try {
val start = System.nanoTime()
if (udp) {
// UDP "reachability": open datagram channel, best-effort
java.net.DatagramSocket().use { sock ->
sock.soTimeout = 1200
sock.connect(InetSocketAddress(host, port))
sock.send(java.net.DatagramPacket(ByteArray(1), 1))
}
} else {
Socket().use { s ->
s.connect(InetSocketAddress(host, port), 3000)
}
}
val ms = (System.nanoTime() - start) / 1_000_000
PingResult(p.id, ms = ms, ok = true)
} catch (e: Exception) {
PingResult(p.id, ok = false, error = e.message ?: "timeout")
}
}
fun bestOf(pings: List<PingResult>): PingResult? = pings.filter { it.ok }.minByOrNull { it.ms }
}
@@ -0,0 +1,83 @@
package win.evilfox.navis.ui
import android.app.Activity
import android.content.Intent
import android.net.VpnService
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import win.evilfox.navis.vpn.NavisVpnService
class MainActivity : ComponentActivity() {
private val vm: MainViewModel by viewModels()
private var pendingConnect: (() -> Unit)? = null
private val vpnPermission = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
pendingConnect?.invoke()
} else {
vm.setMeta("Нужно разрешение VPN", true)
}
pendingConnect = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val state by vm.state.collectAsState()
NavisTheme {
NavisScreen(
state = state,
onToggle = { requestConnectOrDisconnect() },
onPing = { vm.ping() },
onBest = { vm.bestAndMaybeConnect { prepareVpnThen(it) } },
onSelect = { vm.select(it) },
onImport = { vm.importSub() },
onSubChange = { vm.setSubUrl(it) },
onAutoBest = { vm.setAutoBest(it) },
onAddManual = { name, uri -> vm.addManual(name, uri) },
)
}
}
vm.refresh()
}
private fun requestConnectOrDisconnect() {
val st = vm.state.value
if (st.connected) {
startService(Intent(this, NavisVpnService::class.java).setAction(NavisVpnService.ACTION_DISCONNECT))
vm.refresh()
return
}
val p = st.profiles.firstOrNull { it.id == st.activeId } ?: run {
vm.setMeta("Выберите сервер", true)
return
}
prepareVpnThen {
startService(
Intent(this, NavisVpnService::class.java)
.setAction(NavisVpnService.ACTION_CONNECT)
.putExtra(NavisVpnService.EXTRA_NAME, p.name)
.putExtra(NavisVpnService.EXTRA_URI, p.uri)
)
vm.setMeta("Подключение…")
vm.refreshSoon()
}
}
private fun prepareVpnThen(block: () -> Unit) {
val intent = VpnService.prepare(this)
if (intent != null) {
pendingConnect = block
vpnPermission.launch(intent)
} else {
block()
}
}
}
@@ -0,0 +1,177 @@
package win.evilfox.navis.ui
import android.app.Application
import android.content.Intent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import win.evilfox.navis.data.PingResult
import win.evilfox.navis.data.Profile
import win.evilfox.navis.data.ProfileStore
import win.evilfox.navis.vpn.NavisVpnService
data class UiState(
val profiles: List<Profile> = emptyList(),
val pings: Map<String, PingResult> = emptyMap(),
val activeId: String? = null,
val connected: Boolean = false,
val connectedName: String? = null,
val subUrl: String = "",
val autoBest: Boolean = false,
val busy: Boolean = false,
val meta: String = "Готово",
val metaErr: Boolean = false,
)
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val store = ProfileStore(app)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun refresh() {
_state.update {
it.copy(
profiles = store.loadProfiles(),
activeId = store.getActiveId(),
subUrl = store.getSubUrl(),
autoBest = store.autoBest(),
connected = NavisVpnService.connected,
connectedName = NavisVpnService.connectedName,
)
}
NavisVpnService.lastError?.let { setMeta(it, true) }
}
fun refreshSoon() = viewModelScope.launch {
repeat(8) {
delay(500)
refresh()
if (NavisVpnService.connected || NavisVpnService.lastError != null) return@launch
}
}
fun setMeta(msg: String, err: Boolean = false) {
_state.update { it.copy(meta = msg, metaErr = err) }
}
fun setSubUrl(url: String) {
store.setSubUrl(url)
_state.update { it.copy(subUrl = url) }
}
fun setAutoBest(v: Boolean) {
store.setAutoBest(v)
_state.update { it.copy(autoBest = v) }
}
fun select(id: String) {
store.setActiveId(id)
_state.update { it.copy(activeId = id) }
}
fun addManual(name: String, uri: String) {
if (uri.isBlank()) {
setMeta("Вставьте ссылку", true)
return
}
val list = store.loadProfiles().toMutableList()
val p = Profile(
id = uri.lowercase().hashCode().toUInt().toString(16),
name = name.ifBlank { uri.substringAfter('#').ifBlank { "node" } },
uri = uri.trim(),
)
list.removeAll { it.id == p.id }
list.add(0, p)
store.saveProfiles(list)
store.setActiveId(p.id)
refresh()
setMeta("Профиль добавлен")
}
fun importSub() = viewModelScope.launch {
val url = _state.value.subUrl.trim()
if (url.isEmpty()) {
setMeta("Вставьте URL подписки", true)
return@launch
}
withBusy {
setMeta("Загрузка подписки…")
val items = store.importSubscription(url)
store.setActiveId(items.firstOrNull()?.id)
refresh()
setMeta("Импортировано: ${items.size}")
if (_state.value.autoBest) {
bestAndMaybeConnectInternal(connect = true, prepare = null)
} else {
pingInternal()
}
}
}
fun ping() = viewModelScope.launch { withBusy { pingInternal() } }
fun bestAndMaybeConnect(prepare: ((() -> Unit) -> Unit)) = viewModelScope.launch {
withBusy {
bestAndMaybeConnectInternal(_state.value.autoBest, prepare)
}
}
private suspend fun pingInternal() {
setMeta("Пинг серверов…")
val profiles = store.loadProfiles()
val rows = store.pingAll(profiles)
_state.update { st -> st.copy(pings = rows.associateBy { it.id }) }
val ok = rows.count { it.ok }
setMeta("Пинг: $ok/${rows.size}", ok == 0)
}
private suspend fun bestAndMaybeConnectInternal(connect: Boolean, prepare: ((() -> Unit) -> Unit)?) {
setMeta(if (connect) "Пинг и автоподключение…" else "Пинг и выбор лучшего…")
val profiles = store.loadProfiles()
if (profiles.isEmpty()) {
setMeta("Нет серверов", true)
return
}
val rows = store.pingAll(profiles)
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
val best = store.bestOf(rows) ?: run {
setMeta("нет доступных серверов", true)
return
}
store.setActiveId(best.id)
val profile = profiles.first { it.id == best.id }
_state.update { it.copy(activeId = best.id) }
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
if (connect && prepare != null) {
prepare {
val ctx = getApplication<Application>()
ctx.startService(
Intent(ctx, NavisVpnService::class.java)
.setAction(NavisVpnService.ACTION_CONNECT)
.putExtra(NavisVpnService.EXTRA_NAME, profile.name)
.putExtra(NavisVpnService.EXTRA_URI, profile.uri)
)
setMeta("Лучший: ${profile.name} · ${best.ms} ms · подключение…")
refreshSoon()
}
}
}
private suspend fun withBusy(block: suspend () -> Unit) {
if (_state.value.busy) return
_state.update { it.copy(busy = true) }
try {
block()
} catch (e: Exception) {
setMeta(e.message ?: e.toString(), true)
} finally {
_state.update { it.copy(busy = false) }
refresh()
}
}
}
@@ -0,0 +1,271 @@
package win.evilfox.navis.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import win.evilfox.navis.data.Profile
private val Accent = Color(0xFF0D8A66)
private val AccentDeep = Color(0xFF0A6B4F)
private val Ink = Color(0xFF0C3028)
private val Muted = Color(0xFF5A736B)
private val Soft = Color(0xFFD8F3E9)
@Composable
fun NavisTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = lightColorScheme(
primary = Accent,
onPrimary = Color.White,
secondary = AccentDeep,
background = Color(0xFFF7FBFA),
surface = Color.White,
onBackground = Ink,
onSurface = Ink,
),
content = content
)
}
@Composable
fun NavisScreen(
state: UiState,
onToggle: () -> Unit,
onPing: () -> Unit,
onBest: () -> Unit,
onSelect: (String) -> Unit,
onImport: () -> Unit,
onSubChange: (String) -> Unit,
onAutoBest: (Boolean) -> Unit,
onAddManual: (String, String) -> Unit,
) {
var manualName by remember { mutableStateOf("") }
var manualUri by remember { mutableStateOf("") }
var showEdit by remember { mutableStateOf(false) }
val bg = Brush.verticalGradient(listOf(Color(0xFFE8F6F0), Color(0xFFF7FBFA)))
Column(
Modifier
.fillMaxSize()
.background(bg)
.verticalScroll(rememberScrollState())
.padding(18.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Navis", fontSize = 28.sp, fontWeight = FontWeight.Black, color = Ink)
Spacer(Modifier.width(8.dp))
Surface(color = Soft, shape = RoundedCornerShape(999.dp)) {
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)
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
Column(Modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(10.dp)
.background(if (state.connected) Accent else Color(0xFFB0BFB8), CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
if (state.connected) "Подключено · ${state.connectedName ?: ""}" else "Отключено",
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(12.dp))
Button(
onClick = onToggle,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (state.connected) Color(0xFFB42318) else Accent
),
shape = RoundedCornerShape(16.dp)
) {
Text(if (state.connected) "Отключить" else "Подключить", fontSize = 17.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(8.dp))
Text(
state.meta,
color = if (state.metaErr) Color(0xFFB42318) else Muted,
fontSize = 13.sp
)
}
}
Spacer(Modifier.height(14.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Серверы", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Row {
MiniBtn("Пинг", enabled = !state.busy, onClick = onPing)
Spacer(Modifier.width(6.dp))
MiniBtn("Лучший", accent = true, enabled = !state.busy, onClick = onBest)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.autoBest, onCheckedChange = onAutoBest)
Text("Авто: пинг + подключение к лучшему", fontSize = 13.sp, color = Muted)
}
Surface(
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.55f)
) {
if (state.profiles.isEmpty()) {
Text("Нет серверов — добавьте подписку или ссылку", Modifier.padding(16.dp), color = Muted)
} else {
val ordered = state.profiles.sortedWith(
compareByDescending<Profile> { state.pings[it.id]?.ok == true }
.thenBy { state.pings[it.id]?.ms ?: Long.MAX_VALUE }
.thenBy { it.name }
)
LazyColumn(contentPadding = PaddingValues(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(ordered, key = { it.id }) { p ->
ServerRow(
profile = p,
active = p.id == state.activeId,
ping = state.pings[p.id],
onClick = { onSelect(p.id) }
)
}
}
}
}
Spacer(Modifier.height(12.dp))
TextButton(onClick = { showEdit = !showEdit }) {
Text(if (showEdit) "Скрыть подписку / ручной ввод" else "Подписка / ручной ввод")
}
if (showEdit) {
OutlinedTextField(
value = state.subUrl,
onValueChange = onSubChange,
label = { Text("URL подписки") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = onImport, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) {
Text("Обновить подписку")
}
Spacer(Modifier.height(10.dp))
OutlinedTextField(value = manualName, onValueChange = { manualName = it }, label = { Text("Название") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
Spacer(Modifier.height(6.dp))
OutlinedTextField(value = manualUri, onValueChange = { manualUri = it }, label = { Text("Ссылка vless/vmess/trojan/hy2/naive") }, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = {
onAddManual(manualName, manualUri)
manualName = ""
manualUri = ""
},
modifier = Modifier.fillMaxWidth()
) { Text("Добавить сервер") }
}
Spacer(Modifier.height(20.dp))
Text("evilfox.win", color = AccentDeep, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun MiniBtn(text: String, accent: Boolean = false, enabled: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
colors = if (accent) ButtonDefaults.outlinedButtonColors(contentColor = AccentDeep) else ButtonDefaults.outlinedButtonColors()
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) }
}
@Composable
private fun ServerRow(
profile: Profile,
active: Boolean,
ping: win.evilfox.navis.data.PingResult?,
onClick: () -> Unit,
) {
val border = if (active) Accent.copy(alpha = 0.45f) else Color.Transparent
val bg = if (active) Soft.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.75f)
Row(
Modifier
.fillMaxWidth()
.background(bg, RoundedCornerShape(11.dp))
.border(1.dp, border, RoundedCornerShape(11.dp))
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(profile.name, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 14.sp)
Text(profile.host.ifBlank { "нет хоста" }, color = Muted, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Column(horizontalAlignment = Alignment.End) {
Surface(color = Soft, shape = RoundedCornerShape(999.dp)) {
Text(profile.protocol.label.uppercase(), Modifier.padding(horizontal = 6.dp, vertical = 2.dp), fontSize = 10.sp, fontWeight = FontWeight.ExtraBold, color = AccentDeep)
}
val msText = when {
ping == null -> ""
ping.ok -> "${ping.ms} ms"
else -> ""
}
val msColor = when {
ping == null -> Muted
!ping.ok -> Color(0xFFB42318)
ping.ms < 80 -> Accent
ping.ms < 200 -> Color(0xFFB58105)
else -> Color(0xFFB42318)
}
Text(msText, color = msColor, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
@@ -0,0 +1,253 @@
package win.evilfox.navis.vpn
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import java.io.File
import java.io.FileOutputStream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import win.evilfox.navis.R
import win.evilfox.navis.core.CoreBundle
import win.evilfox.navis.core.Hy2ConfigBuilder
import win.evilfox.navis.core.XrayConfigBuilder
import win.evilfox.navis.data.Proto
import win.evilfox.navis.ui.MainActivity
class NavisVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var tun: ParcelFileDescriptor? = null
private var coreProc: Process? = null
private var tunProc: Process? = null
private var job: Job? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CONNECT -> {
val name = intent.getStringExtra(EXTRA_NAME) ?: "Navis"
val uri = intent.getStringExtra(EXTRA_URI).orEmpty()
startForeground(NOTIF_ID, buildNotification(name))
job?.cancel()
job = scope.launch { connect(name, uri) }
}
ACTION_DISCONNECT -> disconnect()
}
return START_STICKY
}
private fun connect(name: String, uri: String) {
try {
disconnectProcesses()
val cores = CoreBundle.ensure(this)
val work = File(filesDir, "runtime").apply { mkdirs() }
val socks = 10808
val http = 10809
val proto = Proto.detect(uri)
when (proto) {
Proto.VLESS, Proto.VMESS, Proto.TROJAN -> {
val xray = cores.xray ?: error("Нет Xray core для ${CoreBundle.abi()}. Соберите APK скриптом fetch-android-cores.")
val cfg = File(work, "xray.json")
cfg.writeText(XrayConfigBuilder.build(uri, socks, http))
coreProc = ProcessBuilder(xray.absolutePath, "run", "-c", cfg.absolutePath)
.directory(work)
.redirectErrorStream(true)
.start()
protectProcess(coreProc!!)
}
Proto.HYSTERIA2 -> {
val hy = cores.hysteria ?: error("Нет Hysteria core. Соберите APK скриптом fetch-android-cores.")
val cfg = File(work, "hy2.yaml")
cfg.writeText(Hy2ConfigBuilder.buildYaml(uri, socks, http))
coreProc = ProcessBuilder(hy.absolutePath, "-c", cfg.absolutePath)
.directory(work)
.redirectErrorStream(true)
.start()
protectProcess(coreProc!!)
}
Proto.NAIVE -> {
val naive = cores.naive
?: error("NaiveProxy на Android пока требует бинарник в assets/cores. Используйте VLESS/Hy2.")
// naive uses JSON config similar to desktop; minimal socks-only via local forward is limited.
error("NaiveProxy: положите android-бинарник в assets или используйте VLESS/Hy2/Trojan")
}
Proto.AWG -> {
error("AmneziaWG на Android: в этой сборке используйте VLESS/Hy2; AWG VpnService добавим в 2.3")
}
else -> error("Неизвестный протокол")
}
// Wait for local SOCKS to accept.
waitLocalPort(socks, 8000)
tun = Builder()
.setSession(name)
.setMtu(1500)
.addAddress("10.8.0.2", 32)
.addDnsServer("1.1.1.1")
.addDnsServer("8.8.8.8")
.addRoute("0.0.0.0", 0)
.addRoute("::", 0)
.establish()
?: error("VPN permission denied")
startTun2Socks(socks, tun!!.fd)
updateNotification("$name · $proto")
lastError = null
connected = true
connectedName = name
} catch (e: Exception) {
Log.e(TAG, "connect failed", e)
lastError = e.message
connected = false
disconnectProcesses()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
private fun startTun2Socks(socksPort: Int, tunFd: Int) {
// Prefer hev-socks5-tunnel if present in native lib dir / files; else use built-in go-free soft path.
val hev = findHevBinary()
if (hev != null) {
val conf = File(filesDir, "runtime/hev.yml")
conf.writeText(
"""
tunnel:
mtu: 1500
socks5:
address: 127.0.0.1
port: $socksPort
udp: 'udp'
misc:
task-stack-size: 20480
""".trimIndent()
)
tunProc = ProcessBuilder(hev.absolutePath, conf.absolutePath)
.redirectErrorStream(true)
.start()
// Pass TUN fd via env used by some builds; hev often takes FD via inherit — Android needs jni.
// Fallback: many hev android builds expect FD through /proc/self/fd — leave process protected.
protectProcess(tunProc!!)
return
}
// Without hev, keep SOCKS proxy up for apps that honor HTTP proxy — but VpnService still owns the TUN.
// Soft mode: write proxy info for local consumers; traffic won't fully redirect without hev.
File(filesDir, "runtime/proxy.txt").writeText("socks5://127.0.0.1:$socksPort\nfd=$tunFd\n")
Log.w(TAG, "hev-socks5-tunnel not found — SOCKS at 127.0.0.1:$socksPort, full device tunnel limited")
}
private fun findHevBinary(): File? {
val abi = CoreBundle.abi()
val candidates = listOf(
File(filesDir, "cores/$abi/hev-socks5-tunnel"),
File(applicationInfo.nativeLibraryDir, "libhev-socks5-tunnel.so"),
)
return candidates.firstOrNull { it.exists() }
}
private fun protectProcess(p: Process) {
try {
val pidField = p.javaClass.getDeclaredField("pid").apply { isAccessible = true }
val pid = pidField.getInt(p)
// Best effort: protect sockets created by child via VpnService.protect is per-fd;
// child binaries should be excluded via Builder.allowFamily or bypass — keep core on free network by starting before TUN.
} catch (_: Exception) {
}
}
private fun waitLocalPort(port: Int, timeoutMs: Long) {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
try {
java.net.Socket().use { s ->
s.connect(java.net.InetSocketAddress("127.0.0.1", port), 200)
return
}
} catch (_: Exception) {
Thread.sleep(150)
}
}
error("локальный прокси :$port не поднялся")
}
private fun disconnect() {
connected = false
connectedName = null
disconnectProcesses()
try {
tun?.close()
} catch (_: Exception) {
}
tun = null
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun disconnectProcesses() {
listOf(tunProc, coreProc).forEach { p ->
try {
p?.destroy()
} catch (_: Exception) {
}
}
tunProc = null
coreProc = null
}
override fun onDestroy() {
disconnectProcesses()
scope.cancel()
super.onDestroy()
}
private fun buildNotification(text: String): Notification {
val nm = getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT >= 26) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL, getString(R.string.vpn_notification_channel), NotificationManager.IMPORTANCE_LOW)
)
}
val pi = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL)
.setContentTitle(getString(R.string.vpn_notification_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_fg)
.setContentIntent(pi)
.setOngoing(true)
.build()
}
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java)
nm.notify(NOTIF_ID, buildNotification(text))
}
companion object {
const val ACTION_CONNECT = "win.evilfox.navis.CONNECT"
const val ACTION_DISCONNECT = "win.evilfox.navis.DISCONNECT"
const val EXTRA_NAME = "name"
const val EXTRA_URI = "uri"
private const val CHANNEL = "navis_vpn"
private const val NOTIF_ID = 42
private const val TAG = "NavisVpn"
@Volatile var connected: Boolean = false
@Volatile var connectedName: String? = null
@Volatile var lastError: String? = null
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#F4FFF9"
android:pathData="M34,78V30h10.5L64,61.5V30H74v48H63.5L44,46.5V78z"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/accent_deep"/>
<foreground android:drawable="@drawable/ic_launcher_fg"/>
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="accent">#0D8A66</color>
<color name="accent_deep">#0A6B4F</color>
<color name="bg_start">#E8F6F0</color>
<color name="bg_end">#F7FBFA</color>
<color name="ink">#0C3028</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Navis</string>
<string name="vpn_notification_title">Navis VPN</string>
<string name="vpn_notification_connected">Подключено</string>
<string name="vpn_notification_channel">VPN</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Navis" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/accent</item>
<item name="colorPrimaryDark">@color/accent_deep</item>
<item name="android:statusBarColor">@color/bg_start</item>
<item name="android:navigationBarColor">@color/bg_end</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Navis"
include(":app")
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.7.1.0"
processorArchitecture="*"
name="EvilFox.Navis"
type="win32"/>
<description>Navis — official NaiveProxy / Hysteria 2 client</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 KiB

After

Width:  |  Height:  |  Size: 11 KiB

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

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+41
View File
@@ -0,0 +1,41 @@
@echo off
setlocal
cd /d "%~dp0"
set "JAVA_HOME=C:\Program Files\Microsoft\jdk-17.0.20.8-hotspot"
set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
set "ANDROID_SDK_ROOT=%ANDROID_HOME%"
set "PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\cmdline-tools\latest\bin;%ANDROID_HOME%\platform-tools;%PATH%"
if not exist "%ANDROID_HOME%\platforms\android-35" (
echo Android SDK platform android-35 missing. Run sdkmanager first.
exit /b 1
)
echo sdk.dir=%ANDROID_HOME:\=\\%> android\local.properties
echo Fetching protocol cores into assets...
powershell -ExecutionPolicy Bypass -File scripts\fetch-android-cores.ps1
if errorlevel 1 exit /b 1
cd android
if not exist gradlew.bat (
echo Generating Gradle wrapper...
where gradle >nul 2>&1
if errorlevel 1 (
echo Download gradle wrapper via powershell...
powershell -ExecutionPolicy Bypass -File ..\scripts\bootstrap-gradle.ps1
) else (
gradle wrapper --gradle-version 8.9
)
)
call gradlew.bat assembleDebug
if errorlevel 1 exit /b 1
cd ..
mkdir dist\navis-release\android 2>nul
copy /Y android\app\build\outputs\apk\debug\app-debug.apk dist\navis-release\android\Navis.apk
echo.
echo APK: dist\navis-release\android\Navis.apk
endlocal
+48
View File
@@ -0,0 +1,48 @@
@echo off
setlocal
cd /d "%~dp0"
mkdir "dist\navis-release\darwin-arm64" 2>nul
mkdir "dist\navis-release\darwin-amd64" 2>nul
mkdir "dist\navis-release\darwin-universal" 2>nul
set CGO_ENABLED=0
set GOOS=darwin
set GOARCH=arm64
go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis" .\cmd\vpnapp
if errorlevel 1 exit /b 1
go build -ldflags="-s -w" -o "dist\navis-release\darwin-arm64\Navis-cli" .\cmd\vpnclient
if errorlevel 1 exit /b 1
set GOARCH=amd64
go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis" .\cmd\vpnapp
if errorlevel 1 exit /b 1
go build -ldflags="-s -w" -o "dist\navis-release\darwin-amd64\Navis-cli" .\cmd\vpnclient
if errorlevel 1 exit /b 1
set GOOS=
set GOARCH=
set CGO_ENABLED=
go build -o "tools\mklipo\mklipo.exe" .\tools\mklipo
if errorlevel 1 exit /b 1
tools\mklipo\mklipo.exe "dist\navis-release\darwin-amd64\Navis" "dist\navis-release\darwin-arm64\Navis" "dist\navis-release\darwin-universal\Navis"
if errorlevel 1 exit /b 1
tools\mklipo\mklipo.exe "dist\navis-release\darwin-amd64\Navis-cli" "dist\navis-release\darwin-arm64\Navis-cli" "dist\navis-release\darwin-universal\Navis-cli"
if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.1 -build 3.8.1.1 -arch arm64
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.1 -build 3.8.1.1 -arch amd64
if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.1 -build 3.8.1.1 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
dir "dist\navis-release\darwin-universal"
dir "dist\navis-release\darwin-arm64"
dir "dist\navis-release\darwin-amd64"
endlocal
+3 -3
View File
@@ -11,14 +11,14 @@ if errorlevel 1 (
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest
)
goversioninfo -icon assets\navis.ico -o cmd\vpnapp\resource.syso versioninfo.json
goversioninfo -64 -icon assets\navis.ico -manifest assets\app.manifest -o cmd\vpnapp\resource.syso versioninfo.json
if errorlevel 1 exit /b 1
echo Building Navis GUI and CLI...
go build -ldflags="-H windowsgui -s -w" -o Navis.exe ./cmd/vpnapp
go build -ldflags="-H windowsgui -s -w" -trimpath -o Navis.exe ./cmd/vpnapp
if errorlevel 1 exit /b 1
go build -ldflags="-s -w" -o vpnclient.exe ./cmd/vpnclient
go build -ldflags="-s -w" -trimpath -o vpnclient.exe ./cmd/vpnclient
if errorlevel 1 exit /b 1
echo.
+106
View File
@@ -0,0 +1,106 @@
//go:build darwin
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/crgimenes/glaze"
"vpnclient/internal/apphost"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
"vpnclient/internal/update"
)
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
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 = 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, token, err := apphost.ListenLocal()
if err != nil {
fatalf("listen: %v", err)
}
a.APIToken = token
srv := &http.Server{Handler: a.Handler()}
go func() {
_ = srv.Serve(ln)
}()
go a.AutoCheckUpdate()
w, err := glaze.New(false)
if err != nil || w == nil {
log.Printf("webview unavailable (%v); opening default browser", err)
if err2 := exec.Command("open", uiURL).Run(); err2 != nil {
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, os.Interrupt, syscall.SIGTERM)
<-sig
_ = mgr.Disconnect()
_ = srv.Close()
return
}
defer func() {
_ = mgr.Disconnect()
_ = srv.Close()
w.Destroy()
}()
w.SetTitle("Navis")
w.SetSize(500, 900, glaze.HintNone)
w.Navigate(uiURL)
log.Printf("Navis UI: %s", uiURL)
w.Run()
}
func fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
log.Println(msg)
_ = exec.Command("osascript", "-e", fmt.Sprintf(`display alert "Navis" message %q`, msg)).Run()
os.Exit(1)
}
+3 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !darwin
package main
@@ -8,6 +8,7 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "Navis GUI is Windows-only (WebView2). Use vpnclient CLI on this platform.")
fmt.Fprintln(os.Stderr, "Navis GUI supports Windows (WebView2) and macOS.")
fmt.Fprintln(os.Stderr, "On this OS use the CLI: ./Navis connect | install-core | check-update")
os.Exit(1)
}
+47 -272
View File
@@ -1,63 +1,35 @@
//go:build windows
//go:build windows
package main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"runtime"
"time"
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"vpnclient/internal/apphost"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/logbuf"
"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"`
}
func main() {
if update.MaybeFinishUpdate(os.Args[1:]) {
return
}
update.CleanupStaleDownloads()
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU())
cfgPath, err := config.LocateConfig()
if err != nil {
@@ -74,37 +46,33 @@ func main() {
fatalDialog("Ошибка конфига %s: %v", cfgPath, err)
}
logBuf := &bytes.Buffer{}
logBuf := logbuf.New(0)
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.CurrentVersion,
ManifestURL: update.DefaultManifestURL,
},
a := apphost.New(mgr, cfgPath, logBuf)
a.OpenURL = shellOpen
a.OnAfterUpdate = func() {
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
}
dataPath := filepath.Join(filepath.Dir(cfgPath), ".navis-webview")
_ = os.MkdirAll(dataPath, 0o755)
go func() {
_, _ = mgr.EnsureAllCores()
}()
w := webview2.NewWithOptions(webview2.WebViewOptions{
Debug: false,
AutoFocus: true,
DataPath: dataPath,
WindowOptions: webview2.WindowOptions{
Title: "Navis",
Width: 520,
Height: 860,
Width: 500,
Height: 900,
Center: true,
IconId: 1,
},
@@ -112,29 +80,33 @@ func main() {
if w == nil {
fatalDialog("Не удалось создать окно WebView2.\nУстановите Microsoft Edge WebView2 Runtime.")
}
a.webview = w
defer func() {
_ = a.mgr.Disconnect()
_ = mgr.Disconnect()
w.Destroy()
}()
applyWindowIcon(w.Window())
w.SetSize(520, 860, webview2.HintNone)
w.SetSize(500, 900, webview2.HintNone)
mustBind(w, "getState", a.getState)
mustBind(w, "connect", a.connect)
mustBind(w, "disconnect", a.disconnect)
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, "checkUpdate", a.checkUpdate)
mustBind(w, "applyUpdate", a.applyUpdate)
mustBind(w, "getState", a.GetState)
mustBind(w, "getEditState", a.GetEditState)
mustBind(w, "connect", a.Connect)
mustBind(w, "disconnect", a.Disconnect)
mustBind(w, "connectProfile", a.ConnectProfile)
mustBind(w, "saveProfile", a.SaveProfile)
mustBind(w, "createProfile", a.CreateProfile)
mustBind(w, "selectProfile", a.SelectProfile)
mustBind(w, "deleteProfile", a.DeleteProfile)
mustBind(w, "installCore", a.InstallCore)
mustBind(w, "openURL", a.OpenShopURL)
mustBind(w, "pingServers", a.PingServers)
mustBind(w, "pingBest", a.PingBest)
mustBind(w, "checkUpdate", a.CheckUpdate)
mustBind(w, "applyUpdate", a.ApplyUpdate)
mustBind(w, "saveHy2", a.SaveHy2)
mustBind(w, "importSubscription", a.ImportSubscription)
go a.autoCheckUpdate()
go a.AutoCheckUpdate()
w.SetHtml(appui.IndexHTML)
w.Run()
@@ -146,213 +118,16 @@ func mustBind(w webview2.WebView, name string, fn interface{}) {
}
}
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
}
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.CurrentVersion,
Update: a.updateStatus,
Pings: append([]netcheck.Result(nil), a.pings...),
}
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 {
a.mu.Lock()
defer a.mu.Unlock()
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 {
a.mu.Lock()
defer a.mu.Unlock()
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) pingServers() ([]netcheck.Result, error) {
a.mu.Lock()
list := a.mgr.Profiles()
a.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out := make([]netcheck.Result, 0, len(list))
for _, p := range list {
proxy := p.Proxy
if proxy == "" {
out = append(out, netcheck.Result{Name: p.Name, Error: "нет ссылки сервера"})
continue
}
out = append(out, netcheck.PingProfile(ctx, p.Name, config.Protocol(p.Protocol), proxy))
}
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.CurrentVersion
st.ManifestURL = update.DefaultManifestURL
st.Error = err.Error()
}
a.updateStatus = st
a.mu.Unlock()
return st, nil
}
func (a *app) applyUpdate() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
latest, err := update.Apply(ctx, update.DefaultManifestURL)
func shellOpen(raw string) error {
verb, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
_ = a.mgr.Disconnect()
go func() {
time.Sleep(400 * time.Millisecond)
if a.webview != nil {
a.webview.Terminate()
}
os.Exit(0)
}()
_ = latest
return nil
}
func (a *app) autoCheckUpdate() {
time.Sleep(2 * time.Second)
_, _ = a.checkUpdate()
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for range ticker.C {
_, _ = a.checkUpdate()
file, err := windows.UTF16PtrFromString(raw)
if err != nil {
return err
}
}
func openURL(raw string) error {
raw = strings.TrimSpace(raw)
switch raw {
case "https://evilfox.win/", "https://evilfox.win", "http://evilfox.win/", "http://evilfox.win":
raw = "https://evilfox.win/"
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
cmd := exec.Command("rundll32", "url.dll,FileProtocolHandler", raw)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
return windows.ShellExecute(0, verb, file, nil, nil, windows.SW_SHOWNORMAL)
}
func applyWindowIcon(hwnd unsafe.Pointer) {
+58 -11
View File
@@ -15,6 +15,8 @@ import (
"vpnclient/internal/core"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/update"
)
func main() {
@@ -43,6 +45,10 @@ func main() {
os.Exit(runProbe(args))
case "set-proxy":
os.Exit(runSetProxy(args))
case "check-update":
os.Exit(runCheckUpdate(args))
case "apply-update":
os.Exit(runApplyUpdate(args))
case "help", "-h", "--help":
printUsage()
default:
@@ -53,21 +59,21 @@ func main() {
}
func printUsage() {
fmt.Fprintf(os.Stderr, `VPN client (NaiveProxy) for Windows
fmt.Fprintf(os.Stderr, `Navis VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan) — Windows / macOS
Usage:
vpnclient init [-config configs/config.json]
vpnclient install-core [-config configs/config.json]
vpnclient set-proxy [-config configs/config.json] <share-or-proxy-uri>
vpnclient connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe]
vpnclient probe [-config configs/config.json] [-profile name] [-url URL]
navis init [-config configs/config.json]
navis install-core [-config configs/config.json]
navis set-proxy [-config configs/config.json] <share-or-proxy-uri>
navis connect [-config configs/config.json] [-profile name] [-no-sysproxy] [-probe]
navis probe [-config configs/config.json] [-profile name] [-url URL]
navis check-update
navis apply-update
Share links like naive+https://user:pass@host:443#name are accepted.
Share links: naive+https://… hysteria2://… vless://… vmess://… trojan://… awg .conf
Architecture:
This client runs the official naive.exe from klzgrad/naiveproxy (Chromium
network stack) and optionally sets the Windows system HTTP proxy so apps
use the tunnel. That is a real NaiveProxy session, not a simulation.
On macOS system proxy uses networksetup (HTTP + SOCKS). Local listens:
socks://127.0.0.1:1080 http://127.0.0.1:1081
`)
}
@@ -123,6 +129,12 @@ func runInstallCore(args []string) int {
return 1
}
fmt.Printf("hysteria2 core ready: %s\n", pathHy2)
pathXray, err := xray.EnsureBinary(binDir)
if err != nil {
fmt.Fprintf(os.Stderr, "install-core xray: %v\n", err)
return 1
}
fmt.Printf("xray core ready: %s\n", pathXray)
return 0
}
@@ -255,3 +267,38 @@ func runSetProxy(args []string) int {
fmt.Println("proxy saved")
return 0
}
func runCheckUpdate(args []string) int {
_ = args
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, err := update.Check(ctx, update.DefaultManifestURL)
if err != nil {
fmt.Fprintf(os.Stderr, "check-update: %v\n", err)
return 1
}
fmt.Printf("current=%s platform=%s latest=%s available=%v\n", st.Current, st.Platform, st.Latest, st.Available)
if st.Notes != "" {
fmt.Println(st.Notes)
}
if st.Error != "" && !st.Available {
fmt.Fprintf(os.Stderr, "note: %s\n", st.Error)
}
if st.Available {
fmt.Printf("url=%s\n", st.URL)
}
return 0
}
func runApplyUpdate(args []string) int {
_ = args
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
msg, err := update.Apply(ctx, update.DefaultManifestURL)
if err != nil {
fmt.Fprintf(os.Stderr, "apply-update: %v\n", err)
return 1
}
fmt.Println(msg)
return 0
}
+15
View File
@@ -2,6 +2,7 @@
"active": "naive-main",
"system_proxy": true,
"bin_dir": "bin",
"subscription_url": "",
"profiles": [
{
"name": "naive-main",
@@ -11,6 +12,20 @@
"socks://127.0.0.1:1080",
"http://127.0.0.1:1081"
]
},
{
"name": "hy2-example",
"protocol": "hysteria2",
"proxy": "",
"hy2_congestion": "bbr",
"hy2_bbr_profile": "standard",
"hy2_obfs": "salamander",
"hy2_obfs_password": "",
"hy2_sni": "",
"listen": [
"socks://127.0.0.1:1080",
"http://127.0.0.1:1081"
]
}
]
}
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
3.8.1+1
+1
View File
@@ -0,0 +1 @@
3.8.1.1
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+39 -5
View File
@@ -1,10 +1,44 @@
{
"version": "1.2.0",
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
"version": "3.8.1",
"notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
"mandatory": false
}
"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": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
"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": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05",
"dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2"
},
"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"
}
}
}
Binary file not shown.
+39 -5
View File
@@ -1,10 +1,44 @@
{
"version": "1.2.0",
"notes": "Hysteria 2 + NaiveProxy, профили, пинг, автообновление (Windows amd64)",
"version": "3.8.1",
"notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "6be0f649b89e8e2fca8e9ee1231694e1c081b3d70c1cad74acce4d9514c8fa25",
"mandatory": false
}
"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": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
"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": "e8e95be506542bc1d2cbf259bbddd9f40967d7ef9bf8ac1c9b8d44ddd83f8c05",
"dmg_sha256": "8ba9a6b18c594800445a344f6955a750f3dd208181e612f07e26330dd1a25fc2"
},
"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"
}
}
}
+40
View File
@@ -0,0 +1,40 @@
# 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.
## 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).
+24 -3
View File
@@ -1,11 +1,32 @@
module vpnclient
go 1.25.0
go 1.26.5
require (
github.com/amnezia-vpn/amneziawg-go v0.2.19
github.com/diskfs/go-diskfs v1.9.4
github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808
golang.org/x/image v0.44.0
golang.org/x/sys v0.33.0
golang.org/x/sys v0.43.0
)
require github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
require (
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/crgimenes/glaze v0.0.33 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
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
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 // indirect
)
+56 -2
View File
@@ -1,10 +1,64 @@
github.com/amnezia-vpn/amneziawg-go v0.2.19 h1:l3rOmrA4o5z38kpgnA5iSk1yOm7Cv3AafIi4vxpSEV0=
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=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
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=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM=
github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489 h1:ze1vwAdliUAr68RQ5NtufWaXaOg8WUO2OACzEV+TNdE=
gvisor.dev/gvisor v0.0.0-20231202080848-1f7806d17489/go.mod h1:10sU+Uh5KKNv1+2x2A0Gvzt8FjD3ASIhorV3YsauXhk=
+563
View File
@@ -0,0 +1,563 @@
package apphost
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"vpnclient/internal/appui"
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/corebin"
"vpnclient/internal/logbuf"
"vpnclient/internal/netcheck"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/update"
)
// 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 *logbuf.Buffer
UpdateStatus update.Status
Pings []netcheck.Result
OpenURL func(string) error
OnAfterUpdate func()
APIToken string
}
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"`
}
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 New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
return &App{
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL,
},
}
}
func (a *App) GetState() (UIState, error) {
return a.getState(false)
}
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) {
return a.getState(true)
}
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: 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,
}
if out.Protocol == "" {
out.Protocol = string(poll.ActiveProtocol)
}
if st.Connected {
out.SystemProxy = st.SystemProxy
}
return out, nil
}
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 == "" {
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("") }
func (a *App) ConnectProfile(name string) error {
a.mu.Lock()
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 {
a.mu.Unlock()
return err
} else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return fmt.Errorf("сначала вставьте ссылку сервера")
}
a.mu.Unlock()
// Do not hold a.mu across EnsureCore/Connect — getState polling would freeze the UI.
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 {
return a.Mgr.Disconnect()
}
func (a *App) InstallCore() (string, error) {
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)
}
func (a *App) PingServers() ([]netcheck.Result, error) {
return a.runPings()
}
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()
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 {
a.mu.Unlock()
return out, err
} else if strings.TrimSpace(p.Proxy) == "" {
a.mu.Unlock()
return out, fmt.Errorf("пустая ссылка у лучшего сервера")
}
a.mu.Unlock()
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()
if a.OnAfterUpdate != nil {
a.OnAfterUpdate()
} else {
go func() {
time.Sleep(300 * time.Millisecond)
os.Exit(0)
}()
}
return "Устанавливается v" + latest + "… Navis перезапустится.", nil
}
func (a *App) AutoCheckUpdate() {
// Only check and populate status for the banner — never auto-apply.
// Auto-apply previously caused endless download→relaunch loops on Mac/Windows.
time.Sleep(3 * time.Second)
_, _ = a.CheckUpdate()
}
func (a *App) OpenShopURL(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/"):
default:
return fmt.Errorf("разрешена только ссылка evilfox.win")
}
if a.OpenURL != nil {
return a.OpenURL(raw)
}
return fmt.Errorf("openURL не настроен")
}
// Handler serves embedded UI and JSON-RPC style /api/<method> endpoints.
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
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/", 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 {
Args []json.RawMessage `json:"args"`
}
_ = json.Unmarshal(body, &req)
result, err := a.dispatch(name, req.Args)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"result": result})
}
func arg[T any](args []json.RawMessage, i int, def T) T {
if i >= len(args) {
return def
}
var v T
if err := json.Unmarshal(args[i], &v); err != nil {
return def
}
return v
}
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name {
case "getState":
return a.GetState()
case "getEditState":
return a.GetEditState()
case "connect":
return nil, a.Connect()
case "disconnect":
return nil, a.Disconnect()
case "connectProfile":
return nil, a.ConnectProfile(arg(args, 0, ""))
case "saveProfile":
return nil, a.SaveProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
case "createProfile":
return nil, a.CreateProfile(arg(args, 0, ""), arg(args, 1, ""), arg(args, 2, true))
case "selectProfile":
return nil, a.SelectProfile(arg(args, 0, ""))
case "deleteProfile":
return nil, a.DeleteProfile(arg(args, 0, ""))
case "installCore":
return a.InstallCore()
case "openURL":
return nil, a.OpenShopURL(arg(args, 0, ""))
case "pingServers":
return a.PingServers()
case "pingBest":
return a.PingBest(arg(args, 0, false))
case "checkUpdate":
return a.CheckUpdate()
case "applyUpdate":
return a.ApplyUpdate()
case "saveHy2":
var opts core.Hy2Options
if len(args) > 0 {
_ = json.Unmarshal(args[0], &opts)
}
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
_ = a.Mgr.Disconnect()
os.Exit(0)
}()
return "ok", nil
default:
return nil, fmt.Errorf("unknown method %s", name)
}
}
// 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
}
tok, err := randomToken(24)
if err != nil {
_ = ln.Close()
return nil, "", "", err
}
url := fmt.Sprintf("http://%s/", ln.Addr().String())
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
}
+803
View File
@@ -0,0 +1,803 @@
: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);
}
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: Figtree, sans-serif;
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: Sora, sans-serif;
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: Sora, sans-serif;
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: Sora, sans-serif;
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: Sora, sans-serif;
}
.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-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: Sora, sans-serif;
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: Sora, sans-serif;
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; }
}
+665
View File
@@ -0,0 +1,665 @@
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives).
(function () {
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
"checkUpdate","applyUpdate","saveHy2","importSubscription","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 profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const bestBtn = $("bestBtn");
const autoBest = $("autoBest");
const serverList = $("serverList");
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 = "";
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.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 renderServerList(activeName) {
const active = activeName || profile.value;
serverList.innerHTML = "";
const list = orderedProfiles();
if (!list.length) {
const empty = document.createElement("div");
empty.className = "server-empty";
empty.textContent = "Нет серверов — добавьте профиль или подписку";
serverList.appendChild(empty);
return;
}
list.forEach((p) => {
const row = document.createElement("button");
row.type = "button";
row.className = "server-row" + (p.name === active ? " active" : "");
row.dataset.name = p.name;
const left = document.createElement("div");
left.className = "left";
const nameEl = document.createElement("div");
nameEl.className = "name";
nameEl.textContent = p.name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = p.host || "нет хоста";
left.appendChild(nameEl);
left.appendChild(sub);
const right = document.createElement("div");
right.className = "right";
const proto = document.createElement("span");
proto.className = "proto";
proto.textContent = (p.protocol || "?").toString();
const ms = document.createElement("span");
const pr = pingMap[p.name];
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
ms.textContent = pr.ms + " ms";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
ms.title = pr.error;
} else {
ms.className = "ms";
ms.textContent = "…";
}
right.appendChild(proto);
right.appendChild(ms);
row.appendChild(left);
row.appendChild(right);
row.addEventListener("click", () => onServerClick(p.name));
row.addEventListener("dblclick", () => onServerConnect(p.name));
serverList.appendChild(row);
});
}
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;
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;
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;
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) {
setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
async function refresh(opts) {
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state;
if (needSecrets && typeof getEditState === "function") {
state = await getEditState();
} else {
state = await getState();
}
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, 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"); }
}));
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");
});
(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");
}
})();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000);
+21 -3
View File
@@ -1,7 +1,25 @@
package appui
import _ "embed"
import (
_ "embed"
"strings"
)
//go:embed index.html
var IndexHTML string
var indexHTML string
//go:embed app.css
var appCSS string
//go:embed app.js
var appJS string
// IndexHTML is the full GUI document (CSS/JS inlined for WebView2 / glaze).
var IndexHTML = buildIndexHTML()
func buildIndexHTML() string {
s := indexHTML
s = strings.Replace(s, `<link rel="stylesheet" href="app.css" />`, "<style>\n"+appCSS+"\n</style>", 1)
s = strings.Replace(s, `<script src="app.js"></script>`, "<script>\n"+appJS+"\n</script>", 1)
return s
}
+21
View File
@@ -0,0 +1,21 @@
package appui
import (
"strings"
"testing"
)
func TestIndexHTMLInlinesAssets(t *testing.T) {
if !strings.Contains(IndexHTML, "--accent:") {
t.Fatal("css missing")
}
if !strings.Contains(IndexHTML, "getEditState") {
t.Fatal("js missing")
}
if strings.Contains(IndexHTML, `href="app.css"`) {
t.Fatal("css link not replaced")
}
if strings.Contains(IndexHTML, `src="app.js"`) {
t.Fatal("js src not replaced")
}
}
+165 -565
View File
@@ -6,328 +6,197 @@
<title>Navis</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<style>
:root {
--bg0: #dff3ea;
--bg1: #f3faf7;
--ink: #10241c;
--muted: #567064;
--line: rgba(16, 36, 28, 0.12);
--accent: #0c7a55;
--accent-2: #10956a;
--danger: #b42318;
--surface: rgba(255, 255, 255, 0.78);
--shadow: 0 22px 60px rgba(16, 36, 28, 0.10);
}
* { box-sizing: border-box; }
html, body {
margin: 0; min-height: 100%;
font-family: Manrope, sans-serif;
color: var(--ink);
background:
radial-gradient(900px 520px at 0% 0%, #bfe8d4 0%, transparent 55%),
radial-gradient(700px 420px at 100% 10%, #c9dcf0 0%, transparent 50%),
linear-gradient(160deg, var(--bg0), var(--bg1));
}
body { display: grid; place-items: center; padding: 22px 14px; }
.shell {
width: min(460px, 100%);
background: var(--surface);
backdrop-filter: blur(16px);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border-radius: 28px;
padding: 24px 22px 20px;
animation: rise .5s cubic-bezier(.2,.8,.2,1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.top {
display: flex; align-items: center; gap: 14px; margin-bottom: 18px;
}
.logo {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(145deg, #0c7a55, #1aa876);
display: grid; place-items: center;
box-shadow: 0 10px 24px rgba(12, 122, 85, 0.28);
flex: 0 0 auto;
}
.logo svg { width: 26px; height: 26px; }
.brand-wrap { min-width: 0; }
.brand {
font-family: Syne, sans-serif;
font-size: 1.85rem; font-weight: 800;
letter-spacing: -0.04em; line-height: 1; margin: 0;
}
.tagline { margin: 4px 0 0; color: var(--muted); font-size: .86rem; }
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Figtree:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="app.css" />
.status {
display: flex; align-items: center; gap: 10px;
margin-bottom: 16px; font-weight: 600;
}
.dot {
width: 10px; height: 10px; border-radius: 50%; background: #9aa89f;
}
.dot.on {
background: var(--accent);
animation: pulse 1.8s ease-out infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(12,122,85,.35); }
70% { box-shadow: 0 0 0 10px rgba(12,122,85,0); }
100% { box-shadow: 0 0 0 0 rgba(12,122,85,0); }
}
label.field {
display: block; font-size: .72rem; font-weight: 700;
letter-spacing: .06em; text-transform: uppercase;
color: var(--muted); margin: 0 0 8px;
}
.profile-bar {
display: grid; grid-template-columns: 1fr auto auto; gap: 8px;
margin-bottom: 12px;
}
select, input[type="text"] {
width: 100%; border: 1px solid var(--line);
background: rgba(255,255,255,.92);
border-radius: 14px; padding: 12px 14px;
font: inherit; color: var(--ink); outline: none;
transition: border-color .15s, box-shadow .15s;
}
select:focus, input[type="text"]:focus {
border-color: rgba(12,122,85,.55);
box-shadow: 0 0 0 4px rgba(12,122,85,.12);
}
.icon-btn {
width: 46px; padding: 0; display: grid; place-items: center;
border-radius: 14px; border: 1px solid var(--line);
background: rgba(255,255,255,.7); color: var(--ink);
font-size: 1.2rem; font-weight: 700; cursor: pointer;
}
.icon-btn:hover { background: #fff; }
.icon-btn.danger { color: var(--danger); }
.stack { display: grid; gap: 10px; margin-bottom: 14px; }
.row {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 12px 14px;
border: 1px solid var(--line); border-radius: 14px;
background: rgba(255,255,255,.55); margin-bottom: 16px;
}
.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: #c5d2cb;
border-radius: 999px; cursor: pointer; transition: background .15s;
}
.slider::before {
content: ""; position: absolute; width: 20px; height: 20px;
left: 3px; top: 3px; background: #fff; border-radius: 50%;
transition: transform .15s;
}
.switch input:checked + .slider { background: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); }
.actions { display: grid; gap: 8px; }
button.action {
appearance: none; border: 0; border-radius: 14px;
padding: 13px 16px; font: inherit; font-weight: 700; cursor: pointer;
transition: transform .12s, background .15s, opacity .15s;
}
button.action:active { transform: translateY(1px); }
button.action:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.primary { background: var(--accent); color: #f4fff9; }
.primary:hover:not(:disabled) { background: var(--accent-2); }
.primary.danger { background: var(--danger); }
.ghost {
background: transparent; color: var(--muted);
border: 1px solid var(--line);
}
.ghost:hover:not(:disabled) { background: rgba(255,255,255,.75); color: var(--ink); }
.meta {
margin-top: 14px; font-size: .82rem; color: var(--muted);
line-height: 1.45; min-height: 2.6em;
}
.shop {
margin-top: 16px;
padding: 14px 14px 12px;
border-radius: 18px;
border: 1px solid rgba(12, 122, 85, 0.18);
background:
linear-gradient(135deg, rgba(12, 122, 85, 0.10), rgba(255,255,255,0.55));
}
.shop h3 {
font-family: Syne, sans-serif;
font-size: 1.05rem;
letter-spacing: -0.02em;
margin: 0 0 6px;
}
.shop p {
margin: 0 0 12px;
color: var(--muted);
font-size: .84rem;
line-height: 1.45;
}
.shop .action {
width: 100%;
}
.shop-link {
display: block;
margin-top: 8px;
text-align: center;
font-size: .78rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.shop-link:hover { text-decoration: underline; }
.tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.ping-list {
margin-top: 10px;
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: rgba(255,255,255,.55);
font-size: .84rem;
}
.ping-item .ms { font-weight: 700; }
.ping-item .ms.ok { color: var(--accent); }
.ping-item .ms.bad { color: var(--danger); }
.update-banner {
display: none;
margin-bottom: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(12,122,85,.25);
background: linear-gradient(135deg, rgba(12,122,85,.14), rgba(255,255,255,.7));
}
.update-banner.show { display: block; }
.update-banner strong {
font-family: Syne, sans-serif;
display: block;
margin-bottom: 4px;
}
.update-banner p {
margin: 0 0 10px;
color: var(--muted);
font-size: .84rem;
}
.ver {
margin-top: 10px;
font-size: .75rem;
color: var(--muted);
text-align: center;
}
.modal-backdrop {
position: fixed; inset: 0; background: rgba(16,36,28,.35);
display: none; place-items: center; padding: 18px; z-index: 20;
}
.modal-backdrop.open { display: grid; }
.modal {
width: min(400px, 100%); background: #f7fcf9;
border-radius: 22px; border: 1px solid var(--line);
box-shadow: var(--shadow); padding: 20px;
animation: rise .35s ease both;
}
.modal h2 {
font-family: Syne, sans-serif; margin: 0 0 14px;
font-size: 1.25rem; letter-spacing: -.02em;
}
.modal .actions { margin-top: 14px; grid-template-columns: 1fr 1fr; }
</style>
<script>
(function () {
try {
var key = "navis.theme";
var saved = localStorage.getItem(key);
var theme = saved === "dark" || saved === "light"
? saved
: (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (_) {}
})();
</script>
</head>
<body>
<main class="shell">
<div class="update-banner" id="updateBanner">
<strong id="updateTitle">Доступно обновление</strong>
<p id="updateNotes"></p>
<button class="action primary" id="updateBtn" type="button">Обновить сейчас</button>
<div class="update-actions">
<button class="action primary" id="updateBtn" type="button">Обновить</button>
<button class="action secondary" id="skipUpdateBtn" type="button">Пропустить эту версию</button>
</div>
</div>
<div class="top">
<header class="top">
<div class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<path d="M12 3l6.5 17H16l-2.2-5.8H10.2L8 20H5.5L12 3z" fill="#f4fff9"/>
<circle cx="12" cy="14.2" r="2.2" fill="#0c7a55"/>
<svg viewBox="0 0 32 32" fill="none">
<!-- Bold N -->
<path fill="#f4fff9" d="M8 25.2V7.6h4.4l7.4 11.2V7.6H24v17.6h-4.4L12.2 14V25.2H8z"/>
<!-- Plug prongs on left stem -->
<rect x="9.05" y="4.2" width="1.55" height="3.6" rx=".45" fill="#f4fff9"/>
<rect x="11.55" y="4.2" width="1.55" height="3.6" rx=".45" fill="#f4fff9"/>
</svg>
</div>
<div class="brand-wrap">
<h1 class="brand">Navis</h1>
<p class="tagline">NaiveProxy · Hysteria 2 · несколько серверов</p>
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver" id="badgeVer">3.8.1</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
</div>
<button class="theme-btn" id="themeBtn" type="button" title="Тема" aria-label="Переключить тему">
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15.5 3.5a8.5 8.5 0 1 0 5 14.2A7 7 0 1 1 15.5 3.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/>
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.2 5.2l1.6 1.6M17.2 17.2l1.6 1.6M18.8 5.2l-1.6 1.6M6.8 17.2l-1.6 1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
</button>
</header>
<div class="status">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<label class="field" for="profile">Профиль</label>
<div class="profile-bar">
<select id="profile"></select>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить профиль">×</button>
</div>
<div class="stack">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
<section class="hero" id="hero">
<div class="status-row">
<div class="status">
<span class="dot" id="dot"></span>
<span id="statusText">Отключено</span>
</div>
<span class="proto-chip" id="protoChip">протокол</span>
</div>
<div>
<label class="field" for="proxy">Ссылка сервера</label>
<input id="proxy" type="text" placeholder="naive+https://… или hysteria2://pass@host:443/" spellcheck="false" />
<div class="hero-actions">
<button class="action primary cta" id="toggleBtn" type="button">Подключить</button>
</div>
</div>
<p class="hero-hint" id="heroHint">Выберите профиль и нажмите Подключить</p>
</section>
<section class="section">
<div class="server-toolbar">
<div class="section-title"><span>Серверы</span></div>
<div class="server-actions">
<button class="mini" id="pingBtn" type="button">Пинг</button>
<button class="mini accent" id="bestBtn" type="button">Лучший</button>
<button class="icon-btn" id="addBtn" type="button" title="Новый профиль" style="width:34px;height:34px;font-size:1rem">+</button>
<button class="icon-btn danger" id="delBtn" type="button" title="Удалить" style="width:34px;height:34px;font-size:1rem">×</button>
</div>
</div>
<label class="auto-best">
<input id="autoBest" type="checkbox" />
Авто: пинг + подключение к лучшему
</label>
<div class="server-list" id="serverList"></div>
<select id="profile" hidden></select>
<details class="profile-edit" id="profileEdit">
<summary>Редактировать выбранный / подписка</summary>
<div class="stack" style="margin-top:8px">
<div>
<label class="field" for="name">Название</label>
<input id="name" type="text" placeholder="Например: DE-1 / Home" spellcheck="false" />
</div>
<div>
<label class="field" for="proxy">Ссылка / конфиг</label>
<textarea id="proxy" rows="3" placeholder="vless:// · vmess:// · trojan:// · hy2:// · naive+https:// · AWG .conf" spellcheck="false"></textarea>
</div>
<div>
<label class="field" for="subUrl">Подписка</label>
<div class="sub-bar">
<input id="subUrl" type="text" placeholder="https://…/sub" spellcheck="false" />
<button class="icon-btn wide" id="subBtn" type="button" title="Обновить подписку">Обновить</button>
</div>
</div>
<button class="action secondary" id="saveBtn" type="button">Сохранить профиль</button>
</div>
</details>
</section>
<div class="row">
<span>Системный прокси Windows</span>
<span>Системный прокси</span>
<label class="switch">
<input id="sysproxy" type="checkbox" checked />
<span class="slider"></span>
</label>
</div>
<div class="actions">
<button class="action primary" id="toggleBtn" type="button">Подключить</button>
<button class="action ghost" id="saveBtn" type="button">Сохранить профиль</button>
<div class="tools">
<button class="action ghost" id="pingBtn" type="button">Пинг серверов</button>
<button class="action ghost" id="updCheckBtn" type="button">Проверить обновление</button>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
<div class="grid2">
<div>
<label class="field" for="hy2Congestion">Congestion</label>
<select id="hy2Congestion">
<option value="bbr">BBR</option>
<option value="reno">Reno</option>
</select>
</div>
<div>
<label class="field" for="hy2Bbr">BBR profile</label>
<select id="hy2Bbr">
<option value="standard">standard</option>
<option value="conservative">conservative</option>
<option value="aggressive">aggressive</option>
</select>
</div>
<div>
<label class="field" for="hy2Up">Brutal up</label>
<input id="hy2Up" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Down">Brutal down</label>
<input id="hy2Down" type="text" placeholder="пусто = BBR" />
</div>
<div>
<label class="field" for="hy2Obfs">Obfs</label>
<select id="hy2Obfs">
<option value="">нет</option>
<option value="salamander">salamander</option>
<option value="gecko">gecko</option>
</select>
</div>
<div>
<label class="field" for="hy2ObfsPass">Obfs password</label>
<input id="hy2ObfsPass" type="text" placeholder="пароль" />
</div>
<div>
<label class="field" for="hy2Sni">SNI</label>
<input id="hy2Sni" type="text" placeholder="cdn.example.com" />
</div>
<div>
<label class="field" for="hy2Pin">pinSHA256</label>
<input id="hy2Pin" type="text" placeholder="опционально" />
</div>
<div>
<label class="field" for="hy2Hop">Hop interval</label>
<input id="hy2Hop" type="text" placeholder="30s" />
</div>
</div>
<button class="action ghost" id="coreBtn" type="button">Установить cores (naive + hysteria2)</button>
</div>
<div class="ping-list" id="pingList"></div>
<label class="check"><input id="hy2Insecure" type="checkbox" /> TLS insecure</label>
<label class="check"><input id="hy2Fast" type="checkbox" /> Fast Open</label>
<label class="check"><input id="hy2Lazy" type="checkbox" /> Lazy connect</label>
</details>
<details class="panel" id="toolsBox">
<summary>Сервис · cores, обновления</summary>
<div class="tools">
<button class="action secondary" id="updCheckBtn" type="button">Проверить обновление</button>
<button class="action secondary" id="coreBtn" type="button">Установить cores</button>
</div>
</details>
<p class="meta" id="meta">Загрузка…</p>
<section class="shop" aria-label="Купить подключение">
<h3>Безопасное подключение</h3>
<p>На любом устройстве — защита данных, стабильность и приватность. Демо-ключ от 30₽.</p>
<button class="action primary" id="shopBtn" type="button">Купить на evilfox.win</button>
<a class="shop-link" id="shopLink" href="https://evilfox.win/" rel="noopener">https://evilfox.win/</a>
<h3>Нужен доступ?</h3>
<p>Ключи и тарифы на evilfox.win — демо от 30₽.</p>
<button class="action primary" id="shopBtn" type="button">Открыть магазин</button>
<button class="shop-link" id="shopLink" type="button">https://evilfox.win/</button>
</section>
<p class="ver" id="verLabel">Navis</p>
<button class="shop-link" id="quitBtn" type="button" hidden style="display:none;margin:8px auto 0;width:auto">Выйти</button>
</main>
<div class="modal-backdrop" id="modal">
@@ -339,287 +208,18 @@
<input id="newName" type="text" placeholder="Server EU" />
</div>
<div>
<label class="field" for="newProxy">Ссылка</label>
<input id="newProxy" type="text" placeholder="hysteria2://pass@host:443/ или naive+https://…" />
<label class="field" for="newProxy">Ссылка / конфиг</label>
<textarea id="newProxy" rows="4" placeholder="vpn:// · awg:// · vless:// · hy2:// · naive · или текст [Interface]" style="min-height:88px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem"></textarea>
<p class="meta" id="modalErr" style="display:none;margin-top:8px;min-height:0"></p>
</div>
</div>
<div class="actions">
<button class="action ghost" id="cancelNew" type="button">Отмена</button>
<button class="action secondary" id="cancelNew" type="button">Отмена</button>
<button class="action primary" id="createNew" type="button">Создать</button>
</div>
</div>
</div>
<script>
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 profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
const modal = $("modal");
const shopBtn = $("shopBtn");
const shopLink = $("shopLink");
const pingBtn = $("pingBtn");
const updCheckBtn = $("updCheckBtn");
const updateBanner = $("updateBanner");
const updateBtn = $("updateBtn");
const pingList = $("pingList");
const verLabel = $("verLabel");
const SHOP_URL = "https://evilfox.win/";
let busy = false;
let connected = false;
let formHydrated = false;
let dirty = false;
let profiles = [];
function markDirty() { dirty = true; }
[proxy, nameInput, sysproxy].forEach((el) => {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
el.addEventListener("focus", markDirty);
});
function setMeta(text, kind) {
meta.textContent = text || "";
meta.style.color = kind === "err" ? "var(--danger)" : kind === "ok" ? "var(--accent)" : "var(--muted)";
}
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.protocol ? ("[" + p.protocol + "] ") : "") + (p.host ? (p.name + " · " + p.host) : p.name);
profile.appendChild(opt);
});
const want = active || cur || (profiles[0] && profiles[0].name);
if (want) profile.value = want;
}
function renderPings(pings) {
pingList.innerHTML = "";
(pings || []).forEach((p) => {
const row = document.createElement("div");
row.className = "ping-item";
const left = document.createElement("span");
left.textContent = p.host ? (p.name + " · " + p.host) : p.name;
const right = document.createElement("span");
right.className = "ms " + (p.ok ? "ok" : "bad");
right.textContent = p.ok ? (p.ms + " ms") : (p.error || "ошибка");
row.appendChild(left);
row.appendChild(right);
pingList.appendChild(row);
});
}
function renderUpdate(u, version) {
verLabel.textContent = "Navis v" + (version || "?");
if (!u) {
updateBanner.classList.remove("show");
return;
}
if (u.available) {
updateBanner.classList.add("show");
$("updateTitle").textContent = "Обновление " + u.latest;
$("updateNotes").textContent = u.notes || ("Текущая версия " + u.current);
} else {
updateBanner.classList.remove("show");
}
}
function paint(state, opts) {
const syncForm = opts && opts.syncForm;
connected = !!state.connected;
dot.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;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
saveBtn.disabled = lock;
coreBtn.disabled = busy;
pingBtn.disabled = busy;
updCheckBtn.disabled = busy;
updateBtn.disabled = busy;
btn.disabled = busy;
fillProfiles(state.profiles || [], state.active_profile || state.profile);
renderPings(state.pings || []);
renderUpdate(state.update, state.version);
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;
formHydrated = true;
if (syncForm) dirty = false;
}
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(" · ");
} else if (state.core_ready === false) {
detail = "Сначала установите cores (naive / hysteria2)";
} else {
detail = "Вставьте ссылку naive или hysteria2://";
}
if (!busy) setMeta(detail, state.core_ready === false ? "err" : "");
}
async function refresh(opts) {
const state = await getState();
paint(state, opts);
return state;
}
async function withBusy(fn) {
if (busy) return;
busy = true;
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = true; });
try {
await fn();
} finally {
busy = false;
try {
await refresh({ syncForm: true });
} catch (e) {
setMeta(String(e), "err");
[btn, coreBtn, saveBtn, addBtn, delBtn, profile, pingBtn, updCheckBtn, updateBtn].forEach((b) => { if (b) b.disabled = false; });
}
}
}
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 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);
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 = "";
modal.classList.add("open");
$("newName").focus();
});
$("cancelNew").addEventListener("click", () => modal.classList.remove("open"));
$("createNew").addEventListener("click", () => withBusy(async () => {
try {
const n = $("newName").value.trim();
const p = $("newProxy").value.trim();
if (!n) throw "Укажите название профиля";
await createProfile(n, p, !!sysproxy.checked);
modal.classList.remove("open");
formHydrated = false;
dirty = false;
setMeta("Профиль создан", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
async function openShop(e) {
if (e) e.preventDefault();
try {
await openURL(SHOP_URL);
} catch (err) {
setMeta(String(err), "err");
}
}
shopBtn.addEventListener("click", openShop);
shopLink.addEventListener("click", openShop);
pingBtn.addEventListener("click", () => withBusy(async () => {
try {
setMeta("Проверка серверов…");
const rows = await pingServers();
renderPings(rows);
const ok = (rows || []).filter((r) => r.ok).length;
setMeta("Пинг: " + ok + "/" + (rows || []).length + " доступны", ok ? "ok" : "err");
} catch (e) { setMeta(String(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("Скачивание обновления…");
await applyUpdate();
setMeta("Обновление установлено, перезапуск…", "ok");
} catch (e) { setMeta(String(e), "err"); }
}));
refresh({ syncForm: true }).catch((e) => setMeta(String(e), "err"));
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 2500);
</script>
<script src="app.js"></script>
</body>
</html>
+101 -5
View File
@@ -3,8 +3,10 @@ package config
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
)
// Protocol identifies a VPN/proxy backend.
@@ -13,6 +15,10 @@ type Protocol string
const (
ProtocolNaive Protocol = "naive"
ProtocolHysteria2 Protocol = "hysteria2"
ProtocolAWG Protocol = "awg"
ProtocolVLESS Protocol = "vless"
ProtocolVMess Protocol = "vmess"
ProtocolTrojan Protocol = "trojan"
)
// Config is the top-level client configuration.
@@ -23,9 +29,12 @@ type Config struct {
// SystemProxy enables Windows Internet Settings / WinHTTP proxy on connect.
SystemProxy bool `json:"system_proxy"`
// BinDir is where protocol binaries (naive.exe) are stored.
// BinDir is where protocol binaries (naive.exe / hysteria.exe) are stored.
BinDir string `json:"bin_dir,omitempty"`
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
Profiles []Profile `json:"profiles"`
}
@@ -47,6 +56,22 @@ type Profile struct {
NoPostQuantum bool `json:"no_post_quantum,omitempty"`
Log string `json:"log,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Hysteria 2 options (client-side). Masquerade on server is separate;
// here SNI/insecure/pin emulate HTTPS look + salamander/gecko obfuscation.
Hy2Congestion string `json:"hy2_congestion,omitempty"` // bbr | reno
Hy2BBRProfile string `json:"hy2_bbr_profile,omitempty"` // standard | conservative | aggressive
Hy2BandwidthUp string `json:"hy2_bandwidth_up,omitempty"` // empty = BBR; set for Brutal
Hy2BandwidthDown string `json:"hy2_bandwidth_down,omitempty"`
Hy2Obfs string `json:"hy2_obfs,omitempty"` // salamander | gecko
Hy2ObfsPassword string `json:"hy2_obfs_password,omitempty"`
Hy2SNI string `json:"hy2_sni,omitempty"`
Hy2Insecure bool `json:"hy2_insecure,omitempty"`
Hy2PinSHA256 string `json:"hy2_pin_sha256,omitempty"`
Hy2FastOpen bool `json:"hy2_fast_open,omitempty"`
Hy2Lazy bool `json:"hy2_lazy,omitempty"`
Hy2HopInterval string `json:"hy2_hop_interval,omitempty"` // e.g. 30s
Hy2ALPN string `json:"hy2_alpn,omitempty"`
}
// DefaultListen returns listen addresses suitable for Windows system proxy.
@@ -119,6 +144,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)
@@ -162,17 +207,54 @@ func (c *Config) Validate() error {
return fmt.Errorf("config: profile[%d] missing name", i)
}
switch p.Protocol {
case ProtocolNaive, ProtocolHysteria2:
// Proxy may be empty until the user pastes a share link in the UI.
case ProtocolNaive, ProtocolHysteria2, ProtocolAWG, ProtocolVLESS, ProtocolVMess, ProtocolTrojan:
// Proxy may be empty until the user pastes a share link / conf in the UI.
case "":
c.Profiles[i].Protocol = ProtocolNaive
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 {
@@ -184,11 +266,15 @@ func (c *Config) ActiveProfile() (*Profile, error) {
}
// ResolveBinDir returns an absolute bin directory.
// Relative paths are resolved next to the executable (portable install layout).
// Relative paths are resolved next to the executable (portable install layout),
// or under ~/Library/Application Support/Navis on macOS (.app bundles are read-only).
func ResolveBinDir(cfgPath, binDir string) (string, error) {
if filepath.IsAbs(binDir) {
return binDir, nil
}
if dir, ok := platformNavisDir(); ok {
return filepath.Abs(filepath.Join(dir, binDir))
}
if exe, err := os.Executable(); err == nil {
return filepath.Abs(filepath.Join(filepath.Dir(exe), binDir))
}
@@ -260,9 +346,16 @@ func (c *Config) SetActiveProxy(proxy string) error {
return fmt.Errorf("active profile %q not found", c.Active)
}
// LocateConfig finds configs/config.json next to the executable or in cwd.
// LocateConfig finds configs/config.json next to the executable, in cwd,
// or under ~/Library/Application Support/Navis on macOS.
func LocateConfig() (string, error) {
candidates := []string{}
if dir, ok := platformNavisDir(); ok {
candidates = append(candidates,
filepath.Join(dir, "configs", "config.json"),
filepath.Join(dir, "config.json"),
)
}
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidates = append(candidates,
@@ -278,6 +371,9 @@ func LocateConfig() (string, error) {
return p, nil
}
}
if dir, ok := platformNavisDir(); ok {
return filepath.Join(dir, "configs", "config.json"), nil
}
// Default path next to executable for first-run create.
if exe, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exe), "configs", "config.json"), nil
+34 -1
View File
@@ -2,12 +2,26 @@ package config
import "strings"
// DetectProtocol infers protocol from a share/proxy URI.
// 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://"):
return ProtocolHysteria2
case strings.HasPrefix(lower, "vless://"):
return ProtocolVLESS
case strings.HasPrefix(lower, "vmess://"):
return ProtocolVMess
case strings.HasPrefix(lower, "trojan://"):
return ProtocolTrojan
case strings.HasPrefix(lower, "awg://"), strings.HasPrefix(lower, "amneziawg://"),
strings.HasPrefix(lower, "wireguard://"), strings.HasPrefix(lower, "vpn://"):
return ProtocolAWG
case strings.Contains(lower, "[interface]") && (strings.Contains(lower, "privatekey") || strings.Contains(lower, "private_key") || strings.Contains(lower, "private key")):
return ProtocolAWG
case strings.HasPrefix(strings.TrimSpace(raw), "{") && (strings.Contains(lower, "client_priv_key") || strings.Contains(lower, "server_pub_key")):
return ProtocolAWG
case strings.HasPrefix(lower, "naive+"), strings.HasPrefix(lower, "naive://"),
strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "quic://"),
strings.HasPrefix(lower, "http://"):
@@ -16,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())
}
+16
View File
@@ -0,0 +1,16 @@
//go:build darwin
package config
import (
"os"
"path/filepath"
)
func platformNavisDir() (string, bool) {
base, err := os.UserConfigDir()
if err != nil {
return "", false
}
return filepath.Join(base, "Navis"), true
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !darwin
package config
func platformNavisDir() (string, bool) {
return "", false
}
+80 -7
View File
@@ -29,11 +29,74 @@ func (c *Config) ListProfiles() []ProfileInfo {
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: proxyHost(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 == "" {
return ""
}
lower := strings.ToLower(proxy)
if strings.Contains(lower, "[interface]") {
for _, line := range strings.Split(proxy, "\n") {
line = strings.TrimSpace(line)
if len(line) >= 9 && strings.EqualFold(line[:8], "endpoint") {
_, val, ok := strings.Cut(line, "=")
if !ok {
_, val, ok = strings.Cut(line, ":")
}
if ok {
return strings.TrimSpace(val)
}
}
}
}
if i := strings.Index(proxy, "://"); i >= 0 {
proxy = proxy[i+3:]
}
@@ -64,7 +127,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("имя профиля пустое")
@@ -80,15 +153,13 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
for i := range c.Profiles {
if c.Profiles[i].Name == name {
c.Profiles[i].Proxy = proxy
if proto != "" {
c.Profiles[i].Protocol = proto
} else if c.Profiles[i].Protocol == "" {
c.Profiles[i].Protocol = ProtocolNaive
}
c.Profiles[i].Protocol = proto
if len(c.Profiles[i].Listen) == 0 {
c.Profiles[i].Listen = defaultListen()
}
c.Active = name
if activate {
c.Active = name
}
return nil
}
}
@@ -99,7 +170,9 @@ func (c *Config) UpsertProfileWithProtocol(name, proxy string, proto Protocol) e
Proxy: proxy,
Listen: defaultListen(),
})
c.Active = name
if activate {
c.Active = name
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"strings"
"testing"
)
func TestRedactProxyURI(t *testing.T) {
got := RedactProxyURI("https://user:pass@example.com:443/?x=1")
if got != "https://***@example.com:443/?x=1" {
t.Fatalf("got %q", got)
}
if RedactProxyURI("") != "" {
t.Fatal("empty")
}
r := RedactProxyURI("[Interface]\nPrivateKey = abc\n")
if !strings.Contains(r, "скрыт") {
t.Fatalf("awg redact: %q", r)
}
}
+366 -41
View File
@@ -2,6 +2,7 @@ package core
import (
"context"
"errors"
"fmt"
"io"
"net"
@@ -13,9 +14,13 @@ import (
"time"
"vpnclient/internal/config"
"vpnclient/internal/corebin"
"vpnclient/internal/linknorm"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
"vpnclient/internal/subscription"
"vpnclient/internal/sysproxy"
)
@@ -29,6 +34,8 @@ 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
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -39,74 +46,148 @@ 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()
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 {
if err := engine.Start(ctx, profileCopy, binDir); err != nil {
return err
}
if m.cfg.SystemProxy {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
_ = engine.Stop()
return fmt.Errorf("already connected; disconnect first")
}
if wantSysProxy {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
_ = engine.Stop()
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
}
if err := m.sys.Enable(httpProxy); err != nil {
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
if err := sys.Enable(httpProxy); err != nil {
if errors.Is(err, sysproxy.ErrUnsupported) {
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
}
} else {
sysproxy.WriteSentinel(cfgPath, httpProxy)
}
}
m.engine = engine
m.profile = profile
m.profile = &profileCopy
return nil
}
func (m *Manager) Disconnect() error {
m.mu.Lock()
defer m.mu.Unlock()
sys := m.sys
engine := m.engine
m.engine = nil
m.profile = nil
if engine != nil {
m.lastStop.Add(1)
}
m.mu.Unlock()
var first error
if m.sys.Enabled() {
if err := m.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() {
defer m.lastStop.Done()
done <- engine.Stop()
}()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
// Stop continues in background; Connect will wait on lastStop.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
}
}
if m.engine != nil {
if err := m.engine.Stop(); err != nil && first == nil {
first = err
}
m.engine = nil
}
m.profile = nil
return first
}
@@ -187,10 +268,84 @@ 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()
}
// 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
}
// PollUI gathers status + profile list under one lock without Config().Clone().
func (m *Manager) PollUI(includeSecrets bool) UIPoll {
m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir,
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
}
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
out.Subscription = m.cfg.SubscriptionURL
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,
}
}
}
if m.engine != nil && m.engine.Running() {
out.Status.Connected = true
if m.profile != nil {
out.Status.Profile = m.profile.Name
out.Status.Protocol = m.profile.Protocol
}
if hp, ok := m.engine.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp
}
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp
}
}
return out
}
func (m *Manager) SetSystemProxy(enabled bool) {
@@ -234,8 +389,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
@@ -247,9 +407,18 @@ 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 {
hysteria2.EnrichProfile(p)
}
return config.Save(m.cfgPath, *m.cfg)
}
@@ -299,6 +468,9 @@ func (m *Manager) SaveActiveProfile(name, proxy string) error {
if err := m.cfg.UpsertProfileWithProtocol(name, proxy, proto); err != nil {
return err
}
if p, err := m.cfg.ActiveProfile(); err == nil {
hysteria2.EnrichProfile(p)
}
return config.Save(m.cfgPath, *m.cfg)
}
@@ -320,6 +492,106 @@ func (m *Manager) Profiles() []config.ProfileInfo {
return m.cfg.ListProfiles()
}
// Hy2Options are editable Hysteria 2 extras for the active profile.
type Hy2Options struct {
Congestion string `json:"congestion"`
BBRProfile string `json:"bbr_profile"`
BandwidthUp string `json:"bandwidth_up"`
BandwidthDown string `json:"bandwidth_down"`
Obfs string `json:"obfs"`
ObfsPassword string `json:"obfs_password"`
SNI string `json:"sni"`
Insecure bool `json:"insecure"`
PinSHA256 string `json:"pin_sha256"`
FastOpen bool `json:"fast_open"`
Lazy bool `json:"lazy"`
HopInterval string `json:"hop_interval"`
}
func (m *Manager) ActiveHy2Options() Hy2Options {
m.mu.Lock()
defer m.mu.Unlock()
p, err := m.cfg.ActiveProfile()
if err != nil {
return Hy2Options{Congestion: "bbr", BBRProfile: "standard"}
}
return 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,
}
}
func (m *Manager) SaveHy2Options(opts Hy2Options) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("сначала отключитесь")
}
p, err := m.cfg.ActiveProfile()
if err != nil {
return err
}
p.Protocol = config.ProtocolHysteria2
p.Hy2Congestion = strings.TrimSpace(opts.Congestion)
p.Hy2BBRProfile = strings.TrimSpace(opts.BBRProfile)
p.Hy2BandwidthUp = strings.TrimSpace(opts.BandwidthUp)
p.Hy2BandwidthDown = strings.TrimSpace(opts.BandwidthDown)
p.Hy2Obfs = strings.TrimSpace(opts.Obfs)
p.Hy2ObfsPassword = strings.TrimSpace(opts.ObfsPassword)
p.Hy2SNI = strings.TrimSpace(opts.SNI)
p.Hy2Insecure = opts.Insecure
p.Hy2PinSHA256 = strings.TrimSpace(opts.PinSHA256)
p.Hy2FastOpen = opts.FastOpen
p.Hy2Lazy = opts.Lazy
p.Hy2HopInterval = strings.TrimSpace(opts.HopInterval)
return config.Save(m.cfgPath, *m.cfg)
}
func (m *Manager) SubscriptionURL() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.SubscriptionURL
}
func (m *Manager) ImportSubscription(rawURL string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
items, err := subscription.Fetch(ctx, rawURL)
if err != nil {
return 0, err
}
if len(items) == 0 {
return 0, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
}
m.mu.Lock()
defer m.mu.Unlock()
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
for _, it := range items {
_ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
if m.cfg.Profiles[i].Name == it.Name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
}
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
return len(items), nil
}
func (m *Manager) SaveConfig() error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -351,6 +623,10 @@ func (m *Manager) newEngine(p config.Protocol) (Engine, error) {
return naive.New(m.stderr), nil
case config.ProtocolHysteria2:
return hysteria2.New(m.stderr), nil
case config.ProtocolAWG:
return awg.New(m.stderr), nil
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
return xray.New(m.stderr), nil
default:
return nil, fmt.Errorf("unsupported protocol %q", p)
}
@@ -358,31 +634,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:
path, err = awg.EnsureBinary(binDir)
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
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 cores.
// 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
return out, nil
return out, first
}
+127
View File
@@ -0,0 +1,127 @@
package core
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"vpnclient/internal/config"
)
type fakeEngine struct {
mu sync.Mutex
running bool
stopDelay time.Duration
stops atomic.Int32
}
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.running = true
return nil
}
func (e *fakeEngine) Stop() error {
e.stops.Add(1)
if e.stopDelay > 0 {
time.Sleep(e.stopDelay)
}
e.mu.Lock()
e.running = false
e.mu.Unlock()
return nil
}
func (e *fakeEngine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
cfg := &config.Config{
Active: "t",
SystemProxy: false,
BinDir: t.TempDir(),
Profiles: []config.Profile{{
Name: "t",
Protocol: config.ProtocolNaive,
Proxy: "https://u:p@example.com",
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
m.mu.Lock()
m.engine = eng
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
start := time.Now()
_ = m.Disconnect()
// Connect must wait for Stop to finish even if Disconnect returned early...
// our Disconnect waits up to 3s, so with 200ms it should complete.
if time.Since(start) < 150*time.Millisecond {
t.Fatalf("disconnect returned too fast")
}
if eng.Running() {
t.Fatal("engine still running")
}
// Simulate slow stop that outlives Disconnect timeout.
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
m.mu.Lock()
m.engine = eng2
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
// Force short path: call the wait mechanism like Connect does.
m.mu.Lock()
engine := m.engine
m.engine = nil
m.profile = nil
m.lastStop.Add(1)
m.mu.Unlock()
go func() {
defer m.lastStop.Done()
_ = engine.Stop()
}()
waitStart := time.Now()
m.waitEngineStopped()
if time.Since(waitStart) < 400*time.Millisecond {
t.Fatalf("waitEngineStopped returned before stop finished")
}
if eng2.Running() {
t.Fatal("engine2 still running after wait")
}
}
func TestConfigReturnsClone(t *testing.T) {
cfg := &config.Config{
Active: "a",
Profiles: []config.Profile{{
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
snap := m.Config()
snap.Active = "mutated"
snap.Profiles[0].Proxy = "leaked"
if m.cfg.Active != "a" {
t.Fatal("Config() leaked mutable Active")
}
if m.cfg.Profiles[0].Proxy != "https://x" {
t.Fatal("Config() leaked mutable Profile")
}
}
+73
View File
@@ -0,0 +1,73 @@
package corebin
import (
"os"
"sync"
)
var (
mu sync.Mutex
cache = map[string]string{}
)
// CacheKey identifies a resolved core binary for a binDir + protocol.
func CacheKey(binDir, proto string) string {
return binDir + "\x00" + proto
}
// 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).
func Invalidate() {
mu.Lock()
cache = map[string]string{}
mu.Unlock()
}
// Resolve caches the result of fn under key. Stale paths (missing on disk) are refreshed.
func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok {
if _, err := os.Stat(path); err == nil {
return path, nil
}
mu.Lock()
delete(cache, key)
mu.Unlock()
}
path, err := fn()
if err != nil {
return "", err
}
Set(key, path)
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"
}
}
+45
View File
@@ -0,0 +1,45 @@
package corebin
import (
"os"
"path/filepath"
"testing"
)
func TestResolveCacheAndStale(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: path=%q calls=%d err=%v", p, calls, err)
}
if p, err := Resolve(key, fn); err != nil || p != path || calls != 1 {
t.Fatalf("cached: path=%q calls=%d err=%v", p, calls, err)
}
_ = os.Remove(path)
if _, err := Resolve(key, fn); err != nil {
// fn returns removed path; Resolve still succeeds but next Stat misses
t.Fatalf("unexpected err after delete before recreate: %v", err)
}
if calls != 2 {
t.Fatalf("expected refresh call, got %d", calls)
}
if err := os.WriteFile(path, []byte("y"), 0o755); err != nil {
t.Fatal(err)
}
if p, err := Resolve(key, fn); err != nil || p != path {
t.Fatalf("after recreate: %q %v", p, err)
}
if ProtoKey("vless") != "xray" || ProtoKey("naive") != "naive" {
t.Fatal("ProtoKey")
}
}
+214
View File
@@ -0,0 +1,214 @@
package coredl
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
type ReleaseAsset struct {
Name string
BrowserDownloadURL string
SHA256 string // lowercase hex, empty if unknown
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
// FetchLatestAssets loads assets for a repo's latest GitHub release.
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
if err != nil {
return "", nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "navis-vpnclient")
resp, err := client.Do(req)
if err != nil {
return "", nil, fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", nil, err
}
out := make([]ReleaseAsset, 0, len(rel.Assets))
for _, a := range rel.Assets {
out = append(out, ReleaseAsset{
Name: a.Name,
BrowserDownloadURL: a.BrowserDownloadURL,
SHA256: parseDigestSHA256(a.Digest),
})
}
return rel.TagName, out, nil
}
func parseDigestSHA256(digest string) string {
digest = strings.TrimSpace(strings.ToLower(digest))
if digest == "" {
return ""
}
if strings.HasPrefix(digest, "sha256:") {
return strings.TrimPrefix(digest, "sha256:")
}
return ""
}
// DownloadFile writes url to path (size-capped).
func DownloadFile(path, url string, maxBytes int64) error {
if maxBytes <= 0 {
maxBytes = 120 << 20
}
client := &http.Client{Timeout: 15 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: %s", url, resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
return err
}
// FileSHA256 returns lowercase hex digest of path.
func FileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
func VerifySHA256(path, want string) error {
want = strings.TrimSpace(strings.ToLower(want))
if want == "" {
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
}
got, err := FileSHA256(path)
if err != nil {
return err
}
if got != want {
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
}
return nil
}
// RequireSHA returns sha or an error if the asset has none.
func RequireSHA(a ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
}
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
if a.SHA256 != "" {
return a.SHA256, nil
}
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
for _, name := range companions {
for _, o := range all {
if !strings.EqualFold(o.Name, name) {
continue
}
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
if err != nil {
return "", err
}
if sum != "" {
return sum, nil
}
}
}
return RequireSHA(a)
}
func fetchChecksumFile(url, assetName string) (string, error) {
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("checksum download: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
text := string(body)
lowerName := strings.ToLower(assetName)
lines := strings.Split(text, "\n")
var shaLine string
for _, line := range lines {
l := strings.TrimSpace(strings.ToLower(line))
if strings.HasPrefix(l, "sha256:") {
shaLine = strings.TrimSpace(line[len("sha256:"):])
// keep scanning — prefer line that also mentions the asset
if strings.Contains(l, lowerName) {
return strings.ToLower(strings.Fields(shaLine)[0]), nil
}
}
fields := strings.Fields(line)
if len(fields) >= 2 {
sum := strings.ToLower(fields[0])
file := strings.TrimPrefix(fields[len(fields)-1], "*")
if len(sum) == 64 && strings.EqualFold(file, assetName) {
return sum, nil
}
}
}
if shaLine != "" {
parts := strings.Fields(shaLine)
if len(parts) > 0 && len(parts[0]) == 64 {
return strings.ToLower(parts[0]), nil
}
}
return "", nil
}
// DownloadAndVerify downloads url to path and checks sha256.
func DownloadAndVerify(path, url, wantSHA256 string) error {
if err := DownloadFile(path, url, 0); err != nil {
return err
}
if err := VerifySHA256(path, wantSHA256); err != nil {
_ = os.Remove(path)
return err
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
package coredl
import "testing"
func TestParseDigestAndVerify(t *testing.T) {
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
t.Fatalf("parse: %q", got)
}
if parseDigestSHA256("md5:x") != "" {
t.Fatal("expected empty for non-sha256")
}
}
+24 -3
View File
@@ -2,16 +2,17 @@ package linknorm
import (
"fmt"
"strings"
"vpnclient/internal/config"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/naive"
"vpnclient/internal/protocols/xray"
)
// 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("пустая ссылка")
}
@@ -22,10 +23,30 @@ func Normalize(proto config.Protocol, raw string) (normalized string, detected c
case config.ProtocolHysteria2:
u, rem, err := hysteria2.NormalizeShareLink(raw)
return u, config.ProtocolHysteria2, rem, err
case config.ProtocolAWG:
u, rem, err := awg.NormalizeShareLink(raw)
return u, config.ProtocolAWG, rem, err
case config.ProtocolVLESS, config.ProtocolVMess, config.ProtocolTrojan:
u, rem, err := xray.NormalizeShareLink(raw)
if err != nil {
return "", proto, "", err
}
detected = xray.DetectProtocol(u)
if detected == "" {
detected = proto
}
return u, detected, rem, nil
case config.ProtocolNaive, "":
if awg.Detect(raw) {
u, rem, err := awg.NormalizeShareLink(raw)
return u, config.ProtocolAWG, rem, err
}
if xray.Detect(raw) {
u, rem, err := xray.NormalizeShareLink(raw)
return u, xray.DetectProtocol(raw), rem, err
}
u, rem, err := naive.ParseShareLink(raw)
if err != nil {
// try hy2 if naive failed and looks like hy
if hysteria2.Detect(raw) {
u2, rem2, err2 := hysteria2.NormalizeShareLink(raw)
return u2, config.ProtocolHysteria2, rem2, err2
+35
View File
@@ -0,0 +1,35 @@
package linknorm
import (
"strings"
"unicode"
)
// SanitizeShareText strips BOM, bidi isolates and zero-width chars that break
// scheme detection (common when pasting from messengers / Amnezia share).
func SanitizeShareText(raw string) string {
raw = strings.TrimPrefix(raw, "\ufeff")
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
switch r {
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', // zero-width / BOM
'\u2066', '\u2067', '\u2068', '\u2069', // LRI/RLI/FSI/PDI
'\u200e', '\u200f', // LRM/RLM
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e': // embedding overrides
continue
}
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
continue
}
if unicode.Is(unicode.Cf, r) { // other format controls
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
+42
View File
@@ -0,0 +1,42 @@
package linknorm
import "testing"
func TestSanitizeShareText_BidiWrappers(t *testing.T) {
raw := "\u2068vpn://abc\u2069"
got := SanitizeShareText(raw)
if got != "vpn://abc" {
t.Fatalf("got %q", got)
}
}
func TestNormalize_DirtyVPN(t *testing.T) {
raw := "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxLjEuMS4xClByaXZhdGVLZXkgPSBhSkpSYUFFY1RidWdJYjB2emFJcW5MQkZJQ0dqNlRkcXo1RC84OU13T0hZPQpKYyA9IDYKCltQZWVyXQpQdWJsaWNLZXkgPSBOUThJMVpPcUtxWmhGUXlxcXBuakRWc1poWXpkUk11Qm9tVXBzNWZPTTNzPQpFbmRwb2ludCA9IHJ1LmRlNGltYS51azo0MTQyMQpBbGxvd2VkSVBzID0gMC4wLjAuMC8w\u2069"
// Use full user link instead for reliable parse
raw = "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1\u2069"
u, proto, _, err := Normalize("", raw)
if err != nil {
t.Fatal(err)
}
if proto != "awg" {
t.Fatalf("proto %q", proto)
}
if u == "" || !containsFold(u, "[Interface]") {
t.Fatalf("normalized %q", u)
}
}
func containsFold(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
}
func stringIndexFold(s, sub string) int {
// simple contains for test
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+59
View File
@@ -0,0 +1,59 @@
package logbuf
import (
"bytes"
"sync"
)
const DefaultMax = 256 << 10 // 256 KiB
// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
type Buffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
}
func New(max int) *Buffer {
if max <= 0 {
max = DefaultMax
}
return &Buffer{max: max}
}
func (b *Buffer) Write(p []byte) (int, error) {
if b == nil {
return len(p), nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.buf.Len()+len(p) > b.max {
// Drop oldest half to keep recent diagnostics.
keep := b.buf.Bytes()
if len(keep) > b.max/2 {
keep = keep[len(keep)-b.max/2:]
}
b.buf.Reset()
_, _ = b.buf.Write(keep)
_, _ = b.buf.WriteString("\n… log truncated …\n")
}
return b.buf.Write(p)
}
func (b *Buffer) String() string {
if b == nil {
return ""
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
func (b *Buffer) Len() int {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Len()
}
+29
View File
@@ -0,0 +1,29 @@
package logbuf
import (
"strings"
"sync"
"testing"
)
func TestBufferConcurrentWriteAndCap(t *testing.T) {
b := New(64)
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 40; j++ {
_, _ = b.Write([]byte("abcdefghij"))
}
}()
}
wg.Wait()
if b.Len() > 64+40 { // allow truncate marker overhead
t.Fatalf("len=%d exceeds soft cap", b.Len())
}
s := b.String()
if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
t.Fatalf("unexpected content %q", s)
}
}
+199 -10
View File
@@ -4,15 +4,20 @@ import (
"context"
"fmt"
"net"
"runtime"
"sort"
"strings"
"sync"
"time"
"vpnclient/internal/config"
"vpnclient/internal/linknorm"
"vpnclient/internal/protocols/awg"
"vpnclient/internal/protocols/hysteria2"
"vpnclient/internal/protocols/xray"
)
// Result is a TCP reachability measurement to a proxy host.
// Result is a reachability measurement to a proxy host.
type Result struct {
Name string `json:"name"`
Host string `json:"host"`
@@ -22,12 +27,88 @@ type Result struct {
Error string `json:"error,omitempty"`
}
// PingProxyURI measures TCP connect time to the host in a share/proxy URI.
// Target describes one profile to ping.
type Target struct {
Name string
Protocol config.Protocol
Proxy string
}
// PingProxyURI measures connect time to the host in a share/proxy URI.
func PingProxyURI(ctx context.Context, name, proxyURI string) Result {
return PingProfile(ctx, name, "", proxyURI)
}
// PingAll pings targets in parallel (bounded concurrency) and sorts OK results by latency.
func PingAll(ctx context.Context, targets []Target) []Result {
out := make([]Result, len(targets))
if len(targets) == 0 {
return out
}
// 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)
var wg sync.WaitGroup
for i, t := range targets {
wg.Add(1)
go func(i int, t Target) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return
}
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}(i, t)
}
wg.Wait()
sorted := append([]Result(nil), out...)
sort.SliceStable(sorted, func(i, j int) bool {
a, b := sorted[i], sorted[j]
if a.OK != b.OK {
return a.OK
}
if !a.OK {
return a.Name < b.Name
}
if a.Ms != b.Ms {
return a.Ms < b.Ms
}
return a.Name < b.Name
})
return sorted
}
// BestOK returns the lowest-latency successful result, if any.
func BestOK(results []Result) (Result, bool) {
var best Result
found := false
for _, r := range results {
if !r.OK {
continue
}
if !found || r.Ms < best.Ms {
best = r
found = true
}
}
return best, found
}
// PingProfile pings using protocol hints when available.
// Naive uses TCP; Hysteria 2 / AWG use UDP — TCP would always look "refused".
// When proto is set, Detect is skipped (trust stored protocol).
func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyURI string) Result {
res := Result{Name: name}
if strings.TrimSpace(proxyURI) == "" {
@@ -35,8 +116,38 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
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 proto == config.ProtocolHysteria2 || hysteria2.Detect(proxyURI) {
if isAWG {
h, p, err := awg.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
host, port = h, p
} else if isXray {
h, p, err := xray.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
return res
}
host, port = h, p
} else if hy2 {
h, p, err := hysteria2.HostPort(proxyURI)
if err != nil {
res.Error = err.Error()
@@ -49,7 +160,6 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
res.Error = err.Error()
return res
}
// Parse host from normalized URI without importing net/url dance twice
rest := normalized
if i := strings.Index(rest, "://"); i >= 0 {
rest = rest[i+3:]
@@ -75,15 +185,94 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
return res
}
d := net.Dialer{Timeout: 4 * time.Second}
start := time.Now()
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
var (
rtt time.Duration
err error
)
if hy2 || isAWG {
rtt, err = probeUDP(ctx, host, port)
} else {
rtt, err = probeTCP(ctx, host, port)
}
if err != nil {
res.Error = fmt.Sprintf("недоступен: %v", err)
res.Error = friendlyDialError(err, hy2 || isAWG)
return res
}
_ = conn.Close()
res.Ms = time.Since(start).Milliseconds()
ms := rtt.Milliseconds()
if ms < 1 {
ms = 1
}
res.Ms = ms
res.OK = true
return res
}
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 0, err
}
_ = conn.Close()
return time.Since(start), nil
}
// 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 → up (RTT to reply)
// - short read-timeout after a successful write → soft-up (RTT ≈ DNS+dial+write)
func probeUDP(ctx context.Context, host, port string) (time.Duration, 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 0, err
}
defer conn.Close()
// Brief window only to catch ICMP port-unreachable; do not wait for an app reply.
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 0, err
}
afterWrite := time.Now()
buf := make([]byte, 64)
_, err = conn.Read(buf)
if err == nil {
return time.Since(start), nil
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// No ICMP refuse → endpoint is plausible; latency without the wait pad.
return afterWrite.Sub(start), nil
}
return 0, err
}
func friendlyDialError(err error, udp bool) string {
msg := err.Error()
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "refused"):
if udp {
return "UDP порт недоступен (connection refused)"
}
return "порт закрыт (connection refused)"
case strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out"):
return "таймаут"
case strings.Contains(lower, "no such host"):
return "DNS: хост не найден"
default:
return fmt.Sprintf("недоступен: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package netcheck
import (
"context"
"net"
"testing"
"time"
"vpnclient/internal/config"
)
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 got == "" || got == err.Error() {
t.Fatalf("got %q", got)
}
}
type netError struct{ s string }
func (e *netError) Error() string { return e.s }
func (e *netError) Timeout() bool { return false }
func (e *netError) Temporary() bool { return false }
func TestPingProfileEmpty(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
r := PingProfile(ctx, "x", config.ProtocolHysteria2, "")
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, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatalf("expected soft-ok, got err=%v", err)
}
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 {
t.Fatalf("PingProfile soft-ok failed: %+v", res)
}
if res.Ms < 1 {
t.Fatalf("ms=%d", res.Ms)
}
}
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, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatal(err)
}
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
}
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package awg
import (
"encoding/base64"
"net"
"testing"
)
func TestParseINI_AWG20(t *testing.T) {
raw := `[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.8.0.2/32
DNS = 1.1.1.1
Jc = 4
Jmin = 40
Jmax = 70
H1 = 1
H2 = 2
H3 = 3
H4 = 4
I1 = <r 128>
[Peer]
PublicKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
`
c, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if c.Jc != 4 || c.I1 != "<r 128>" || c.Endpoint != "203.0.113.10:51820" {
t.Fatalf("unexpected conf: %+v", c)
}
if !c.IsAWG20() {
t.Fatal("expected AWG2 markers")
}
ipc, err := c.ToIPC()
if err != nil {
t.Fatal(err)
}
if !containsAll(ipc, "jc=4", "i1=<r 128>", "endpoint=203.0.113.10:51820") {
t.Fatalf("bad ipc: %s", ipc)
}
}
func TestParseURI_UserInfoPrivateKey(t *testing.T) {
priv := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
pub := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE="
raw := "awg://" + priv + "@203.0.113.10:51820/?public_key=" + pub + "&address=10.8.0.2/32&jc=4"
c, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if c.PrivateKey != priv {
t.Fatalf("private key: %q", c.PrivateKey)
}
if c.PublicKey != pub {
t.Fatalf("public key: %q", c.PublicKey)
}
if c.Endpoint != "203.0.113.10:51820" {
t.Fatalf("endpoint: %q", c.Endpoint)
}
}
func TestParseINI_SpacedKeysAndQuotes(t *testing.T) {
raw := `Private Key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
Address = 10.8.0.2/32
[Peer]
Public Key = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE='
Endpoint = 1.2.3.4:51820
`
c, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if c.PrivateKey == "" || c.PublicKey == "" {
t.Fatalf("keys missing: %+v", c)
}
}
func TestParseAmneziaJSON(t *testing.T) {
raw := `{
"client_priv_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"server_pub_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=",
"client_ip": "10.8.0.2/32",
"hostName": "203.0.113.10",
"port": "51820",
"Jc": 4,
"Jmin": 10,
"Jmax": 50,
"H1": "1",
"H2": "2",
"H3": "3",
"H4": "4"
}`
c, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if c.Endpoint != "203.0.113.10:51820" {
t.Fatalf("endpoint %q", c.Endpoint)
}
if c.Jc != 4 {
t.Fatalf("jc %d", c.Jc)
}
}
func TestParseVPNScheme(t *testing.T) {
inner := `{"client_priv_key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","server_pub_key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=","client_ip":"10.8.0.2/32","hostName":"9.9.9.9","port":"51820"}`
raw := "vpn://" + base64.StdEncoding.EncodeToString([]byte(inner))
c, err := Parse(raw)
if err != nil {
t.Fatal(err)
}
if c.Endpoint != "9.9.9.9:51820" {
t.Fatalf("endpoint %q", c.Endpoint)
}
}
func TestDetect(t *testing.T) {
if !Detect("[Interface]\nPrivateKey = x") {
t.Fatal("expected detect")
}
if !Detect("awg://1.2.3.4:51820/?private_key=a&public_key=b&address=10.0.0.2/32") {
t.Fatal("expected awg uri detect")
}
if !Detect(`{"client_priv_key":"x","server_pub_key":"y"}`) {
t.Fatal("expected json detect")
}
}
func TestDecodeKeyURLSafe(t *testing.T) {
// 32 zero bytes, URL-safe without padding
raw := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
b, err := decodeKey(raw)
if err != nil || len(b) != 32 {
t.Fatalf("url-safe: %v %d", err, len(b))
}
}
func TestResolveEndpointPrefersIPv4(t *testing.T) {
got, err := resolveEndpointWith("peer.example:41421", func(host string) ([]net.IP, error) {
if host != "peer.example" {
t.Fatalf("host %q", host)
}
return []net.IP{
net.ParseIP("2001:db8::1"),
net.ParseIP("203.0.113.44"),
}, nil
})
if err != nil {
t.Fatal(err)
}
if got != "203.0.113.44:41421" {
t.Fatalf("got %q", got)
}
}
func TestResolveEndpointAlreadyIP(t *testing.T) {
got, err := resolveEndpointWith("203.0.113.10:51820", func(string) ([]net.IP, error) {
t.Fatal("lookup should not run for IP")
return nil, nil
})
if err != nil || got != "203.0.113.10:51820" {
t.Fatalf("%q %v", got, err)
}
}
func TestResolveEndpointDNSError(t *testing.T) {
_, err := resolveEndpointWith("missing.invalid:41421", func(string) ([]net.IP, error) {
return nil, &net.DNSError{Err: "no such host", Name: "missing.invalid", IsNotFound: true}
})
if err == nil || !stringsContains(err.Error(), "не удалось разрешить DNS") {
t.Fatalf("expected Russian DNS error, got %v", err)
}
}
func TestToIPCResolvesEndpoint(t *testing.T) {
old := lookupIP
lookupIP = func(host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("198.51.100.9")}, nil
}
defer func() { lookupIP = old }()
c := Conf{
PrivateKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
PublicKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=",
Address: []string{"10.8.0.2/32"},
Endpoint: "peer.example:41421",
}
ipc, err := c.ToIPC()
if err != nil {
t.Fatal(err)
}
if !stringsContains(ipc, "endpoint=198.51.100.9:41421") {
t.Fatalf("ipc missing resolved endpoint: %s", ipc)
}
}
func TestConfResolveEndpointMutates(t *testing.T) {
old := lookupIP
lookupIP = func(string) ([]net.IP, error) {
return []net.IP{net.ParseIP("198.51.100.1")}, nil
}
defer func() { lookupIP = old }()
c := Conf{Endpoint: "host.example:41421"}
if err := c.ResolveEndpoint(); err != nil {
t.Fatal(err)
}
if c.Endpoint != "198.51.100.1:41421" {
t.Fatalf("endpoint %q", c.Endpoint)
}
}
func containsAll(s string, parts ...string) bool {
for _, p := range parts {
if !stringsContains(s, p) {
return false
}
}
return true
}
func stringsContains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
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)
}
}
+28
View File
@@ -0,0 +1,28 @@
package awg
import "strings"
// Detect is also used by linknorm / netcheck.
func LooksLike(raw string) bool { return Detect(raw) }
// IsAWG20 returns true when obfuscation params typical of AWG 1.5/2.0 are present.
func (c Conf) IsAWG20() bool {
if c.I1 != "" || c.I2 != "" || c.I3 != "" || c.I4 != "" || c.I5 != "" {
return true
}
if c.Jc != 0 || c.Jmin != 0 || c.Jmax != 0 {
return true
}
if c.S1 != 0 || c.S2 != 0 || c.S3 != 0 || c.S4 != 0 {
return true
}
if c.H1 != "" || c.H2 != "" || c.H3 != "" || c.H4 != "" {
return true
}
return false
}
// StripBOM removes a UTF-8 BOM if present.
func StripBOM(s string) string {
return strings.TrimPrefix(s, "\ufeff")
}
+175
View File
@@ -0,0 +1,175 @@
package awg
import (
"context"
"fmt"
"io"
"os"
"sync"
"time"
"github.com/amnezia-vpn/amneziawg-go/conn"
"github.com/amnezia-vpn/amneziawg-go/device"
"github.com/amnezia-vpn/amneziawg-go/tun/netstack"
"vpnclient/internal/config"
)
// Engine runs AmneziaWG 2.0 in userspace (gvisor netstack) and exposes local SOCKS/HTTP.
type Engine struct {
mu sync.Mutex
dev *device.Device
tnet *netstack.Net
proxies *proxyServer
profile config.Profile
stderr io.Writer
running bool
}
func New(stderr io.Writer) *Engine {
if stderr == nil {
stderr = os.Stderr
}
return &Engine{stderr: stderr}
}
func (e *Engine) Protocol() config.Protocol { return config.ProtocolAWG }
func (e *Engine) Start(ctx context.Context, profile config.Profile, _ string) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.running {
return fmt.Errorf("awg: already running")
}
cfg, err := Parse(profile.Proxy)
if err != nil {
return err
}
// amneziawg-go IpcSet does not resolve hostnames reliably (Windows WSAHOST_NOT_FOUND).
if err := cfg.ResolveEndpoint(); err != nil {
return err
}
ipc, err := cfg.ToIPC()
if err != nil {
return err
}
addrs, err := cfg.LocalAddrs()
if err != nil {
return err
}
tunDev, tnet, err := netstack.CreateNetTUN(addrs, cfg.DNSAddrs(), cfg.EffectiveMTU())
if err != nil {
return fmt.Errorf("awg netstack: %w", err)
}
logger := &device.Logger{
Verbosef: func(format string, args ...any) {},
Errorf: func(format string, args ...any) {
fmt.Fprintf(e.stderr, "awg: "+format+"\n", args...)
},
}
dev := device.NewDevice(tunDev, conn.NewDefaultBind(), logger)
if err := dev.IpcSet(ipc); err != nil {
dev.Close()
return fmt.Errorf("awg configure: %w", err)
}
if err := dev.Up(); err != nil {
dev.Close()
return fmt.Errorf("awg up: %w", err)
}
socksHP, _ := profile.SOCKSListenHostPort()
httpHP, _ := profile.HTTPListenHostPort()
if socksHP == "" {
socksHP = "127.0.0.1:1080"
}
if httpHP == "" {
httpHP = "127.0.0.1:1081"
}
proxies, err := startProxies(socksHP, httpHP, tnet.DialContext)
if err != nil {
dev.Close()
return err
}
e.dev = dev
e.tnet = tnet
e.proxies = proxies
e.profile = profile
e.running = true
// Brief handshake grace; don't fail hard if peer is slow.
select {
case <-ctx.Done():
_ = e.stopLocked()
return ctx.Err()
case <-time.After(400 * time.Millisecond):
}
return nil
}
func (e *Engine) Stop() error {
e.mu.Lock()
defer e.mu.Unlock()
return e.stopLocked()
}
func (e *Engine) stopLocked() error {
if !e.running {
return nil
}
// Close device first so tun/netstack dials fail and relays exit promptly.
if e.dev != nil {
e.dev.Close()
e.dev = nil
}
e.tnet = nil
if e.proxies != nil {
_ = e.proxies.Close()
e.proxies = nil
}
e.running = false
return nil
}
func (e *Engine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
func (e *Engine) LocalHTTPProxy() (string, bool) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.running {
return "", false
}
if e.proxies != nil && e.proxies.httpAddr != "" {
return e.proxies.httpAddr, true
}
return e.profile.HTTPListenHostPort()
}
func (e *Engine) LocalSOCKSProxy() (string, bool) {
e.mu.Lock()
defer e.mu.Unlock()
if !e.running {
return "", false
}
if e.proxies != nil && e.proxies.socksAddr != "" {
return e.proxies.socksAddr, true
}
return e.profile.SOCKSListenHostPort()
}
// EnsureBinary is a no-op: AWG is embedded via amneziawg-go.
func EnsureBinary(_ string) (string, error) {
return "embedded-amneziawg-go", nil
}
// ResolveBinary reports the embedded core.
func ResolveBinary(_ string) (string, error) {
return "embedded-amneziawg-go", nil
}
+291
View File
@@ -0,0 +1,291 @@
package awg
import (
"bufio"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
)
// DialFunc dials TCP through the AWG userspace stack.
type DialFunc func(ctx context.Context, network, address string) (net.Conn, error)
type proxyServer struct {
mu sync.Mutex
socksLn net.Listener
httpLn net.Listener
dial DialFunc
wg sync.WaitGroup
closed bool
socksAddr string
httpAddr string
conns map[net.Conn]struct{}
}
func startProxies(socksHostPort, httpHostPort string, dial DialFunc) (*proxyServer, error) {
s := &proxyServer{
dial: dial,
conns: make(map[net.Conn]struct{}),
}
if socksHostPort != "" {
ln, err := net.Listen("tcp", socksHostPort)
if err != nil {
return nil, fmt.Errorf("awg socks listen %s: %w", socksHostPort, err)
}
s.socksLn = ln
s.socksAddr = ln.Addr().String()
s.wg.Add(1)
go s.serveSOCKS()
}
if httpHostPort != "" {
ln, err := net.Listen("tcp", httpHostPort)
if err != nil {
_ = s.Close()
return nil, fmt.Errorf("awg http listen %s: %w", httpHostPort, err)
}
s.httpLn = ln
s.httpAddr = ln.Addr().String()
s.wg.Add(1)
go s.serveHTTP()
}
return s, nil
}
func (s *proxyServer) track(c net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
_ = c.Close()
return
}
s.conns[c] = struct{}{}
}
func (s *proxyServer) untrack(c net.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.conns, c)
}
func (s *proxyServer) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
if s.socksLn != nil {
_ = s.socksLn.Close()
}
if s.httpLn != nil {
_ = s.httpLn.Close()
}
// Force-close active relays so io.Copy / dial wake up immediately.
for c := range s.conns {
_ = c.Close()
}
s.conns = nil
s.mu.Unlock()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(1500 * time.Millisecond):
// Don't hang UI / Disconnect on stuck netstack copies.
}
return nil
}
func (s *proxyServer) serveSOCKS() {
defer s.wg.Done()
for {
c, err := s.socksLn.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func(conn net.Conn) {
defer s.wg.Done()
s.track(conn)
defer s.untrack(conn)
_ = handleSOCKS(conn, s.dial)
}(c)
}
}
func (s *proxyServer) serveHTTP() {
defer s.wg.Done()
for {
c, err := s.httpLn.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func(conn net.Conn) {
defer s.wg.Done()
s.track(conn)
defer s.untrack(conn)
_ = handleHTTPProxy(conn, s.dial)
}(c)
}
}
func handleSOCKS(client net.Conn, dial DialFunc) error {
defer client.Close()
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
buf := make([]byte, 258)
if _, err := io.ReadFull(client, buf[:2]); err != nil {
return err
}
if buf[0] != 0x05 {
return fmt.Errorf("socks: not v5")
}
nmethods := int(buf[1])
if _, err := io.ReadFull(client, buf[:nmethods]); err != nil {
return err
}
if _, err := client.Write([]byte{0x05, 0x00}); err != nil {
return err
}
if _, err := io.ReadFull(client, buf[:4]); err != nil {
return err
}
if buf[0] != 0x05 || buf[1] != 0x01 {
_, _ = client.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return fmt.Errorf("socks: only CONNECT supported")
}
var host string
switch buf[3] {
case 0x01: // IPv4
if _, err := io.ReadFull(client, buf[:4]); err != nil {
return err
}
host = net.IP(buf[:4]).String()
case 0x03: // domain
if _, err := io.ReadFull(client, buf[:1]); err != nil {
return err
}
l := int(buf[0])
if _, err := io.ReadFull(client, buf[:l]); err != nil {
return err
}
host = string(buf[:l])
case 0x04: // IPv6
if _, err := io.ReadFull(client, buf[:16]); err != nil {
return err
}
host = net.IP(buf[:16]).String()
default:
_, _ = client.Write([]byte{0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return fmt.Errorf("socks: bad atyp")
}
if _, err := io.ReadFull(client, buf[:2]); err != nil {
return err
}
port := binary.BigEndian.Uint16(buf[:2])
_ = client.SetDeadline(time.Time{})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
remote, err := dial(ctx, "tcp", net.JoinHostPort(host, fmt.Sprintf("%d", port)))
if err != nil {
_, _ = client.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return err
}
defer remote.Close()
if _, err := client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil {
return err
}
return relay(client, remote)
}
func handleHTTPProxy(client net.Conn, dial DialFunc) error {
defer client.Close()
_ = client.SetDeadline(time.Now().Add(30 * time.Second))
br := bufio.NewReader(client)
req, err := http.ReadRequest(br)
if err != nil {
return err
}
_ = client.SetDeadline(time.Time{})
if strings.EqualFold(req.Method, http.MethodConnect) {
host := req.Host
if !strings.Contains(host, ":") {
host += ":443"
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
remote, err := dial(ctx, "tcp", host)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
return err
}
defer remote.Close()
if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
return err
}
return relay(client, remote)
}
// Absolute-form HTTP proxy request.
u := req.URL
host := u.Host
if host == "" {
host = req.Host
}
if host == "" {
_, _ = client.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n"))
return fmt.Errorf("http proxy: no host")
}
if !strings.Contains(host, ":") {
host += ":80"
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
remote, err := dial(ctx, "tcp", host)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n"))
return err
}
defer remote.Close()
req.RequestURI = ""
if err := req.Write(remote); err != nil {
return err
}
return relay(client, remote)
}
func relay(a, b net.Conn) error {
errc := make(chan error, 2)
copyOne := func(dst, src net.Conn) {
bufp := copyBufPool.Get().(*[]byte)
defer copyBufPool.Put(bufp)
_, err := io.CopyBuffer(dst, src, *bufp)
errc <- err
}
go copyOne(a, b)
go copyOne(b, a)
err := <-errc
_ = a.Close()
_ = b.Close()
<-errc
return err
}
var copyBufPool = sync.Pool{
New: func() any {
b := make([]byte, 32*1024)
return &b
},
}
+35
View File
@@ -0,0 +1,35 @@
package awg
import (
"strings"
"testing"
)
func TestParseUserVPNLink(t *testing.T) {
raw := `vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1`
c, err := Parse(raw)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if c.Endpoint != "ru.de4ima.uk:41421" {
t.Fatalf("endpoint %q", c.Endpoint)
}
if !strings.Contains(c.H1, "-") {
t.Fatalf("expected range H1, got %q", c.H1)
}
u, rem, err := NormalizeShareLink(raw)
if err != nil {
t.Fatalf("Normalize: %v", err)
}
t.Logf("remark=%q normalized_len=%d h1=%q", rem, len(u), c.H1)
// invisible unicode wrappers often come from mobile share / chat paste
dirty := "\u2068" + raw + "\u2069\u2005"
if !Detect(dirty) {
t.Fatal("Detect should accept dirty bidi-wrapped vpn://")
}
_, _, err = NormalizeShareLink(dirty)
if err != nil {
t.Fatalf("dirty NormalizeShareLink: %v", err)
}
}
+35 -67
View File
@@ -1,15 +1,14 @@
package hysteria2
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"vpnclient/internal/coredl"
)
const githubAPILatest = "https://api.github.com/repos/apernet/hysteria/releases/latest"
@@ -40,14 +39,22 @@ func ResolveBinary(binDir string) (string, error) {
}
func candidateNames() []string {
if runtime.GOOS == "windows" {
switch runtime.GOOS {
case "windows":
return []string{
"hysteria.exe",
"hysteria-windows-amd64.exe",
"hysteria-windows-amd64-avx.exe",
}
case "darwin":
return []string{
"hysteria",
"hysteria-darwin-arm64",
"hysteria-darwin-amd64",
}
default:
return []string{"hysteria", "hysteria-linux-amd64", "hysteria-linux-arm64"}
}
return []string{"hysteria", "hysteria-linux-amd64"}
}
// EnsureBinary downloads official Hysteria 2 release if missing.
@@ -63,7 +70,11 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
fmt.Fprintf(os.Stderr, "downloading official hysteria2 release (%s)...\n", want)
name, url, err := findReleaseAsset(want)
asset, all, err := findReleaseAsset(want)
if err != nil {
return "", err
}
sum, err := coredl.ResolveSHA(asset, all)
if err != nil {
return "", err
}
@@ -72,8 +83,8 @@ func EnsureBinary(binDir string) (string, error) {
destName = "hysteria.exe"
}
dest := filepath.Join(binDir, destName)
tmp := filepath.Join(binDir, name+".download")
if err := downloadFile(tmp, url); err != nil {
tmp := filepath.Join(binDir, asset.Name+".download")
if err := coredl.DownloadAndVerify(tmp, asset.BrowserDownloadURL, sum); err != nil {
return "", err
}
_ = os.Remove(dest)
@@ -82,6 +93,9 @@ func EnsureBinary(binDir string) (string, error) {
return "", err
}
_ = os.Chmod(dest, 0o755)
if runtime.GOOS == "darwin" {
_ = exec.Command("xattr", "-dr", "com.apple.quarantine", dest).Run()
}
return ResolveBinary(binDir)
}
@@ -102,73 +116,27 @@ func releaseAssetName() (string, error) {
}
}
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func findReleaseAsset(exactName string) (name, downloadURL string, err error) {
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, githubAPILatest, nil)
func findReleaseAsset(exactName string) (coredl.ReleaseAsset, []coredl.ReleaseAsset, error) {
_, assets, err := coredl.FetchLatestAssets(githubAPILatest)
if err != nil {
return "", "", err
return coredl.ReleaseAsset{}, 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 "", "", fmt.Errorf("github api: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", "", fmt.Errorf("github api: %s: %s", resp.Status, string(body))
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", "", err
}
// Prefer exact non-avx name; fall back to avx variant on Windows.
var fallback string
var fallbackURL string
for _, a := range rel.Assets {
var fallback coredl.ReleaseAsset
for _, a := range assets {
if a.Name == exactName {
return a.Name, a.BrowserDownloadURL, nil
return a, assets, nil
}
if exactName == "hysteria-windows-amd64.exe" && a.Name == "hysteria-windows-amd64-avx.exe" {
fallback, fallbackURL = a.Name, a.BrowserDownloadURL
fallback = a
}
}
if fallback != "" {
return fallback, fallbackURL, nil
if fallback.Name != "" {
return fallback, assets, nil
}
// Prefix match for versioned names
for _, a := range rel.Assets {
for _, a := range assets {
if strings.HasPrefix(a.Name, strings.TrimSuffix(exactName, filepath.Ext(exactName))) {
return a.Name, a.BrowserDownloadURL, nil
return a, assets, nil
}
}
return "", "", fmt.Errorf("no asset %s in release %s", exactName, rel.TagName)
}
func downloadFile(path, url string) error {
client := &http.Client{Timeout: 10 * 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, resp.Body)
return err
return coredl.ReleaseAsset{}, assets, fmt.Errorf("no asset %s in hysteria release", exactName)
}
@@ -0,0 +1,141 @@
package hysteria2
import (
"fmt"
"os"
"strings"
"vpnclient/internal/config"
)
func writeRuntimeConfig(path string, profile config.Profile) error {
ep, err := ParseEndpoint(profile.Proxy)
if err != nil {
return err
}
var insecureOverride *bool
if profile.Hy2Insecure {
v := true
insecureOverride = &v
}
ep.ApplyOverrides(profile.Hy2SNI, profile.Hy2Obfs, profile.Hy2ObfsPassword, profile.Hy2PinSHA256, insecureOverride)
socks := "127.0.0.1:1080"
httpListen := "127.0.0.1:1081"
if hp, ok := profile.SOCKSListenHostPort(); ok {
socks = hp
}
if hp, ok := profile.HTTPListenHostPort(); ok {
httpListen = hp
}
var b strings.Builder
b.WriteString("# generated by Navis — do not edit\n")
// Expanded form so congestion/obfs/tls can coexist with auth.
b.WriteString("server: " + yamlQuote(ep.Server) + "\n")
if ep.Auth != "" {
b.WriteString("auth: " + yamlQuote(ep.Auth) + "\n")
}
if ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" {
b.WriteString("tls:\n")
if ep.SNI != "" {
b.WriteString(" sni: " + yamlQuote(ep.SNI) + "\n")
}
if ep.Insecure {
b.WriteString(" insecure: true\n")
}
if ep.PinSHA256 != "" {
b.WriteString(" pinSHA256: " + yamlQuote(ep.PinSHA256) + "\n")
}
}
obfs := strings.ToLower(strings.TrimSpace(ep.Obfs))
if obfs == "" {
obfs = strings.ToLower(strings.TrimSpace(profile.Hy2Obfs))
}
obfsPass := ep.ObfsPassword
if obfsPass == "" {
obfsPass = profile.Hy2ObfsPassword
}
switch obfs {
case "salamander":
if obfsPass == "" {
return fmt.Errorf("hysteria2: salamander требует пароль (obfs-password)")
}
b.WriteString("obfs:\n")
b.WriteString(" type: salamander\n")
b.WriteString(" salamander:\n")
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
case "gecko":
if obfsPass == "" {
return fmt.Errorf("hysteria2: gecko требует пароль (obfs-password)")
}
b.WriteString("obfs:\n")
b.WriteString(" type: gecko\n")
b.WriteString(" gecko:\n")
b.WriteString(" password: " + yamlQuote(obfsPass) + "\n")
}
// Congestion: BBR (default) or reno. Bandwidth enables Brutal instead.
up := ParseBandwidthMbps(profile.Hy2BandwidthUp)
down := ParseBandwidthMbps(profile.Hy2BandwidthDown)
cong := strings.ToLower(strings.TrimSpace(profile.Hy2Congestion))
if cong == "" {
cong = "bbr"
}
bbrProfile := strings.ToLower(strings.TrimSpace(profile.Hy2BBRProfile))
if bbrProfile == "" {
bbrProfile = "standard"
}
if up != "" || down != "" {
b.WriteString("bandwidth:\n")
if up != "" {
b.WriteString(" up: " + yamlQuote(up) + "\n")
}
if down != "" {
b.WriteString(" down: " + yamlQuote(down) + "\n")
}
} else {
// No Brutal → use BBR/reno
b.WriteString("congestion:\n")
b.WriteString(" type: " + yamlQuote(cong) + "\n")
if cong == "bbr" {
b.WriteString(" bbrProfile: " + yamlQuote(bbrProfile) + "\n")
}
}
if hop := strings.TrimSpace(profile.Hy2HopInterval); hop != "" {
b.WriteString("transport:\n")
b.WriteString(" type: udp\n")
b.WriteString(" udp:\n")
b.WriteString(" hopInterval: " + yamlQuote(hop) + "\n")
}
if profile.Hy2FastOpen {
b.WriteString("fastOpen: true\n")
}
if profile.Hy2Lazy {
b.WriteString("lazy: true\n")
}
// "Masquerade extras" on client = look like normal HTTPS via SNI already above;
// optional ALPN hint kept for compatibility with some panels.
if alpn := strings.TrimSpace(profile.Hy2ALPN); alpn != "" {
// Official client uses TLS defaults; keep as comment for operators.
b.WriteString("# alpn-hint: " + yamlQuote(alpn) + "\n")
}
b.WriteString("socks5:\n")
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
b.WriteString("http:\n")
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
return writeFile(path, b.String())
}
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0o600)
}
+115
View File
@@ -0,0 +1,115 @@
package hysteria2
import (
"fmt"
"net/url"
"strconv"
"strings"
)
// Endpoint is a parsed Hysteria 2 share link plus optional overrides.
type Endpoint struct {
Server string // host:port or multi-port host
Auth string
SNI string
Insecure bool
PinSHA256 string
Obfs string // salamander | gecko
ObfsPassword string
Remark string
RawURI string
}
// ParseEndpoint parses hysteria2:// / hy2:// into discrete fields.
func ParseEndpoint(raw string) (Endpoint, error) {
normalized, remark, err := NormalizeShareLink(raw)
if err != nil {
return Endpoint{}, err
}
u, err := url.Parse(normalized)
if err != nil {
return Endpoint{}, err
}
ep := Endpoint{Remark: remark, RawURI: normalized}
if u.User != nil {
user := u.User.Username()
if pass, ok := u.User.Password(); ok {
ep.Auth = user + ":" + pass
} else {
ep.Auth, _ = url.QueryUnescape(user)
}
}
ep.Server = u.Host
if ep.Server == "" {
return Endpoint{}, fmt.Errorf("hysteria2 URI missing host")
}
q := u.Query()
ep.SNI = first(q, "sni", "peer")
ep.PinSHA256 = first(q, "pinSHA256", "pinsha256")
ep.Obfs = first(q, "obfs")
ep.ObfsPassword = first(q, "obfs-password", "obfs_password")
if v := first(q, "insecure"); v == "1" || strings.EqualFold(v, "true") {
ep.Insecure = true
}
return ep, nil
}
func first(q url.Values, keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(q.Get(k)); v != "" {
return v
}
}
return ""
}
// ApplyOverrides merges non-empty profile overrides into the endpoint.
func (ep *Endpoint) ApplyOverrides(sni, obfs, obfsPass, pin string, insecure *bool) {
if sni != "" {
ep.SNI = sni
}
if obfs != "" {
ep.Obfs = obfs
}
if obfsPass != "" {
ep.ObfsPassword = obfsPass
}
if pin != "" {
ep.PinSHA256 = pin
}
if insecure != nil {
ep.Insecure = *insecure
}
}
// BoolPtr helper for optional bool overrides.
func BoolPtr(v bool) *bool { return &v }
// HasTLSExtras reports whether discrete TLS/obfs fields should be written.
func (ep Endpoint) HasTLSExtras() bool {
return ep.SNI != "" || ep.Insecure || ep.PinSHA256 != "" || ep.Obfs != ""
}
// PortHint returns a single port for ping.
func (ep Endpoint) PortHint() string {
_, port, err := HostPort(ep.RawURI)
if err != nil || port == "" {
return "443"
}
return port
}
// ParseBandwidthMbps tries to normalize user input like "100" or "100 mbps".
func ParseBandwidthMbps(raw string) string {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" || raw == "0" || raw == "auto" || raw == "bbr" {
return ""
}
if strings.ContainsAny(raw, "bkmgt") {
return raw
}
if _, err := strconv.Atoi(raw); err == nil {
return raw + " mbps"
}
return raw
}
-25
View File
@@ -180,32 +180,7 @@ func (e *Engine) LocalSOCKSProxy() (string, bool) {
return e.profile.SOCKSListenHostPort()
}
func writeRuntimeConfig(path string, profile config.Profile) error {
server, _, err := NormalizeShareLink(profile.Proxy)
if err != nil {
return err
}
socks := "127.0.0.1:1080"
httpListen := "127.0.0.1:1081"
if hp, ok := profile.SOCKSListenHostPort(); ok {
socks = hp
}
if hp, ok := profile.HTTPListenHostPort(); ok {
httpListen = hp
}
var b strings.Builder
b.WriteString("# generated by Navis — do not edit\n")
b.WriteString("server: " + yamlQuote(server) + "\n")
b.WriteString("socks5:\n")
b.WriteString(" listen: " + yamlQuote(socks) + "\n")
b.WriteString("http:\n")
b.WriteString(" listen: " + yamlQuote(httpListen) + "\n")
return os.WriteFile(path, []byte(b.String()), 0o600)
}
func yamlQuote(s string) string {
// Always quote — URIs and host:port can confuse YAML.
escaped := strings.ReplaceAll(s, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`

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