mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-21 07:51:29 +02:00
Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts: # client/ui/frontend/src/screens/Update.tsx
This commit is contained in:
126
client/ui/authsession/service.go
Normal file
126
client/ui/authsession/service.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// ExtendStartParams optionally pre-fills the IdP login form.
|
||||
type ExtendStartParams struct {
|
||||
// Hint is the OIDC login_hint, typically the user's email.
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// ExtendStartResult tells the UI what to open and how to match the
|
||||
// follow-up Wait call to the daemon's pending flow.
|
||||
type ExtendStartResult struct {
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
UserCode string `json:"userCode"`
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
ExpiresIn int64 `json:"expiresIn"`
|
||||
}
|
||||
|
||||
// ExtendWaitParams identifies the pending flow by the device/user code
|
||||
// the UI received from RequestExtend.
|
||||
type ExtendWaitParams struct {
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
UserCode string `json:"userCode"`
|
||||
}
|
||||
|
||||
// ExtendResult carries the refreshed deadline. ExpiresAt is nil when the
|
||||
// management server reported the peer is not eligible for session
|
||||
// extension.
|
||||
type ExtendResult struct {
|
||||
ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// DaemonConn yields a lazy daemon gRPC client. Mirrors services.DaemonConn
|
||||
// in the Wails services package; duplicated here so the Session can be
|
||||
// owned by authsession without an import cycle.
|
||||
type DaemonConn interface {
|
||||
Client() (proto.DaemonServiceClient, error)
|
||||
}
|
||||
|
||||
// Session bundles the session-auth daemon RPCs the UI drives — the
|
||||
// interactive extend flow (RequestExtend + WaitExtend) and the Dismiss
|
||||
// hand-off. The tray uses it directly; the Wails-bound wrapper in
|
||||
// client/ui/services exposes only the subset the React frontend needs.
|
||||
type Session struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
// NewSession returns a Session backed by the shared daemon connection.
|
||||
func NewSession(conn DaemonConn) *Session {
|
||||
return &Session{conn: conn}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||
// returns the verification URI for the UI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ExtendStartResult{}, err
|
||||
}
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
if p.Hint != "" {
|
||||
h := p.Hint
|
||||
req.Hint = &h
|
||||
}
|
||||
|
||||
resp, err := cli.RequestExtendAuthSession(ctx, req)
|
||||
if err != nil {
|
||||
return ExtendStartResult{}, err
|
||||
}
|
||||
|
||||
return ExtendStartResult{
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
UserCode: resp.GetUserCode(),
|
||||
DeviceCode: resp.GetDeviceCode(),
|
||||
ExpiresIn: resp.GetExpiresIn(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the user completes the SSO flow started by
|
||||
// RequestExtend, then returns the new session deadline (or nil when the
|
||||
// management server reports the peer ineligible).
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
|
||||
resp, err := cli.WaitExtendAuthSession(ctx, &proto.WaitExtendAuthSessionRequest{
|
||||
DeviceCode: p.DeviceCode,
|
||||
UserCode: p.UserCode,
|
||||
})
|
||||
if err != nil {
|
||||
return ExtendResult{}, err
|
||||
}
|
||||
|
||||
out := ExtendResult{}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DismissWarning records the user's "Dismiss" click on the T-WarningLead
|
||||
// notification so the daemon suppresses the T-FinalWarningLead fallback
|
||||
// dialog for the current deadline. Best-effort: the daemon never reports
|
||||
// a "deadline not found" error — a stale or no-op call is silently swallowed.
|
||||
func (s *Session) DismissWarning(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.DismissSessionWarning(ctx, &proto.DismissSessionWarningRequest{})
|
||||
return err
|
||||
}
|
||||
83
client/ui/authsession/warning.go
Normal file
83
client/ui/authsession/warning.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
// Package authsession holds the UI-side domain logic for the SSO
|
||||
// session-extend feature. Wails service facades in
|
||||
// client/ui/services/session*.go are thin adapters around the types and
|
||||
// functions defined here; the parsing, request shapes, and constants
|
||||
// live in this package so future-us can reason about (and test) the
|
||||
// feature without dragging the Wails service surface around with it.
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
|
||||
)
|
||||
|
||||
// Metadata keys the daemon attaches to session-warning SystemEvents.
|
||||
// Re-exported from sessionwatch (single source of truth on the daemon
|
||||
// side) so UI-side consumers don't have to import the daemon-internal
|
||||
// package directly.
|
||||
const (
|
||||
MetaWarning = sessionwatch.MetaSessionWarning
|
||||
MetaFinal = sessionwatch.MetaSessionFinal
|
||||
MetaExpiresAt = sessionwatch.MetaSessionExpiresAt
|
||||
MetaLeadMinutes = sessionwatch.MetaSessionLeadMinutes
|
||||
)
|
||||
|
||||
// Warning is the typed payload emitted on the session-warning Wails
|
||||
// events. The React side subscribes to "netbird:session:warning" and
|
||||
// "netbird:session:final-warning" and receives this shape.
|
||||
//
|
||||
// ExpiresAt is best-effort: when the metadata is missing or malformed
|
||||
// (e.g. an older daemon emits the event without the timestamp) it stays
|
||||
// zero — the UI can fall back to the Status snapshot.
|
||||
type Warning struct {
|
||||
// ExpiresAt is the absolute UTC deadline the warning was fired
|
||||
// against. The UI displays remaining time relative to its own clock.
|
||||
ExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||
// LeadMinutes is the warning's configured lead time in minutes
|
||||
// (WarningLead for the T-10 event, FinalWarningLead for the T-2
|
||||
// event). Exposed so the UI can show "expires in ~N minutes" without
|
||||
// hardcoding either constant on its side.
|
||||
LeadMinutes int `json:"leadMinutes"`
|
||||
// Final is true on the T-FinalWarningLead fallback event and false
|
||||
// on the regular T-WarningLead notification. Exposed so a frontend
|
||||
// listener bound to the dedicated final-warning Wails event still
|
||||
// receives a payload it can self-describe (and so a tray that
|
||||
// happens to see both event streams can branch in one place).
|
||||
Final bool `json:"final"`
|
||||
}
|
||||
|
||||
// WarningFromMetadata parses the daemon's SystemEvent metadata into a
|
||||
// Warning payload. Returns (nil, false) when the event is not a
|
||||
// session-warning at all (the common case). When the metadata flag is
|
||||
// set but a field fails to parse, the field stays at its zero value and
|
||||
// the event is still surfaced — the UI gets to decide how to handle it.
|
||||
func WarningFromMetadata(meta map[string]string) (*Warning, bool) {
|
||||
if meta == nil || meta[MetaWarning] != "true" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
out := &Warning{
|
||||
Final: meta[MetaFinal] == "true",
|
||||
}
|
||||
if raw := meta[MetaExpiresAt]; raw != "" {
|
||||
if t, err := sessionwatch.ParseExpiresAt(raw); err == nil {
|
||||
out.ExpiresAt = t
|
||||
}
|
||||
}
|
||||
if raw := meta[MetaLeadMinutes]; raw != "" {
|
||||
if n, err := sessionwatch.ParseLeadMinutes(raw); err == nil {
|
||||
out.LeadMinutes = n
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// ParseExpiresAt decodes a MetaExpiresAt metadata value to a UTC time.
|
||||
// Thin re-export of sessionwatch.ParseExpiresAt so UI-side call sites
|
||||
// (tray, frontend bindings) don't import the daemon-internal package.
|
||||
func ParseExpiresAt(s string) (time.Time, error) {
|
||||
return sessionwatch.ParseExpiresAt(s)
|
||||
}
|
||||
82
client/ui/authsession/warning_test.go
Normal file
82
client/ui/authsession/warning_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package authsession
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWarningFromMetadata_NotASessionWarning(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
meta map[string]string
|
||||
}{
|
||||
{"nil metadata", nil},
|
||||
{"empty map", map[string]string{}},
|
||||
{"unrelated event", map[string]string{"new_version_available": "0.65.0"}},
|
||||
{"flag not 'true'", map[string]string{"session_warning": "1"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if w, ok := WarningFromMetadata(tc.meta); ok {
|
||||
t.Fatalf("expected (nil, false), got (%+v, %v)", w, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_FullPayload(t *testing.T) {
|
||||
ts := "2026-05-18T13:30:00Z"
|
||||
meta := map[string]string{
|
||||
"session_warning": "true",
|
||||
"session_expires_at": ts,
|
||||
"lead_minutes": "10",
|
||||
}
|
||||
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("expected the warning to be recognised, got ok=false")
|
||||
}
|
||||
want, _ := time.Parse(time.RFC3339, ts)
|
||||
if !got.ExpiresAt.Equal(want.UTC()) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, want.UTC())
|
||||
}
|
||||
if got.LeadMinutes != 10 {
|
||||
t.Errorf("LeadMinutes = %d, want 10", got.LeadMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_BadFieldsStillEmits(t *testing.T) {
|
||||
// Older or buggy daemon: the flag is set but the timestamp/lead are
|
||||
// missing or malformed. The UI should still get a warning so it can
|
||||
// at least surface "session expires soon"; field zero-values are fine.
|
||||
meta := map[string]string{
|
||||
"session_warning": "true",
|
||||
"session_expires_at": "not-a-timestamp",
|
||||
"lead_minutes": "abc",
|
||||
}
|
||||
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("warning should still be recognised even with malformed fields")
|
||||
}
|
||||
if !got.ExpiresAt.IsZero() {
|
||||
t.Errorf("malformed timestamp should leave field zero, got %v", got.ExpiresAt)
|
||||
}
|
||||
if got.LeadMinutes != 0 {
|
||||
t.Errorf("malformed lead_minutes should leave field 0, got %d", got.LeadMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningFromMetadata_MissingFieldsStillEmits(t *testing.T) {
|
||||
// Only the flag is present (e.g. future-trimmed event). Still emit.
|
||||
meta := map[string]string{"session_warning": "true"}
|
||||
got, ok := WarningFromMetadata(meta)
|
||||
if !ok {
|
||||
t.Fatalf("warning should still be recognised when only flag is present")
|
||||
}
|
||||
if got.ExpiresAt.IsZero() != true || got.LeadMinutes != 0 {
|
||||
t.Errorf("missing fields should be zero-valued, got %+v", got)
|
||||
}
|
||||
}
|
||||
3193
client/ui/frontend/pnpm-lock.yaml
generated
3193
client/ui/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { NewProfileModal } from "@/components/NewProfileModal";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -40,7 +41,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -70,7 +71,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Getrennt",
|
||||
"tray.status.daemonUnavailable": "Nicht aktiv",
|
||||
"tray.status.error": "Fehler",
|
||||
"tray.session.expiresIn": "Sitzung: {remaining}",
|
||||
|
||||
"tray.menu.open": "NetBird öffnen",
|
||||
"tray.menu.connect": "Verbinden",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
|
||||
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
|
||||
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"notify.sessionWarning.title": "Sitzung läuft bald ab",
|
||||
"notify.sessionWarning.body": "Ihre NetBird-Sitzung läuft in {remaining} ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
|
||||
"notify.sessionWarning.bodyGeneric": "Ihre NetBird-Sitzung läuft bald ab. Klicken Sie auf Jetzt verlängern, um zu erneuern.",
|
||||
"notify.sessionWarning.extend": "Jetzt verlängern",
|
||||
"notify.sessionWarning.dismiss": "Verwerfen",
|
||||
"notify.sessionWarning.failed": "NetBird-Sitzung konnte nicht verlängert werden",
|
||||
"notify.sessionWarning.successTitle": "NetBird-Sitzung verlängert",
|
||||
"notify.sessionWarning.successBody": "Ihre Sitzung wurde erneuert.",
|
||||
|
||||
"common.cancel": "Abbrechen",
|
||||
"common.save": "Speichern",
|
||||
@@ -272,6 +281,7 @@
|
||||
"sessionAboutToExpire.stay": "Verbunden bleiben",
|
||||
"sessionAboutToExpire.logout": "Abmelden",
|
||||
"sessionAboutToExpire.expired": "Sitzung abgelaufen",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Sitzungsverlängerung fehlgeschlagen",
|
||||
|
||||
"peers.search.placeholder": "Nach Peer-Name, DNS oder IP-Adresse suchen",
|
||||
"peers.filter.all": "Alle",
|
||||
@@ -284,5 +294,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird-Dienst läuft nicht",
|
||||
"daemon.unavailable.description": "Die App stellt automatisch die Verbindung wieder her, sobald der Dienst läuft.",
|
||||
"daemon.unavailable.docsLink": "Dokumentation"
|
||||
"daemon.unavailable.docsLink": "Dokumentation",
|
||||
|
||||
"error.jwt_clock_skew": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut.",
|
||||
"error.jwt_expired": "Ihr Anmeldetoken ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error.jwt_signature_invalid": "Anmeldung fehlgeschlagen: Die Token-Signatur ist ungültig. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"error.session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"error.invalid_setup_key": "Der Setup-Schlüssel fehlt oder ist ungültig.",
|
||||
"error.permission_denied": "Die Anmeldung wurde vom Server abgelehnt.",
|
||||
"error.daemon_unreachable": "Der NetBird-Dienst antwortet nicht. Bitte prüfen Sie, ob der Dienst läuft.",
|
||||
"error.unknown": "Vorgang fehlgeschlagen. Technische Details siehe unten."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Disconnected",
|
||||
"tray.status.daemonUnavailable": "Not running",
|
||||
"tray.status.error": "Error",
|
||||
"tray.session.expiresIn": "Session: {remaining}",
|
||||
|
||||
"tray.menu.open": "Open NetBird",
|
||||
"tray.menu.connect": "Connect",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Failed to switch to {profile}",
|
||||
"notify.sessionExpired.title": "NetBird session expired",
|
||||
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
|
||||
"notify.sessionWarning.title": "Session expires soon",
|
||||
"notify.sessionWarning.body": "Your NetBird session expires in {remaining}. Click Extend now to renew.",
|
||||
"notify.sessionWarning.bodyGeneric": "Your NetBird session is about to expire. Click Extend now to renew.",
|
||||
"notify.sessionWarning.extend": "Extend now",
|
||||
"notify.sessionWarning.dismiss": "Dismiss",
|
||||
"notify.sessionWarning.failed": "Failed to extend NetBird session",
|
||||
"notify.sessionWarning.successTitle": "NetBird session extended",
|
||||
"notify.sessionWarning.successBody": "Your session has been refreshed.",
|
||||
|
||||
"common.cancel": "Cancel",
|
||||
"common.save": "Save",
|
||||
@@ -293,6 +302,7 @@
|
||||
"sessionAboutToExpire.stay": "Stay connected",
|
||||
"sessionAboutToExpire.logout": "Logout",
|
||||
"sessionAboutToExpire.expired": "Session expired",
|
||||
"sessionAboutToExpire.extendFailedTitle": "Extend Session Failed",
|
||||
|
||||
"peers.search.placeholder": "Search by peer name, DNS or IP address",
|
||||
"peers.filter.all": "All",
|
||||
@@ -305,5 +315,14 @@
|
||||
|
||||
"daemon.unavailable.title": "NetBird Service Is Not Running",
|
||||
"daemon.unavailable.description": "The app will reconnect automatically once the service is running.",
|
||||
"daemon.unavailable.docsLink": "Documentation"
|
||||
"daemon.unavailable.docsLink": "Documentation",
|
||||
|
||||
"error.jwt_clock_skew": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",
|
||||
"error.jwt_expired": "Your sign-in token has expired. Please sign in again.",
|
||||
"error.jwt_signature_invalid": "Sign-in failed: the token signature is invalid. Please contact your administrator.",
|
||||
"error.session_expired": "Your session has expired. Please sign in again.",
|
||||
"error.invalid_setup_key": "The setup key is missing or invalid.",
|
||||
"error.permission_denied": "Sign-in was rejected by the server.",
|
||||
"error.daemon_unreachable": "The NetBird daemon is not responding. Please check that the service is running.",
|
||||
"error.unknown": "Operation failed. See details for the technical message."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"tray.status.disconnected": "Lekapcsolva",
|
||||
"tray.status.daemonUnavailable": "Nem fut",
|
||||
"tray.status.error": "Hiba",
|
||||
"tray.session.expiresIn": "Munkamenet: {remaining}",
|
||||
|
||||
"tray.menu.open": "NetBird megnyitása",
|
||||
"tray.menu.connect": "Csatlakozás",
|
||||
@@ -31,6 +32,14 @@
|
||||
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
|
||||
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
|
||||
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"notify.sessionWarning.title": "Munkamenet hamarosan lejár",
|
||||
"notify.sessionWarning.body": "A NetBird munkamenet {remaining} múlva lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
|
||||
"notify.sessionWarning.bodyGeneric": "A NetBird munkamenet hamarosan lejár. Kattints a Meghosszabbítás gombra a megújításhoz.",
|
||||
"notify.sessionWarning.extend": "Meghosszabbítás",
|
||||
"notify.sessionWarning.dismiss": "Elvetés",
|
||||
"notify.sessionWarning.failed": "A NetBird munkamenet meghosszabbítása sikertelen",
|
||||
"notify.sessionWarning.successTitle": "NetBird munkamenet meghosszabbítva",
|
||||
"notify.sessionWarning.successBody": "A munkamenet frissítve.",
|
||||
|
||||
"common.cancel": "Mégse",
|
||||
"common.save": "Mentés",
|
||||
@@ -272,6 +281,7 @@
|
||||
"sessionAboutToExpire.stay": "Maradjon csatlakoztatva",
|
||||
"sessionAboutToExpire.logout": "Kijelentkezés",
|
||||
"sessionAboutToExpire.expired": "Munkamenet lejárt",
|
||||
"sessionAboutToExpire.extendFailedTitle": "A munkamenet meghosszabbítása sikertelen",
|
||||
|
||||
"peers.search.placeholder": "Keresés társ neve, DNS vagy IP-cím alapján",
|
||||
"peers.filter.all": "Összes",
|
||||
@@ -284,5 +294,14 @@
|
||||
|
||||
"daemon.unavailable.title": "A NetBird szolgáltatás nem fut",
|
||||
"daemon.unavailable.description": "Az alkalmazás automatikusan újracsatlakozik, amint a szolgáltatás újra elérhető.",
|
||||
"daemon.unavailable.docsLink": "Dokumentáció"
|
||||
"daemon.unavailable.docsLink": "Dokumentáció",
|
||||
|
||||
"error.jwt_clock_skew": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra.",
|
||||
"error.jwt_expired": "A bejelentkezési token lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"error.jwt_signature_invalid": "A bejelentkezés sikertelen: a token aláírása érvénytelen. Kérjük, lépjen kapcsolatba a rendszergazdával.",
|
||||
"error.session_expired": "A munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||
"error.invalid_setup_key": "A telepítési kulcs hiányzik vagy érvénytelen.",
|
||||
"error.permission_denied": "A szerver elutasította a bejelentkezést.",
|
||||
"error.daemon_unreachable": "A NetBird szolgáltatás nem válaszol. Kérjük, ellenőrizze, hogy fut-e a szolgáltatás.",
|
||||
"error.unknown": "A művelet meghiúsult. A technikai részleteket a Details mezőben találja."
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ToggleSwitch } from "@/components/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
@@ -38,8 +39,7 @@ const NEEDS_LOGIN_STATES = new Set([
|
||||
"LoginFailed",
|
||||
]);
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
|
||||
// popup window is the only login UI; errors surface as a native
|
||||
@@ -230,6 +230,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
// See connect() above — clear via the effect, not eagerly.
|
||||
};
|
||||
|
||||
// Tracks whether the daemon has entered Connecting during the
|
||||
// current "connect" action. Lets us distinguish "still waiting for
|
||||
// the daemon to start" (Idle → Idle) from "the connect flow was
|
||||
// cancelled externally" (Connecting → Idle, e.g. tray Disconnect
|
||||
// while the UI was Connecting). Reset whenever action returns to
|
||||
// null.
|
||||
const sawConnectingRef = useRef(false);
|
||||
|
||||
// Release the action latch when the daemon settles on a terminal
|
||||
// state for the user's intent — and, in the connect → NeedsLogin
|
||||
// case, hand off to driveLogin so the user doesn't have to click
|
||||
@@ -237,6 +245,13 @@ export const ConnectionStatusSwitch = () => {
|
||||
// .finally, not here: Login's internal Down makes the daemon flap
|
||||
// through Idle, which would otherwise look like a terminal state.
|
||||
useEffect(() => {
|
||||
if (action === null) {
|
||||
sawConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (daemonState === "Connecting") {
|
||||
sawConnectingRef.current = true;
|
||||
}
|
||||
if (action === "connect") {
|
||||
if (needsLogin) {
|
||||
driveLogin();
|
||||
@@ -244,6 +259,14 @@ export const ConnectionStatusSwitch = () => {
|
||||
}
|
||||
if (daemonState === "Connected" || unreachable) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
// Cancelled externally (e.g. tray Disconnect during our
|
||||
// Connecting): the daemon went back to Idle after we'd
|
||||
// observed Connecting. Clear the latch so the UI stops
|
||||
// showing Connecting forever.
|
||||
if (sawConnectingRef.current && daemonState === "Idle") {
|
||||
setAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
52
client/ui/frontend/src/lib/errors.ts
Normal file
52
client/ui/frontend/src/lib/errors.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Shared error formatter for native dialog bodies.
|
||||
//
|
||||
// The Go service layer (client/ui/services/connection.go classifyDaemonError)
|
||||
// wraps daemon errors in a ClientError struct exposed to the TS side as
|
||||
// {code, short, long}. Short is already localised (Go reads the current
|
||||
// preferences.Store language and resolves "error.<code>" via i18n.Bundle).
|
||||
// Long always carries the unwrapped raw daemon message so the operator can
|
||||
// see the JWT / mgm stack when the short text is too generic.
|
||||
//
|
||||
// Wails wraps Go-returned errors as Error({message, cause, kind}) where
|
||||
// .message holds the JSON-stringified payload and the structured object
|
||||
// lives on .cause — Object.keys(err) is empty in that case. We therefore
|
||||
// probe .cause first, then fall back to parsing .message as JSON, then
|
||||
// to plain .message text for callers that still hand us a raw Error.
|
||||
const extractClientError = (e: unknown): { short?: string; long?: string } | null => {
|
||||
if (!e || typeof e !== "object") return null;
|
||||
const withCause = e as { cause?: unknown; message?: unknown };
|
||||
if (withCause.cause && typeof withCause.cause === "object") {
|
||||
return withCause.cause as { short?: string; long?: string };
|
||||
}
|
||||
if (typeof withCause.message === "string") {
|
||||
const m = withCause.message.trim();
|
||||
if (m.startsWith("{") && m.endsWith("}")) {
|
||||
try {
|
||||
const parsed = JSON.parse(m);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if ("cause" in parsed && parsed.cause && typeof parsed.cause === "object") {
|
||||
return parsed.cause as { short?: string; long?: string };
|
||||
}
|
||||
return parsed as { short?: string; long?: string };
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to plain-message handling
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const formatErrorMessage = (e: unknown): string => {
|
||||
const ce = extractClientError(e);
|
||||
if (ce) {
|
||||
const short = typeof ce.short === "string" ? ce.short : "";
|
||||
const long = typeof ce.long === "string" ? ce.long : "";
|
||||
if (short && long && long !== short) {
|
||||
return `${short}\n\nDetails: ${long}`;
|
||||
}
|
||||
if (short) return short;
|
||||
}
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { ClockIcon } from "lucide-react";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
@@ -9,10 +9,15 @@ import { DialogActions } from "@/components/DialogActions";
|
||||
import { DialogDescription } from "@/components/DialogDescription";
|
||||
import { DialogHeading } from "@/components/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, WindowManager } from "@bindings/services";
|
||||
import {
|
||||
Connection,
|
||||
Profiles as ProfilesSvc,
|
||||
Session,
|
||||
WindowManager,
|
||||
} from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/lib/useAutoSizeWindow";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
const DEFAULT_SECONDS = 360;
|
||||
const WINDOW_WIDTH = 360;
|
||||
|
||||
@@ -35,6 +40,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
}, [params]);
|
||||
|
||||
const [remaining, setRemaining] = useState(initialSeconds);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const expired = remaining <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,10 +55,38 @@ export default function SessionAboutToExpireDialog() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [remaining]);
|
||||
|
||||
const stay = useCallback(() => {
|
||||
void Events.Emit(EVENT_TRIGGER_LOGIN);
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
}, []);
|
||||
// Mirrors tray.go::runExtendSession: starts the daemon SSO extend flow,
|
||||
// opens the browser for the user to sign in, blocks on the daemon until
|
||||
// the new deadline arrives. Tunnel stays up; success simply closes the
|
||||
// dialog, failure surfaces a native error dialog and leaves this one
|
||||
// open so the user can retry or logout.
|
||||
const stay = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const start = await Session.RequestExtend({ hint: "" });
|
||||
const uri = start.verificationUriComplete || start.verificationUri;
|
||||
if (uri) {
|
||||
try {
|
||||
await Connection.OpenURL(uri);
|
||||
} catch (e) {
|
||||
console.debug("OpenURL failed during extend", e);
|
||||
}
|
||||
}
|
||||
await Session.WaitExtend({
|
||||
deviceCode: start.deviceCode,
|
||||
userCode: start.userCode,
|
||||
});
|
||||
WindowManager.CloseSessionAboutToExpire().catch(console.error);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("sessionAboutToExpire.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
@@ -99,7 +133,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={stay}
|
||||
disabled={expired}
|
||||
disabled={expired || busy}
|
||||
>
|
||||
{t("sessionAboutToExpire.stay")}
|
||||
</Button>
|
||||
@@ -108,6 +142,7 @@ export default function SessionAboutToExpireDialog() {
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("sessionAboutToExpire.logout")}
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -158,7 +159,7 @@ export const useDebugBundle = () => {
|
||||
setStage({ kind: "idle" });
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HelpText } from "@/components/HelpText";
|
||||
import { Label } from "@/components/Label";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
// Flags live alongside the rest of the SVG flag library under
|
||||
// assets/flags/1x1 and are filename-matched to the language code
|
||||
@@ -91,7 +92,7 @@ export function LanguagePicker() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
|
||||
@@ -13,9 +13,7 @@ import type { Config } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
||||
import { SkeletonSettings } from "@/modules/skeletons/SkeletonSettings.tsx";
|
||||
|
||||
const errorMessage = (e: unknown) =>
|
||||
e instanceof Error ? e.message : String(e);
|
||||
import { formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
@@ -45,7 +46,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -90,7 +91,7 @@ export function SettingsProfiles() {
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: e instanceof Error ? e.message : String(e),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
@@ -60,6 +61,7 @@ func init() {
|
||||
application.RegisterEvent[services.Status](services.EventStatus)
|
||||
application.RegisterEvent[services.SystemEvent](services.EventSystem)
|
||||
application.RegisterEvent[services.ProfileRef](services.EventProfileChanged)
|
||||
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
|
||||
application.RegisterEvent[updater.State](updater.EventStateChanged)
|
||||
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
|
||||
}
|
||||
@@ -121,7 +123,6 @@ func main() {
|
||||
},
|
||||
})
|
||||
|
||||
connection := services.NewConnection(conn)
|
||||
settings := services.NewSettings(conn)
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State. Peers feeds the daemon
|
||||
@@ -131,7 +132,6 @@ func main() {
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
peers := services.NewPeers(conn, app.Event, updaterHolder)
|
||||
notifier := notifications.New()
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
// localesFS reroots the embedded tree at the locales directory itself
|
||||
// so the bundle sees _index.json and <lang>/common.json at the top
|
||||
@@ -154,7 +154,18 @@ func main() {
|
||||
}
|
||||
localizer := NewLocalizer(bundle, prefStore)
|
||||
|
||||
// Connection lives after bundle + prefStore so it can localise daemon
|
||||
// errors (services.NewConnection takes both as dependencies).
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||
|
||||
app.RegisterService(application.NewService(connection))
|
||||
// authsession.Session owns the full extend + dismiss surface; the tray
|
||||
// drives the "Extend now" action from the T-10 OS notification through
|
||||
// this directly. The Wails-bound services.Session wraps only the subset
|
||||
// the React frontend calls, so the generated TS surface stays minimal.
|
||||
authSession := authsession.NewSession(conn)
|
||||
app.RegisterService(application.NewService(services.NewSession(authSession)))
|
||||
app.RegisterService(application.NewService(settings))
|
||||
app.RegisterService(application.NewService(services.NewNetworks(conn)))
|
||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||
@@ -215,6 +226,7 @@ func main() {
|
||||
Update: update,
|
||||
ProfileSwitcher: profileSwitcher,
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
@@ -4,15 +4,143 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// ErrorTranslator is the subset of i18n.Bundle Connection needs to localise
|
||||
// daemon errors. Defined as an interface so tests can stub it; the runtime
|
||||
// implementation is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
}
|
||||
|
||||
// LanguagePreference is the subset of preferences.Store Connection needs
|
||||
// to discover the current UI language at error-classification time. The
|
||||
// runtime implementation is *preferences.Store.
|
||||
type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend.
|
||||
//
|
||||
// The daemon hands us gRPC errors whose Message is a stack of wrapped strings
|
||||
// from the management server and the underlying JWT library, for example:
|
||||
//
|
||||
// "invalid jwt token, err: token could not be parsed: token has invalid
|
||||
// claims: token used before issued"
|
||||
//
|
||||
// Showing that raw message in a native dialog is unreadable, so we map the
|
||||
// substrings we recognise to a {code, short, long} triple. The frontend
|
||||
// translates Code through i18n (preferred); Short is an English fallback so
|
||||
// the dialog still reads cleanly if a code is missing from the locale; Long
|
||||
// always carries the unwrapped daemon message for the operator.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
// Error returns the user-facing short message so plain Go callers and the
|
||||
// Wails default error path still get a readable string.
|
||||
func (e *ClientError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Short
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the full {code, short, long} triple so the Wails
|
||||
// binding emits a structured object instead of the default "error: ..."
|
||||
// string. The TS layer accesses these fields via try/catch.
|
||||
func (e *ClientError) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type alias ClientError
|
||||
return json.Marshal((*alias)(e))
|
||||
}
|
||||
|
||||
// classifyDaemonError turns a raw gRPC error from the daemon into a
|
||||
// ClientError with a stable code and a short localised summary. The Long
|
||||
// field always carries the unwrapped daemon message so the operator can
|
||||
// inspect the root cause when the short text is too generic. Short is
|
||||
// looked up via i18n under "error.<code>": i18n.Bundle.Translate already
|
||||
// handles current-language → English → key passthrough, so any missing
|
||||
// locale entry surfaces as a visible "error.<code>" string in the dialog —
|
||||
// a deliberate fail-loud signal that the bundle needs updating.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if st, ok := gstatus.FromError(err); ok {
|
||||
msg = st.Message()
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
switch {
|
||||
case strings.Contains(lower, "token used before issued"),
|
||||
strings.Contains(lower, "token is not valid yet"):
|
||||
code = "jwt_clock_skew"
|
||||
case strings.Contains(lower, "token is expired"),
|
||||
strings.Contains(lower, "token has expired"):
|
||||
code = "jwt_expired"
|
||||
case strings.Contains(lower, "token signature is invalid"):
|
||||
code = "jwt_signature_invalid"
|
||||
case strings.Contains(lower, "peer login has expired"):
|
||||
code = "session_expired"
|
||||
case strings.Contains(lower, "invalid setup-key"),
|
||||
strings.Contains(lower, "invalid setup key"):
|
||||
code = "invalid_setup_key"
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
code = "permission_denied"
|
||||
case strings.Contains(lower, "no connection could be made"),
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: s.translateShort(code),
|
||||
Long: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code. The i18n
|
||||
// Bundle's own Translate already falls back current-language → English →
|
||||
// key passthrough, so callers either see the localised string or the bare
|
||||
// "error.<code>" key (which makes the missing translation obvious). If
|
||||
// the translator is nil — e.g. a Connection constructed in a unit test —
|
||||
// we return the key for the same reason.
|
||||
func (s *Connection) translateShort(code string) string {
|
||||
key := "error." + code
|
||||
if s.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if s.prefs != nil {
|
||||
if pref := s.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return s.translator.Translate(lang, key)
|
||||
}
|
||||
|
||||
// LoginParams carries the fields the UI sets when starting a login.
|
||||
type LoginParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
@@ -52,11 +180,17 @@ type LogoutParams struct {
|
||||
|
||||
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
type Connection struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
}
|
||||
|
||||
func NewConnection(conn DaemonConn) *Connection {
|
||||
return &Connection{conn: conn}
|
||||
// NewConnection wires Connection with its translation dependencies. Either
|
||||
// translator or prefs may be nil; in that case classifyDaemonError falls
|
||||
// back to the English Short text baked into the error map. main.go always
|
||||
// supplies both at startup.
|
||||
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
||||
return &Connection{conn: conn, translator: translator, prefs: prefs}
|
||||
}
|
||||
|
||||
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
||||
@@ -117,7 +251,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
return LoginResult{}, s.classifyDaemonError(err)
|
||||
}
|
||||
return LoginResult{
|
||||
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
||||
@@ -137,7 +271,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
@@ -155,8 +289,10 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Up(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Up(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
@@ -164,8 +300,10 @@ func (s *Connection) Down(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.Down(ctx, &proto.DownRequest{})
|
||||
return err
|
||||
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenURL launches the user's preferred browser to display url. Mirrors the
|
||||
@@ -201,6 +339,8 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Logout(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,14 @@ const (
|
||||
// others without polling. The daemon itself does not emit a profile
|
||||
// event, so this is the only signal that closes the gap.
|
||||
EventProfileChanged = "netbird:profile:changed"
|
||||
// EventSessionWarning is emitted on every session-warning watcher
|
||||
// fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed
|
||||
// sibling of EventSystem so React / tray subscribers don't have to
|
||||
// filter the firehose of EventSystem. Consumers branch on the
|
||||
// SessionWarning.Final flag to tell the interactive T-10 event apart
|
||||
// from the fallback T-2 event; the dialog auto-open lives in the
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
@@ -43,11 +52,11 @@ const (
|
||||
|
||||
// Daemon connection status strings — mirror internal.Status* in
|
||||
// client/internal/state.go.
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusSessionExpired = "SessionExpired"
|
||||
)
|
||||
|
||||
@@ -110,13 +119,19 @@ type LocalPeer struct {
|
||||
|
||||
// Status is the snapshot the frontend renders on the dashboard.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Events []SystemEvent `json:"events"`
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's
|
||||
// SSO session expires. nil when the peer is not SSO-tracked or login
|
||||
// expiration is disabled (either server-side off, or peer not
|
||||
// SSO-registered). The UI derives "warning active" from this value
|
||||
// plus its own clock.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// Peers serves the dashboard data: one polled Status RPC and a long-running
|
||||
@@ -277,23 +292,6 @@ func (s *Peers) Get(ctx context.Context) (Status, error) {
|
||||
return statusFromProto(resp), nil
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
|
||||
// re-emits each FullStatus snapshot on the Wails event bus. The first
|
||||
// message is the current snapshot; subsequent messages fire on
|
||||
@@ -406,6 +404,9 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
s.emitter.Emit(EventSystem, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
}
|
||||
if s.updater != nil {
|
||||
s.updater.OnSystemEvent(ev)
|
||||
}
|
||||
@@ -468,6 +469,10 @@ func statusFromProto(resp *proto.StatusResponse) Status {
|
||||
for _, e := range full.GetEvents() {
|
||||
st.Events = append(st.Events, systemEventFromProto(e))
|
||||
}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
st.SessionExpiresAt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -488,3 +493,20 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
48
client/ui/services/session.go
Normal file
48
client/ui/services/session.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
)
|
||||
|
||||
// Re-exports so frontend bindings stay on services.ExtendStart* /
|
||||
// services.ExtendWait* / services.ExtendResult without each call site
|
||||
// importing authsession.
|
||||
type (
|
||||
ExtendStartParams = authsession.ExtendStartParams
|
||||
ExtendStartResult = authsession.ExtendStartResult
|
||||
ExtendWaitParams = authsession.ExtendWaitParams
|
||||
ExtendResult = authsession.ExtendResult
|
||||
)
|
||||
|
||||
// Session is the Wails-bound wrapper around authsession.Session. It only
|
||||
// re-exposes the subset the React frontend actually calls
|
||||
// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray
|
||||
// uses authsession.Session directly, so methods that only the tray needs
|
||||
// (DismissWarning) are deliberately absent here — keeping the generated
|
||||
// TS surface minimal.
|
||||
type Session struct {
|
||||
inner *authsession.Session
|
||||
}
|
||||
|
||||
// NewSession returns the Wails-bound wrapper. The caller owns the inner
|
||||
// authsession.Session and may use it directly (e.g. the tray).
|
||||
func NewSession(inner *authsession.Session) *Session {
|
||||
return &Session{inner: inner}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||
// returns the verification URI for the UI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
return s.inner.RequestExtend(ctx, p)
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the user completes the SSO flow started by
|
||||
// RequestExtend, then returns the new session deadline (or nil when the
|
||||
// management server reports the peer ineligible).
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
return s.inner.WaitExtend(ctx, p)
|
||||
}
|
||||
@@ -9,12 +9,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
"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/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
@@ -34,13 +37,29 @@ const (
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
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"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
|
||||
|
||||
// finalWarningCountdownSeconds is the countdown shown in the auto-opened
|
||||
// SessionAboutToExpire dialog. Mirrors sessionwatch.FinalWarningLead
|
||||
// (2 minutes); the values stay in sync by hand because the lead is fixed
|
||||
// for the initial rollout.
|
||||
finalWarningCountdownSeconds = 120
|
||||
)
|
||||
|
||||
|
||||
// Tray builds and updates the systray menu. It mirrors the layout of the Fyne
|
||||
// systray 1:1 and routes clicks back to the gRPC services. Dynamic state
|
||||
// (status icon, exit-node submenu) is driven by the netbird:status event.
|
||||
@@ -57,6 +76,12 @@ type TrayServices struct {
|
||||
Update *services.Update
|
||||
ProfileSwitcher *services.ProfileSwitcher
|
||||
WindowManager *services.WindowManager
|
||||
// Session drives the SSO session-extend flow invoked from the
|
||||
// "Extend now" action on the T-10min OS notification, plus the
|
||||
// Dismiss hand-off that suppresses the T-2 fallback dialog. Bound to
|
||||
// the authsession package directly because the Wails wrapper in
|
||||
// services only re-exposes the React-facing subset.
|
||||
Session *authsession.Session
|
||||
// Localizer is the tray's bridge to translations. Constructed in main
|
||||
// from i18n.Bundle + preferences.Store; the Wails-bound facades
|
||||
// (services.I18n, services.Preferences) are registered separately for
|
||||
@@ -75,8 +100,14 @@ type Tray struct {
|
||||
// language switch.
|
||||
loc *Localizer
|
||||
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
menu *application.Menu
|
||||
statusItem *application.MenuItem
|
||||
// sessionExpiresItem displays the SSO session deadline as a humanised
|
||||
// remaining-time label ("Session: 47m"). Hidden when no deadline is
|
||||
// tracked (non-SSO peer or login-expiration disabled on the account).
|
||||
// Refreshed by applyStatus on every Status push and by a 1-minute
|
||||
// ticker between pushes so the countdown moves naturally.
|
||||
sessionExpiresItem *application.MenuItem
|
||||
upItem *application.MenuItem
|
||||
downItem *application.MenuItem
|
||||
exitNodeItem *application.MenuItem
|
||||
@@ -99,6 +130,16 @@ type Tray struct {
|
||||
activeProfile string
|
||||
activeUsername string
|
||||
switchCancel context.CancelFunc
|
||||
// sessionExpiresAt is the most recent deadline observed on a Status
|
||||
// snapshot. Used to skip a no-op label rewrite when the daemon repeats
|
||||
// the same value across rapid pushes. Guarded by mu.
|
||||
sessionExpiresAt time.Time
|
||||
// pendingConnectLogin is set when handleConnect kicks off an Up on an
|
||||
// idle daemon. The daemon will flip to NeedsLogin if the peer is
|
||||
// SSO-tracked and has no cached token; applyStatus consumes this flag
|
||||
// on that transition to automatically open the browser-login flow,
|
||||
// saving the user a second Connect click. Guarded by mu.
|
||||
pendingConnectLogin bool
|
||||
|
||||
// profileLoadMu serializes loadProfiles so the daemon-status-driven
|
||||
// refresh in applyStatus cannot race with the ApplicationStarted seed
|
||||
@@ -151,6 +192,14 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
|
||||
// nil-deref).
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
go t.loadProfiles()
|
||||
// Notification-category registration must run after the Wails
|
||||
// notifications service Startup has populated wn.appName /
|
||||
// registry path on Windows; before app.Run() the category lookup
|
||||
// in SendNotificationWithActions silently falls back to a
|
||||
// gomb-nélküli notification (the Windows impl logs "Category not
|
||||
// found"). The macOS/Linux impls don't strictly require this
|
||||
// ordering, but running here is harmless for them.
|
||||
t.registerSessionWarningCategory()
|
||||
})
|
||||
|
||||
// Localizer fires this callback after it has already swapped its own
|
||||
@@ -185,6 +234,7 @@ func (t *Tray) reapplyMenuState() {
|
||||
lastStatus := t.lastStatus
|
||||
daemonVersion := t.lastDaemonVersion
|
||||
exitNodes := append([]string(nil), t.exitNodes...)
|
||||
sessionDeadline := t.sessionExpiresAt
|
||||
t.mu.Unlock()
|
||||
|
||||
daemonUnavailable := strings.EqualFold(lastStatus, services.StatusDaemonUnavailable)
|
||||
@@ -195,6 +245,15 @@ func (t *Tray) reapplyMenuState() {
|
||||
t.statusItem.SetEnabled(false)
|
||||
t.applyStatusIndicator(lastStatus)
|
||||
}
|
||||
if t.sessionExpiresItem != nil {
|
||||
if sessionDeadline.IsZero() {
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
} else {
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(sessionDeadline))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
t.sessionExpiresItem.SetHidden(false)
|
||||
}
|
||||
}
|
||||
if t.upItem != nil {
|
||||
t.upItem.SetHidden(connected || connecting || daemonUnavailable)
|
||||
t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable)
|
||||
@@ -264,6 +323,14 @@ func (t *Tray) buildMenu() *application.Menu {
|
||||
SetEnabled(false).
|
||||
SetBitmap(iconMenuDotIdle)
|
||||
|
||||
// sessionExpiresItem sits directly below the status row so the
|
||||
// remaining-time label reads as a sub-line of "Connected" etc. Hidden
|
||||
// until applyStatus sees a non-zero SessionExpiresAt on the daemon
|
||||
// Status snapshot — peers without SSO tracking or with login expiry
|
||||
// disabled never reveal this row.
|
||||
t.sessionExpiresItem = menu.Add("").SetEnabled(false)
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
|
||||
menu.AddSeparator()
|
||||
// The tray icon's left-click handler is intentionally unbound (see
|
||||
// NewTray for the rationale), so expose the window through an explicit
|
||||
@@ -362,12 +429,25 @@ func (t *Tray) handleConnect() {
|
||||
return
|
||||
}
|
||||
t.upItem.SetEnabled(false)
|
||||
// Arm the SSO auto-handoff: Up() is async and the daemon may flip to
|
||||
// NeedsLogin once it detects an SSO peer with no cached token. The
|
||||
// flag is consumed by applyStatus on that transition, which then
|
||||
// triggers the browser-login flow without the user having to click
|
||||
// Connect a second time. Cleared on any terminal state (Connected /
|
||||
// Idle / LoginFailed / DaemonUnavailable / SessionExpired) so a stale
|
||||
// flag can't hijack a future status push.
|
||||
t.mu.Lock()
|
||||
t.pendingConnectLogin = true
|
||||
t.mu.Unlock()
|
||||
go func() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := t.svc.Connection.Up(ctx, services.UpParams{}); err != nil {
|
||||
log.Errorf("connect: %v", err)
|
||||
t.notifyError(t.loc.T("notify.error.connect"))
|
||||
t.mu.Lock()
|
||||
t.pendingConnectLogin = false
|
||||
t.mu.Unlock()
|
||||
t.upItem.SetEnabled(true)
|
||||
}
|
||||
}()
|
||||
@@ -414,7 +494,14 @@ func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
|
||||
// its own richer notification when EventUpdateState fires.
|
||||
func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
se, ok := ev.Data.(services.SystemEvent)
|
||||
if !ok || se.UserMessage == "" {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Session-warning events carry no UserMessage — the tray builds the
|
||||
// localised notification body locally from metadata. Every other
|
||||
// event needs a non-empty UserMessage to show anything meaningful.
|
||||
isSessionWarning := se.Metadata[authsession.MetaWarning] == "true"
|
||||
if !isSessionWarning && se.UserMessage == "" {
|
||||
return
|
||||
}
|
||||
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
|
||||
@@ -438,6 +525,29 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// Session-warning events come in two flavours; detect via the stable
|
||||
// metadata flags rather than category/severity so a future reword on
|
||||
// the daemon side still routes here.
|
||||
// - T-WarningLead (MetaSessionWarning + no MetaSessionFinal) →
|
||||
// interactive "Extend now / Dismiss" OS notification. Title and
|
||||
// body are built locally from i18n + metadata so the text follows
|
||||
// the active UI language regardless of what the daemon (which has
|
||||
// no locale context) writes into UserMessage.
|
||||
// - T-FinalWarningLead (MetaSessionFinal=true) → auto-open the
|
||||
// SessionAboutToExpire dialog. No OS notification here; the
|
||||
// dialog is the last-chance reminder, doubling up would be noise.
|
||||
if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" {
|
||||
if se.Metadata[authsession.MetaFinal] == "true" {
|
||||
t.openSessionAboutToExpire()
|
||||
return
|
||||
}
|
||||
t.notifySessionWarning(
|
||||
t.loc.T("notify.sessionWarning.title"),
|
||||
t.buildSessionWarningBody(se.Metadata),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
body := se.UserMessage
|
||||
if id := se.Metadata["id"]; id != "" {
|
||||
body += fmt.Sprintf(" ID: %s", id)
|
||||
@@ -467,6 +577,28 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
// flag in onSessionExpire.
|
||||
sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) &&
|
||||
!strings.EqualFold(t.lastStatus, services.StatusSessionExpired)
|
||||
|
||||
// Consume the SSO auto-handoff flag armed by handleConnect. Trigger
|
||||
// the browser-login flow on a Connect → NeedsLogin transition so the
|
||||
// user doesn't need to click Connect a second time. Clear it on any
|
||||
// other terminal state — including Connecting bursts that resolve to
|
||||
// Connected / Idle / LoginFailed / DaemonUnavailable — so a stale
|
||||
// flag can't fire weeks later when the daemon happens to flip.
|
||||
triggerLogin := false
|
||||
if t.pendingConnectLogin {
|
||||
switch {
|
||||
case strings.EqualFold(st.Status, services.StatusNeedsLogin):
|
||||
triggerLogin = true
|
||||
t.pendingConnectLogin = false
|
||||
case strings.EqualFold(st.Status, services.StatusConnected),
|
||||
strings.EqualFold(st.Status, services.StatusIdle),
|
||||
strings.EqualFold(st.Status, services.StatusLoginFailed),
|
||||
strings.EqualFold(st.Status, services.StatusSessionExpired),
|
||||
strings.EqualFold(st.Status, services.StatusDaemonUnavailable):
|
||||
t.pendingConnectLogin = false
|
||||
}
|
||||
}
|
||||
|
||||
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
|
||||
t.connected = connected
|
||||
t.lastStatus = st.Status
|
||||
@@ -479,6 +611,11 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
t.exitNodes = exitNodes
|
||||
t.mu.Unlock()
|
||||
|
||||
if triggerLogin {
|
||||
t.ShowWindow()
|
||||
t.app.Event.Emit(services.EventTriggerLogin)
|
||||
}
|
||||
|
||||
if iconChanged {
|
||||
t.applyIcon()
|
||||
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
||||
@@ -547,6 +684,8 @@ func (t *Tray) applyStatus(st services.Status) {
|
||||
if sessionExpiredEnter {
|
||||
t.handleSessionExpired()
|
||||
}
|
||||
|
||||
t.applySessionExpiry(st.SessionExpiresAt, connected)
|
||||
}
|
||||
|
||||
// handleSessionExpired surfaces the SSO re-authentication path when the
|
||||
@@ -849,6 +988,40 @@ func (t *Tray) switchProfile(name string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// applySessionExpiry refreshes the "Session: 47m" tray row from the latest
|
||||
// Status snapshot's SessionExpiresAt. Only shown when the tunnel is up:
|
||||
// in any other state (Idle after a Down, Connecting, NeedsLogin,
|
||||
// SessionExpired, LoginFailed, DaemonUnavailable, or mid profile-switch)
|
||||
// the deadline is meaningless and the row is hidden. The internal
|
||||
// sessionExpiresAt cache is cleared in the same path so reapplyMenuState
|
||||
// after a language switch doesn't resurrect a stale label.
|
||||
//
|
||||
// No per-minute ticker: between Status pushes the label may drift by a
|
||||
// few minutes, which is fine for a tray-menu status row that the user
|
||||
// opens on demand. The T-10min OS notification (driven by the daemon's
|
||||
// sessionwatch) does the time-critical signalling.
|
||||
func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) {
|
||||
var d time.Time
|
||||
if connected && deadline != nil {
|
||||
d = *deadline
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.sessionExpiresAt = d
|
||||
t.mu.Unlock()
|
||||
|
||||
if t.sessionExpiresItem == nil {
|
||||
return
|
||||
}
|
||||
if d.IsZero() {
|
||||
t.sessionExpiresItem.SetHidden(true)
|
||||
return
|
||||
}
|
||||
remaining := nbstatus.FormatRemainingDuration(time.Until(d))
|
||||
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
|
||||
t.sessionExpiresItem.SetHidden(false)
|
||||
}
|
||||
|
||||
// notify wraps the Wails notification service with the tray's standard
|
||||
// id-prefix scheme and swallows errors (notifications are best-effort).
|
||||
func (t *Tray) notify(title, body, id string) {
|
||||
@@ -864,6 +1037,166 @@ func (t *Tray) notify(title, body, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := t.svc.Notifier.SendNotificationWithActions(notifications.NotificationOptions{
|
||||
ID: notifyIDSessionWarning,
|
||||
Title: title,
|
||||
Body: body,
|
||||
CategoryID: notifyCategorySessionWarning,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("notify session-warning with actions: %v", err)
|
||||
// Fall back to a plain notification so the user at least gets
|
||||
// the warning text, even without buttons.
|
||||
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, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := t.svc.Session.WaitExtend(ctx, services.ExtendWaitParams{
|
||||
DeviceCode: start.DeviceCode,
|
||||
UserCode: start.UserCode,
|
||||
}); err != nil {
|
||||
log.Warnf("session-warning: WaitExtend failed: %v", err)
|
||||
t.notifyError(t.loc.T("notify.sessionWarning.failed"))
|
||||
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
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := t.svc.Session.DismissWarning(ctx); err != nil {
|
||||
log.Debugf("session-warning: DismissWarning failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// openSessionAboutToExpire 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) openSessionAboutToExpire() {
|
||||
if t.svc.WindowManager == nil {
|
||||
return
|
||||
}
|
||||
t.svc.WindowManager.OpenSessionAboutToExpire(finalWarningCountdownSeconds)
|
||||
}
|
||||
|
||||
// notifyError fires a generic "Error" notification for tray-driven action
|
||||
// failures. Each tray click site already logs the underlying error; this
|
||||
// adds the user-visible toast.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo pkg-config: x11 gtk+-3.0 cairo cairo-xlib
|
||||
#cgo pkg-config: x11 gtk4 gtk4-x11 cairo cairo-xlib
|
||||
#cgo LDFLAGS: -lX11
|
||||
#include "xembed_tray_linux.h"
|
||||
#include <X11/Xlib.h>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cairo/cairo-xlib.h>
|
||||
#include <cairo/cairo.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <gdk/x11/gdkx.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
@@ -241,7 +242,7 @@ int xembed_poll_event(Display *dpy, Window icon_win,
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* --- GTK3 popup window menu support --- */
|
||||
/* --- GTK4 popup window menu support --- */
|
||||
|
||||
/* Implemented in Go via //export */
|
||||
extern void goMenuItemClicked(int id);
|
||||
@@ -274,18 +275,19 @@ static void free_popup_data(popup_data *pd) {
|
||||
free(pd);
|
||||
}
|
||||
|
||||
|
||||
/* Close every popup window — top-level plus any open submenus.
|
||||
Called when the user clicks an actionable item or focus leaves the
|
||||
top-level window. */
|
||||
menu tree. */
|
||||
static void close_all_popups(void) {
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
gtk_widget_destroy(GTK_WIDGET(l->data));
|
||||
gtk_window_destroy(GTK_WINDOW(l->data));
|
||||
}
|
||||
g_list_free(submenu_popups);
|
||||
submenu_popups = NULL;
|
||||
|
||||
if (popup_win) {
|
||||
gtk_widget_hide(popup_win);
|
||||
gtk_widget_set_visible(popup_win, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,24 +298,26 @@ static void on_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
static void on_check_toggled(GtkToggleButton *btn, gpointer user_data) {
|
||||
static void on_check_toggled(GtkCheckButton *btn, gpointer user_data) {
|
||||
(void)btn;
|
||||
int id = GPOINTER_TO_INT(user_data);
|
||||
close_all_popups();
|
||||
goMenuItemClicked(id);
|
||||
}
|
||||
|
||||
/* When any popup loses focus we want to close the entire popup tree —
|
||||
unless focus moved to another window we own (e.g. opening a submenu).
|
||||
focus-out fires before the corresponding focus-in on the new window,
|
||||
so we defer the check to an idle callback: by then any sibling popup
|
||||
has had a chance to grab focus. If none of our windows still has
|
||||
toplevel focus, the user clicked outside the menu tree → tear down. */
|
||||
/* The popup is a regular WM-managed window (not override-redirect),
|
||||
so the WM hands keyboard focus to it on map. When focus moves
|
||||
elsewhere — the user clicked somewhere else, switched apps, etc. —
|
||||
the focus controller's "leave" signal fires and we tear down the
|
||||
menu tree. Submenus open from inside the top-level popup, so we
|
||||
defer the actual close to an idle callback: that gives the new
|
||||
submenu a chance to take focus first, and we only close if none of
|
||||
our windows still has it. */
|
||||
static gboolean any_popup_has_focus(void) {
|
||||
if (popup_win && gtk_window_has_toplevel_focus(GTK_WINDOW(popup_win)))
|
||||
if (popup_win && gtk_window_is_active(GTK_WINDOW(popup_win)))
|
||||
return TRUE;
|
||||
for (GList *l = submenu_popups; l; l = l->next) {
|
||||
if (gtk_window_has_toplevel_focus(GTK_WINDOW(l->data)))
|
||||
if (gtk_window_is_active(GTK_WINDOW(l->data)))
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
@@ -321,17 +325,90 @@ static gboolean any_popup_has_focus(void) {
|
||||
|
||||
static gboolean focus_out_recheck(gpointer user_data) {
|
||||
(void)user_data;
|
||||
if (!any_popup_has_focus()) {
|
||||
if (!any_popup_has_focus())
|
||||
close_all_popups();
|
||||
}
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static gboolean on_popup_focus_out(GtkWidget *widget, GdkEvent *event,
|
||||
gpointer user_data) {
|
||||
(void)widget; (void)event; (void)user_data;
|
||||
static void on_popup_focus_leave(GtkEventControllerFocus *ctrl,
|
||||
gpointer user_data) {
|
||||
(void)ctrl; (void)user_data;
|
||||
g_idle_add(focus_out_recheck, NULL);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/* Attach a focus controller that fires close_all_popups on focus loss. */
|
||||
static void attach_outside_click_close(GtkWidget *win) {
|
||||
GtkEventController *focus = gtk_event_controller_focus_new();
|
||||
g_signal_connect(focus, "leave",
|
||||
G_CALLBACK(on_popup_focus_leave), NULL);
|
||||
gtk_widget_add_controller(win, focus);
|
||||
}
|
||||
|
||||
/* Move a GtkWindow at the X11 level. GTK4 removed gtk_window_move(); the
|
||||
GdkSurface is mapped to a real X11 Window we can reposition with
|
||||
XMoveWindow. Must be called after the window has been realized (i.e.
|
||||
after gtk_widget_set_visible TRUE).
|
||||
|
||||
The popup is **not** override-redirect — the WM keeps managing it so
|
||||
focus tracking still works (focus-out fires when the user clicks
|
||||
elsewhere). We tag the window with a stack of EWMH hints that make
|
||||
sane WMs (fluxbox, openbox, i3, kwin, mutter) render it like a
|
||||
floating menu: above the tray panel, skipped from taskbar/pager,
|
||||
no decorations. */
|
||||
static void x11_move_window(GtkWidget *win, int x, int y) {
|
||||
GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(win));
|
||||
if (!surface || !GDK_IS_X11_SURFACE(surface))
|
||||
return;
|
||||
Window xid = gdk_x11_surface_get_xid(surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
|
||||
/* _NET_WM_WINDOW_TYPE_POPUP_MENU: makes fluxbox / openbox / etc
|
||||
render the window above panels and skip decorations. Must be
|
||||
set before the window is mapped to be honoured by some WMs;
|
||||
on already-mapped windows it works for most modern WMs but a
|
||||
few need an unmap/map cycle to re-read the property. */
|
||||
Atom wm_type = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE", False);
|
||||
Atom wm_type_popup = XInternAtom(xdpy, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
|
||||
XChangeProperty(xdpy, xid, wm_type, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)&wm_type_popup, 1);
|
||||
|
||||
/* _NET_WM_STATE_ABOVE + SKIP_TASKBAR + SKIP_PAGER. Bundled into
|
||||
one property write. */
|
||||
Atom wm_state = XInternAtom(xdpy, "_NET_WM_STATE", False);
|
||||
Atom state_above = XInternAtom(xdpy, "_NET_WM_STATE_ABOVE", False);
|
||||
Atom state_skip_tb = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_TASKBAR", False);
|
||||
Atom state_skip_pg = XInternAtom(xdpy, "_NET_WM_STATE_SKIP_PAGER", False);
|
||||
Atom states[3] = { state_above, state_skip_tb, state_skip_pg };
|
||||
XChangeProperty(xdpy, xid, wm_state, XA_ATOM, 32,
|
||||
PropModeReplace, (unsigned char *)states, 3);
|
||||
|
||||
XMoveWindow(xdpy, xid, x, y);
|
||||
XRaiseWindow(xdpy, xid);
|
||||
|
||||
/* POPUP_MENU windows aren't given keyboard focus by most WMs (the
|
||||
spec says they're "menus", which traditionally use a grab rather
|
||||
than focus). Without focus GtkEventControllerFocus's leave signal
|
||||
never fires, so we'd have no way to notice the user clicking
|
||||
elsewhere. Ask the WM to activate us via _NET_ACTIVE_WINDOW
|
||||
(source=2 means "pager / pseudo-user request" which most WMs
|
||||
honour without timestamp checks). This is safer than calling
|
||||
XSetInputFocus directly — that races the X server with the
|
||||
not-yet-fully-mapped window and trips BadMatch. */
|
||||
Atom net_active = XInternAtom(xdpy, "_NET_ACTIVE_WINDOW", False);
|
||||
XClientMessageEvent ev;
|
||||
memset(&ev, 0, sizeof(ev));
|
||||
ev.type = ClientMessage;
|
||||
ev.window = xid;
|
||||
ev.message_type = net_active;
|
||||
ev.format = 32;
|
||||
ev.data.l[0] = 2; /* source: pager */
|
||||
ev.data.l[1] = CurrentTime;
|
||||
XSendEvent(xdpy, DefaultRootWindow(xdpy), False,
|
||||
SubstructureRedirectMask | SubstructureNotifyMask,
|
||||
(XEvent *)&ev);
|
||||
|
||||
XFlush(xdpy);
|
||||
}
|
||||
|
||||
/* Forward declaration — submenu buttons need to schedule a child popup. */
|
||||
@@ -346,55 +423,81 @@ typedef struct {
|
||||
static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
submenu_open_data *sd = (submenu_open_data *)user_data;
|
||||
|
||||
GtkWidget *win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(win), GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
GtkWidget *win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(win), TRUE);
|
||||
|
||||
g_signal_connect(win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(sd->items, sd->count);
|
||||
gtk_container_add(GTK_CONTAINER(win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(win), vbox);
|
||||
|
||||
/* GtkButton has no native GdkWindow of its own — gtk_widget_get_window
|
||||
returns the parent popup's window. To get the button's screen-space
|
||||
position we read the popup origin (ox, oy) and add the button's
|
||||
allocation within the popup. */
|
||||
gint ox, oy;
|
||||
gdk_window_get_origin(gtk_widget_get_window(GTK_WIDGET(btn)), &ox, &oy);
|
||||
GtkAllocation alloc;
|
||||
gtk_widget_get_allocation(GTK_WIDGET(btn), &alloc);
|
||||
int ax = ox + alloc.x;
|
||||
int ay = oy + alloc.y;
|
||||
/* Need the anchor button's position in root coordinates. GTK4
|
||||
removed gtk_widget_translate_coordinates(); compute via the
|
||||
button's bounds within its native widget plus the native
|
||||
surface's screen origin via X11. */
|
||||
graphene_rect_t bounds;
|
||||
if (!gtk_widget_compute_bounds(GTK_WIDGET(btn),
|
||||
GTK_WIDGET(gtk_widget_get_native(GTK_WIDGET(btn))),
|
||||
&bounds)) {
|
||||
bounds.origin.x = 0;
|
||||
bounds.origin.y = 0;
|
||||
bounds.size.width = 0;
|
||||
bounds.size.height = 0;
|
||||
}
|
||||
GdkSurface *anchor_surface =
|
||||
gtk_native_get_surface(gtk_widget_get_native(GTK_WIDGET(btn)));
|
||||
int ox = 0, oy = 0;
|
||||
if (anchor_surface && GDK_IS_X11_SURFACE(anchor_surface)) {
|
||||
Window axid = gdk_x11_surface_get_xid(anchor_surface);
|
||||
GdkDisplay *display = gdk_surface_get_display(anchor_surface);
|
||||
Display *xdpy = gdk_x11_display_get_xdisplay(GDK_X11_DISPLAY(display));
|
||||
Window child;
|
||||
XTranslateCoordinates(xdpy, axid, DefaultRootWindow(xdpy),
|
||||
0, 0, &ox, &oy, &child);
|
||||
}
|
||||
int ax = ox + (int)bounds.origin.x;
|
||||
int ay = oy + (int)bounds.origin.y;
|
||||
|
||||
gtk_widget_show_all(win);
|
||||
gint sw, sh;
|
||||
gtk_window_get_size(GTK_WINDOW(win), &sw, &sh);
|
||||
gtk_widget_set_visible(win, TRUE);
|
||||
|
||||
int sw, sh;
|
||||
gtk_window_get_default_size(GTK_WINDOW(win), &sw, &sh);
|
||||
if (sw <= 0 || sh <= 0) {
|
||||
/* default_size returns -1,-1 if never explicitly set; fall back
|
||||
to the measured preferred size. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(win, NULL, &req);
|
||||
sw = req.width;
|
||||
sh = req.height;
|
||||
}
|
||||
|
||||
/* The parent popup grows upward from the tray, so submenu items
|
||||
sit closer to the bottom of the screen than to the top. Align
|
||||
the submenu's BOTTOM to the anchor button's bottom: the popup
|
||||
grows upward, level with the row that opened it. Don't clamp
|
||||
to the monitor top — that would re-position the submenu next
|
||||
to an unrelated sibling row above the anchor. */
|
||||
int final_x = ax + alloc.width;
|
||||
int final_y = ay + alloc.height - sh;
|
||||
grows upward, level with the row that opened it. */
|
||||
int final_x = ax + (int)bounds.size.width;
|
||||
int final_y = ay + (int)bounds.size.height - sh;
|
||||
|
||||
/* Horizontal flip against the monitor under the anchor button. */
|
||||
GdkDisplay *display = gtk_widget_get_display(win);
|
||||
GdkMonitor *monitor = gdk_display_get_monitor_at_point(display, ax, ay);
|
||||
if (monitor) {
|
||||
GListModel *monitors = gdk_display_get_monitors(display);
|
||||
guint n = g_list_model_get_n_items(monitors);
|
||||
for (guint i = 0; i < n; i++) {
|
||||
GdkMonitor *m = (GdkMonitor *)g_list_model_get_item(monitors, i);
|
||||
GdkRectangle geom;
|
||||
gdk_monitor_get_geometry(monitor, &geom);
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
gdk_monitor_get_geometry(m, &geom);
|
||||
if (ax >= geom.x && ax < geom.x + geom.width &&
|
||||
ay >= geom.y && ay < geom.y + geom.height) {
|
||||
if (final_x + sw > geom.x + geom.width)
|
||||
final_x = ax - sw; /* flip to the left */
|
||||
g_object_unref(m);
|
||||
break;
|
||||
}
|
||||
g_object_unref(m);
|
||||
}
|
||||
|
||||
gtk_window_move(GTK_WINDOW(win), final_x, final_y);
|
||||
x11_move_window(win, final_x, final_y);
|
||||
gtk_window_present(GTK_WINDOW(win));
|
||||
|
||||
submenu_popups = g_list_prepend(submenu_popups, win);
|
||||
@@ -402,8 +505,7 @@ static void on_submenu_button_clicked(GtkButton *btn, gpointer user_data) {
|
||||
|
||||
/* Build a vbox of GtkWidgets for the supplied items. Used for both the
|
||||
top-level popup and each submenu popup. The submenu_open_data attached
|
||||
to submenu buttons is freed when the submenu_popups list is cleared
|
||||
(we use the button's "destroy" signal). */
|
||||
to submenu buttons is freed when the button is destroyed. */
|
||||
static void on_button_destroy_free_data(GtkWidget *widget, gpointer user_data) {
|
||||
(void)widget;
|
||||
free(user_data);
|
||||
@@ -417,19 +519,21 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
if (mi->is_separator) {
|
||||
GtkWidget *sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 2);
|
||||
gtk_widget_set_margin_top(sep, 2);
|
||||
gtk_widget_set_margin_bottom(sep, 2);
|
||||
gtk_box_append(GTK_BOX(vbox), sep);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mi->is_check) {
|
||||
GtkWidget *chk = gtk_check_button_new_with_label(
|
||||
mi->label ? mi->label : "");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(chk), mi->checked);
|
||||
gtk_check_button_set_active(GTK_CHECK_BUTTON(chk), mi->checked);
|
||||
gtk_widget_set_sensitive(chk, mi->enabled);
|
||||
g_signal_connect(chk, "toggled",
|
||||
G_CALLBACK(on_check_toggled),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
gtk_box_pack_start(GTK_BOX(vbox), chk, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), chk);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -447,9 +551,10 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
|
||||
GtkWidget *btn = gtk_button_new_with_label(label_text);
|
||||
gtk_widget_set_sensitive(btn, mi->enabled);
|
||||
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
|
||||
GtkWidget *lbl = gtk_bin_get_child(GTK_BIN(btn));
|
||||
if (lbl) gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
gtk_button_set_has_frame(GTK_BUTTON(btn), FALSE);
|
||||
GtkWidget *lbl = gtk_button_get_child(GTK_BUTTON(btn));
|
||||
if (GTK_IS_LABEL(lbl))
|
||||
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
|
||||
|
||||
free(display_label);
|
||||
|
||||
@@ -468,7 +573,7 @@ static GtkWidget *build_menu_box(xembed_menu_item *items, int count) {
|
||||
G_CALLBACK(on_button_clicked),
|
||||
GINT_TO_POINTER(mi->id));
|
||||
}
|
||||
gtk_box_pack_start(GTK_BOX(vbox), btn, FALSE, FALSE, 0);
|
||||
gtk_box_append(GTK_BOX(vbox), btn);
|
||||
}
|
||||
|
||||
return vbox;
|
||||
@@ -480,38 +585,35 @@ static gboolean popup_menu_idle(gpointer user_data) {
|
||||
/* Destroy old top-level (and orphan submenus) before rebuilding. */
|
||||
close_all_popups();
|
||||
if (popup_win) {
|
||||
gtk_widget_destroy(popup_win);
|
||||
gtk_window_destroy(GTK_WINDOW(popup_win));
|
||||
popup_win = NULL;
|
||||
}
|
||||
|
||||
popup_win = gtk_window_new(GTK_WINDOW_TOPLEVEL);
|
||||
gtk_window_set_type_hint(GTK_WINDOW(popup_win),
|
||||
GDK_WINDOW_TYPE_HINT_POPUP_MENU);
|
||||
popup_win = gtk_window_new();
|
||||
gtk_window_set_decorated(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_resizable(GTK_WINDOW(popup_win), FALSE);
|
||||
gtk_window_set_skip_taskbar_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_skip_pager_hint(GTK_WINDOW(popup_win), TRUE);
|
||||
gtk_window_set_keep_above(GTK_WINDOW(popup_win), TRUE);
|
||||
|
||||
/* Close on focus loss. */
|
||||
g_signal_connect(popup_win, "focus-out-event",
|
||||
G_CALLBACK(on_popup_focus_out), NULL);
|
||||
attach_outside_click_close(popup_win);
|
||||
|
||||
GtkWidget *vbox = build_menu_box(pd->items, pd->count);
|
||||
gtk_container_add(GTK_CONTAINER(popup_win), vbox);
|
||||
gtk_window_set_child(GTK_WINDOW(popup_win), vbox);
|
||||
|
||||
gtk_widget_show_all(popup_win);
|
||||
gtk_widget_set_visible(popup_win, TRUE);
|
||||
|
||||
/* Position the window above the click point (menu grows upward
|
||||
from tray). Use measured preferred size — default_size is -1
|
||||
until set. */
|
||||
GtkRequisition req;
|
||||
gtk_widget_get_preferred_size(popup_win, NULL, &req);
|
||||
int win_w = req.width;
|
||||
int win_h = req.height;
|
||||
|
||||
/* Position the window above the click point (menu grows upward from tray). */
|
||||
gint win_w, win_h;
|
||||
gtk_window_get_size(GTK_WINDOW(popup_win), &win_w, &win_h);
|
||||
int final_x = pd->x - win_w / 2;
|
||||
int final_y = pd->y - win_h;
|
||||
if (final_x < 0) final_x = 0;
|
||||
if (final_y < 0) final_y = pd->y; /* fallback: below click */
|
||||
gtk_window_move(GTK_WINDOW(popup_win), final_x, final_y);
|
||||
x11_move_window(popup_win, final_x, final_y);
|
||||
|
||||
/* Grab focus so focus-out-event works. */
|
||||
gtk_window_present(GTK_WINDOW(popup_win));
|
||||
|
||||
/* The vbox+children retain pointers into pd->items (via submenu
|
||||
|
||||
Reference in New Issue
Block a user