From 086d8ba5078c9ce5b7b5e48fcf773b8e44795278 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 31 Aug 2026 10:32:36 +0200 Subject: [PATCH] [client] Close the session-expiration dialog only on renewal (#7337) * [client] Close the session-expiration dialog only on an actual session renewal The dialog auto-closed on any Connected status snapshot, but the daemon emits Connected periodically regardless of session state, so the warning popup disappeared on the next snapshot (~30s) with no chance to re-authenticate. Close only when the snapshot's session deadline jumps past the one the dialog was opened for, meaning the session was renewed from another surface (tray action, CLI, main window). * [client] Compare session renewals against the exact deadline in the expiration dialog The dialog reconstructed its reference deadline from the relative seconds URL parameter, which carries up to a second of truncation and mount latency, forcing a renewal-detection margin wide enough to miss a renewal made shortly after the previous login. Pass the absolute deadline (unix ms) from both tray call sites - the extend flow's cached deadline and the final warning's event metadata - so any forward jump in the snapshot deadline closes the dialog; the seconds-derived fallback with a small tolerance remains for an unknown deadline. * [client] Derive the expiration dialog countdown from the deadline The per-second decrement assumed the interval fires once a second, but the webview's timers get suspended for tens of seconds under App Nap / hidden-window throttling, leaving the displayed countdown behind the wall clock by the suspended time. Recompute the remaining time from the absolute deadline on every tick so the first tick after a suspension shows the correct value. * [client] Tolerate the warning deadline's second precision in the renewal check The final-warning metadata formats the deadline as RFC3339 truncated to whole seconds while the status snapshot keeps millisecond precision, so an unchanged deadline could appear up to 999 ms newer than the exact URL value and close the dialog on the first snapshot. Allow a sub-second tolerance on the exact path; any real renewal jumps by at least seconds. --- .../session/SessionExpirationDialog.tsx | 51 ++++++++++++++++--- client/ui/services/windowmanager.go | 8 ++- client/ui/tray_events.go | 3 +- client/ui/tray_session.go | 19 +++++-- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx index ef8d6862f..e57040a7a 100644 --- a/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -18,6 +18,11 @@ import { formatRemaining } from "@/lib/formatters"; const DEFAULT_SECONDS = 360; const WINDOW_WIDTH = 360; const SOON_THRESHOLD_SECONDS = 60 * 60; +const DEADLINE_TOLERANCE_MS = 5 * 1000; +// The final-warning deadline reaches the Go side as RFC3339 truncated to whole +// seconds, while the status snapshot carries millisecond precision, so an +// unchanged deadline can look up to 999 ms newer than the exact URL value. +const EXACT_DEADLINE_TOLERANCE_MS = 999; export default function SessionExpirationDialog() { const { t } = useTranslation(); @@ -29,11 +34,19 @@ export default function SessionExpirationDialog() { const n = Number.parseInt(raw, 10); return Number.isFinite(n) && n > 0 ? n : DEFAULT_SECONDS; }, [params]); + const initialDeadline = useMemo(() => { + const raw = params.get("deadline"); + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }, [params]); const [remaining, setRemaining] = useState(initialSeconds); const [busy, setBusy] = useState(false); const busyRef = useRef(busy); busyRef.current = busy; + const openedDeadlineRef = useRef(initialDeadline ?? Date.now() + initialSeconds * 1000); + const exactDeadlineRef = useRef(initialDeadline !== null); const expired = remaining <= 0; const expiredRef = useRef(expired); expiredRef.current = expired; @@ -45,23 +58,45 @@ export default function SessionExpirationDialog() { useEffect(() => { setRemaining(initialSeconds); - }, [initialSeconds]); + openedDeadlineRef.current = initialDeadline ?? Date.now() + initialSeconds * 1000; + exactDeadlineRef.current = initialDeadline !== null; + }, [initialSeconds, initialDeadline]); + // Recompute from the absolute deadline instead of decrementing per tick: webview + // timers get suspended for tens of seconds (App Nap / hidden-window throttling), + // so a tick counter drifts behind the wall clock by the suspended time. useEffect(() => { const id = globalThis.setInterval(() => { - setRemaining((s) => (s <= 1 ? 0 : s - 1)); + setRemaining(Math.max(0, Math.ceil((openedDeadlineRef.current - Date.now()) / 1000))); }, 1000); return () => globalThis.clearInterval(id); }, [initialSeconds]); + // Auto-close only when the session was actually renewed elsewhere (tray action, CLI, + // main window): the daemon keeps emitting Connected snapshots regardless of session + // state, so the signal is the deadline jumping past the one this dialog was opened for. + // With the exact deadline from the URL any jump past its sub-second precision loss + // counts; the seconds-derived fallback needs a wider tolerance for the Go-side + // truncation and mount latency. // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). useEffect(() => { - const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { - if (busyRef.current || expiredRef.current) return; - if (ev?.data?.status === "Connected") { - WindowManager.CloseSessionExpiration().catch(console.error); - } - }); + const off = Events.On( + "netbird:status", + (ev: { data: { status?: string; sessionExpiresAt?: string | null } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status !== "Connected") return; + const raw = ev?.data?.sessionExpiresAt; + if (!raw) return; + const renewed = Date.parse(raw); + if (!Number.isFinite(renewed)) return; + const tolerance = exactDeadlineRef.current + ? EXACT_DEADLINE_TOLERANCE_MS + : DEADLINE_TOLERANCE_MS; + if (renewed - openedDeadlineRef.current > tolerance) { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }, + ); return () => { off(); }; diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 4930ce22b..94dba6038 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -292,11 +292,15 @@ func (s *WindowManager) CloseBrowserLogin() { } // OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds -// the countdown. Singleton, destroyed on close. -func (s *WindowManager) OpenSessionExpiration(seconds int) { +// the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog +// compares renewal snapshots against. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { s.mu.Lock() defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + if deadlineUnixMilli > 0 { + startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) + } if s.sessionExpiration == nil { opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) opts.Screen = s.getScreenBasedOnCursorPosition() diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..f23b5d715 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -76,7 +76,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { - t.openSessionExpiration() + deadline, _ := authsession.ParseExpiresAt(se.Metadata[authsession.MetaExpiresAt]) + t.openSessionExpiration(deadline) return } t.notifySessionWarning( diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 6e5d07740..91c38be08 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -284,12 +284,23 @@ func (t *Tray) dismissSessionWarning() { } // openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. -// Idempotent on the WindowManager side. -func (t *Tray) openSessionExpiration() { +// deadline is the absolute expiry from the warning event's metadata; when zero (older daemon, +// malformed metadata) the cached status-snapshot deadline fills in. Idempotent on the +// WindowManager side. +func (t *Tray) openSessionExpiration(deadline time.Time) { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) + if deadline.IsZero() { + t.sessionMu.Lock() + deadline = t.sessionExpiresAt + t.sessionMu.Unlock() + } + var deadlineMs int64 + if !deadline.IsZero() { + deadlineMs = deadline.UnixMilli() + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds, deadlineMs) } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, @@ -310,5 +321,5 @@ func (t *Tray) openSessionExtendFlow() { if t.svc.WindowManager == nil { return } - t.svc.WindowManager.OpenSessionExpiration(seconds) + t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli()) }