mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
The Wails notifications service connects to the D-Bus session bus in its ServiceStartup, which Wails runs synchronously inside app.Run. daemonFeed.Watch was started before app.Run, so the first daemon SubscribeEvents message (which replays the cached available-update state) fanned out to the tray's update-state listener and fired an OS notification before that startup ran. The notifier's *dbus.Conn was still nil, so SendNotification nil-dereferenced deep in godbus and the panic was fatal to the whole process (observed on Linux Mint). Move daemonFeed.Watch into the ApplicationStarted hook so it runs after the service-startup loop, and route every notification send through a new safeSendNotification helper that recovers from a panic and logs it, so a broken or unavailable notification bus degrades to a skipped toast instead of crashing.
339 lines
12 KiB
Go
339 lines
12 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 groups the "Extend now" / "Dismiss"
|
|
// actions on the T-10min OS notification. Registered once at tray
|
|
// construction with the Wails notifications service; subsequent
|
|
// SendNotificationWithActions calls reference it by ID.
|
|
notifyCategorySessionWarning = "netbird-session-warning"
|
|
notifyActionExtendNow = "extend-now"
|
|
notifyActionDismiss = "dismiss"
|
|
|
|
// finalWarningCountdownSeconds is the countdown shown in the auto-opened
|
|
// SessionExpiration dialog. Mirrors sessionwatch.FinalWarningLead
|
|
// (2 minutes); the values stay in sync by hand because the lead is fixed
|
|
// for the initial rollout.
|
|
finalWarningCountdownSeconds = 120
|
|
)
|
|
|
|
// handleSessionExpired surfaces the SSO re-authentication path when the
|
|
// daemon reports StatusSessionExpired. Posts a single OS notification
|
|
// (the applyStatus guard ensures it fires only on the transition, not
|
|
// on every status snapshot) and brings the main window forward so the
|
|
// frontend's /login route can drive the renewed SSO flow. Mirrors the
|
|
// Fyne client's onSessionExpire, which used a runSelfCommand to spawn
|
|
// the login-url helper; here the window is already in-process.
|
|
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 "Session: 47m" tray row from the latest
|
|
// SSO deadline carried on the Status snapshot. Hidden when no deadline is
|
|
// tracked or the tunnel is down; otherwise renders the remaining time via
|
|
// formatSessionRemaining.
|
|
func (t *Tray) applySessionExpiry(deadline *time.Time, connected 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)
|
|
}
|
|
}
|
|
|
|
if t.sessionExpiresItem == nil {
|
|
return
|
|
}
|
|
if d.IsZero() {
|
|
t.sessionExpiresItem.SetHidden(true)
|
|
return
|
|
}
|
|
remaining := t.formatSessionRemaining(time.Until(d))
|
|
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
|
t.sessionExpiresItem.SetHidden(false)
|
|
}
|
|
|
|
// runSessionExpiryTicker keeps the "Expires in …" countdown row fresh by
|
|
// recomputing its label every 30 seconds for the app's lifetime. Started
|
|
// once on ApplicationStarted; the goroutine lives until the process exits.
|
|
func (t *Tray) runSessionExpiryTicker() {
|
|
tk := time.NewTicker(30 * time.Second)
|
|
for range tk.C {
|
|
t.refreshSessionExpiresLabel()
|
|
}
|
|
}
|
|
|
|
// refreshSessionExpiresLabel recomputes the "Session expires in …" tray
|
|
// row label from the cached SSO deadline.
|
|
func (t *Tray) refreshSessionExpiresLabel() {
|
|
if t.sessionExpiresItem == nil {
|
|
return
|
|
}
|
|
t.sessionMu.Lock()
|
|
deadline := t.sessionExpiresAt
|
|
t.sessionMu.Unlock()
|
|
if deadline.IsZero() {
|
|
return
|
|
}
|
|
remaining := t.formatSessionRemaining(time.Until(deadline))
|
|
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
|
}
|
|
|
|
// formatSessionRemaining renders the time-to-deadline as a localised
|
|
// long-form string ("47 minutes", "2 hours", "1 day"). Picks the
|
|
// largest unit that fits non-zero and keeps singular/plural distinct
|
|
// — the unit name keys (`tray.session.unit.minute(s)|hour(s)|day(s)`)
|
|
// are split per language so translators can spell each form properly.
|
|
// Sub-minute deltas read as "less than a minute" so a countdown that
|
|
// has rolled past zero between Status pushes still produces something
|
|
// sensible.
|
|
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 for the
|
|
// T-10min SSO expiry warning. The category carries two actions ("Extend now"
|
|
// and "Dismiss") and the global response handler so a click resolves back
|
|
// into runExtendSession. Idempotent — called once from NewTray; errors are
|
|
// logged and swallowed because the worst case is a plain text 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 covers the body-click on platforms
|
|
// that don't expose buttons separately (e.g. some minimal
|
|
// Linux notification daemons fall back to a single click
|
|
// area). Treat it as Extend so the user always has a path.
|
|
go t.runExtendSession()
|
|
case notifyActionDismiss:
|
|
// Explicit user opt-out. Tell the daemon so the
|
|
// T-FinalWarningLead fallback dialog stays closed for this
|
|
// deadline; the regular watcher remains armed for the next
|
|
// deadline value (e.g. after a successful extend elsewhere).
|
|
go t.dismissSessionWarning()
|
|
}
|
|
})
|
|
}
|
|
|
|
// buildSessionWarningBody composes the localised body for the T-10min
|
|
// notification from the daemon's metadata. The daemon does not have a
|
|
// locale, so it ships a stable RFC3339 deadline ("session_expires_at")
|
|
// and integer lead time ("lead_minutes") in metadata; the tray turns
|
|
// them into a user-language sentence via the active i18n bundle.
|
|
//
|
|
// Falls back to a constant string when the metadata is missing or the
|
|
// timestamp fails to parse — the user still sees the warning, just
|
|
// without the remaining-time count.
|
|
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 T-10min OS notification. Falls
|
|
// back to the plain `notify` helper if the Wails service doesn't expose the
|
|
// with-actions variant (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 {
|
|
// Fall back to a plain notification so the user at least gets
|
|
// the warning text, even without buttons. (A nil err here also
|
|
// covers the panic-recovered case, where the bus is dead and the
|
|
// plain fallback would fail too — so we correctly skip it.)
|
|
t.notify(title, body, notifyIDSessionWarning)
|
|
}
|
|
}
|
|
|
|
// runExtendSession drives the daemon's RequestExtendAuthSession +
|
|
// WaitExtendAuthSession pair when the user clicks "Extend now" on the
|
|
// session-warning notification. Mirrors `doExtendSession` in
|
|
// client/cmd/login.go but talks to the in-process Wails Session service
|
|
// instead of opening a daemon gRPC channel from a CLI process. The
|
|
// browser is opened via Connection.OpenURL (which honours $BROWSER on
|
|
// Unix). Errors surface as plain notifyError calls — there is no foreground
|
|
// UI flow here because the warning may fire while the main 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 (e.g. the about-to-expire dialog) started a
|
|
// flow for the same deadline and took over. Stay silent so the
|
|
// user only sees the outcome of the surviving flow.
|
|
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 T-FinalWarningLead
|
|
// fallback dialog for the current deadline. Best-effort: a failure only
|
|
// means the dialog will still appear, so we log and move on.
|
|
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 auto-opened fallback dialog at
|
|
// T-FinalWarningLead when the user did not dismiss the earlier T-10
|
|
// notification. Idempotent on the WindowManager side (a second call
|
|
// while the window is already open is a no-op).
|
|
func (t *Tray) openSessionExpiration() {
|
|
if t.svc.WindowManager == nil {
|
|
return
|
|
}
|
|
t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds)
|
|
}
|
|
|
|
// openSessionExtendFlow opens the SessionExpiration window seeded with
|
|
// the actual remaining time on the cached SSO deadline. Triggered by a
|
|
// click on the "Expires in …" tray row so the user can extend the session
|
|
// proactively, instead of waiting for the daemon's T-FinalWarningLead
|
|
// auto-prompt. Silently no-ops when the deadline is unknown or already
|
|
// elapsed — the menu row is hidden in those states anyway.
|
|
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)
|
|
}
|