mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-26 14:29:08 +01:00
tray: selectable exit nodes + push-based network list refresh
Make the tray Exit Node submenu selectable (mutually exclusive, sourced from ListNetworks by NetID) instead of read-only. Add networksRevision to the status snapshot, bumped by the route manager on network-map and selection changes, so the tray and the React NetworksContext re-fetch ListNetworks via the push stream instead of polling. The peer-status route list only carries chosen routes, so a candidate exit node appearing or disappearing would otherwise never reach the UI.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -190,21 +191,21 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
|||||||
// every private-service request) don't contend against each other.
|
// every private-service request) don't contend against each other.
|
||||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
mux sync.RWMutex
|
mux sync.RWMutex
|
||||||
peers map[string]State
|
peers map[string]State
|
||||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||||
signalState bool
|
signalState bool
|
||||||
signalError error
|
signalError error
|
||||||
managementState bool
|
managementState bool
|
||||||
managementError error
|
managementError error
|
||||||
relayStates []relay.ProbeResult
|
relayStates []relay.ProbeResult
|
||||||
localPeer LocalPeerState
|
localPeer LocalPeerState
|
||||||
offlinePeers []State
|
offlinePeers []State
|
||||||
mgmAddress string
|
mgmAddress string
|
||||||
signalAddress string
|
signalAddress string
|
||||||
notifier *notifier
|
notifier *notifier
|
||||||
rosenpassEnabled bool
|
rosenpassEnabled bool
|
||||||
rosenpassPermissive bool
|
rosenpassPermissive bool
|
||||||
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
// sessionExpiresAt is the absolute UTC instant at which the peer's SSO
|
||||||
// session expires. Zero when the peer is not SSO-tracked or login
|
// session expires. Zero when the peer is not SSO-tracked or login
|
||||||
// expiration is disabled. Populated from management LoginResponse /
|
// expiration is disabled. Populated from management LoginResponse /
|
||||||
@@ -234,6 +235,13 @@ type Status struct {
|
|||||||
stateChangeMux sync.Mutex
|
stateChangeMux sync.Mutex
|
||||||
stateChangeStreams map[string]chan struct{}
|
stateChangeStreams map[string]chan struct{}
|
||||||
|
|
||||||
|
// networksRevision bumps whenever the routed-networks set or their
|
||||||
|
// selected state changes (driven by the route manager). Surfaced in the
|
||||||
|
// status snapshot so the UI can fingerprint on it and re-fetch
|
||||||
|
// ListNetworks only on a real change. Atomic so the snapshot builder can
|
||||||
|
// read it without taking mux.
|
||||||
|
networksRevision atomic.Uint64
|
||||||
|
|
||||||
ingressGwMgr *ingressgw.Manager
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -1355,6 +1363,23 @@ func (d *Status) NotifyStateChange() {
|
|||||||
d.notifyStateChange()
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BumpNetworksRevision increments the routed-networks revision and wakes every
|
||||||
|
// SubscribeStatus subscriber. The route manager calls it when a network map
|
||||||
|
// changes the available routes or when a selection is applied — the peer
|
||||||
|
// status itself only records actively-routed (chosen) networks, so without
|
||||||
|
// this bump a candidate route appearing/disappearing would never reach the UI.
|
||||||
|
func (d *Status) BumpNetworksRevision() {
|
||||||
|
d.networksRevision.Add(1)
|
||||||
|
d.notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNetworksRevision returns the current routed-networks revision, surfaced in
|
||||||
|
// the status snapshot so the UI can detect route/selection changes (see
|
||||||
|
// BumpNetworksRevision).
|
||||||
|
func (d *Status) GetNetworksRevision() uint64 {
|
||||||
|
return d.networksRevision.Load()
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -439,6 +439,11 @@ func (m *DefaultManager) UpdateRoutes(
|
|||||||
|
|
||||||
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
m.updateClientNetworks(updateSerial, filteredClientRoutes)
|
||||||
m.notifier.OnNewRoutes(filteredClientRoutes)
|
m.notifier.OnNewRoutes(filteredClientRoutes)
|
||||||
|
// A new network map can add or drop route/exit-node candidates without
|
||||||
|
// touching any peer's chosen-route state, so the peer status alone
|
||||||
|
// wouldn't notify SubscribeStatus subscribers. Bump the revision so the
|
||||||
|
// UI re-fetches ListNetworks.
|
||||||
|
m.statusRecorder.BumpNetworksRevision()
|
||||||
}
|
}
|
||||||
m.clientRoutes = clientRoutes
|
m.clientRoutes = clientRoutes
|
||||||
|
|
||||||
@@ -579,6 +584,10 @@ func (m *DefaultManager) TriggerSelection(networks route.HAMap) {
|
|||||||
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
if err := m.stateManager.UpdateState((*SelectorState)(m.routeSelector)); err != nil {
|
||||||
log.Errorf("failed to update state: %v", err)
|
log.Errorf("failed to update state: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A selection change flips Network.selected without altering the candidate
|
||||||
|
// set, so bump the revision to push the new state to the UI.
|
||||||
|
m.statusRecorder.BumpNetworksRevision()
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
// stopObsoleteClients stops the client network watcher for the networks that are not in the new list
|
||||||
|
|||||||
@@ -2122,8 +2122,13 @@ type FullStatus struct {
|
|||||||
Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"`
|
Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"`
|
||||||
LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"`
|
LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"`
|
||||||
SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"`
|
SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// networksRevision bumps whenever the set of routed networks (route and
|
||||||
sizeCache protoimpl.SizeCache
|
// exit-node candidates) or their selected state changes. The UI fingerprints
|
||||||
|
// on it to know when to re-fetch ListNetworks via the push stream, instead
|
||||||
|
// of polling on every status snapshot.
|
||||||
|
NetworksRevision uint64 `protobuf:"varint,11,opt,name=networksRevision,proto3" json:"networksRevision,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *FullStatus) Reset() {
|
func (x *FullStatus) Reset() {
|
||||||
@@ -2226,6 +2231,13 @@ func (x *FullStatus) GetSshServerState() *SSHServerState {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *FullStatus) GetNetworksRevision() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.NetworksRevision
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
type ListNetworksRequest struct {
|
type ListNetworksRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -6763,7 +6775,7 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" +
|
"\fportForwards\x18\x05 \x03(\tR\fportForwards\"^\n" +
|
||||||
"\x0eSSHServerState\x12\x18\n" +
|
"\x0eSSHServerState\x12\x18\n" +
|
||||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
|
"\aenabled\x18\x01 \x01(\bR\aenabled\x122\n" +
|
||||||
"\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xaf\x04\n" +
|
"\bsessions\x18\x02 \x03(\v2\x16.daemon.SSHSessionInfoR\bsessions\"\xdb\x04\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"FullStatus\x12A\n" +
|
"FullStatus\x12A\n" +
|
||||||
"\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" +
|
"\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" +
|
||||||
@@ -6777,7 +6789,8 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" +
|
"\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" +
|
||||||
"\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" +
|
"\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" +
|
||||||
"\x0esshServerState\x18\n" +
|
"\x0esshServerState\x18\n" +
|
||||||
" \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\"\x15\n" +
|
" \x01(\v2\x16.daemon.SSHServerStateR\x0esshServerState\x12*\n" +
|
||||||
|
"\x10networksRevision\x18\v \x01(\x04R\x10networksRevision\"\x15\n" +
|
||||||
"\x13ListNetworksRequest\"?\n" +
|
"\x13ListNetworksRequest\"?\n" +
|
||||||
"\x14ListNetworksResponse\x12'\n" +
|
"\x14ListNetworksResponse\x12'\n" +
|
||||||
"\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" +
|
"\x06routes\x18\x01 \x03(\v2\x0f.daemon.NetworkR\x06routes\"a\n" +
|
||||||
|
|||||||
@@ -443,6 +443,12 @@ message FullStatus {
|
|||||||
|
|
||||||
bool lazyConnectionEnabled = 9;
|
bool lazyConnectionEnabled = 9;
|
||||||
SSHServerState sshServerState = 10;
|
SSHServerState sshServerState = 10;
|
||||||
|
|
||||||
|
// networksRevision bumps whenever the set of routed networks (route and
|
||||||
|
// exit-node candidates) or their selected state changes. The UI fingerprints
|
||||||
|
// on it to know when to re-fetch ListNetworks via the push stream, instead
|
||||||
|
// of polling on every status snapshot.
|
||||||
|
uint64 networksRevision = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Networks
|
// Networks
|
||||||
|
|||||||
@@ -1242,6 +1242,7 @@ func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusRes
|
|||||||
pbFullStatus := fullStatus.ToProto()
|
pbFullStatus := fullStatus.ToProto()
|
||||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||||
|
pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision()
|
||||||
statusResponse.FullStatus = pbFullStatus
|
statusResponse.FullStatus = pbFullStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Networks as NetworksSvc } from "@bindings/services";
|
import { Networks as NetworksSvc } from "@bindings/services";
|
||||||
import type { Network } from "@bindings/services/models.js";
|
import type { Network } from "@bindings/services/models.js";
|
||||||
|
import { useStatus } from "@/modules/daemon-status/StatusContext";
|
||||||
|
|
||||||
// A range is treated as an exit-node candidate when any of its CIDRs is a
|
// A range is treated as an exit-node candidate when any of its CIDRs is a
|
||||||
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
|
// default route (v4 or v6). The daemon may merge a v4+v6 pair into a single
|
||||||
@@ -40,6 +41,7 @@ export const useNetworks = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { status } = useStatus();
|
||||||
const [routes, setRoutes] = useState<Network[]>([]);
|
const [routes, setRoutes] = useState<Network[]>([]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -51,9 +53,15 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The daemon bumps networksRevision whenever the routed-network set or a
|
||||||
|
// selection changes (from any surface) and pushes it on the status stream.
|
||||||
|
// Refetch on every bump so the list stays live without polling — and on
|
||||||
|
// mount, since the revision is already defined by the time this provider
|
||||||
|
// renders (StatusProvider only mounts children once the daemon is reachable).
|
||||||
|
const networksRevision = status?.networksRevision;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh, networksRevision]);
|
||||||
|
|
||||||
const toggleNetwork = useCallback(
|
const toggleNetwork = useCallback(
|
||||||
async (id: string, selected: boolean) => {
|
async (id: string, selected: boolean) => {
|
||||||
@@ -120,7 +128,5 @@ export const NetworksProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
};
|
};
|
||||||
}, [routes, refresh, toggleNetwork, toggleExitNode]);
|
}, [routes, refresh, toggleNetwork, toggleExitNode]);
|
||||||
|
|
||||||
return (
|
return <NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>;
|
||||||
<NetworksContext.Provider value={value}>{children}</NetworksContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"notify.error.connect": "Verbindung fehlgeschlagen",
|
"notify.error.connect": "Verbindung fehlgeschlagen",
|
||||||
"notify.error.disconnect": "Trennen fehlgeschlagen",
|
"notify.error.disconnect": "Trennen fehlgeschlagen",
|
||||||
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
|
"notify.error.switchProfile": "Wechsel zu {profile} fehlgeschlagen",
|
||||||
|
"notify.error.exitNode": "Exit-Node {name} konnte nicht aktualisiert werden",
|
||||||
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
|
"notify.sessionExpired.title": "NetBird-Sitzung abgelaufen",
|
||||||
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
"notify.sessionExpired.body": "Ihre NetBird-Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||||
"notify.sessionWarning.title": "Sitzung läuft bald ab",
|
"notify.sessionWarning.title": "Sitzung läuft bald ab",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"notify.error.connect": "Failed to connect",
|
"notify.error.connect": "Failed to connect",
|
||||||
"notify.error.disconnect": "Failed to disconnect",
|
"notify.error.disconnect": "Failed to disconnect",
|
||||||
"notify.error.switchProfile": "Failed to switch to {profile}",
|
"notify.error.switchProfile": "Failed to switch to {profile}",
|
||||||
|
"notify.error.exitNode": "Failed to update exit node {name}",
|
||||||
"notify.sessionExpired.title": "NetBird session expired",
|
"notify.sessionExpired.title": "NetBird session expired",
|
||||||
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
|
"notify.sessionExpired.body": "Your NetBird session has expired. Please log in again.",
|
||||||
"notify.sessionWarning.title": "Session expires soon",
|
"notify.sessionWarning.title": "Session expires soon",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"notify.error.connect": "Csatlakozás sikertelen",
|
"notify.error.connect": "Csatlakozás sikertelen",
|
||||||
"notify.error.disconnect": "Bontás sikertelen",
|
"notify.error.disconnect": "Bontás sikertelen",
|
||||||
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
|
"notify.error.switchProfile": "Átváltás sikertelen erre: {profile}",
|
||||||
|
"notify.error.exitNode": "A kilépő csomópont frissítése sikertelen: {name}",
|
||||||
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
|
"notify.sessionExpired.title": "NetBird munkamenet lejárt",
|
||||||
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
"notify.sessionExpired.body": "A NetBird munkamenet lejárt. Kérjük, jelentkezzen be újra.",
|
||||||
"notify.sessionWarning.title": "Munkamenet hamarosan lejár",
|
"notify.sessionWarning.title": "Munkamenet hamarosan lejár",
|
||||||
|
|||||||
+3
-1
@@ -167,7 +167,8 @@ func main() {
|
|||||||
authSession := authsession.NewSession(conn)
|
authSession := authsession.NewSession(conn)
|
||||||
app.RegisterService(application.NewService(services.NewSession(authSession)))
|
app.RegisterService(application.NewService(services.NewSession(authSession)))
|
||||||
app.RegisterService(application.NewService(settings))
|
app.RegisterService(application.NewService(settings))
|
||||||
app.RegisterService(application.NewService(services.NewNetworks(conn)))
|
networks := services.NewNetworks(conn)
|
||||||
|
app.RegisterService(application.NewService(networks))
|
||||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||||
app.RegisterService(application.NewService(profiles))
|
app.RegisterService(application.NewService(profiles))
|
||||||
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
||||||
@@ -233,6 +234,7 @@ func main() {
|
|||||||
Connection: connection,
|
Connection: connection,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Profiles: profiles,
|
Profiles: profiles,
|
||||||
|
Networks: networks,
|
||||||
Peers: peers,
|
Peers: peers,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
Update: update,
|
Update: update,
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ type Status struct {
|
|||||||
Local LocalPeer `json:"local"`
|
Local LocalPeer `json:"local"`
|
||||||
Peers []PeerStatus `json:"peers"`
|
Peers []PeerStatus `json:"peers"`
|
||||||
Events []SystemEvent `json:"events"`
|
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.
|
||||||
|
NetworksRevision uint64 `json:"networksRevision"`
|
||||||
// SessionExpiresAt is the absolute UTC instant at which the peer's
|
// SessionExpiresAt is the absolute UTC instant at which the peer's
|
||||||
// SSO session expires. nil when the peer is not SSO-tracked or login
|
// SSO session expires. nil when the peer is not SSO-tracked or login
|
||||||
// expiration is disabled (either server-side off, or peer not
|
// expiration is disabled (either server-side off, or peer not
|
||||||
@@ -425,8 +429,9 @@ func statusFromProto(resp *proto.StatusResponse) Status {
|
|||||||
local := full.GetLocalPeerState()
|
local := full.GetLocalPeerState()
|
||||||
|
|
||||||
st := Status{
|
st := Status{
|
||||||
Status: resp.GetStatus(),
|
Status: resp.GetStatus(),
|
||||||
DaemonVersion: resp.GetDaemonVersion(),
|
DaemonVersion: resp.GetDaemonVersion(),
|
||||||
|
NetworksRevision: full.GetNetworksRevision(),
|
||||||
Management: PeerLink{
|
Management: PeerLink{
|
||||||
URL: mgmt.GetURL(),
|
URL: mgmt.GetURL(),
|
||||||
Connected: mgmt.GetConnected(),
|
Connected: mgmt.GetConnected(),
|
||||||
|
|||||||
+163
-74
@@ -73,6 +73,7 @@ type TrayServices struct {
|
|||||||
Connection *services.Connection
|
Connection *services.Connection
|
||||||
Settings *services.Settings
|
Settings *services.Settings
|
||||||
Profiles *services.Profiles
|
Profiles *services.Profiles
|
||||||
|
Networks *services.Networks
|
||||||
Peers *services.Peers
|
Peers *services.Peers
|
||||||
Notifier *notifications.NotificationService
|
Notifier *notifications.NotificationService
|
||||||
Update *services.Update
|
Update *services.Update
|
||||||
@@ -122,9 +123,18 @@ type Tray struct {
|
|||||||
|
|
||||||
updater *trayUpdater
|
updater *trayUpdater
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
connected bool
|
connected bool
|
||||||
exitNodes []string
|
// exitNodes are the rows currently painted into the Exit Node submenu,
|
||||||
|
// sourced from Networks.List() (NetID + selected state) so each row can be
|
||||||
|
// toggled. lastNetworksRevision is the daemon's routed-networks revision
|
||||||
|
// from the last Status snapshot; a bump in it — or a connect/disconnect
|
||||||
|
// transition — is what triggers a refreshExitNodes re-fetch, so we hit
|
||||||
|
// ListNetworks only when routes or their selection actually change rather
|
||||||
|
// than on every push. The peer-status route list can't be used here: it
|
||||||
|
// only carries actively-routed (chosen) routes, not candidate exit nodes.
|
||||||
|
exitNodes []exitNodeEntry
|
||||||
|
lastNetworksRevision uint64
|
||||||
lastStatus string
|
lastStatus string
|
||||||
lastDaemonVersion string
|
lastDaemonVersion string
|
||||||
notificationsEnabled bool
|
notificationsEnabled bool
|
||||||
@@ -148,6 +158,11 @@ type Tray struct {
|
|||||||
// SetMenu, which the Wails menu API is not safe against concurrent
|
// SetMenu, which the Wails menu API is not safe against concurrent
|
||||||
// callers.
|
// callers.
|
||||||
profileLoadMu sync.Mutex
|
profileLoadMu sync.Mutex
|
||||||
|
// exitNodesLoadMu serializes refreshExitNodes for the same reason: the
|
||||||
|
// Status stream can fire several pushes in quick succession and each may
|
||||||
|
// kick a refresh, but the ListNetworks fetch + submenu rebuild + SetMenu
|
||||||
|
// must not run concurrently with itself.
|
||||||
|
exitNodesLoadMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
|
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
|
||||||
@@ -267,7 +282,7 @@ func (t *Tray) reapplyMenuState() {
|
|||||||
connected := t.connected
|
connected := t.connected
|
||||||
lastStatus := t.lastStatus
|
lastStatus := t.lastStatus
|
||||||
daemonVersion := t.lastDaemonVersion
|
daemonVersion := t.lastDaemonVersion
|
||||||
exitNodes := append([]string(nil), t.exitNodes...)
|
exitNodes := append([]exitNodeEntry(nil), t.exitNodes...)
|
||||||
sessionDeadline := t.sessionExpiresAt
|
sessionDeadline := t.sessionExpiresAt
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
@@ -311,7 +326,13 @@ func (t *Tray) reapplyMenuState() {
|
|||||||
if t.updater != nil {
|
if t.updater != nil {
|
||||||
t.updater.applyLanguage()
|
t.updater.applyLanguage()
|
||||||
}
|
}
|
||||||
|
// buildMenu just recreated an empty Exit Node submenu, so repaint the
|
||||||
|
// cached rows unconditionally (a refreshExitNodes would skip the rebuild
|
||||||
|
// when the list is unchanged). Hold exitNodesLoadMu so this rebuild can't
|
||||||
|
// race a status-push-driven refreshExitNodes mutating the same submenu.
|
||||||
|
t.exitNodesLoadMu.Lock()
|
||||||
t.rebuildExitNodes(exitNodes)
|
t.rebuildExitNodes(exitNodes)
|
||||||
|
t.exitNodesLoadMu.Unlock()
|
||||||
go t.loadProfiles()
|
go t.loadProfiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,9 +666,8 @@ func (t *Tray) applyStatus(st services.Status) {
|
|||||||
t.lastDaemonVersion = st.DaemonVersion
|
t.lastDaemonVersion = st.DaemonVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
exitNodes := exitNodesFromStatus(st)
|
revisionChanged := st.NetworksRevision != t.lastNetworksRevision
|
||||||
exitNodesChanged := !equalStrings(exitNodes, t.exitNodes)
|
t.lastNetworksRevision = st.NetworksRevision
|
||||||
t.exitNodes = exitNodes
|
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
if triggerLogin {
|
if triggerLogin {
|
||||||
@@ -687,15 +707,10 @@ func (t *Tray) applyStatus(st services.Status) {
|
|||||||
t.downItem.SetHidden(!connected && !connecting)
|
t.downItem.SetHidden(!connected && !connecting)
|
||||||
t.downItem.SetEnabled(connected || connecting)
|
t.downItem.SetEnabled(connected || connecting)
|
||||||
}
|
}
|
||||||
// Exit Node surfaces tunnel-routed state, so only expose it while
|
// Exit Node parent-item enablement (greyed unless the tunnel is up
|
||||||
// the tunnel is up AND the account actually has at least one
|
// AND at least one candidate exists) is owned by refreshExitNodes,
|
||||||
// exit-node candidate (a peer advertising 0.0.0.0/0 or ::/0).
|
// triggered below on this same transition. Settings just needs the
|
||||||
// The row stays visible but greyed when no candidate is around,
|
// daemon socket reachable.
|
||||||
// so the user can tell the feature exists. Settings just needs
|
|
||||||
// the daemon socket reachable.
|
|
||||||
if t.exitNodeItem != nil {
|
|
||||||
t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0)
|
|
||||||
}
|
|
||||||
if t.settingsItem != nil {
|
if t.settingsItem != nil {
|
||||||
t.settingsItem.SetEnabled(!daemonUnavailable)
|
t.settingsItem.SetEnabled(!daemonUnavailable)
|
||||||
}
|
}
|
||||||
@@ -713,20 +728,14 @@ func (t *Tray) applyStatus(st services.Status) {
|
|||||||
// reads item.disabled/item.hidden at NSMenuItem construction time.
|
// reads item.disabled/item.hidden at NSMenuItem construction time.
|
||||||
go t.loadProfiles()
|
go t.loadProfiles()
|
||||||
}
|
}
|
||||||
if exitNodesChanged {
|
// Re-fetch the selectable exit-node list whenever the daemon's routed-
|
||||||
// Re-evaluate the parent item's enablement here too, not only in
|
// networks revision bumps (a route candidate added/removed, or a selection
|
||||||
// the iconChanged block above: the daemon ships peer routes in a
|
// applied from any surface) or the tunnel flips state (iconChanged). The
|
||||||
// later snapshot than the Connected status text (networks=[] first,
|
// revision is the only reliable signal: candidate routes never appear in
|
||||||
// then populated), so the candidate list can flip empty→non-empty
|
// the peer-status snapshot, so a removed exit node would otherwise go
|
||||||
// while the status string is unchanged. Without this the parent
|
// unnoticed. The refresh owns the parent item's enablement and the rebuild.
|
||||||
// "Exit Node" item stays greyed from when it was last set on the
|
if iconChanged || revisionChanged {
|
||||||
// status transition and the freshly painted rows are unreachable.
|
go t.refreshExitNodes()
|
||||||
// Set before rebuildExitNodes' SetMenu so the rebuild reads the
|
|
||||||
// updated enabled state at NSMenuItem construction time.
|
|
||||||
if t.exitNodeItem != nil {
|
|
||||||
t.exitNodeItem.SetEnabled(connected && len(exitNodes) > 0)
|
|
||||||
}
|
|
||||||
t.rebuildExitNodes(exitNodes)
|
|
||||||
}
|
}
|
||||||
if daemonVersionChanged && t.daemonVersionItem != nil {
|
if daemonVersionChanged && t.daemonVersionItem != nil {
|
||||||
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion))
|
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", st.DaemonVersion))
|
||||||
@@ -754,27 +763,114 @@ func (t *Tray) handleSessionExpired() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebuildExitNodes paints one row per exit-node candidate into the
|
// rebuildExitNodes paints one clickable row per exit-node candidate into the
|
||||||
// Exit Node submenu. The list is read-only for now — selection wiring
|
// Exit Node submenu. Each row carries the network's NetID and its selected
|
||||||
// would need ListNetworks + a peer-FQDN → network-ID lookup, which the
|
// state from ListNetworks; clicking toggles it via toggleExitNode. The active
|
||||||
// PeerStatus stream doesn't ship. Rebuilds via Clear + Add so the row
|
// node is marked with a "✓ " prefix using a plain Add rather than AddCheckbox
|
||||||
// set stays in sync with the daemon snapshot; SetMenu on the root menu
|
// for the same reason as loadProfiles — Wails auto-toggles a checkbox's state
|
||||||
// is required because Wails v3 alpha menu Update() builds a detached
|
// on click before the OnClick handler runs, so the deselect/select round-trip
|
||||||
// NSMenu on darwin that never replaces the empty submenu attached at
|
// would briefly show two checked rows. Rebuilds via Clear + Add so the row set
|
||||||
// initial setup (same workaround as loadProfiles).
|
// stays in sync; SetMenu on the root menu is required because Wails v3 alpha
|
||||||
func (t *Tray) rebuildExitNodes(nodes []string) {
|
// menu Update() builds a detached NSMenu on darwin that never replaces the
|
||||||
|
// empty submenu attached at initial setup (same workaround as loadProfiles).
|
||||||
|
// Callers must hold exitNodesLoadMu so concurrent rebuilds can't race the
|
||||||
|
// submenu's item slice.
|
||||||
|
func (t *Tray) rebuildExitNodes(nodes []exitNodeEntry) {
|
||||||
if t.exitNodeSubmenu == nil {
|
if t.exitNodeSubmenu == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.exitNodeSubmenu.Clear()
|
t.exitNodeSubmenu.Clear()
|
||||||
for _, fqdn := range nodes {
|
for _, n := range nodes {
|
||||||
t.exitNodeSubmenu.Add(fqdn).SetEnabled(false)
|
id := n.ID
|
||||||
|
selected := n.Selected
|
||||||
|
label := id
|
||||||
|
if selected {
|
||||||
|
label = "✓ " + id
|
||||||
|
}
|
||||||
|
t.exitNodeSubmenu.Add(label).OnClick(func(*application.Context) {
|
||||||
|
t.toggleExitNode(id, selected)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if t.menu != nil {
|
if t.menu != nil {
|
||||||
t.tray.SetMenu(t.menu)
|
t.tray.SetMenu(t.menu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshExitNodes re-fetches the routed-network list from the daemon and
|
||||||
|
// repaints the Exit Node submenu. Sourcing the rows from Networks.List() (not
|
||||||
|
// the Status stream) is what makes them selectable: the stream only ships peer
|
||||||
|
// FQDNs, whereas ListNetworks returns the NetID + selected state the
|
||||||
|
// Select/Deselect RPCs need. Serialized by exitNodesLoadMu so overlapping
|
||||||
|
// Status pushes can't race the submenu rebuild. Owns the parent item's
|
||||||
|
// enablement: greyed unless the tunnel is up and at least one candidate exists.
|
||||||
|
func (t *Tray) refreshExitNodes() {
|
||||||
|
t.exitNodesLoadMu.Lock()
|
||||||
|
defer t.exitNodesLoadMu.Unlock()
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
connected := t.connected
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
var nodes []exitNodeEntry
|
||||||
|
if connected {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
list, err := t.svc.Networks.List(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("tray list networks: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodes = exitNodesFromNetworks(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("tray refreshExitNodes: %d exit node(s)", len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
log.Infof("tray exit node: id=%q selected=%v", n.ID, n.Selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
changed := !equalExitNodes(nodes, t.exitNodes)
|
||||||
|
t.exitNodes = nodes
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Set enablement before rebuildExitNodes' SetMenu so the rebuild reads the
|
||||||
|
// updated state at NSMenuItem construction time (Wails v3 alpha reads
|
||||||
|
// item.disabled at build time, not lazily).
|
||||||
|
if t.exitNodeItem != nil {
|
||||||
|
t.exitNodeItem.SetEnabled(connected && len(nodes) > 0)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
t.rebuildExitNodes(nodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleExitNode activates or deactivates one exit node by NetID. Exit nodes
|
||||||
|
// are mutually exclusive, so Select uses append=false to clear any other
|
||||||
|
// active node before turning this one on; deselecting an active node turns
|
||||||
|
// routing off entirely. Mirrors the frontend's toggleExitNode semantics. Runs
|
||||||
|
// the RPC off the menu-click goroutine and re-fetches so the ✓ moves to the
|
||||||
|
// new selection.
|
||||||
|
func (t *Tray) toggleExitNode(id string, selected bool) {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
params := services.SelectNetworksParams{NetworkIDs: []string{id}, Append: false, All: false}
|
||||||
|
var err error
|
||||||
|
if selected {
|
||||||
|
err = t.svc.Networks.Deselect(ctx, params)
|
||||||
|
} else {
|
||||||
|
err = t.svc.Networks.Select(ctx, params)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("tray toggle exit node %q: %v", id, err)
|
||||||
|
t.notifyError(t.loc.T("notify.error.exitNode", "name", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.refreshExitNodes()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// applyStatusIndicator sets the small coloured dot shown on the status
|
// applyStatusIndicator sets the small coloured dot shown on the status
|
||||||
// menu entry. The dot mirrors the tray icon's state through a fixed
|
// menu entry. The dot mirrors the tray icon's state through a fixed
|
||||||
// palette: green for Connected, yellow for Connecting, blue for the
|
// palette: green for Connected, yellow for Connecting, blue for the
|
||||||
@@ -955,13 +1051,11 @@ func (t *Tray) loadProfiles() {
|
|||||||
}
|
}
|
||||||
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
||||||
|
|
||||||
log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username)
|
|
||||||
t.profileSubmenu.Clear()
|
t.profileSubmenu.Clear()
|
||||||
var activeName, activeEmail string
|
var activeName, activeEmail string
|
||||||
for _, p := range profiles {
|
for _, p := range profiles {
|
||||||
name := p.Name
|
name := p.Name
|
||||||
active := p.IsActive
|
active := p.IsActive
|
||||||
log.Infof("tray loadProfiles: profile=%q active=%v", name, active)
|
|
||||||
// Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's
|
// Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's
|
||||||
// checked state on click (before the OnClick handler fires), so with
|
// checked state on click (before the OnClick handler fires), so with
|
||||||
// AddCheckbox both the old and the new profile would briefly show as
|
// AddCheckbox both the old and the new profile would briefly show as
|
||||||
@@ -984,6 +1078,7 @@ func (t *Tray) loadProfiles() {
|
|||||||
activeEmail = p.Email
|
activeEmail = p.Email
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.Infof("tray loadProfiles: received %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
|
||||||
if t.profileSubmenuItem != nil && activeName != "" {
|
if t.profileSubmenuItem != nil && activeName != "" {
|
||||||
t.profileSubmenuItem.SetLabel(activeName)
|
t.profileSubmenuItem.SetLabel(activeName)
|
||||||
}
|
}
|
||||||
@@ -1357,44 +1452,38 @@ func (t *Tray) notifyError(message string) {
|
|||||||
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
|
t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// exitNodesFromStatus returns the FQDNs of peers advertising an IPv4
|
// exitNodeEntry is one selectable row in the Exit Node submenu. ID is the
|
||||||
// or IPv6 default route (`0.0.0.0/0` or `::/0`) — the only candidates
|
// network's NetID — both the row label and the argument the Select/Deselect
|
||||||
// the user can pick as an exit node. The daemon ships each peer's
|
// RPCs take; Selected drives the ✓ prefix.
|
||||||
// route table as `maps.Keys(...)` of a CIDR-keyed map (see
|
type exitNodeEntry struct {
|
||||||
// client/internal/peer/status.go: pbPeerState.Networks = maps.Keys(
|
ID string
|
||||||
// peerState.GetRoutes())), so we parse each entry with netip and
|
Selected bool
|
||||||
// match by `Bits()==0 && Addr().IsUnspecified()` rather than
|
}
|
||||||
// string-comparing "0.0.0.0/0" — that catches the v4/v6 partner
|
|
||||||
// management pairs together for a dual-stack exit, and tolerates any
|
// exitNodesFromNetworks filters the daemon's routed-network list down to
|
||||||
// future canonicalisation of the prefix string.
|
// exit-node candidates (a default-route range) and maps them to selectable
|
||||||
func exitNodesFromStatus(st services.Status) []string {
|
// rows. Sorted case-insensitively by ID so the submenu reads alphabetically.
|
||||||
seen := map[string]struct{}{}
|
func exitNodesFromNetworks(networks []services.Network) []exitNodeEntry {
|
||||||
out := []string{}
|
out := []exitNodeEntry{}
|
||||||
for _, p := range st.Peers {
|
for _, n := range networks {
|
||||||
if p.Fqdn == "" {
|
if !rangeIsDefaultRoute(n.Range) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !advertisesDefaultRoute(p.Networks) {
|
out = append(out, exitNodeEntry{ID: n.ID, Selected: n.Selected})
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[p.Fqdn]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[p.Fqdn] = struct{}{}
|
|
||||||
out = append(out, p.Fqdn)
|
|
||||||
}
|
}
|
||||||
// Case-insensitive sort so the submenu reads alphabetically the
|
|
||||||
// way a human would — sort.Strings alone would put every
|
|
||||||
// uppercase letter ahead of any lowercase one.
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
return strings.ToLower(out[i]) < strings.ToLower(out[j])
|
return strings.ToLower(out[i].ID) < strings.ToLower(out[j].ID)
|
||||||
})
|
})
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func advertisesDefaultRoute(networks []string) bool {
|
// rangeIsDefaultRoute reports whether a Network.Range string contains an IPv4
|
||||||
for _, n := range networks {
|
// or IPv6 default route. The daemon may merge a v4+v6 exit pair into a single
|
||||||
pref, err := netip.ParsePrefix(n)
|
// comma-joined range ("0.0.0.0/0, ::/0"), so we split and check each part,
|
||||||
|
// matching by Bits()==0 && unspecified rather than a literal string compare.
|
||||||
|
func rangeIsDefaultRoute(r string) bool {
|
||||||
|
for _, part := range strings.Split(r, ",") {
|
||||||
|
pref, err := netip.ParsePrefix(strings.TrimSpace(part))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1494,7 @@ func advertisesDefaultRoute(networks []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func equalStrings(a, b []string) bool {
|
func equalExitNodes(a, b []exitNodeEntry) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user