From 177b8a4338d0e923ceca2bc6f59274c5304d4a4e Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 1 Jul 2026 12:04:31 -0400 Subject: [PATCH] feat(mesh): show federated Archipelago nodes on the Mesh Map Peers that opt in via a new "Share Location" toggle in Settings (server.set-location RPC) get plotted on other trusted peers' Mesh Map with a distinct Archy-logo marker, separate from raw LoRa radio peers. Location is persisted locally, carried in NodeStateSnapshot, and propagated through federation sync/delta like other node state. Co-Authored-By: Claude Sonnet 5 --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + .../src/api/rpc/federation/handlers.rs | 7 ++ .../src/api/rpc/system/handlers.rs | 49 ++++++++++ core/archipelago/src/data_model.rs | 15 ++++ core/archipelago/src/federation/storage.rs | 2 + core/archipelago/src/federation/sync.rs | 10 ++- core/archipelago/src/federation/types.rs | 8 ++ core/archipelago/src/server.rs | 10 +++ core/archipelago/src/transport/delta.rs | 22 +++++ neode-ui/src/api/rpc-client.ts | 2 + neode-ui/src/components/MeshMap.vue | 76 +++++++++++++++- neode-ui/src/stores/mesh.ts | 46 ++++++++++ neode-ui/src/types/api.ts | 3 + neode-ui/src/views/Mesh.vue | 1 + .../src/views/settings/AccountInfoSection.vue | 89 +++++++++++++++++++ 15 files changed, 337 insertions(+), 4 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index a9bfea0a..1855cb6a 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -419,6 +419,7 @@ impl RpcHandler { // Server settings "server.set-name" => self.handle_server_set_name(params).await, + "server.set-location" => self.handle_server_set_location(params).await, // System monitoring "system.get-hostname" => self.handle_system_get_hostname().await, diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index 3088e8ac..e7a135b4 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -454,6 +454,12 @@ impl RpcHandler { .flatten(), }; + let shared_location = if data.server_info.share_location { + data.server_info.lat.zip(data.server_info.lon) + } else { + None + }; + let state = federation::build_local_state( apps, 0.0, @@ -467,6 +473,7 @@ impl RpcHandler { nostr_npub, own_fips_npub, &federated_peers, + shared_location, ); Ok(serde_json::to_value(&state)?) diff --git a/core/archipelago/src/api/rpc/system/handlers.rs b/core/archipelago/src/api/rpc/system/handlers.rs index 972a262e..512f2f3a 100644 --- a/core/archipelago/src/api/rpc/system/handlers.rs +++ b/core/archipelago/src/api/rpc/system/handlers.rs @@ -77,6 +77,55 @@ impl RpcHandler { })) } + /// server.set-location — Set this node's own lat/lon + whether to share + /// it with trusted federation peers (for the Mesh Map). `lat`/`lon` are + /// optional so a caller can flip `share` off without clearing the saved + /// position, or clear the position by passing nulls. + pub(in crate::api::rpc) async fn handle_server_set_location( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let lat = params.get("lat").and_then(|v| v.as_f64()); + let lon = params.get("lon").and_then(|v| v.as_f64()); + let share_location = params + .get("share") + .and_then(|v| v.as_bool()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: share"))?; + + if let (Some(lat), Some(lon)) = (lat, lon) { + if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { + anyhow::bail!("Invalid lat/lon"); + } + } + + let location_file = self.config.data_dir.join("server-location.json"); + let payload = serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location }); + tokio::fs::write(&location_file, serde_json::to_vec(&payload)?) + .await + .context("Failed to write server location")?; + + let (mut data, _) = self.state_manager.get_snapshot().await; + data.server_info.lat = lat; + data.server_info.lon = lon; + data.server_info.share_location = share_location; + self.state_manager.update_data(data).await; + + info!(share_location, "Server location updated"); + + // Push the new location to federation peers in background, same as + // a rename — trusted peers' next state sync picks it up. + let data_dir = self.config.data_dir.clone(); + let state_manager = self.state_manager.clone(); + tokio::spawn(async move { + if let Err(e) = push_name_to_peers(&data_dir, &state_manager).await { + debug!("Federation location push (non-fatal): {}", e); + } + }); + + Ok(serde_json::json!({ "lat": lat, "lon": lon, "share_location": share_location })) + } + /// system.get-hostname — Current OS hostname + the mDNS `.local` name it /// resolves to on the LAN (avahi-daemon advertises `.local`). /// Lets Settings show users where to reach this node over HTTPS for diff --git a/core/archipelago/src/data_model.rs b/core/archipelago/src/data_model.rs index 2eadd12d..55d458ec 100644 --- a/core/archipelago/src/data_model.rs +++ b/core/archipelago/src/data_model.rs @@ -61,6 +61,18 @@ pub struct ServerInfo { /// True if this node's keys are derived from a BIP-39 seed. #[serde(rename = "seed-backed", default)] pub seed_backed: bool, + /// This node's own physical location, for the Mesh Map — opt-in only + /// (see `share_location`), set via `server.set-location`. `None` until + /// the user sets one, regardless of `share_location`. + #[serde(default)] + pub lat: Option, + #[serde(default)] + pub lon: Option, + /// Whether `lat`/`lon` should be included in the state snapshot we send + /// to trusted federation peers (so they can plot us on their Mesh Map). + /// Defaults to false — never shared unless explicitly turned on. + #[serde(rename = "share-location", default)] + pub share_location: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -347,6 +359,9 @@ impl DataModel { wifi_ssids: vec![], zram_enabled: false, seed_backed: false, + lat: None, + lon: None, + share_location: false, }, package_data: HashMap::new(), peer_health: HashMap::new(), diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index 0f89b079..6b23bfd0 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -506,6 +506,8 @@ mod tests { nostr_npub: None, own_fips_npub: None, federated_peers: Vec::new(), + lat: None, + lon: None, }; update_node_state(dir.path(), "did:key:z1", state) diff --git a/core/archipelago/src/federation/sync.rs b/core/archipelago/src/federation/sync.rs index 5c460ca4..ffac8944 100644 --- a/core/archipelago/src/federation/sync.rs +++ b/core/archipelago/src/federation/sync.rs @@ -208,6 +208,7 @@ async fn merge_transitive_peers( /// and route directly over FIPS from now on). Only peers we trust are /// shared — an Untrusted/Observer node should not be re-exported /// through us to the network. +#[allow(clippy::too_many_arguments)] pub fn build_local_state( apps: Vec, cpu: f64, @@ -221,6 +222,9 @@ pub fn build_local_state( nostr_npub: Option, own_fips_npub: Option, federated_peers: &[FederatedNode], + // Only Some when the node has opted in via server.set-location's + // `share` flag — see NodeStateSnapshot::lat/lon's doc comment. + shared_location: Option<(f64, f64)>, ) -> NodeStateSnapshot { let hints = federated_peers .iter() @@ -248,6 +252,8 @@ pub fn build_local_state( nostr_npub, own_fips_npub, federated_peers: hints, + lat: shared_location.map(|(lat, _)| lat), + lon: shared_location.map(|(_, lon)| lon), } } @@ -341,12 +347,14 @@ mod tests { None, None, &[], + None, ); assert_eq!(state.apps.len(), 1); assert_eq!(state.cpu_usage_percent, Some(25.5)); assert_eq!(state.tor_active, Some(true)); assert_eq!(state.node_name, Some("Test Node".to_string())); assert!(state.federated_peers.is_empty()); + assert_eq!(state.lat, None); } #[test] @@ -392,7 +400,7 @@ mod tests { last_transport_at: None, }, ]; - let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers); + let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers, None); assert_eq!(state.federated_peers.len(), 1); assert_eq!(state.federated_peers[0].did, "did:key:zTrusted"); assert_eq!( diff --git a/core/archipelago/src/federation/types.rs b/core/archipelago/src/federation/types.rs index 332e1046..8194bab9 100644 --- a/core/archipelago/src/federation/types.rs +++ b/core/archipelago/src/federation/types.rs @@ -93,6 +93,14 @@ pub struct NodeStateSnapshot { /// re-export them in her own state snapshots). #[serde(default)] pub federated_peers: Vec, + /// This node's own location, for the Mesh Map — only present when the + /// sender has opted in via `server.set-location`'s `share` flag. Absent + /// (not just null) for nodes that haven't opted in, so older receivers + /// and the map's "no location shared" state both fall out naturally. + #[serde(default)] + pub lat: Option, + #[serde(default)] + pub lon: Option, } /// Minimal peer summary shared via `NodeStateSnapshot.federated_peers`. diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index e8b6facd..b5f6de3c 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -83,6 +83,16 @@ impl Server { data.server_info.name = Some(name); } } + // Load persisted node location (Mesh Map opt-in sharing) + let location_file = config.data_dir.join("server-location.json"); + if let Ok(bytes) = tokio::fs::read(&location_file).await { + if let Ok(loc) = serde_json::from_slice::(&bytes) { + data.server_info.lat = loc.get("lat").and_then(|v| v.as_f64()); + data.server_info.lon = loc.get("lon").and_then(|v| v.as_f64()); + data.server_info.share_location = + loc.get("share_location").and_then(|v| v.as_bool()).unwrap_or(false); + } + } data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await; if let Some(ref tor) = data.server_info.tor_address { data.server_info.node_address = Some(identity.node_address(tor)); diff --git a/core/archipelago/src/transport/delta.rs b/core/archipelago/src/transport/delta.rs index 9172e8d4..02590dff 100644 --- a/core/archipelago/src/transport/delta.rs +++ b/core/archipelago/src/transport/delta.rs @@ -48,6 +48,12 @@ pub struct StateDelta { /// Tor active flag (only if changed). #[serde(skip_serializing_if = "Option::is_none")] pub tor: Option, + /// Shared location (only if changed) — same "None means unchanged" + /// convention as the other scalar fields here. + #[serde(skip_serializing_if = "Option::is_none")] + pub lat: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub lon: Option, } /// Compute the delta between two state snapshots. @@ -114,6 +120,12 @@ pub fn compute_delta(prev: &NodeStateSnapshot, curr: &NodeStateSnapshot) -> Stat if curr.tor_active != prev.tor_active { delta.tor = curr.tor_active; } + if curr.lat != prev.lat { + delta.lat = curr.lat; + } + if curr.lon != prev.lon { + delta.lon = curr.lon; + } delta } @@ -162,6 +174,12 @@ pub fn apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSna if let Some(tor) = delta.tor { result.tor_active = Some(tor); } + if let Some(lat) = delta.lat { + result.lat = Some(lat); + } + if let Some(lon) = delta.lon { + result.lon = Some(lon); + } result } @@ -225,6 +243,8 @@ mod tests { nostr_npub: None, own_fips_npub: None, federated_peers: Vec::new(), + lat: None, + lon: None, } } @@ -259,6 +279,8 @@ mod tests { nostr_npub: None, own_fips_npub: None, federated_peers: Vec::new(), + lat: None, + lon: None, } } diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 48801f37..aafcd79a 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -721,6 +721,8 @@ class RPCClient { uptime_secs?: number tor_active?: boolean nostr_npub?: string + lat?: number | null + lon?: number | null } }> }> { diff --git a/neode-ui/src/components/MeshMap.vue b/neode-ui/src/components/MeshMap.vue index 85193582..f98a2245 100644 --- a/neode-ui/src/components/MeshMap.vue +++ b/neode-ui/src/components/MeshMap.vue @@ -13,7 +13,7 @@ const markersLayer = ref(null) const linesLayer = ref(null) // Whether we have any position data to show -const hasPositions = computed(() => mesh.nodePositions.size > 0) +const hasPositions = computed(() => mesh.nodePositions.size > 0 || mesh.federatedPositions.size > 0) // Location sharing state const sharingLocation = ref(false) @@ -108,6 +108,59 @@ function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon { }) } +/// Marker for an Archipelago node (federation peer) — the little Archy logo +/// in a glowing badge, distinct from the plain colored dots used for raw +/// LoRa mesh-radio peers. Trusted nodes get the warm orange ring/glow the +/// rest of the map already uses for "this is us/ours"; Observer nodes get a +/// cooler blue so the trust boundary is visible at a glance. +function createFederatedMarkerIcon(trusted: boolean): L.DivIcon { + const ring = trusted ? '#fb923c' : '#38bdf8' + const glow = trusted ? 'rgba(251,146,60,0.55)' : 'rgba(56,189,248,0.5)' + const size = 30 + return L.divIcon({ + className: 'mesh-map-marker-wrapper', + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + popupAnchor: [0, -(size / 2 + 2)], + html: ` +
+
+ +
+ `, + }) +} + +function buildFederatedPopupContent(name: string, onion: string, trusted: boolean): string { + const badge = trusted + ? 'TRUSTED' + : 'OBSERVER' + const onionShort = onion.length > 20 ? `${onion.slice(0, 10)}...${onion.slice(-6)}` : onion + return ` +
+
+ 📡 ${name}${badge} +
+
+ ${onionShort} +
+
Archipelago node
+
+ ` +} + function getSignalBars(rssi: number | null): string { if (rssi === null) return 'Unknown' if (rssi >= -70) return 'Strong' @@ -218,7 +271,8 @@ function updateMarkers() { linesLayer.value.clearLayers() const positions = mesh.nodePositions - if (positions.size === 0) return + const fedPositions = mesh.federatedPositions + if (positions.size === 0 && fedPositions.size === 0) return const bounds: L.LatLngExpression[] = [] const selfPos = positions.get(-1) @@ -267,6 +321,22 @@ function updateMarkers() { } }) + // Archipelago nodes (federation peers who opted into location sharing) — + // own logo marker, no signal/hops (that's a radio-peer concept). + fedPositions.forEach((fed) => { + const marker = L.marker([fed.lat, fed.lng], { + icon: createFederatedMarkerIcon(fed.trusted), + zIndexOffset: 500, + }) + marker.bindPopup(buildFederatedPopupContent(fed.name ?? 'Archipelago Node', fed.onion, fed.trusted), { + className: 'mesh-map-popup', + closeButton: true, + maxWidth: 250, + }) + markersLayer.value!.addLayer(marker) + bounds.push([fed.lat, fed.lng]) + }) + // Fit map to show all markers if (bounds.length > 1) { map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 }) @@ -283,7 +353,7 @@ function handleResize() { // Watch for changes in node positions and peers watch( - () => [mesh.nodePositions.size, mesh.peers.length], + () => [mesh.nodePositions.size, mesh.peers.length, mesh.federatedPositions.size], () => { updateMarkers() }, diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 3152c70a..ce346560 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -179,6 +179,19 @@ export interface NodePosition { timestamp: string } +/// An Archipelago node (federation peer), not a raw LoRa radio peer — shown +/// on the Mesh Map with its own marker (the Archy logo) since it's a whole +/// server, not a mesh-radio contact. Only ones that opted into +/// server.set-location's `share` flag ever carry a lat/lon. +export interface FederatedNodePosition { + did: string + lat: number + lng: number + name: string | null + onion: string + trusted: boolean +} + export const useMeshStore = defineStore('mesh', () => { const status = ref(null) const peers = ref([]) @@ -192,6 +205,10 @@ export const useMeshStore = defineStore('mesh', () => { // Node position tracking for map view (contact_id -> position) const nodePositions = ref>(new Map()) + // Federated Archipelago nodes with a shared location (DID -> position) — + // separate from nodePositions since these are whole servers, not radio + // peers, and render with a different marker on the Mesh Map. + const federatedPositions = ref>(new Map()) // Track unread message counts per peer (contact_id -> count) const unreadCounts = ref>({}) @@ -340,6 +357,33 @@ export const useMeshStore = defineStore('mesh', () => { }) } + // Federation nodes that opted into sharing their location (server.set-location + // `share: true`) — fed by Mesh.vue's existing federation.list-nodes poll. + function updateFederatedPositions(nodes: Array<{ + did: string + name?: string | null + onion: string + trust_level: string + last_state?: { lat?: number | null; lon?: number | null } | null + }>) { + const next = new Map() + for (const n of nodes) { + const lat = n.last_state?.lat + const lon = n.last_state?.lon + if (typeof lat === 'number' && typeof lon === 'number') { + next.set(n.did, { + did: n.did, + lat, + lng: lon, + name: n.name ?? null, + onion: n.onion, + trusted: n.trust_level === 'trusted', + }) + } + } + federatedPositions.value = next + } + function markChatRead(contactId: number) { viewingChatId.value = contactId delete unreadCounts.value[contactId] @@ -799,6 +843,8 @@ export const useMeshStore = defineStore('mesh', () => { unreadCounts, totalUnread, nodePositions, + federatedPositions, + updateFederatedPositions, deadmanStatus, blockHeaders, latestBlockHeight, diff --git a/neode-ui/src/types/api.ts b/neode-ui/src/types/api.ts index 16a95b77..714e060a 100644 --- a/neode-ui/src/types/api.ts +++ b/neode-ui/src/types/api.ts @@ -30,6 +30,9 @@ export interface ServerInfo { 'wifi-ssids': string[] 'zram-enabled': boolean 'seed-backed': boolean + lat?: number | null + lon?: number | null + 'share-location'?: boolean } export interface StatusInfo { diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index f55c90a5..14b3b07a 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -130,6 +130,7 @@ async function refreshFederationNodes() { }) } fedNodesByDid.value = next + mesh.updateFederatedPositions(res.nodes) } catch { /* non-fatal */ } } async function refreshSelfOnion() { diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 9feb5b60..165e38c5 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -68,6 +68,60 @@ const copiedOnion = ref(false) const copiedDid = ref(false) let copiedTimer: ReturnType | null = null +// Location sharing — opt-in only, off by default. Lets this node's own +// position appear on OTHER trusted federation peers' Mesh Map (with the +// Archy logo marker), the same way a mesh radio peer's position shows up. +// Backed by the store's serverInfo (already synced live over the WS), not +// a separate fetch, so it stays in sync with any other tab/session too. +const shareLocation = computed(() => !!store.serverInfo?.['share-location']) +const savedLat = computed(() => store.serverInfo?.lat ?? null) +const savedLon = computed(() => store.serverInfo?.lon ?? null) +const locationSaving = ref(false) +const locationError = ref('') + +async function useCurrentLocation() { + locationError.value = '' + if (!navigator.geolocation) { + locationError.value = 'Geolocation not supported by this browser' + return + } + locationSaving.value = true + navigator.geolocation.getCurrentPosition( + async (pos) => { + await saveLocation(pos.coords.latitude, pos.coords.longitude, shareLocation.value) + locationSaving.value = false + }, + (err) => { + locationError.value = err.code === 1 ? 'Location permission denied' : err.message + locationSaving.value = false + }, + { enableHighAccuracy: true, timeout: 15000 }, + ) +} + +async function toggleShareLocation() { + const next = !shareLocation.value + await saveLocation(savedLat.value, savedLon.value, next) +} + +async function saveLocation(lat: number | null, lon: number | null, share: boolean) { + try { + locationSaving.value = true + await rpcClient.call({ method: 'server.set-location', params: { lat, lon, share } }) + // Optimistic update — the next WS state push confirms it, but no need + // to wait for that round-trip to reflect the change in the toggle/coords. + if (store.serverInfo) { + store.serverInfo.lat = lat + store.serverInfo.lon = lon + store.serverInfo['share-location'] = share + } + } catch (e) { + locationError.value = e instanceof Error ? e.message : 'Failed to save location' + } finally { + locationSaving.value = false + } +} + // mDNS hostname — HTTPS (even self-signed) is required for mic/camera access // (getUserMedia refuses plain HTTP outside localhost); surface both so users // know where to go for features that need it, without forcing HTTPS on anyone. @@ -260,6 +314,41 @@ init() + +
+
+ + + + +

Share Location

+
+
+

Show this node on trusted peers' Mesh Map

+ +
+
+ {{ savedLat.toFixed(3) }}, {{ savedLon.toFixed(3) }} + No location set + +
+

{{ locationError }}

+
+