diff --git a/client/ui/frontend/src/modules/profile/ProfileContext.tsx b/client/ui/frontend/src/modules/profile/ProfileContext.tsx index 71ba18412..f57d27594 100644 --- a/client/ui/frontend/src/modules/profile/ProfileContext.tsx +++ b/client/ui/frontend/src/modules/profile/ProfileContext.tsx @@ -56,9 +56,17 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { setActiveProfile(active.profileName || "default"); setProfiles(list); } catch (e) { + // Daemon-down is already surfaced globally by + // DaemonUnavailableOverlay; a second popup on top of it is + // pure noise. Every profile RPC routes through the same gRPC + // conn, so the Unavailable code is the reliable marker. + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("code = Unavailable")) { + return; + } await Dialogs.Error({ Title: i18next.t("profile.error.loadTitle"), - Message: e instanceof Error ? e.message : String(e), + Message: msg, }); } finally { setLoaded(true); diff --git a/client/ui/grpc.go b/client/ui/grpc.go index 1ee3e5518..33f781e8a 100644 --- a/client/ui/grpc.go +++ b/client/ui/grpc.go @@ -7,8 +7,10 @@ import ( "runtime" "strings" "sync" + "time" "google.golang.org/grpc" + "google.golang.org/grpc/backoff" "google.golang.org/grpc/credentials/insecure" "github.com/netbirdio/netbird/client/proto" @@ -39,6 +41,19 @@ func (c *Conn) Client() (proto.DaemonServiceClient, error) { strings.TrimPrefix(c.addr, "tcp://"), grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithUserAgent(desktop.GetUIUserAgent()), + // Without ConnectParams the SubChannel uses gRPC's default 120s + // MaxDelay, so after a couple of failed dials the UI waits 30-60s + // before noticing a freshly-started daemon. The Wails UI is a + // desktop client expecting prompt reconnects, not a high-fanout + // backend, so a 5s cap is a better trade-off than the default. + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 1 * time.Second, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 5 * time.Second, + }, + }), ) if err != nil { return nil, fmt.Errorf("dial daemon: %w", err) diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index 5503b2134..7d53a519a 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -253,14 +253,25 @@ func (s *Peers) ServiceShutdown() error { return nil } -// Get returns the current daemon status snapshot. +// 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. func (s *Peers) Get(ctx context.Context) (Status, error) { cli, err := s.conn.Client() if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } return Status{}, err } resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { + if isDaemonUnreachable(err) { + return Status{Status: StatusDaemonUnavailable}, nil + } return Status{}, err } return statusFromProto(resp), nil diff --git a/client/ui/tray.go b/client/ui/tray.go index 7c181a414..3e5fb6d9f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -214,6 +214,9 @@ func (t *Tray) reapplyMenuState() { if t.debugItem != nil { t.debugItem.SetEnabled(!daemonUnavailable) } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable) + } if daemonVersion != "" && t.daemonVersionItem != nil { t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion)) } @@ -511,6 +514,9 @@ func (t *Tray) applyStatus(st services.Status) { if t.debugItem != nil { t.debugItem.SetEnabled(!daemonUnavailable) } + if t.profileSubmenuItem != nil { + t.profileSubmenuItem.SetEnabled(!daemonUnavailable) + } // Refresh the Profiles submenu on every status-text transition: the // daemon does not emit an active-profile event, so the startup race // (UI loads profiles before autoconnect picks the persisted profile)