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 <noreply@anthropic.com>
This commit is contained in:
parent
e3baaa5de3
commit
177b8a4338
@ -419,6 +419,7 @@ impl RpcHandler {
|
|||||||
|
|
||||||
// Server settings
|
// Server settings
|
||||||
"server.set-name" => self.handle_server_set_name(params).await,
|
"server.set-name" => self.handle_server_set_name(params).await,
|
||||||
|
"server.set-location" => self.handle_server_set_location(params).await,
|
||||||
|
|
||||||
// System monitoring
|
// System monitoring
|
||||||
"system.get-hostname" => self.handle_system_get_hostname().await,
|
"system.get-hostname" => self.handle_system_get_hostname().await,
|
||||||
|
|||||||
@ -454,6 +454,12 @@ impl RpcHandler {
|
|||||||
.flatten(),
|
.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(
|
let state = federation::build_local_state(
|
||||||
apps,
|
apps,
|
||||||
0.0,
|
0.0,
|
||||||
@ -467,6 +473,7 @@ impl RpcHandler {
|
|||||||
nostr_npub,
|
nostr_npub,
|
||||||
own_fips_npub,
|
own_fips_npub,
|
||||||
&federated_peers,
|
&federated_peers,
|
||||||
|
shared_location,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(serde_json::to_value(&state)?)
|
Ok(serde_json::to_value(&state)?)
|
||||||
|
|||||||
@ -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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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
|
/// system.get-hostname — Current OS hostname + the mDNS `.local` name it
|
||||||
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
/// resolves to on the LAN (avahi-daemon advertises `<hostname>.local`).
|
||||||
/// Lets Settings show users where to reach this node over HTTPS for
|
/// Lets Settings show users where to reach this node over HTTPS for
|
||||||
|
|||||||
@ -61,6 +61,18 @@ pub struct ServerInfo {
|
|||||||
/// True if this node's keys are derived from a BIP-39 seed.
|
/// True if this node's keys are derived from a BIP-39 seed.
|
||||||
#[serde(rename = "seed-backed", default)]
|
#[serde(rename = "seed-backed", default)]
|
||||||
pub seed_backed: bool,
|
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<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lon: Option<f64>,
|
||||||
|
/// 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@ -347,6 +359,9 @@ impl DataModel {
|
|||||||
wifi_ssids: vec![],
|
wifi_ssids: vec![],
|
||||||
zram_enabled: false,
|
zram_enabled: false,
|
||||||
seed_backed: false,
|
seed_backed: false,
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
|
share_location: false,
|
||||||
},
|
},
|
||||||
package_data: HashMap::new(),
|
package_data: HashMap::new(),
|
||||||
peer_health: HashMap::new(),
|
peer_health: HashMap::new(),
|
||||||
|
|||||||
@ -506,6 +506,8 @@ mod tests {
|
|||||||
nostr_npub: None,
|
nostr_npub: None,
|
||||||
own_fips_npub: None,
|
own_fips_npub: None,
|
||||||
federated_peers: Vec::new(),
|
federated_peers: Vec::new(),
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
update_node_state(dir.path(), "did:key:z1", state)
|
update_node_state(dir.path(), "did:key:z1", state)
|
||||||
|
|||||||
@ -208,6 +208,7 @@ async fn merge_transitive_peers(
|
|||||||
/// and route directly over FIPS from now on). Only peers we trust are
|
/// and route directly over FIPS from now on). Only peers we trust are
|
||||||
/// shared — an Untrusted/Observer node should not be re-exported
|
/// shared — an Untrusted/Observer node should not be re-exported
|
||||||
/// through us to the network.
|
/// through us to the network.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_local_state(
|
pub fn build_local_state(
|
||||||
apps: Vec<AppStatus>,
|
apps: Vec<AppStatus>,
|
||||||
cpu: f64,
|
cpu: f64,
|
||||||
@ -221,6 +222,9 @@ pub fn build_local_state(
|
|||||||
nostr_npub: Option<String>,
|
nostr_npub: Option<String>,
|
||||||
own_fips_npub: Option<String>,
|
own_fips_npub: Option<String>,
|
||||||
federated_peers: &[FederatedNode],
|
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 {
|
) -> NodeStateSnapshot {
|
||||||
let hints = federated_peers
|
let hints = federated_peers
|
||||||
.iter()
|
.iter()
|
||||||
@ -248,6 +252,8 @@ pub fn build_local_state(
|
|||||||
nostr_npub,
|
nostr_npub,
|
||||||
own_fips_npub,
|
own_fips_npub,
|
||||||
federated_peers: hints,
|
federated_peers: hints,
|
||||||
|
lat: shared_location.map(|(lat, _)| lat),
|
||||||
|
lon: shared_location.map(|(_, lon)| lon),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,12 +347,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
&[],
|
&[],
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
assert_eq!(state.apps.len(), 1);
|
assert_eq!(state.apps.len(), 1);
|
||||||
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
assert_eq!(state.cpu_usage_percent, Some(25.5));
|
||||||
assert_eq!(state.tor_active, Some(true));
|
assert_eq!(state.tor_active, Some(true));
|
||||||
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
assert_eq!(state.node_name, Some("Test Node".to_string()));
|
||||||
assert!(state.federated_peers.is_empty());
|
assert!(state.federated_peers.is_empty());
|
||||||
|
assert_eq!(state.lat, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -392,7 +400,7 @@ mod tests {
|
|||||||
last_transport_at: None,
|
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.len(), 1);
|
||||||
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
|
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -93,6 +93,14 @@ pub struct NodeStateSnapshot {
|
|||||||
/// re-export them in her own state snapshots).
|
/// re-export them in her own state snapshots).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub federated_peers: Vec<FederationPeerHint>,
|
pub federated_peers: Vec<FederationPeerHint>,
|
||||||
|
/// 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<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub lon: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal peer summary shared via `NodeStateSnapshot.federated_peers`.
|
/// Minimal peer summary shared via `NodeStateSnapshot.federated_peers`.
|
||||||
|
|||||||
@ -83,6 +83,16 @@ impl Server {
|
|||||||
data.server_info.name = Some(name);
|
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::<serde_json::Value>(&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;
|
data.server_info.tor_address = docker_packages::read_tor_address("archipelago").await;
|
||||||
if let Some(ref tor) = data.server_info.tor_address {
|
if let Some(ref tor) = data.server_info.tor_address {
|
||||||
data.server_info.node_address = Some(identity.node_address(tor));
|
data.server_info.node_address = Some(identity.node_address(tor));
|
||||||
|
|||||||
@ -48,6 +48,12 @@ pub struct StateDelta {
|
|||||||
/// Tor active flag (only if changed).
|
/// Tor active flag (only if changed).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tor: Option<bool>,
|
pub tor: Option<bool>,
|
||||||
|
/// 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<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub lon: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the delta between two state snapshots.
|
/// 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 {
|
if curr.tor_active != prev.tor_active {
|
||||||
delta.tor = curr.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
|
delta
|
||||||
}
|
}
|
||||||
@ -162,6 +174,12 @@ pub fn apply_delta(base: &NodeStateSnapshot, delta: &StateDelta) -> NodeStateSna
|
|||||||
if let Some(tor) = delta.tor {
|
if let Some(tor) = delta.tor {
|
||||||
result.tor_active = Some(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
|
result
|
||||||
}
|
}
|
||||||
@ -225,6 +243,8 @@ mod tests {
|
|||||||
nostr_npub: None,
|
nostr_npub: None,
|
||||||
own_fips_npub: None,
|
own_fips_npub: None,
|
||||||
federated_peers: Vec::new(),
|
federated_peers: Vec::new(),
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -259,6 +279,8 @@ mod tests {
|
|||||||
nostr_npub: None,
|
nostr_npub: None,
|
||||||
own_fips_npub: None,
|
own_fips_npub: None,
|
||||||
federated_peers: Vec::new(),
|
federated_peers: Vec::new(),
|
||||||
|
lat: None,
|
||||||
|
lon: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -721,6 +721,8 @@ class RPCClient {
|
|||||||
uptime_secs?: number
|
uptime_secs?: number
|
||||||
tor_active?: boolean
|
tor_active?: boolean
|
||||||
nostr_npub?: string
|
nostr_npub?: string
|
||||||
|
lat?: number | null
|
||||||
|
lon?: number | null
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const markersLayer = ref<L.LayerGroup | null>(null)
|
|||||||
const linesLayer = ref<L.LayerGroup | null>(null)
|
const linesLayer = ref<L.LayerGroup | null>(null)
|
||||||
|
|
||||||
// Whether we have any position data to show
|
// 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
|
// Location sharing state
|
||||||
const sharingLocation = ref(false)
|
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: `
|
||||||
|
<div style="
|
||||||
|
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||||
|
width:${size + 14}px; height:${size + 14}px; border-radius:50%;
|
||||||
|
background:${glow}; animation:mesh-map-pulse 2.4s infinite;
|
||||||
|
"></div>
|
||||||
|
<div style="
|
||||||
|
width:${size}px; height:${size}px; border-radius:9px;
|
||||||
|
background:#0b0d14; border:2px solid ${ring};
|
||||||
|
box-shadow:0 0 10px ${glow}, 0 2px 6px rgba(0,0,0,0.5);
|
||||||
|
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
z-index:3; overflow:hidden;
|
||||||
|
">
|
||||||
|
<img src="/assets/icon/apple-touch-icon-180x180-v2.png" width="${size - 6}" height="${size - 6}"
|
||||||
|
style="border-radius:6px;object-fit:cover;" alt="" />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFederatedPopupContent(name: string, onion: string, trusted: boolean): string {
|
||||||
|
const badge = trusted
|
||||||
|
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">TRUSTED</span>'
|
||||||
|
: '<span style="display:inline-block;background:rgba(56,189,248,0.2);color:#38bdf8;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">OBSERVER</span>'
|
||||||
|
const onionShort = onion.length > 20 ? `${onion.slice(0, 10)}...${onion.slice(-6)}` : onion
|
||||||
|
return `
|
||||||
|
<div style="font-family:'Avenir Next',sans-serif;min-width:170px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
|
||||||
|
📡 ${name}${badge}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:6px;word-break:break-all;">
|
||||||
|
${onionShort}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.78rem;color:rgba(255,255,255,0.6);">Archipelago node</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
function getSignalBars(rssi: number | null): string {
|
function getSignalBars(rssi: number | null): string {
|
||||||
if (rssi === null) return 'Unknown'
|
if (rssi === null) return 'Unknown'
|
||||||
if (rssi >= -70) return 'Strong'
|
if (rssi >= -70) return 'Strong'
|
||||||
@ -218,7 +271,8 @@ function updateMarkers() {
|
|||||||
linesLayer.value.clearLayers()
|
linesLayer.value.clearLayers()
|
||||||
|
|
||||||
const positions = mesh.nodePositions
|
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 bounds: L.LatLngExpression[] = []
|
||||||
const selfPos = positions.get(-1)
|
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
|
// Fit map to show all markers
|
||||||
if (bounds.length > 1) {
|
if (bounds.length > 1) {
|
||||||
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
|
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 for changes in node positions and peers
|
||||||
watch(
|
watch(
|
||||||
() => [mesh.nodePositions.size, mesh.peers.length],
|
() => [mesh.nodePositions.size, mesh.peers.length, mesh.federatedPositions.size],
|
||||||
() => {
|
() => {
|
||||||
updateMarkers()
|
updateMarkers()
|
||||||
},
|
},
|
||||||
|
|||||||
@ -179,6 +179,19 @@ export interface NodePosition {
|
|||||||
timestamp: string
|
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', () => {
|
export const useMeshStore = defineStore('mesh', () => {
|
||||||
const status = ref<MeshStatus | null>(null)
|
const status = ref<MeshStatus | null>(null)
|
||||||
const peers = ref<MeshPeer[]>([])
|
const peers = ref<MeshPeer[]>([])
|
||||||
@ -192,6 +205,10 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
|
|
||||||
// Node position tracking for map view (contact_id -> position)
|
// Node position tracking for map view (contact_id -> position)
|
||||||
const nodePositions = ref<Map<number, NodePosition>>(new Map())
|
const nodePositions = ref<Map<number, NodePosition>>(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<Map<string, FederatedNodePosition>>(new Map())
|
||||||
|
|
||||||
// Track unread message counts per peer (contact_id -> count)
|
// Track unread message counts per peer (contact_id -> count)
|
||||||
const unreadCounts = ref<Record<number, number>>({})
|
const unreadCounts = ref<Record<number, number>>({})
|
||||||
@ -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<string, FederatedNodePosition>()
|
||||||
|
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) {
|
function markChatRead(contactId: number) {
|
||||||
viewingChatId.value = contactId
|
viewingChatId.value = contactId
|
||||||
delete unreadCounts.value[contactId]
|
delete unreadCounts.value[contactId]
|
||||||
@ -799,6 +843,8 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
unreadCounts,
|
unreadCounts,
|
||||||
totalUnread,
|
totalUnread,
|
||||||
nodePositions,
|
nodePositions,
|
||||||
|
federatedPositions,
|
||||||
|
updateFederatedPositions,
|
||||||
deadmanStatus,
|
deadmanStatus,
|
||||||
blockHeaders,
|
blockHeaders,
|
||||||
latestBlockHeight,
|
latestBlockHeight,
|
||||||
|
|||||||
@ -30,6 +30,9 @@ export interface ServerInfo {
|
|||||||
'wifi-ssids': string[]
|
'wifi-ssids': string[]
|
||||||
'zram-enabled': boolean
|
'zram-enabled': boolean
|
||||||
'seed-backed': boolean
|
'seed-backed': boolean
|
||||||
|
lat?: number | null
|
||||||
|
lon?: number | null
|
||||||
|
'share-location'?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusInfo {
|
export interface StatusInfo {
|
||||||
|
|||||||
@ -130,6 +130,7 @@ async function refreshFederationNodes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
fedNodesByDid.value = next
|
fedNodesByDid.value = next
|
||||||
|
mesh.updateFederatedPositions(res.nodes)
|
||||||
} catch { /* non-fatal */ }
|
} catch { /* non-fatal */ }
|
||||||
}
|
}
|
||||||
async function refreshSelfOnion() {
|
async function refreshSelfOnion() {
|
||||||
|
|||||||
@ -68,6 +68,60 @@ const copiedOnion = ref(false)
|
|||||||
const copiedDid = ref(false)
|
const copiedDid = ref(false)
|
||||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null
|
let copiedTimer: ReturnType<typeof setTimeout> | 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
|
// mDNS hostname — HTTPS (even self-signed) is required for mic/camera access
|
||||||
// (getUserMedia refuses plain HTTP outside localhost); surface both so users
|
// (getUserMedia refuses plain HTTP outside localhost); surface both so users
|
||||||
// know where to go for features that need it, without forcing HTTPS on anyone.
|
// know where to go for features that need it, without forcing HTTPS on anyone.
|
||||||
@ -260,6 +314,41 @@ init()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Sharing Card — opt-in, off by default. Puts this node on
|
||||||
|
OTHER trusted peers' Mesh Map with the Archy logo marker. -->
|
||||||
|
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Share Location</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 mb-1">
|
||||||
|
<p class="text-sm text-white/70">Show this node on trusted peers' Mesh Map</p>
|
||||||
|
<button
|
||||||
|
class="glass-toggle"
|
||||||
|
:class="{ active: shareLocation }"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="shareLocation"
|
||||||
|
:disabled="locationSaving"
|
||||||
|
@click="toggleShareLocation"
|
||||||
|
>
|
||||||
|
<span class="glass-toggle-knob" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-white/50 mt-2">
|
||||||
|
<span v-if="savedLat !== null && savedLon !== null">{{ savedLat.toFixed(3) }}, {{ savedLon.toFixed(3) }}</span>
|
||||||
|
<span v-else class="text-white/30">No location set</span>
|
||||||
|
<button
|
||||||
|
class="ml-auto glass-button px-3 py-1 text-xs"
|
||||||
|
:disabled="locationSaving"
|
||||||
|
@click="useCurrentLocation"
|
||||||
|
>{{ locationSaving ? 'Locating…' : 'Use current location' }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="locationError" class="mt-2 text-xs text-yellow-300">{{ locationError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Release Notes Modal -->
|
<!-- Release Notes Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user