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": {