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) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-18 11:53:06 -04:00
parent 0c02d06a66
commit 1736f6f99e
7 changed files with 86 additions and 6 deletions

View File

@ -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,

View File

@ -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<serde_json::Value> {
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" }))
}
}
}

View File

@ -59,6 +59,8 @@ pub enum MeshCommand {
/// Broadcast pre-encoded binary on a mesh channel.
BroadcastChannel { channel: u8, payload: Vec<u8> },
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<HashMap<(u32, u8), ChunkAssembly>>,
pub(crate) chunk_buffer: RwLock<HashMap<(u32, u8), ChunkAssembly>>,
/// Double Ratchet session manager for forward-secret encryption.
pub session_manager: Arc<super::session::SessionManager>,
/// 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<u8, String>,
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<String>,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> 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,
)

View File

@ -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<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> 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::<String>();
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::<String>()
} else {
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
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;
}
}
}

View File

@ -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<String>,
// Phase 4: off-grid Bitcoin operations
pub block_header_cache: Arc<BlockHeaderCache>,
pub relay_tracker: Arc<RelayTracker>,
@ -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<String>) {
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<listener::MeshState> {
&self.state
}
/// Get list of discovered peers.
pub async fn peers(&self) -> Vec<MeshPeer> {
self.state.peers.read().await.values().cloned().collect()

View File

@ -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

View File

@ -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 list -->
<div data-controller-container tabindex="0" class="glass-card mesh-peers-card">
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
<div class="flex items-center justify-between">
<h2 class="mesh-section-title">Peers <span class="mesh-peer-count">{{ mesh.peers.length }}</span></h2>
<button class="text-xs text-white/40 hover:text-red-400 transition-colors px-2 py-1" @click="clearAllMesh" title="Clear all peers, messages, and chat history">Clear All</button>
</div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet.