Release 3.8.2.1: reliability, UI probe/logs, CI, signing gates.

Clear sysproxy on core death; probe+reconnect; subscription prune;
Best ignores soft UDP; Windows tray; Android protocol gate; CI workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-07-30 02:44:48 +03:00
co-authored by Cursor
parent 6b6c13c933
commit e782209769
39 changed files with 913 additions and 143 deletions
+31
View File
@@ -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
+31
View File
@@ -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
+6
View File
@@ -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 с;
+4 -4
View File
@@ -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) |
## Сборка
+2 -2
View File
@@ -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")
@@ -24,6 +24,12 @@ enum class Proto(val label: String) {
}
}
}
/** Desktop has Naive/AWG; this APK only runs Xray + Hysteria2 in VpnService. */
fun supportedOnAndroid(): Boolean = when (this) {
VLESS, VMESS, TROJAN, HYSTERIA2 -> true
else -> false
}
}
data class Profile(
@@ -59,6 +59,10 @@ class MainActivity : ComponentActivity() {
vm.setMeta("Выберите сервер", true)
return
}
if (!p.protocol.supportedOnAndroid()) {
vm.setMeta("${p.protocol.label}: на Android недоступен — используйте VLESS/VMess/Trojan/Hy2", true)
return
}
prepareVpnThen {
startService(
Intent(this, NavisVpnService::class.java)
@@ -139,12 +139,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
}
val rows = store.pingAll(profiles)
_state.update { it.copy(pings = rows.associateBy { r -> r.id }) }
val best = store.bestOf(rows) ?: run {
setMeta("нет доступных серверов", true)
val supported = profiles.filter { it.protocol.supportedOnAndroid() }
val supportedRows = rows.filter { r -> supported.any { it.id == r.id } }
val best = store.bestOf(supportedRows) ?: run {
setMeta("нет доступных серверов (Android: VLESS/VMess/Trojan/Hy2)", true)
return
}
store.setActiveId(best.id)
val profile = profiles.first { it.id == best.id }
if (!profile.protocol.supportedOnAndroid()) {
setMeta("${profile.protocol.label}: на Android недоступен", true)
return
}
_state.update { it.copy(activeId = best.id) }
setMeta("Лучший: ${profile.name} · ${best.ms} ms")
if (connect && prepare != null) {
@@ -100,7 +100,7 @@ fun NavisScreen(
Text("2.6.1", Modifier.padding(horizontal = 8.dp, vertical = 2.dp), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = AccentDeep)
}
}
Text("Android · Naive · Hy2 · AWG · Xray", color = Muted, fontSize = 13.sp)
Text("Android · Xray · Hy2 (Naive/AWG — только Desktop)", color = Muted, fontSize = 13.sp)
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(22.dp), color = Color.White.copy(alpha = 0.85f), tonalElevation = 2.dp) {
+3 -3
View File
@@ -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:
+12
View File
@@ -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)
+16
View File
@@ -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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
3.8.1+1
3.8.2+1
+1 -1
View File
@@ -1 +1 @@
3.8.1.1
3.8.2.1
+11 -7
View File
@@ -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"
}
}
}
+11 -7
View File
@@ -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"
}
}
}
+9
View File
@@ -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):
+138 -46
View File
@@ -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)
+15
View File
@@ -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; }
+40 -4
View File
@@ -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 });
+24 -1
View File
@@ -46,7 +46,7 @@
<div class="brand-wrap">
<div class="brand-row">
<h1 class="brand">Navis</h1>
<span class="badge-ver" id="badgeVer">3.8.1</span>
<span class="badge-ver" id="badgeVer">3.8.2</span>
</div>
<p class="tagline">Быстрый клиент · Naive · Hy2 · AWG · Xray</p>
</div>
@@ -122,6 +122,29 @@
<span class="slider"></span>
</label>
</div>
<div class="row">
<span>Подключать при запуске</span>
<label class="switch">
<input id="bootConnect" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<div class="row">
<span>Автопереподключение</span>
<label class="switch">
<input id="autoReconnect" type="checkbox" />
<span class="slider"></span>
</label>
</div>
<details class="panel" id="logBox">
<summary>Логи ядра</summary>
<pre class="log-tail" id="logTail"></pre>
<div class="row-actions">
<button class="action secondary" id="logRefreshBtn" type="button">Обновить лог</button>
<button class="action secondary" id="probeBtn" type="button">Проверить туннель</button>
</div>
</details>
<details class="panel hy2" id="hy2Box">
<summary>Hysteria 2 · BBR / obfuscation</summary>
+9
View File
@@ -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"`
}
+173 -14
View File
@@ -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 {
+17 -15
View File
@@ -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 {
+12 -3
View File
@@ -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)
}
+14
View File
@@ -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)
}
+10
View File
@@ -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
}
+194
View File
@@ -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)
}
+1 -1
View File
@@ -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.
+12 -9
View File
@@ -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"
+35 -6
View File
@@ -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
+24 -1
View File
@@ -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()
+11 -7
View File
@@ -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"
}
}
}
+23 -4
View File
@@ -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
}
+4 -4
View File
@@ -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": {