diff --git a/client/ui/authsession/service.go b/client/ui/authsession/service.go index 5cc96aca4..28efe7cfd 100644 --- a/client/ui/authsession/service.go +++ b/client/ui/authsession/service.go @@ -12,14 +12,11 @@ import ( "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"` @@ -28,45 +25,33 @@ type ExtendStartResult struct { 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. Preempted is true when a newer WaitExtend (e.g. started from -// another UI surface for the same deadline) took over the IdP poll — -// callers should treat the call as a no-op rather than a failure. +// ExtendResult: ExpiresAt is nil when the peer is ineligible for extension. +// Preempted means a newer WaitExtend took over the IdP poll — a no-op, not a failure. type ExtendResult struct { ExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` Preempted bool `json:"preempted,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. +// DaemonConn duplicates services.DaemonConn to avoid 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. +// Session bundles the session-auth daemon RPCs the UI drives. 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. +// RequestExtend starts the SSO session-extension flow on the daemon. func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) { cli, err := s.conn.Client() if err != nil { @@ -93,9 +78,7 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten }, 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). +// WaitExtend blocks until the user completes the SSO flow started by RequestExtend. func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { cli, err := s.conn.Client() if err != nil { @@ -121,10 +104,8 @@ func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendRes 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. +// DismissWarning suppresses the daemon's T-FinalWarningLead fallback dialog for +// the current deadline. Best-effort: a stale call is silently swallowed daemon-side. func (s *Session) DismissWarning(ctx context.Context) error { cli, err := s.conn.Client() if err != nil { diff --git a/client/ui/authsession/warning.go b/client/ui/authsession/warning.go index 304b903d2..91ae7f101 100644 --- a/client/ui/authsession/warning.go +++ b/client/ui/authsession/warning.go @@ -1,11 +1,8 @@ //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. +// session-extend feature. The Wails facades in client/ui/services/session*.go +// are thin adapters over these types. package authsession import ( @@ -14,10 +11,8 @@ import ( "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. +// Re-exported from sessionwatch so UI-side consumers don't import the +// daemon-internal package directly. const ( MetaWarning = sessionwatch.MetaSessionWarning MetaFinal = sessionwatch.MetaSessionFinal @@ -26,35 +21,21 @@ const ( MetaDeadlineRejected = sessionwatch.MetaSessionDeadlineRejected ) -// 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. +// Warning is the typed payload emitted on the session-warning Wails events. type Warning struct { - // ExpiresAt is the absolute UTC deadline the warning was fired - // against. The UI displays remaining time relative to its own clock. + // Absolute UTC deadline; best-effort, stays zero when metadata is + // missing or malformed (e.g. an older daemon) and the UI falls back + // to the Status snapshot. 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. + // Configured lead time, so the UI need not hardcode the constant. 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). + // True on the final-warning fallback event. 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. +// WarningFromMetadata parses SystemEvent metadata into a Warning, or returns +// (nil, false) when the event is not a session-warning. A field that fails to +// parse stays zero; the event is still surfaced. func WarningFromMetadata(meta map[string]string) (*Warning, bool) { if meta == nil || meta[MetaWarning] != "true" { return nil, false @@ -76,9 +57,8 @@ func WarningFromMetadata(meta map[string]string) (*Warning, bool) { 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. +// ParseExpiresAt re-exports sessionwatch.ParseExpiresAt so UI-side call sites +// don't import the daemon-internal package. func ParseExpiresAt(s string) (time.Time, error) { return sessionwatch.ParseExpiresAt(s) } diff --git a/client/ui/grpc.go b/client/ui/grpc.go index 33f781e8a..c8e3aed76 100644 --- a/client/ui/grpc.go +++ b/client/ui/grpc.go @@ -17,8 +17,7 @@ import ( "github.com/netbirdio/netbird/client/ui/desktop" ) -// Conn is a lazy, lock-protected gRPC connection to the NetBird daemon. -// One Conn instance is shared by all services so they reuse the same channel. +// Conn is the lazy, lock-protected gRPC connection shared by all services so they reuse one channel. type Conn struct { addr string @@ -41,11 +40,8 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { strings.TrimPrefix(c.addr, "tcp://"), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithUserAgent(desktop.GetUIUserAgent()), - // Without ConnectParams the SubChannel uses gRPC's default 120s - // MaxDelay, so after a couple of failed dials the UI waits 30-60s - // before noticing a freshly-started daemon. The Wails UI is a - // desktop client expecting prompt reconnects, not a high-fanout - // backend, so a 5s cap is a better trade-off than the default. + // Cap reconnect backoff at 5s; gRPC's default 120s MaxDelay would + // leave the UI waiting 30-60s to notice a freshly-started daemon. grpc.WithConnectParams(grpc.ConnectParams{ Backoff: backoff.Config{ BaseDelay: 1 * time.Second, @@ -62,8 +58,7 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { return c.client, nil } -// DaemonAddr returns the default daemon gRPC address for the current OS. -// Linux/macOS use a Unix socket; Windows uses TCP loopback. +// DaemonAddr returns the default daemon gRPC address: a Unix socket on Linux/macOS, TCP loopback on Windows. func DaemonAddr() string { if runtime.GOOS == "windows" { return "tcp://127.0.0.1:41731" diff --git a/client/ui/guilog/debuglog.go b/client/ui/guilog/debuglog.go index fe24f3be6..3a25c26c8 100644 --- a/client/ui/guilog/debuglog.go +++ b/client/ui/guilog/debuglog.go @@ -1,11 +1,8 @@ //go:build !android && !ios && !freebsd && !js -// Package guilog manages the desktop UI's own file log (gui-client.log), which -// follows the daemon's log level: when the daemon is in debug/trace the GUI -// attaches a rotated file alongside the console so its (and the React frontend's -// forwarded) output is captured for the debug bundle. It is intentionally not a -// Wails service — it has no frontend-facing methods and generates no TS -// bindings — so it lives outside client/ui/services. +// Package guilog manages gui-client.log, which follows the daemon's log level: +// in debug/trace the GUI attaches a rotated file alongside the console so its +// (and the React frontend's forwarded) output is captured for the debug bundle. package guilog import ( @@ -16,17 +13,9 @@ import ( "github.com/netbirdio/netbird/util" ) -// DebugLog is the daemon-debug-driven GUI file log. The daemon publishes a -// marked "log-level-changed" SystemEvent over SubscribeEvents (both on change -// and once per new subscription, so a daemon already in debug is picked up at -// startup); services.DaemonFeed routes it here via Apply. -// -// When the daemon is in debug/trace and the GUI owns its log (no manual -// --log-file), it attaches a rotated gui-client.log alongside the console and -// raises the logrus level; back to a higher level it detaches the file and -// restores info. The file is left on disk (rotated by timberjack) for the debug -// bundle to collect. When the user set --log-file explicitly, it is disabled and -// never touches logging. +// DebugLog attaches/detaches gui-client.log based on the daemon's log level, +// fed via Apply. The file is left on disk for the debug bundle to collect. +// Disabled (and never touches logging) when the user set --log-file explicitly. type DebugLog struct { uiPath string enabled bool @@ -35,16 +24,14 @@ type DebugLog struct { fileOn bool } -// NewDebugLog builds the GUI debug log. uiPath is the absolute gui-client.log -// path; enabled is false when the user passed --log-file (manual override), in -// which case it leaves logging untouched. +// NewDebugLog builds the GUI debug log. enabled is false when the user passed +// --log-file (manual override). func NewDebugLog(uiPath string, enabled bool) *DebugLog { return &DebugLog{uiPath: uiPath, enabled: enabled} } -// Path returns the GUI log path to register with the daemon, or "" when the GUI -// doesn't own its log (manual --log-file) — in that case the daemon shouldn't -// try to collect a gui-client.log the GUI never writes. +// Path returns the GUI log path to register with the daemon, or "" when disabled +// so the daemon won't collect a file the GUI never writes. func (d *DebugLog) Path() string { if !d.enabled { return "" @@ -52,17 +39,15 @@ func (d *DebugLog) Path() string { return d.uiPath } -// Apply reacts to a daemon log level (the lowercase logrus name, e.g. "debug"). -// Idempotent: repeated identical levels are no-ops, so the startup replay plus a -// racing change-event do no harm. +// Apply reacts to a daemon log level (the logrus name, e.g. "debug"). +// Idempotent via the fileOn guard, so the startup replay plus a racing +// change-event are harmless. func (d *DebugLog) Apply(level string) { if !d.enabled { return } - // "debug or more verbose" (debug/trace) turns the file log on; anything less - // verbose turns it off. Compare numerically against logrus' own levels so - // there are no hard-coded level-name literals. + // Compared numerically so there are no hard-coded level-name literals. lvl, err := log.ParseLevel(level) if err != nil { lvl = log.InfoLevel diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go index 42d6369ef..892916999 100644 --- a/client/ui/i18n/bundle.go +++ b/client/ui/i18n/bundle.go @@ -1,14 +1,10 @@ //go:build !android && !ios && !freebsd && !js -// Package i18n carries the translation domain: the BCP-47 LanguageCode -// type, the per-language Language metadata, and the Bundle that loads and -// serves translation strings for both the tray (Go) and the React UI -// (via the Wails-bound services.I18n facade). +// Package i18n loads and serves translation strings for both the tray (Go) +// and the React UI (via the services.I18n facade). // -// No Wails or daemon dependencies — this package can be tested and used -// standalone. The locale tree is passed in as an fs.FS so the embed -// directive can live in the main binary alongside the rest of the -// embedded assets. +// The locale tree is passed in as an fs.FS so the embed directive can live in +// the main binary. package i18n import ( @@ -25,64 +21,45 @@ import ( ) const ( - // localeIndexFile sits at the locale tree root and lists every shipped - // language with its display name. Adding a new language means dropping - // a new /common.json bundle and appending a row to this index. localeIndexFile = "_index.json" - // commonBundleFile is the per-language translation bundle. Single - // namespace for now ("common") — split later if the key set grows - // enough to warrant per-screen bundles. - // - // Shape is Chrome-extension JSON (each key maps to an object with a - // "message" and an optional "description") so Crowdin reads the - // description as translator context straight from the source file. - // Only the source bundle (en) needs descriptions; target bundles carry - // just "message". loadBundle flattens both back to key->message. + // commonBundleFile shape is Chrome-extension JSON (key -> "message" plus + // optional Crowdin "description"); loadBundle flattens to key->message. commonBundleFile = "common.json" ) -// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). Carried -// as a named string so the compiler distinguishes a language code from a -// translation key or an arbitrary user-supplied string in function -// signatures; JSON serialisation is unchanged (still a plain string). +// LanguageCode is a BCP-47-ish locale identifier ("en", "hu", ...). type LanguageCode string -// DefaultLanguage is used when no preference is on disk and as the fallback -// bundle for missing keys. +// DefaultLanguage is the fallback bundle for missing keys and the default +// when no preference is on disk. const DefaultLanguage LanguageCode = "en" -// ErrUnsupportedLanguage is returned when a caller asks for a language -// that has no bundle loaded. var ErrUnsupportedLanguage = errors.New("unsupported language") -// Language describes one shipped UI locale. DisplayName is shown in the -// picker in its own script (so a Hungarian user sees "Magyar" even when -// the current UI language is English). +// Language describes one shipped UI locale. DisplayName is in the locale's +// own script (a Hungarian entry reads "Magyar" regardless of UI language). type Language struct { Code LanguageCode `json:"code"` DisplayName string `json:"displayName"` EnglishName string `json:"englishName"` } -// localeIndex is the on-disk shape of _index.json. type localeIndex struct { Languages []Language `json:"languages"` } // Bundle holds the parsed translation bundles. Loaded once at construction -// and never mutated, so concurrent readers (tray menu rebuilds + Wails -// service calls) don't need to coordinate beyond the RW mutex. +// and never mutated. type Bundle struct { mu sync.RWMutex languages []Language bundles map[LanguageCode]map[string]string } -// NewBundle parses _index.json plus every /common.json file in the -// locale tree. Hard-fails only when the default language is missing — -// individual locales without a bundle are dropped with a warning so the -// rest of the product keeps shipping. +// NewBundle parses _index.json plus every /common.json in the locale +// tree. Hard-fails only when the default language is missing; other locales +// without a bundle are dropped with a warning. func NewBundle(localesFS fs.FS) (*Bundle, error) { idx, err := loadLocaleIndex(localesFS) if err != nil { @@ -113,7 +90,7 @@ func NewBundle(localesFS fs.FS) (*Bundle, error) { }, nil } -// Languages returns the list of available locales as a copy. +// Languages returns a copy of the available locales. func (b *Bundle) Languages() []Language { b.mu.RLock() defer b.mu.RUnlock() @@ -122,8 +99,6 @@ func (b *Bundle) Languages() []Language { return out } -// HasLanguage reports whether a bundle is loaded for the given code. -// preferences.Store uses this to validate SetLanguage input. func (b *Bundle) HasLanguage(code LanguageCode) bool { b.mu.RLock() defer b.mu.RUnlock() @@ -131,9 +106,7 @@ func (b *Bundle) HasLanguage(code LanguageCode) bool { return ok } -// BundleFor returns the full key->text map for one language as a copy. -// The Wails facade exposes this to React so the frontend can drive its -// own translation library (i18next, etc.) off the same source bundles. +// BundleFor returns a copy of the full key->text map for one language. func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { b.mu.RLock() defer b.mu.RUnlock() @@ -149,11 +122,9 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { return out, nil } -// Translate resolves key for the given language with a placeholder pass. -// Args must come in {placeholderName, value} pairs (e.g. "version", "1.2.3" -// substitutes "{version}"). Unknown keys fall back to the default language; -// if even that fails, the key itself is returned — a missed key is visible -// in the UI rather than blank. +// Translate resolves key for lang, substituting args given as name/value +// pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to +// the default language, then to the key itself so a miss is visible in the UI. func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { b.mu.RLock() defer b.mu.RUnlock() @@ -169,9 +140,8 @@ func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string return key } -// applyPlaceholders substitutes {name} occurrences in s using args interpreted -// as flat name/value pairs. Odd-length args lists drop the trailing item with -// a debug log — preferable to a hard error since the caller is internal code. +// applyPlaceholders substitutes {name} in s using args as flat name/value +// pairs. An odd-length args drops the trailing item. func applyPlaceholders(s string, args []string) string { if len(args) == 0 { return s @@ -201,9 +171,8 @@ func loadLocaleIndex(localesFS fs.FS) (*localeIndex, error) { return &idx, nil } -// bundleEntry is the on-disk shape of one translation key: a Chrome-JSON -// object carrying the translatable "message" plus an optional translator -// "description" (consumed by Crowdin, ignored at runtime). +// bundleEntry is one translation key on disk; Description is Crowdin context, +// ignored at runtime. type bundleEntry struct { Message string `json:"message"` Description string `json:"description,omitempty"` diff --git a/client/ui/icons.go b/client/ui/icons.go index 1680d2d4a..62569fc77 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -4,12 +4,7 @@ package main import _ "embed" -// Tray icons embedded from the legacy Fyne UI's asset set. Each pair is a -// light-mode PNG and its dark-mode variant; macOS template variants -// (*-macos.png) live alongside for menubar use. Windows uses the same -// PNGs — multi-resolution .ico files looked promising on disk but -// Wails3's Shell_NotifyIcon NIM_MODIFY never redrew them on the running -// tray; PNG single-frame works. +// Windows reuses these PNGs: multi-frame .ico never redrew under Wails3's NIM_MODIFY, single-frame PNG does. //go:embed assets/netbird-systemtray-connected.png var iconConnected []byte @@ -56,12 +51,8 @@ var iconUpdateConnectedMacOS []byte //go:embed assets/netbird-systemtray-update-disconnected-macos.png var iconUpdateDisconnectedMacOS []byte -// Linux monochrome tray icons. Linux's SNI tray has no template-recoloring -// (unlike macOS's SetTemplateIcon), so we ship an explicit black/white pair: -// the black silhouette (*-mono.png) goes to SetIcon for light panels, the -// white one (*-mono-dark.png) goes to SetDarkModeIcon for dark panels, and -// the SNI host picks per panel theme. Generated from the macOS template -// silhouettes — states differ by shape, not color. +// SNI has no template recoloring, so ship an explicit pair: black (*-mono.png) +// for light panels, white (*-mono-dark.png) for dark panels. //go:embed assets/netbird-systemtray-connected-mono.png var iconConnectedMono []byte @@ -108,9 +99,6 @@ var iconUpdateDisconnectedMonoDark []byte //go:embed assets/netbird.png var iconWindow []byte -// Per-platform menu-row icons (status dots + NetBird brand mark) live in -// icons_menu_windows.go and icons_menu_other.go. Windows installs them -// into the Win32 check-mark slot, which expects SM_CXMENUCHECK-sized -// bitmaps (~16x16 at 100% DPI) — anything bigger gets cropped, anything -// smaller leaves blank space — so Windows ships its own 16x16 set -// while macOS/Linux keep the larger 24x24 assets that fit their menus. +// Per-platform menu-row icons live in icons_menu_{windows,other}.go. Windows +// uses 16x16: they go into the Win32 check-mark slot (SM_CXMENUCHECK, ~16x16 at +// 100% DPI) which crops anything bigger; macOS/Linux use 24x24. diff --git a/client/ui/icons_menu_darwin.go b/client/ui/icons_menu_darwin.go index 1b1e88391..c077a6d2d 100644 --- a/client/ui/icons_menu_darwin.go +++ b/client/ui/icons_menu_darwin.go @@ -4,11 +4,8 @@ package main import _ "embed" -// 22x22 status dot icons used on macOS. Apple's HIG recommends an -// 18–22 px glyph for NSMenuItem leading images; 22 matches the visual -// weight of the surrounding row text. Windows ships a 16x16 variant -// (Win32 SM_CXMENUCHECK slot) and Linux a 24x24 variant (GTK menu row -// supports the larger range) — see the sibling icons_menu_*.go files. +// 22px matches the NSMenuItem row text weight (HIG's 18-22 range); +// Windows uses 16px and Linux 24px — see the sibling icons_menu_*.go. //go:embed assets/netbird-menu-dot-connected-22.png var iconMenuDotConnected []byte diff --git a/client/ui/icons_menu_linux.go b/client/ui/icons_menu_linux.go index 454138604..54e0c743a 100644 --- a/client/ui/icons_menu_linux.go +++ b/client/ui/icons_menu_linux.go @@ -4,13 +4,7 @@ package main import _ "embed" -// 24x24 menu-row icons used on Linux. GTK4 menu rows accept icons in the -// 22–48 px range with no automatic downscaling at this size; 24 reads -// cleanly next to the row text across the GNOME / KDE / minimal-WM -// flavours we ship to. Windows ships a 16x16 variant (Win32 -// SM_CXMENUCHECK slot) and macOS a 22x22 variant — see the sibling -// icons_menu_*.go files. Status dots are the canonical 24x24 originals -// used everywhere else in the legacy Fyne tray. +// 24x24: GTK4 menu rows render 22–48 px with no downscaling. //go:embed assets/netbird-menu-dot-connected.png var iconMenuDotConnected []byte diff --git a/client/ui/icons_menu_windows.go b/client/ui/icons_menu_windows.go index 434fefadd..c08359902 100644 --- a/client/ui/icons_menu_windows.go +++ b/client/ui/icons_menu_windows.go @@ -4,16 +4,9 @@ package main import _ "embed" -// 16x16 menu-row icons used on Windows. The Win32 SetMenuItemBitmaps API -// paints the HBITMAP into the check-mark slot, sized to SM_CXMENUCHECK / -// SM_CYMENUCHECK (typically 16x16 at 100% DPI). Larger bitmaps overflow -// the row visually, so Windows ships its own scaled set instead of the -// 24x24 assets used on macOS/Linux. The status dots are downscaled from -// the 24x24 originals with ImageMagick — simple solid-fill circles -// survive the bicubic resize without visible quality loss: -// magick netbird-menu-dot-.png -resize 16x16 \ -// -background none -gravity center -extent 16x16 \ -// netbird-menu-dot--16.png +// SetMenuItemBitmaps sizes the HBITMAP to SM_CXMENUCHECK/SM_CYMENUCHECK (16x16 +// at 100% DPI); larger bitmaps overflow the row, hence this Windows-only set +// downscaled from the 24x24 originals. //go:embed assets/netbird-menu-dot-connected-16.png var iconMenuDotConnected []byte diff --git a/client/ui/localizer.go b/client/ui/localizer.go index b9571e674..33c1cf205 100644 --- a/client/ui/localizer.go +++ b/client/ui/localizer.go @@ -13,15 +13,11 @@ import ( "github.com/netbirdio/netbird/client/ui/services" ) -// Localizer is the tray's bridge to the i18n bundle and preferences store. -// It caches the active language so every menu-build pass and notification -// call can resolve a key without re-querying preferences, and it owns -// the preference-subscription lifecycle so consumers don't have to. +// Localizer caches the active language so key lookups skip the preferences store. // // Kept in the main package (not i18n/) because StatusLabel maps daemon -// status enum strings (services.StatusIdle, services.StatusDaemonUnavailable) -// to translations — pulling those into i18n would invert the dependency -// direction. +// status enum strings to translations; moving it would invert the +// dependency direction. type Localizer struct { bundle *i18n.Bundle store *preferences.Store @@ -32,10 +28,8 @@ type Localizer struct { unsubscribe func() } -// NewLocalizer seeds the active language from the on-disk preference so -// the first menu render is already in the right locale. Either argument -// may be nil — useful for tests/dry-runs — in which case Translate falls -// back to the raw key and Watch is a no-op. +// NewLocalizer seeds the active language from the on-disk preference. Either +// argument may be nil (tests): T then returns the raw key and Watch is a no-op. func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer { l := &Localizer{ bundle: bundle, @@ -50,16 +44,15 @@ func NewLocalizer(bundle *i18n.Bundle, store *preferences.Store) *Localizer { return l } -// Language returns the BCP-47 code currently driving translations. +// Language returns the active language code. func (l *Localizer) Language() i18n.LanguageCode { l.mu.RLock() defer l.mu.RUnlock() return l.lang } -// T resolves key in the current language with optional {placeholder}/value -// argument pairs. When no bundle is wired the key is returned as-is so -// callers always get a non-empty string. +// T resolves key in the current language; args are {placeholder}/value pairs. +// With no bundle wired it returns key unchanged. func (l *Localizer) T(key string, args ...string) string { if l == nil || l.bundle == nil { return key @@ -70,10 +63,8 @@ func (l *Localizer) T(key string, args ...string) string { return l.bundle.Translate(lang, key, args...) } -// Watch subscribes to preference changes; cb fires for each new language -// (after the Localizer's own cached language has been updated, so cb can -// call l.T to render with the new locale). Safe to call once per -// Localizer; later calls overwrite the previous subscription. +// Watch invokes cb on each language change, after the cached language is +// updated so cb may call l.T with the new locale. Replaces any prior subscription. func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { if l.store == nil { return @@ -106,9 +97,7 @@ func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { }() } -// Close drops the preference subscription. Currently unused (the tray -// lives for the whole process) but kept so a future shutdown path can -// release the channel cleanly. +// Close cancels the preference subscription. func (l *Localizer) Close() { l.mu.Lock() defer l.mu.Unlock() @@ -118,10 +107,8 @@ func (l *Localizer) Close() { } } -// StatusLabel maps a daemon status string to its user-facing tray label. -// Idle and the daemon-unavailable sentinel get translated phrasing; every -// other status passes through verbatim (matches the legacy behaviour of -// surfacing the raw daemon enum for the connecting/needs-login states). +// StatusLabel maps a daemon status string to its tray label; unrecognised +// statuses pass through verbatim. func (l *Localizer) StatusLabel(status string) string { switch { case status == "", strings.EqualFold(status, services.StatusIdle): diff --git a/client/ui/main.go b/client/ui/main.go index 6c12b57c2..32e464ad4 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -27,19 +27,16 @@ import ( //go:embed all:frontend/dist var assets embed.FS -// localesFS roots the i18n translation bundles. Embedded from the same -// directory the React app imports, so a single JSON source drives both -// the tray (Go) and the in-window UI (Vite imports the files directly). -// The `all:` prefix is required so _index.json is included — //go:embed -// silently drops files whose names start with "_" or "." otherwise. +// localesRoot embeds the i18n bundles shared by the tray (Go) and the React +// UI (Vite imports the same files). The `all:` prefix is required so +// _index.json is included — //go:embed drops files starting with "_" or "." +// otherwise. // //go:embed all:i18n/locales var localesRoot embed.FS -// stringList is a flag.Value that collects repeated string flags. The first -// time the user passes -log-file the seeded default ("console") is dropped; -// subsequent passes append. Lets the user replace or extend the log target -// list without a separate "reset" flag. +// stringList collects repeated string flags. The first user-supplied value +// drops the seeded default; subsequent passes append. type stringList struct { values []string userSet bool @@ -58,8 +55,6 @@ func (s *stringList) Set(v string) error { return nil } -// registeredServices bundles the constructed services that registerServices -// binds to the Wails app, keeping the call site readable. type registeredServices struct { connection *services.Connection authSession *authsession.Session @@ -87,15 +82,12 @@ func main() { daemonAddr, userSetLogFile := parseFlagsAndInitLog() conn := NewConn(daemonAddr) - // GUI file logging: when the user didn't pass --log-file, the GUI manages a - // gui-client.log that follows the daemon's debug level (attached when the - // daemon is in debug/trace, detached otherwise, rotated by timberjack) and is - // included in the debug bundle. It rides DaemonFeed's SubscribeEvents stream - // (passed into NewDaemonFeed below; see guilog.DebugLog). + // Without --log-file, the GUI manages a gui-client.log that follows the + // daemon's debug level and is collected in the debug bundle. It rides + // DaemonFeed's SubscribeEvents stream (see guilog.DebugLog). debugLog := newDebugLog(userSetLogFile) - // tray is captured in the SingleInstance callback below; the var is - // declared before app.New so the closure has a stable reference. + // Declared before app.New so the SingleInstance callback closes over it. var tray *Tray app := newApplication(func() { if tray != nil { @@ -105,31 +97,28 @@ func main() { settings := services.NewSettings(conn) profiles := services.NewProfiles(conn) - // updater.Holder owns the typed update State. DaemonFeed pipes the - // daemon SubscribeEvents stream into it; the Update service is a thin - // Wails-bound facade over the holder plus the install RPCs. + // updater.Holder owns the typed update State; DaemonFeed feeds it and the + // Update service is a thin Wails-bound facade over it plus the install RPCs. updaterHolder := updater.NewHolder(app.Event) update := services.NewUpdate(conn, updaterHolder) daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog) notifier := notifications.New() - // macOS won't surface any toast until the app has requested permission; - // the request runs after ApplicationStarted so the notifier's Startup has - // initialised the notification-center delegate. Linux/Windows stubs return - // authorized, so this is a no-op there. + // macOS shows no toast until permission is requested. Run it after + // ApplicationStarted so the notifier's Startup has initialised the + // notification-center delegate. No-op on Linux/Windows (stubs report + // authorized). app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { go requestNotificationAuthorization(notifier) }) bundle, prefStore, localizer := buildI18n(app) - // Connection lives after bundle + prefStore so it can localise daemon - // errors (services.NewConnection takes both as dependencies). + // After bundle + prefStore: both are used to localise daemon errors. connection := services.NewConnection(conn, bundle, prefStore) profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed) - // 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.Session owns the full extend + dismiss surface the tray + // drives directly; the Wails-bound services.Session wraps only the subset + // the React frontend calls, keeping the generated TS surface minimal. authSession := authsession.NewSession(conn) networks := services.NewNetworks(conn) @@ -149,37 +138,31 @@ func main() { window := newMainWindow(app, prefStore) - // Settings is created eagerly (hidden) inside NewWindowManager so the - // first click on the gear paints instantly and the React side keeps - // per-tab state across reopens. The other auxiliary windows - // (BrowserLogin, Session*, InstallProgress) stay lazy + destroy-on-close - // so they don't linger as hidden windows that Wails's macOS dock-reopen - // handler would pop back up. + // Settings is created eagerly (hidden) so the first gear click paints + // instantly and React keeps per-tab state across reopens. The other + // auxiliary windows stay lazy + destroy-on-close so Wails's macOS + // dock-reopen handler can't resurrect them. windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) - // On minimal WMs (the in-process XEmbed-tray path) the WM neither centers - // small windows nor restores their position across a hide -> show, so the - // main/Settings windows would open in the top-left corner. Gate Go-side - // re-centering on that environment; nil (full desktops, macOS, Windows) - // leaves placement to the WM. See WindowManager.SetRecenterOnShow. + // Minimal WMs (XEmbed-tray path) neither center small windows nor restore + // position across hide -> show, dropping them top-left. Gate Go-side + // re-centering on that environment; nil leaves placement to the WM on full + // desktops, macOS, and Windows. windowManager.SetRecenterOnShow(recenterOnShowPredicate()) app.RegisterService(application.NewService(windowManager)) - // Welcome / onboarding window. First launch only — the Continue - // button in the dialog flips OnboardingCompleted=true via the - // Preferences service before closing, so subsequent launches skip - // straight to the tray-only flow. ApplicationStarted hook so the - // Wails window machinery is fully up before the window is created. + // Welcome window, first launch only — Continue flips OnboardingCompleted + // so later launches skip it. ApplicationStarted hook so the Wails window + // machinery is fully up before the window is created. if !prefStore.Get().OnboardingCompleted { app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { windowManager.OpenWelcome() }) } - // Register an in-process StatusNotifierWatcher so the tray works on - // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the - // AppIndicator extension) that don't ship one themselves. No-op on - // non-Linux platforms. Must run before NewTray so the Wails systray's - // RegisterStatusNotifierItem call hits a watcher we control. + // In-process StatusNotifierWatcher so the tray works on minimal WMs that + // don't ship one (Fluxbox, i3, GNOME without AppIndicator). No-op off + // Linux. Must run before NewTray so the systray's + // RegisterStatusNotifierItem hits a watcher we control. startStatusNotifierWatcher() tray = NewTray(app, window, TrayServices{ @@ -197,17 +180,13 @@ func main() { }) listenForShowSignal(context.Background(), tray) - // Start the daemon event feed only after Wails has run every service's - // ServiceStartup. The very first daemon SubscribeEvents message replays - // the cached state (status + available update) synchronously, which fans - // out through app.Event into the tray's update-state listener and fires an - // OS notification. If Watch ran before app.Run, that send could beat the - // notifications service's ServiceStartup — on Linux the Wails notifier - // connects to the session bus there, so its *dbus.Conn would still be nil - // and SendNotification would nil-deref (fatal panic on the event-dispatch - // goroutine; observed on Linux Mint). ApplicationStarted fires inside - // app.Run after the synchronous service-startup loop, so the bus is up by - // the time the first event lands. + // Start the feed only after every service's ServiceStartup has run. The + // first SubscribeEvents message replays cached state synchronously and can + // fire an OS notification; if Watch ran before app.Run it could beat the + // notifier's ServiceStartup, where the Linux notifier connects the session + // bus — its *dbus.Conn would still be nil and SendNotification would + // nil-deref (fatal panic on the dispatch goroutine, observed on Linux + // Mint). ApplicationStarted fires after the startup loop, so the bus is up. app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { daemonFeed.Watch(context.Background()) }) @@ -217,11 +196,9 @@ func main() { } } -// requestNotificationAuthorization prompts for macOS notification permission -// when the app first runs unauthorized. RequestNotificationAuthorization -// blocks until the user responds (up to 3 minutes on macOS), so callers run -// it in a goroutine. On Linux/Windows the Wails notifier stubs report -// authorized, making this a no-op. +// requestNotificationAuthorization prompts for macOS notification permission. +// The request blocks until the user responds (up to 3 minutes), so callers run +// it in a goroutine. No-op on Linux/Windows. func requestNotificationAuthorization(notifier *notifications.NotificationService) { authorized, err := notifier.CheckNotificationAuthorization() if err != nil { @@ -236,14 +213,12 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic } } -// parseFlagsAndInitLog parses the CLI flags, initialises the logger, and -// returns the resolved daemon gRPC address plus userSetLogFile — true when the -// user passed --log-file explicitly. userSetLogFile is the manual-override -// signal: when true the GUI leaves logging alone (the daemon's debug level -// won't attach gui-client.log); when false the GUI manages a per-session -// gui-client.log driven by the daemon level. The default seed is empty (not -// "console") so "no flag" and an explicit "--log-file console" are -// distinguishable; an empty result falls back to console for InitLog. +// parseFlagsAndInitLog returns the daemon gRPC address and userSetLogFile +// (true when --log-file was passed). userSetLogFile is the manual-override +// signal: true leaves logging alone, false lets the GUI manage a +// daemon-driven gui-client.log. The flag default is empty (not "console") so +// "no flag" and an explicit "--log-file console" stay distinguishable; empty +// falls back to console for InitLog. func parseFlagsAndInitLog() (string, bool) { daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port") logFiles := &stringList{} @@ -263,27 +238,25 @@ func parseFlagsAndInitLog() (string, bool) { return *daemonAddr, userSetLogFile } -// newApplication constructs the Wails application. onSecondInstance is invoked -// when a second process launches under the same SingleInstance UniqueID. +// newApplication constructs the Wails application. onSecondInstance fires when +// a second process launches under the same SingleInstance UniqueID. func newApplication(onSecondInstance func()) *application.App { - // On macOS, application.Options.Icon is fed into NSApplication's - // setApplicationIconImage at startup, which would override the bundle - // icon (Assets.car / icons.icns) the OS already picked. We want the - // bundle's squircle to stay, so suppress it on darwin. + // On macOS, Options.Icon feeds NSApplication's setApplicationIconImage, + // overriding the bundle icon (Assets.car / icons.icns) the OS already + // picked. Suppress it on darwin to keep the bundle's squircle. appIcon := iconWindow if runtime.GOOS == "darwin" { appIcon = nil } return application.New(application.Options{ - // Windows uses Name as the AppUserModelID for toast notifications - // (see notifications_windows.go: cfg.Name -> wn.appName -> AppID) - // and as the registry path under HKCU\Software\Classes\AppUserModelId\. - // Must match the System.AppUserModel.ID value the MSI sets on the - // Start Menu shortcut (client/netbird.wxs) and the AppUserModelId - // key the installer pre-populates with the toast activator CLSID; - // otherwise toasts show under a different identity and the MSI's - // CustomActivator registry value is orphaned. + // On Windows, Name is the AppUserModelID for toast notifications and + // the HKCU\Software\Classes\AppUserModelId\ registry path. It must + // match the System.AppUserModel.ID the MSI sets on the Start Menu + // shortcut (client/netbird.wxs) and the AppUserModelId key the + // installer pre-populates with the toast activator CLSID; otherwise + // toasts show under a different identity and the MSI's CustomActivator + // value is orphaned. Name: "NetBird", Description: "NetBird desktop client", Icon: appIcon, @@ -305,14 +278,13 @@ func newApplication(onSecondInstance func()) *application.App { }) } -// buildI18n constructs the domain-layer i18n bundle, the preferences store, -// and the tray localizer. The Bundle satisfies preferences.LanguageValidator -// so SetLanguage rejects codes that have no shipped translation. +// buildI18n constructs the i18n bundle, preferences store, and tray localizer. +// The Bundle satisfies preferences.LanguageValidator so SetLanguage rejects +// codes that have no shipped translation. func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localizer) { - // localesFS reroots the embedded tree at the locales directory itself - // so the bundle sees _index.json and /common.json at the top - // level (the //go:embed path is rooted at the package, not the leaf - // dir). + // Reroot the embedded tree at the locales dir so the bundle sees + // _index.json and /common.json at top level (//go:embed roots at + // the package, not the leaf dir). localesFS, err := fs.Sub(localesRoot, "i18n/locales") if err != nil { log.Fatalf("locate locales fs: %v", err) @@ -329,9 +301,8 @@ func buildI18n(app *application.App) (*i18n.Bundle, *preferences.Store, *Localiz } // registerServices binds every Wails-facing service onto the application. -// Services constructed inline here (Session, Forwarding, Debug, I18n, -// Preferences) have no other caller; the rest arrive already built so the -// tray and feed loops can share the same instances. +// Services with no other caller are constructed inline; the rest arrive +// already built so the tray and feed loops share the same instances. func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(s.connection)) app.RegisterService(application.NewService(services.NewSession(s.authSession))) @@ -354,9 +325,8 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { // newMainWindow creates the hidden main window, sized to the user's last view // mode, and installs the hide-on-close and macOS dock-reopen hooks. func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow { - // Open the main window at the width matching the user's last view - // choice so an Advanced-mode user doesn't see the window pop from 380px - // to 900px on every launch. Height is the same in both modes. + // Width matches the last view mode so Advanced-mode users don't see the + // window pop from 380px to 900px on launch. Height is mode-agnostic. initialWidth := 380 if prefStore.Get().ViewMode == preferences.ViewModeAdvanced { initialWidth = 900 @@ -366,9 +336,8 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat Title: "NetBird", Width: initialWidth, Height: services.WindowHeight, - // Center on first show. Full DEs (GNOME/KDE) place small windows - // centered by default, but minimal WMs (fluxbox et al, the XEmbed - // tray path) drop new windows in the top-left corner unless asked. + // Center on first show; minimal WMs (fluxbox, the XEmbed tray path) + // drop new windows top-left unless asked. InitialPosition: application.WindowCentered, Hidden: true, BackgroundColour: services.WindowBackgroundColour, @@ -383,19 +352,16 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat }, }) - // Intercept the window close to hide instead of quit. The user reaches - // "really quit" via tray -> Quit. + // Hide instead of quit on close; "really quit" is reached via tray -> Quit. window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() window.Hide() }) - // On macOS, replace Wails' default applicationShouldHandleReopen handler - // (events_common_darwin.go setupCommonEvents) which calls Show() on - // every hidden window when the dock icon is clicked. That resurrects - // hide-on-close auxiliary surfaces like Settings. Cancel the event in - // a hook (hooks run synchronously, before listeners) and bring up only - // the main window. No-op on other platforms — the event never fires. + // On macOS, Wails' default applicationShouldHandleReopen handler Show()s + // every hidden window on dock-icon click, resurrecting hide-on-close + // surfaces like Settings. Cancel it in a hook (hooks run before listeners) + // and show only the main window. No-op elsewhere — the event never fires. if runtime.GOOS == "darwin" { app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { e.Cancel() diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index f10662a56..afc854185 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -1,14 +1,9 @@ //go:build !android && !ios && !freebsd && !js -// Package preferences holds user-scope UI state that is independent of the -// daemon profile: language, and any future toggles the React UI exposes to -// the user. The Store reads from and writes to a JSON file under -// os.UserConfigDir(), validates input against an injected language -// validator (typically *i18n.Bundle), and broadcasts changes to in-process -// subscribers (tray) plus an optional Wails emitter (frontend). -// -// No Wails dependency — the emitter is consumed through a minimal -// interface so the package can be tested without spinning up Wails. +// Package preferences holds user-scope UI state, independent of the daemon +// profile and shared across all profiles. The Store persists to JSON under +// os.UserConfigDir() and broadcasts changes to in-process subscribers plus an +// optional emitter. package preferences import ( @@ -25,21 +20,15 @@ import ( "github.com/netbirdio/netbird/util" ) -// preferencesFileName is the JSON file holding user-scope UI preferences. -// Stored under os.UserConfigDir()/netbird so it lives in the OS-user's -// writable config dir, not the daemon's root-owned state. Per-OS-user, -// shared across all daemon profiles. +// Lives under os.UserConfigDir()/netbird (OS-user writable, not the daemon's +// root-owned state). const preferencesFileName = "ui-preferences.json" -// EventPreferencesChanged fires whenever the on-disk preferences are -// updated (from any source). The payload is the fresh UIPreferences value. -// Wails registers this name in init() so the React frontend can subscribe. +// EventPreferencesChanged fires on every persisted update, payload UIPreferences. const EventPreferencesChanged = "netbird:preferences:changed" -// ViewMode is the user's preferred Main-window layout. "default" is the -// compact 380-wide layout shown on first launch; "advanced" is the wider -// 900-px layout that matches the Settings window. Persisted so the next -// launch comes up in the same mode the user last picked. +// ViewMode is the preferred Main-window layout: "default" (compact, 380-wide) +// or "advanced" (900-wide). type ViewMode string const ( @@ -47,15 +36,11 @@ const ( ViewModeAdvanced ViewMode = "advanced" ) -// DefaultViewMode is the value served when no preferences file exists yet -// or the on-disk file has an empty view-mode field. +// DefaultViewMode applies when no file exists or its view-mode is empty. const DefaultViewMode = ViewModeDefault -// ErrUnsupportedViewMode is returned by SetViewMode when the caller passes -// a value outside the known set. var ErrUnsupportedViewMode = errors.New("unsupported view mode") -// IsValid reports whether v is one of the known ViewMode constants. func (v ViewMode) IsValid() bool { switch v { case ViewModeDefault, ViewModeAdvanced: @@ -64,31 +49,26 @@ func (v ViewMode) IsValid() bool { return false } -// UIPreferences is the user-scope UI state mirrored to disk and to the -// frontend. Pointer-free because the whole document is rewritten on every -// change — there are no per-field partial updates. +// UIPreferences is rewritten in full on every change; there are no partial updates. type UIPreferences struct { Language i18n.LanguageCode `json:"language"` ViewMode ViewMode `json:"viewMode"` OnboardingCompleted bool `json:"onboardingCompleted"` } -// LanguageValidator is the dependency Store needs to reject SetLanguage -// inputs that have no shipped bundle. *i18n.Bundle satisfies it directly. +// LanguageValidator rejects SetLanguage inputs with no shipped bundle. +// *i18n.Bundle satisfies it. type LanguageValidator interface { HasLanguage(code i18n.LanguageCode) bool } -// Emitter is the dependency Store needs to broadcast changes to the -// frontend. *application.EventProcessor (Wails) satisfies it; tests pass -// nil or a fake. +// Emitter broadcasts changes to the frontend. Wails' +// *application.EventProcessor satisfies it; tests pass nil or a fake. type Emitter interface { Emit(name string, data ...any) bool } -// Store is the user-scope UI preferences store. Read at app start, -// updated by the React settings page (via the Wails-bound facade), and -// observed by the tray which re-renders its menu in the new language. +// Store is the user-scope UI preferences store. type Store struct { path string @@ -102,21 +82,16 @@ type Store struct { emitter Emitter } -// NewStore loads preferences from disk (creating a default file when -// none exists). The validator is consulted on SetLanguage; pass nil to -// skip validation (used by the unit tests). The emitter is optional — -// when set, SetLanguage broadcasts EventPreferencesChanged. +// NewStore loads preferences from disk, falling back to defaults. A nil +// validator skips SetLanguage validation; a nil emitter skips broadcasting. func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) { path, err := preferencesPath() if err != nil { return nil, fmt.Errorf("resolve preferences path: %w", err) } - // Language starts empty — the absence of a value is the signal the - // frontend uses on first launch to detect the browser locale and call - // SetLanguage. Consumers that need an effective language (tray - // Localizer, i18n.Bundle.Translate) already fall back to - // i18n.DefaultLanguage when the code is empty. + // Language starts empty: the frontend treats absence as the signal to + // detect the browser locale on first launch and call SetLanguage. s := &Store{ path: path, validator: validator, @@ -138,8 +113,7 @@ func (s *Store) Get() UIPreferences { return s.current } -// SetViewMode validates and persists the user's Main-window view choice, -// then broadcasts the change so any other open window can react. +// SetViewMode validates, persists, and broadcasts. No-op if unchanged. func (s *Store) SetViewMode(mode ViewMode) error { if !mode.IsValid() { return fmt.Errorf("%w: %q", ErrUnsupportedViewMode, mode) @@ -163,9 +137,7 @@ func (s *Store) SetViewMode(mode ViewMode) error { return nil } -// SetOnboardingCompleted persists the welcome-window dismissal so the -// welcome flow doesn't run again on subsequent launches. Idempotent — a -// repeat of the current value is a no-op (no disk write, no broadcast). +// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged. func (s *Store) SetOnboardingCompleted(done bool) error { s.mu.Lock() if s.current.OnboardingCompleted == done { @@ -185,9 +157,7 @@ func (s *Store) SetOnboardingCompleted(done bool) error { return nil } -// SetLanguage validates and persists a new language preference, then -// broadcasts the change to internal subscribers (tray) and the emitter -// (frontend). +// SetLanguage validates, persists, and broadcasts. No-op if unchanged. func (s *Store) SetLanguage(lang i18n.LanguageCode) error { if lang == "" { return fmt.Errorf("%w: empty code", i18n.ErrUnsupportedLanguage) @@ -214,9 +184,8 @@ func (s *Store) SetLanguage(lang i18n.LanguageCode) error { return nil } -// Subscribe returns a channel that receives every persisted change. The -// unsubscribe function closes the channel and removes it from the list; -// callers must not close the channel themselves. +// Subscribe returns a channel of persisted changes and an unsubscribe func. +// The unsubscribe func closes the channel; callers must not close it themselves. func (s *Store) Subscribe() (<-chan UIPreferences, func()) { ch := make(chan UIPreferences, 4) s.subsMu.Lock() @@ -237,9 +206,8 @@ func (s *Store) Subscribe() (<-chan UIPreferences, func()) { return ch, unsubscribe } -// load reads the on-disk file into current. A missing file is not an -// error (we keep the in-memory default); malformed contents are reported -// so the caller can log+continue with the default. +// load reads the file into current. A missing file is not an error (the +// in-memory default stands); malformed contents return an error. func (s *Store) load() error { if _, err := os.Stat(s.path); errors.Is(err, os.ErrNotExist) { return nil @@ -260,9 +228,8 @@ func (s *Store) load() error { return nil } -// persistLocked writes the candidate preferences atomically. Caller must -// hold s.mu (write lock); the lock is not released here so the in-memory -// state is updated only after a successful write. +// persistLocked writes v to disk. Caller must hold s.mu and update in-memory +// state only after this returns nil. func (s *Store) persistLocked(v UIPreferences) error { if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", filepath.Dir(s.path), err) @@ -270,10 +237,8 @@ func (s *Store) persistLocked(v UIPreferences) error { return util.WriteJson(context.Background(), s.path, v) } -// broadcast fans the new value out to internal subscribers and to the -// frontend emitter. Subscribers with a full buffer are skipped — the tray -// only cares about the latest value, so dropping intermediate frames -// during a burst is safe. +// broadcast fans v out to subscribers and the emitter. Full-buffer subscribers +// are skipped: consumers only need the latest value, so dropping is safe. func (s *Store) broadcast(v UIPreferences) { s.subsMu.Lock() subs := make([]chan UIPreferences, len(s.subs)) @@ -293,7 +258,6 @@ func (s *Store) broadcast(v UIPreferences) { } } -// preferencesPath resolves os.UserConfigDir()/netbird/ui-preferences.json. func preferencesPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go index 2c574e08f..25c468d7f 100644 --- a/client/ui/recenter_linux.go +++ b/client/ui/recenter_linux.go @@ -2,16 +2,12 @@ package main -// recenterOnShowPredicate returns the predicate WindowManager uses to decide -// whether to re-center its Go-shown windows (main, Settings) on each show. -// -// On Linux this is xembedTrayAvailable: re-centering is needed only in the -// minimal-WM / in-process-XEmbed-tray environment, where the window manager -// neither centers small windows for us nor restores their position across a -// hide -> show round-trip. The predicate is evaluated per show (not once at -// startup) because the XEmbed tray can appear after the UI starts — the panel -// and the autostarted app race at login — and xembedTrayAvailable is a cheap, -// side-effect-free selection-owner probe, fine to call repeatedly. +// recenterOnShowPredicate returns a per-show predicate; re-centering is only +// needed under the minimal-WM / in-process-XEmbed-tray environment, which neither +// centers small windows nor restores position across hide -> show. Evaluated per +// show, not at startup, because the XEmbed tray can appear after the UI starts +// (panel and autostarted app race at login); xembedTrayAvailable is a cheap, +// side-effect-free probe safe to call repeatedly. func recenterOnShowPredicate() func() bool { return xembedTrayAvailable } diff --git a/client/ui/recenter_other.go b/client/ui/recenter_other.go index 62f5702d9..7bb4bc93a 100644 --- a/client/ui/recenter_other.go +++ b/client/ui/recenter_other.go @@ -2,11 +2,9 @@ package main -// recenterOnShowPredicate returns nil off Linux (and on the cgo-less linux/386 -// build): macOS and Windows window managers center windows and restore their -// position across hide -> show themselves, so the Go-side re-centering that -// the minimal-WM Linux path needs would only fight a window the user moved. -// A nil predicate makes WindowManager.centerWhenReady a no-op. +// recenterOnShowPredicate returns nil off Linux: macOS and Windows WMs restore +// window position across hide -> show themselves, so Go-side re-centering would +// only fight a window the user moved. func recenterOnShowPredicate() func() bool { return nil } diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go index dcdfaeafe..f7e3aeea0 100644 --- a/client/ui/services/autostart.go +++ b/client/ui/services/autostart.go @@ -10,31 +10,22 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) -// Autostart is the Wails-bound facade over Wails' AutostartManager. The OS -// login-item registration (launchd/SMAppService on macOS, HKCU\…\Run on -// Windows, an XDG .desktop on Linux) is the single source of truth — IsEnabled -// reads it directly, so nothing is mirrored to the preferences file. Enable -// registers the running executable to launch at login with no extra arguments; -// the app comes up hidden into the tray, same as a normal launch. +// Autostart facade over Wails' AutostartManager. The OS login-item registration +// is the single source of truth; nothing is mirrored to preferences. type Autostart struct { mgr *application.AutostartManager } -// NewAutostart wraps the application's AutostartManager (app.Autostart). func NewAutostart(mgr *application.AutostartManager) *Autostart { return &Autostart{mgr: mgr} } -// Supported reports whether autostart can be toggled on this platform. The -// frontend hides the toggle entirely when this is false. func (a *Autostart) Supported(_ context.Context) bool { _, err := a.mgr.Status() return !errors.Is(err, application.ErrAutostartNotSupported) } -// IsEnabled reports whether the app is currently registered to launch at -// login. On an unsupported platform it returns false without error so the -// frontend can render the toggle off (gated by Supported). +// IsEnabled returns false without error on unsupported platforms. func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { enabled, err := a.mgr.IsEnabled() if err != nil { @@ -46,8 +37,7 @@ func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { return enabled, nil } -// SetEnabled registers (enabled) or removes (disabled) the launch-at-login -// entry. The change takes effect on the next login, not immediately. +// SetEnabled takes effect on the next login, not immediately. func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error { if enabled { if err := a.mgr.Enable(); err != nil { diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 794746989..6048d630d 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -21,41 +21,26 @@ import ( "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. +// ErrorTranslator localises daemon errors; runtime impl 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. +// LanguagePreference reports the current UI language; runtime impl 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. +// ClientError is a structured error returned to the frontend. The frontend +// translates Code via i18n; Short is an English fallback; Long carries the +// unwrapped daemon message. 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. +// Error returns the short message for plain Go callers. func (e *ClientError) Error() string { if e == nil { return "" @@ -63,9 +48,8 @@ func (e *ClientError) Error() string { 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. +// MarshalJSON emits the struct so the Wails binding sends an object, not the +// default "error: ..." string. func (e *ClientError) MarshalJSON() ([]byte, error) { if e == nil { return []byte("null"), nil @@ -74,14 +58,9 @@ func (e *ClientError) MarshalJSON() ([]byte, error) { 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.": i18n.Bundle.Translate already -// handles current-language → English → key passthrough, so any missing -// locale entry surfaces as a visible "error." string in the dialog — -// a deliberate fail-loud signal that the bundle needs updating. +// classifyDaemonError maps a gRPC error to a ClientError by matching known +// substrings to a stable code. A missing locale entry surfaces as a visible +// "error." string — a deliberate fail-loud signal to update the bundle. func (s *Connection) classifyDaemonError(err error) *ClientError { if err == nil { return nil @@ -123,12 +102,8 @@ func (s *Connection) classifyDaemonError(err error) *ClientError { } } -// 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." 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. +// translateShort resolves the localised short message for code, returning the +// bare "error." key when no translation is available so the gap stays visible. func (s *Connection) translateShort(code string) string { key := "error." + code if s.translator == nil { @@ -143,7 +118,7 @@ func (s *Connection) translateShort(code string) string { return s.translator.Translate(lang, key) } -// LoginParams carries the fields the UI sets when starting a login. +// LoginParams are the inputs to Login. type LoginParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` @@ -154,7 +129,7 @@ type LoginParams struct { Hint string `json:"hint"` } -// LoginResult is the daemon's reply to a Login call. +// LoginResult is the daemon's reply to Login. type LoginResult struct { NeedsSSOLogin bool `json:"needsSsoLogin"` UserCode string `json:"userCode"` @@ -162,19 +137,19 @@ type LoginResult struct { VerificationURIComplete string `json:"verificationUriComplete"` } -// WaitSSOParams carries the fields the UI passes to WaitSSOLogin. +// WaitSSOParams are the inputs to WaitSSOLogin. type WaitSSOParams struct { UserCode string `json:"userCode"` Hostname string `json:"hostname"` } -// UpParams selects the profile the daemon should bring up. +// UpParams selects the profile to bring up. type UpParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` } -// LogoutParams selects the profile the daemon should log out. +// LogoutParams selects the profile to log out. type LogoutParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` @@ -187,10 +162,8 @@ type Connection struct { prefs LanguagePreference } -// 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. +// NewConnection wires up a Connection. translator or prefs may be nil, in which +// case classifyDaemonError falls back to the bare error key. func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection { return &Connection{conn: conn, translator: translator, prefs: prefs} } @@ -201,18 +174,10 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err return LoginResult{}, err } - // No pre-Login Down: the daemon's Login dislodges a pending WaitSSOLogin - // itself (server.go cancels the in-flight wait via actCancel), and an - // abandoned browser leg is torn down by startLogin cancelling the - // WaitSSOLogin RPC, which the daemon reacts to by clearing the stale - // OAuth flow. A defensive Down here would only add a visible Idle blink - // to the tray during the SSO handoff (Connect/profile-switch → - // NeedsLogin → auto-login) for no gain. + // No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a + // defensive Down would only flash an Idle blink in the tray during handoff. - // Mirror the Fyne client's defaulting: when the frontend doesn't supply - // profile / username, fall back to the daemon's active profile and the - // current OS user. The flag matches the Fyne ui's IsUnixDesktopClient - // condition so the daemon knows we can render an SSO browser flow. + // Fall back to the daemon's active profile and the current OS user. profileName := p.ProfileName username := p.Username if profileName == "" { @@ -280,7 +245,7 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { if err != nil { return err } - // The UI always uses async mode: status updates flow via SubscribeStatus. + // Always async: status updates flow via SubscribeStatus. req := &proto.UpRequest{Async: true} if p.ProfileName != "" { req.ProfileName = ptrStr(p.ProfileName) @@ -305,11 +270,9 @@ func (s *Connection) Down(ctx context.Context) error { return nil } -// OpenURL launches the user's preferred browser to display url. Mirrors the -// Fyne client's openURL helper so the SSO flow can pop the verification page -// the same way as the legacy UI — WebKitGTK's window.open is blocked by the -// embedded webview, and asking the user to copy/paste defeats the point of -// SSO. Honors $BROWSER first, then falls back to the platform default. +// OpenURL opens url in an external browser; the embedded webview blocks +// window.open, so the SSO verification page can't pop inline. Honors $BROWSER +// before the platform default. func (s *Connection) OpenURL(url string) error { if browser := os.Getenv("BROWSER"); browser != "" { return exec.Command(browser, url).Start() @@ -343,9 +306,8 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error { } // The daemon runs as root and can't reach the user-owned per-profile state - // file that holds the account email (see Profiles.List). Drop it here from - // the UI process so a logged-out profile no longer shows a stale email; the - // next SSO login recreates it. + // file holding the account email (see Profiles.List), so clear the stale + // email here; the next SSO login recreates it. if p.ProfileName != "" { if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil { // Non-fatal: the logout itself succeeded. diff --git a/client/ui/services/cursor_linux.go b/client/ui/services/cursor_linux.go index 5edd58429..760fd5b86 100644 --- a/client/ui/services/cursor_linux.go +++ b/client/ui/services/cursor_linux.go @@ -14,9 +14,8 @@ typedef struct CursorPoint { int ok; } CursorPoint; -// XQueryPointer hits Xorg directly on X11 sessions and XWayland on -// Wayland sessions (shipped by default on the supported distros). ok=0 -// when no X server is reachable — caller falls back gracefully. +// XQueryPointer works on X11 and, via XWayland, on Wayland sessions. +// ok=0 when no X server is reachable. CursorPoint nbGetCursorPos(void) { CursorPoint p = {0, 0, 0}; Display *dpy = XOpenDisplay(NULL); diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 92e749283..03836bdbb 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -20,42 +20,22 @@ import ( ) const ( - // EventStatusSnapshot is emitted to the frontend whenever a fresh - // Status snapshot is captured (from a poll or a stream-driven refresh). EventStatusSnapshot = "netbird:status" - // EventDaemonNotification is emitted for each SubscribeEvents message - // (DNS, network, auth, connectivity categories). Auto-update - // SystemEvents are also forwarded here to updater.Holder.OnSystemEvent - // so the typed update state can be maintained without a second daemon - // subscription. + // EventDaemonNotification carries each SubscribeEvents message. Auto-update + // SystemEvents are also forwarded to updater.Holder.OnSystemEvent so the typed + // update state needs no second daemon subscription. EventDaemonNotification = "netbird:event" - // EventProfileChanged fires after ProfileSwitcher.SwitchActive completes - // a daemon-side switch. The payload is the new ProfileRef. Both tray - // and React subscribers refresh their profile views off this so a flip - // driven from one surface (tray menu, settings page) paints in the - // others without polling. The daemon itself does not emit a profile - // event, so this is the only signal that closes the gap. + // EventProfileChanged fires after a daemon-side switch (payload: the new + // ProfileRef). The daemon emits no profile event, so this is the only signal + // that lets a flip driven from one surface paint in the others. EventProfileChanged = "netbird:profile:changed" - // EventSessionWarning is emitted on every session-warning watcher - // fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed - // sibling of EventDaemonNotification so React / tray subscribers - // don't have to filter the firehose of EventDaemonNotification. - // 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 is a typed sibling of EventDaemonNotification so + // subscribers needn't filter the notification firehose. Consumers branch on + // SessionWarning.Final to tell the T-10 event from the T-2 fallback. EventSessionWarning = "netbird:session:warning" - // The SystemEvent.metadata markers the daemon stamps on its internal - // control events live in the shared proto package - // (proto.MetadataKind*/MetadataKindKey/MetadataLevelKey) so producer - // (client/server) and consumer (here) reference the same constants. See - // dispatchSystemEvent for how they're recognised. - - // StatusDaemonUnavailable is the synthetic Status the UI emits when the - // daemon's gRPC socket is unreachable (daemon not running, socket - // permission, etc.). Real daemon statuses come straight from - // internal.Status* — none of those collide with this label. + // StatusDaemonUnavailable is the synthetic Status emitted when the daemon's + // gRPC socket is unreachable. No internal.Status* collides with this label. StatusDaemonUnavailable = "DaemonUnavailable" // Daemon connection status strings — mirror internal.Status* in @@ -68,9 +48,7 @@ const ( StatusSessionExpired = "SessionExpired" ) -// Emitter is what DaemonFeed.Watch needs from the host application: a simple -// "send this name and payload to the frontend" hook. The Wails app.Event -// satisfies this with its Emit method. +// Emitter sends a named payload to the frontend. Satisfied by Wails app.Event. type Emitter interface { Emit(name string, data ...any) bool } @@ -86,9 +64,7 @@ type SystemEvent struct { Metadata map[string]string `json:"metadata"` } -// PeerStatus is the frontend-facing shape of a daemon PeerState. Carries -// enough detail for the dashboard's compact peer row plus the on-click -// troubleshooting expansion (ICE candidate types, endpoints, handshake age). +// PeerStatus is the frontend-facing shape of a daemon PeerState. type PeerStatus struct { IP string `json:"ip"` IPv6 string `json:"ipv6"` @@ -110,15 +86,14 @@ type PeerStatus struct { Networks []string `json:"networks"` } -// PeerLink is one of the named connections between this peer and its mgmt -// or signal server. +// PeerLink is this peer's connection to its mgmt or signal server. type PeerLink struct { URL string `json:"url"` Connected bool `json:"connected"` Error string `json:"error,omitempty"` } -// LocalPeer mirrors LocalPeerState — what this client looks like on the mesh. +// LocalPeer mirrors LocalPeerState. type LocalPeer struct { IP string `json:"ip"` IPv6 string `json:"ipv6"` @@ -137,42 +112,31 @@ type Status struct { Peers []PeerStatus `json:"peers"` Events []SystemEvent `json:"events"` // NetworksRevision bumps whenever the daemon's routed-networks set or their - // selected state changes. Consumers fingerprint on it to know when to - // re-fetch ListNetworks instead of polling every snapshot. + // selected state changes, so consumers know when to re-fetch ListNetworks + // instead of polling every snapshot. NetworksRevision uint64 `json:"networksRevision"` - // 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 is the absolute UTC instant the SSO session expires; nil + // when the peer is not SSO-tracked or login expiration is disabled. SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"` } -// DaemonFeed fans the daemon's two long-running gRPC streams out to the -// frontend and the tray: SubscribeStatus snapshots (per state change) and -// SubscribeEvents system notifications (per DNS / network / auth / etc. -// event). Also exposes a one-shot Status RPC for callers that want the -// current snapshot without subscribing. +// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus, +// SubscribeEvents) out to the frontend and tray, and exposes a one-shot Status +// RPC for callers wanting the current snapshot without subscribing. // -// Profile-switch suppression: ProfileSwitcher calls BeginProfileSwitch -// before tearing down the old profile when it would otherwise be followed -// by an Up on the new profile (i.e. previous status was Connected or -// Connecting). statusStreamLoop then swallows the transient stale -// Connected and Idle pushes the daemon emits during Down so the tray -// and the React Status page both see Connecting → new-profile-state -// instead of Connected → Connected → Idle → Connecting → new-state. +// Profile-switch suppression: BeginProfileSwitch makes statusStreamLoop swallow +// the transient stale Connected and Idle pushes the daemon emits during Down, so +// consumers see Connecting → new-profile-state instead of the full blink. // // Two flags govern the switch lifecycle, evaluated independently by -// consumeForSwitch on every push (lifetimes differ — see godoc): +// consumeForSwitch on every push because their lifetimes differ: // -// switchInProgress (suppression): clears on the first real push from -// the new Up. The daemon-side StatusConnecting comes BEFORE any -// NeedsLogin, so suppression has to release here even though the -// final terminal hasn't arrived yet. -// switchLoginWatch (trigger): outlives suppression. Watches for -// NeedsLogin / LoginFailed / SessionExpired anywhere along the -// Up's retry loop and emits EventTriggerLogin so the React -// orchestrator opens the browser-login flow. +// switchInProgress (suppression): clears on the first real push from the new +// Up. Daemon-side StatusConnecting comes BEFORE any NeedsLogin, so +// suppression must release here before the terminal arrives. +// switchLoginWatch (trigger): outlives suppression. Watches for NeedsLogin +// / LoginFailed / SessionExpired along the Up's retry loop and emits +// EventTriggerLogin so the React orchestrator opens browser-login. // // ┌────────────────────────────────────────────┬──────────────────────────────────┐ // │ Incoming daemon status │ Action │ @@ -189,12 +153,10 @@ type DaemonFeed struct { conn DaemonConn emitter Emitter updater *updater.Holder - // logCtl reacts to the daemon's log level (delivered as a marked - // SystemEvent over the same SubscribeEvents stream) by attaching/detaching - // the GUI file log. nil when the GUI doesn't manage its log (server build / - // not wired), in which case the marker is ignored. Held as a narrow - // interface so this package doesn't depend on client/ui/guilog (the concrete - // type lives there; main passes it into NewDaemonFeed). + // logCtl attaches/detaches the GUI file log in response to the daemon's log + // level (a marked SystemEvent on the SubscribeEvents stream). nil when the GUI + // doesn't manage its log (server build / not wired), in which case the marker + // is ignored. logCtl LogController mu sync.Mutex @@ -204,24 +166,13 @@ type DaemonFeed struct { switchMu sync.Mutex switchInProgress bool switchInProgressUntil time.Time - // switchLoginWatch outlives switchInProgress: the suppression flag - // clears on Connecting (first real push from the new Up) but the - // trigger-login watcher must survive past that to catch the eventual - // NeedsLogin / LoginFailed / SessionExpired terminal. Cleared on - // Connected (success), Idle (the new profile is offline), or - // DaemonUnavailable (daemon went away mid-switch) — and on a 30s - // timeout for safety. switchLoginWatch bool switchLoginWatchUntil time.Time } -// LogController is the subset of client/ui/guilog.DebugLog that DaemonFeed -// drives: Apply turns the GUI file log on/off for a daemon level, Path is the -// gui-client.log path to register with the daemon (empty when the GUI doesn't -// own its log). Kept as an interface so services doesn't import guilog. The -// daemon delivers log-level changes as marked SystemEvents on the same -// SubscribeEvents stream this feed consumes, so it rides along here rather than -// opening a second daemon subscription. +// LogController is the subset of guilog.DebugLog that DaemonFeed drives: Apply +// turns the GUI file log on/off for a daemon level; Path is the gui-client.log +// path to register with the daemon (empty when the GUI doesn't own its log). type LogController interface { Apply(level string) Path() string @@ -229,20 +180,15 @@ type LogController interface { // NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not // managed), in which case log-level markers on the event stream are ignored. -// Injected at construction rather than via a setter so DaemonFeed (a Wails -// service) exposes no extra method to the binding generator. func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed { return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl} } -// BeginProfileSwitch is called by ProfileSwitcher at the start of a switch -// when the previous status was Connected/Connecting — i.e. the daemon is -// about to emit Connected updates during Down's peer-count teardown and -// then an Idle before the new profile's Up resumes the stream. The flag -// makes statusStreamLoop drop those transient events. A synthetic -// Connecting snapshot is emitted right away so both consumers (tray and -// React) paint the optimistic state immediately. A 30s safety timeout -// clears the flag if the daemon never emits a follow-up status. +// BeginProfileSwitch arms suppression for a switch from Connected/Connecting, +// where the daemon emits stale Connected updates during Down's teardown then an +// Idle before the new Up; statusStreamLoop drops those, and a synthetic +// Connecting snapshot is emitted so consumers paint optimistically. A 30s safety +// timeout clears the flag if no follow-up status arrives. func (s *DaemonFeed) BeginProfileSwitch() { now := time.Now() s.switchMu.Lock() @@ -254,11 +200,9 @@ func (s *DaemonFeed) BeginProfileSwitch() { s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting}) } -// CancelProfileSwitch is called by callers that abort the switch midway -// (the tray's Disconnect click while Connecting). Clears the suppression -// flag so the next daemon Idle paints through immediately instead of -// being swallowed, and disarms the login-watch so the abort doesn't pop -// a browser-login window after the user explicitly cancelled. +// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting): +// clears suppression so the next daemon Idle paints through, and disarms the +// login-watch so the abort doesn't pop a browser-login after the user cancelled. func (s *DaemonFeed) CancelProfileSwitch() { s.switchMu.Lock() s.switchInProgress = false @@ -266,18 +210,8 @@ func (s *DaemonFeed) CancelProfileSwitch() { s.switchMu.Unlock() } -// Watch starts the background loops that feed the frontend: -// - statusStreamLoop: push-driven snapshots on connection-state change -// (Connected/Disconnected/Connecting, peer list, address). Drives the -// tray icon, Status page, and Peers page. -// - toastStreamLoop: DNS / network / auth / connectivity / update -// SystemEvent stream. Drives OS notifications, the Recent Events -// list, and the update-overlay flag. The daemon-side RPC is named -// SubscribeEvents — only the loop's local alias differs to keep the -// two streams distinguishable in this file. -// -// Safe to call once at boot; both loops self-restart on stream errors -// via exponential backoff. +// Watch starts the two background stream loops. Idempotent (a second call while +// running is a no-op); both loops self-restart via exponential backoff. func (s *DaemonFeed) Watch(ctx context.Context) { s.mu.Lock() if s.cancel != nil { @@ -306,12 +240,9 @@ func (s *DaemonFeed) ServiceShutdown() error { return nil } -// Get returns the current daemon status snapshot. When the daemon socket -// is unreachable (process down, socket missing, permission denied) it -// returns Status{Status: StatusDaemonUnavailable} instead of an error so -// the frontend's initial useStatus().refresh() picks up the same string -// the live event stream emits — the React overlay and per-screen gating -// then key off a single status enum without a parallel "error" path. +// Get returns the current daemon status snapshot. An unreachable daemon socket +// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the +// frontend keys off a single status enum without a parallel "error" path. func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { cli, err := s.conn.Client() if err != nil { @@ -330,21 +261,14 @@ func (s *DaemonFeed) Get(ctx context.Context) (Status, error) { return statusFromProto(resp), nil } -// consumeForSwitch decides whether the incoming status push should be -// suppressed during an in-progress profile switch and whether the switch -// landed in a state that warrants kicking the SSO flow (NeedsLogin, -// SessionExpired, LoginFailed — the three "Up won't proceed without a -// fresh token" states the React UI collapses under NEEDS_LOGIN_STATES). -// The triggerLogin signal centralises the auto-handoff for both -// tray-initiated and React-initiated profile switches, mirroring the -// tray's pendingConnectLogin path for the plain Connect button. +// consumeForSwitch decides, for an incoming push during a profile switch, +// whether to suppress it (suppress) and whether the switch landed in a state +// needing the SSO flow (triggerLogin: NeedsLogin / SessionExpired / LoginFailed). // -// The suppression and trigger flags are evaluated independently because -// they have different lifetimes: suppression clears on the first real -// push from the new Up (Connecting), but the trigger watcher must survive -// past Connecting to catch the eventual NeedsLogin terminal — -// daemon-side state.Set(StatusConnecting) at connect.go:246 fires before -// loginToManagement, which is what may then set StatusNeedsLogin at :297. +// The two flags have different lifetimes: suppression clears on Connecting, but +// the trigger watcher must survive past it to catch the eventual NeedsLogin — +// daemon-side StatusConnecting fires before loginToManagement, which is what may +// then set StatusNeedsLogin. func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { s.switchMu.Lock() defer s.switchMu.Unlock() @@ -364,16 +288,11 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { strings.EqualFold(st.Status, StatusLoginFailed), strings.EqualFold(st.Status, StatusSessionExpired), strings.EqualFold(st.Status, StatusDaemonUnavailable): - // New profile's flow has officially begun (Up started, or - // daemon refused to start it). Clear the suppression guard - // and let it through. + // New flow has begun (Up started, or daemon refused it). s.switchInProgress = false default: - // Connected (stale carryover from old profile's teardown) or - // Idle (transient between Down and Up). Suppress so the - // optimistic Connecting from BeginProfileSwitch stays - // painted. Login-watch stays armed for the eventual - // terminal. + // Stale Connected from teardown or transient Idle: suppress so the + // optimistic Connecting stays painted. Login-watch stays armed. return true, false } } @@ -383,17 +302,13 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { case strings.EqualFold(st.Status, StatusNeedsLogin), strings.EqualFold(st.Status, StatusLoginFailed), strings.EqualFold(st.Status, StatusSessionExpired): - // Up landed on an "SSO needed" terminal: clear the watch and - // ask the React orchestrator to drive the browser-login flow - // without the user having to click Connect a second time. + // SSO-needed terminal: trigger browser-login without a second click. s.switchLoginWatch = false return false, true case strings.EqualFold(st.Status, StatusConnected), strings.EqualFold(st.Status, StatusIdle), strings.EqualFold(st.Status, StatusDaemonUnavailable): - // Terminal but not SSO — switch finished without needing - // re-auth (Connected) or with no new flow to await (Idle / - // DaemonUnavailable). Disarm without triggering. + // Terminal but not SSO — disarm without triggering. s.switchLoginWatch = false } } @@ -401,12 +316,9 @@ func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) { return false, false } -// 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 -// connection-state changes only — no fixed-interval polling, no idle -// chatter. Reconnects with exponential backoff if the stream drops -// (daemon restart, socket break). +// statusStreamLoop subscribes to SubscribeStatus and re-emits each snapshot on +// the Wails event bus. The first message is the current snapshot; later ones +// fire on connection-state changes only — no polling. func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { defer s.streamWg.Done() @@ -420,10 +332,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { Clock: backoff.SystemClock, }, ctx) - // unavailable tracks whether we've already signalled the daemon as - // unreachable. The synthetic event is emitted once per outage so the - // tray flips to the "Daemon not running" state, but the exponential - // backoff retries don't re-fire it on every attempt. + // unavailable fires the synthetic event once per outage, not on every retry. unavailable := false emitUnavailable := func() { if unavailable { @@ -442,10 +351,9 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) { } } -// subscribeAndStreamStatus is one attempt of the status backoff loop: open the -// SubscribeStatus stream and re-emit every snapshot until it errors. Returns a -// wrapped error so backoff retries; a daemon-unreachable failure also flips the -// synthetic-unavailable signal (once per outage, guarded by *unavailable). +// subscribeAndStreamStatus is one attempt of the status backoff loop: open +// SubscribeStatus and re-emit every snapshot until it errors. A daemon- +// unreachable failure also flips the synthetic-unavailable signal. func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error { cli, err := s.conn.Client() if err != nil { @@ -469,10 +377,9 @@ func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable * } } -// handleStatusRecvErr maps a SubscribeStatus stream.Recv error into the -// backoff loop's return value: ctx cancellation stops the loop, an -// unreachable socket flips the synthetic-unavailable signal, everything -// else is a retryable wrapped error. +// handleStatusRecvErr maps a SubscribeStatus Recv error into the backoff loop's +// return: ctx cancellation stops the loop, an unreachable socket flips the +// synthetic-unavailable signal, everything else is retryable. func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error { if ctx.Err() != nil { return ctx.Err() @@ -483,7 +390,7 @@ func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUna return fmt.Errorf("status stream recv: %w", err) } -// emitStatus pushes a fresh snapshot to the frontend, dropping the transient +// emitStatus pushes a snapshot to the frontend, dropping the transient // stale-Connected / Idle pushes that occur mid profile switch. func (s *DaemonFeed) emitStatus(st Status) { log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) @@ -498,13 +405,9 @@ func (s *DaemonFeed) emitStatus(st Status) { } } -// toastStreamLoop subscribes to the daemon's SubscribeEvents RPC and -// re-emits every SystemEvent on the Wails event bus. The downstream -// consumers turn these into OS notifications, populate the Recent -// Events card on the Status page, and listen for the -// "new_version_available" metadata to flip the tray's update overlay. -// Local name differs from the RPC ("SubscribeEvents") so the file's -// two streams aren't both called streamLoop. +// toastStreamLoop subscribes to SubscribeEvents and re-emits every SystemEvent +// on the Wails event bus. Local name differs from the RPC so the file's two +// streams aren't both called streamLoop. func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { defer s.streamWg.Done() @@ -527,9 +430,8 @@ func (s *DaemonFeed) toastStreamLoop(ctx context.Context) { } } -// subscribeAndStreamEvents is one attempt of the event backoff loop: open the -// SubscribeEvents stream and fan out every SystemEvent until it errors. ctx -// cancellation stops the loop; any other error is wrapped so backoff retries. +// subscribeAndStreamEvents is one attempt of the event backoff loop: open +// SubscribeEvents and fan out every SystemEvent until it errors. func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { cli, err := s.conn.Client() if err != nil { @@ -541,10 +443,8 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { } // Re-register the GUI log path on every (re)connect so a daemon restart - // re-learns it and a later debug bundle still finds the file. Best-effort — - // a failure here must not abort the event stream. Done even when file - // logging is off (enabled but not in debug), so the path is known ahead of - // any debug toggle. + // re-learns it. Best-effort — a failure must not abort the stream. Done even + // when file logging is off, so the path is known ahead of any debug toggle. if s.logCtl != nil && s.logCtl.Path() != "" { if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil { log.Warnf("register UI log path: %v", err) @@ -568,19 +468,14 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error { func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) { se := systemEventFromProto(ev) log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage) - // A CLI-driven profile add/remove publishes a marked SYSTEM event purely - // to nudge the UI's profile views. Translate it into the existing - // EventProfileChanged (which the tray's loadProfiles and React's - // ProfileContext.refresh already subscribe to) and stop — it's an internal - // refresh signal, not a user-facing notification, so it must not reach the - // Recent Events list or fire an OS toast. + // Internal refresh signal (CLI-driven profile add/remove), not a notification: + // translate and stop so it never reaches Recent Events or fires an OS toast. if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged { s.emitter.Emit(EventProfileChanged, ProfileRef{}) return } - // A marked log-level-changed event drives the GUI file log on/off. It's an - // internal control signal, not a user-facing notification — handle and stop - // so it never reaches the Recent Events list or fires an OS toast. + // Internal control signal driving the GUI file log on/off — handle and stop + // so it never reaches Recent Events or toasts. if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged { if s.logCtl != nil { s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey]) @@ -675,12 +570,10 @@ 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. +// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't +// answering, versus the daemon responding with an application-level code. Only +// the former should flip the tray to "Not running" — a daemon returning e.g. +// FailedPrecondition is alive and must not be reported as down. func isDaemonUnreachable(err error) bool { if err == nil { return false diff --git a/client/ui/services/debug.go b/client/ui/services/debug.go index 9b84acd57..76adaabd7 100644 --- a/client/ui/services/debug.go +++ b/client/ui/services/debug.go @@ -17,8 +17,6 @@ import ( "github.com/netbirdio/netbird/version" ) -// DebugBundleParams configures what the daemon collects when generating a -// debug bundle. type DebugBundleParams struct { Anonymize bool `json:"anonymize"` SystemInfo bool `json:"systemInfo"` @@ -26,22 +24,19 @@ type DebugBundleParams struct { LogFileCount uint32 `json:"logFileCount"` } -// DebugBundleResult mirrors DebugBundleResponse — Path is set on local-only -// bundles, UploadedKey on successful uploads, UploadFailureReason on failed -// uploads. +// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload +// success, UploadFailureReason on upload failure. type DebugBundleResult struct { Path string `json:"path"` UploadedKey string `json:"uploadedKey"` UploadFailureReason string `json:"uploadFailureReason"` } -// LogLevel is a single log-level value the daemon understands ("error", -// "warn", "info", "debug", "trace"). +// LogLevel carries a logrus level name: "error", "warn", "info", "debug", "trace". type LogLevel struct { Level string `json:"level"` } -// Debug groups debug / log-level / packet-trace RPCs. type Debug struct { conn DaemonConn } @@ -84,9 +79,8 @@ func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) { return LogLevel{Level: resp.GetLevel().String()}, nil } -// RevealFile opens the OS file manager focused on the given path. Wails' -// Browser.OpenURL refuses non-http(s) schemes, so the UI calls this binding -// instead of constructing a file:// URL. +// RevealFile opens the OS file manager focused on path. Needed because Wails' +// Browser.OpenURL refuses non-http(s) schemes like file://. func (s *Debug) RevealFile(_ context.Context, path string) error { if path == "" { return fmt.Errorf("empty path") @@ -103,10 +97,9 @@ func (s *Debug) RevealFile(_ context.Context, path string) error { return cmd.Start() } -// RegisterUILog tells the daemon the absolute path of the GUI's log file so -// the daemon's debug bundle can collect it (the daemon runs as root and can't -// resolve the user's config dir). Called by LogLevelWatcher on each daemon -// (re)connect. +// RegisterUILog reports the GUI log path to the daemon for bundle collection; +// the daemon runs as root and can't resolve the user's config dir. Called on +// each daemon (re)connect. func (s *Debug) RegisterUILog(ctx context.Context, path string) error { cli, err := s.conn.Client() if err != nil { @@ -143,10 +136,9 @@ func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error { if err != nil { return err } - // proto.LogLevel_value keys are the enum names (TRACE/DEBUG/INFO/...), but - // callers (the React side, GetLogLevel) use the lowercase logrus names - // ("trace"/"debug"/...). Upper-case before the lookup so a lowercase level - // doesn't silently fall back to INFO. + // proto.LogLevel_value keys are upper-case enum names; callers pass + // lowercase logrus names. Upper-case before lookup or a valid level + // silently falls through to INFO. level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)] if !ok { level = int32(proto.LogLevel_INFO) diff --git a/client/ui/services/forwarding.go b/client/ui/services/forwarding.go index 7302818cc..4ba979ad0 100644 --- a/client/ui/services/forwarding.go +++ b/client/ui/services/forwarding.go @@ -8,21 +8,19 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// PortRange describes a contiguous port range. Both ends are inclusive. +// PortRange is a port range; both ends are inclusive. type PortRange struct { Start uint32 `json:"start"` End uint32 `json:"end"` } -// PortInfo carries the destination or translated port for a forwarding rule. -// Exactly one of Port or Range is populated, mirroring the daemon's oneof. +// PortInfo holds exactly one of Port or Range (the daemon's oneof). type PortInfo struct { Port *uint32 `json:"port,omitempty"` Range *PortRange `json:"range,omitempty"` } -// ForwardingRule is one entry from the daemon's reverse-proxy table — -// what we ship to the frontend's "exposed services" view. +// ForwardingRule is one entry from the daemon's reverse-proxy table. type ForwardingRule struct { Protocol string `json:"protocol"` DestinationPort PortInfo `json:"destinationPort"` @@ -40,8 +38,6 @@ func NewForwarding(conn DaemonConn) *Forwarding { return &Forwarding{conn: conn} } -// List returns the current set of forwarding rules from the daemon's -// reverse proxy. The frontend renders these as the "exposed services" list. func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) { cli, err := s.conn.Client() if err != nil { diff --git a/client/ui/services/i18n.go b/client/ui/services/i18n.go index 21a20a8a5..755bbe02d 100644 --- a/client/ui/services/i18n.go +++ b/client/ui/services/i18n.go @@ -8,10 +8,8 @@ import ( "github.com/netbirdio/netbird/client/ui/i18n" ) -// I18n is the Wails-bound facade over i18n.Bundle. It exists only to give -// the binding generator a service type with the context.Context-first -// signatures it expects; the translation logic, locale loading and the -// LanguageCode type all live in client/ui/i18n. +// I18n is the Wails-bound facade over i18n.Bundle; the translation logic lives +// in client/ui/i18n. type I18n struct { bundle *i18n.Bundle } @@ -20,15 +18,13 @@ func NewI18n(bundle *i18n.Bundle) *I18n { return &I18n{bundle: bundle} } -// Languages exposes the list of shipped locales to the frontend so the -// settings page can populate its language picker. +// Languages returns the shipped locales. func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) { return s.bundle.Languages(), nil } -// Bundle returns the full key->text map for one language, letting the -// React side drive its own translation library (i18next, etc.) off the -// same source bundles the tray uses. +// Bundle returns the full key->text map so the React side can drive its own +// translation library off the same source bundles. func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) { return s.bundle.BundleFor(code) } diff --git a/client/ui/services/network.go b/client/ui/services/network.go index 44257e120..c2d127494 100644 --- a/client/ui/services/network.go +++ b/client/ui/services/network.go @@ -8,7 +8,6 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// Network is one routed network the daemon offers to the client. type Network struct { ID string `json:"id"` Range string `json:"range"` @@ -17,16 +16,13 @@ type Network struct { ResolvedIPs map[string][]string `json:"resolvedIps"` } -// SelectNetworksParams selects which networks to enable / disable. -// All means "every available network" (used by Select-All / Deselect-All buttons); -// Append means "leave the existing selection in place and merge these IDs in". +// SelectNetworksParams: All targets every available network; Append merges IDs into the existing selection. type SelectNetworksParams struct { NetworkIDs []string `json:"networkIds"` Append bool `json:"append"` All bool `json:"all"` } -// Networks groups the daemon RPCs that read and toggle routed networks. type Networks struct { conn DaemonConn } diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go index 27fd47a1c..dae086de8 100644 --- a/client/ui/services/preferences.go +++ b/client/ui/services/preferences.go @@ -9,10 +9,8 @@ import ( "github.com/netbirdio/netbird/client/ui/preferences" ) -// Preferences is the Wails-bound facade over preferences.Store. The store -// itself owns persistence and the subscription channel; this type just -// re-exposes Get and SetLanguage with the context.Context-first signature -// the Wails binding generator wants. +// Preferences is the Wails-bound facade over preferences.Store; the context.Context-first +// signatures are what the binding generator requires. type Preferences struct { store *preferences.Store } @@ -21,23 +19,18 @@ func NewPreferences(store *preferences.Store) *Preferences { return &Preferences{store: store} } -// Get returns the current user-scope preferences. func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) { return s.store.Get(), nil } -// SetLanguage validates and persists a new UI language. func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error { return s.store.SetLanguage(lang) } -// SetViewMode validates and persists the Main-window view choice -// ("default" or "advanced"). func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error { return s.store.SetViewMode(mode) } -// SetOnboardingCompleted persists the welcome-flow dismissal flag. func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { return s.store.SetOnboardingCompleted(done) } diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go index 8700df606..494db98b1 100644 --- a/client/ui/services/profile.go +++ b/client/ui/services/profile.go @@ -10,34 +10,25 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// Profile is one named daemon profile. type Profile struct { Name string `json:"name"` IsActive bool `json:"isActive"` - // Email is the account address associated with this profile, sourced from - // the per-profile state file written by the CLI after a successful SSO - // login (e.g. ~/Library/Application Support/netbird/default.state.json on - // macOS). The daemon always runs as root, so its getConfigDir() resolves to - // the root home directory and cannot reach the user-owned state file. The - // UI process runs as the logged-in user and can read it directly via - // profilemanager.ProfileManager, which is why the email is fetched here - // instead of being returned by the ListProfiles RPC. + // Email is read from the user-owned per-profile state file (CLI writes it + // after SSO login), not via ListProfiles: the daemon runs as root and can't + // reach it, while the UI runs as the logged-in user. Email string `json:"email"` } -// ProfileRef identifies a profile by name+username. type ProfileRef struct { ProfileName string `json:"profileName"` Username string `json:"username"` } -// ActiveProfile is the result of GetActiveProfile. type ActiveProfile struct { ProfileName string `json:"profileName"` Username string `json:"username"` } -// Profiles groups the daemon RPCs that manage named profiles. type Profiles struct { conn DaemonConn } @@ -47,7 +38,6 @@ func NewProfiles(conn DaemonConn) *Profiles { } // Username returns the OS username the daemon expects for profile lookups. -// The frontend calls this once at boot and reuses the result. func (s *Profiles) Username() (string, error) { u, err := user.Current() if err != nil { diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go index 6256a92ba..c9110e1c3 100644 --- a/client/ui/services/profileswitcher.go +++ b/client/ui/services/profileswitcher.go @@ -12,65 +12,24 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" ) -// ProfileSwitcher encapsulates the full profile-switching reconnect policy -// so both the tray and the React frontend use identical logic. +// ProfileSwitcher holds the reconnect policy shared by the tray and React +// frontend so both flip profiles identically. The policy keys off prevStatus +// from DaemonFeed.Get at SwitchActive entry: // -// Reconnect policy + optimistic-feedback table (driven by prevStatus -// captured from DaemonFeed.Get at SwitchActive entry): -// -// ┌─────────────────┬──────────────────────┬──────────────────────────┬────────────────────┐ -// │ Previous status │ Action │ Optimistic UI label │ Suppressed events │ -// │ │ │ shown immediately │ until new flow │ -// ├─────────────────┼──────────────────────┼──────────────────────────┼────────────────────┤ -// │ Connected │ Switch + Down + Up │ Connecting (synthetic) │ Connected, Idle │ -// │ Connecting │ Switch + Down + Up │ Connecting (unchanged) │ Connected, Idle │ -// │ NeedsLogin │ Switch + Down │ (no change) │ — │ -// │ LoginFailed │ Switch + Down │ (no change) │ — │ -// │ SessionExpired │ Switch + Down │ (no change) │ — │ -// │ Idle │ Switch only │ (no change) │ — │ -// └─────────────────┴──────────────────────┴──────────────────────────┴────────────────────┘ -// -// Only Connected/Connecting trigger the optimistic Connecting paint -// (via DaemonFeed.BeginProfileSwitch): they're the only prevStatuses where -// the daemon emits stale Connected updates (peer count drops as the -// engine tears down) and then Idle, before the new profile's Up -// resumes the stream. Both are swallowed by DaemonFeed.consumeForSwitch -// until a status that signals the new flow has begun (Connecting, or -// any of the "Up won't run" terminal states: NeedsLogin / LoginFailed / -// SessionExpired / DaemonUnavailable). The NeedsLogin / LoginFailed / -// SessionExpired exits additionally cause DaemonFeed to emit EventTriggerLogin -// so the React orchestrator opens the browser-login flow automatically. -// The other prevStatuses either -// don't drive Down/Up at all (Idle) or stop after Down (NeedsLogin / -// LoginFailed / SessionExpired) — the resulting Idle is the correct -// terminal state, so no suppression is needed. -// -// Rationale for each Action choice: -// -// Connected → Reconnect with the new profile. -// Connecting → Stop old retry loop, restart. -// NeedsLogin → Clear stale error; user logs in. -// LoginFailed → Clear stale error; user logs in. -// SessionExpired → Clear stale error; user logs in. -// Idle → User chose offline; don't connect. +// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint. +// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login. +// Idle → Switch only. type ProfileSwitcher struct { profiles *Profiles connection *Connection feed *DaemonFeed } -// NewProfileSwitcher creates a ProfileSwitcher backed by the given services. -// EventProfileChanged is emitted via feed.emitter (same package), so React -// refreshes after a tray-driven switch and vice versa — the daemon does -// not emit a dedicated profile event. func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher { return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed} } // SwitchActive switches to the named profile applying the reconnect policy. -// All RPCs complete quickly: Up uses async mode so the daemon starts the -// connection attempt and returns immediately; status updates flow via the -// SubscribeStatus stream. func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error { prevStatus := "" if st, err := s.feed.Get(ctx); err == nil { @@ -89,12 +48,9 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v", p.ProfileName, prevStatus, wasActive, needsDown) - // Optimistic Connecting feedback for tray + React Status page: only - // when wasActive — those are the prevStatuses where the daemon will - // emit stale Connected + transient Idle pushes during Down before - // the new profile's Up resumes the stream (see DaemonFeed godoc for the - // suppression table). Other prevStatuses already terminate cleanly - // on Idle, no suppression needed. + // Optimistic Connecting paint only when wasActive: those prevStatuses emit + // stale Connected + transient Idle pushes during Down that must be + // suppressed until Up resumes the stream (see DaemonFeed suppression table). if wasActive { s.feed.BeginProfileSwitch() } @@ -103,17 +59,10 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error return fmt.Errorf("switch profile %q: %w", p.ProfileName, err) } - // Mirror the daemon-side switch into the user-side ProfileManager state - // (~/Library/Application Support/netbird/active_profile on macOS, the - // equivalent user config dir elsewhere). The CLI's `netbird up` reads - // from this file (cmd/up.go: pm.GetActiveProfile()) and then sends the - // resolved name back in the Login/Up RPC — if it diverges from the - // daemon-side /var/lib/netbird/active_profile.json, the daemon will - // silently switch its active profile to whatever the CLI sends, so the - // next CLI `up` after a UI switch reverts the profile. Failures here - // don't abort the switch (the daemon is the authority; the local - // mirror is a cache the CLI consults), but they leave the CLI's view - // stale until the next successful switch — surface as a warning. + // Mirror into the user-side ProfileManager state: the CLI's `netbird up` + // reads this file and sends the name back in the Up RPC, so if it diverges + // the daemon reverts the UI switch on the next CLI `up`. Best-effort — the + // daemon is authoritative; a failure only leaves the CLI's view stale. if err := profilemanager.NewProfileManager().SwitchProfile(p.ProfileName); err != nil { log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err) } @@ -130,11 +79,8 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error } } - // Fan out the switch to every UI surface. The daemon does not emit a - // profile event, so without this the React ProfileContext stays on the - // old profile after a tray-initiated switch (and the tray's profile - // submenu would lag a React-initiated one, except the tray rebuilds on - // every status transition). + // The daemon emits no profile event, so fan out ourselves or the React + // ProfileContext stays on the old profile after a tray-initiated switch. if s.feed != nil && s.feed.emitter != nil { s.feed.emitter.Emit(EventProfileChanged, p) } diff --git a/client/ui/services/session.go b/client/ui/services/session.go index a4e103ec1..5ee56788e 100644 --- a/client/ui/services/session.go +++ b/client/ui/services/session.go @@ -8,9 +8,7 @@ import ( "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. +// Re-exports so generated bindings reference services.* without importing authsession. type ( ExtendStartParams = authsession.ExtendStartParams ExtendStartResult = authsession.ExtendStartResult @@ -18,31 +16,23 @@ type ( ExtendResult = authsession.ExtendResult ) -// Session is the Wails-bound wrapper around authsession.Session. It only -// re-exposes the subset the React frontend actually calls -// (SessionExpirationDialog.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. +// Session wraps authsession.Session, exposing only the subset the React frontend +// calls; the tray uses authsession.Session directly, 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). +// NewSession wraps inner; the caller retains ownership and may use it directly. 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. +// RequestExtend starts the SSO session-extension flow; the result carries the verification URI 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). +// WaitExtend blocks until the RequestExtend flow completes; the deadline is nil when the peer is ineligible. func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) { return s.inner.WaitExtend(ctx, p) } diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 48744784d..d56d137fb 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -12,18 +12,18 @@ import ( type MDMFields struct { ManagementURL string `json:"managementURL"` PreSharedKey bool `json:"preSharedKey"` - WireguardPort bool `json:"wireguardPort"` - RosenpassEnabled bool `json:"rosenpassEnabled"` - RosenpassPermissive bool `json:"rosenpassPermissive"` - DisableClientRoutes bool `json:"disableClientRoutes"` - DisableServerRoutes bool `json:"disableServerRoutes"` - AllowServerSSH bool `json:"allowServerSSH"` - DisableAutoConnect bool `json:"disableAutoConnect"` - BlockInbound bool `json:"blockInbound"` - DisableMetricsCollection bool `json:"disableMetricsCollection"` - SplitTunnelMode bool `json:"splitTunnelMode"` - SplitTunnelApps bool `json:"splitTunnelApps"` - DisableAdvancedView bool `json:"disableAdvancedView"` + WireguardPort bool `json:"wireguardPort"` + RosenpassEnabled bool `json:"rosenpassEnabled"` + RosenpassPermissive bool `json:"rosenpassPermissive"` + DisableClientRoutes bool `json:"disableClientRoutes"` + DisableServerRoutes bool `json:"disableServerRoutes"` + AllowServerSSH bool `json:"allowServerSSH"` + DisableAutoConnect bool `json:"disableAutoConnect"` + BlockInbound bool `json:"blockInbound"` + DisableMetricsCollection bool `json:"disableMetricsCollection"` + SplitTunnelMode bool `json:"splitTunnelMode"` + SplitTunnelApps bool `json:"splitTunnelApps"` + DisableAdvancedView bool `json:"disableAdvancedView"` } type Features struct { @@ -37,19 +37,16 @@ type Restrictions struct { Features Features `json:"features"` } - -// ConfigParams selects which profile/user to read or write config for. type ConfigParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` } - type Config struct { - ManagementURL string `json:"managementUrl"` - AdminURL string `json:"adminUrl"` - ConfigFile string `json:"configFile"` - LogFile string `json:"logFile"` + ManagementURL string `json:"managementUrl"` + AdminURL string `json:"adminUrl"` + ConfigFile string `json:"configFile"` + LogFile string `json:"logFile"` PreSharedKeySet bool `json:"preSharedKeySet"` InterfaceName string `json:"interfaceName"` WireguardPort int64 `json:"wireguardPort"` @@ -75,8 +72,8 @@ type Config struct { SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"` } -// SetConfigParams is a partial update — only fields with non-nil pointers -// are sent to the daemon. The frontend uses this to flip individual toggles. +// SetConfigParams is a partial update — only non-nil pointer fields are sent +// to the daemon; nil fields are preserved. type SetConfigParams struct { ProfileName string `json:"profileName"` Username string `json:"username"` @@ -108,7 +105,6 @@ type SetConfigParams struct { SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"` } -// Settings groups the daemon RPCs that read and write the daemon config. type Settings struct { conn DaemonConn } @@ -199,7 +195,6 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error { return err } -// MDM + Features Restrictions func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) { cli, err := s.conn.Client() if err != nil { diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go index 1c7669438..0d794c137 100644 --- a/client/ui/services/uilog.go +++ b/client/ui/services/uilog.go @@ -8,15 +8,13 @@ import ( log "github.com/sirupsen/logrus" ) -// UILog lets the frontend forward console output into the Go logrus -// pipeline. The JS origin is carried as the "ui" log field so it stays -// distinct from logrus's own Go-caller source. +// UILog forwards frontend console output into logrus, tagging the JS origin +// as the "ui" field to stay distinct from logrus's Go-caller source. type UILog struct{} func NewUILog() *UILog { return &UILog{} } -// Log forwards one frontend console entry. level is trace/debug/info/warn/ -// error (anything else → info); source is the JS origin (may be empty). +// Log maps an unrecognised level to info; empty source becomes "unknown". func (s *UILog) Log(_ context.Context, level, source, msg string) { origin := "unknown" if source != "" { diff --git a/client/ui/services/update.go b/client/ui/services/update.go index a99617e9b..753177d45 100644 --- a/client/ui/services/update.go +++ b/client/ui/services/update.go @@ -12,18 +12,14 @@ import ( "github.com/netbirdio/netbird/client/ui/updater" ) -// UpdateResult mirrors TriggerUpdateResponse: Success false carries an error -// message in ErrorMsg. +// UpdateResult mirrors TriggerUpdateResponse. type UpdateResult struct { Success bool `json:"success"` ErrorMsg string `json:"errorMsg"` } -// Update is the Wails-bound facade over the daemon's update RPCs and the -// updater.Holder cached state. The state machine, metadata schema, and -// push event live in client/ui/updater — this file exists only to give -// the binding generator a service type with the context.Context-first -// signatures it expects. +// Update is the Wails-bound facade over the daemon's update RPCs. The state +// machine and push event live in client/ui/updater. type Update struct { conn DaemonConn holder *updater.Holder @@ -33,18 +29,12 @@ func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update { return &Update{conn: conn, holder: holder} } -// GetState returns the latest update.State snapshot. The frontend calls -// this once on mount, then subscribes to updater.EventStateChanged for -// live updates. func (s *Update) GetState() updater.State { return s.holder.Get() } -// Quit asks the host application to exit. The /update page calls this once -// the daemon-side installer has reported success, mirroring the legacy -// Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit -// off the calling goroutine so the JS-side caller's response can return -// before the runtime tears down. +// Quit exits the app. Scheduled off the calling goroutine so the JS caller's +// response returns before the runtime tears down. func (s *Update) Quit() { go func() { time.Sleep(100 * time.Millisecond) diff --git a/client/ui/services/version.go b/client/ui/services/version.go index ca25630c7..8caea9f72 100644 --- a/client/ui/services/version.go +++ b/client/ui/services/version.go @@ -8,19 +8,15 @@ import ( "github.com/netbirdio/netbird/version" ) -// Version is the Wails-bound facade exposing build/version metadata to the -// frontend. Today it only reports the GUI's own version (the daemon version is -// surfaced separately through the status feed's DaemonVersion field). +// Version reports only the GUI's own version; the daemon version comes from +// the status feed's DaemonVersion field. type Version struct{} -// NewVersion constructs the Version service. func NewVersion() *Version { return &Version{} } -// GUI returns the version of the running UI binary, baked in at build time via -// the version package's ldflags. Falls back to "development" for un-stamped -// builds (see version.NetbirdVersion). +// GUI returns the UI binary's version, stamped via ldflags ("development" if un-stamped). func (v *Version) GUI(_ context.Context) string { return version.NetbirdVersion() } diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index d71d976bd..02cc731af 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -15,45 +15,35 @@ import ( "github.com/netbirdio/netbird/client/ui/preferences" ) -// LanguageSubscriber delivers UI preference changes (currently only the -// language flip; reusing preferences.UIPreferences keeps the channel -// payload identical to preferences.Store.Subscribe). The runtime -// implementation is *preferences.Store. WindowManager uses this to keep -// the long-lived Settings window title in the active language. +// LanguageSubscriber delivers UI preference changes so live window titles can +// follow the active language. Runtime impl is *preferences.Store. type LanguageSubscriber interface { Subscribe() (<-chan preferences.UIPreferences, func()) } -// EventTriggerLogin asks the frontend's startLogin() orchestrator to begin -// an SSO flow. Emitted by the tray (Login menu item, session expired) since -// the tray can't call JS directly. +// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. +// Emitted by the tray since the tray can't call JS directly. const EventTriggerLogin = "trigger-login" -// EventBrowserLoginCancel is emitted by the BrowserLogin popup window when -// the user clicks Cancel or closes the window. startLogin() listens for it -// and tears down the daemon's pending SSO wait. +// EventBrowserLoginCancel signals that the user dismissed the BrowserLogin +// popup; startLogin() listens for it to tear down the daemon's pending SSO wait. const EventBrowserLoginCancel = "browser-login:cancel" -// EventSettingsOpen tells the (already-mounted, currently-hidden) settings -// window which tab to land on, then drives Window.Show()/Focus() from the -// React side. Routing the open through the React layer avoids the -// SetURL-on-every-open path that re-mounted the entire provider tree and -// flashed the SettingsSkeleton between opens. +// EventSettingsOpen tells the already-mounted settings window which tab to +// land on. Routing through React avoids a SetURL-per-open, which re-mounted +// the provider tree and flashed the SettingsSkeleton. const EventSettingsOpen = "netbird:settings:open" -// WindowBackgroundColour is the shared in-window background for every -// NetBird webview (matches the bg-nb-gray utility in the Tailwind config -// at #181A1D / nb-gray-950, used by AppLayout's background). +// WindowBackgroundColour matches AppLayout's bg-nb-gray-950 (#181A1D). var WindowBackgroundColour = application.NewRGB(24, 26, 29) -// WindowHeight is the shared frame height for the main window and the -// Settings window so the right panel inside both ends up the same size. +// WindowHeight is shared by the main and Settings windows so the right panel +// inside both ends up the same size. const WindowHeight = 660 // Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed). -// Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21); -// title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only -// because WindowTheme fields are *uint32 and Go has no literal address-of. +// Border/title bar match AppRightPanel bg-nb-gray-940 (#1C1E21); title text +// matches text-nb-gray-100 (#E4E7E9). func u32ptr(v uint32) *uint32 { return &v } var microsoftWindowsTheme = &application.WindowTheme{ @@ -62,10 +52,9 @@ var microsoftWindowsTheme = &application.WindowTheme{ TitleTextColour: u32ptr(0x00E9E7E4), } -// MicrosoftWindowsAppearanceOptions is the per-window Microsoft Windows OS -// chrome shared by every NetBird webview window. Mica backdrop (no-op on -// pre-22621), dark theme, custom title bar/border colours so the chrome -// reads as an extension of the in-window AppRightPanel. +// MicrosoftWindowsAppearanceOptions is the shared Windows chrome: Mica backdrop +// (no-op pre-22621), dark theme, and custom title bar/border colours so the +// chrome extends the in-window AppRightPanel. func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { return application.WindowsWindow{ BackdropType: application.Mica, @@ -79,11 +68,9 @@ func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { } } -// AppleMacOSAppearanceOptions is the per-window macOS chrome shared by -// every NetBird webview window. The hidden title bar inset clears space -// for the traffic-light buttons; the FullScreenNone collection behavior -// keeps the green button from offering a full-screen mode that breaks -// our fixed-size layouts. +// AppleMacOSAppearanceOptions is the shared macOS chrome. The hidden title bar +// inset clears space for the traffic-light buttons; FullScreenNone stops the +// green button offering a full-screen mode that breaks our fixed-size layouts. func AppleMacOSAppearanceOptions() application.MacWindow { return application.MacWindow{ InvisibleTitleBarHeight: 38, @@ -93,10 +80,9 @@ func AppleMacOSAppearanceOptions() application.MacWindow { } } -// LinuxAppearanceOptions is the per-window Linux chrome shared by every -// NetBird webview window. Icon shows up in the WM task list / minimised -// state; WindowIsTranslucent is left off so the opaque background colour -// paints reliably on compositors that fake translucency badly. +// LinuxAppearanceOptions is the shared Linux chrome. WindowIsTranslucent stays +// off so the opaque background paints reliably on compositors that fake +// translucency. func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { return application.LinuxWindow{ Icon: icon, @@ -104,14 +90,9 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { } } -// DialogWindowOptions is the baseline for every auxiliary dialog window -// (BrowserLogin, SessionExpiration, InstallProgress). -// All share size (360x320), the no-resize / no-min / no-max chrome, -// Hidden-on-create (so the React side can auto-size before first paint), -// AlwaysOnTop (the dialogs interrupt the user, the SSO popup overrides -// this), and the shared background/Mac/Windows appearance. Callers fill -// in per-dialog overrides (URL params, screen targeting, etc.) on the -// returned value before passing it to Window.NewWithOptions. +// DialogWindowOptions is the baseline for every auxiliary dialog window: fixed +// size, Hidden-on-create (React auto-sizes before first paint), and AlwaysOnTop. +// Callers apply per-dialog overrides before Window.NewWithOptions. func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions { return application.WebviewWindowOptions{ Name: name, @@ -132,18 +113,13 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application. } } -// WindowManager opens auxiliary application windows on demand from the -// frontend. The main window is created up-front in main.go; this service is -// for secondary surfaces (Settings, BrowserLogin, Session*, InstallProgress). +// WindowManager owns the auxiliary windows; the main window is created up-front +// in main.go. // -// Settings is created eagerly (hidden) at construction and hides — rather -// than destroys — on close, so reopens are instant and the React side keeps -// whatever in-window state the user left behind (selected tab, scroll -// position, unsaved form fields). All other auxiliary windows are created -// on first open and destroyed on close — the Wails-recommended singleton -// pattern (see Multiple Windows docs: "Cleanup on close"). Destroying rather -// than hiding means the macOS dock-reopen handler doesn't find a hidden -// window to resurrect. +// Settings is created eagerly (hidden) and hides on close so reopens are +// instant and React keeps its in-window state (tab, scroll, unsaved fields). +// Every other auxiliary window is created on first open and destroyed on +// close, so the macOS dock-reopen handler finds no hidden window to resurrect. type WindowManager struct { app *application.App mainWindow *application.WebviewWindow @@ -156,29 +132,20 @@ type WindowManager struct { installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow - // hiddenForLogin remembers windows that were visible when the - // BrowserLogin popup opened. They were Hide()n to keep focus on the - // SSO flow without resorting to AlwaysOnTop, and are restored when - // the BrowserLogin window closes (success or cancel). + // hiddenForLogin holds windows hidden while the BrowserLogin popup is open + // (keeps focus on the SSO flow without AlwaysOnTop), restored when it closes. hiddenForLogin []application.Window mu sync.Mutex - // recenterOnShow reports whether Go should re-center the Go-shown - // windows (main, Settings) on each show. Only true in the minimal-WM / - // in-process XEmbed-tray environment, where the WM neither centers small - // windows for us nor restores their position across a hide -> show - // round-trip. On full desktops (GNOME/KDE) the WM handles placement, so - // re-centering is unnecessary and would fight a window the user moved — - // there this stays nil and centerWhenReady is a no-op. Set by the Linux - // startup path via SetRecenterOnShow; nil on macOS/Windows and in tests. - // A predicate (not a bool) because the XEmbed tray can appear after the - // UI starts (panel/app login race), so the answer is evaluated per show. + // recenterOnShow reports whether Go should re-center on each show. Only true + // on the minimal-WM / XEmbed-tray path, where the WM neither centers small + // windows nor restores position across a hide -> show; on full desktops it + // stays nil so re-centering can't fight a user-moved window. A predicate, not + // a bool, because the XEmbed tray can appear after the UI starts. recenterOnShow func() bool } // title resolves a window-title i18n key in the user's current language. -// Falls back to the raw key when the translator or prefs are missing -// (mirrors services.Connection.translateShort) — a deliberate fail-loud -// signal that a key is missing from the bundle. +// Falls back to the raw key when translator or prefs are missing. func (s *WindowManager) title(key string) string { if s.translator == nil { return key @@ -192,26 +159,17 @@ func (s *WindowManager) title(key string) string { return s.translator.Translate(lang, key) } -// NewWindowManager wires the manager to the main app. `mainWindow` is the -// up-front-created webview the user interacts with from the tray — used to -// pick the BrowserLogin window's display so the sign-in popup follows the -// user onto the screen they're already looking at. `translator` + `prefs` -// resolve the user-facing window titles in the active UI language; both -// may be nil (callers in tests can omit them), in which case title() falls -// back to the raw i18n key. +// NewWindowManager wires the manager to the main app. translator and prefs may +// be nil (tests), in which case title() falls back to the raw i18n key. // -// The Settings window is created here, hidden, so the first OpenSettings -// call paints instantly instead of paying webview construction + asset load -// at click time. +// The Settings window is created here, hidden, so the first OpenSettings paints +// instantly instead of paying webview construction + asset load at click time. func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager { s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon} - // If the prefs implementation also exposes Subscribe (the runtime - // *preferences.Store does), wire up a goroutine that re-titles every - // live auxiliary window on language flip. Done here — instead of via - // an exported WatchLanguage method on the service — so the Wails - // binding generator doesn't try to expose a LanguageSubscriber-taking - // method to the frontend (interface params can't round-trip through - // JSON and would emit a generator warning). + // If prefs also exposes Subscribe, re-title every live auxiliary window on + // language flip. Wired here rather than via an exported method so the Wails + // binding generator doesn't try to expose a LanguageSubscriber param + // (interface params can't round-trip through JSON). if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { ch, _ := sub.Subscribe() go func() { @@ -241,11 +199,9 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo Windows: MicrosoftWindowsAppearanceOptions(), Linux: LinuxAppearanceOptions(linuxIcon), }) - // Hide on close instead of destroying — preserves in-window React state - // across reopens. Mirrors the main window's close behaviour. Resetting - // the active tab to General on hide means the *next* OpenSettings("") - // finds the window already on General, so showing it is a single Show() - // with nothing to update first — no flash. + // Hide on close instead of destroying, preserving in-window React state. + // Resetting the tab to General on hide means the next OpenSettings("") finds + // it already there, so showing it is a single Show() — no flash. s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() s.app.Event.Emit(EventSettingsOpen, "general") @@ -254,10 +210,10 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo return s } -// retitleAll re-applies the localised title to every currently-alive -// auxiliary window. Reads the window pointers under s.mu so a concurrent -// Open*/Close* can't observe a torn slice. SetTitle itself dispatches to -// the OS UI thread, so calling it from this goroutine is safe. +// retitleAll re-applies the localised title to every alive auxiliary window. +// Snapshots the window pointers under s.mu so a concurrent Open*/Close* can't +// race; SetTitle dispatches to the OS UI thread, so the calls are safe to make +// after releasing the lock. func (s *WindowManager) retitleAll() { s.mu.Lock() type pair struct { @@ -280,16 +236,12 @@ func (s *WindowManager) retitleAll() { } } -// OpenSettings asks the (already-mounted, currently-hidden) settings window -// to land on `tab` and bring itself to front. Empty `tab` lands on General. +// OpenSettings shows the settings window on tab (empty → General). // -// The window stays at a single URL (`/#/settings`) for its entire lifetime: -// calling SetURL on every open re-loaded the WKWebView, which re-mounted the -// `AppLayout` provider stack and visibly flashed the `SettingsSkeleton` while -// `SettingsContext` re-fetched config. Instead, the React side keeps tab in -// local state and listens for `EventSettingsOpen` to switch it. The close -// hook (above) already resets state to "general", so the common-case -// reopen-on-gear path has nothing to update — Show is a no-op repaint. +// The window keeps a single URL (/#/settings) for its lifetime: SetURL per open +// re-loaded the WKWebView, re-mounting the AppLayout provider stack and flashing +// the SettingsSkeleton. Instead React keeps the tab in local state and switches +// it on EventSettingsOpen. func (s *WindowManager) OpenSettings(tab string) { target := tab if target == "" { @@ -298,15 +250,13 @@ func (s *WindowManager) OpenSettings(tab string) { s.app.Event.Emit(EventSettingsOpen, target) s.settings.Show() s.settings.Focus() - // Re-center on every open (minimal-WM only): like the main window, - // Settings is hidden (not destroyed) on close, and a hide -> show - // round-trip lands it back in the corner there unless re-centered. + // Re-center (minimal-WM only): Settings is hidden on close, and a hide -> + // show round-trip lands it in the corner unless re-centered. s.centerWhenReady(s.settings) } -// OpenBrowserLogin shows the SSO popup window, creating it on first use (and -// after the user has closed a previous instance). The URI is encoded into -// the window's start URL so the React page reads it via useSearchParams. +// OpenBrowserLogin shows the SSO popup window, creating it on first use. uri is +// encoded into the start URL so the React page reads it via useSearchParams. func (s *WindowManager) OpenBrowserLogin(uri string) { s.mu.Lock() defer s.mu.Unlock() @@ -316,10 +266,8 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") - // Prefer the screen the main window is on so the sign-in popup - // shows up where the user is already looking on multi-monitor - // setups. Falls back to OS-default centering if the main window - // has no resolvable screen yet. + // Prefer the main window's screen so the popup shows where the user is + // looking on multi-monitor setups; falls back to OS-default centering. var screen *application.Screen if s.mainWindow != nil { if sc, err := s.mainWindow.GetScreen(); err == nil { @@ -327,20 +275,15 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { } } opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) - // SSO popup deliberately is NOT always-on-top — the user moves - // between the browser tab and our popup; pinning it would obscure - // the browser at the moment they need to interact with it. + // Not always-on-top: the user moves between the browser tab and the + // popup; pinning it would obscure the browser when they need it. opts.AlwaysOnTop = false - // WindowCentered + Screen centers on the chosen display's - // WorkArea (see WebviewWindowOptions.Screen docs) so the popup - // follows the user onto the screen they're already looking at. opts.InitialPosition = application.WindowCentered opts.Screen = screen s.browserLogin = s.app.Window.NewWithOptions(opts) bl := s.browserLogin - // User-initiated close (red X) means cancel. Emit the event so - // startLogin() can tear the SSO wait down, then let the window - // destroy naturally — no hide trickery. + // Red-X close means cancel: emit the event so startLogin() tears down + // the SSO wait, then let the window destroy naturally. bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.app.Event.Emit(EventBrowserLoginCancel) s.mu.Lock() @@ -348,11 +291,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { s.restoreHiddenWindowsLocked() s.mu.Unlock() }) - // First open: window is Hidden, the React side auto-sizes via - // useAutoSizeWindow and calls Window.Show/Focus once content is - // measured. Returning here avoids the snap from placeholder to - // measured height. centerWhenReady polls for that JS-driven show, - // so it centers (minimal-WM only) whoever ends up calling Show. + // First open: the window is Hidden; React auto-sizes and calls Show/Focus + // once content is measured. centerWhenReady polls for that JS-driven show + // (minimal-WM only). s.centerWhenReady(s.browserLogin) return } @@ -364,9 +305,8 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { s.centerWhenReady(s.browserLogin) } -// hideOtherWindowsLocked hides every currently visible window except the one -// named `keepName` and remembers them in hiddenForLogin so they can be -// restored when the BrowserLogin flow ends. Caller must hold s.mu. +// hideOtherWindowsLocked hides every visible window except keepName, recording +// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. func (s *WindowManager) hideOtherWindowsLocked(keepName string) { for _, w := range s.app.Window.GetAll() { if w == nil || w.Name() == keepName { @@ -380,7 +320,7 @@ func (s *WindowManager) hideOtherWindowsLocked(keepName string) { } } -// restoreHiddenWindowsLocked re-shows every window that was hidden by +// restoreHiddenWindowsLocked re-shows windows hidden by // hideOtherWindowsLocked. Caller must hold s.mu. func (s *WindowManager) restoreHiddenWindowsLocked() { for _, w := range s.hiddenForLogin { @@ -392,30 +332,26 @@ func (s *WindowManager) restoreHiddenWindowsLocked() { s.hiddenForLogin = nil } -// BrowserLoginWindow returns the live SSO popup window, or nil if no SSO -// flow is in progress. While it is non-nil it should be treated as the -// app's focal window — tray "Open" and dock/taskbar activation hand off -// to it instead of the (currently hidden) main window. +// BrowserLoginWindow returns the live SSO popup, or nil if no SSO flow is in +// progress. While non-nil it is the app's focal window: tray "Open" and +// dock/taskbar activation hand off to it instead of the main window. func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() return s.browserLogin } -// InstallProgressWindow returns the live install-progress window, or nil -// if no install is in progress. Same contract as BrowserLoginWindow: while -// it is non-nil it is the app's focal window — tray "Open" and dock / -// taskbar activation route to it instead of the (currently hidden) main -// window. Install supersedes every other surface, so callers should check -// this before BrowserLoginWindow. +// InstallProgressWindow returns the live install-progress window, or nil. Same +// focal-window contract as BrowserLoginWindow; install supersedes every other +// surface, so check this first. func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() return s.installProgress } -// CloseBrowserLogin destroys the SSO popup window if it exists. Called from -// startLogin() when the flow completes or cancels programmatically. +// CloseBrowserLogin destroys the SSO popup. Called from startLogin() when the +// flow completes or cancels programmatically. func (s *WindowManager) CloseBrowserLogin() { s.mu.Lock() w := s.browserLogin @@ -426,9 +362,9 @@ func (s *WindowManager) CloseBrowserLogin() { } } -// OpenSessionExpiration shows the countdown warning above all other -// windows on the display the cursor is currently on. `seconds` seeds the -// mm:ss countdown rendered React-side. Singleton, destroyed on close. +// OpenSessionExpiration shows the countdown warning above all windows on the +// display the cursor is on. seconds seeds the React-side mm:ss countdown. +// Singleton, destroyed on close. func (s *WindowManager) OpenSessionExpiration(seconds int) { s.mu.Lock() defer s.mu.Unlock() @@ -452,7 +388,6 @@ func (s *WindowManager) OpenSessionExpiration(seconds int) { s.sessionExpiration.Focus() } -// CloseSessionExpiration destroys the countdown warning window if open. func (s *WindowManager) CloseSessionExpiration() { s.mu.Lock() w := s.sessionExpiration @@ -463,17 +398,13 @@ func (s *WindowManager) CloseSessionExpiration() { } } -// OpenInstallProgress shows the install-progress window above all other -// application windows for the duration of the auto-update install. The -// daemon is unreliable mid-install (it gets restarted by the installer), -// so this window owns its own polling loop against Update.GetInstallerResult -// and treats a sustained gRPC failure as success. +// OpenInstallProgress shows the install-progress window above all windows. The +// daemon is unreliable mid-install (the installer restarts it), so this window +// owns its own polling loop against Update.GetInstallerResult and treats a +// sustained gRPC failure as success. // -// All other visible windows are hidden while the install runs — the ticket -// requires that the user can't reach other menus during install — and are -// restored when the window closes (cancel, error dismissal, success-quit -// race). Singleton, destroyed on close. Created Hidden so the React side -// can auto-size before paint. +// All other visible windows are hidden during the install (restored on close) +// so the user can't reach other menus. Singleton, destroyed on close. func (s *WindowManager) OpenInstallProgress(version string) { s.mu.Lock() defer s.mu.Unlock() @@ -501,7 +432,6 @@ func (s *WindowManager) OpenInstallProgress(version string) { s.centerWhenReady(s.installProgress) } -// CloseInstallProgress destroys the install-progress window if open. func (s *WindowManager) CloseInstallProgress() { s.mu.Lock() w := s.installProgress @@ -512,24 +442,17 @@ func (s *WindowManager) CloseInstallProgress() { } } -// OpenWelcome shows the first-launch onboarding window. The React side -// auto-sizes the window height to its content; the Continue button calls -// Preferences.SetOnboardingCompleted(true) before closing so the flow -// doesn't re-run. Singleton, destroyed on close. Created Hidden so the -// React side can auto-size before paint. +// OpenWelcome shows the first-launch onboarding window. The Continue button +// calls Preferences.SetOnboardingCompleted(true) before closing so the flow +// doesn't re-run. Singleton, destroyed on close. func (s *WindowManager) OpenWelcome() { s.mu.Lock() defer s.mu.Unlock() if s.welcome == nil { opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) opts.Width = 420 - // Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions) - // so the user can't accidentally bury the first-launch flow behind - // another window and lose track of how to finish setup. - // Land in the middle of the user's primary display — the welcome - // flow is identity-defining and shouldn't read as an incidental - // dialog floating in a corner. WindowCentered + nil Screen - // resolves against the primary display (see WebviewWindowOptions). + // Stays AlwaysOnTop (inherited) so the first-launch flow can't get + // buried. nil Screen centers on the primary display. opts.InitialPosition = application.WindowCentered s.welcome = s.app.Window.NewWithOptions(opts) w := s.welcome @@ -546,7 +469,6 @@ func (s *WindowManager) OpenWelcome() { s.centerWhenReady(s.welcome) } -// CloseWelcome destroys the welcome window if open. func (s *WindowManager) CloseWelcome() { s.mu.Lock() w := s.welcome @@ -557,20 +479,13 @@ func (s *WindowManager) CloseWelcome() { } } -// OpenError shows a custom error dialog window above all other application -// windows. The window's chrome title is always the generic localised "Error"; -// `title` is the error's name (e.g. a login failure passes the translated -// "Login Failed") and is rendered as the dialog heading in the body, while -// `message` is the body text below it. The caller is responsible for localising -// both. title + message are carried in the window's start URL so the page reads -// them via useSearchParams; if the window is already open it is steered to the -// new content via SetURL so a second error replaces the first instead of -// stacking another window. Singleton — destroyed on close. Created Hidden so -// the React side can auto-size to the (variable-length) message before paint. +// OpenError shows the custom error dialog above all windows. title and message +// are pre-localised by the caller and ride in the start URL (read via +// useSearchParams). A second error while one is open is steered via SetURL so +// it replaces the first instead of stacking. Singleton, destroyed on close. // -// This is the in-window alternative to the native errorDialog wrapper: it -// keeps the frameless NetBird chrome and survives the Windows-MessageBox -// parent-disable race that the native path has to detach around. +// In-window alternative to a native MessageBox: keeps the frameless chrome and +// avoids the Windows parent-disable race the native path had to detach around. func (s *WindowManager) OpenError(title, message string) { s.mu.Lock() defer s.mu.Unlock() @@ -593,10 +508,9 @@ func (s *WindowManager) OpenError(title, message string) { s.centerWhenReady(s.errorDialog) } -// errorDialogURL builds the hash-route start URL for the error window with the -// title (rendered as the body heading) and message carried as query params. -// Both are query-escaped so newlines, ampersands, and other characters common -// in formatted daemon errors survive the round-trip into useSearchParams. +// errorDialogURL builds the error window's hash-route start URL with title and +// message as query params, escaped so newlines and ampersands common in +// formatted daemon errors survive into useSearchParams. func errorDialogURL(title, message string) string { q := url.Values{} if title != "" { @@ -612,7 +526,6 @@ func errorDialogURL(title, message string) string { return startURL } -// CloseError destroys the error dialog window if open. func (s *WindowManager) CloseError() { s.mu.Lock() w := s.errorDialog @@ -623,43 +536,38 @@ func (s *WindowManager) CloseError() { } } -// OpenMain brings the main window forward. Used by the welcome Continue -// button to hand off from onboarding to the regular UI without depending -// on the tray. +// OpenMain brings the main window forward. The welcome Continue button uses it +// to hand off from onboarding without depending on the tray. func (s *WindowManager) OpenMain() { s.ShowMain() } -// ShowMain brings the main window forward, centering it on each show (see -// centerWhenReady). The single entry point every surface — tray, SIGUSR1, -// welcome handoff — should use so the centering fix applies uniformly. +// ShowMain brings the main window forward, centering on each show (see +// centerWhenReady). The single entry point every surface (tray, SIGUSR1, +// welcome handoff) should use so centering applies uniformly. func (s *WindowManager) ShowMain() { if s.mainWindow == nil { return } s.mainWindow.Show() s.mainWindow.Focus() - // Re-center on every show (minimal-WM only — see centerWhenReady). The - // window is hidden (not destroyed) on close, and on a hide -> show - // round-trip minimal WMs (the XEmbed tray path) re-place it in the - // top-left corner rather than restoring its prior position, so - // re-opening from the tray lands it in the corner again otherwise. + // Re-center (minimal-WM only; see centerWhenReady). The window is hidden on + // close, and minimal WMs re-place it top-left across a hide -> show instead + // of restoring its position. s.centerWhenReady(s.mainWindow) } -// SetRecenterOnShow installs the predicate that gates Go-side re-centering of -// the main and Settings windows (see the recenterOnShow field). The Linux -// startup path passes xembedTrayAvailable so re-centering happens only in the -// minimal-WM / in-process-XEmbed-tray environment; macOS/Windows and tests -// leave it unset, making centerWhenReady a no-op. +// SetRecenterOnShow installs the recenterOnShow predicate (see the field). The +// Linux startup path passes xembedTrayAvailable; macOS/Windows and tests leave +// it unset. func (s *WindowManager) SetRecenterOnShow(pred func() bool) { s.recenterOnShow = pred } -// getScreenBasedOnCursorPosition returns the display the OS cursor is -// on, falling back through main-window screen → nil (Wails treats nil -// as OS-default placement). Linux uses XQueryPointer via XWayland on -// Wayland sessions, which ships by default on the supported distros. +// getScreenBasedOnCursorPosition returns the display the OS cursor is on, +// falling back to the main-window screen, then nil (OS-default placement). +// On Linux the cursor query uses XQueryPointer, which works on Wayland via +// XWayland. func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { if s.app == nil || s.app.Screen == nil { return nil @@ -677,27 +585,17 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { return nil } -// centerWhenReady centers w once its native window actually exists — but only -// in environments where the WM won't do it for us (recenterOnShow). On full -// desktops the WM centers small windows and restores position across hide -> -// show, so this returns immediately and never fights a user-moved window. +// centerWhenReady centers w once its native window exists, but only where the +// WM won't (recenterOnShow); otherwise it returns immediately so it never fights +// a user-moved window. // -// Why it can't be a simple inline Center() after Show(): on Linux/GTK4 (Wails' -// linux_cgo backend) Center() moves the window via raw X11 (window_move_x11), -// which silently no-ops while the GdkSurface is still nil — and GTK4 realizes -// the surface asynchronously on the main loop, *after* Show() returns. So an -// immediate Center() races realization and lands in the top-left corner; the -// minimal WMs this targets don't re-center for us, so it sticks. -// -// It also can't be deferred via InvokeAsync(w.Center): Center() itself hops to -// the main thread with InvokeSync, so running it *on* the main thread would -// deadlock. So we drive it from a background goroutine (Center() and Position() -// are main-thread-safe off-thread for exactly that reason) and retry until the -// move actually takes effect, which is the unambiguous signal that the surface -// now exists: position() goes through X11 (window_get_position_x11) and reports -// (0,0) while the surface is nil — so a non-zero post-Center position means the -// centering landed. Bounded so a window that legitimately centers at the origin -// (e.g. fills the monitor) can't spin forever. +// An inline Center() after Show() doesn't work on Linux/GTK4: Center() moves via +// raw X11, which silently no-ops while the GdkSurface is nil, and GTK4 realizes +// the surface asynchronously after Show() returns. Deferring via InvokeAsync +// would deadlock (Center hops to the main thread with InvokeSync). So a +// background goroutine retries (Center/Position are main-thread-safe off-thread) +// until a non-zero Position confirms the surface is realized, bounded so a +// window legitimately centered at the origin can't spin forever. func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() { return @@ -706,20 +604,17 @@ func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { for i := 0; i < 50; i++ { // ~1s budget at 20ms steps w.Center() if x, y := w.Position(); x != 0 || y != 0 { - return // move took effect -> surface is realized + return // surface realized } time.Sleep(20 * time.Millisecond) } }() } -// centerOnCursorScreen centers w in the work area of the display the -// cursor is on. Each guard is a no-op (nil window, no cursor screen, -// zero size, zero work area) so a headless / no-monitor session is safe. -// On minimal WMs (recenterOnShow → Fluxbox/XEmbed) the same retry loop -// centerWhenReady uses kicks in: Linux SetPosition silently no-ops while -// the GdkSurface is nil, and a non-zero post-move Position is the -// signal that it landed. +// centerOnCursorScreen centers w in the work area of the display the cursor is +// on. Each guard no-ops (nil window, no cursor screen, zero size/work area) so +// headless sessions are safe. On minimal WMs (recenterOnShow) the same +// realize-detection retry loop as centerWhenReady kicks in. func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { if w == nil { return diff --git a/client/ui/signal_unix.go b/client/ui/signal_unix.go index a5a9205c0..876c5dd6e 100644 --- a/client/ui/signal_unix.go +++ b/client/ui/signal_unix.go @@ -11,9 +11,7 @@ import ( log "github.com/sirupsen/logrus" ) -// listenForShowSignal opens the main window when the process receives SIGUSR1. -// External tools (the daemon, the installer, or another `netbird-ui` invocation) -// can poke this channel by signalling the running pid. +// listenForShowSignal lets external tools surface the running UI by signalling its pid (SIGUSR1). func listenForShowSignal(ctx context.Context, tray *Tray) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGUSR1) diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go index 22f1623cf..86caa6d0c 100644 --- a/client/ui/signal_windows.go +++ b/client/ui/signal_windows.go @@ -16,14 +16,11 @@ const ( waitTimeout = 5 * time.Second desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE - // WaitForSingleObject returns this when the timeout elapses without the - // object being signalled. golang.org/x/sys/windows does not expose it. + // WAIT_TIMEOUT return code; not exposed by golang.org/x/sys/windows. waitTimeoutCode uint32 = 0x00000102 ) -// listenForShowSignal opens the main window when an external process pulses -// the named event Global\NetBirdQuickActionsTriggerEvent. Mirrors the trigger -// the legacy Fyne UI used so the installer and CLI integrations keep working. +// listenForShowSignal shows the main window when an external process pulses the named event. func listenForShowSignal(ctx context.Context, tray *Tray) { namePtr, err := windows.UTF16PtrFromString(quickActionsTriggerEventName) if err != nil { diff --git a/client/ui/tray.go b/client/ui/tray.go index cbe91ed42..0fc0c4b58 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -20,15 +20,8 @@ import ( "github.com/netbirdio/netbird/version" ) -// Translation keys for every user-facing string the tray paints. The text -// itself lives in i18n/locales//common.json — both the tray and the -// React UI read from there so a single bundle drives the whole product. -// Keys are referenced by the Tray.tr helper. - -// Non-translated identifiers. Notification IDs coalesce duplicate toasts -// (the OS uses them as dedup keys); statusError is a tray-only sentinel -// distinguishing the error-icon state from real daemon status strings; -// URLs are baked-in product links. +// Notification IDs are OS dedup keys that coalesce duplicate toasts; +// statusError is a tray-only sentinel for the error-icon state. const ( notifyIDUpdatePrefix = "netbird-update-" notifyIDEvent = "netbird-event-" @@ -42,13 +35,8 @@ const ( urlDocs = "https://docs.netbird.io" ) -// 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. -// TrayServices bundles the daemon-RPC and notification services the tray -// menu needs. Grouped into a single struct so NewTray stays under the -// linter's parameter-count threshold and so adding another service later -// is a one-line struct change instead of a NewTray signature break. +// TrayServices bundles the services the tray menu needs, grouped so NewTray +// stays under the linter's parameter-count threshold. type TrayServices struct { Connection *services.Connection Settings *services.Settings @@ -59,16 +47,9 @@ 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 - // React and are not needed here. + // Session is bound to authsession directly because the services wrapper + // only re-exposes the React subset. + Session *authsession.Session Localizer *Localizer } @@ -77,17 +58,12 @@ type Tray struct { tray *application.SystemTray window *application.WebviewWindow svc TrayServices - // panelDark reports whether the desktop panel uses a dark colour - // scheme, so iconForState can pick the black vs white monochrome tray - // icon on Linux. Set by startTrayTheme (Linux only); nil on macOS and - // Windows, where the OS/Wails handles light-vs-dark icon selection and - // panelIsDark falls back to its default. + // panelDark reports whether the desktop panel uses a dark scheme, so + // iconForState can pick the black vs white mono tray icon on Linux. Set + // by startTrayTheme (Linux only); nil elsewhere, where panelIsDark falls + // back to its default. panelDark func() bool - // loc owns the active language plus the preference subscription. The - // tray talks to it for every translated label (t.loc.T(...)) and - // registers a callback in NewTray that re-renders the menu on a - // language switch. - loc *Localizer + loc *Localizer // menu and the *Item/*Submenu fields below are reassigned by buildMenu // on every relayout — touch them only with menuMu held. Exceptions: @@ -95,9 +71,8 @@ type Tray struct { // refreshSessionExpiresLabel snapshots its item under menuMu. menu *application.Menu statusItem *application.MenuItem - // sessionExpiresItem displays the SSO session deadline as a humanised - // remaining-time label ("Session: 47m"). Painted by relayoutMenu from - // the sessionMu cache; a 30s ticker keeps the countdown moving. + // sessionExpiresItem shows the SSO deadline as a remaining-time label, + // repainted by a 30s ticker. sessionExpiresItem *application.MenuItem upItem *application.MenuItem downItem *application.MenuItem @@ -111,73 +86,53 @@ type Tray struct { updater *trayUpdater - // statusMu guards the daemon-status core mirrored on the tray — - // connected, the last status string, the daemon version, the - // routed-networks revision, and the post-connect login-trigger flag. - // These are all written by applyStatus and read by the menu painters - // (applyIcon, relayoutMenu, refreshExitNodes' connected sample, - // etc.). One mutex covers them because they change together on every - // Status push. + // statusMu guards the daemon-status core mirrored on the tray. One mutex + // covers these fields because applyStatus writes them together on every + // Status push and the menu painters read them. statusMu sync.Mutex connected bool lastStatus string lastDaemonVersion string - // lastNetworksRevision is the daemon's routed-networks revision from - // the last Status snapshot; a bump in it — or a connect/disconnect - // transition — is what triggers a refreshExitNodes re-fetch, so we - // hit ListNetworks only when routes or their selection actually - // change rather than on every push. The peer-status route list can't - // be used here: it only carries actively-routed (chosen) routes, not - // candidate exit nodes. + // lastNetworksRevision is the daemon's routed-networks revision; a bump (or + // a connect/disconnect transition) gates the refreshExitNodes re-fetch so + // ListNetworks runs only when routes change. The peer-status route list + // can't substitute: it carries only actively-routed routes, not candidate + // exit nodes. lastNetworksRevision uint64 - // 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. - // - // Profile-switch reconnects (which also fire an Up) are handled - // centrally by DaemonFeed.statusStreamLoop — see DaemonFeed's - // switchInProgress transitions and its EventTriggerLogin emit, so - // that the React UI's profile dropdown gets the same auto-handoff - // without going through this tray flag. + // pendingConnectLogin is set when handleConnect fires an Up on an idle + // daemon. The daemon flips to NeedsLogin if the peer is SSO-tracked with + // no cached token; applyStatus consumes the flag on that transition to + // open the browser-login flow, saving a second Connect click. + // Profile-switch reconnects are handled separately by + // DaemonFeed.statusStreamLoop. pendingConnectLogin bool - // sessionMu guards the cached SSO deadline used by the "Session: 47m" - // tray row. Independent of statusMu because the ticker reads it on a - // 30s cadence and applySessionExpiry writes it whenever the daemon's - // Status push carries a new value — neither should block the other's - // readers. - sessionMu sync.Mutex - // 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. + // sessionMu guards the cached SSO deadline used by the session row. + // Independent of statusMu so the 30s ticker reader and the Status-push + // writer don't block each other. + sessionMu sync.Mutex sessionExpiresAt time.Time - // profileMu guards the profile-domain state: the active profile - // identity cached by loadConfig, the notifications gate also cached - // there, and the in-flight switchProfile cancel. Independent of - // statusMu because a long-running switch (Down + Up) holds the - // switchCancel write under this lock, and we don't want it to block - // a concurrent Status-push reader of t.connected. + // profileMu guards the profile-domain state (active identity, the + // notifications gate, the in-flight switch cancel). Independent of + // statusMu so a long-running switch holding switchCancel doesn't block a + // Status-push reader of t.connected. profileMu sync.Mutex activeProfile string activeUsername string notificationsEnabled bool switchCancel context.CancelFunc - // profileLoadMu serializes loadProfiles so the daemon-status-driven - // refresh in applyStatus cannot race with the ApplicationStarted seed - // or the post-switchProfile reload — both manipulate profileSubmenu and - // SetMenu, which the Wails menu API is not safe against concurrent - // callers. + // profileLoadMu serializes loadProfiles so the applyStatus refresh can't + // race the ApplicationStarted seed or the post-switch reload — all + // manipulate profileSubmenu + SetMenu, which Wails isn't concurrency-safe + // against. profileLoadMu sync.Mutex // profilesMu guards the cached profile rows that relayoutMenu repaints - // into a freshly built Profiles submenu. loadProfiles fetches and stores - // them here; fillProfileSubmenu reads them. Kept separate from the live - // submenu so a relayout (which throws the old submenu away) always has a - // source of truth to repaint from without re-hitting the daemon. + // into a freshly built Profiles submenu, kept separate from the live + // submenu so a relayout always has a source to repaint from without + // re-hitting the daemon. profilesMu sync.Mutex profiles []services.Profile profilesUser string @@ -188,26 +143,19 @@ type Tray struct { // reinstall a stale tree. menuMu sync.Mutex - // exitNodesMu guards the t.exitNodes row cache so reading the cached - // rows in relayoutMenu (and tearing a copy off the slice for - // Repaint) doesn't contend with status-push readers of statusMu. + // exitNodesMu guards the exitNodes row cache so relayoutMenu's read (and + // the Repaint copy) doesn't contend with status-push readers of statusMu. exitNodesMu sync.Mutex - // exitNodes are the rows currently painted into the Exit Node - // submenu, sourced from Networks.List() (NetID + selected state) so - // each row can be toggled. - exitNodes []exitNodeEntry - // exitNodesRebuildMu serialises the submenu.Clear + Add + SetMenu - // cycle. The Status stream can fire several pushes in quick - // succession and each may kick a refresh, but the ListNetworks fetch + - // submenu rebuild + SetMenu must not run concurrently with itself. + exitNodes []exitNodeEntry + // exitNodesRebuildMu serialises the ListNetworks fetch + submenu rebuild + + // SetMenu cycle so back-to-back Status pushes can't run it concurrently + // with itself. exitNodesRebuildMu sync.Mutex - // featureMu guards the daemon feature kill switches mirrored on the - // tray. Fetched once at startup and refreshed on every config_changed - // system event (the daemon re-applies MDM policy on each engine spawn - // and signals it via that event). Folded into the Profiles and Exit - // Node menu enablement by featuresDisabled so an operator- or - // MDM-disabled surface greys out without a periodic GetFeatures poll. + // featureMu guards the daemon feature kill switches mirrored on the tray. + // Fetched at startup and refreshed on every config_changed event (the + // daemon re-applies MDM policy per engine spawn), so featuresDisabled can + // grey out menus without polling GetFeatures. featureMu sync.Mutex disableProfiles bool disableNetworks bool @@ -219,78 +167,54 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe window: window, svc: svc, notificationsEnabled: true, - // Localizer is constructed by main from the i18n.Bundle and - // preferences.Store so the first menu render below is already in - // the right locale — no English flash followed by a re-paint. + // Localizer is constructed by main so the first menu render is already + // in the right locale — no English flash then re-paint. loc: svc.Localizer, } t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }) t.tray = app.SystemTray.New() - // Seed panel-theme detection (Linux only) before the first paint so the - // initial icon already matches the panel's light/dark scheme; repaints - // on live theme switches. + // Seed panel-theme detection before the first paint so the initial icon + // matches the panel's light/dark scheme (Linux only). t.startTrayTheme() t.applyIcon() t.tray.SetTooltip(t.loc.T("tray.tooltip")) - // On Linux the SNI hover tooltip is sourced from the systray *Label* - // (the StatusNotifierItem Title/ToolTip props), not SetTooltip — - // SetTooltip is a no-op on Linux. With no label set, Wails falls back - // to the literal "Wails", so set it explicitly here. macOS is skipped - // because its setLabel paints visible text next to the icon; Windows - // is skipped because its tooltip comes from SetTooltip above. + // On Linux the SNI hover tooltip rides on the systray label, not + // SetTooltip (a no-op there); without a label Wails shows the literal + // "Wails". macOS/Windows are skipped (label paints visible text on + // macOS; Windows uses SetTooltip above). if runtime.GOOS == "linux" { t.tray.SetLabel(t.loc.T("tray.tooltip")) } t.menu = t.buildMenu() t.tray.SetMenu(t.menu) - // Left-click on the tray icon opens the menu, and the window is reached - // through the explicit "Open NetBird" entry. This matches macOS - // NSStatusItem convention (click → menu), the Linux StatusNotifierItem - // spec, and the legacy Fyne client. macOS and Linux give us click→menu - // natively, so bindTrayClick is a no-op there (binding OnClick→OpenMenu - // on macOS would freeze the tray — see tray_click_other.go). Windows has - // no native left-click handler, so bindTrayClick wires one explicitly - // (see tray_click_windows.go). On Linux we deliberately skip AttachWindow: - // it plus Wails3's applySmartDefaults would pop the window alongside the - // menu on environments like GNOME Shell with the AppIndicator extension. - // Right-click opens the menu through Wails' default rightClickHandler on - // every platform. + // macOS/Linux give click→menu natively, so bindTrayClick is a no-op there + // (binding OnClick→OpenMenu on macOS would freeze the tray); Windows has no + // native handler so it wires one (see tray_click_*.go). On Linux + // AttachWindow is skipped — with applySmartDefaults it would pop the window + // alongside the menu (e.g. GNOME Shell AppIndicator). bindTrayClick(t) app.Event.On(services.EventStatusSnapshot, t.onStatusEvent) app.Event.On(services.EventDaemonNotification, t.onSystemEvent) - // Refresh the Profiles submenu when ProfileSwitcher fires the change. - // applyStatus already reloads on status-text transitions, but a - // switch on an idle daemon doesn't drive one — without this hook, - // a React-initiated switch leaves the tray's submenu and active- - // profile label stale. + // Refresh the Profiles submenu on ProfileSwitcher's change event. A + // switch on an idle daemon drives no status transition, so without this + // hook a React-initiated switch leaves the tray's submenu stale. app.Event.On(services.EventProfileChanged, func(*application.CustomEvent) { go t.loadProfiles() }) - // Defer the first profile load until the macOS/GTK/Win32 menu impl is - // live — Menu.Update() short-circuits while app.running is false, and - // AppKit's main queue isn't ready earlier either (see d23ef34 InvokeSync - // nil-deref). + // Defer the first profile load until the menu impl is live — Menu.Update() + // short-circuits while app.running is false, and AppKit's main queue isn't + // ready earlier (see d23ef34 InvokeSync nil-deref). app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { go t.loadProfiles() - // Seed the feature kill switches so a DisableProfiles / DisableNetworks - // policy already greys out the matching menus on the first paint - // (config_changed events refresh them afterwards). go t.refreshRestrictions() go t.runSessionExpiryTicker() - // 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. + // Category registration must run after the notifications service + // Startup populates appName/registry path on Windows; before app.Run() + // the category lookup silently falls back to a plain notification. t.registerSessionWarningCategory() }) - // Localizer fires this callback after it has already swapped its own - // cached language, so every t.loc.T(...) lookup inside applyLanguage - // runs against the new locale. t.loc.Watch(func(i18n.LanguageCode) { t.applyLanguage() }) go t.loadConfig() @@ -298,25 +222,16 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe } // ShowWindow brings the main window forward — used by SIGUSR1 / Windows event. -// Show() alone is not enough on macOS: makeKeyAndOrderFront skips app -// activation, so a tray-style app's window pops up behind the currently -// active app. Focus() additionally calls activateIgnoringOtherApps:YES on -// macOS and SetForegroundWindow on Windows. +// Show() alone is not enough on macOS (makeKeyAndOrderFront skips activation, +// so the window pops up behind the active app); Focus() additionally calls +// activateIgnoringOtherApps:YES on macOS and SetForegroundWindow on Windows. func (t *Tray) ShowWindow() { - // While an auto-update install is running the install-progress window - // is the focal surface (all other windows hidden by WindowManager). - // Tray "Open" / SIGUSR1 / dock-reopen should bring it forward, not - // resurrect the main one mid-install. Checked before BrowserLogin - // because an install supersedes every other flow. + // An install supersedes every other flow, so check it before BrowserLogin. if w := t.svc.WindowManager.InstallProgressWindow(); w != nil { w.Show() w.Focus() return } - // While an SSO flow is in progress the BrowserLogin popup is the focal - // window — the main window was hidden by WindowManager so the user - // stays on the sign-in surface. Tray "Open" / SIGUSR1 / dock-reopen - // should bring that window forward, not resurrect the main one mid-flow. if w := t.svc.WindowManager.BrowserLoginWindow(); w != nil { w.Show() w.Focus() @@ -325,9 +240,9 @@ func (t *Tray) ShowWindow() { if t.window == nil { return } - // Route through WindowManager so the main window is centered on its - // first show (see WindowManager.ShowMain) — minimal WMs (fluxbox, the - // XEmbed tray path) otherwise drop it in the top-left corner. + // Route through WindowManager so the main window is centered on first + // show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in + // the top-left corner. if t.svc.WindowManager != nil { t.svc.WindowManager.ShowMain() return @@ -336,42 +251,35 @@ func (t *Tray) ShowWindow() { t.window.Focus() } -// applyLanguage re-renders every translated surface using the Localizer's -// current language. Wails dispatches menu/tray APIs onto the platform's -// UI thread internally, so calling them from the Localizer's background -// goroutine is safe; profileLoadMu prevents loadProfiles from racing the -// rebuild. +// applyLanguage re-renders every translated surface in the Localizer's current +// language. Wails dispatches menu/tray APIs onto the UI thread internally, so +// calling them from the Localizer's background goroutine is safe; profileLoadMu +// prevents loadProfiles from racing the rebuild. func (t *Tray) applyLanguage() { t.tray.SetTooltip(t.loc.T("tray.tooltip")) - // Mirror the Linux label fix from NewTray — the SNI hover tooltip - // rides on the label, so refresh it on language change too. + // Mirror the Linux label fix from NewTray (the SNI tooltip rides on the + // label). if runtime.GOOS == "linux" { t.tray.SetLabel(t.loc.T("tray.tooltip")) } t.relayoutMenu() } -// relayoutMenu rebuilds the ENTIRE tray menu from scratch (buildMenu), repaints -// the cached status/session/profile/exit-node state into the fresh items, and -// pushes the whole tree with a single SetMenu. It is the only Linux path that -// reliably propagates submenu changes. +// relayoutMenu rebuilds the entire tray menu, repaints the cached +// status/session/profile/exit-node state into the fresh items, and pushes the +// whole tree with a single SetMenu. // -// Why a full rebuild rather than mutating the existing submenu in place: on -// KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first -// time it is opened (GetLayout for that submenu id) and never re-fetches it on -// a LayoutUpdated(parent=0) signal — so Clear()+Add() into the same submenu -// container left the visible menu (and, worse, the click→id mapping) frozen on -// the first snapshot: clicks sent the stale ids, which the freshly-rebuilt -// itemMap no longer knew, so they silently no-op'd. buildMenu allocates a brand -// new submenu container id every time, which Plasma treats as an unseen menu -// and re-queries on next open — both the labels and the click ids stay live. -// (Confirmed via dbus-monitor: a re-opened submenu issued no GetLayout until -// its container id changed.) The darwin detached-NSMenu workaround that the old -// per-submenu SetMenu addressed is also covered, since this rebuilds the whole -// tree against the cached top-level pointer. +// A full rebuild is required because on KDE/Plasma the StatusNotifierItem host +// caches a submenu's layout on first open (GetLayout for that submenu id) and +// never re-fetches it on a LayoutUpdated(parent=0) signal — so Clear()+Add() +// into the same container froze both the visible rows and the click→id mapping, +// and stale ids no-op'd. buildMenu allocates a fresh submenu container id each +// time, which Plasma treats as unseen and re-queries (confirmed via +// dbus-monitor). This also covers the darwin detached-NSMenu workaround, since +// it rebuilds the whole tree against the cached top-level pointer. // -// Pulls profile/exit-node rows from their caches (profilesMu / exitNodes) so it -// never re-hits the daemon and never recurses back into loadProfiles. +// Rows come from the profilesMu/exitNodes caches, so it never re-hits the +// daemon or recurses back into loadProfiles. func (t *Tray) relayoutMenu() { t.menuMu.Lock() defer t.menuMu.Unlock() @@ -418,7 +326,7 @@ func (t *Tray) relayoutMenu() { t.upItem.SetEnabled(!connected && !connecting && !daemonUnavailable) } if t.downItem != nil { - // Disconnect doubles as the abort path while still Connecting. + // Disconnect doubles as the Connecting abort path. t.downItem.SetHidden(!connected && !connecting) t.downItem.SetEnabled(connected || connecting) } @@ -437,44 +345,33 @@ func (t *Tray) relayoutMenu() { if t.updater != nil { t.updater.applyLanguage() } - // buildMenu just recreated empty Profiles + Exit Node submenus, so repaint - // both from their caches before the single SetMenu below. fillExitNodeSubmenu - // uses the entries snapshotted above; fillProfileSubmenu reads profilesMu. - // Neither re-fetches, so relayoutMenu never recurses back into - // loadProfiles/refreshExitNodes. (We must NOT re-take exitNodesRebuildMu - // here — refreshExitNodes already holds it when it calls relayoutMenu.) + // buildMenu recreated empty submenus, so repaint both from their caches + // before SetMenu. Neither fill re-fetches. Do NOT re-take + // exitNodesRebuildMu here — refreshExitNodes already holds it when it + // calls relayoutMenu. t.fillExitNodeSubmenu(exitNodeEntries) t.fillProfileSubmenu() - // Single push of the whole tree. On Linux this emits one LayoutUpdated with - // fresh submenu container ids; on darwin it rebuilds the NSMenu against the - // cached top-level pointer. + // Single push of the whole tree: on Linux one LayoutUpdated with fresh + // container ids; on darwin an NSMenu rebuild against the cached pointer. t.tray.SetMenu(t.menu) } func (t *Tray) buildMenu() *application.Menu { menu := application.NewMenu() - // statusItem shows the daemon's current status. Informational row - // with no OnClick handler — clicks are no-ops. Whether the row is - // kept enabled is platform-dependent (see statusRowEnabled): on - // Windows the disabled-state mask would desaturate the coloured - // status dot painted into the check-mark slot, so the row stays - // enabled there; macOS/Linux disable it so the greyed-out label - // signals that it is not clickable. The Connect entry below drives - // every actionable transition, including the SSO re-auth flow for - // NeedsLogin/SessionExpired (the daemon's Up RPC returns - // NeedsSSOLogin when applicable). + // Enabled state is platform-dependent (see statusRowEnabled): Windows keeps + // it enabled because the disabled mask would desaturate the coloured status + // dot; macOS/Linux disable it so the greyed label signals it isn't + // clickable. t.statusItem = menu.Add(t.loc.T("tray.status.disconnected")). SetEnabled(statusRowEnabled()). SetBitmap(iconMenuDotIdle) menu.AddSeparator() - // Only the action that applies to the current state is visible: Connect - // when disconnected, Disconnect when connected. The OnClick closures - // capture the local item — t.upItem/t.downItem are menuMu-guarded and - // must not be read from the click goroutine. + // The OnClick closures capture the local item because t.upItem/t.downItem + // are menuMu-guarded and must not be read from the click goroutine. upItem := menu.Add(t.loc.T("tray.menu.connect")) upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) }) t.upItem = upItem @@ -485,60 +382,32 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() - // Profiles submenu is populated asynchronously once the application - // has started — Menu.Update() is a no-op before app.running is true, - // so the initial fill is gated on the ApplicationStarted hook. + // Populated asynchronously once the app has started — Menu.Update() is a + // no-op before app.running is true, so the initial fill is gated on the + // ApplicationStarted hook. profilesLabel := t.loc.T("tray.menu.profiles") t.profileSubmenu = menu.AddSubmenu(profilesLabel) - // profileSubmenuItem is the parent MenuItem whose label is the active - // profile name. AddSubmenu returns the child *Menu, so we retrieve the - // parent *MenuItem via FindByLabel immediately after insertion. + // AddSubmenu returns the child *Menu, so retrieve the parent *MenuItem via + // FindByLabel. t.profileSubmenuItem = menu.FindByLabel(profilesLabel) - // profileEmailItem shows the account email of the active profile directly - // in the main menu, below the Profiles submenu — matching the behaviour of - // the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a - // non-empty email for the active profile. t.profileEmailItem = menu.Add("").SetEnabled(false) t.profileEmailItem.SetHidden(true) - // sessionExpiresItem sits below the profile email so the active profile, - // its account email, and the SSO session deadline read as a single block. - // 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. Click opens the SessionExpiration - // window so the user can extend the session ahead of the daemon's - // T-FinalWarningLead auto-prompt. + // Click opens the SessionExpiration window so the user can extend ahead of + // the daemon's T-FinalWarningLead auto-prompt. t.sessionExpiresItem = menu.Add("").OnClick(func(*application.Context) { t.openSessionExtendFlow() }) 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 - // menu entry on every platform. - // - // Accelerators are wired on the Settings and Quit entries below. - // Cross-platform behaviour in Wails v3 alpha.95: - // - macOS: SetAccelerator calls NSMenuItem.setKeyEquivalent — the - // glyph row paints to the right of the label and the combo fires - // when the menu is open OR while the app is the frontmost app. - // - Linux (GTK): SetAccelerator binds the GTK accel — the combo - // fires while the menu is open and the label paints the row. On - // XEmbed/AppIndicator hosts the visual hint may not render but - // activation through the keyboard still resolves. - // - Windows: SetAccelerator is a no-op in alpha.95 (the impl is - // commented out in menuitem_windows.go), so the row is plain - // text. We still call it for forward compatibility — a future - // Wails release picks the labels up without churn here. + // Accelerators on the Settings/Quit entries below are a no-op on Windows in + // Wails v3 alpha.95 (impl commented out in menuitem_windows.go); still set + // for forward compatibility. macOS/GTK render and fire them. menu.Add(t.loc.T("tray.menu.open")).OnClick(func(*application.Context) { t.ShowWindow() }) menu.AddSeparator() - // exitNodeSubmenu hosts one row per peer advertising a default - // route (0.0.0.0/0 or ::/0). Populated asynchronously by - // refreshExitNodes (via relayoutMenu) on every Status push that changes the set; - // the parent row stays disabled until at least one candidate is - // known. We grab the parent MenuItem via FindByLabel (same - // pattern as the Profiles submenu) so applyStatus can flip its - // enabled state independently of the children. + // exitNodeSubmenu hosts one row per peer advertising a default route + // (0.0.0.0/0 or ::/0). FindByLabel grabs the parent so applyStatus can flip + // its enabled state independently of the children. exitNodeLabel := t.loc.T("tray.menu.exitNode") t.exitNodeSubmenu = menu.AddSubmenu(exitNodeLabel) t.exitNodeItem = menu.FindByLabel(exitNodeLabel) @@ -546,12 +415,8 @@ func (t *Tray) buildMenu() *application.Menu { menu.AddSeparator() - // Settings, runtime toggles (SSH, Quantum-Resistance, lazy connection, - // block-inbound, auto-connect, notifications) and profile switching - // all live in the in-window Settings page now. The tray menu only - // surfaces the day-to-day actions. The trailing ellipsis on the label - // (i18n string) follows the macOS HIG convention for menu items that - // open a dialog/window rather than performing an inline action. + // The label's trailing ellipsis follows the macOS HIG convention for items + // that open a window. t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")). SetAccelerator("CmdOrCtrl+,"). OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") }) @@ -564,22 +429,14 @@ func (t *Tray) buildMenu() *application.Menu { about.Add(t.loc.T("tray.menu.documentation")).OnClick(func(*application.Context) { _ = t.app.Browser.OpenURL(urlDocs) }) - // Troubleshoot deep-links into the Settings window at the - // Troubleshooting tab, which hosts the debug-bundle flow that used - // to live as a top-level tray entry. about.Add(t.loc.T("tray.menu.troubleshoot")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("troubleshooting") }) about.AddSeparator() - // Disabled informational entries: the GUI version is baked in at - // build time via -ldflags, the daemon version comes from the first - // Status snapshot and is updated in applyStatus. about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false) t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false) - // Update menu item is hidden until the daemon reports a new version - // (EventUpdateState with Available=true). trayUpdater rewrites the - // label between tray.menu.downloadLatest (opt-in) and - // tray.menu.installVersion (enforced) and drives the click. + // trayUpdater rewrites the label between downloadLatest (opt-in) and + // installVersion (enforced) and drives the click. updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")). OnClick(func(*application.Context) { t.updater.handleClick() }) updateItem.SetHidden(true) @@ -596,13 +453,10 @@ func (t *Tray) buildMenu() *application.Menu { // handleConnect receives the clicked item from the buildMenu closure — // t.upItem is menuMu-guarded and must not be read here. func (t *Tray) handleConnect(upItem *application.MenuItem) { - // NeedsLogin/SessionExpired/LoginFailed mean the daemon won't honor a - // plain Up RPC ("up already in progress: current status NeedsLogin") — - // it needs the Login → WaitSSOLogin → Up sequence instead. Emit - // EventTriggerLogin so the React-side startLogin() (which owns the - // BrowserLogin popup) drives the flow. The main window's webview is - // alive even while hidden, so we don't surface it — only the popup - // appears. + // NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they + // need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so + // the React startLogin() (which owns the BrowserLogin popup) drives it; + // the hidden main webview is alive and subscribed, so only the popup shows. t.statusMu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || @@ -614,12 +468,9 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) { } 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. + // NeedsLogin on an SSO peer with no cached token. applyStatus consumes the + // flag on that transition to trigger browser-login without a second Connect + // click, and clears it on any terminal state. t.statusMu.Lock() t.pendingConnectLogin = true t.statusMu.Unlock() @@ -635,12 +486,10 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) { }() } -// handleDisconnect aborts any in-flight profile switch before sending -// Down — otherwise the switcher's queued Up would re-establish the -// connection right after the Disconnect, making the click look like a -// no-op. Also clears Peers' optimistic-Connecting guard so the daemon's -// Idle push (and any subsequent updates) paint through immediately -// instead of being swallowed by the profile-switch suppression filter. +// handleDisconnect aborts any in-flight profile switch before sending Down — +// otherwise the switcher's queued Up would reconnect right after, making the +// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's +// Idle push paints through instead of being swallowed by the suppression filter. // Receives the clicked item from the buildMenu closure (see handleConnect). func (t *Tray) handleDisconnect(downItem *application.MenuItem) { downItem.SetEnabled(false) diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go index 86e17e9cf..91c3598c8 100644 --- a/client/ui/tray_click_windows.go +++ b/client/ui/tray_click_windows.go @@ -4,24 +4,10 @@ package main // bindTrayClick wires the tray icon's click handlers on Windows. // -// Unlike macOS (NSStatusItem auto-shows the menu on left-click) and Linux -// (the StatusNotifierItem host paints the menu itself), Wails v3's Windows -// systray installs a default left-click handler that only logs "Left Button -// Clicked" and does nothing visible — only right-click opens the menu via the -// default rightClickHandler (see systemtray_windows.go run()). Left-clicking -// the icon therefore appears dead to the user. We bind OnClick to OpenMenu so -// left- and right-click behave identically, matching the platform-native -// click→menu behaviour we get for free on macOS/Linux. -// -// On top of that, a double-click opens the main window — the Windows-native -// convention for tray apps (the Wails systray dispatches WM_LBUTTONDBLCLK to -// the doubleClickHandler; see systemtray_windows.go). ShowWindow() routes the -// SSO/install-progress edge cases correctly and calls SetForegroundWindow. -// -// The macOS freeze that motivated reverting an earlier OnClick→OpenMenu wiring -// (commit c77e5cef8: NSStatusItem's blocking [button mouseDown:] starves the -// serial main GCD queue) does not apply here — the Windows openMenu() path is -// the same menu.ShowAt() that right-click already uses without issue. +// Wails v3's Windows systray leaves left-click a no-op (only right-click opens +// the menu), so bind OnClick to OpenMenu to match macOS/Linux. The macOS freeze +// that blocks OnClick→OpenMenu doesn't apply here: openMenu() is the same +// menu.ShowAt() path right-click already uses. func bindTrayClick(t *Tray) { t.tray.OnClick(func() { t.tray.OpenMenu() }) t.tray.OnDoubleClick(func() { t.ShowWindow() }) diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 7dad9cb0d..62f696886 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -14,35 +14,20 @@ import ( "github.com/netbirdio/netbird/client/ui/services" ) -// onSystemEvent fires an OS notification for daemon SystemEvents that carry -// a user-facing message, mirroring the legacy event.Manager behaviour: gated -// by the user's "Notifications" toggle, with CRITICAL events bypassing the -// gate. Update-related events are skipped here because trayUpdater produces -// its own richer notification when EventUpdateState fires. +// onSystemEvent fires an OS notification for daemon SystemEvents that carry a +// user-facing message. Gated by the "Notifications" toggle; critical events bypass it. func (t *Tray) onSystemEvent(ev *application.CustomEvent) { se, ok := ev.Data.(services.SystemEvent) if !ok { return } - // config_changed: the daemon re-applied its effective config (engine - // spawn, Up, or MDM policy diff) and signals the UI to re-sync. It - // carries no UserMessage, so it must be handled before the user-facing - // message gate below. Re-fetch the feature kill switches (DisableProfiles - // / DisableNetworks) and the notifications gate so CLI- or MDM-driven - // changes reflect in the tray without a periodic poll. This replaces the - // legacy Fyne UI's 2s GetFeatures poll. + // config_changed carries no UserMessage, so handle it before the message gate below. if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged { log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey]) go t.refreshRestrictions() go t.loadConfig() - // An MDM-driven config change gets a user-facing toast so the - // operator knows their IT policy was applied. The daemon also - // emits a separate "policy_applied" event carrying an English - // UserMessage, but that text has no locale context — it's - // suppressed in shouldSkipSystemEvent and the tray builds the - // localised toast here instead. Other sources (startup, up_rpc) - // stay silent, matching the daemon's empty-UserMessage intent. - // Gated by the notifications toggle like every other INFO event. + // MDM gets a localised toast here; the daemon's English "policy_applied" + // event is suppressed in shouldSkipSystemEvent. Other sources stay silent. if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM { t.profileMu.Lock() enabled := t.notificationsEnabled @@ -57,9 +42,8 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { } return } - // Session-warning and deadline-rejected 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. + // Session-warning and deadline-rejected events build their body locally from + // metadata; every other event needs a UserMessage. isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != "" if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" { @@ -77,17 +61,10 @@ 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 - // SessionExpiration dialog. No OS notification here; the - // dialog is the last-chance reminder, doubling up would be noise. + // Session-warning events route via stable metadata flags rather than + // category/severity so a daemon-side reword still lands here. Final warning + // auto-opens the SessionExpiration dialog with no notification (the dialog is + // the last-chance reminder; doubling up would be noise). if isDeadlineRejected { t.notify( t.loc.T("notify.sessionDeadlineRejected.title"), @@ -116,9 +93,7 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { t.notify(eventTitle(se), body, notifyIDEvent+se.ID) } -// eventTitle composes a notification title from a SystemEvent's severity and -// category — "Critical: DNS", "Warning: Authentication", etc. — matching the -// format the legacy Fyne event.Manager produced. +// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". func eventTitle(e services.SystemEvent) string { prefix := titleCase(e.Severity) if prefix == "" { @@ -138,19 +113,14 @@ func titleCase(s string) string { return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) } -// shouldSkipSystemEvent reports whether a daemon SystemEvent must not -// surface as a tray notification. Three sources are filtered out: -// - update-available announcements (trayUpdater emits its own richer -// notification when EventUpdateState fires) +// shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as +// a tray notification: +// - update-available announcements (trayUpdater emits its own) // - install-progress signals (consumed by the install-progress window) -// - the ::/0 partner of an exit-node default-route event (the 0.0.0.0/0 -// partner already drove the user-facing toast, so the v6 row is -// suppressed to avoid a duplicate notification) +// - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted) func shouldSkipSystemEvent(se services.SystemEvent) bool { - // The daemon's MDM "policy_applied" event carries a hardcoded English - // UserMessage. The tray shows its own localised toast on the paired - // config_changed (source=mdm) event instead, so drop this one to avoid - // a duplicate, non-localised notification. + // "policy_applied" carries a hardcoded English message; the localised toast + // fires on the paired config_changed (source=mdm) event instead. if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied { return true } diff --git a/client/ui/tray_exitnodes.go b/client/ui/tray_exitnodes.go index 0b87fb36e..3e6f30842 100644 --- a/client/ui/tray_exitnodes.go +++ b/client/ui/tray_exitnodes.go @@ -15,24 +15,15 @@ import ( "github.com/netbirdio/netbird/client/ui/services" ) -// exitNodeEntry is one selectable row in the Exit Node submenu. ID is the -// network's NetID — both the row label and the argument the Select/Deselect -// RPCs take; Selected drives the ✓ prefix. +// exitNodeEntry is one Exit Node submenu row; ID is the network's NetID, the Select/Deselect argument. type exitNodeEntry struct { ID string Selected bool } -// fillExitNodeSubmenu paints one clickable row per exit-node candidate into -// the (freshly built) Exit Node submenu. Each row carries the network's NetID -// and its selected state from ListNetworks; clicking toggles it via -// toggleExitNode. The active node is marked with a "✓ " prefix using a plain -// Add rather than AddCheckbox for the same reason as fillProfileSubmenu — -// Wails auto-toggles a checkbox's state on click before the OnClick handler -// runs, so the deselect/select round-trip would briefly show two checked rows. -// Pure UI: it never calls SetMenu — relayoutMenu owns the single SetMenu that -// pushes the whole tree. Callers must hold exitNodesRebuildMu so concurrent -// rebuilds can't race the submenu's item slice. +// fillExitNodeSubmenu uses a "✓ " prefix with plain Add, not AddCheckbox: Wails +// auto-toggles a checkbox on click before OnClick runs, so the deselect/select +// round-trip would briefly show two checked rows. Callers must hold exitNodesRebuildMu. func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) { if t.exitNodeSubmenu == nil { return @@ -51,13 +42,9 @@ func (t *Tray) fillExitNodeSubmenu(nodes []exitNodeEntry) { } } -// refreshExitNodes re-fetches the routed-network list from the daemon and -// repaints the Exit Node submenu. Sourcing the rows from Networks.List() (not -// the Status stream) is what makes them selectable: the stream only ships peer -// FQDNs, whereas ListNetworks returns the NetID + selected state the -// Select/Deselect RPCs need. Serialized by exitNodesRebuildMu so overlapping -// Status pushes can't race the submenu rebuild. Owns the parent item's -// enablement: greyed unless the tunnel is up and at least one candidate exists. +// refreshExitNodes sources rows from Networks.List() rather than the Status stream +// because only ListNetworks carries the NetID + selected state Select/Deselect need. +// Serialized by exitNodesRebuildMu against overlapping Status pushes. func (t *Tray) refreshExitNodes() { t.exitNodesRebuildMu.Lock() defer t.exitNodesRebuildMu.Unlock() @@ -88,22 +75,15 @@ func (t *Tray) refreshExitNodes() { t.exitNodes = nodes t.exitNodesMu.Unlock() - // relayoutMenu rebuilds the whole tree (allocating a fresh exitNodeItem) and - // repaints the parent's enablement from the cached entries we just stored, - // so there is no need to poke the old exitNodeItem here. + // relayoutMenu repaints from the cached entries, so the old exitNodeItem needs no poking here. if changed { t.relayoutMenu() } } -// toggleExitNode activates or deactivates one exit node by NetID. Exit nodes -// are mutually exclusive, but enforcement of that lives daemon-side: the -// SelectNetworks handler deselects every other exit node when this Select -// activates one. So Select uses append=true — append=false would tell the -// RouteSelector to drop the whole current selection (default-on semantics), -// which also turns off every non-exit routed network the user had enabled. -// Mirrors the frontend's toggleExitNode semantics. Runs the RPC off the -// menu-click goroutine and re-fetches so the ✓ moves to the new selection. +// toggleExitNode uses append=true: append=false would drop the whole current +// selection (default-on semantics), turning off every other routed network the +// user had enabled. Mutual exclusion of exit nodes is enforced daemon-side. func (t *Tray) toggleExitNode(id string, selected bool) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -125,9 +105,7 @@ func (t *Tray) toggleExitNode(id string, selected bool) { }() } -// exitNodesFromNetworks filters the daemon's routed-network list down to -// exit-node candidates (a default-route range) and maps them to selectable -// rows. Sorted case-insensitively by ID so the submenu reads alphabetically. +// exitNodesFromNetworks keeps only networks whose range is a default route: those are the exit-node candidates. func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { out := []exitNodeEntry{} for _, n := range networks { @@ -142,10 +120,8 @@ func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry { return out } -// rangeIsDefaultRoute reports whether a Network.Range string contains an IPv4 -// or IPv6 default route. The daemon may merge a v4+v6 exit pair into a single -// comma-joined range ("0.0.0.0/0, ::/0"), so we split and check each part, -// matching by Bits()==0 && unspecified rather than a literal string compare. +// rangeIsDefaultRoute reports whether r contains a default route. The daemon may +// comma-join a v4+v6 pair ("0.0.0.0/0, ::/0"), so each part is parsed rather than string-compared. func rangeIsDefaultRoute(r string) bool { for _, part := range strings.Split(r, ",") { pref, err := netip.ParsePrefix(strings.TrimSpace(part)) diff --git a/client/ui/tray_features.go b/client/ui/tray_features.go index f2df4a5ff..2ff99e2be 100644 --- a/client/ui/tray_features.go +++ b/client/ui/tray_features.go @@ -8,12 +8,9 @@ import ( log "github.com/sirupsen/logrus" ) -// refreshRestrictions pulls the daemon's operator-disabled UI surfaces -// (DisableProfiles / DisableNetworks) and re-applies the tray menu gating. -// Called once at startup (ApplicationStarted) and on every config_changed -// system event — the daemon re-applies its MDM policy on each engine spawn -// and emits that event, so this is the tray's signal to re-sync the kill -// switches. +// refreshRestrictions re-reads the operator-disabled UI flags and re-gates the +// menu. Must run on every config_changed event: the daemon re-applies its MDM +// policy on each engine spawn. func (t *Tray) refreshRestrictions() { r, err := t.svc.Settings.GetRestrictions(context.Background()) if err != nil { @@ -26,20 +23,13 @@ func (t *Tray) refreshRestrictions() { t.disableProfiles = r.Features.DisableProfiles t.disableNetworks = r.Features.DisableNetworks t.featureMu.Unlock() - // Repaint only when a flag actually flipped: relayoutMenu rebuilds the - // whole menu tree, so a no-op refresh (the common case) must not churn - // it. relayoutMenu and fillProfileSubmenu read the cached flags via - // featuresDisabled, so the new state applies regardless of which relayout - // (this one, a status push, or a profile reload) runs last. + // relayoutMenu rebuilds the whole tree, so skip the no-op refresh (common case). if changed { t.relayoutMenu() } } -// featuresDisabled returns the cached DisableProfiles / DisableNetworks kill -// switches under featureMu. Read by relayoutMenu, refreshMenuItemsForStatus, -// and fillProfileSubmenu to grey out the Profiles and Exit Node menus when -// the operator (or an MDM policy) disabled those surfaces server-side. +// featuresDisabled returns the cached flags under featureMu. func (t *Tray) featuresDisabled() (profiles, networks bool) { t.featureMu.Lock() defer t.featureMu.Unlock() diff --git a/client/ui/tray_icon.go b/client/ui/tray_icon.go index cc893e2d1..70344b2ce 100644 --- a/client/ui/tray_icon.go +++ b/client/ui/tray_icon.go @@ -30,11 +30,9 @@ func (t *Tray) applyIcon() { return } if runtime.GOOS == "linux" { - // Wails' Linux SNI backend ignores SetDarkModeIcon (its - // setDarkModeIcon just calls setIcon, last-write-wins), so we pick - // the black-vs-white silhouette ourselves in iconForState based on - // the panel theme and push a single SetIcon. Calling - // SetDarkModeIcon here would only clobber that choice. + // Wails' Linux SNI backend ignores SetDarkModeIcon (last write wins + // over SetIcon), so iconForState already picked the silhouette by + // panel theme; push that single icon. t.tray.SetIcon(icon) return } @@ -44,10 +42,8 @@ func (t *Tray) applyIcon() { } } -// panelIsDark reports whether the desktop panel uses a dark colour scheme, so -// the Linux branch of iconForState can choose the white silhouette. Defaults -// to true when no detector is wired (panelDark nil — non-Linux, or the -// freedesktop portal was unavailable), matching the common dark Linux panel. +// panelIsDark defaults to true when no detector is wired (panelDark nil — +// non-Linux or portal unavailable), matching the common dark Linux panel. func (t *Tray) panelIsDark() bool { if t.panelDark == nil { return true @@ -92,11 +88,9 @@ func (t *Tray) iconForState() (icon, dark []byte) { } if runtime.GOOS == "linux" { - // Linux: monochrome silhouette chosen by panel theme. Wails' SNI - // backend can't switch icons per theme itself (see applyIcon), so we - // resolve black (light panel) vs white (dark panel) here and the - // caller pushes a single SetIcon. The second return is unused on - // Linux. + // Theme resolved here (black for light panel, white for dark) since + // the SNI backend can't switch per theme (see applyIcon); second + // return is unused on Linux. dark := t.panelIsDark() pick := func(black, white []byte) ([]byte, []byte) { if dark { diff --git a/client/ui/tray_label_other.go b/client/ui/tray_label_other.go index 7274fbb69..e104bc8e8 100644 --- a/client/ui/tray_label_other.go +++ b/client/ui/tray_label_other.go @@ -2,8 +2,6 @@ package main -// menuLabel is the identity on macOS and Linux — both render an ampersand -// literally in tray-menu labels, so no escaping is needed. Windows opts in via -// the sibling tray_label_windows.go file, where a lone "&" would otherwise be -// swallowed as a Win32 mnemonic prefix. +// menuLabel is the identity on macOS/Linux, which render "&" literally; +// Windows escapes it separately (tray_label_windows.go) to dodge the Win32 mnemonic. func menuLabel(s string) string { return s } diff --git a/client/ui/tray_label_windows.go b/client/ui/tray_label_windows.go index f0e575820..8c27ae9ea 100644 --- a/client/ui/tray_label_windows.go +++ b/client/ui/tray_label_windows.go @@ -4,13 +4,8 @@ package main import "strings" -// menuLabel escapes a tray-menu label for Win32. A single ampersand in an -// MFT_STRING menu item is consumed as the mnemonic (accelerator) prefix and -// never painted — so "Help & Support" renders as "Help Support". Doubling it -// to "&&" tells Win32 to draw a literal ampersand. Wails v3 passes the label -// straight to InsertMenuItem without escaping (see menuitem_windows.go), so we -// do it here. macOS/Linux render "&" literally and use the identity helper in -// the sibling tray_label_other.go. +// menuLabel doubles ampersands so Win32 draws a literal "&" instead of +// consuming it as the menu mnemonic prefix (Wails passes the label unescaped). func menuLabel(s string) string { return strings.ReplaceAll(s, "&", "&&") } diff --git a/client/ui/tray_linux.go b/client/ui/tray_linux.go index 042b40e1b..1d6e0a48b 100644 --- a/client/ui/tray_linux.go +++ b/client/ui/tray_linux.go @@ -19,40 +19,25 @@ func disableDMABUFRenderer() { return } - // WebKitGTK's DMA-BUF renderer fails on many setups (VMs, containers, - // minimal WMs without proper GPU access) and leaves the window blank - // white. Wails only disables it for NVIDIA+Wayland, but the issue is - // broader. Always disable it — software rendering works fine for a - // small UI like this. + // WebKitGTK's DMA-BUF renderer leaves a blank-white window on many setups + // (VMs, containers, minimal WMs). Wails only disables it for NVIDIA+Wayland, + // but the issue is broader; software rendering is fine for a small UI. _ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") } -// disableCompositingMode turns off WebKitGTK's accelerated (GL) compositing -// path. Disabling the DMA-BUF renderer alone is not enough on some Intel -// setups: WebKitGTK 2.52 still drives the GPU through the GL compositor, and -// Mesa's anv/i965 hits unimplemented DRM-format-modifier code paths -// ("FINISHME: support YUV colorspace with DRM format modifiers" / -// "...multi-planar formats...") that crash with a SIGSEGV inside -// g_application_run before the first frame paints. Forcing compositing off -// makes WebKit render on the CPU, which is fine for a small UI like this and -// sidesteps the broken modifier path. The user can re-enable it by setting -// WEBKIT_DISABLE_COMPOSITING_MODE themselves (e.g. to "0"). func disableCompositingMode() { if os.Getenv("WEBKIT_DISABLE_COMPOSITING_MODE") != "" { return } + // Disabling the DMA-BUF renderer alone isn't enough on some Intel setups: the + // GL compositor still hits Mesa's unimplemented DRM-format-modifier paths and + // SIGSEGVs inside g_application_run before the first frame. _ = os.Setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1") } // disableWebKitSandboxIfNeeded works around WebKitGTK crashing at startup when -// its bubblewrap (bwrap) sandbox can't create an unprivileged user namespace — -// "bwrap: setting up uid map: Permission denied" followed by "Failed to fully -// launch dbus-proxy" and a panic in webkit_web_view_load_uri. This happens in -// containers/VMs and on Ubuntu 24.04+ where AppArmor restricts unprivileged -// user namespaces (kernel.apparmor_restrict_unprivileged_userns=1). Software -// can't grant the namespace from here, so when we detect that userns are -// blocked we disable the WebKit sandbox to keep the UI usable. The user can -// override either way by setting WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS. +// its bwrap sandbox can't create an unprivileged user namespace (containers/VMs, +// or Ubuntu 24.04+ AppArmor restrictions). func disableWebKitSandboxIfNeeded() { if _, set := os.LookupEnv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"); set { return @@ -63,11 +48,9 @@ func disableWebKitSandboxIfNeeded() { _ = os.Setenv("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1") } -// unprivilegedUsernsAllowed reports whether the kernel currently permits -// unprivileged user namespaces, which WebKit's bwrap sandbox needs. It reads -// the relevant procfs knobs; on a kernel that doesn't expose them (older or -// hardened), it conservatively assumes namespaces are available so we don't -// needlessly weaken the sandbox. +// unprivilegedUsernsAllowed reports whether the kernel permits unprivileged +// user namespaces (needed by WebKit's bwrap sandbox). Absent knobs are treated +// as allowed, to avoid needlessly weakening the sandbox. func unprivilegedUsernsAllowed() bool { // Debian/Ubuntu legacy switch: 0 disables unprivileged user namespaces. if v, err := os.ReadFile("/proc/sys/kernel/unprivileged_userns_clone"); err == nil { @@ -84,7 +67,5 @@ func unprivilegedUsernsAllowed() bool { return true } -// On Linux, the system tray provider may require the menu to be recreated -// rather than updated in place. The rebuildExitNodeMenu method in tray.go -// already handles this by removing and re-adding items; no additional -// Linux-specific workaround is needed for Wails v3. +// Linux's tray provider needs the menu recreated rather than updated in place; +// tray.go's rebuildExitNodeMenu already does this, so no extra workaround here. diff --git a/client/ui/tray_notify.go b/client/ui/tray_notify.go index 1a4a030b6..e3a54b862 100644 --- a/client/ui/tray_notify.go +++ b/client/ui/tray_notify.go @@ -7,23 +7,17 @@ import ( "github.com/wailsapp/wails/v3/pkg/services/notifications" ) -// sendFn is either NotificationService.SendNotification or -// SendNotificationWithActions — both share the same signature. +// sendFn fits both NotificationService.SendNotification and SendNotificationWithActions. type sendFn func(notifications.NotificationOptions) error -// safeSendNotification dispatches an OS notification, swallowing both errors -// and panics. OS toasts are best-effort: a missing or broken session bus must -// never crash the app. +// safeSendNotification sends a best-effort OS notification, swallowing errors and panics. // -// The panic guard is load-bearing on Linux. Wails' notifier connects to the -// session bus in its ServiceStartup; when that connect fails (headless box, -// no/unreachable DBUS_SESSION_BUS_ADDRESS, a UI launched outside a desktop -// session), Wails logs the error but leaves the service registered with a nil -// *dbus.Conn. The next SendNotification then nil-derefs deep inside -// godbus (Conn.getSerial) and, because the send runs on a Wails event-dispatch -// goroutine, the panic is fatal to the whole process rather than to one event -// listener. recover() turns that into a logged no-op. See -// notifications_linux.go in wails v3. +// The panic guard is load-bearing on Linux: when Wails' notifier fails to +// connect the session bus at startup (headless, unreachable +// DBUS_SESSION_BUS_ADDRESS) it stays registered with a nil *dbus.Conn, so the +// next send nil-derefs inside godbus. Because sends run on a Wails +// event-dispatch goroutine that panic is fatal process-wide; recover() turns +// it into a logged no-op. func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) { defer func() { if r := recover(); r != nil { diff --git a/client/ui/tray_profiles.go b/client/ui/tray_profiles.go index ddaaa39b7..5b6639d7b 100644 --- a/client/ui/tray_profiles.go +++ b/client/ui/tray_profiles.go @@ -13,15 +13,8 @@ import ( "github.com/netbirdio/netbird/client/ui/services" ) -// loadConfig seeds the in-process notifications gate from the daemon's -// stored config and caches the active-profile identity for any future -// SetConfig calls. Called once at startup from a goroutine so a slow or -// unreachable daemon does not block menu construction. -// -// The Settings page in the main window is the source of truth for every -// other knob (SSH, auto-connect, Rosenpass, lazy connections, block-inbound, -// notifications); we only mirror the notifications flag because the tray -// itself uses it to gate OS toasts in onSystemEvent. +// loadConfig caches the active-profile identity and the notifications gate. +// Runs in a startup goroutine so a slow daemon does not block menu construction. func (t *Tray) loadConfig() { ctx := context.Background() @@ -43,16 +36,10 @@ func (t *Tray) loadConfig() { t.profileMu.Unlock() } -// loadProfiles fetches the profile list from the daemon, caches it under -// profilesMu, and drives a full tray relayout (relayoutMenu) so the Profiles -// submenu repaints. Called on ApplicationStarted, after a successful -// switchProfile, and from applyStatus whenever the daemon's status text -// changes — the last case catches profile flips driven by another channel -// (CLI "netbird profile select", autoconnect picking the persisted profile -// after the UI's first ListProfiles, etc.) since the daemon does not emit a -// dedicated active-profile event. The relayout (rather than a Clear()+Add() -// into the live submenu) is what makes KDE/Plasma actually repaint and keep -// the click→id mapping live — see relayoutMenu's doc comment. +// loadProfiles fetches the profile list and relayouts the menu. Also called +// from applyStatus to catch flips from another channel (CLI, autoconnect), +// since the daemon emits no active-profile event. Full relayout (not +// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment. func (t *Tray) loadProfiles() { t.profileLoadMu.Lock() defer t.profileLoadMu.Unlock() @@ -77,11 +64,8 @@ func (t *Tray) loadProfiles() { t.relayoutMenu() } -// fillProfileSubmenu paints the cached profile rows into the (freshly built) -// Profiles submenu and updates the parent label + email row. Pure UI: it -// never fetches and never calls SetMenu — relayoutMenu owns the single -// SetMenu that pushes the whole tree. Reads the rows captured by loadProfiles -// under profilesMu. +// fillProfileSubmenu paints cached profile rows into the freshly built submenu. +// Pure UI: never fetches, never calls SetMenu (relayoutMenu owns the SetMenu). func (t *Tray) fillProfileSubmenu() { if t.profileSubmenu == nil { return @@ -93,11 +77,8 @@ func (t *Tray) fillProfileSubmenu() { sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) - // When the daemon (or an MDM policy) disables profiles, the parent menu - // is greyed out by relayoutMenu/refreshMenuItemsForStatus, but Wails' - // systray does not reliably propagate a disabled parent to its children - // on every platform — so disable each row and "Manage Profiles" too, - // mirroring the legacy Fyne UI's profile.setEnabled lock. + // Wails' systray does not reliably propagate a disabled parent to its + // children on every platform, so disable each row explicitly. disableProfiles, _ := t.featuresDisabled() t.profileSubmenu.Clear() @@ -105,11 +86,9 @@ func (t *Tray) fillProfileSubmenu() { for _, p := range profiles { name := p.Name active := p.IsActive - // Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's - // checked state on click (before the OnClick handler fires), so with - // AddCheckbox both the old and the new profile would briefly show as - // checked while the switchProfile goroutine is running. A plain item - // with a "✓ " prefix avoids the race entirely. + // Add, not AddCheckbox: Wails auto-toggles a checkbox on click before + // OnClick fires, so both old and new would briefly show checked during + // the switch. A plain item with a "✓ " prefix avoids the race. label := name if active { label = "✓ " + name @@ -148,17 +127,9 @@ func (t *Tray) fillProfileSubmenu() { } } -// switchProfile cancels any in-flight profile switch, then starts a new one. -// Cancelling the previous context aborts its in-flight gRPC calls (Down/Up) -// so rapid clicks always converge to the last selected profile. -// -// The optimistic Connecting paint (and suppression of the transient -// Idle/stale Connected daemon events that follow Down) lives in -// services/daemon_feed.go — ProfileSwitcher calls DaemonFeed.BeginProfileSwitch -// when the previous status was Connected/Connecting, which emits a -// synthetic Connecting status to the event bus and starts filtering -// the daemon stream. That way both this tray and the React Status -// page see the same optimistic state without duplicating policy. +// switchProfile cancels any in-flight switch before starting a new one, so +// rapid clicks converge to the last selected profile. Optimistic paint and +// event suppression live in ProfileSwitcher, shared with the React Status page. func (t *Tray) switchProfile(name string) { t.profileMu.Lock() if t.switchCancel != nil { diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 671cea5af..6b73ddb49 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -19,28 +19,15 @@ const ( notifyIDSessionExpired = "netbird-session-expired" notifyIDSessionWarning = "netbird-session-warning" - // notifyCategorySessionWarning groups the "Extend now" / "Dismiss" - // actions on the T-10min OS notification. Registered once at tray - // construction with the Wails notifications service; subsequent - // SendNotificationWithActions calls reference it by ID. notifyCategorySessionWarning = "netbird-session-warning" notifyActionExtendNow = "extend-now" notifyActionDismiss = "dismiss" - // finalWarningCountdownSeconds is the countdown shown in the auto-opened - // SessionExpiration dialog. Mirrors sessionwatch.FinalWarningLead - // (2 minutes); the values stay in sync by hand because the lead is fixed - // for the initial rollout. + // finalWarningCountdownSeconds must stay in sync by hand with sessionwatch.FinalWarningLead. finalWarningCountdownSeconds = 120 ) -// handleSessionExpired surfaces the SSO re-authentication path when the -// daemon reports StatusSessionExpired. Posts a single OS notification -// (the applyStatus guard ensures it fires only on the transition, not -// on every status snapshot) and brings the main window forward so the -// frontend's /login route can drive the renewed SSO flow. Mirrors the -// Fyne client's onSessionExpire, which used a runSelfCommand to spawn -// the login-url helper; here the window is already in-process. +// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal. func (t *Tray) handleSessionExpired() { t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) if t.window != nil { @@ -50,9 +37,8 @@ func (t *Tray) handleSessionExpired() { } } -// applySessionExpiry refreshes the cached SSO deadline and reports whether -// it changed. Cache-only — the tray row is painted by relayoutMenu; -// applyStatus drives a relayout when this returns true. +// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed. +// Cache-only; the caller relayouts when this returns true. func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { var d time.Time if connected && deadline != nil { @@ -78,9 +64,7 @@ func (t *Tray) applySessionExpiry(deadline *time.Time, connected bool) bool { return changed } -// runSessionExpiryTicker keeps the "Expires in …" countdown row fresh by -// recomputing its label every 30 seconds for the app's lifetime. Started -// once on ApplicationStarted; the goroutine lives until the process exits. +// runSessionExpiryTicker recomputes the "Expires in …" row label every 30s. Runs until process exit. func (t *Tray) runSessionExpiryTicker() { tk := time.NewTicker(30 * time.Second) for range tk.C { @@ -88,10 +72,8 @@ func (t *Tray) runSessionExpiryTicker() { } } -// refreshSessionExpiresLabel recomputes the countdown row label from the -// cached SSO deadline. The item is snapshotted under menuMu (buildMenu -// reassigns it on every relayout); no full relayout here — a 30s-cadence -// rebuild could disturb an open menu. +// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu. +// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout. func (t *Tray) refreshSessionExpiresLabel() { t.menuMu.Lock() item := t.sessionExpiresItem @@ -109,14 +91,8 @@ func (t *Tray) refreshSessionExpiresLabel() { item.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining)) } -// formatSessionRemaining renders the time-to-deadline as a localised -// long-form string ("47 minutes", "2 hours", "1 day"). Picks the -// largest unit that fits non-zero and keeps singular/plural distinct -// — the unit name keys (`tray.session.unit.minute(s)|hour(s)|day(s)`) -// are split per language so translators can spell each form properly. -// Sub-minute deltas read as "less than a minute" so a countdown that -// has rolled past zero between Status pushes still produces something -// sensible. +// formatSessionRemaining renders d as a localised long-form string picking the largest non-zero unit. +// Singular/plural keys are split per language for proper translation. func (t *Tray) formatSessionRemaining(d time.Duration) string { switch { case d < time.Minute: @@ -142,12 +118,8 @@ func (t *Tray) formatSessionRemaining(d time.Duration) 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. +// registerSessionWarningCategory wires the OS notification category and response handler for the expiry warning. +// Errors are swallowed since the worst case is a plain notification without buttons. func (t *Tray) registerSessionWarningCategory() { if t.svc.Notifier == nil { return @@ -171,30 +143,17 @@ func (t *Tray) registerSessionWarningCategory() { } 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. + // DefaultActionIdentifier is the body-click on platforms with no separate buttons; treat as Extend. 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. +// buildSessionWarningBody composes the localised notification body from the daemon's metadata. +// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence. +// Falls back to a generic string when metadata is missing or unparsable. func (t *Tray) buildSessionWarningBody(meta map[string]string) string { if meta == nil { return t.loc.T("notify.sessionWarning.bodyGeneric") @@ -211,9 +170,8 @@ func (t *Tray) buildSessionWarningBody(meta map[string]string) string { 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). +// notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the +// with-actions variant is unavailable (older platform impls, or a bare Notifier in tests). func (t *Tray) notifySessionWarning(title, body string) { if t.svc.Notifier == nil { return @@ -225,23 +183,13 @@ func (t *Tray) notifySessionWarning(title, body string) { CategoryID: notifyCategorySessionWarning, }) if err != nil { - // Fall back to a plain notification so the user at least gets - // the warning text, even without buttons. (A nil err here also - // covers the panic-recovered case, where the bus is dead and the - // plain fallback would fail too — so we correctly skip it.) + // A recovered panic returns nil err, so a dead bus correctly skips this fallback (it would panic too). t.notify(title, body, notifyIDSessionWarning) } } -// runExtendSession drives the daemon's 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. +// runExtendSession drives the daemon's RequestExtend + WaitExtend pair, opening the browser via Connection.OpenURL. +// Errors surface as notifyError rather than foreground UI, since the warning may fire while the window is closed. func (t *Tray) runExtendSession() { if t.svc.Session == nil || t.svc.Connection == nil { log.Debugf("session-warning: extend requested but services not wired") @@ -276,18 +224,15 @@ func (t *Tray) runExtendSession() { return } if result.Preempted { - // Another UI surface (e.g. the about-to-expire dialog) started a - // flow for the same deadline and took over. Stay silent so the - // user only sees the outcome of the surviving flow. + // Another UI surface owns the flow; stay silent so the user only sees the surviving flow's outcome. log.Debugf("session-warning: WaitExtend preempted by a newer flow") return } t.notify(t.loc.T("notify.sessionWarning.successTitle"), t.loc.T("notify.sessionWarning.successBody"), notifyIDSessionWarning) } -// dismissSessionWarning tells the daemon to silence the 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. +// dismissSessionWarning tells the daemon to silence the fallback dialog for the current deadline. +// Best-effort: a failure only means the dialog will still appear. func (t *Tray) dismissSessionWarning() { if t.svc.Session == nil { return @@ -297,10 +242,8 @@ func (t *Tray) dismissSessionWarning() { } } -// openSessionExpiration fires the auto-opened fallback dialog at -// T-FinalWarningLead when the user did not dismiss the earlier T-10 -// notification. Idempotent on the WindowManager side (a second call -// while the window is already open is a no-op). +// openSessionExpiration fires the fallback dialog when the earlier warning notification wasn't dismissed. +// Idempotent on the WindowManager side. func (t *Tray) openSessionExpiration() { if t.svc.WindowManager == nil { return @@ -308,12 +251,8 @@ func (t *Tray) openSessionExpiration() { t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) } -// openSessionExtendFlow opens the SessionExpiration window seeded with -// the actual remaining time on the cached SSO deadline. Triggered by a -// click on the "Expires in …" tray row so the user can extend the session -// proactively, instead of waiting for the daemon's T-FinalWarningLead -// auto-prompt. Silently no-ops when the deadline is unknown or already -// elapsed — the menu row is hidden in those states anyway. +// openSessionExtendFlow opens the SessionExpiration window seeded with the cached deadline's remaining time, +// for the "Expires in …" tray row. No-ops when the deadline is unknown or elapsed. func (t *Tray) openSessionExtendFlow() { if t.svc.WindowManager == nil { return diff --git a/client/ui/tray_status.go b/client/ui/tray_status.go index 40d76cf51..793f1785a 100644 --- a/client/ui/tray_status.go +++ b/client/ui/tray_status.go @@ -18,26 +18,15 @@ func (t *Tray) onStatusEvent(ev *application.CustomEvent) { t.applyStatus(st) } -// applyStatus updates the tray icon, status label, exit-node submenu, and -// connect/disconnect enablement based on the latest daemon snapshot. -// Skips the icon refresh when none of the icon-relevant inputs -// (connected, hasUpdate, status label) changed — the daemon emits -// rapid SubscribeStatus bursts during health probes that would -// otherwise spam Shell_NotifyIcon and the log. -// -// Profile-switch suppression lives one layer up in services/daemon_feed.go -// (DaemonFeed.BeginProfileSwitch / consumeForSwitch) so the optimistic -// Connecting paint and the suppressed Idle/Connected events are shared -// with the React Status page rather than being a tray-only behaviour. +// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped +// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus +// bursts during health probes that would otherwise spam Shell_NotifyIcon. func (t *Tray) applyStatus(st services.Status) { t.statusMu.Lock() connected := strings.EqualFold(st.Status, services.StatusConnected) iconChanged := connected != t.connected || st.Status != t.lastStatus - // Detect the transition into SessionExpired: the daemon emits the - // state on every Status snapshot for as long as the session stays - // expired, so without this guard we would re-fire the notification - // on every push. Mirrors the legacy Fyne client's sendNotification - // flag in onSessionExpire. + // The daemon re-emits SessionExpired on every snapshot while expired; act + // only on the transition into it so the notification fires once. sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) @@ -64,20 +53,14 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() } - // All repainting goes through relayoutMenu (menuMu-serialised, paints - // from the caches committed above): applyStatus runs concurrently with - // itself and with relayouts (Wails dispatches listeners on fresh - // goroutines), so in-place item mutation here would race the buildMenu - // pointer swap. + // All repainting goes through relayoutMenu (menuMu-serialised): applyStatus + // runs concurrently with itself and with relayouts, so in-place item + // mutation would race the buildMenu pointer swap. if iconChanged || daemonVersionChanged || sessionChanged { t.relayoutMenu() } - // Re-fetch the selectable exit-node list whenever the daemon's routed- - // networks revision bumps (a route candidate added/removed, or a selection - // applied from any surface) or the tunnel flips state (iconChanged). The - // revision is the only reliable signal: candidate routes never appear in - // the peer-status snapshot, so a removed exit node would otherwise go - // unnoticed. The refresh owns the parent item's enablement and the rebuild. + // The revision is the only reliable signal: candidate routes never appear + // in the peer-status snapshot, so a removed exit node would go unnoticed. if iconChanged || revisionChanged { go t.refreshExitNodes() } @@ -92,13 +75,9 @@ func (t *Tray) applyStatus(st services.Status) { } // consumePendingConnectLogin acts on the SSO auto-handoff flag armed by -// handleConnect. It returns true (and clears the flag) when the daemon -// reached NeedsLogin, signalling the browser-login flow should start so the -// user doesn't need to click Connect a second time. The flag is also cleared -// 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. Must be called with -// statusMu held. +// handleConnect. Returns true on NeedsLogin so the browser-login flow starts +// without a second Connect click; clears the flag on any terminal state so a +// stale flag can't fire on a later daemon flip. Must hold statusMu. func (t *Tray) consumePendingConnectLogin(status string) bool { if !t.pendingConnectLogin { return false @@ -117,9 +96,9 @@ func (t *Tray) consumePendingConnectLogin(status string) bool { return false } -// applyStatusIndicator sets the coloured status dot. Called only from -// relayoutMenu (menuMu held): on macOS the bitmap repaints via the -// relayout's trailing SetMenu — no SetMenu here, the tree is half-built. +// applyStatusIndicator sets the status dot bitmap. Call only from relayoutMenu +// (menuMu held): on macOS the bitmap repaints via the relayout's trailing +// SetMenu, not here — the tree is half-built. func (t *Tray) applyStatusIndicator(status string) { if t.statusItem == nil { return diff --git a/client/ui/tray_status_enabled_linux.go b/client/ui/tray_status_enabled_linux.go index 79e4acd21..ab7f869b8 100644 --- a/client/ui/tray_status_enabled_linux.go +++ b/client/ui/tray_status_enabled_linux.go @@ -2,13 +2,7 @@ package main -// statusRowEnabled reports whether the informational status row at the -// top of the tray menu should stay enabled. True on Linux: a disabled -// row is painted greyed-out, which makes the connection-status indicator -// at the top of the menu look washed-out. Keeping it enabled lets the -// row (and its coloured status dot) render at full opacity. The row has -// no OnClick handler, so clicking it is still a no-op — enabling only -// affects how it is drawn, not its behaviour. macOS disables the row -// (tray_status_enabled_other.go); Windows enables it for a different -// reason (tray_status_enabled_windows.go). +// statusRowEnabled keeps the top status row enabled on Linux: a disabled row +// paints greyed-out, washing out the status dot. The row has no OnClick, so +// enabling only affects drawing. func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_status_enabled_other.go b/client/ui/tray_status_enabled_other.go index 5f0d16032..606a7d702 100644 --- a/client/ui/tray_status_enabled_other.go +++ b/client/ui/tray_status_enabled_other.go @@ -2,11 +2,6 @@ package main -// statusRowEnabled reports whether the informational status row at the -// top of the tray menu should stay enabled. False on macOS: it paints -// disabled menu rows at slightly reduced opacity without desaturating -// the leading bitmap, so the coloured status dot stays visible while the -// greyed-out label still signals to the user that the row is -// informational and not clickable. Windows opts in via -// tray_status_enabled_windows.go; Linux via tray_status_enabled_linux.go. +// statusRowEnabled is false on macOS: disabling the row dims the label (signalling +// non-clickable) while keeping the bitmap opaque, so the coloured dot stays visible. func statusRowEnabled() bool { return false } diff --git a/client/ui/tray_status_enabled_windows.go b/client/ui/tray_status_enabled_windows.go index cdfec41a6..06750b7c0 100644 --- a/client/ui/tray_status_enabled_windows.go +++ b/client/ui/tray_status_enabled_windows.go @@ -2,12 +2,6 @@ package main -// statusRowEnabled reports whether the informational status row at the -// top of the tray menu should stay enabled. Always true on Windows: -// the Win32 disabled-state mask desaturates both the row text and the -// HBITMAP painted into the check-mark slot, so a disabled row would -// render the coloured status dot in greyscale and defeat the indicator. -// macOS/Linux disable the row (see tray_status_enabled_other.go) because -// neither platform applies that desaturation and the visual cue that -// the row is informational reads better. +// statusRowEnabled is always true on Windows: the Win32 disabled-state mask +// desaturates the row's HBITMAP, which would grey out the coloured status dot. func statusRowEnabled() bool { return true } diff --git a/client/ui/tray_theme_linux.go b/client/ui/tray_theme_linux.go index fccc32fcb..a3249e57a 100644 --- a/client/ui/tray_theme_linux.go +++ b/client/ui/tray_theme_linux.go @@ -2,21 +2,10 @@ package main -// Linux panel-theme detection for the monochrome tray icons. -// -// Wails v3's Linux SNI backend does not honour SetDarkModeIcon — its -// setDarkModeIcon just calls setIcon, so the last write wins regardless of -// panel theme (see pkg/application/systemtray_linux.go). The SNI spec itself -// also carries no reliable "panel is dark/light" hint for clients. So we -// detect the desktop's colour scheme ourselves and pick the black or white -// silhouette in iconForState. -// -// This file holds the (stateless) dark/light decision helpers; the live -// watcher that seeds and repaints on change lives in -// tray_theme_watcher_linux.go. -// -// color-scheme values (per the freedesktop appearance spec): -// 0 = no preference, 1 = prefer dark, 2 = prefer light. +// Wails v3's Linux SNI backend ignores SetDarkModeIcon (it just calls setIcon, +// last write wins) and SNI carries no panel dark/light hint, so we detect the +// desktop colour scheme ourselves and pick the silhouette in iconForState. +// The live watcher is in tray_theme_watcher_linux.go. import ( "bufio" @@ -28,17 +17,15 @@ import ( log "github.com/sirupsen/logrus" ) -// startTrayTheme wires the Linux panel-theme watcher into the tray: it seeds -// t.panelDark from the freedesktop Settings portal and repaints the icon on -// every live colour-scheme flip. Called from NewTray before the first -// applyIcon so the initial paint already uses the right silhouette. +// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must +// run before the first applyIcon so the initial paint uses the right silhouette. func (t *Tray) startTrayTheme() { w := startThemeWatcher(func() { t.applyIcon() }) t.panelDark = w.IsDark } // isKDE reports whether the current desktop is KDE Plasma. XDG_CURRENT_DESKTOP -// is a colon-separated list (e.g. "KDE", "ubuntu:KDE"), so we match the token. +// is a colon-separated list (e.g. "ubuntu:KDE"), so match per token. func isKDE() bool { for _, d := range strings.Split(os.Getenv("XDG_CURRENT_DESKTOP"), ":") { if strings.EqualFold(strings.TrimSpace(d), "KDE") { @@ -48,11 +35,9 @@ func isKDE() bool { return false } -// kdeglobalsPath returns the user kdeglobals path ($XDG_CONFIG_HOME/kdeglobals, -// or ~/.config/kdeglobals), the highest-priority file in KDE's config cascade. -// We read only this file rather than replaying the full XDG_CONFIG_DIRS + -// kdedefaults cascade: the user file is where Plasma writes the active scheme, -// and if the Complementary group is absent here we fall back to the portal. +// kdeglobalsPath returns the user kdeglobals path. We read only this file, not +// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a +// missing Complementary group falls back to the portal. func kdeglobalsPath() string { if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { return filepath.Join(dir, "kdeglobals") @@ -64,11 +49,10 @@ func kdeglobalsPath() string { return filepath.Join(home, ".config", "kdeglobals") } -// kdePanelIsDark reports whether the KDE Plasma panel is dark, reading the -// Breeze "Complementary" background — the colour Plasma actually paints the -// panel/system-tray with — from kdeglobals and deciding by its luma. The -// second return is false when this isn't KDE or the colour can't be read, so -// readDarkMode falls through to the portal/GTK path. +// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of +// its "Complementary" background (the colour Plasma paints the tray with). ok +// is false when this isn't KDE or the colour can't be read, so the caller falls +// through to the portal/GTK path. func kdePanelIsDark() (dark, ok bool) { if !isKDE() { return false, false @@ -115,7 +99,7 @@ func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) { return rgb, false } -// parseRGB parses a "r,g,b" triple (KDE's colour format) into bytes. +// parseRGB parses KDE's "r,g,b" colour triple into bytes. func parseRGB(s string) (rgb [3]uint8, ok bool) { parts := strings.Split(s, ",") if len(parts) != 3 { @@ -131,16 +115,15 @@ func parseRGB(s string) (rgb [3]uint8, ok bool) { return rgb, true } -// isDarkRGB reports whether a colour is dark using the Rec. 601 relative luma. -// The 128 midpoint matches the perceptual split between needing a light vs a -// dark foreground. +// isDarkRGB reports whether a colour is dark via Rec. 601 luma, split at the +// 128 midpoint. func isDarkRGB(r, g, b uint8) bool { luma := (299*int(r) + 587*int(g) + 114*int(b)) / 1000 return luma < 128 } -// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is -// treated as dark to match the default-dark fallback used elsewhere. +// gtkThemeIsDark inspects the GTK_THEME env var. Empty (no override) is treated +// as dark to match the default-dark fallback used elsewhere. func gtkThemeIsDark() bool { theme := os.Getenv("GTK_THEME") if theme == "" { diff --git a/client/ui/tray_theme_other.go b/client/ui/tray_theme_other.go index 86a8eae73..1ff7581ff 100644 --- a/client/ui/tray_theme_other.go +++ b/client/ui/tray_theme_other.go @@ -3,6 +3,5 @@ package main func (t *Tray) startTrayTheme() { - // No-op off Linux: macOS template icons and Windows colored PNGs need no - // colour-scheme probe. panelDark stays nil; panelIsDark uses its default. + // No-op off Linux: leaves panelDark nil so panelIsDark uses its default. } diff --git a/client/ui/tray_theme_watcher_linux.go b/client/ui/tray_theme_watcher_linux.go index b12c4930a..b9bafe30b 100644 --- a/client/ui/tray_theme_watcher_linux.go +++ b/client/ui/tray_theme_watcher_linux.go @@ -2,16 +2,10 @@ package main -// themeWatcher: the live half of Linux panel-theme detection. It seeds the -// current dark/light state, then watches for changes from two sources and -// repaints the tray icon when the panel theme flips: -// - the freedesktop Settings portal's SettingChanged signal (the cross- -// desktop colour-scheme source), and -// - on KDE, the user kdeglobals file (the portal's color-scheme doesn't -// track the panel's Complementary colour — see readDarkMode). -// -// The dark/light decision itself lives in tray_theme_linux.go; this file owns -// the session-bus connection, the signal/file subscriptions, and the repaint. +// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE +// the kdeglobals file (the portal's color-scheme doesn't track the panel's +// Complementary colour — see readDarkMode). The dark/light decision lives in +// tray_theme_linux.go; this file owns the session-bus connection and subscriptions. import ( "path/filepath" @@ -35,9 +29,8 @@ const ( colorSchemePreferLight = 2 ) -// themeWatcher reads the desktop colour-scheme preference over the session -// bus and invokes onChange whenever it flips. It owns a private session-bus -// connection so its signal subscription is isolated from the SNI watcher's. +// themeWatcher owns a private session-bus connection so its signal subscription +// is isolated from the SNI watcher's. type themeWatcher struct { conn *dbus.Conn onChange func() @@ -46,10 +39,8 @@ type themeWatcher struct { darkMode bool } -// startThemeWatcher opens a private session-bus connection, seeds the current -// colour scheme, and subscribes to the portal's SettingChanged signal. It -// returns nil (and logs) if the portal is unavailable — callers treat a nil -// watcher as "no preference", which keeps the default-dark icon choice. +// startThemeWatcher returns nil if the session bus is unavailable; callers treat +// a nil watcher as "no preference", keeping the default-dark icon. func startThemeWatcher(onChange func()) *themeWatcher { conn, err := dbus.SessionBusPrivate() if err != nil { @@ -75,9 +66,7 @@ func startThemeWatcher(onChange func()) *themeWatcher { // Keep the connection: the seeded darkMode value is still useful. } - // On KDE the portal's color-scheme signal doesn't track the panel's - // Complementary colour, so watch kdeglobals directly to repaint on a - // theme switch. + // The portal's signal doesn't track KDE's panel Complementary colour. if isKDE() { w.watchKdeglobals() } @@ -86,9 +75,8 @@ func startThemeWatcher(onChange func()) *themeWatcher { return w } -// IsDark reports the last observed colour-scheme preference. A nil watcher -// (portal unavailable) reports true so the icon defaults to the white -// silhouette, which suits the common dark Linux panel. +// IsDark reports true for a nil watcher, so the icon defaults to the white +// silhouette suiting the common dark Linux panel. func (w *themeWatcher) IsDark() bool { if w == nil { return true @@ -98,22 +86,14 @@ func (w *themeWatcher) IsDark() bool { return w.darkMode } -// readDarkMode resolves whether the desktop panel (where the tray icon sits) -// is dark. +// readDarkMode resolves whether the panel the tray icon sits on is dark. // -// On KDE the freedesktop color-scheme is the *application* window preference, -// not the panel's: Plasma paints its panel and system tray from the Breeze -// "Complementary" colour group, which stays dark even under a Light global -// scheme (kdeglobals [Colors:Window] light vs [Colors:Complementary] dark). -// So a light color-scheme there would wrongly pick the black silhouette, -// which then disappears against the dark panel. We therefore read the actual -// panel background from kdeglobals first under KDE and decide by its luma. -// -// Off KDE (or when kdeglobals can't be read), the freedesktop color-scheme -// portal is the source; when it is unavailable or reports "no preference" -// (0), we fall back to the GTK_THEME env var (the GTK convention appends -// ":dark" for the dark variant, e.g. "Adwaita:dark"). If nothing yields a -// signal we default to dark, matching the common dark Linux panel. +// On KDE the freedesktop color-scheme is the application preference, not the +// panel's: Plasma paints its panel from the Breeze "Complementary" group, which +// stays dark even under a Light global scheme, so we read the panel background +// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal +// is the source; on "no preference" (0) or when unavailable we fall back to +// GTK_THEME (":dark" suffix ⇒ dark), then default to dark. func (w *themeWatcher) readDarkMode() bool { if dark, ok := kdePanelIsDark(); ok { return dark @@ -123,14 +103,13 @@ func (w *themeWatcher) readDarkMode() bool { return true case colorSchemePreferLight: return false - default: // colorSchemeNoPreference or portal unavailable + default: return gtkThemeIsDark() } } -// readColorScheme returns the raw freedesktop color-scheme value (0 = no -// preference, 1 = prefer dark, 2 = prefer light), or colorSchemeNoPreference -// when the portal can't be reached. +// readColorScheme returns the raw freedesktop color-scheme value, or +// colorSchemeNoPreference when the portal can't be reached. func (w *themeWatcher) readColorScheme() uint32 { obj := w.conn.Object(portalBusName, portalObjectPath) call := obj.Call(portalSettings+".Read", 0, appearanceNamespace, colorSchemeKey) @@ -148,9 +127,6 @@ func (w *themeWatcher) readColorScheme() uint32 { return variantToColorScheme(v) } -// subscribe registers a match rule for the portal's SettingChanged signal and -// spawns a goroutine that re-reads the scheme and fires onChange on each -// relevant change. func (w *themeWatcher) subscribe() error { if err := w.conn.AddMatchSignal( dbus.WithMatchObjectPath(portalObjectPath), @@ -166,8 +142,6 @@ func (w *themeWatcher) subscribe() error { return nil } -// loop consumes SettingChanged signals, filters to the colour-scheme key, and -// repaints the icon when the dark/light preference actually flips. func (w *themeWatcher) loop(sigs chan *dbus.Signal) { for sig := range sigs { if sig.Name != portalSettings+".SettingChanged" { @@ -186,16 +160,12 @@ func (w *themeWatcher) loop(sigs chan *dbus.Signal) { continue } - // Re-resolve via readDarkMode rather than the signal's value: under - // KDE the panel colour comes from kdeglobals' Complementary group, - // not the portal's color-scheme, so the signal value alone would be - // wrong there. Off KDE this just re-reads the same color-scheme. + // Re-resolve via readDarkMode, not the signal value: under KDE the panel + // colour comes from kdeglobals, so the signal value would be wrong. w.update() } } -// update re-resolves the panel dark/light state and repaints the icon if it -// flipped. Shared by the portal-signal loop and the KDE kdeglobals watcher. func (w *themeWatcher) update() { dark := w.readDarkMode() w.mu.Lock() @@ -209,11 +179,9 @@ func (w *themeWatcher) update() { } } -// watchKdeglobals watches the user kdeglobals file for changes and re-resolves -// the panel theme on each write, so a KDE colour-scheme switch repaints the -// icon live. KDE rewrites kdeglobals atomically (write-temp + rename), which -// drops the inotify watch on the original inode, so we watch the parent -// directory and filter to the kdeglobals name, re-arming implicitly. +// watchKdeglobals watches the parent directory, not the file: KDE rewrites +// kdeglobals atomically (write-temp + rename), which would drop an inotify watch +// on the original inode. Filtering by name re-arms implicitly. func (w *themeWatcher) watchKdeglobals() { path := kdeglobalsPath() if path == "" { @@ -257,9 +225,7 @@ func (w *themeWatcher) watchKdeglobals() { }() } -// variantToColorScheme unwraps the color-scheme variant (the portal nests it -// one level: a variant holding a uint32) into the raw scheme value, returning -// colorSchemeNoPreference for an unexpected payload. +// variantToColorScheme unwraps the color-scheme variant; the portal nests it one level. func variantToColorScheme(v dbus.Variant) uint32 { inner := v.Value() if nested, ok := inner.(dbus.Variant); ok { diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go index c3da3812f..2d79cff05 100644 --- a/client/ui/tray_update.go +++ b/client/ui/tray_update.go @@ -15,36 +15,24 @@ import ( "github.com/netbirdio/netbird/client/ui/updater" ) -// trayUpdater owns every piece of tray UI that reacts to the auto-update -// feature: the "Download latest / Install version X" menu item, the -// EventUpdateState subscription, the click that either opens the GitHub -// releases page or triggers the in-window installer flow, the OS -// notification for a freshly announced version, and the bring-window-forward -// call when the daemon enters force-install. Composed inside Tray; never -// used standalone. +// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. type trayUpdater struct { - app *application.App - window *application.WebviewWindow - update *services.Update - notifier *notifications.NotificationService - loc *Localizer - // onIconChange is invoked whenever the "update available" flag - // transitions, so the tray can repaint its icon (the small badge - // overlay differs between has-update / no-update). + app *application.App + window *application.WebviewWindow + update *services.Update + notifier *notifications.NotificationService + loc *Localizer onIconChange func() - // onMenuChange drives a full tray relayout (Tray.relayoutMenu) after an - // event-driven update-state change. The update row lives in the About - // submenu, which KDE/Plasma caches on first open and never re-fetches on a - // plain SetLabel/SetHidden — so a newly-available update would never paint - // there. relayoutMenu rebuilds the whole tree (fresh submenu ids) and - // re-attaches this item from the cached state via attach → refreshMenuItem. + // onMenuChange drives a full tray relayout: the update row lives in the + // About submenu, which KDE/Plasma caches on first open and never re-fetches + // on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints. onMenuChange func() mu sync.Mutex item *application.MenuItem state updater.State - notifiedVersion string // last version we surfaced as an OS notification - progressWindowOpen bool // last installing value we acted on + notifiedVersion string + progressWindowOpen bool } func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { @@ -58,17 +46,13 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd onMenuChange: onMenuChange, } app.Event.On(updater.EventStateChanged, u.onStateEvent) - // Seed from the cached state so we don't miss an event that fired - // before NewTray finished wiring (DaemonFeed.Watch starts after tray - // construction today, but treat that as an implementation detail). + // Seed from cached state to cover an event that fired before wiring completed. u.state = update.GetState() return u } -// attach (re)binds the menu item the tray builds for us. Called every time -// Tray.buildMenu runs — initial menu construction and language switches. -// The menu item's OnClick handler is owned by the caller; this method only -// configures label and visibility from the cached state. +// attach (re)binds the menu item on each Tray.buildMenu run. The caller owns the +// item's OnClick handler. func (u *trayUpdater) attach(item *application.MenuItem) { u.mu.Lock() u.item = item @@ -77,16 +61,14 @@ func (u *trayUpdater) attach(item *application.MenuItem) { u.refreshMenuItem(state) } -// hasUpdate reports whether the tray should paint the "update available" -// icon variant. Read by Tray.iconForState during applyIcon. +// hasUpdate reports whether the tray should paint the "update available" icon. func (u *trayUpdater) hasUpdate() bool { u.mu.Lock() defer u.mu.Unlock() return u.state.Available } -// applyLanguage re-renders the menu item label from the cached state, used -// after Tray.applyLanguage rebuilds the menu with a fresh locale. +// applyLanguage re-renders the menu item label after a locale switch. func (u *trayUpdater) applyLanguage() { u.mu.Lock() state := u.state @@ -94,10 +76,8 @@ func (u *trayUpdater) applyLanguage() { u.refreshMenuItem(state) } -// handleClick runs when the user clicks the tray update entry. Branch 1 -// (Enforced=false) opens the GitHub releases page in the browser; Branch 2 -// (Enforced=true) surfaces the in-window /update progress page and asks -// the daemon to start the installer. +// handleClick opens the GitHub releases page when not Enforced, otherwise shows +// the progress page and asks the daemon to start the installer. func (u *trayUpdater) handleClick() { u.mu.Lock() state := u.state @@ -128,10 +108,8 @@ func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) { u.applyState(st) } -// applyState diffs the incoming UpdateState against the cached copy and -// drives every side effect: icon repaint, menu label/visibility, OS -// notification on a newly-announced version, /update window on install -// entry. +// applyState diffs st against the cached state and drives the resulting side +// effects: icon repaint, menu refresh, new-version notification, progress window. func (u *trayUpdater) applyState(st updater.State) { u.mu.Lock() prev := u.state @@ -150,11 +128,8 @@ func (u *trayUpdater) applyState(st updater.State) { } u.mu.Unlock() - // Drive a full relayout rather than mutating u.item in place: on KDE the - // About submenu is layout-cached, so a direct SetLabel/SetHidden here would - // not paint the newly-available update. relayoutMenu re-attaches the item - // from u.state, which re-runs refreshMenuItem. Fall back to the in-place - // refresh if no relayout hook was wired (defensive — always set today). + // Full relayout rather than in-place: KDE layout-caches the About submenu, so + // a direct SetLabel/SetHidden wouldn't paint. Fall back if no hook was wired. if u.onMenuChange != nil { u.onMenuChange() } else { @@ -171,9 +146,6 @@ func (u *trayUpdater) applyState(st updater.State) { } } -// refreshMenuItem updates the menu item's label and visibility from the -// given state. Called from applyState (event-driven), attach (menu rebuild) -// and applyLanguage (locale switch) — all three converge on the same shape. func (u *trayUpdater) refreshMenuItem(st updater.State) { u.mu.Lock() item := u.item @@ -209,10 +181,8 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) { }) } -// openProgressWindow points the main window at the /update progress page -// and brings it forward. Used both when the user clicks an enforced-update -// menu entry (Branch 2) and when the daemon flips Installing to true on -// its own (Branch 3, force install). +// openProgressWindow points the main window at the /update progress page and +// brings it forward. func (u *trayUpdater) openProgressWindow(version string) { if u.window == nil { return diff --git a/client/ui/tray_watcher_linux.go b/client/ui/tray_watcher_linux.go index 4b2c06b29..dd325ff7b 100644 --- a/client/ui/tray_watcher_linux.go +++ b/client/ui/tray_watcher_linux.go @@ -2,19 +2,9 @@ package main -// startStatusNotifierWatcher registers org.kde.StatusNotifierWatcher on the -// session D-Bus if no other process has already claimed it. -// -// Minimal window managers (Fluxbox, OpenBox, i3, etc.) do not ship a -// StatusNotifier watcher, so tray icons using libayatana-appindicator or -// the KDE/freedesktop StatusNotifier protocol silently fail. -// -// By owning the watcher name in-process we allow the Wails v3 built-in tray -// to register itself — no external daemon or package needed. -// -// When an XEmbed system tray is available (_NET_SYSTEM_TRAY_S0), we also -// start an in-process XEmbed host that bridges the SNI icon into the -// XEmbed tray (Fluxbox, IceWM, etc.). +// In-process org.kde.StatusNotifierWatcher for minimal WMs (Fluxbox, OpenBox, +// i3) that ship no watcher. When an XEmbed tray exists (_NET_SYSTEM_TRAY_S0), +// an in-process XEmbed host bridges the SNI icon into it. import ( "sync" @@ -29,13 +19,8 @@ const ( watcherPath = "/StatusNotifierWatcher" watcherIface = "org.kde.StatusNotifierWatcher" - // watcherProbeInterval / watcherProbeTimeout bound how long we keep - // re-probing for an XEmbed tray before giving up. The UI is commonly - // autostarted *before* the panel/tray on minimal WMs, so a single probe - // at startup would miss a tray that comes up a second or two later and - // the icon would silently never appear. ~10s of polling covers a slow - // panel launch while staying short enough that a headless / pure-Wayland - // session (no XEmbed tray ever) winds down quickly. + // The UI is often autostarted before the panel on minimal WMs, so a single + // startup probe would miss a tray that appears a second later. watcherProbeInterval = 500 * time.Millisecond watcherProbeTimeout = 10 * time.Second ) @@ -48,8 +33,7 @@ type statusNotifierWatcher struct { } // RegisterStatusNotifierItem is the D-Bus method called by tray clients. -// The sender parameter is automatically injected by godbus with the caller's -// unique bus name (e.g. ":1.42"). It does not appear in the D-Bus signature. +// sender is injected by godbus and is not part of the D-Bus signature. func (w *statusNotifierWatcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error { for _, s := range w.items { if s == service { @@ -69,8 +53,7 @@ func (w *statusNotifierWatcher) RegisterStatusNotifierHost(service string) *dbus return nil } -// tryStartXembedHost attempts to create an XEmbed tray icon for the given -// SNI item. If no XEmbed tray manager is available, this is a no-op. +// tryStartXembedHost is a no-op when no XEmbed tray manager is available. func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus.ObjectPath) { w.hostsMu.Lock() defer w.hostsMu.Unlock() @@ -79,8 +62,8 @@ func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus. return } - // Use a private session bus so our signal subscriptions don't - // interfere with Wails' signal handler (which panics on unexpected signals). + // Private session bus so our signal subscriptions don't reach Wails' + // signal handler, which panics on unexpected signals. sessionConn, err := dbus.SessionBusPrivate() if err != nil { log.Debugf("StatusNotifierWatcher: cannot open private session bus for XEmbed host: %v", err) @@ -108,27 +91,15 @@ func (w *statusNotifierWatcher) tryStartXembedHost(busName string, objPath dbus. log.Infof("StatusNotifierWatcher: XEmbed tray icon created for %s", busName) } -// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher on the -// session bus, but ONLY as a bridge to an XEmbed system tray on minimal WMs. -// -// The in-process watcher is a stub: its RegisterStatusNotifierItem only -// tracks items so it can mirror them into an XEmbed tray icon — it does -// NOT relay them to any other StatusNotifierHost. So if we claim the name -// on a desktop that has a real watcher/host (e.g. Hyprland + Waybar), every -// other tray app (Slack, etc.) registers into our dead-end watcher and its -// icon never reaches the real host. We won that name purely by starting -// first; a GetNameOwner check doesn't help against a login-order race. -// -// The correct discriminator is whether an XEmbed tray actually exists. If -// one does, we are the bridge of last resort and should claim the watcher. -// If not (pure Wayland, or any environment already running a real watcher), -// we have nothing to bridge and must stay off the bus entirely so the real -// watcher owns the name. Safe to call unconditionally. -// -// The XEmbed tray may come up *after* the UI (the panel and the autostarted -// app race at login), so we re-probe for a short grace period instead of -// deciding once at startup. The probing runs in a goroutine so it never -// blocks the caller's startup path. +// startStatusNotifierWatcher claims org.kde.StatusNotifierWatcher only as a +// bridge to an XEmbed tray on minimal WMs. The watcher is a stub that never +// relays items to a real StatusNotifierHost, so claiming the name on a desktop +// with a real host (e.g. Hyprland + Waybar) would dead-end every other tray +// app's icon. It gates on the actual presence of an XEmbed tray rather than +// GetNameOwner, which can't win a login-order race; without one it stays off +// the bus so the real watcher owns the name. The XEmbed tray may come up after +// the UI, so it re-probes for a grace period rather than deciding once. +// Safe to call unconditionally. func startStatusNotifierWatcher() { go func() { deadline := time.Now().Add(watcherProbeTimeout) @@ -146,11 +117,9 @@ func startStatusNotifierWatcher() { }() } -// claimStatusNotifierWatcher opens a private session-bus connection and takes -// ownership of org.kde.StatusNotifierWatcher, exporting the in-process stub -// watcher. The caller has already confirmed an XEmbed tray is present, so we -// genuinely have an item to bridge. The GetNameOwner / DoNotQueue guards still -// back off if a real watcher already holds the name. +// claimStatusNotifierWatcher takes ownership of org.kde.StatusNotifierWatcher +// on a private session bus and exports the stub watcher. The GetNameOwner / +// DoNotQueue guards back off if a real watcher already holds the name. func claimStatusNotifierWatcher() { conn, err := dbus.SessionBusPrivate() if err != nil { @@ -168,7 +137,6 @@ func claimStatusNotifierWatcher() { return } - // Check whether another process already owns the watcher name. var owner string callErr := conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, watcherName).Store(&owner) if callErr == nil && owner != "" { @@ -195,5 +163,5 @@ func claimStatusNotifierWatcher() { } log.Infof("StatusNotifierWatcher: active on session bus (enables tray on minimal WMs)") - // Connection intentionally kept open for the lifetime of the process. + // Connection kept open for the process lifetime. } diff --git a/client/ui/tray_watcher_other.go b/client/ui/tray_watcher_other.go index 23adc6d4d..171e6d70c 100644 --- a/client/ui/tray_watcher_other.go +++ b/client/ui/tray_watcher_other.go @@ -2,16 +2,7 @@ package main -// startStatusNotifierWatcher is a no-op on non-Linux platforms (and on -// linux/386, which excludes the cgo XEmbed host). -// -// The in-process org.kde.StatusNotifierWatcher + XEmbed bridge in -// tray_watcher_linux.go only exists to rescue the tray on minimal Linux WMs -// that ship no SNI watcher of their own. macOS and Windows have a native -// system tray that Wails talks to directly, so there is nothing to register -// here — the function body is intentionally empty rather than missing so -// main.go can call startStatusNotifierWatcher() unconditionally across all -// build targets. +// startStatusNotifierWatcher is a no-op stub so main.go can call it across all +// build targets; only minimal Linux WMs need the real watcher (tray_watcher_linux.go). func startStatusNotifierWatcher() { - // Intentionally empty: see the doc comment above. } diff --git a/client/ui/uilogpath.go b/client/ui/uilogpath.go index bfe80ee7d..96fbb9637 100644 --- a/client/ui/uilogpath.go +++ b/client/ui/uilogpath.go @@ -11,15 +11,12 @@ import ( "github.com/netbirdio/netbird/client/ui/guilog" ) -// uiLogFileName is the base name of the GUI's log. Rotated siblings -// (gui-client.log.*, *.gz) share the prefix; the daemon's debug bundle globs -// "gui-client*.log.*" to collect them (see addUILog in client/internal/debug). +// uiLogFileName must stay in sync with the daemon's "gui-client*.log.*" glob +// for rotated siblings (addUILog in client/internal/debug). const uiLogFileName = "gui-client.log" -// uiLogPath resolves os.UserConfigDir()/netbird/gui-client.log — the per-OS-user -// path the GUI writes its log to while the daemon is in debug, and the path it -// registers with the daemon for debug-bundle collection. Native separators are -// preserved (the daemon os.Open()s this path). +// uiLogPath returns the GUI log path with native separators, since the daemon +// opens it directly for debug-bundle collection. func uiLogPath() (string, error) { dir, err := os.UserConfigDir() if err != nil { @@ -28,9 +25,8 @@ func uiLogPath() (string, error) { return filepath.Join(dir, "netbird", uiLogFileName), nil } -// newDebugLog builds the GUI debug log. userSetLogFile disables it (manual -// --log-file override). If the config dir can't be resolved it's created -// disabled, so the GUI keeps working without file logging. +// newDebugLog builds the GUI debug log, disabled when userSetLogFile is set +// (manual --log-file override) or the config dir can't be resolved. func newDebugLog(userSetLogFile bool) *guilog.DebugLog { path, err := uiLogPath() if err != nil { diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go index 040309e51..606f8b401 100644 --- a/client/ui/updater/state.go +++ b/client/ui/updater/state.go @@ -1,11 +1,8 @@ //go:build !android && !ios && !freebsd && !js -// Package updater carries the auto-update domain: the typed State the UI -// renders, the daemon-SystemEvent metadata schema, and the Holder that -// caches the latest state and broadcasts changes. Mirrors the layout of -// client/ui/i18n and client/ui/preferences — no Wails dependency, just an -// optional Emitter interface so callers can pass either the Wails event -// processor or a fake in tests. +// Package updater holds the auto-update domain: the typed State, the +// daemon-SystemEvent metadata schema, and the Holder that caches the latest +// state and broadcasts changes. No Wails dependency. package updater import ( @@ -16,25 +13,12 @@ import ( "github.com/netbirdio/netbird/client/proto" ) -// EventStateChanged is the single Wails event the frontend and tray -// subscribe to. The payload is the full State snapshot, so consumers -// never need to combine multiple events to know what to render. +// EventStateChanged carries the full State snapshot as payload. const EventStateChanged = "netbird:update:state" -// State is the typed snapshot of the daemon's update situation, covering -// the three branches the UI cares about: -// -// - Disabled / opt-in: Available=true, Enforced=false, Installing=false. -// Tray shows "Download latest", frontend shows a "Get installer" hint -// pointing at GitHub. -// - Enforced, user-driven: Available=true, Enforced=true, Installing=false. -// Tray shows "Install version X", frontend shows the install banner. -// - Forced, daemon already installing: Available=true, Enforced=true, -// Installing=true. Both surfaces show the install-in-progress UI. -// -// Installing is driven only by the daemon's progress_window:show event; -// a UI-side Update.Trigger() does not flip it. The frontend tracks its own -// "Trigger() in flight" state for the enforced flow. +// State is the typed snapshot of the daemon's update situation. Installing is +// driven only by the daemon's progress_window:show event; a UI-side +// Update.Trigger() does not flip it. type State struct { Available bool `json:"available"` Version string `json:"version"` @@ -42,18 +26,13 @@ type State struct { Installing bool `json:"installing"` } -// Emitter is the dependency Holder needs to broadcast changes. The Wails -// app.Event processor satisfies this; tests pass nil or a fake. Same shape -// the preferences package uses, intentionally — both are "broadcast to the -// frontend" hooks with no other contract. +// Emitter is the broadcast dependency Holder needs; the Wails app.Event +// processor satisfies it. type Emitter interface { Emit(name string, data ...any) bool } -// Holder caches the latest update State and broadcasts changes. Fed by -// services.DaemonFeed, which forwards every daemon SystemEvent here via -// OnSystemEvent. The state is read by the Wails-bound services.Update -// facade (Get) and pushed to subscribers via the Emitter. +// Holder caches the latest update State and broadcasts changes. type Holder struct { emitter Emitter @@ -61,29 +40,21 @@ type Holder struct { state State } -// NewHolder constructs an empty-state Holder. The emitter is optional — -// pass nil in tests to skip the broadcast. +// NewHolder constructs an empty-state Holder. A nil emitter skips the broadcast. func NewHolder(emitter Emitter) *Holder { return &Holder{emitter: emitter} } -// Get returns a copy of the cached State. Used by the Wails facade so the -// frontend can pull the current value on mount before its push subscription -// has anything to deliver. +// Get returns a copy of the cached State. func (h *Holder) Get() State { h.mu.Lock() defer h.mu.Unlock() return h.state } -// OnSystemEvent inspects the daemon's SystemEvent metadata for the three -// update-related keys (new_version_available, enforced, progress_window -// plus version) and folds the result into the cached state. Emits -// EventStateChanged only when the state actually changed, so subscribers -// do not see redundant pushes when the daemon repeats a snapshot. -// -// The metadata schema is owned here and nowhere else — neither Peers nor -// the tray nor the frontend reaches into ev.Metadata directly. +// OnSystemEvent folds update-related metadata into the cached state, emitting +// EventStateChanged only on an actual change so repeated daemon snapshots +// don't produce redundant pushes. func (h *Holder) OnSystemEvent(ev *proto.SystemEvent) { md := ev.GetMetadata() if len(md) == 0 { diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go index f94c96e43..bc6bb5d7c 100644 --- a/client/ui/xembed_host_linux.go +++ b/client/ui/xembed_host_linux.go @@ -22,16 +22,13 @@ import ( log "github.com/sirupsen/logrus" ) -// activeMenuHost is the xembedHost that currently owns the popup menu. -// This is needed because C callbacks cannot carry Go pointers. +// activeMenuHost holds the popup owner; C callbacks cannot carry Go pointers. var ( activeMenuHost *xembedHost activeMenuHostMu sync.Mutex ) -// menuItemInfo is the Go-side representation of one popup menu entry, -// flattened from a dbusMenuLayout tree before it is handed to the C -// popup builder. Submenus populate children; leaves leave it nil. +// menuItemInfo is a dbusMenuLayout entry flattened for the C popup builder. type menuItemInfo struct { id int32 label string @@ -42,9 +39,8 @@ type menuItemInfo struct { children []menuItemInfo } -// dbusMenuLayout mirrors the (ia{sv}av) result returned by -// com.canonical.dbusmenu.GetLayout. The Children variants each wrap a -// nested dbusMenuLayout; we decode them lazily in flattenMenu. +// dbusMenuLayout mirrors the (ia{sv}av) result of com.canonical.dbusmenu.GetLayout. +// Each Children variant wraps a nested dbusMenuLayout, decoded in flattenMenu. type dbusMenuLayout struct { ID int32 Properties map[string]dbus.Variant @@ -71,7 +67,7 @@ type xembedHost struct { } // newXembedHost creates an XEmbed tray icon for the given SNI item. -// Returns an error if no XEmbed tray manager is available (graceful fallback). +// Errors when no XEmbed tray manager is available, so callers can fall back. func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { dpy := C.XOpenDisplay(nil) if dpy == nil { @@ -85,7 +81,6 @@ func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*x return nil, errors.New("no XEmbed system tray found") } - // Query the tray manager's preferred icon size. iconSize := int(C.xembed_get_icon_size(dpy, trayMgr)) if iconSize <= 0 { iconSize = 24 // fallback @@ -118,7 +113,6 @@ func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*x return h, nil } -// fetchAndDrawIcon reads IconPixmap from the SNI item via D-Bus and draws it. func (h *xembedHost) fetchAndDrawIcon() { obj := h.conn.Object(h.busName, h.objPath) variant, err := obj.GetProperty("org.kde.StatusNotifierItem.IconPixmap") @@ -127,8 +121,7 @@ func (h *xembedHost) fetchAndDrawIcon() { return } - // IconPixmap is []struct{W, H int32; Pix []byte} on D-Bus, - // represented as a(iiay) signature. + // IconPixmap has D-Bus signature a(iiay). type px struct { W int32 H int32 @@ -161,7 +154,6 @@ func (h *xembedHost) fetchAndDrawIcon() { h.drawIcon() } -// drawIcon draws the cached icon data onto the X11 window. func (h *xembedHost) drawIcon() { h.mu.Lock() data := h.iconData @@ -180,10 +172,8 @@ func (h *xembedHost) drawIcon() { (*C.uchar)(cData), C.int(w), C.int(ht)) } -// run is the main event loop. It polls X11 events and listens for D-Bus -// NewIcon signals to keep the tray icon updated. +// run is the event loop: polls X11 events and D-Bus NewIcon signals until stopped. func (h *xembedHost) run() { - // Subscribe to NewIcon signals from the SNI item. matchRule := "type='signal',interface='org.kde.StatusNotifierItem',member='NewIcon',sender='" + h.busName + "'" if err := h.conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { log.Debugf("xembed: failed to add signal match: %v", err) @@ -242,10 +232,8 @@ func (h *xembedHost) activate(x, y int32) { } func (h *xembedHost) contextMenu(x, y int32) { - // Read the menu path from the SNI item's Menu property. menuPath := dbus.ObjectPath("/StatusNotifierMenu") - // Fetch menu layout from com.canonical.dbusmenu. menuObj := h.conn.Object(h.busName, menuPath) var revision uint32 var layout dbusMenuLayout @@ -265,11 +253,6 @@ func (h *xembedHost) contextMenu(x, y int32) { return } - // Build a C-allocated tree from the Go menu. xembed_show_popup_menu - // deep-copies into its own buffer (so it can outlive this stack - // frame), but it expects valid C strings + pointers in the caller's - // array — we still have to walk the items on the Go side and build - // matching C.xembed_menu_item nodes recursively. var allocs []unsafe.Pointer cItems := buildCItems(items, &allocs) defer func() { @@ -278,7 +261,7 @@ func (h *xembedHost) contextMenu(x, y int32) { } }() - // Set the active menu host so the C callback can reach us. + // C callback reaches us through this global. activeMenuHostMu.Lock() activeMenuHost = h activeMenuHostMu.Unlock() @@ -303,9 +286,7 @@ func (h *xembedHost) flattenMenu(layout dbusMenuLayout) []menuItemInfo { return items } -// menuItemFromLayout decodes one dbusmenu child node into a menuItemInfo, -// recursing into submenus. The bool return is false when the item is hidden -// (visible=false) and should be dropped from the parent's list. +// menuItemFromLayout decodes one dbusmenu child; ok is false for hidden items (drop them). func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, bool) { mi := menuItemInfo{id: child.ID, enabled: true} @@ -329,10 +310,7 @@ func (h *xembedHost) menuItemFromLayout(child dbusMenuLayout) (menuItemInfo, boo mi.checked = true } - // Recurse into nested submenus. The dbusmenu spec marks a folder - // item with children-display=="submenu"; the children are already - // in child.Children because GetLayout was called with - // recursionDepth=-1 (all levels). + // children are already present from the recursionDepth=-1 GetLayout. if propString(child.Properties, "children-display") == "submenu" { mi.children = h.flattenMenu(child) } @@ -354,7 +332,7 @@ func (h *xembedHost) sendMenuEvent(id int32) { func (h *xembedHost) stop() { select { case <-h.stopCh: - return // already stopped + return default: close(h.stopCh) } @@ -363,13 +341,8 @@ func (h *xembedHost) stop() { C.XCloseDisplay(h.dpy) } -// buildCItems recursively translates Go menuItemInfo slices into a -// C-allocated array of xembed_menu_item suitable for passing across the -// Cgo boundary. The C side deep-copies the structure when it stages -// the popup, so any transient labels/children we allocate here can be -// released as soon as xembed_show_popup_menu returns. Every malloc is -// recorded in *allocs so the caller can free it via a single deferred -// loop. Returns nil for empty slices. +// buildCItems builds a C-allocated xembed_menu_item tree. Every malloc is +// appended to *allocs for the caller to free once the C side has deep-copied it. func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_item { if len(items) == 0 { return nil @@ -400,15 +373,11 @@ func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_ return (*C.xembed_menu_item)(arr) } -// xembedTrayAvailable reports whether an XEmbed system tray manager -// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen. -// It is a cheap, side-effect-free probe — it only queries the selection -// owner, creating no windows. Used to decide whether the in-process -// StatusNotifierWatcher is needed at all: the watcher exists solely to -// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed -// tray is present (e.g. Wayland compositors with a real SNI host like -// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the -// real one. Returns false when there is no X display (pure Wayland). +// xembedTrayAvailable reports whether an XEmbed tray manager (_NET_SYSTEM_TRAY_S0) +// owns the default screen. Side-effect-free probe. Gates the in-process +// StatusNotifierWatcher: when a real SNI host already owns the tray (e.g. Waybar +// on Wayland) we must not claim org.kde.StatusNotifierWatcher and shadow it. +// Returns false when there is no X display (pure Wayland). func xembedTrayAvailable() bool { dpy := C.XOpenDisplay(nil) if dpy == nil { @@ -419,11 +388,9 @@ func xembedTrayAvailable() bool { return C.xembed_find_tray(dpy, screen) != 0 } -// goMenuItemClicked is the C callback invoked from the GTK main thread -// when the user activates a popup-menu entry. C callbacks cannot carry -// Go pointers, so the active xembedHost is looked up through the -// activeMenuHost global instead. //export makes this symbol visible to -// the C side; the function must therefore live in package main. +// goMenuItemClicked is the C callback fired from the GTK main thread on popup +// activation. The host is looked up via activeMenuHost since C callbacks can't +// carry Go pointers; //export requires this to live in package main. // //export goMenuItemClicked func goMenuItemClicked(id C.int) { @@ -436,8 +403,6 @@ func goMenuItemClicked(id C.int) { } } -// boolToInt converts a Go bool to the C int the dbusmenu C API uses -// for boolean flags. func boolToInt(b bool) C.int { if b { return 1 @@ -445,8 +410,7 @@ func boolToInt(b bool) C.int { return 0 } -// propString returns the string value of a dbusmenu property, or "" when the -// property is absent or not a string. +// propString returns the property's string value, or "" if absent or not a string. func propString(props map[string]dbus.Variant, key string) string { if v, ok := props[key]; ok { if s, ok := v.Value().(string); ok { @@ -456,8 +420,7 @@ func propString(props map[string]dbus.Variant, key string) string { return "" } -// propBool returns the bool value of a dbusmenu property; ok is false when the -// property is absent or not a bool. +// propBool returns the property's bool value; ok is false if absent or not a bool. func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { if v, present := props[key]; present { if b, isBool := v.Value().(bool); isBool { @@ -467,8 +430,7 @@ func propBool(props map[string]dbus.Variant, key string) (value, ok bool) { return false, false } -// propInt32 returns the int32 value of a dbusmenu property; ok is false when -// the property is absent or not an int32. +// propInt32 returns the property's int32 value; ok is false if absent or not an int32. func propInt32(props map[string]dbus.Variant, key string) (value int32, ok bool) { if v, present := props[key]; present { if n, isInt := v.Value().(int32); isInt {