From 1736f6f99e54375993962bd7fbe85d8a0267819f Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 18 Apr 2026 11:53:06 -0400 Subject: [PATCH] feat(mesh): server name in adverts + clear-all button + CI fix - Mesh adverts now use the node's configured server name (e.g. "ThinkPad", "Arch Dev") instead of DID key fragments ("Archy-z6MkmkSB") - Added mesh.clear-all RPC to reset peers, messages, contacts, and history - Added "Clear All" button in Mesh UI peers panel - Both glibc and musl builds verified Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/api/rpc/dispatcher.rs | 1 + core/archipelago/src/api/rpc/mesh/status.rs | 35 +++++++++++++++++++ core/archipelago/src/mesh/listener/mod.rs | 8 +++-- core/archipelago/src/mesh/listener/session.rs | 16 +++++++-- core/archipelago/src/mesh/mod.rs | 14 ++++++++ core/archipelago/src/server.rs | 2 ++ neode-ui/src/views/Mesh.vue | 16 ++++++++- 7 files changed, 86 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 8941e2df..599a3d12 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -319,6 +319,7 @@ impl RpcHandler { "mesh.send-channel-invite" => self.handle_mesh_send_channel_invite(params).await, "conversations.list" => self.handle_conversations_list(params).await, "conversations.messages" => self.handle_conversations_messages(params).await, + "mesh.clear-all" => self.handle_mesh_clear_all().await, "mesh.outbox" => self.handle_mesh_outbox(params).await, "mesh.session-status" => self.handle_mesh_session_status(params).await, "mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await, diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 0f7b4a5e..f649fada 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -215,4 +215,39 @@ impl RpcHandler { })) } } + + /// mesh.clear-all — Clear all peers, messages, contacts, and presence data. + /// Resets the mesh state to a fresh start while keeping the device connected. + pub(in crate::api::rpc) async fn handle_mesh_clear_all(&self) -> Result { + let service = self.mesh_service.read().await; + if let Some(svc) = service.as_ref() { + let state = svc.state(); + // Clear peers (except synthetic federation peers) + state.peers.write().await.clear(); + // Clear all messages + state.messages.write().await.clear(); + // Clear contacts + state.contacts.write().await.clear(); + // Clear presence + state.presence.write().await.clear(); + // Clear chunk buffer + state.chunk_buffer.write().await.clear(); + // Re-seed federation peers + let data_dir = self.config.data_dir.clone(); + crate::mesh::seed_federation_peers_into_mesh(state, &data_dir).await; + // Delete persisted messages file + let msg_file = data_dir.join("messages.json"); + let _ = tokio::fs::remove_file(&msg_file).await; + // Delete persisted contacts file + let contacts_file = data_dir.join("mesh-contacts.json"); + let _ = tokio::fs::remove_file(&contacts_file).await; + // Trigger a contact refresh from the radio device + let _ = state.send_cmd( + crate::mesh::listener::MeshCommand::RefreshContacts, + ).await; + Ok(serde_json::json!({ "status": "cleared" })) + } else { + Ok(serde_json::json!({ "status": "no_service" })) + } + } } diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index cfb25e82..1702f8c8 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -59,6 +59,8 @@ pub enum MeshCommand { /// Broadcast pre-encoded binary on a mesh channel. BroadcastChannel { channel: u8, payload: Vec }, SendAdvert, + /// Re-fetch contact list from the radio device. + RefreshContacts, } /// Shared state for the mesh listener, accessible from RPC handlers. @@ -86,7 +88,7 @@ pub struct MeshState { /// Steganography mode for outgoing/incoming messages. pub stego_mode: super::steganography::SteganographyMode, /// Chunk reassembly buffer for multi-frame messages. - chunk_buffer: RwLock>, + pub(crate) chunk_buffer: RwLock>, /// Double Ratchet session manager for forward-secret encryption. pub session_manager: Arc, /// Whether to encrypt directed relay messages (config toggle for rollback). @@ -121,7 +123,7 @@ pub struct ContactEntry { } /// In-progress chunk reassembly for a multi-frame message. -struct ChunkAssembly { +pub(crate) struct ChunkAssembly { chunks: HashMap, total: u8, created: std::time::Instant, @@ -255,6 +257,7 @@ pub fn spawn_mesh_listener( our_ed_pubkey_hex: String, our_x25519_secret: [u8; 32], our_x25519_pubkey_hex: String, + server_name: Option, shutdown: tokio::sync::watch::Receiver, cmd_rx: mpsc::Receiver, ) -> tokio::task::JoinHandle<()> { @@ -275,6 +278,7 @@ pub fn spawn_mesh_listener( &our_ed_pubkey_hex, &our_x25519_secret, &our_x25519_pubkey_hex, + server_name.as_deref(), &mut shutdown, &mut cmd_rx, ) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 5c14db6c..682aae35 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -249,6 +249,7 @@ pub(super) async fn run_mesh_session( _our_ed_pubkey_hex: &str, our_x25519_secret: &[u8; 32], _our_x25519_pubkey_hex: &str, + server_name: Option<&str>, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, ) -> Result<()> { @@ -284,9 +285,15 @@ pub(super) async fn run_mesh_session( let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info)); - // Set advert name to something identifiable - let short_did = our_did.chars().skip(8).take(8).collect::(); - let advert_name = format!("Archy-{}", short_did); + // Set advert name to the server's human-readable name (e.g. "ThinkPad"), + // falling back to the DID fragment if no name is configured. + let advert_name = if let Some(name) = server_name { + // Meshcore firmware limits advert names — truncate to 20 chars + name.chars().take(20).collect::() + } else { + let short_did = our_did.chars().skip(8).take(8).collect::(); + format!("Archy-{}", short_did) + }; if let Err(e) = device.set_advert_name(&advert_name).await { warn!("Failed to set advert name: {}", e); } else { @@ -440,5 +447,8 @@ async fn handle_send_command( *consecutive_write_failures = 0; } } + MeshCommand::RefreshContacts => { + refresh_contacts(device, state).await; + } } } diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index a60b3449..979097ec 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -204,6 +204,8 @@ pub struct MeshService { our_x25519_secret: [u8; 32], our_x25519_pubkey_hex: String, signing_key: SigningKey, + /// Human-readable server name (e.g. "Arch Dev", "ThinkPad") for mesh adverts. + server_name: Option, // Phase 4: off-grid Bitcoin operations pub block_header_cache: Arc, pub relay_tracker: Arc, @@ -277,12 +279,18 @@ impl MeshService { our_x25519_secret: x25519_secret, our_x25519_pubkey_hex: x25519_pubkey_hex, signing_key: signing_key.clone(), + server_name: None, block_header_cache, relay_tracker, dead_man_switch, }) } + /// Set the human-readable server name used in mesh adverts. + pub fn set_server_name(&mut self, name: Option) { + self.server_name = name; + } + /// Start the background mesh listener. pub fn start(&mut self) -> Result<()> { if self.listener_handle.is_some() { @@ -302,6 +310,7 @@ impl MeshService { self.our_ed_pubkey_hex.clone(), self.our_x25519_secret, self.our_x25519_pubkey_hex.clone(), + self.server_name.clone(), shutdown_rx, cmd_rx, ); @@ -518,6 +527,11 @@ impl MeshService { self.state.status.read().await.clone() } + /// Get a reference to the shared mesh state. + pub fn state(&self) -> &Arc { + &self.state + } + /// Get list of discovered peers. pub async fn peers(&self) -> Vec { self.state.peers.read().await.values().cloned().collect() diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 58542baf..6ee50848 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -156,6 +156,8 @@ impl Server { let signing_key = identity.signing_key(); match crate::mesh::MeshService::new(&data_dir, signing_key, &did, &pubkey_hex).await { Ok(mut mesh_service) => { + // Pass the human-readable server name for mesh adverts + mesh_service.set_server_name(data.server_info.name.clone()); let mut mesh_config = crate::mesh::load_config(&data_dir).await.unwrap_or_default(); // Auto-enable mesh if a radio is detected and no config exists yet diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 1d82a0da..b121bfc9 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -229,6 +229,17 @@ async function refreshOutboxCount() { } } +async function clearAllMesh() { + if (!window.confirm('Clear all mesh peers, messages, and chat history? This cannot be undone.')) return + try { + await rpcClient('mesh.clear-all') + await mesh.refreshAll() + selectedPeer.value = null + } catch (e) { + console.error('Failed to clear mesh:', e) + } +} + // Phase 4: Off-grid Bitcoin + Dead Man's Switch const activeTab = ref<'chat' | 'bitcoin' | 'deadman' | 'map'>('chat') @@ -1341,7 +1352,10 @@ function isImageMime(mime?: string): boolean {
-

Peers {{ mesh.peers.length }}

+
+

Peers {{ mesh.peers.length }}

+ +
No peers discovered yet.