mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 23:29:08 +02:00
[client] UI refactor (#6069)
Refactor UI --------- 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: Claude <noreply@anthropic.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
co-authored by
Eduard Gert
braginini
Pascal Fischer
Claude
riccardom
parent
679c7182a4
commit
8b7ce337d8
@@ -0,0 +1,271 @@
|
||||
//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)
|
||||
}
|
||||
Reference in New Issue
Block a user