mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 19:49:56 +00:00
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
272 lines
9.1 KiB
Go
272 lines
9.1 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 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=<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 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)
|
|
}
|