Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6c13c933 | ||
|
|
bec6c8392d | ||
|
|
6a7480dceb | ||
|
|
54b5b87990 | ||
|
|
64c097d1e7 | ||
|
|
041cbb1250 | ||
|
|
dc700f2bac | ||
|
|
43882b95ad | ||
|
|
563edc4db8 | ||
|
|
53eb845d04 | ||
|
|
2d4a797eb1 | ||
|
|
6255a0d39b | ||
|
|
4ecfb9bed7 | ||
|
|
99bbac20e2 | ||
|
|
ffb3ef7512 | ||
|
|
a2cdad5ebe | ||
|
|
8c513f6e08 | ||
|
|
37e31ae742 | ||
|
|
b49339b9d3 | ||
|
|
573a834d1b | ||
|
|
fce99cc393 | ||
|
|
672979be4c | ||
|
|
cd0b3f4707 | ||
|
|
29643e2157 | ||
|
|
740517dbfb | ||
|
|
68a48b05ef | ||
|
|
a1e9019dcd | ||
|
|
4c2d6b8be7 | ||
|
|
f266ea8288 | ||
|
|
f7fded5b40 | ||
|
|
5d2cb6d76a |
+22
@@ -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/
|
||||
|
||||
|
||||
|
||||
@@ -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, S1–S4, H1–H4, I1–I5) или `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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+252
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
3.8.1+1
|
||||
+1
@@ -0,0 +1 @@
|
||||
3.8.1.1
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Vendored
+39
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+39
-5
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !darwin
|
||||
|
||||
package config
|
||||
|
||||
func platformNavisDir() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"vpnclient/internal/config"
|
||||
)
|
||||
|
||||
type fakeEngine struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
stopDelay time.Duration
|
||||
stops atomic.Int32
|
||||
}
|
||||
|
||||
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
|
||||
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.running = true
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Stop() error {
|
||||
e.stops.Add(1)
|
||||
if e.stopDelay > 0 {
|
||||
time.Sleep(e.stopDelay)
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.running = false
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (e *fakeEngine) Running() bool {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.running
|
||||
}
|
||||
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
|
||||
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
|
||||
|
||||
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "t",
|
||||
SystemProxy: false,
|
||||
BinDir: t.TempDir(),
|
||||
Profiles: []config.Profile{{
|
||||
Name: "t",
|
||||
Protocol: config.ProtocolNaive,
|
||||
Proxy: "https://u:p@example.com",
|
||||
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
start := time.Now()
|
||||
_ = m.Disconnect()
|
||||
// Connect must wait for Stop to finish even if Disconnect returned early...
|
||||
// our Disconnect waits up to 3s, so with 200ms it should complete.
|
||||
if time.Since(start) < 150*time.Millisecond {
|
||||
t.Fatalf("disconnect returned too fast")
|
||||
}
|
||||
if eng.Running() {
|
||||
t.Fatal("engine still running")
|
||||
}
|
||||
|
||||
// Simulate slow stop that outlives Disconnect timeout.
|
||||
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
|
||||
m.mu.Lock()
|
||||
m.engine = eng2
|
||||
m.profile = &cfg.Profiles[0]
|
||||
m.mu.Unlock()
|
||||
|
||||
// Force short path: call the wait mechanism like Connect does.
|
||||
m.mu.Lock()
|
||||
engine := m.engine
|
||||
m.engine = nil
|
||||
m.profile = nil
|
||||
m.lastStop.Add(1)
|
||||
m.mu.Unlock()
|
||||
go func() {
|
||||
defer m.lastStop.Done()
|
||||
_ = engine.Stop()
|
||||
}()
|
||||
|
||||
waitStart := time.Now()
|
||||
m.waitEngineStopped()
|
||||
if time.Since(waitStart) < 400*time.Millisecond {
|
||||
t.Fatalf("waitEngineStopped returned before stop finished")
|
||||
}
|
||||
if eng2.Running() {
|
||||
t.Fatal("engine2 still running after wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigReturnsClone(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Active: "a",
|
||||
Profiles: []config.Profile{{
|
||||
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
|
||||
}},
|
||||
}
|
||||
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := m.Config()
|
||||
snap.Active = "mutated"
|
||||
snap.Profiles[0].Proxy = "leaked"
|
||||
if m.cfg.Active != "a" {
|
||||
t.Fatal("Config() leaked mutable Active")
|
||||
}
|
||||
if m.cfg.Profiles[0].Proxy != "https://x" {
|
||||
t.Fatal("Config() leaked mutable Profile")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package coredl
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReleaseAsset is one GitHub release file (optional SHA-256 digest).
|
||||
type ReleaseAsset struct {
|
||||
Name string
|
||||
BrowserDownloadURL string
|
||||
SHA256 string // lowercase hex, empty if unknown
|
||||
}
|
||||
|
||||
type ghRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
Digest string `json:"digest"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// FetchLatestAssets loads assets for a repo's latest GitHub release.
|
||||
func FetchLatestAssets(apiLatestURL string) (tag string, assets []ReleaseAsset, err error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequest(http.MethodGet, apiLatestURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "navis-vpnclient")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("github api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return "", nil, fmt.Errorf("github api: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var rel ghRelease
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
out := make([]ReleaseAsset, 0, len(rel.Assets))
|
||||
for _, a := range rel.Assets {
|
||||
out = append(out, ReleaseAsset{
|
||||
Name: a.Name,
|
||||
BrowserDownloadURL: a.BrowserDownloadURL,
|
||||
SHA256: parseDigestSHA256(a.Digest),
|
||||
})
|
||||
}
|
||||
return rel.TagName, out, nil
|
||||
}
|
||||
|
||||
func parseDigestSHA256(digest string) string {
|
||||
digest = strings.TrimSpace(strings.ToLower(digest))
|
||||
if digest == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(digest, "sha256:") {
|
||||
return strings.TrimPrefix(digest, "sha256:")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DownloadFile writes url to path (size-capped).
|
||||
func DownloadFile(path, url string, maxBytes int64) error {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 120 << 20
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Minute}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download %s: %s", url, resp.Status)
|
||||
}
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, maxBytes))
|
||||
return err
|
||||
}
|
||||
|
||||
// FileSHA256 returns lowercase hex digest of path.
|
||||
func FileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, io.LimitReader(f, 200<<20)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifySHA256 compares file digest to want (lowercase hex). Empty want is an error.
|
||||
func VerifySHA256(path, want string) error {
|
||||
want = strings.TrimSpace(strings.ToLower(want))
|
||||
if want == "" {
|
||||
return fmt.Errorf("coredl: пустой sha256 — загрузка отклонена")
|
||||
}
|
||||
got, err := FileSHA256(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got != want {
|
||||
return fmt.Errorf("coredl: sha256 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireSHA returns sha or an error if the asset has none.
|
||||
func RequireSHA(a ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
return "", fmt.Errorf("coredl: у ассета %s нет sha256 в GitHub API — установка отклонена", a.Name)
|
||||
}
|
||||
|
||||
// ResolveSHA picks digest from the asset or companion .dgst/.sha256 assets in the same release.
|
||||
func ResolveSHA(a ReleaseAsset, all []ReleaseAsset) (string, error) {
|
||||
if a.SHA256 != "" {
|
||||
return a.SHA256, nil
|
||||
}
|
||||
companions := []string{a.Name + ".dgst", a.Name + ".sha256", "SHA256SUMS", "sha256sums"}
|
||||
for _, name := range companions {
|
||||
for _, o := range all {
|
||||
if !strings.EqualFold(o.Name, name) {
|
||||
continue
|
||||
}
|
||||
sum, err := fetchChecksumFile(o.BrowserDownloadURL, a.Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sum != "" {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return RequireSHA(a)
|
||||
}
|
||||
|
||||
func fetchChecksumFile(url, assetName string) (string, error) {
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("checksum download: %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(body)
|
||||
lowerName := strings.ToLower(assetName)
|
||||
lines := strings.Split(text, "\n")
|
||||
var shaLine string
|
||||
for _, line := range lines {
|
||||
l := strings.TrimSpace(strings.ToLower(line))
|
||||
if strings.HasPrefix(l, "sha256:") {
|
||||
shaLine = strings.TrimSpace(line[len("sha256:"):])
|
||||
// keep scanning — prefer line that also mentions the asset
|
||||
if strings.Contains(l, lowerName) {
|
||||
return strings.ToLower(strings.Fields(shaLine)[0]), nil
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
sum := strings.ToLower(fields[0])
|
||||
file := strings.TrimPrefix(fields[len(fields)-1], "*")
|
||||
if len(sum) == 64 && strings.EqualFold(file, assetName) {
|
||||
return sum, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if shaLine != "" {
|
||||
parts := strings.Fields(shaLine)
|
||||
if len(parts) > 0 && len(parts[0]) == 64 {
|
||||
return strings.ToLower(parts[0]), nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// DownloadAndVerify downloads url to path and checks sha256.
|
||||
func DownloadAndVerify(path, url, wantSHA256 string) error {
|
||||
if err := DownloadFile(path, url, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := VerifySHA256(path, wantSHA256); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package coredl
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDigestAndVerify(t *testing.T) {
|
||||
if got := parseDigestSHA256("sha256:AbC"); got != "abc" {
|
||||
t.Fatalf("parse: %q", got)
|
||||
}
|
||||
if parseDigestSHA256("md5:x") != "" {
|
||||
t.Fatal("expected empty for non-sha256")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package linknorm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SanitizeShareText strips BOM, bidi isolates and zero-width chars that break
|
||||
// scheme detection (common when pasting from messengers / Amnezia share).
|
||||
func SanitizeShareText(raw string) string {
|
||||
raw = strings.TrimPrefix(raw, "\ufeff")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch r {
|
||||
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', // zero-width / BOM
|
||||
'\u2066', '\u2067', '\u2068', '\u2069', // LRI/RLI/FSI/PDI
|
||||
'\u200e', '\u200f', // LRM/RLM
|
||||
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e': // embedding overrides
|
||||
continue
|
||||
}
|
||||
if r < 0x20 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
if unicode.Is(unicode.Cf, r) { // other format controls
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package linknorm
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeShareText_BidiWrappers(t *testing.T) {
|
||||
raw := "\u2068vpn://abc\u2069"
|
||||
got := SanitizeShareText(raw)
|
||||
if got != "vpn://abc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalize_DirtyVPN(t *testing.T) {
|
||||
raw := "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxLjEuMS4xClByaXZhdGVLZXkgPSBhSkpSYUFFY1RidWdJYjB2emFJcW5MQkZJQ0dqNlRkcXo1RC84OU13T0hZPQpKYyA9IDYKCltQZWVyXQpQdWJsaWNLZXkgPSBOUThJMVpPcUtxWmhGUXlxcXBuakRWc1poWXpkUk11Qm9tVXBzNWZPTTNzPQpFbmRwb2ludCA9IHJ1LmRlNGltYS51azo0MTQyMQpBbGxvd2VkSVBzID0gMC4wLjAuMC8w\u2069"
|
||||
// Use full user link instead for reliable parse
|
||||
raw = "\u2068vpn://W0ludGVyZmFjZV0KQWRkcmVzcyA9IDEwLjguMS4xMy8zMgpETlMgPSAxNzIuMjkuMTcyLjI1NCwgMS4wLjAuMQpQcml2YXRlS2V5ID0gYUpKUmFBRWNUYnVnSWIwdnphSXFuTEJGSUNHajZUZHF6NUQvODlNd09IWT0KTVRVID0gMTI4MApKYyA9IDYKSm1pbiA9IDEwCkptYXggPSA1MApTMSA9IDc0ClMyID0gNDcKUzMgPSA1NgpTNCA9IDMKSDEgPSA1OTA2OTcyNzktMTg4ODc3NDc4NApIMiA9IDIxMDk1MTkwNDctMjEyNDM5NTA2OQpIMyA9IDIxMzY3MjM2NDAtMjE0MTI1MTI4MwpINCA9IDIxNDcyNDg5MzYtMjE0NzM5ODY2MQoKW1BlZXJdClB1YmxpY0tleSA9IE5ROEkxWk9xS3FaaEZReXFxcG5qRFZzWmhZemRSTXVCb21VcHM1Zk9NM3M9ClByZXNoYXJlZEtleSA9IGxTVS9vd1hoNHIvU09UVndTaU5FM1BCZGVPbDdBUUpreUZlTGdMc1Q4ekk9CkFsbG93ZWRJUHMgPSAwLjAuMC4wLzAsIDo6LzAKRW5kcG9pbnQgPSBydS5kZTRpbWEudWs6NDE0MjEKUGVyc2lzdGVudEtlZXBhbGl2ZSA9IDI1\u2069"
|
||||
u, proto, _, err := Normalize("", raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if proto != "awg" {
|
||||
t.Fatalf("proto %q", proto)
|
||||
}
|
||||
if u == "" || !containsFold(u, "[Interface]") {
|
||||
t.Fatalf("normalized %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func containsFold(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
|
||||
(len(s) > 0 && (stringIndexFold(s, sub) >= 0)))
|
||||
}
|
||||
|
||||
func stringIndexFold(s, sub string) int {
|
||||
// simple contains for test
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const DefaultMax = 256 << 10 // 256 KiB
|
||||
|
||||
// Buffer is a concurrency-safe, size-capped bytes.Buffer replacement for engine stderr.
|
||||
type Buffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
max int
|
||||
}
|
||||
|
||||
func New(max int) *Buffer {
|
||||
if max <= 0 {
|
||||
max = DefaultMax
|
||||
}
|
||||
return &Buffer{max: max}
|
||||
}
|
||||
|
||||
func (b *Buffer) Write(p []byte) (int, error) {
|
||||
if b == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.buf.Len()+len(p) > b.max {
|
||||
// Drop oldest half to keep recent diagnostics.
|
||||
keep := b.buf.Bytes()
|
||||
if len(keep) > b.max/2 {
|
||||
keep = keep[len(keep)-b.max/2:]
|
||||
}
|
||||
b.buf.Reset()
|
||||
_, _ = b.buf.Write(keep)
|
||||
_, _ = b.buf.WriteString("\n… log truncated …\n")
|
||||
}
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *Buffer) String() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func (b *Buffer) Len() int {
|
||||
if b == nil {
|
||||
return 0
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Len()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package logbuf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBufferConcurrentWriteAndCap(t *testing.T) {
|
||||
b := New(64)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 40; j++ {
|
||||
_, _ = b.Write([]byte("abcdefghij"))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if b.Len() > 64+40 { // allow truncate marker overhead
|
||||
t.Fatalf("len=%d exceeds soft cap", b.Len())
|
||||
}
|
||||
s := b.String()
|
||||
if !strings.Contains(s, "abcdefghij") && !strings.Contains(s, "truncated") {
|
||||
t.Fatalf("unexpected content %q", s)
|
||||
}
|
||||
}
|
||||
+199
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user