diff --git a/.cursor/rules/post-build-notes.mdc b/.cursor/rules/post-build-notes.mdc new file mode 100644 index 0000000..134bbcd --- /dev/null +++ b/.cursor/rules/post-build-notes.mdc @@ -0,0 +1,26 @@ +--- +description: After every compile/bump, always write release notes for the new build +alwaysApply: true +--- + +# Описание изменений после компиляции + +После любой успешной сборки с бампом `BuildNumber` (через `build.bat`, `build-macos.bat` / скрипты bump, или ручной bump) **обязательно** обновить описание сделанных изменений — не оставлять только бинарник. + +## Что обновлять + +1. `notes` в: + - `dist/update.json` + - `dist/navis-release/update.json` + - `server/update.json` +2. Краткую строку в changelog `README.md` (секция текущей версии, формат `x.y.z+N: …`). +3. При релизе Windows-артефакта — `sha256` для `Navis.exe` / `windows-amd64` в тех же `update.json`. + +## Как писать notes + +- На русском, в стиле существующих записей. +- Начинать с `Navis x.y.z+N:` (как в `DisplayVersion` / build). +- Перечислить суть изменений для пользователя и ключевые тех. точки (не дамп диффа). +- Синхронизировать смысл между `update.json` и `README.md` (README можно короче). + +Не редактировать plan-файлы ради notes. Не коммитить и не пушить, пока пользователь явно не попросил. diff --git a/README.md b/README.md index 2007795..4984e32 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,8 @@ https://evilfox.win/ - 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении; - 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone; - 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+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.1: - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db31bfa..8a36aa6 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 = 3080205 - versionName = "3.8.2+5" + versionCode = 3080206 + versionName = "3.8.2+6" vectorDrawables.useSupportLibrary = true ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") diff --git a/build-macos.bat b/build-macos.bat index 587df60..53b0a4b 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.5 -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.6 -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.5 -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.6 -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.5 -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.6 -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 66af264..b6ffe15 100644 --- a/cmd/vpnapp/gui_desktop.go +++ b/cmd/vpnapp/gui_desktop.go @@ -74,9 +74,11 @@ func main() { trayhost.Start("Navis", trayhost.Hooks{ Connect: func() { _ = a.Connect() }, Disconnect: func() { _ = a.Disconnect() }, + Show: showMainWindow, Quit: func() { _ = a.Disconnect() - os.Exit(0) + a.Shutdown() + closeMainWindow() }, IsUp: func() bool { return a.Mgr.Status().Connected }, }) @@ -98,6 +100,7 @@ func main() { _ = srv.Close() return } + a.OnQuit = closeMainWindow defer func() { a.Shutdown() _ = mgr.Disconnect() diff --git a/cmd/vpnapp/platform_darwin.go b/cmd/vpnapp/platform_darwin.go index b8dc3fa..0b7fc7c 100644 --- a/cmd/vpnapp/platform_darwin.go +++ b/cmd/vpnapp/platform_darwin.go @@ -31,3 +31,11 @@ func decorateWindow(w glaze.WebView) { // macOS Dock/app icon comes from the .app bundle; nothing to apply here. _ = w } + +func showMainWindow() {} + +func prepareWindowQuit() {} + +func closeMainWindow() { + os.Exit(0) +} diff --git a/cmd/vpnapp/platform_windows.go b/cmd/vpnapp/platform_windows.go index 2cfb7a9..d0ef030 100644 --- a/cmd/vpnapp/platform_windows.go +++ b/cmd/vpnapp/platform_windows.go @@ -7,12 +7,38 @@ import ( "log" "os" "path/filepath" + "sync/atomic" "unsafe" "github.com/crgimenes/glaze" "golang.org/x/sys/windows" ) +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procGetWindowLongPtr = user32.NewProc("GetWindowLongPtrW") + procSetWindowLongPtr = user32.NewProc("SetWindowLongPtrW") + procCallWindowProc = user32.NewProc("CallWindowProcW") + procShowWindow = user32.NewProc("ShowWindow") + procSetForeground = user32.NewProc("SetForegroundWindow") + procPostMessage = user32.NewProc("PostMessageW") + procIsIconic = user32.NewProc("IsIconic") + + mainHWND uintptr + origWndProc uintptr + closeHookCB uintptr + allowDestroy atomic.Bool +) + +const ( + // GWLP_WNDPROC as unsigned pointer-sized -4. + gwlpWndProc = ^uintptr(3) + wmClose = 0x0010 + swHide = 0 + swShow = 5 + swRestore = 9 +) + func openExternal(raw string) error { verb, err := windows.UTF16PtrFromString("open") if err != nil { @@ -40,7 +66,61 @@ func decorateWindow(w glaze.WebView) { if w == nil { return } - applyWindowIcon(w.Window()) + hwnd := w.Window() + applyWindowIcon(hwnd) + installCloseToHide(hwnd) +} + +func installCloseToHide(hwnd unsafe.Pointer) { + if hwnd == nil { + return + } + mainHWND = uintptr(hwnd) + allowDestroy.Store(false) + if closeHookCB == 0 { + closeHookCB = windows.NewCallback(closeToHideWndProc) + } + orig, _, _ := procGetWindowLongPtr.Call(mainHWND, gwlpWndProc) + if orig == 0 { + return + } + origWndProc = orig + procSetWindowLongPtr.Call(mainHWND, gwlpWndProc, closeHookCB) +} + +func closeToHideWndProc(hwnd uintptr, msg uint32, wParam, lParam uintptr) uintptr { + if msg == wmClose && !allowDestroy.Load() { + procShowWindow.Call(hwnd, uintptr(swHide)) + return 0 + } + r, _, _ := procCallWindowProc.Call(origWndProc, hwnd, uintptr(msg), wParam, lParam) + return r +} + +func showMainWindow() { + if mainHWND == 0 { + return + } + iconic, _, _ := procIsIconic.Call(mainHWND) + if iconic != 0 { + procShowWindow.Call(mainHWND, uintptr(swRestore)) + } else { + procShowWindow.Call(mainHWND, uintptr(swShow)) + } + procSetForeground.Call(mainHWND) +} + +func prepareWindowQuit() { + allowDestroy.Store(true) +} + +func closeMainWindow() { + prepareWindowQuit() + if mainHWND == 0 { + os.Exit(0) + return + } + procPostMessage.Call(mainHWND, uintptr(wmClose), 0, 0) } func applyWindowIcon(hwnd unsafe.Pointer) { @@ -52,7 +132,6 @@ func applyWindowIcon(hwnd unsafe.Pointer) { if err != nil { return } - user32 := windows.NewLazySystemDLL("user32.dll") loadImage := user32.NewProc("LoadImageW") sendMessage := user32.NewProc("SendMessageW") const ( diff --git a/dist/navis-release/Navis-3.8.2.6.exe b/dist/navis-release/Navis-3.8.2.6.exe new file mode 100644 index 0000000..245160b Binary files /dev/null and b/dist/navis-release/Navis-3.8.2.6.exe differ diff --git a/dist/navis-release/Navis.exe b/dist/navis-release/Navis.exe index ed1c68d..245160b 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 feae301..edf3b9d 100644 --- a/dist/navis-release/update.json +++ b/dist/navis-release/update.json @@ -1,16 +1,16 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.", + "notes": "Navis 3.8.2+6: поиск/фильтр серверов и хоткеи (/, Ctrl+F, Space/Enter, Esc); Windows: закрытие окна = скрытие в трей, «Открыть»/Выход из трея; фоновый EnsureCore активного протокола; SSE /api/events для мгновенного статуса + poll 12 с как safety net.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", + "sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", + "sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e", "os": "windows", "arch": "amd64" }, diff --git a/dist/navis-release/vpnclient-3.8.2.6.exe b/dist/navis-release/vpnclient-3.8.2.6.exe new file mode 100644 index 0000000..42f54d8 Binary files /dev/null and b/dist/navis-release/vpnclient-3.8.2.6.exe differ diff --git a/dist/navis-release/vpnclient.exe b/dist/navis-release/vpnclient.exe index 018d8a5..42f54d8 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 feae301..edf3b9d 100644 --- a/dist/update.json +++ b/dist/update.json @@ -1,16 +1,16 @@ { "version": "3.8.2", - "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.", + "notes": "Navis 3.8.2+6: поиск/фильтр серверов и хоткеи (/, Ctrl+F, Space/Enter, Esc); Windows: закрытие окна = скрытие в трей, «Открыть»/Выход из трея; фоновый EnsureCore активного протокола; SSE /api/events для мгновенного статуса + poll 12 с как safety net.", "platform": "windows-amd64", "os": "windows", "arch": "amd64", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", + "sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e", "mandatory": false, "platforms": { "windows-amd64": { "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", - "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6", + "sha256": "aab618d452f8eddd7cc4400770e53a6a9c456b2bc6093190dd21f1b3fbcd765e", "os": "windows", "arch": "amd64" }, diff --git a/internal/apphost/app.go b/internal/apphost/app.go index 7eb29d9..20c1f9c 100644 --- a/internal/apphost/app.go +++ b/internal/apphost/app.go @@ -39,9 +39,11 @@ type App struct { Pings []netcheck.Result OpenURL func(string) error OnAfterUpdate func() + OnQuit func() APIToken string ctx context.Context cancel context.CancelFunc + events *eventBroker } type UIState struct { @@ -81,11 +83,12 @@ type PingBestResult struct { func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App { ctx, cancel := context.WithCancel(context.Background()) return &App{ - Mgr: mgr, + Mgr: mgr, CfgPath: cfgPath, - LogBuf: logBuf, - ctx: ctx, - cancel: cancel, + LogBuf: logBuf, + ctx: ctx, + cancel: cancel, + events: newEventBroker(), UpdateStatus: update.Status{ Current: update.DisplayVersion(), ManifestURL: update.DefaultManifestURL, @@ -257,8 +260,13 @@ func (a *App) CreateProfile(name, proxy string, systemProxy bool) error { func (a *App) SelectProfile(name string) error { a.mu.Lock() - defer a.mu.Unlock() - return a.Mgr.SetActiveProfile(name) + err := a.Mgr.SetActiveProfile(name) + a.mu.Unlock() + if err == nil { + go a.warmActiveCore() + a.NotifyState("profile") + } + return err } func (a *App) DeleteProfile(name string) error { @@ -305,6 +313,7 @@ func (a *App) ConnectProfile(name string) error { for attempt := 0; attempt < 2; attempt++ { if _, err := a.Mgr.EnsureCore(""); err != nil { a.Mgr.SetLastError(err.Error()) + a.NotifyState("error") return err } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) @@ -320,6 +329,7 @@ func (a *App) ConnectProfile(name string) error { pcancel() if perr == nil { dockicon.SetConnected(true) + a.NotifyState("connected") return nil } last = fmt.Errorf("туннель не прошёл проверку: %w", perr) @@ -329,12 +339,14 @@ func (a *App) ConnectProfile(name string) error { time.Sleep(400 * time.Millisecond) } dockicon.SetConnected(false) + a.NotifyState("error") return last } func (a *App) Disconnect() error { err := a.Mgr.Disconnect() dockicon.SetConnected(false) + a.NotifyState("disconnected") return err } @@ -367,6 +379,7 @@ func (a *App) SavePrefs(connectOnLaunch, autoReconnect, systemProxy bool) error func (a *App) StartBackground() { go a.watchdogLoop() go a.dockIconLoop() + go a.warmActiveCore() prefs := a.Mgr.Prefs() if prefs.ConnectOnLaunch { go func() { @@ -383,6 +396,20 @@ func (a *App) StartBackground() { } } +// warmActiveCore downloads/resolves the binary for the active profile protocol. +// Non-blocking; Connect still EnsureCore if this has not finished yet. +func (a *App) warmActiveCore() { + select { + case <-a.ctx.Done(): + return + case <-time.After(350 * time.Millisecond): + } + if _, err := a.Mgr.EnsureCore(""); err != nil { + // Soft fail — Connect will surface the error if the user tries. + return + } +} + func (a *App) dockIconLoop() { dockicon.SetConnected(false) var last bool @@ -620,6 +647,7 @@ func (a *App) Handler() http.Handler { } _, _ = io.WriteString(w, html) }) + mux.HandleFunc("/api/events", a.handleEvents) mux.HandleFunc("/api/", a.handleAPI) return mux } @@ -721,6 +749,11 @@ func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { go func() { time.Sleep(200 * time.Millisecond) _ = a.Mgr.Disconnect() + a.Shutdown() + if a.OnQuit != nil { + a.OnQuit() + return + } os.Exit(0) }() return "ok", nil diff --git a/internal/apphost/events.go b/internal/apphost/events.go new file mode 100644 index 0000000..4008155 --- /dev/null +++ b/internal/apphost/events.go @@ -0,0 +1,117 @@ +package apphost + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" +) + +// eventBroker fans out lightweight UI notifications (SSE). +type eventBroker struct { + mu sync.Mutex + subs map[chan []byte]struct{} +} + +func newEventBroker() *eventBroker { + return &eventBroker{subs: make(map[chan []byte]struct{})} +} + +func (b *eventBroker) subscribe() chan []byte { + ch := make(chan []byte, 8) + b.mu.Lock() + b.subs[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +func (b *eventBroker) unsubscribe(ch chan []byte) { + b.mu.Lock() + if _, ok := b.subs[ch]; ok { + delete(b.subs, ch) + close(ch) + } + b.mu.Unlock() +} + +func (b *eventBroker) publish(payload []byte) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.subs { + select { + case ch <- payload: + default: + // Drop if a slow client is behind; safety-net poll will catch up. + } + } +} + +// NotifyState pushes a small SSE payload so the UI can refresh immediately. +func (a *App) NotifyState(kind string) { + if a == nil || a.events == nil { + return + } + if kind == "" { + kind = "state" + } + b, err := json.Marshal(map[string]string{"type": kind}) + if err != nil { + return + } + a.events.publish(b) +} + +func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "GET only", http.StatusMethodNotAllowed) + return + } + if a.APIToken != "" { + tok := r.Header.Get("X-Navis-Token") + if tok == "" { + tok = r.URL.Query().Get("t") + } + if tok != a.APIToken { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ch := a.events.subscribe() + defer a.events.unsubscribe(ch) + + ping := time.NewTicker(20 * time.Second) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-a.ctx.Done(): + return + case <-ping.C: + if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { + return + } + flusher.Flush() + case msg, ok := <-ch: + if !ok { + return + } + if _, err := fmt.Fprintf(w, "data: %s\n\n", msg); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/internal/appui/app.css b/internal/appui/app.css index 04b14d8..ecbb037 100644 --- a/internal/appui/app.css +++ b/internal/appui/app.css @@ -548,6 +548,28 @@ margin-bottom: 8px; } .server-toolbar .section-title { margin: 0; } + .server-search-wrap { + margin: 0 0 8px; + } + .server-search-wrap input { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(8, 90, 68, 0.18); + background: var(--field-bg, rgba(255,255,255,.55)); + color: inherit; + border-radius: 12px; + padding: 9px 12px; + font: inherit; + font-size: .88rem; + outline: none; + } + .server-search-wrap input:focus { + border-color: rgba(37, 88, 75, 0.45); + } + [data-theme="dark"] .server-search-wrap input { + background: rgba(0,0,0,.22); + border-color: rgba(180, 220, 200, 0.14); + } .server-actions { display: flex; gap: 6px; diff --git a/internal/appui/app.js b/internal/appui/app.js index 68424b6..5251e1d 100644 --- a/internal/appui/app.js +++ b/internal/appui/app.js @@ -51,6 +51,7 @@ const bestBtn = $("bestBtn"); const autoBest = $("autoBest"); const serverList = $("serverList"); + const serverSearch = $("serverSearch"); const updCheckBtn = $("updCheckBtn"); const updateBanner = $("updateBanner"); const updateBtn = $("updateBtn"); @@ -251,17 +252,35 @@ return list; } + function filteredProfiles() { + const q = (serverSearch && serverSearch.value || "").trim().toLowerCase(); + const list = orderedProfiles(); + if (!q) return list; + return list.filter((p) => { + const hay = [p.name, p.host, p.protocol].map((x) => String(x || "").toLowerCase()).join(" "); + return hay.includes(q); + }); + } + function renderServerList(activeName) { const active = activeName || profile.value; serverList.innerHTML = ""; - const list = orderedProfiles(); - if (!list.length) { + const all = orderedProfiles(); + const list = filteredProfiles(); + if (!all.length) { 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 = "Ничего не найдено"; + serverList.appendChild(empty); + return; + } list.forEach((p) => { const row = document.createElement("button"); row.type = "button"; @@ -707,11 +726,60 @@ setMeta(String(e), "err"); } })(); + if (serverSearch) { + serverSearch.addEventListener("input", () => renderServerList(profile.value)); + } + + function typingInField(el) { + if (!el) return false; + const tag = (el.tagName || "").toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable; + } + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.classList.contains("open")) { + modal.classList.remove("open"); + e.preventDefault(); + return; + } + if (typingInField(document.activeElement)) return; + if ((e.key === "/" || (e.key === "f" && (e.ctrlKey || e.metaKey))) && serverSearch) { + e.preventDefault(); + serverSearch.focus(); + serverSearch.select(); + return; + } + if ((e.key === "Enter" || e.key === " ") && !busy) { + e.preventDefault(); + btn.click(); + } + }); + + // Safety-net poll; primary updates come from SSE /api/events when available. setInterval(() => { if (document.hidden || busy || modal.classList.contains("open")) return; refresh().catch(() => {}); - }, 4000); + }, 12000); document.addEventListener("visibilitychange", () => { if (!document.hidden && !busy) refresh({ force: true }).catch(() => {}); }); + + (function startEventStream() { + if (!window.__navisHttp || typeof EventSource === "undefined") return; + const tok = window.__NAVIS_TOKEN__ || ""; + const url = "/api/events" + (tok ? ("?t=" + encodeURIComponent(tok)) : ""); + let es; + const connectES = () => { + try { if (es) es.close(); } catch (_) {} + es = new EventSource(url); + es.onmessage = () => { + if (!busy) refresh({ force: true }).catch(() => {}); + }; + es.onerror = () => { + try { es.close(); } catch (_) {} + setTimeout(connectES, 2500); + }; + }; + connectES(); + })(); diff --git a/internal/appui/index.html b/internal/appui/index.html index eb5bb93..05de862 100644 --- a/internal/appui/index.html +++ b/internal/appui/index.html @@ -85,6 +85,9 @@ +
+ +