Files
netbird/client/ui/tray_session.go
Zoltan Papp 086d8ba507 [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.
2026-08-31 10:32:36 +02:00

326 lines
11 KiB
Go

//go:build !android && !ios && !freebsd && !js
package main
import (
"context"
"strconv"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
nbstatus "github.com/netbirdio/netbird/client/status"
"github.com/netbirdio/netbird/client/ui/authsession"
"github.com/netbirdio/netbird/client/ui/services"
)
const (
notifyIDSessionExpired = "netbird-session-expired"
notifyIDSessionWarning = "netbird-session-warning"
notifyCategorySessionWarning = "netbird-session-warning"
notifyActionExtendNow = "extend-now"
notifyActionDismiss = "dismiss"
// finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead.
finalWarningCountdownSeconds = 120
)
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
t.showMain()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
// Cache-only; the caller relayouts when this returns true.
func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool {
var d time.Time
if connected && deadline != nil {
d = *deadline
}
t.sessionMu.Lock()
changed := !t.sessionExpiresAt.Equal(d)
t.sessionExpiresAt = d
t.sessionMu.Unlock()
if changed {
switch {
case deadline == nil:
log.Infof("tray applySessionExpiry: deadline=<nil> connected=%v → row hidden", connected)
case deadline.IsZero():
log.Infof("tray applySessionExpiry: deadline=<zero> connected=%v → row hidden", connected)
default:
log.Infof("tray applySessionExpiry: deadline=%s (in %s) connected=%v",
deadline.Format(time.RFC3339), time.Until(*deadline), connected)
}
}
return changed
}
// runSessionExpiryTicker recomputes the "Expires in …" row label until process exit.
// The interval scales with the remaining time: coarse when the deadline is far off,
// down to 10s in the final two minutes so the label doesn't lag the ceiling-rounded
// countdown near expiry. The cached deadline is re-read every iteration, so an extend
// or reconnect that moves it is picked up on the next tick.
func (t *Tray) runSessionExpiryTicker() {
tm := time.NewTimer(sessionRefreshInterval(t.sessionRemaining()))
defer tm.Stop()
for range tm.C {
t.refreshSessionExpiresLabel()
tm.Reset(sessionRefreshInterval(t.sessionRemaining()))
}
}
// sessionRemaining returns the time left on the cached SSO deadline, or 0 when unknown.
func (t *Tray) sessionRemaining() time.Duration {
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return 0
}
return time.Until(deadline)
}
// sessionRefreshInterval picks how long to wait before the next label recompute.
func sessionRefreshInterval(remaining time.Duration) time.Duration {
switch {
case remaining <= 0:
return 30 * time.Second
case remaining <= 2*time.Minute:
return 10 * time.Second
case remaining <= time.Hour:
return 30 * time.Second
default:
return time.Minute
}
}
// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu.
// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout.
func (t *Tray) refreshSessionExpiresLabel() {
t.menuMu.Lock()
item := t.sessionExpiresItem
t.menuMu.Unlock()
if item == nil {
return
}
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return
}
item.SetLabel(t.sessionRowLabel(deadline))
}
func (t *Tray) sessionRowLabel(deadline time.Time) string {
remaining := time.Until(deadline)
if remaining <= 0 {
return t.loc.T("tray.status.sessionExpired")
}
return t.loc.T("tray.session.expiresIn", "remaining", t.formatSessionRemaining(remaining))
}
// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit.
// Each unit is rounded up so the label never claims less time than actually remains, matching the
// upper-bound sense of the sub-minute "less than a minute" fragment.
// Singular/plural keys are split per language for proper translation.
func (t *Tray) formatSessionRemaining(d time.Duration) string {
switch {
case d < time.Minute:
return t.loc.T("tray.session.unit.lessThanMinute")
case d <= 59*time.Minute:
m := ceilDiv(d, time.Minute)
if m == 1 {
return t.loc.T("tray.session.unit.minute")
}
return t.loc.T("tray.session.unit.minutes", "count", strconv.Itoa(m))
case d <= 23*time.Hour:
h := ceilDiv(d, time.Hour)
if h == 1 {
return t.loc.T("tray.session.unit.hour")
}
return t.loc.T("tray.session.unit.hours", "count", strconv.Itoa(h))
default:
days := ceilDiv(d, 24*time.Hour)
if days == 1 {
return t.loc.T("tray.session.unit.day")
}
return t.loc.T("tray.session.unit.days", "count", strconv.Itoa(days))
}
}
// ceilDiv divides d by unit rounding up, assuming d > 0.
func ceilDiv(d, unit time.Duration) int {
return int((d + unit - time.Nanosecond) / unit)
}
// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning.
// Errors are swallowed since the worst case is a plain notification without buttons.
func (t *Tray) registerSessionWarningCategory() {
if t.svc.Notifier == nil {
return
}
if err := t.svc.Notifier.RegisterNotificationCategory(notifications.NotificationCategory{
ID: notifyCategorySessionWarning,
Actions: []notifications.NotificationAction{
{ID: notifyActionExtendNow, Title: t.loc.T("notify.sessionWarning.extend")},
{ID: notifyActionDismiss, Title: t.loc.T("notify.sessionWarning.dismiss")},
},
}); err != nil {
log.Debugf("register session-warning notification category: %v", err)
}
t.svc.Notifier.OnNotificationResponse(func(result notifications.NotificationResult) {
if result.Error != nil {
log.Debugf("notification response error: %v", result.Error)
return
}
if result.Response.CategoryID != notifyCategorySessionWarning {
return
}
switch result.Response.ActionIdentifier {
case notifyActionExtendNow, notifications.DefaultActionIdentifier:
// DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend.
go t.runExtendSession()
case notifyActionDismiss:
go t.dismissSessionWarning()
}
})
}
// buildSessionWarningBody composes the localised notification body from the daemon's metadata.
// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence.
// Falls back to a generic string when metadata is missing or unparsable.
func (t *Tray) buildSessionWarningBody(meta map[string]string) string {
if meta == nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
raw := meta[authsession.MetaExpiresAt]
if raw == "" {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
deadline, err := authsession.ParseExpiresAt(raw)
if err != nil {
return t.loc.T("notify.sessionWarning.bodyGeneric")
}
remaining := nbstatus.FormatRemainingDuration(time.Until(deadline))
return t.loc.T("notify.sessionWarning.body", "remaining", remaining)
}
// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the
// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests).
func (t *Tray) notifySessionWarning(title, body string) {
if t.svc.Notifier == nil {
return
}
err := safeSendNotification(t.svc.Notifier.SendNotificationWithActions, "session-warning with actions", notifications.NotificationOptions{
ID: notifyIDSessionWarning,
Title: title,
Body: body,
CategoryID: notifyCategorySessionWarning,
})
if err != nil {
// A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too).
t.notify(title, body, notifyIDSessionWarning)
}
}
// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL.
// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed.
func (t *Tray) runExtendSession() {
if t.svc.Session == nil || t.svc.Connection == nil {
log.Debugf("session-warning: extend requested but services not wired")
return
}
ctx := context.Background()
start, err := t.svc.Session.RequestExtend(ctx, services.ExtendStartParams{})
if err != nil {
log.Warnf("session-warning: RequestExtend failed: %v", err)
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
return
}
uri := start.VerificationURIComplete
if uri == "" {
uri = start.VerificationURI
}
if uri != "" {
if err := t.svc.Connection.OpenURL(uri); err != nil {
log.Debugf("session-warning: opening verification URL: %v", err)
}
}
result, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{
DeviceCode: start.DeviceCode,
UserCode: start.UserCode,
})
if err != nil {
log.Warnf("session-warning: WaitExtend failed: %v", err)
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
return
}
if result.Preempted {
// Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome.
log.Debugf("session-warning: WaitExtend preempted by a newer flow")
return
}
t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning)
}
// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline.
// Best-effort: a failure only means the dialog will still appear.
func (t *Tray) dismissSessionWarning() {
if t.svc.Session == nil {
return
}
if err := t.svc.Session.DismissWarning(context.Background()); err != nil {
log.Debugf("session-warning: DismissWarning failed: %v", err)
}
}
// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed.
// 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
}
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,
// for the "Expires in …" tray row. Once the deadline has elapsed the row reads "Session expired" and the
// click routes to the login flow instead. No-op when the deadline is unknown.
func (t *Tray) openSessionExtendFlow() {
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
t.showMainAndEmit(services.EventTriggerLogin)
return
}
if t.svc.WindowManager == nil {
return
}
t.svc.WindowManager.OpenSessionExpiration(seconds, deadline.UnixMilli())
}