mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-28 09:39:05 +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:
@@ -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
|
||||
|
||||
+11
-19
@@ -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()
|
||||
}
|
||||
|
||||
+137
-242
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user