Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor

# Conflicts:
#	client/ui/frontend/src/screens/Update.tsx
This commit is contained in:
Eduard Gert
2026-05-21 09:34:45 +02:00
59 changed files with 6967 additions and 2714 deletions

View 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
}

View 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)
}

View 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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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),
});
}
};

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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;
}

View 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);
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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),
});
}
};

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View 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)
}

View File

@@ -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.

View File

@@ -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>

View File

@@ -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