diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..8fa8a9a
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: ci
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Test
+ run: go test ./...
+ - name: Build GUI
+ shell: bash
+ run: |
+ if [[ "${{ runner.os }}" == "Windows" ]]; then
+ go build -o Navis.exe ./cmd/vpnapp
+ else
+ go build -o Navis ./cmd/vpnapp
+ fi
+ - name: Build CLI
+ run: go build -o Navis-cli ./cmd/vpnclient
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fd2c658
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: ci
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+
+jobs:
+ test:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Test
+ run: go test ./...
+ - name: Build GUI
+ shell: bash
+ run: |
+ if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
+ go build -o Navis.exe ./cmd/vpnapp
+ else
+ go build -o Navis ./cmd/vpnapp
+ fi
+ - name: Build CLI
+ run: go build -o Navis-cli ./cmd/vpnclient
diff --git a/README.md b/README.md
index e25f3e1..5fb4bc7 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,12 @@ https://evilfox.win/
Некоторые AV (Bkav, Microsoft Wacapew/Wacatac, Ikarus Trojan.WinGo.Agent, Google, Trapmine) часто помечают **любые** неподписанные Go-бинарники с сетью и сменой системного прокси.
+В 3.8.2:
+- при неожиданном падении ядра снимается системный прокси и sentinel;
+- логи ядра и проверка туннеля (Probe) в UI; connect-on-launch и автопереподключение;
+- подписка удаляет устаревшие ноды прошлой синхронизации; «Лучший» без soft-ok UDP;
+- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии.
+
В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
- список серверов в UI не пересобирается без изменений; интервал опроса 4 с;
diff --git a/android/README.md b/android/README.md
index e701c95..e8b4add 100644
--- a/android/README.md
+++ b/android/README.md
@@ -1,8 +1,8 @@
# Navis Android
-Клиент с UI как на Windows 2.2: список нод, пинг, «лучший», автоподключение, подписка.
+Клиент с UI как на Desktop: список нод, пинг, «лучший», автоподключение, подписка.
-Версия APK: **2.3.0**
+Версия APK: **3.8.2+1** (синхронизируется с Desktop через `scripts/sync-version.py`).
## Протоколы
@@ -10,8 +10,8 @@
|----------|--------------|
| VLESS / VMess / Trojan (Xray) | SOCKS core + VpnService |
| Hysteria 2 | SOCKS core + VpnService |
-| NaiveProxy | импорт в UI; Android Chromium-бинарник не бандлится |
-| AmneziaWG | импорт в UI; полный Tun — следующий релиз |
+| NaiveProxy | импорт в UI; подключение отключено (только Desktop) |
+| AmneziaWG | импорт в UI; подключение отключено (только Desktop) |
## Сборка
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 0238a1b..42d4aba 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
minSdk = 26
targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
- versionCode = 2_070_201
- versionName = "2.7.3+1"
+ versionCode = 3080201
+ versionName = "3.8.2+1"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
diff --git a/android/app/src/main/java/win/evilfox/navis/data/Models.kt b/android/app/src/main/java/win/evilfox/navis/data/Models.kt
index 50cc61d..af9b4bd 100644
--- a/android/app/src/main/java/win/evilfox/navis/data/Models.kt
+++ b/android/app/src/main/java/win/evilfox/navis/data/Models.kt
@@ -24,6 +24,12 @@ enum class Proto(val label: String) {
}
}
}
+
+ /** Desktop has Naive/AWG; this APK only runs Xray + Hysteria2 in VpnService. */
+ fun supportedOnAndroid(): Boolean = when (this) {
+ VLESS, VMESS, TROJAN, HYSTERIA2 -> true
+ else -> false
+ }
}
data class Profile(
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt b/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt
index 990d6dd..a922fb5 100644
--- a/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt
+++ b/android/app/src/main/java/win/evilfox/navis/ui/MainActivity.kt
@@ -59,6 +59,10 @@ class MainActivity : ComponentActivity() {
vm.setMeta("Выберите сервер", true)
return
}
+ if (!p.protocol.supportedOnAndroid()) {
+ vm.setMeta("${p.protocol.label}: на Android недоступен — используйте VLESS/VMess/Trojan/Hy2", true)
+ return
+ }
prepareVpnThen {
startService(
Intent(this, NavisVpnService::class.java)
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt b/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt
index 6f58372..ca87c5f 100644
--- a/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt
+++ b/android/app/src/main/java/win/evilfox/navis/ui/MainViewModel.kt
@@ -139,12 +139,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
val rows = store.pingAll(profiles)
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
- val best = store.bestOf(rows) ?: run {
- setMeta("нет доступных серверов", true)
+ val supported = profiles.filter { it.protocol.supportedOnAndroid() }
+ val supportedRows = rows.filter { r -> supported.any { it.id == r.id } }
+ val best = store.bestOf(supportedRows) ?: run {
+ setMeta("нет доступных серверов (Android: VLESS/VMess/Trojan/Hy2)", true)
return
}
store.setActiveId(best.id)
val profile = profiles.first { it.id == best.id }
+ if (!profile.protocol.supportedOnAndroid()) {
+ setMeta("${profile.protocol.label}: на Android недоступен", true)
+ return
+ }
_state.update { it.copy(activeId = best.id) }
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
if (connect && prepare != null) {
diff --git a/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt b/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt
index f41253a..0068405 100644
--- a/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt
+++ b/android/app/src/main/java/win/evilfox/navis/ui/NavisScreen.kt
@@ -100,7 +100,7 @@ fun NavisScreen(
Text("2.6.1", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
}
}
- Text("Android · Naive · Hy2 · AWG · Xray", color = Muted, fontSize = 13.sp)
+ Text("Android · Xray · Hy2 (Naive/AWG — только Desktop)", color = Muted, fontSize = 13.sp)
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
diff --git a/build-macos.bat b/build-macos.bat
index ad3771e..5747167 100644
--- a/build-macos.bat
+++ b/build-macos.bat
@@ -34,11 +34,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1
-tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.1 -build 3.8.1.1 -arch arm64
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.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
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.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
+tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.1 -arch universal
if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI:
diff --git a/cmd/vpnapp/main_darwin.go b/cmd/vpnapp/main_darwin.go
index 6a5e091..54a434f 100644
--- a/cmd/vpnapp/main_darwin.go
+++ b/cmd/vpnapp/main_darwin.go
@@ -19,6 +19,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
+ "vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
@@ -56,6 +57,7 @@ func main() {
a.OnAfterUpdate = func() {
go func() {
time.Sleep(300 * time.Millisecond)
+ _ = a.Disconnect()
os.Exit(0)
}()
}
@@ -69,6 +71,16 @@ func main() {
go func() {
_ = srv.Serve(ln)
}()
+ a.StartBackground()
+ trayhost.Start("Navis", trayhost.Hooks{
+ Connect: func() { _ = a.Connect() },
+ Disconnect: func() { _ = a.Disconnect() },
+ Quit: func() {
+ _ = a.Disconnect()
+ os.Exit(0)
+ },
+ IsUp: func() bool { return a.Mgr.Status().Connected },
+ })
go a.AutoCheckUpdate()
w, err := glaze.New(false)
diff --git a/cmd/vpnapp/main_windows.go b/cmd/vpnapp/main_windows.go
index ffa0cb7..8bab6c0 100644
--- a/cmd/vpnapp/main_windows.go
+++ b/cmd/vpnapp/main_windows.go
@@ -19,6 +19,7 @@ import (
"vpnclient/internal/config"
"vpnclient/internal/core"
"vpnclient/internal/logbuf"
+ "vpnclient/internal/trayhost"
"vpnclient/internal/update"
)
@@ -58,6 +59,7 @@ func main() {
// Hard-exit so Windows unlocks Navis.exe; do not wait on webview.Terminate.
go func() {
time.Sleep(300 * time.Millisecond)
+ _ = a.Disconnect()
os.Exit(0)
}()
}
@@ -105,6 +107,20 @@ func main() {
mustBind(w, "applyUpdate", a.ApplyUpdate)
mustBind(w, "saveHy2", a.SaveHy2)
mustBind(w, "importSubscription", a.ImportSubscription)
+ mustBind(w, "getLogs", a.GetLogs)
+ mustBind(w, "probeTunnel", a.ProbeTunnel)
+ mustBind(w, "savePrefs", a.SavePrefs)
+
+ a.StartBackground()
+ trayhost.Start("Navis", trayhost.Hooks{
+ Connect: func() { _ = a.Connect() },
+ Disconnect: func() { _ = a.Disconnect() },
+ Quit: func() {
+ _ = a.Disconnect()
+ os.Exit(0)
+ },
+ IsUp: func() bool { return a.Mgr.Status().Connected },
+ })
go a.AutoCheckUpdate()
diff --git a/dist/navis-release/darwin-arm64/Navis b/dist/navis-release/darwin-arm64/Navis
index 9627cd8..781c8ac 100755
Binary files a/dist/navis-release/darwin-arm64/Navis and b/dist/navis-release/darwin-arm64/Navis differ
diff --git a/dist/navis-release/darwin-arm64/Navis-cli b/dist/navis-release/darwin-arm64/Navis-cli
index cbe97d3..616cb37 100755
Binary files a/dist/navis-release/darwin-arm64/Navis-cli and b/dist/navis-release/darwin-arm64/Navis-cli differ
diff --git a/dist/navis-release/darwin-arm64/Navis.app.zip b/dist/navis-release/darwin-arm64/Navis.app.zip
index 769c57c..a0eb275 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.app.zip and b/dist/navis-release/darwin-arm64/Navis.app.zip differ
diff --git a/dist/navis-release/darwin-arm64/Navis.dmg b/dist/navis-release/darwin-arm64/Navis.dmg
index 717bd22..e854b8d 100644
Binary files a/dist/navis-release/darwin-arm64/Navis.dmg and b/dist/navis-release/darwin-arm64/Navis.dmg differ
diff --git a/dist/navis-release/darwin-arm64/Navis.version b/dist/navis-release/darwin-arm64/Navis.version
index e9781d3..da40605 100644
--- a/dist/navis-release/darwin-arm64/Navis.version
+++ b/dist/navis-release/darwin-arm64/Navis.version
@@ -1 +1 @@
-3.8.1+1
+3.8.2+1
diff --git a/dist/navis-release/darwin-arm64/VERSION b/dist/navis-release/darwin-arm64/VERSION
index d37c27a..b70b377 100644
--- a/dist/navis-release/darwin-arm64/VERSION
+++ b/dist/navis-release/darwin-arm64/VERSION
@@ -1 +1 @@
-3.8.1.1
+3.8.2.1
diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json
index 1e4818b..ad82b71 100644
--- a/dist/navis-release/update.json
+++ b/dist/navis-release/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.1",
- "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
+ "version": "3.8.2",
+ "notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
+ "sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"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"
+ "zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
+ "dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -30,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
+ "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
+ "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -38,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
+ "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
+ "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
diff --git a/dist/update.json b/dist/update.json
index 1e4818b..ad82b71 100644
--- a/dist/update.json
+++ b/dist/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.1",
- "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
+ "version": "3.8.2",
+ "notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
+ "sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"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"
+ "zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
+ "dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -30,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
+ "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
+ "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -38,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
+ "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
+ "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
diff --git a/docs/signing.md b/docs/signing.md
index 8654774..3b98be7 100644
--- a/docs/signing.md
+++ b/docs/signing.md
@@ -28,6 +28,15 @@ export NAVIS_NOTARY_PROFILE="navis-notary"
Without these env vars, packmac uses **ad-hoc** signing (`-`) — fine for local/dev.
+For a **release gate** (fail if unsigned):
+
+```bash
+export REQUIRE_CODESIGN=1
+export NAVIS_CODESIGN_IDENTITY="Developer ID Application: Example Ltd (TEAMID)"
+./scripts/build-macos-arm64.sh
+```
+
+In-app macOS updates **do not** re-sign with ad-hoc `-` (that would strip Developer ID). They only re-sign when `NAVIS_CODESIGN_IDENTITY` is set to a real identity.
## Windows
Sign `Navis.exe` with Authenticode (EV or standard code signing cert):
diff --git a/internal/apphost/app.go b/internal/apphost/app.go
index e779194..47e8668 100644
--- a/internal/apphost/app.go
+++ b/internal/apphost/app.go
@@ -41,23 +41,27 @@ type App struct {
}
type UIState struct {
- Connected bool `json:"connected"`
- Profile string `json:"profile,omitempty"`
- ActiveProfile string `json:"active_profile,omitempty"`
- Protocol string `json:"protocol,omitempty"`
- HTTPProxy string `json:"http_proxy,omitempty"`
- SOCKSProxy string `json:"socks_proxy,omitempty"`
- SystemProxy bool `json:"system_proxy"`
- Proxy string `json:"proxy"`
- CoreReady bool `json:"core_ready"`
- CorePath string `json:"core_path,omitempty"`
- ConfigPath string `json:"config_path"`
- Profiles []config.ProfileInfo `json:"profiles"`
- Version string `json:"version"`
- Update update.Status `json:"update"`
- Pings []netcheck.Result `json:"pings"`
- Subscription string `json:"subscription_url"`
- Hy2 core.Hy2Options `json:"hy2"`
+ Connected bool `json:"connected"`
+ Profile string `json:"profile,omitempty"`
+ ActiveProfile string `json:"active_profile,omitempty"`
+ Protocol string `json:"protocol,omitempty"`
+ HTTPProxy string `json:"http_proxy,omitempty"`
+ SOCKSProxy string `json:"socks_proxy,omitempty"`
+ SystemProxy bool `json:"system_proxy"`
+ Proxy string `json:"proxy"`
+ CoreReady bool `json:"core_ready"`
+ CorePath string `json:"core_path,omitempty"`
+ ConfigPath string `json:"config_path"`
+ Profiles []config.ProfileInfo `json:"profiles"`
+ Version string `json:"version"`
+ Update update.Status `json:"update"`
+ Pings []netcheck.Result `json:"pings"`
+ Subscription string `json:"subscription_url"`
+ Hy2 core.Hy2Options `json:"hy2"`
+ LastError string `json:"last_error,omitempty"`
+ ConnectOnLaunch bool `json:"connect_on_launch"`
+ AutoReconnect bool `json:"auto_reconnect"`
+ LogTail string `json:"log_tail,omitempty"`
}
type PingBestResult struct {
@@ -117,23 +121,29 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
}
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,
+ Connected: st.Connected,
+ Profile: st.Profile,
+ ActiveProfile: poll.Active,
+ Protocol: string(st.Protocol),
+ HTTPProxy: st.HTTPProxy,
+ SOCKSProxy: st.SOCKSProxy,
+ SystemProxy: poll.SystemProxy,
+ Proxy: proxy,
+ CoreReady: coreReady,
+ CorePath: corePath,
+ ConfigPath: cfgPath,
+ Profiles: profiles,
+ Version: update.DisplayVersion(),
+ Update: upd,
+ Pings: pings,
+ Subscription: poll.Subscription,
+ Hy2: hy2,
+ LastError: poll.LastError,
+ ConnectOnLaunch: poll.ConnectOnLaunch,
+ AutoReconnect: poll.AutoReconnect,
+ }
+ if includeSecrets && a.LogBuf != nil {
+ out.LogTail = trimLogTail(a.LogBuf.String(), 4000)
}
if out.Protocol == "" {
out.Protocol = string(poll.ActiveProtocol)
@@ -144,6 +154,13 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
return out, nil
}
+func trimLogTail(s string, max int) string {
+ if max <= 0 || len(s) <= max {
+ return s
+ }
+ return "…\n" + s[len(s)-max:]
+}
+
func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready bool) {
key := corebin.CacheKey(binDir, corebin.ProtoKey(string(proto)))
var err error
@@ -229,19 +246,93 @@ func (a *App) ConnectProfile(name string) error {
}
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
+ var last error
+ for attempt := 0; attempt < 2; attempt++ {
+ if _, err := a.Mgr.EnsureCore(""); err != nil {
+ a.Mgr.SetLastError(err.Error())
+ return err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
+ err := a.Mgr.Connect(ctx, "")
+ cancel()
+ if err != nil {
+ a.Mgr.SetLastError(err.Error())
+ last = err
+ continue
+ }
+ pctx, pcancel := context.WithTimeout(context.Background(), 12*time.Second)
+ perr := a.Mgr.Probe(pctx, "")
+ pcancel()
+ if perr == nil {
+ return nil
+ }
+ last = fmt.Errorf("туннель не прошёл проверку: %w", perr)
+ a.Mgr.SetLastError(last.Error())
+ _ = a.Mgr.Disconnect()
+ time.Sleep(400 * time.Millisecond)
}
- ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
- defer cancel()
- return a.Mgr.Connect(ctx, "")
+ return last
}
func (a *App) Disconnect() error {
return a.Mgr.Disconnect()
}
+func (a *App) GetLogs() string {
+ if a.LogBuf == nil {
+ return ""
+ }
+ return trimLogTail(a.LogBuf.String(), 12000)
+}
+
+func (a *App) ProbeTunnel() error {
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+ err := a.Mgr.Probe(ctx, "")
+ if err != nil {
+ a.Mgr.SetLastError(err.Error())
+ }
+ return err
+}
+
+func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error {
+ return a.Mgr.SavePrefs(core.Prefs{
+ ConnectOnLaunch: connectOnLaunch,
+ AutoReconnect: autoReconnect,
+ SystemProxy: systemProxy,
+ })
+}
+
+// StartBackground runs reconnect-on-crash and optional connect-on-launch.
+func (a *App) StartBackground() {
+ go a.watchdogLoop()
+ prefs := a.Mgr.Prefs()
+ if prefs.ConnectOnLaunch {
+ go func() {
+ time.Sleep(1200 * time.Millisecond)
+ if a.Mgr.Status().Connected {
+ return
+ }
+ _ = a.Connect()
+ }()
+ }
+}
+
+func (a *App) watchdogLoop() {
+ for {
+ time.Sleep(2 * time.Second)
+ if !a.Mgr.TakeUnexpectedDrop() {
+ continue
+ }
+ prefs := a.Mgr.Prefs()
+ if !prefs.AutoReconnect {
+ continue
+ }
+ time.Sleep(800 * time.Millisecond)
+ _ = a.Connect()
+ }
+}
+
func (a *App) InstallCore() (string, error) {
paths, err := a.Mgr.EnsureAllCores()
if err != nil {
@@ -316,12 +407,7 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) {
}
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 {
+ if err := a.ConnectProfile(""); err != nil {
return out, err
}
out.Connected = true
@@ -527,6 +613,12 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
return nil, a.SaveHy2(opts)
case "importSubscription":
return a.ImportSubscription(arg(args, 0, ""))
+ case "getLogs":
+ return a.GetLogs(), nil
+ case "probeTunnel":
+ return nil, a.ProbeTunnel()
+ case "savePrefs":
+ return nil, a.SavePrefs(arg(args, 0, false), arg(args, 1, false), arg(args, 2, true))
case "quit":
go func() {
time.Sleep(200 * time.Millisecond)
diff --git a/internal/appui/app.css b/internal/appui/app.css
index c2f0e79..04b14d8 100644
--- a/internal/appui/app.css
+++ b/internal/appui/app.css
@@ -801,3 +801,18 @@
.tools { grid-template-columns: 1fr; }
}
+
+ .log-tail {
+ max-height: 160px;
+ overflow: auto;
+ font-size: .72rem;
+ line-height: 1.35;
+ white-space: pre-wrap;
+ word-break: break-word;
+ background: var(--panel-bg, rgba(0,0,0,.04));
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 10px 12px;
+ margin: 8px 0;
+ }
+ .row-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
diff --git a/internal/appui/app.js b/internal/appui/app.js
index f16876d..06f7730 100644
--- a/internal/appui/app.js
+++ b/internal/appui/app.js
@@ -3,7 +3,7 @@
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
"selectProfile","deleteProfile","installCore","openURL","pingServers","pingBest",
- "checkUpdate","applyUpdate","saveHy2","importSubscription","quit"
+ "checkUpdate","applyUpdate","saveHy2","importSubscription","getLogs","probeTunnel","savePrefs","quit"
];
if (typeof window.getState === "function") return;
window.__navisHttp = true;
@@ -36,6 +36,11 @@
const proxy = $("proxy");
const nameInput = $("name");
const sysproxy = $("sysproxy");
+ const bootConnect = $("bootConnect");
+ const autoReconnect = $("autoReconnect");
+ const logTail = $("logTail");
+ const logRefreshBtn = $("logRefreshBtn");
+ const probeBtn = $("probeBtn");
const profile = $("profile");
const dot = $("dot");
const statusText = $("statusText");
@@ -213,7 +218,7 @@
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 || ""));
+ parts.push(p.name + "|" + (p.ok ? 1 : 0) + "|" + (p.soft ? 1 : 0) + "|" + (p.ms || 0) + "|" + (p.error || ""));
});
return parts.join("\n");
}
@@ -282,7 +287,8 @@
const pr = pingMap[p.name];
if (pr && pr.ok) {
ms.className = "ms " + msClass(pr.ms, true);
- ms.textContent = pr.ms + " ms";
+ ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms";
+ if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»";
} else if (pr && pr.error) {
ms.className = "ms bad";
ms.textContent = "—";
@@ -363,6 +369,10 @@
proxy.disabled = lock;
nameInput.disabled = lock;
sysproxy.disabled = lock;
+ if (bootConnect) bootConnect.disabled = busy;
+ if (autoReconnect) autoReconnect.disabled = busy;
+ if (probeBtn) probeBtn.disabled = busy || !connected;
+ if (logRefreshBtn) logRefreshBtn.disabled = busy;
profile.disabled = lock;
addBtn.disabled = lock;
delBtn.disabled = lock || ((state.profiles || profiles).length <= 1);
@@ -392,6 +402,8 @@
nameInput.value = active.name || state.active_profile || "";
proxy.value = typeof state.proxy === "string" ? state.proxy : (active.proxy || "");
if (typeof state.system_proxy === "boolean") sysproxy.checked = state.system_proxy;
+ if (bootConnect && typeof state.connect_on_launch === "boolean") bootConnect.checked = state.connect_on_launch;
+ if (autoReconnect && typeof state.auto_reconnect === "boolean") autoReconnect.checked = state.auto_reconnect;
fillHy2(state.hy2);
formHydrated = true;
if (syncForm) dirty = false;
@@ -420,11 +432,17 @@
heroHint.textContent = n > 1 ? "Клик — выбрать, двойной клик — подключить" : "Выберите сервер и нажмите Подключить";
}
if (!busy && Date.now() > metaHoldUntil) {
- setMeta(detail, state.core_ready === false ? "err" : "");
+ if (!connected && state.last_error) setMeta(state.last_error, "err");
+ else setMeta(detail, state.core_ready === false ? "err" : "");
metaHoldUntil = 0;
}
}
+ async function persistPrefs() {
+ if (typeof savePrefs !== "function") return;
+ await savePrefs(!!(bootConnect && bootConnect.checked), !!(autoReconnect && autoReconnect.checked), !!sysproxy.checked);
+ }
+
async function refresh(opts) {
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state;
@@ -653,6 +671,24 @@
setMeta("Версия " + (latest || "?") + " пропущена. Следующую предложим.", "ok");
});
+ if (sysproxy) sysproxy.addEventListener("change", () => { persistPrefs().catch(() => {}); dirty = true; });
+ if (bootConnect) bootConnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
+ if (autoReconnect) autoReconnect.addEventListener("change", () => { persistPrefs().catch(() => {}); });
+ if (logRefreshBtn) logRefreshBtn.addEventListener("click", () => withBusy(async () => {
+ try {
+ const t = await getLogs();
+ if (logTail) logTail.textContent = t || "—";
+ setMeta("Лог обновлён", "ok");
+ } catch (e) { setMeta(fmtErr(e), "err"); }
+ }));
+ if (probeBtn) probeBtn.addEventListener("click", () => withBusy(async () => {
+ try {
+ setMeta("Проверка туннеля…");
+ await probeTunnel();
+ setMeta("Туннель OK", "ok");
+ } catch (e) { setMeta(fmtErr(e), "err"); }
+ }));
+
(async () => {
try {
await refresh({ syncForm: true });
diff --git a/internal/appui/index.html b/internal/appui/index.html
index 783722d..eb5bb93 100644
--- a/internal/appui/index.html
+++ b/internal/appui/index.html
@@ -46,7 +46,7 @@
Navis
- 3.8.1
+ 3.8.2
Быстрый клиент · Naive · Hy2 · AWG · Xray
@@ -122,6 +122,29 @@
+
+ Подключать при запуске
+
+
+
+ Автопереподключение
+
+
+
+
+ Логи ядра
+ —
+
+
+
+
+
Hysteria 2 · BBR / obfuscation
diff --git a/internal/config/config.go b/internal/config/config.go
index 22869db..1d3e273 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -35,6 +35,15 @@ type Config struct {
// SubscriptionURL pulls share links (one per line or base64) and imports profiles.
SubscriptionURL string `json:"subscription_url,omitempty"`
+ // SubscriptionNames are profile names last imported from SubscriptionURL (for prune).
+ SubscriptionNames []string `json:"subscription_names,omitempty"`
+
+ // ConnectOnLaunch connects the active profile when the GUI starts.
+ ConnectOnLaunch bool `json:"connect_on_launch,omitempty"`
+
+ // AutoReconnect reconnects after an unexpected core crash.
+ AutoReconnect bool `json:"auto_reconnect,omitempty"`
+
Profiles []Profile `json:"profiles"`
}
diff --git a/internal/core/manager.go b/internal/core/manager.go
index 3732b15..56bbb52 100644
--- a/internal/core/manager.go
+++ b/internal/core/manager.go
@@ -24,7 +24,7 @@ import (
"vpnclient/internal/sysproxy"
)
-// Manager orchestrates protocol engines and Windows system proxy.
+// Manager orchestrates protocol engines and OS system proxy.
type Manager struct {
mu sync.Mutex
cfgPath string
@@ -36,6 +36,10 @@ type Manager struct {
binDir string
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
lastStop sync.WaitGroup
+
+ lastError string
+ unexpectedDrop bool
+ watchGeneration uint64
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -113,15 +117,16 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
}
m.mu.Lock()
- defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
_ = engine.Stop()
+ m.mu.Unlock()
return fmt.Errorf("already connected; disconnect first")
}
if wantSysProxy {
httpProxy, ok := engine.LocalHTTPProxy()
if !ok {
+ m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("system_proxy requires an http:// listen address in the profile")
}
@@ -129,6 +134,7 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
if errors.Is(err, sysproxy.ErrUnsupported) {
fmt.Fprintf(stderr, "system proxy unsupported on this OS; local SOCKS/HTTP still up\n")
} else {
+ m.mu.Unlock()
_ = engine.Stop()
return fmt.Errorf("enable system proxy: %w", err)
}
@@ -139,15 +145,81 @@ func (m *Manager) Connect(ctx context.Context, profileName string) error {
m.engine = engine
m.profile = &profileCopy
+ m.lastError = ""
+ m.unexpectedDrop = false
+ m.watchGeneration++
+ gen := m.watchGeneration
+ m.mu.Unlock()
+ go m.watchEngine(gen, engine)
return nil
}
+// LastError returns the last connection / core failure message.
+func (m *Manager) LastError() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.lastError
+}
+
+// SetLastError stores a UI-facing error string.
+func (m *Manager) SetLastError(msg string) {
+ m.mu.Lock()
+ m.lastError = strings.TrimSpace(msg)
+ m.mu.Unlock()
+}
+
+// TakeUnexpectedDrop reports and clears a crash-driven disconnect.
+func (m *Manager) TakeUnexpectedDrop() bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if !m.unexpectedDrop {
+ return false
+ }
+ m.unexpectedDrop = false
+ return true
+}
+
+func (m *Manager) watchEngine(gen uint64, eng Engine) {
+ if eng == nil {
+ return
+ }
+ for {
+ time.Sleep(800 * time.Millisecond)
+ m.mu.Lock()
+ if m.watchGeneration != gen || m.engine != eng {
+ m.mu.Unlock()
+ return
+ }
+ if eng.Running() {
+ m.mu.Unlock()
+ continue
+ }
+ // Core died without Disconnect().
+ m.engine = nil
+ m.profile = nil
+ m.lastError = "процесс ядра завершился неожиданно"
+ m.unexpectedDrop = true
+ sys := m.sys
+ cfgPath := m.cfgPath
+ m.mu.Unlock()
+
+ if sys != nil && (sys.Enabled() || sysproxy.HasSentinel(cfgPath)) {
+ _ = sys.ForceDisable()
+ sysproxy.ClearSentinel(cfgPath)
+ fmt.Fprintf(m.stderr, "core exited: system proxy cleared\n")
+ }
+ return
+ }
+}
+
func (m *Manager) Disconnect() error {
m.mu.Lock()
sys := m.sys
engine := m.engine
m.engine = nil
m.profile = nil
+ m.watchGeneration++ // stop watcher
+ m.unexpectedDrop = false
if engine != nil {
m.lastStop.Add(1)
}
@@ -280,15 +352,18 @@ func (m *Manager) Config() *config.Config {
// 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
+ Status Status
+ Active string
+ SystemProxy bool
+ Subscription string
+ ActiveProxy string
+ ActiveProtocol config.Protocol
+ Profiles []config.ProfileInfo
+ Hy2 Hy2Options
+ BinDir string
+ LastError string
+ ConnectOnLaunch bool
+ AutoReconnect bool
}
// PollUI gathers status + profile list under one lock without Config().Clone().
@@ -302,11 +377,14 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
+ LastError: m.lastError,
}
if m.cfg != nil {
out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy
out.Subscription = m.cfg.SubscriptionURL
+ out.ConnectOnLaunch = m.cfg.ConnectOnLaunch
+ out.AutoReconnect = m.cfg.AutoReconnect
if includeSecrets {
out.Profiles = m.cfg.ListProfiles()
} else {
@@ -354,6 +432,38 @@ func (m *Manager) SetSystemProxy(enabled bool) {
m.cfg.SystemProxy = enabled
}
+// Prefs are GUI toggles persisted in config.json.
+type Prefs struct {
+ ConnectOnLaunch bool `json:"connect_on_launch"`
+ AutoReconnect bool `json:"auto_reconnect"`
+ SystemProxy bool `json:"system_proxy"`
+}
+
+func (m *Manager) Prefs() Prefs {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.cfg == nil {
+ return Prefs{}
+ }
+ return Prefs{
+ ConnectOnLaunch: m.cfg.ConnectOnLaunch,
+ AutoReconnect: m.cfg.AutoReconnect,
+ SystemProxy: m.cfg.SystemProxy,
+ }
+}
+
+func (m *Manager) SavePrefs(p Prefs) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.cfg == nil {
+ return fmt.Errorf("no config")
+ }
+ m.cfg.ConnectOnLaunch = p.ConnectOnLaunch
+ m.cfg.AutoReconnect = p.AutoReconnect
+ m.cfg.SystemProxy = p.SystemProxy
+ return config.Save(m.cfgPath, *m.cfg)
+}
+
func (m *Manager) UpdateProxyURI(proxy string) error {
m.mu.Lock()
defer m.mu.Unlock()
@@ -576,20 +686,69 @@ func (m *Manager) ImportSubscription(rawURL string) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
+ if m.engine != nil && m.engine.Running() {
+ return 0, fmt.Errorf("сначала отключитесь")
+ }
m.cfg.SubscriptionURL = strings.TrimSpace(rawURL)
+
+ newNames := make([]string, 0, len(items))
+ seen := map[string]struct{}{}
for _, it := range items {
- _ = m.cfg.UpsertProfileWithProtocol(it.Name, it.URI, it.Protocol)
+ name := strings.TrimSpace(it.Name)
+ if name == "" {
+ continue
+ }
+ _ = m.cfg.UpsertProfileKeepActive(name, it.URI, it.Protocol)
for i := range m.cfg.Profiles {
- if m.cfg.Profiles[i].Name == it.Name {
+ if m.cfg.Profiles[i].Name == name {
hysteria2.EnrichProfile(&m.cfg.Profiles[i])
break
}
}
+ if _, ok := seen[name]; !ok {
+ seen[name] = struct{}{}
+ newNames = append(newNames, name)
+ }
}
+
+ prev := map[string]struct{}{}
+ for _, n := range m.cfg.SubscriptionNames {
+ prev[n] = struct{}{}
+ }
+ // Prune profiles that came from the previous subscription sync but are gone now.
+ if len(prev) > 0 {
+ kept := make([]config.Profile, 0, len(m.cfg.Profiles))
+ for _, p := range m.cfg.Profiles {
+ if _, wasSub := prev[p.Name]; wasSub {
+ if _, still := seen[p.Name]; !still {
+ continue
+ }
+ }
+ kept = append(kept, p)
+ }
+ if len(kept) == 0 {
+ // Safety: never wipe all profiles.
+ kept = append([]config.Profile(nil), m.cfg.Profiles...)
+ } else {
+ m.cfg.Profiles = kept
+ }
+ activeOK := false
+ for _, p := range m.cfg.Profiles {
+ if p.Name == m.cfg.Active {
+ activeOK = true
+ break
+ }
+ }
+ if !activeOK && len(m.cfg.Profiles) > 0 {
+ m.cfg.Active = m.cfg.Profiles[0].Name
+ }
+ }
+ m.cfg.SubscriptionNames = newNames
+
if err := config.Save(m.cfgPath, *m.cfg); err != nil {
return 0, err
}
- return len(items), nil
+ return len(newNames), nil
}
func (m *Manager) SaveConfig() error {
diff --git a/internal/netcheck/ping.go b/internal/netcheck/ping.go
index 96540fa..f1bf8dd 100644
--- a/internal/netcheck/ping.go
+++ b/internal/netcheck/ping.go
@@ -24,6 +24,7 @@ type Result struct {
Port string `json:"port"`
Ms int64 `json:"ms"`
OK bool `json:"ok"`
+ Soft bool `json:"soft,omitempty"` // UDP soft-up (timeout, no ICMP refuse)
Error string `json:"error,omitempty"`
}
@@ -90,12 +91,13 @@ func PingAll(ctx context.Context, targets []Target) []Result {
return sorted
}
-// BestOK returns the lowest-latency successful result, if any.
+// BestOK returns the lowest-latency hard OK result (excludes UDP soft-up).
+// Soft-up nodes stay visible in the list but are not chosen as «Лучший».
func BestOK(results []Result) (Result, bool) {
var best Result
found := false
for _, r := range results {
- if !r.OK {
+ if !r.OK || r.Soft {
continue
}
if !found || r.Ms < best.Ms {
@@ -186,11 +188,12 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
}
var (
- rtt time.Duration
- err error
+ rtt time.Duration
+ soft bool
+ err error
)
if hy2 || isAWG {
- rtt, err = probeUDP(ctx, host, port)
+ rtt, soft, err = probeUDP(ctx, host, port)
} else {
rtt, err = probeTCP(ctx, host, port)
}
@@ -204,6 +207,7 @@ func PingProfile(ctx context.Context, name string, proto config.Protocol, proxyU
}
res.Ms = ms
res.OK = true
+ res.Soft = soft
return res
}
@@ -223,18 +227,17 @@ func probeTCP(ctx context.Context, host, port string) (time.Duration, error) {
// 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) {
+// - any reply → hard up (RTT to reply)
+// - short read-timeout after a successful write → soft-up (display only; not for Best)
+func probeUDP(ctx context.Context, host, port string) (time.Duration, bool, error) {
start := time.Now()
d := net.Dialer{Timeout: 3 * time.Second}
conn, err := d.DialContext(ctx, "udp", net.JoinHostPort(host, port))
if err != nil {
- return 0, err
+ return 0, false, 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) {
@@ -243,20 +246,19 @@ func probeUDP(ctx context.Context, host, port string) (time.Duration, error) {
_ = conn.SetDeadline(deadline)
if _, err := conn.Write([]byte{0}); err != nil {
- return 0, err
+ return 0, false, err
}
afterWrite := time.Now()
buf := make([]byte, 64)
_, err = conn.Read(buf)
if err == nil {
- return time.Since(start), nil
+ return time.Since(start), false, 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 afterWrite.Sub(start), true, nil
}
- return 0, err
+ return 0, false, err
}
func friendlyDialError(err error, udp bool) string {
diff --git a/internal/netcheck/ping_test.go b/internal/netcheck/ping_test.go
index 28330d6..d0bde36 100644
--- a/internal/netcheck/ping_test.go
+++ b/internal/netcheck/ping_test.go
@@ -47,10 +47,13 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
start := time.Now()
- rtt, err := probeUDP(ctx, "127.0.0.1", port)
+ rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatalf("expected soft-ok, got err=%v", err)
}
+ if !soft {
+ t.Fatal("expected soft=true")
+ }
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
@@ -59,12 +62,15 @@ func TestProbeUDPSoftOKWhenSilent(t *testing.T) {
}
res := PingProfile(ctx, "silent", config.ProtocolHysteria2, "hysteria2://x@127.0.0.1:"+port+"/")
- if !res.OK {
+ if !res.OK || !res.Soft {
t.Fatalf("PingProfile soft-ok failed: %+v", res)
}
if res.Ms < 1 {
t.Fatalf("ms=%d", res.Ms)
}
+ if _, ok := BestOK([]Result{res}); ok {
+ t.Fatal("BestOK must ignore soft-up")
+ }
}
func TestProbeUDPReplyOK(t *testing.T) {
@@ -87,10 +93,13 @@ func TestProbeUDPReplyOK(t *testing.T) {
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
- rtt, err := probeUDP(ctx, "127.0.0.1", port)
+ rtt, soft, err := probeUDP(ctx, "127.0.0.1", port)
if err != nil {
t.Fatal(err)
}
+ if soft {
+ t.Fatal("reply should be hard-ok")
+ }
if rtt <= 0 {
t.Fatalf("rtt=%v", rtt)
}
diff --git a/internal/trayhost/tray.go b/internal/trayhost/tray.go
new file mode 100644
index 0000000..ff8e459
--- /dev/null
+++ b/internal/trayhost/tray.go
@@ -0,0 +1,14 @@
+package trayhost
+
+// Hooks lets the tray menu drive the VPN app without importing apphost (avoids cycles).
+type Hooks struct {
+ Connect func()
+ Disconnect func()
+ Quit func()
+ IsUp func() bool
+}
+
+// Start runs a platform tray if available. Returns false when unsupported.
+func Start(appName string, h Hooks) bool {
+ return start(appName, h)
+}
diff --git a/internal/trayhost/tray_stub.go b/internal/trayhost/tray_stub.go
new file mode 100644
index 0000000..fefb0ef
--- /dev/null
+++ b/internal/trayhost/tray_stub.go
@@ -0,0 +1,10 @@
+//go:build !windows
+
+package trayhost
+
+// macOS/Linux: no CGO tray in default CGO_ENABLED=0 builds.
+func start(appName string, h Hooks) bool {
+ _ = appName
+ _ = h
+ return false
+}
diff --git a/internal/trayhost/tray_windows.go b/internal/trayhost/tray_windows.go
new file mode 100644
index 0000000..bb0aafc
--- /dev/null
+++ b/internal/trayhost/tray_windows.go
@@ -0,0 +1,194 @@
+//go:build windows
+
+package trayhost
+
+import (
+ "runtime"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ shell32 = windows.NewLazySystemDLL("shell32.dll")
+ user32 = windows.NewLazySystemDLL("user32.dll")
+ procShellNotify = shell32.NewProc("Shell_NotifyIconW")
+ procLoadIcon = user32.NewProc("LoadIconW")
+ procCreatePopup = user32.NewProc("CreatePopupMenu")
+ procAppendMenu = user32.NewProc("AppendMenuW")
+ procTrackPopup = user32.NewProc("TrackPopupMenu")
+ procDestroyMenu = user32.NewProc("DestroyMenu")
+ procDefWindowProc = user32.NewProc("DefWindowProcW")
+ procRegisterClass = user32.NewProc("RegisterClassExW")
+ procCreateWindow = user32.NewProc("CreateWindowExW")
+ procGetMessage = user32.NewProc("GetMessageW")
+ procTranslate = user32.NewProc("TranslateMessage")
+ procDispatch = user32.NewProc("DispatchMessageW")
+ procPostQuit = user32.NewProc("PostQuitMessage")
+ procSetForeground = user32.NewProc("SetForegroundWindow")
+ procGetCursorPos = user32.NewProc("GetCursorPos")
+)
+
+const (
+ nimAdd = 0x00000000
+ nimModify = 0x00000001
+ nimDelete = 0x00000002
+ nifMessage = 0x00000001
+ nifIcon = 0x00000002
+ nifTip = 0x00000004
+ wmApp = 0x8000
+ wmTray = wmApp + 1
+ wmDestroy = 0x0002
+ wmCommand = 0x0111
+ wmRButtonUp = 0x0205
+ wmLButtonUp = 0x0202
+ mfString = 0x00000000
+ mfSeparator = 0x00000800
+ tpmRight = 0x0020
+ tpmBottom = 0x0020
+ idiApplication = 32512
+ idConnect = 1001
+ idDisconnect = 1002
+ idQuit = 1003
+)
+
+type notifyIconData struct {
+ CbSize uint32
+ Hwnd windows.Handle
+ UID uint32
+ UFlags uint32
+ UCallbackMessage uint32
+ HIcon windows.Handle
+ SzTip [128]uint16
+}
+
+type wndClassEx struct {
+ CbSize uint32
+ Style uint32
+ LpfnWndProc uintptr
+ CbClsExtra int32
+ CbWndExtra int32
+ HInstance windows.Handle
+ HIcon windows.Handle
+ HCursor windows.Handle
+ HbrBackground windows.Handle
+ LpszMenuName *uint16
+ LpszClassName *uint16
+ HIconSm windows.Handle
+}
+
+type point struct{ X, Y int32 }
+
+type msg struct {
+ Hwnd windows.Handle
+ Message uint32
+ WParam uintptr
+ LParam uintptr
+ Time uint32
+ Pt point
+}
+
+func start(appName string, h Hooks) bool {
+ if h.Quit == nil {
+ return false
+ }
+ go func() {
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+ runTray(appName, h)
+ }()
+ return true
+}
+
+func runTray(appName string, h Hooks) {
+ className, _ := windows.UTF16PtrFromString("NavisTrayClass")
+ title, _ := windows.UTF16PtrFromString(appName)
+
+ var wndProc = windows.NewCallback(func(hwnd windows.Handle, msgU uint32, wParam, lParam uintptr) uintptr {
+ switch msgU {
+ case wmTray:
+ if lParam == wmRButtonUp || lParam == wmLButtonUp {
+ showMenu(hwnd, h)
+ }
+ return 0
+ case wmCommand:
+ switch int(wParam & 0xffff) {
+ case idConnect:
+ if h.Connect != nil {
+ go h.Connect()
+ }
+ case idDisconnect:
+ if h.Disconnect != nil {
+ go h.Disconnect()
+ }
+ case idQuit:
+ go h.Quit()
+ }
+ return 0
+ case wmDestroy:
+ procPostQuit.Call(0)
+ return 0
+ }
+ r, _, _ := procDefWindowProc.Call(uintptr(hwnd), uintptr(msgU), wParam, lParam)
+ return r
+ })
+
+ wc := wndClassEx{
+ CbSize: uint32(unsafe.Sizeof(wndClassEx{})),
+ LpfnWndProc: wndProc,
+ LpszClassName: className,
+ }
+ procRegisterClass.Call(uintptr(unsafe.Pointer(&wc)))
+ hwnd, _, _ := procCreateWindow.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, 0, 0)
+ if hwnd == 0 {
+ return
+ }
+
+ icon, _, _ := procLoadIcon.Call(0, uintptr(idiApplication))
+ var nid notifyIconData
+ nid.CbSize = uint32(unsafe.Sizeof(nid))
+ nid.Hwnd = windows.Handle(hwnd)
+ nid.UID = 1
+ nid.UFlags = nifMessage | nifIcon | nifTip
+ nid.UCallbackMessage = wmTray
+ nid.HIcon = windows.Handle(icon)
+ tip, _ := windows.UTF16FromString(appName)
+ copy(nid.SzTip[:], tip)
+ procShellNotify.Call(nimAdd, uintptr(unsafe.Pointer(&nid)))
+
+ var m msg
+ for {
+ ret, _, _ := procGetMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0)
+ if int32(ret) <= 0 {
+ break
+ }
+ procTranslate.Call(uintptr(unsafe.Pointer(&m)))
+ procDispatch.Call(uintptr(unsafe.Pointer(&m)))
+ }
+ procShellNotify.Call(nimDelete, uintptr(unsafe.Pointer(&nid)))
+}
+
+func showMenu(hwnd windows.Handle, h Hooks) {
+ menu, _, _ := procCreatePopup.Call()
+ if menu == 0 {
+ return
+ }
+ defer procDestroyMenu.Call(menu)
+ add := func(id int, text string) {
+ p, _ := windows.UTF16PtrFromString(text)
+ procAppendMenu.Call(menu, mfString, uintptr(id), uintptr(unsafe.Pointer(p)))
+ }
+ up := h.IsUp != nil && h.IsUp()
+ if up {
+ add(idDisconnect, "Отключить")
+ } else {
+ add(idConnect, "Подключить")
+ }
+ procAppendMenu.Call(menu, mfSeparator, 0, 0)
+ add(idQuit, "Выход")
+
+ var pt point
+ procGetCursorPos.Call(uintptr(unsafe.Pointer(&pt)))
+ procSetForeground.Call(uintptr(hwnd))
+ procTrackPopup.Call(menu, tpmRight|tpmBottom, uintptr(pt.X), uintptr(pt.Y), 0, uintptr(hwnd), 0)
+}
\ No newline at end of file
diff --git a/internal/update/update.go b/internal/update/update.go
index 25da83b..bec38a1 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -18,7 +18,7 @@ import (
// CurrentVersion is the product/semver used for update eligibility (feed "version").
// Keep major.minor.patch only — no build suffix here.
-const CurrentVersion = "3.8.1"
+const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
diff --git a/internal/update/update_darwin.go b/internal/update/update_darwin.go
index e535a94..2d8fce5 100644
--- a/internal/update/update_darwin.go
+++ b/internal/update/update_darwin.go
@@ -61,16 +61,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
scriptPath := filepath.Join(parent, "navis-update-app.sh")
pid := os.Getpid()
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
- if identity == "" {
- identity = "-"
- }
+ // Never re-sign with ad-hoc "-" after replacing a shipped .app — that strips
+ // Developer ID / notarization. Only re-sign when a real identity is set.
+ doSign := identity != "" && identity != "-"
script := "#!/bin/bash\n" +
"set -e\n" +
"APP=" + shellQuote(p.AppRoot) + "\n" +
"ZIP=" + shellQuote(p.TmpPath) + "\n" +
"PID=" + strconv.Itoa(pid) + "\n" +
- "ID=" + shellQuote(identity) + "\n" +
"PARENT=$(dirname \"$APP\")\n" +
"while kill -0 \"$PID\" 2>/dev/null; do sleep 0.4; done\n" +
"sleep 0.5\n" +
@@ -80,11 +79,15 @@ func applyAppZip(p preparedUpdate) (string, error) {
"if [ -z \"$NEW\" ] || [ ! -d \"$NEW\" ]; then echo 'Navis.app missing in zip' >&2; exit 1; fi\n" +
"rm -rf \"$APP.bak\"\n" +
"mv \"$APP\" \"$APP.bak\"\n" +
- "mv \"$NEW\" \"$APP\"\n" +
- "if command -v codesign >/dev/null 2>&1; then\n" +
- " codesign -s \"$ID\" --force --deep --options runtime \"$APP\" 2>/dev/null || codesign -s \"$ID\" --force --deep \"$APP\" || true\n" +
- " xattr -cr \"$APP\" 2>/dev/null || true\n" +
- "fi\n" +
+ "mv \"$NEW\" \"$APP\"\n"
+ if doSign {
+ script += "ID=" + shellQuote(identity) + "\n" +
+ "if command -v codesign >/dev/null 2>&1; then\n" +
+ " codesign -s \"$ID\" --force --deep --options runtime --timestamp \"$APP\"\n" +
+ " codesign --verify --deep --strict \"$APP\"\n" +
+ "fi\n"
+ }
+ script += "xattr -cr \"$APP\" 2>/dev/null || true\n" +
"rm -rf \"$APP.bak\" \"$TMP\" \"$ZIP\" " + shellQuote(scriptPath) + "\n" +
"open \"$APP\"\n"
diff --git a/scripts/build-macos-arm64.sh b/scripts/build-macos-arm64.sh
index f974599..7c2e35f 100755
--- a/scripts/build-macos-arm64.sh
+++ b/scripts/build-macos-arm64.sh
@@ -40,10 +40,22 @@ printf '%s\n' "$FULL" > "$OUT/VERSION"
printf '%s\n' "${VERSION}+${BUILD}" > "$OUT/Navis.version"
# Ad-hoc sign CLI/bin; packmac also signs the .app (honors NAVIS_CODESIGN_IDENTITY).
+# Set REQUIRE_CODESIGN=1 for release builds (fails without a real Developer ID identity).
+if [[ "${REQUIRE_CODESIGN:-}" == "1" ]]; then
+ if [[ -z "${NAVIS_CODESIGN_IDENTITY:-}" || "${NAVIS_CODESIGN_IDENTITY}" == "-" ]]; then
+ echo "REQUIRE_CODESIGN=1 requires NAVIS_CODESIGN_IDENTITY (Developer ID Application: …)" >&2
+ exit 1
+ fi
+fi
if command -v codesign >/dev/null 2>&1; then
- ID="${NAVIS_CODESIGN_IDENTITY:--}"
- codesign -s "$ID" --force "$OUT/Navis" || codesign -s - --force "$OUT/Navis"
- codesign -s "$ID" --force "$OUT/Navis-cli" || codesign -s - --force "$OUT/Navis-cli"
+ if [[ -n "${NAVIS_CODESIGN_IDENTITY:-}" && "${NAVIS_CODESIGN_IDENTITY}" != "-" ]]; then
+ ID="$NAVIS_CODESIGN_IDENTITY"
+ codesign -s "$ID" --force --options runtime --timestamp "$OUT/Navis"
+ codesign -s "$ID" --force --options runtime --timestamp "$OUT/Navis-cli"
+ else
+ codesign -s - --force "$OUT/Navis" || true
+ codesign -s - --force "$OUT/Navis-cli" || true
+ fi
fi
go run ./tools/packmac -bin "$OUT/Navis" -out "$OUT" -version "$VERSION" -build "$FULL" -arch arm64
@@ -65,9 +77,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
notes = (
- f"Navis {ver}+{build}: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, "
- f"список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; "
- f"параллельная работа на нескольких ядрах CPU."
+ f"Navis {ver}+{build}: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; "
+ f"автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; "
+ f"CI и строгая подпись релиза."
)
paths = [
Path('dist/update.json'),
@@ -95,6 +107,23 @@ for p in paths:
'dmg_url',
'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis.dmg',
)
+ # Refresh checksums for any other darwin trees present on disk (amd64/universal).
+ base = 'https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release'
+ for key in ('darwin-amd64', 'darwin-universal'):
+ if key not in plats:
+ continue
+ plat_dir = Path('dist/navis-release') / key
+ bin2 = plat_dir / 'Navis'
+ zip2 = plat_dir / 'Navis.app.zip'
+ dmg2 = plat_dir / 'Navis.dmg'
+ if bin2.exists():
+ plats[key]['sha256'] = hashlib.sha256(bin2.read_bytes()).hexdigest()
+ if zip2.exists():
+ plats[key]['zip_sha256'] = hashlib.sha256(zip2.read_bytes()).hexdigest()
+ plats[key].setdefault('zip_url', f'{base}/{key}/Navis.app.zip')
+ if dmg2.exists():
+ plats[key]['dmg_sha256'] = hashlib.sha256(dmg2.read_bytes()).hexdigest()
+ plats[key].setdefault('dmg_url', f'{base}/{key}/Navis.dmg')
p.write_text(json.dumps(d, ensure_ascii=False, indent=2) + '\n')
print('updated', p, '->', ver, 'bin', h[:12], 'zip', zh[:12] if zh else '-')
PY
diff --git a/scripts/sync-version.py b/scripts/sync-version.py
index 2843206..4da909f 100755
--- a/scripts/sync-version.py
+++ b/scripts/sync-version.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Sync versioninfo.json + build-macos.bat from internal/update/update.go constants."""
+"""Sync versioninfo.json, build-macos.bat, Android gradle, UI badge from update.go."""
from __future__ import annotations
import json
@@ -10,6 +10,8 @@ ROOT = Path(__file__).resolve().parents[1]
UPDATE_GO = ROOT / "internal" / "update" / "update.go"
VERSIONINFO = ROOT / "versioninfo.json"
BUILD_BAT = ROOT / "build-macos.bat"
+ANDROID_GRADLE = ROOT / "android" / "app" / "build.gradle.kts"
+INDEX_HTML = ROOT / "internal" / "appui" / "index.html"
def read_consts() -> tuple[str, int]:
@@ -28,6 +30,8 @@ def main() -> None:
parts.append(0)
major, minor, patch = parts[0], parts[1], parts[2]
full = f"{ver}.{build}"
+ display = f"{ver}+{build}"
+ version_code = major * 1_000_000 + minor * 10_000 + patch * 100 + build
data = json.loads(VERSIONINFO.read_text(encoding="utf-8"))
data["FixedFileInfo"]["FileVersion"] = {
@@ -54,6 +58,25 @@ def main() -> None:
else:
print("build-macos.bat already in sync")
+ if ANDROID_GRADLE.exists():
+ g = ANDROID_GRADLE.read_text(encoding="utf-8")
+ g2 = re.sub(r"versionCode\s*=\s*[\d_]+", f"versionCode = {version_code}", g)
+ g2 = re.sub(r'versionName\s*=\s*"[^"]+"', f'versionName = "{display}"', g2)
+ if g2 != g:
+ ANDROID_GRADLE.write_text(g2, encoding="utf-8")
+ print("updated", ANDROID_GRADLE, "->", display, version_code)
+ else:
+ print("android gradle already in sync")
+
+ if INDEX_HTML.exists():
+ h = INDEX_HTML.read_text(encoding="utf-8")
+ h2 = re.sub(r'(id="badgeVer">)[^<]+', rf"\g<1>{ver}", h, count=1)
+ if h2 != h:
+ INDEX_HTML.write_text(h2, encoding="utf-8")
+ print("updated", INDEX_HTML, "badge ->", ver)
+ else:
+ print("index.html badge already in sync")
+
if __name__ == "__main__":
main()
diff --git a/server/update.json b/server/update.json
index 1e4818b..ad82b71 100644
--- a/server/update.json
+++ b/server/update.json
@@ -1,6 +1,6 @@
{
- "version": "3.8.1",
- "notes": "Navis 3.8.1+1: меньше нагрузки в простое (лёгкий опрос UI, кэш ядер, список серверов не мигает); быстрее пинг; стабильнее логи и AWG-прокси; параллельная работа на нескольких ядрах CPU.",
+ "version": "3.8.2",
+ "notes": "Navis 3.8.2+1: при падении ядра снимается системный прокси; логи и проверка туннеля в UI; автопереподключение / connect-on-launch; подписка чистит устаревшие ноды; «Лучший» без soft-ok UDP; CI и строгая подпись релиза.",
"platform": "windows-amd64",
"os": "windows",
"arch": "amd64",
@@ -16,13 +16,13 @@
},
"darwin-arm64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis",
- "sha256": "42e90ef84c1000b0850e89b30e13b2c15b0ca230a3ede28038fa03b3b493ec2e",
+ "sha256": "c5990d22af050858354eb9a4e91828572eb65d4191e425500e0a08dd8fb3a63a",
"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"
+ "zip_sha256": "7efbaa11b60b7f732724ff509c805f37a34bd374e20dc56e0089bbeed9a82fbd",
+ "dmg_sha256": "1c8bbfae6abf04920f303e66dfa3bbbf2abaf7f5b50342983c66dedc015fe55f"
},
"darwin-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis",
@@ -30,7 +30,9 @@
"os": "darwin",
"arch": "amd64",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip",
+ "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f",
+ "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15"
},
"darwin-universal": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis",
@@ -38,7 +40,9 @@
"os": "darwin",
"arch": "universal",
"dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg",
- "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip"
+ "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip",
+ "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0",
+ "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13"
}
}
}
diff --git a/tools/packmac/main.go b/tools/packmac/main.go
index 768a326..6fe9043 100644
--- a/tools/packmac/main.go
+++ b/tools/packmac/main.go
@@ -176,22 +176,41 @@ func writeAppBundle(appRoot, binPath, version, bundleVersion, arch string) error
func signAppBundle(appRoot string) error {
if _, err := exec.LookPath("codesign"); err != nil {
+ if os.Getenv("REQUIRE_CODESIGN") == "1" {
+ return fmt.Errorf("codesign required (REQUIRE_CODESIGN=1) but not found")
+ }
return nil
}
- // NAVIS_CODESIGN_IDENTITY=Developer ID Application: … for release; default ad-hoc "-".
identity := strings.TrimSpace(os.Getenv("NAVIS_CODESIGN_IDENTITY"))
+ require := os.Getenv("REQUIRE_CODESIGN") == "1"
+ if require && (identity == "" || identity == "-") {
+ return fmt.Errorf("REQUIRE_CODESIGN=1 requires NAVIS_CODESIGN_IDENTITY")
+ }
if identity == "" {
identity = "-"
}
- cmd := exec.Command("codesign", "-s", identity, "--force", "--deep", "--options", "runtime", appRoot)
+ args := []string{"-s", identity, "--force", "--deep"}
+ if identity != "-" {
+ args = append(args, "--options", "runtime", "--timestamp")
+ }
+ args = append(args, appRoot)
+ cmd := exec.Command("codesign", args...)
out, err := cmd.CombinedOutput()
if err != nil {
- cmd = exec.Command("codesign", "-s", identity, "--force", "--deep", appRoot)
- out, err = cmd.CombinedOutput()
+ if identity == "-" {
+ cmd = exec.Command("codesign", "-s", "-", "--force", "--deep", appRoot)
+ out, err = cmd.CombinedOutput()
+ }
if err != nil {
return fmt.Errorf("codesign %s: %w: %s", appRoot, err, strings.TrimSpace(string(out)))
}
}
+ if identity != "-" {
+ v := exec.Command("codesign", "--verify", "--deep", "--strict", appRoot)
+ if out, err := v.CombinedOutput(); err != nil {
+ return fmt.Errorf("codesign verify: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ }
_ = exec.Command("xattr", "-cr", appRoot).Run()
return nil
}
diff --git a/versioninfo.json b/versioninfo.json
index dd1ef42..e28ec39 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 3,
"Minor": 8,
- "Patch": 1,
+ "Patch": 2,
"Build": 1
},
"ProductVersion": {
"Major": 3,
"Minor": 8,
- "Patch": 1,
+ "Patch": 2,
"Build": 1
},
"FileFlagsMask": "3f",
@@ -21,12 +21,12 @@
"StringFileInfo": {
"CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
- "FileVersion": "3.8.1.1",
+ "FileVersion": "3.8.2.1",
"InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe",
"ProductName": "Navis",
- "ProductVersion": "3.8.1.1",
+ "ProductVersion": "3.8.2.1",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/"
},
"VarFileInfo": {