From e298747203cc1e1c8f4905b6bd7b1ddd769af52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 4 Jun 2026 16:03:22 +0200 Subject: [PATCH] [client/ui] Refresh profile list in tray + UI on CLI profile add/remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon emits no dedicated profile-changed RPC event, and a profile add/remove doesn't move the connection status, so the UI's SubscribeStatus path never fired for CLI-driven `netbird profile add|remove` (and the tray's iconChanged guard would swallow it anyway). The tray menu and the React profile list stayed stale until the next status-string transition. AddProfile/RemoveProfile now publish a marked INFO/SYSTEM event over SubscribeEvents (metadata kind=profile-list-changed, empty userMessage so it stays silent). The UI's dispatchSystemEvent recognises the marker and re-emits the existing EventProfileChanged, which the tray's loadProfiles and React's ProfileContext.refresh already subscribe to — so both surfaces refresh from a single signal that originates in the shared daemon handler (covering both CLI and UI-initiated removals). No proto change. Also drop a stray, build-breaking `app.Updater` line in main.go. --- client/server/server.go | 25 +++++++++++++++++++++++++ client/ui/services/daemon_feed.go | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/client/server/server.go b/client/server/server.go index 17107b8f2..6a7e4b530 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1864,6 +1864,8 @@ func (s *Server) AddProfile(ctx context.Context, msg *proto.AddProfileRequest) ( return nil, fmt.Errorf("failed to create profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.AddProfileResponse{}, nil } @@ -1885,9 +1887,32 @@ func (s *Server) RemoveProfile(ctx context.Context, msg *proto.RemoveProfileRequ return nil, fmt.Errorf("failed to remove profile: %w", err) } + s.publishProfileListChanged(msg.ProfileName) + return &proto.RemoveProfileResponse{}, nil } +// publishProfileListChanged nudges the desktop UI to refresh its profile list +// after a CLI-driven add/remove. The daemon exposes no dedicated +// profile-changed RPC event, and a profile add/remove doesn't move the +// connection status, so the UI's SubscribeStatus path never fires for it (and +// the tray's status-string guard would swallow it anyway). Instead we publish +// a marked INFO/SYSTEM event over SubscribeEvents: the UI's dispatchSystemEvent +// recognises the metadata "kind" marker and translates it into its internal +// profile-changed signal that both the tray menu and the React profile views +// already subscribe to (see client/ui/services/daemon_feed.go, +// MetadataKindProfileListChanged). userMessage is intentionally empty so this +// stays a silent refresh signal rather than a user-facing notification. +func (s *Server) publishProfileListChanged(profileName string) { + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "Profile list changed", + "", + map[string]string{"kind": "profile-list-changed", "profile": profileName}, + ) +} + // ListProfiles lists all profiles in the daemon. func (s *Server) ListProfiles(ctx context.Context, msg *proto.ListProfilesRequest) (*proto.ListProfilesResponse, error) { s.mutex.Lock() diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 05b82e1f9..da95c66df 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -46,6 +46,21 @@ const ( // tray (Go side) so the frontend stays passive on this flow. EventSessionWarning = "netbird:session:warning" + // MetadataKindProfileListChanged is the SystemEvent.metadata["kind"] + // marker the daemon stamps on the INFO/SYSTEM event it publishes after a + // CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated + // profile RPC event). dispatchSystemEvent recognises it and re-emits the + // existing EventProfileChanged so the tray and React profile views refresh + // — closing the gap the SubscribeStatus path can't, since a profile + // add/remove doesn't change the daemon's status string (the tray's + // iconChanged guard would swallow it). The daemon side hard-codes the same + // string literal in client/server/server.go (client/server cannot import + // this UI package). + MetadataKindProfileListChanged = "profile-list-changed" + // metadataKindKey is the SystemEvent.metadata key the "kind" marker lives + // under. Kept in sync with the daemon-side literal in client/server. + metadataKindKey = "kind" + // 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 @@ -526,6 +541,16 @@ 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. + if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged { + s.emitter.Emit(EventProfileChanged, ProfileRef{}) + return + } s.emitter.Emit(EventDaemonNotification, se) if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok { s.emitter.Emit(EventSessionWarning, warn)