diff --git a/README.md b/README.md index d1f7fab..0a0dbfe 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,11 @@ https://evilfox.win/ - 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`); - 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool; - 3.8.2+6: поиск по серверам и хоткеи; Windows close→tray (Открыть/Выход); фоновый прогрев активного core; SSE `/api/events` + редкий poll fallback; -- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS). +- 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); +- 3.8.2+8: дешёвый idle rev-cache; иконка без 400 ms ticker; ping без копирования proxy; cores только из binDir; инкрементальный список серверов; CSP без Google Fonts; ReadHeaderTimeout; +- 3.8.2+9: single-instance; перед Connect убивает orphan hysteria/naive/xray на listen-портах; в UI явная ошибка «порт … занят»; +- 3.8.2+10: крестик = трей (не выход); single-instance поднимает существующее окно; hide-on-close только если трей жив; +- 3.8.2+11: без автоубийства чужих Navis и без balloon при сворачивании в трей. В 3.8.1: - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 14e1638..03163b7 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 = 3080207 - versionName = "3.8.2+7" + versionCode = 3080211 + versionName = "3.8.2+11" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index f5c832a..acf956c 100644 --- a/build-macos.bat +++ b/build-macos.bat @@ -42,11 +42,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.2 -build 3.8.2.7 -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.11 -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.2 -build 3.8.2.7 -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.11 -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.2 -build 3.8.2.7 -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.11 -arch universal if errorlevel 1 exit /b 1 echo Built Mac GUI + CLI: diff --git a/cmd/vpnapp/gui_desktop.go b/cmd/vpnapp/gui_desktop.go index c573742..bf9911a 100644 --- a/cmd/vpnapp/gui_desktop.go +++ b/cmd/vpnapp/gui_desktop.go @@ -17,6 +17,7 @@ import ( "vpnclient/internal/core" "vpnclient/internal/dockicon" "vpnclient/internal/logbuf" + "vpnclient/internal/singleinstance" "vpnclient/internal/trayhost" "vpnclient/internal/update" ) @@ -27,6 +28,16 @@ func main() { } update.CleanupStaleDownloads() + releaseInstance, err := singleinstance.Lock("Navis") + if err != nil { + if err == singleinstance.ErrAlreadyRunning { + // Existing window was activated when possible. + return + } + fatalf("Не удалось получить single-instance lock:\n%v", err) + } + defer releaseInstance() + log.SetFlags(log.LstdFlags | log.Lshortfile) log.Printf("Navis %s · GOMAXPROCS=%d NumCPU=%d", update.DisplayVersion(), runtime.GOMAXPROCS(0), runtime.NumCPU()) @@ -66,14 +77,18 @@ func main() { fatalf("listen: %v", err) } a.APIToken = token - srv := &http.Server{Handler: a.Handler()} + srv := &http.Server{ + Handler: a.Handler(), + ReadHeaderTimeout: 5 * time.Second, + // No WriteTimeout: SSE /api/events is long-lived. + } go func() { _ = srv.Serve(ln) }() a.StartBackground() dockicon.OnIconChange = trayhost.SetConnectedIcon - trayhost.Start("Navis", trayhost.Hooks{ + trayOK := trayhost.Start("Navis", trayhost.Hooks{ Connect: func() { _ = a.Connect() }, Disconnect: func() { _ = a.Disconnect() }, Show: showMainWindow, @@ -112,8 +127,9 @@ func main() { w.SetTitle("Navis") w.SetSize(500, 900, glaze.HintNone) - decorateWindow(w) + // Close→hide only when tray is alive; otherwise X would leave invisible zombies. + decorateWindow(w, trayOK) w.Navigate(uiURL) - log.Printf("Navis UI: %s", uiURL) + log.Printf("Navis UI: %s (tray=%v hideOnClose=%v)", uiURL, trayOK, trayOK) w.Run() } diff --git a/cmd/vpnapp/platform_darwin.go b/cmd/vpnapp/platform_darwin.go index 0b7fc7c..514d539 100644 --- a/cmd/vpnapp/platform_darwin.go +++ b/cmd/vpnapp/platform_darwin.go @@ -27,9 +27,10 @@ func shutdownSignals() []os.Signal { return []os.Signal{os.Interrupt, syscall.SIGTERM} } -func decorateWindow(w glaze.WebView) { - // macOS Dock/app icon comes from the .app bundle; nothing to apply here. +func decorateWindow(w glaze.WebView, hideOnClose bool) { + // macOS Dock/app icon comes from the .app bundle; close-to-hide is Windows-only. _ = w + _ = hideOnClose } func showMainWindow() {} diff --git a/cmd/vpnapp/platform_windows.go b/cmd/vpnapp/platform_windows.go index bdd467f..309bac0 100644 --- a/cmd/vpnapp/platform_windows.go +++ b/cmd/vpnapp/platform_windows.go @@ -64,14 +64,16 @@ func shutdownSignals() []os.Signal { return []os.Signal{os.Interrupt} } -func decorateWindow(w glaze.WebView) { +func decorateWindow(w glaze.WebView, hideOnClose bool) { if w == nil { return } hwnd := w.Window() applyWindowIcon(hwnd) dockicon.BindWindow(hwnd) - installCloseToHide(hwnd) + if hideOnClose { + installCloseToHide(hwnd) + } } func installCloseToHide(hwnd unsafe.Pointer) { diff --git a/dist/navis-release/Navis-3.8.2.10.exe b/dist/navis-release/Navis-3.8.2.10.exe new file mode 100644 index 0000000..bf6d2ee Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.10.exe differ diff --git a/dist/navis-release/Navis-3.8.2.11.exe b/dist/navis-release/Navis-3.8.2.11.exe new file mode 100644 index 0000000..53e32e2 Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.11.exe differ diff --git a/dist/navis-release/Navis-3.8.2.8.exe b/dist/navis-release/Navis-3.8.2.8.exe new file mode 100644 index 0000000..a3aebd3 Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.8.exe differ diff --git a/dist/navis-release/Navis-3.8.2.9.exe b/dist/navis-release/Navis-3.8.2.9.exe new file mode 100644 index 0000000..7a4f7c9 Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.9.exe differ diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index 0661746..53e32e2 100644 Binary files a/dist/navis-release/Navis.exe and b/dist/navis-release/Navis.exe differ diff --git a/dist/navis-release/update.json b/dist/navis-release/update.json index 18320cb..3b86876 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,48 +1,48 @@ -{ - "version": "3.8.2", - "notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.", - "platform": "windows-amd64", - "os": "windows", - "arch": "amd64", - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605", - "mandatory": false, - "platforms": { - "windows-amd64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605", - "os": "windows", - "arch": "amd64" - }, - "darwin-arm64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc", - "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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67", - "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d" - }, - "darwin-amd64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", - "os": "darwin", - "arch": "amd64", - "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", - "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip", - "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", - "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" - }, - "darwin-universal": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", - "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", - "os": "darwin", - "arch": "universal", - "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", - "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip", - "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", - "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" - } - } +{ + "version": "3.8.2", + "notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.", + "platform": "windows-amd64", + "os": "windows", + "arch": "amd64", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83", + "mandatory": false, + "platforms": { + "windows-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83", + "os": "windows", + "arch": "amd64" + }, + "darwin-arm64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", + "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc", + "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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67", + "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d" + }, + "darwin-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", + "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", + "os": "darwin", + "arch": "amd64", + "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", + "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip", + "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", + "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" + }, + "darwin-universal": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", + "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", + "os": "darwin", + "arch": "universal", + "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", + "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip", + "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", + "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" + } + } } diff --git a/dist/navis-release/vpnclient-3.8.2.10.exe b/dist/navis-release/vpnclient-3.8.2.10.exe new file mode 100644 index 0000000..50fa27d Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.10.exe differ diff --git a/dist/navis-release/vpnclient-3.8.2.11.exe b/dist/navis-release/vpnclient-3.8.2.11.exe new file mode 100644 index 0000000..66634c4 Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.11.exe differ diff --git a/dist/navis-release/vpnclient-3.8.2.8.exe b/dist/navis-release/vpnclient-3.8.2.8.exe new file mode 100644 index 0000000..4ee1d30 Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.8.exe differ diff --git a/dist/navis-release/vpnclient-3.8.2.9.exe b/dist/navis-release/vpnclient-3.8.2.9.exe new file mode 100644 index 0000000..e5483c3 Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.9.exe differ diff --git a/dist/navis-release/vpnclient.exe b/dist/navis-release/vpnclient.exe index ac5b3b9..66634c4 100644 Binary files a/dist/navis-release/vpnclient.exe and b/dist/navis-release/vpnclient.exe differ diff --git a/dist/update.json b/dist/update.json index 18320cb..3b86876 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,48 +1,48 @@ -{ - "version": "3.8.2", - "notes": "Navis 3.8.2+7: Windows — иконка в панели задач и трее цвета морской волны при подключении (как Dock на macOS); при отключении возвращается обычная.", - "platform": "windows-amd64", - "os": "windows", - "arch": "amd64", - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605", - "mandatory": false, - "platforms": { - "windows-amd64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "b7902251aeb671946dbfe7ab2f3470dea82cf29cc0cd68e42f6632653ea90605", - "os": "windows", - "arch": "amd64" - }, - "darwin-arm64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", - "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc", - "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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67", - "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d" - }, - "darwin-amd64": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", - "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", - "os": "darwin", - "arch": "amd64", - "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", - "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip", - "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", - "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" - }, - "darwin-universal": { - "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", - "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", - "os": "darwin", - "arch": "universal", - "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", - "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip", - "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", - "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" - } - } +{ + "version": "3.8.2", + "notes": "Navis 3.8.2+11: крестик сворачивает в трей (выход — из меню трея); один экземпляр GUI поднимает существующее окно; без автозавершения других процессов и без всплывающих напоминаний.", + "platform": "windows-amd64", + "os": "windows", + "arch": "amd64", + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83", + "mandatory": false, + "platforms": { + "windows-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", + "sha256": "2b7b74a5320e6dc2bfdbaf20af828bef67d63617475f5316120041422429ba83", + "os": "windows", + "arch": "amd64" + }, + "darwin-arm64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-arm64/Navis", + "sha256": "b59c940dc946d18e6c57d9bcb3d9e36d489d894e9f889b08d476add3207babbc", + "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": "7d695a76a111d0ea3ff13955d49db2d344972e8e0b3fdb3562060aa80b06ea67", + "dmg_sha256": "96927b0006934a9bc042130351cefa57096badb6771c50a31c0731ddade9cb0d" + }, + "darwin-amd64": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis", + "sha256": "8072cacd79fa95da3f3230b88b8e09e380ed2e9f634703f579950952b94f774d", + "os": "darwin", + "arch": "amd64", + "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.dmg", + "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-amd64/Navis.app.zip", + "zip_sha256": "68ddf6b83aa6ef647bba555cb602ad942efe0b4884569c9e5fe580fe3cc83e3f", + "dmg_sha256": "9f5e54560be844da4d17e97c7228e21288037f3e52300c47696eb8bf4ac74a15" + }, + "darwin-universal": { + "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis", + "sha256": "f50642373f615fa55117d1bbe7b2f86f2a19ab03a9ae53cc7e7d137b432ebbe5", + "os": "darwin", + "arch": "universal", + "dmg_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.dmg", + "zip_url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/darwin-universal/Navis.app.zip", + "zip_sha256": "e03b6f859df800dc46c6bbf72400fa74cd950a8cf768afea1d0e0f800aa8d2f0", + "dmg_sha256": "d6fc19d2dc5914f26113e97f65f3a18b56295c234df36d7e0f498dad3489ed13" + } + } } diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 20c1f9c..41c607f 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -44,6 +44,9 @@ type App struct { ctx context.Context cancel context.CancelFunc events *eventBroker + revMu sync.Mutex + cachedRev string + cachedConn bool } type UIState struct { @@ -111,17 +114,35 @@ func (a *App) GetState() (UIState, error) { // GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since // matches the current fingerprint (cheap idle HTTP polls). func (a *App) GetStateSince(since string) (UIState, error) { + if since != "" { + a.revMu.Lock() + cachedRev, cachedConn := a.cachedRev, a.cachedConn + a.revMu.Unlock() + if cachedRev != "" && cachedRev == since && a.Mgr.Status().Connected == cachedConn { + return UIState{Rev: cachedRev, Unchanged: true}, nil + } + } out, err := a.getState(false) if err != nil { return out, err } out.Rev = fingerprintState(out) + a.revMu.Lock() + a.cachedRev = out.Rev + a.cachedConn = out.Connected + a.revMu.Unlock() if since != "" && since == out.Rev { return UIState{Rev: out.Rev, Unchanged: true}, nil } return out, nil } +func (a *App) invalidateRevCache() { + a.revMu.Lock() + a.cachedRev = "" + a.revMu.Unlock() +} + // GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls). func (a *App) GetEditState() (UIState, error) { out, err := a.getState(true) @@ -238,24 +259,34 @@ func resolveCoreCached(binDir string, proto config.Protocol) (path string, ready func (a *App) SaveProfile(name, proxy string, systemProxy bool) error { a.mu.Lock() - defer a.mu.Unlock() name = strings.TrimSpace(name) if name == "" { + a.mu.Unlock() return fmt.Errorf("укажите название профиля") } a.Mgr.SetSystemProxy(systemProxy) - return a.Mgr.SaveActiveProfile(name, proxy) + err := a.Mgr.SaveActiveProfile(name, proxy) + a.mu.Unlock() + if err == nil { + a.NotifyState("profile") + } + return err } func (a *App) CreateProfile(name, proxy string, systemProxy bool) error { a.mu.Lock() - defer a.mu.Unlock() name = strings.TrimSpace(name) if name == "" { + a.mu.Unlock() return fmt.Errorf("укажите название профиля") } a.Mgr.SetSystemProxy(systemProxy) - return a.Mgr.SaveProfile(name, proxy) + err := a.Mgr.SaveProfile(name, proxy) + a.mu.Unlock() + if err == nil { + a.NotifyState("profile") + } + return err } func (a *App) SelectProfile(name string) error { @@ -271,8 +302,12 @@ func (a *App) SelectProfile(name string) error { func (a *App) DeleteProfile(name string) error { a.mu.Lock() - defer a.mu.Unlock() - return a.Mgr.DeleteProfile(name) + err := a.Mgr.DeleteProfile(name) + a.mu.Unlock() + if err == nil { + a.NotifyState("profile") + } + return err } func (a *App) Connect() error { return a.ConnectProfile("") } @@ -368,17 +403,21 @@ func (a *App) ProbeTunnel() error { } func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error { - return a.Mgr.SavePrefs(core.Prefs{ + err := a.Mgr.SavePrefs(core.Prefs{ ConnectOnLaunch: connectOnLaunch, AutoReconnect: autoReconnect, SystemProxy: systemProxy, }) + if err == nil { + a.NotifyState("prefs") + } + return err } -// StartBackground runs reconnect-on-crash, optional connect-on-launch, and Dock icon sync. +// StartBackground runs reconnect-on-crash, optional connect-on-launch, and core warmup. func (a *App) StartBackground() { go a.watchdogLoop() - go a.dockIconLoop() + dockicon.SetConnected(false) go a.warmActiveCore() prefs := a.Mgr.Prefs() if prefs.ConnectOnLaunch { @@ -408,27 +447,7 @@ func (a *App) warmActiveCore() { // Soft fail — Connect will surface the error if the user tries. return } -} - -func (a *App) dockIconLoop() { - dockicon.SetConnected(false) - var last bool - first := true - t := time.NewTicker(400 * time.Millisecond) - defer t.Stop() - for { - select { - case <-a.ctx.Done(): - return - case <-t.C: - up := a.Mgr.Status().Connected - if first || up != last { - first = false - last = up - dockicon.SetConnected(up) - } - } - } + a.NotifyState("cores") } func (a *App) watchdogLoop() { @@ -443,6 +462,8 @@ func (a *App) watchdogLoop() { continue } prefs := a.Mgr.Prefs() + dockicon.SetConnected(false) + a.NotifyState("disconnected") if !prefs.AutoReconnect { continue } @@ -465,17 +486,26 @@ func (a *App) InstallCore() (string, error) { for k, v := range paths { parts = append(parts, k+": "+v) } + a.NotifyState("cores") return strings.Join(parts, " | "), nil } func (a *App) SaveHy2(opts core.Hy2Options) error { a.mu.Lock() - defer a.mu.Unlock() - return a.Mgr.SaveHy2Options(opts) + err := a.Mgr.SaveHy2Options(opts) + a.mu.Unlock() + if err == nil { + a.NotifyState("hy2") + } + return err } func (a *App) ImportSubscription(rawURL string) (int, error) { - return a.Mgr.ImportSubscription(rawURL) + n, err := a.Mgr.ImportSubscription(rawURL) + if err == nil { + a.NotifyState("subscription") + } + return n, err } func (a *App) PingServers() ([]netcheck.Result, error) { @@ -540,24 +570,14 @@ func (a *App) PingBest(autoConnect bool) (PingBestResult, error) { } func (a *App) runPings() ([]netcheck.Result, error) { - a.mu.Lock() - list := a.Mgr.Profiles() - a.mu.Unlock() - - targets := make([]netcheck.Target, 0, len(list)) - for _, p := range list { - targets = append(targets, netcheck.Target{ - Name: p.Name, - Protocol: config.Protocol(p.Protocol), - Proxy: p.Proxy, - }) - } + targets := a.Mgr.PingTargets() ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() out := netcheck.PingAll(ctx, targets) a.mu.Lock() a.Pings = out a.mu.Unlock() + a.NotifyState("pings") return out, nil } @@ -573,6 +593,7 @@ func (a *App) CheckUpdate() (update.Status, error) { } a.UpdateStatus = st a.mu.Unlock() + a.NotifyState("update") return st, nil } diff --git a/internal/apphost/events.go b/internal/apphost/events.go index 4008155..a659ab2 100644 --- a/internal/apphost/events.go +++ b/internal/apphost/events.go @@ -47,9 +47,13 @@ func (b *eventBroker) publish(payload []byte) { } } -// NotifyState pushes a small SSE payload so the UI can refresh immediately. +// NotifyState invalidates the idle rev cache and pushes a small SSE payload. func (a *App) NotifyState(kind string) { - if a == nil || a.events == nil { + if a == nil { + return + } + a.invalidateRevCache() + if a.events == nil { return } if kind == "" { diff --git a/internal/appui/app.css b/internal/appui/app.css index ecbb037..91544c1 100644 --- a/internal/appui/app.css +++ b/internal/appui/app.css @@ -42,6 +42,8 @@ --ms-mid: #b8860b; --focus-ring: rgba(13,138,102,.12); --theme-btn-bg: rgba(255,255,255,.7); + --font-ui: "Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif; + --font-display: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", "Helvetica Neue", sans-serif; } html[data-theme="dark"] { --ink: #e6f4ee; @@ -90,7 +92,7 @@ html, body { margin: 0; min-height: 100%; - font-family: Figtree, sans-serif; + font-family: var(--font-ui); color: var(--ink); background: var(--page-bg); transition: color .2s var(--ease), background .25s var(--ease); @@ -130,7 +132,7 @@ } .update-banner.show { display: block; } .update-banner strong { - font-family: Sora, sans-serif; + font-family: var(--font-display); display: block; margin-bottom: 4px; font-size: .95rem; @@ -199,7 +201,7 @@ flex-wrap: wrap; } .brand { - font-family: Sora, sans-serif; + font-family: var(--font-display); font-size: 1.7rem; font-weight: 800; letter-spacing: -0.045em; @@ -319,7 +321,7 @@ justify-content: space-between; gap: 8px; margin: 0 0 8px; - font-family: Sora, sans-serif; + font-family: var(--font-display); font-size: .78rem; font-weight: 700; letter-spacing: .04em; @@ -528,7 +530,7 @@ width: 100%; min-height: 52px; font-size: 1.02rem; - font-family: Sora, sans-serif; + font-family: var(--font-display); } .actions { display: grid; gap: 8px; } @@ -749,7 +751,7 @@ background: var(--shop-bg); } .shop h3 { - font-family: Sora, sans-serif; + font-family: var(--font-display); font-size: 1rem; letter-spacing: -0.02em; margin: 0 0 5px; @@ -806,7 +808,7 @@ color: var(--ink); } .modal h2 { - font-family: Sora, sans-serif; + font-family: var(--font-display); margin: 0 0 12px; font-size: 1.2rem; letter-spacing: -.02em; diff --git a/internal/appui/app.js b/internal/appui/app.js index 5251e1d..fade72d 100644 --- a/internal/appui/app.js +++ b/internal/appui/app.js @@ -262,69 +262,94 @@ }); } + function pingLabel(pr) { + if (pr && pr.ok) { + return { + cls: "ms " + msClass(pr.ms, true), + text: (pr.soft ? "~" : "") + pr.ms + " ms", + title: pr.soft ? "soft-up (UDP без ответа) — не выбирается как «Лучший»" : "" + }; + } + if (pr && pr.error) { + return { cls: "ms bad", text: "—", title: pr.error }; + } + return { cls: "ms", text: "…", title: "" }; + } + + function ensureServerRow(p) { + let row = serverList.querySelector('.server-row[data-name="' + CSS.escape(p.name) + '"]'); + if (row) return row; + row = document.createElement("button"); + row.type = "button"; + row.className = "server-row"; + row.dataset.name = p.name; + const left = document.createElement("div"); + left.className = "left"; + const nameEl = document.createElement("div"); + nameEl.className = "name"; + const sub = document.createElement("div"); + sub.className = "sub"; + left.appendChild(nameEl); + left.appendChild(sub); + const right = document.createElement("div"); + right.className = "right"; + const proto = document.createElement("span"); + proto.className = "proto"; + const ms = document.createElement("span"); + ms.className = "ms"; + right.appendChild(proto); + right.appendChild(ms); + row.appendChild(left); + row.appendChild(right); + row.addEventListener("click", () => onServerClick(p.name)); + row.addEventListener("dblclick", () => onServerConnect(p.name)); + return row; + } + function renderServerList(activeName) { const active = activeName || profile.value; - serverList.innerHTML = ""; const all = orderedProfiles(); const list = filteredProfiles(); - if (!all.length) { + const keep = new Set(); + + if (!all.length || !list.length) { + serverList.innerHTML = ""; const empty = document.createElement("div"); empty.className = "server-empty"; - empty.textContent = "Нет серверов — добавьте профиль или подписку"; - serverList.appendChild(empty); - return; - } - if (!list.length) { - const empty = document.createElement("div"); - empty.className = "server-empty"; - empty.textContent = "Ничего не найдено"; + empty.textContent = !all.length + ? "Нет серверов — добавьте профиль или подписку" + : "Ничего не найдено"; serverList.appendChild(empty); return; } + + // Drop empty-state nodes from prior renders. + serverList.querySelectorAll(".server-empty").forEach((el) => el.remove()); + list.forEach((p) => { - const row = document.createElement("button"); - row.type = "button"; + keep.add(p.name); + const row = ensureServerRow(p); row.className = "server-row" + (p.name === active ? " active" : ""); - row.dataset.name = p.name; - - const left = document.createElement("div"); - left.className = "left"; - const nameEl = document.createElement("div"); - nameEl.className = "name"; - nameEl.textContent = p.name; - const sub = document.createElement("div"); - sub.className = "sub"; - sub.textContent = p.host || "нет хоста"; - left.appendChild(nameEl); - left.appendChild(sub); - - const right = document.createElement("div"); - right.className = "right"; - const proto = document.createElement("span"); - proto.className = "proto"; - proto.textContent = (p.protocol || "?").toString(); - const ms = document.createElement("span"); - const pr = pingMap[p.name]; - if (pr && pr.ok) { - ms.className = "ms " + msClass(pr.ms, true); - ms.textContent = (pr.soft ? "~" : "") + pr.ms + " ms"; - if (pr.soft) ms.title = "soft-up (UDP без ответа) — не выбирается как «Лучший»"; - } else if (pr && pr.error) { - ms.className = "ms bad"; - ms.textContent = "—"; - ms.title = pr.error; - } else { - ms.className = "ms"; - ms.textContent = "…"; + const nameEl = row.querySelector(".name"); + const sub = row.querySelector(".sub"); + const proto = row.querySelector(".proto"); + const ms = row.querySelector(".ms"); + if (nameEl && nameEl.textContent !== p.name) nameEl.textContent = p.name; + const hostText = p.host || "нет хоста"; + if (sub && sub.textContent !== hostText) sub.textContent = hostText; + const protoText = (p.protocol || "?").toString(); + if (proto && proto.textContent !== protoText) proto.textContent = protoText; + const pl = pingLabel(pingMap[p.name]); + if (ms) { + if (ms.className !== pl.cls) ms.className = pl.cls; + if (ms.textContent !== pl.text) ms.textContent = pl.text; + if ((ms.title || "") !== (pl.title || "")) ms.title = pl.title || ""; } - right.appendChild(proto); - right.appendChild(ms); + serverList.appendChild(row); // re-order if needed + }); - row.appendChild(left); - row.appendChild(right); - row.addEventListener("click", () => onServerClick(p.name)); - row.addEventListener("dblclick", () => onServerConnect(p.name)); - serverList.appendChild(row); + serverList.querySelectorAll(".server-row").forEach((row) => { + if (!keep.has(row.dataset.name)) row.remove(); }); } diff --git a/internal/appui/index.html b/internal/appui/index.html index 05de862..bcb06d5 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -3,10 +3,8 @@
+