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)