//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 frontend's /login route drives renewal. func (t *Tray) handleSessionExpired() { t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) if t.window != nil { t.window.SetURL("/#/login") t.window.Show() t.window.Focus() } } // 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= connected=%v → row hidden", connected) case deadline.IsZero(): log.Infof("tray applySessionExpiry: deadline= 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 every 30s. Runs until process exit. func (t *Tray) runSessionExpiryTicker() { tk := time.NewTicker(30 * time.Second) for range tk.C { t.refreshSessionExpiresLabel() } } // 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 } remaining := t.formatSessionRemaining(time.Until(deadline)) item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) } // formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. // 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 < time.Hour: m := int(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 < 24*time.Hour: h := int((d + 30*time.Minute) / 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 := int((d + 12*time.Hour) / (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)) } } // 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. // Idempotent on the WindowManager side. func (t *Tray) openSessionExpiration() { if t.svc.WindowManager == nil { return } t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) } // openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, // for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed. func (t *Tray) openSessionExtendFlow() { if t.svc.WindowManager == nil { return } t.sessionMu.Lock() deadline := t.sessionExpiresAt t.sessionMu.Unlock() if deadline.IsZero() { return } seconds := int(time.Until(deadline).Seconds()) if seconds <= 0 { return } t.svc.WindowManager.OpenSessionExpiration(seconds) }