mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-31 03:51:29 +02:00
Tighten verbose comments in Wails UI Go code
Shorten over-long godoc/inline comments across the client/ui tray and services code: drop narrative restatement, legacy-Fyne tangents, and text already evident from signatures and names. Keep only the non-obvious why (concurrency/lock ordering, platform quirks, ordering constraints, the profile-switch state table). No code changes.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <code>/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 <code>/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 <code>/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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-<state>.png -resize 16x16 \
|
||||
// -background none -gravity center -extent 16x16 \
|
||||
// netbird-menu-dot-<state>-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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 <lang>/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 <lang>/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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.<code>": i18n.Bundle.Translate already
|
||||
// handles current-language → English → key passthrough, so any missing
|
||||
// locale entry surfaces as a visible "error.<code>" string in the dialog —
|
||||
// a deliberate fail-loud signal that the bundle needs updating.
|
||||
// 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.<code>" 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.<code>" key (which makes the missing translation obvious). If
|
||||
// the translator is nil — e.g. a Connection constructed in a unit test —
|
||||
// we return the key for the same reason.
|
||||
// translateShort resolves the localised short message for code, returning the
|
||||
// bare "error.<code>" 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <html> background).
|
||||
// WindowBackgroundColour matches AppLayout's <html> 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/<lang>/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)
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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, "&", "&&")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user