feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search
- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the public channel, and decodable by stock clients. Plain text (split, not MC-chunked) to non-archipelago contacts; typed envelopes to archy peers. - !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0. - Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert. - clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of blocklisting; blocking filter removed entirely. Wiped contacts return when reachable. - Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows a reachability dot. - Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button. - send_message routes stock contacts as plain native text (fixes garbled envelopes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9f2edf6b7a
commit
f0fdc23cc9
@ -258,43 +258,45 @@ impl RpcHandler {
|
|||||||
if let Some(svc) = service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
let state = svc.state();
|
let state = svc.state();
|
||||||
|
|
||||||
// Snapshot the firmware pubkeys we currently know about, then
|
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||||
// add them to the radio-contact blocklist. MeshCore's on-device
|
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||||
// contact table is persistent and reads back stale rows on the
|
// peer could never return even when it re-advertised (it also broke
|
||||||
// next refresh_contacts, so without this step `clear-all` only
|
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||||
// wipes the app view for a few seconds before the old entries
|
// be a separate, explicit feature. Here we just wipe the app-side
|
||||||
// reappear. The blocklist is also saved to disk so the filter
|
// view and ALSO clear any blocklist left over from older builds, so
|
||||||
// survives a restart.
|
// previously-hidden contacts can re-appear when next heard. The
|
||||||
let firmware_pubkeys: Vec<String> = state
|
// firmware's own contact table is the source of truth on refresh.
|
||||||
|
{
|
||||||
|
let mut set = state.radio_contact_blocklist.write().await;
|
||||||
|
set.clear();
|
||||||
|
}
|
||||||
|
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
|
||||||
|
|
||||||
|
// Actually DELETE each radio contact from the firmware table (via
|
||||||
|
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
|
||||||
|
// refresh. They come back only when they re-advertise (reachable).
|
||||||
|
// Federation-synthetic peers (high contact_id bit) aren't firmware
|
||||||
|
// contacts, so skip those.
|
||||||
|
let firmware_pubkeys: Vec<[u8; 32]> = state
|
||||||
.peers
|
.peers
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.filter_map(|p| {
|
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||||
// Federation-synthetic peers have their contact_id in the
|
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||||
// high half of u32 and carry the archipelago key — those
|
.filter_map(|h| hex::decode(h).ok())
|
||||||
// aren't firmware contacts and must not go on the list.
|
.filter(|b| b.len() == 32)
|
||||||
if p.contact_id & 0x8000_0000 != 0 {
|
.map(|b| {
|
||||||
None
|
let mut k = [0u8; 32];
|
||||||
} else {
|
k.copy_from_slice(&b);
|
||||||
p.pubkey_hex.clone()
|
k
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
{
|
for pk in firmware_pubkeys {
|
||||||
let mut set = state.radio_contact_blocklist.write().await;
|
let _ = state
|
||||||
for pk in &firmware_pubkeys {
|
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||||
set.insert(pk.clone());
|
.await;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let persisted: Vec<String> = state
|
|
||||||
.radio_contact_blocklist
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
|
|
||||||
|
|
||||||
state.peers.write().await.clear();
|
state.peers.write().await.clear();
|
||||||
state.messages.write().await.clear();
|
state.messages.write().await.clear();
|
||||||
|
|||||||
@ -48,6 +48,11 @@ pub(super) enum AssistReply {
|
|||||||
/// direct message, so the answer lands inline in that same conversation
|
/// direct message, so the answer lands inline in that same conversation
|
||||||
/// (encrypted, peer-addressed) rather than as a separate widget.
|
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||||
ChatText { contact_id: u32 },
|
ChatText { contact_id: u32 },
|
||||||
|
/// Plain-text NATIVE direct message back to the asker's radio contact —
|
||||||
|
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
|
||||||
|
/// answer goes as a real unicast DM (not a public-channel broadcast), so
|
||||||
|
/// only the asker sees it and a stock client can read it.
|
||||||
|
RadioDm { dest_prefix: [u8; 6] },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entry point: gate the query, run the model, send the answer back via the
|
/// Entry point: gate the query, run the model, send the answer back via the
|
||||||
@ -224,6 +229,15 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
|||||||
let (text, _) = cap_reply(answer);
|
let (text, _) = cap_reply(answer);
|
||||||
send_chat_text(state, *contact_id, &text).await;
|
send_chat_text(state, *contact_id, &text).await;
|
||||||
}
|
}
|
||||||
|
AssistReply::RadioDm { dest_prefix } => {
|
||||||
|
let text = cap_channel(answer);
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix: *dest_prefix,
|
||||||
|
payload: text.into_bytes(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +260,14 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
|
|||||||
AssistReply::ChatText { contact_id } => {
|
AssistReply::ChatText { contact_id } => {
|
||||||
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
|
||||||
}
|
}
|
||||||
|
AssistReply::RadioDm { dest_prefix } => {
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix: *dest_prefix,
|
||||||
|
payload: format!("AI: {msg}").into_bytes(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -353,28 +353,37 @@ pub(super) async fn store_plain_message(
|
|||||||
state.status.write().await.messages_received += 1;
|
state.status.write().await.messages_received += 1;
|
||||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||||
|
|
||||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||||
// channel is answered by this node's local model when the assistant is on.
|
// by this node's local model when the assistant is on. The trust/rate gate
|
||||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
// lives in run_assist. The reply goes back as a private NATIVE DM to the
|
||||||
// see it. The trust/rate gate lives in run_assist.
|
// asker whenever we know its radio pubkey (so it does NOT land on the public
|
||||||
|
// channel and a stock meshcore client can read it); we only fall back to a
|
||||||
|
// channel reply if the sender has no resolvable pubkey (rare).
|
||||||
if state.assistant.read().await.enabled {
|
if state.assistant.read().await.enabled {
|
||||||
if let Some(prompt) = strip_ai_trigger(text) {
|
if let Some(prompt) = strip_ai_trigger(text) {
|
||||||
if !prompt.is_empty() {
|
if !prompt.is_empty() {
|
||||||
|
let reply = {
|
||||||
|
let peers = state.peers.read().await;
|
||||||
|
peers
|
||||||
|
.get(&contact_id)
|
||||||
|
.and_then(|p| p.pubkey_hex.clone())
|
||||||
|
.filter(|h| h.len() >= 12)
|
||||||
|
.and_then(|h| hex::decode(&h[..12]).ok())
|
||||||
|
.filter(|b| b.len() == 6)
|
||||||
|
.map(|b| {
|
||||||
|
let mut pre = [0u8; 6];
|
||||||
|
pre.copy_from_slice(&b);
|
||||||
|
super::assist::AssistReply::RadioDm { dest_prefix: pre }
|
||||||
|
})
|
||||||
|
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
|
||||||
|
};
|
||||||
let req_id = state.next_id().await;
|
let req_id = state.next_id().await;
|
||||||
let prompt = prompt.to_string();
|
let prompt = prompt.to_string();
|
||||||
let name = peer_name.to_string();
|
let name = peer_name.to_string();
|
||||||
let st = Arc::clone(state);
|
let st = Arc::clone(state);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
super::assist::run_assist(
|
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st)
|
||||||
prompt,
|
.await;
|
||||||
None,
|
|
||||||
req_id,
|
|
||||||
contact_id,
|
|
||||||
name,
|
|
||||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
|
||||||
st,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -480,6 +489,9 @@ pub(super) async fn handle_identity_received(
|
|||||||
snr: None,
|
snr: None,
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
|
last_advert: 0,
|
||||||
|
// We just heard this peer's identity advert, so it's reachable.
|
||||||
|
reachable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_new = {
|
let is_new = {
|
||||||
|
|||||||
@ -22,8 +22,33 @@ pub(super) async fn handle_frame(
|
|||||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||||
info!(
|
info!(
|
||||||
code = frame.code,
|
code = frame.code,
|
||||||
|
data_len = frame.data.len(),
|
||||||
"Contact discovery event — refreshing contacts"
|
"Contact discovery event — refreshing contacts"
|
||||||
);
|
);
|
||||||
|
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
|
||||||
|
// pubkey of a node we just heard. If it isn't already a contact,
|
||||||
|
// add it to the firmware table so it shows up immediately — no
|
||||||
|
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
|
||||||
|
// added by the firmware, so we skip it.)
|
||||||
|
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
|
||||||
|
let mut pubkey = [0u8; 32];
|
||||||
|
pubkey.copy_from_slice(&frame.data[..32]);
|
||||||
|
let pk_hex = hex::encode(pubkey);
|
||||||
|
let known = state
|
||||||
|
.peers
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
|
||||||
|
if !known {
|
||||||
|
let _ = state
|
||||||
|
.send_cmd(super::MeshCommand::AddContact {
|
||||||
|
pubkey,
|
||||||
|
name: String::new(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true; // Signal caller to fetch contacts
|
return true; // Signal caller to fetch contacts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,14 @@ pub enum MeshCommand {
|
|||||||
dest_pubkey_prefix: [u8; 6],
|
dest_pubkey_prefix: [u8; 6],
|
||||||
payload: Vec<u8>,
|
payload: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||||
|
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||||
|
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||||
|
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
|
||||||
|
SendNativeText {
|
||||||
|
dest_pubkey_prefix: [u8; 6],
|
||||||
|
payload: Vec<u8>,
|
||||||
|
},
|
||||||
/// Broadcast pre-encoded binary on a mesh channel.
|
/// Broadcast pre-encoded binary on a mesh channel.
|
||||||
BroadcastChannel {
|
BroadcastChannel {
|
||||||
channel: u8,
|
channel: u8,
|
||||||
@ -71,6 +79,16 @@ pub enum MeshCommand {
|
|||||||
SendAdvert,
|
SendAdvert,
|
||||||
/// Re-fetch contact list from the radio device.
|
/// Re-fetch contact list from the radio device.
|
||||||
RefreshContacts,
|
RefreshContacts,
|
||||||
|
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
|
||||||
|
RemoveContact {
|
||||||
|
pubkey: [u8; 32],
|
||||||
|
},
|
||||||
|
/// Import/add a heard advert as a firmware contact so it shows up without
|
||||||
|
/// needing a flood advert. Name may be empty (firmware fills from advert).
|
||||||
|
AddContact {
|
||||||
|
pubkey: [u8; 32],
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared state for the mesh listener, accessible from RPC handlers.
|
/// Shared state for the mesh listener, accessible from RPC handlers.
|
||||||
|
|||||||
@ -53,6 +53,43 @@ impl MeshRadioDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||||
|
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => device.remove_contact(pubkey).await,
|
||||||
|
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_contact(
|
||||||
|
&mut self,
|
||||||
|
pubkey: &[u8; 32],
|
||||||
|
contact_type: u8,
|
||||||
|
flags: u8,
|
||||||
|
out_path_len: u8,
|
||||||
|
name: &str,
|
||||||
|
last_advert: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Meshcore(device) => {
|
||||||
|
device
|
||||||
|
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Self::Meshtastic(device) => {
|
||||||
|
device
|
||||||
|
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Meshcore(device) => device.get_contacts().await,
|
Self::Meshcore(device) => device.get_contacts().await,
|
||||||
@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
|
|||||||
/// route inbound DMs to the correct contact_id thread.
|
/// route inbound DMs to the correct contact_id thread.
|
||||||
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
pub(super) const DM_V2_MARKER: &str = "@DM2:";
|
||||||
|
|
||||||
|
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
|
||||||
fn wrap_dm_for_channel(
|
fn wrap_dm_for_channel(
|
||||||
dest_pubkey_prefix: &[u8; 6],
|
dest_pubkey_prefix: &[u8; 6],
|
||||||
sender_arch_prefix: &[u8; 6],
|
sender_arch_prefix: &[u8; 6],
|
||||||
@ -169,6 +207,7 @@ fn wrap_dm_for_channel(
|
|||||||
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
|
||||||
/// caller constructed `MeshState` with a bad value — empty string yields
|
/// caller constructed `MeshState` with a bad value — empty string yields
|
||||||
/// all-zero, which won't match any real peer on the receiver side).
|
/// all-zero, which won't match any real peer on the receiver side).
|
||||||
|
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
|
||||||
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
|
||||||
let mut out = [0u8; 6];
|
let mut out = [0u8; 6];
|
||||||
if state.our_ed_pubkey_hex.len() >= 12 {
|
if state.our_ed_pubkey_hex.len() >= 12 {
|
||||||
@ -195,39 +234,42 @@ async fn send_dm_via_channel(
|
|||||||
consecutive_write_failures: &mut u32,
|
consecutive_write_failures: &mut u32,
|
||||||
) {
|
) {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let sender_prefix = our_sender_prefix(state);
|
let _ = state; // native unicast carries no separate sender prefix
|
||||||
// First try a single frame with the raw payload directly wrapped.
|
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
|
||||||
// This keeps small plain-text messages at minimal overhead.
|
// contact, NOT a broadcast on the shared public channel. This is the fix
|
||||||
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
|
// for the long-standing public-channel pollution — archy used to tunnel
|
||||||
if single.len() <= 140 {
|
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
|
||||||
match device.send_channel_text(0, single.as_bytes()).await {
|
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
|
||||||
|
// phone) couldn't decode. A native DM is private and decodes everywhere.
|
||||||
|
// The receive side handles these via the existing RESP_CONTACT_MSG path.
|
||||||
|
//
|
||||||
|
// Small payloads send in one frame; larger ones are base64 + MC-chunked
|
||||||
|
// and reassembled by the receiver (try_chunk_reassemble).
|
||||||
|
if payload.len() <= 140 {
|
||||||
|
match device.send_text_msg(dest_pubkey_prefix, payload).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
*consecutive_write_failures = 0;
|
*consecutive_write_failures = 0;
|
||||||
info!(
|
info!(
|
||||||
dest = %hex::encode(dest_pubkey_prefix),
|
dest = %hex::encode(dest_pubkey_prefix),
|
||||||
len = payload.len(),
|
len = payload.len(),
|
||||||
wire_len = single.len(),
|
"Sent mesh DM (native unicast)"
|
||||||
"Sent mesh message (DM via channel)"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
*consecutive_write_failures += 1;
|
*consecutive_write_failures += 1;
|
||||||
warn!(
|
warn!(
|
||||||
failures = *consecutive_write_failures,
|
failures = *consecutive_write_failures,
|
||||||
"Failed to send DM via channel: {}", e
|
"Failed to send native DM: {}", e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload too large for one wrap — base64 then MC-chunk. Receiver
|
|
||||||
// reassembles base64 chunks and routes the decoded bytes back through
|
|
||||||
// the typed-envelope ladder in handle_channel_payload.
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
|
||||||
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||||
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
let chunk_data_size = 80;
|
let chunk_data_size = 100;
|
||||||
let chunks: Vec<&str> = encoded
|
let chunks: Vec<&str> = encoded
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
.chunks(chunk_data_size)
|
.chunks(chunk_data_size)
|
||||||
@ -239,18 +281,20 @@ async fn send_dm_via_channel(
|
|||||||
raw_len = payload.len(),
|
raw_len = payload.len(),
|
||||||
b64_len = encoded.len(),
|
b64_len = encoded.len(),
|
||||||
chunks = total,
|
chunks = total,
|
||||||
"Sending chunked mesh message (DM via channel)"
|
"Sending chunked mesh DM (native unicast)"
|
||||||
);
|
);
|
||||||
let mut any_err = false;
|
let mut any_err = false;
|
||||||
for (idx, chunk) in chunks.iter().enumerate() {
|
for (idx, chunk) in chunks.iter().enumerate() {
|
||||||
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
|
||||||
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
|
if let Err(e) = device
|
||||||
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
|
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
*consecutive_write_failures += 1;
|
*consecutive_write_failures += 1;
|
||||||
warn!(
|
warn!(
|
||||||
failures = *consecutive_write_failures,
|
failures = *consecutive_write_failures,
|
||||||
chunk = idx,
|
chunk = idx,
|
||||||
"Chunk DM-via-channel send failed: {}",
|
"Chunk native DM send failed: {}",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
any_err = true;
|
any_err = true;
|
||||||
@ -263,20 +307,72 @@ async fn send_dm_via_channel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
|
||||||
|
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
|
||||||
|
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
|
||||||
|
/// split into multiple readable plain messages on UTF-8 char boundaries.
|
||||||
|
async fn send_plain_native_text(
|
||||||
|
device: &mut MeshRadioDevice,
|
||||||
|
dest_pubkey_prefix: &[u8; 6],
|
||||||
|
text: &[u8],
|
||||||
|
consecutive_write_failures: &mut u32,
|
||||||
|
) {
|
||||||
|
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
|
||||||
|
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
|
||||||
|
let s = String::from_utf8_lossy(text);
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
for ch in s.chars() {
|
||||||
|
if cur.len() + ch.len_utf8() > FRAME {
|
||||||
|
parts.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
cur.push(ch);
|
||||||
|
}
|
||||||
|
if !cur.is_empty() || parts.is_empty() {
|
||||||
|
parts.push(cur);
|
||||||
|
}
|
||||||
|
let total = parts.len();
|
||||||
|
for (idx, part) in parts.iter().enumerate() {
|
||||||
|
match device
|
||||||
|
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
*consecutive_write_failures = 0;
|
||||||
|
info!(
|
||||||
|
dest = %hex::encode(dest_pubkey_prefix),
|
||||||
|
part = idx + 1,
|
||||||
|
total,
|
||||||
|
"Sent plain native DM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*consecutive_write_failures += 1;
|
||||||
|
warn!(
|
||||||
|
failures = *consecutive_write_failures,
|
||||||
|
"Plain native DM send failed: {}", e
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total > 1 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the contacts list from the device and update the peer cache.
|
/// Fetch the contacts list from the device and update the peer cache.
|
||||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||||
match device.get_contacts().await {
|
match device.get_contacts().await {
|
||||||
Ok(contacts) => {
|
Ok(contacts) => {
|
||||||
// Skip firmware contacts the user has explicitly wiped via
|
// Contact blocking is intentionally NOT applied here. A read-time
|
||||||
// mesh.clear-all. MeshCore keeps its own persistent contact
|
// blocklist meant a wiped/re-paired contact could never come back
|
||||||
// table the app can't remove from, so we filter on read to
|
// even when it re-advertised (it broke phone re-pairing after a
|
||||||
// keep cleared entries out of the chat list.
|
// clear). Per-contact blocking will return later as an explicit,
|
||||||
let blocklist = state.radio_contact_blocklist.read().await.clone();
|
// user-controlled feature; until then every firmware contact is
|
||||||
|
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||||
let mut peers = state.peers.write().await;
|
let mut peers = state.peers.write().await;
|
||||||
for (idx, contact) in contacts.iter().enumerate() {
|
for (idx, contact) in contacts.iter().enumerate() {
|
||||||
if blocklist.contains(&contact.public_key_hex) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let contact_id = idx as u32;
|
let contact_id = idx as u32;
|
||||||
let existing = peers.get(&contact_id);
|
let existing = peers.get(&contact_id);
|
||||||
let peer = super::super::types::MeshPeer {
|
let peer = super::super::types::MeshPeer {
|
||||||
@ -289,6 +385,10 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
|||||||
snr: None,
|
snr: None,
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: 0,
|
hops: 0,
|
||||||
|
last_advert: contact.last_advert,
|
||||||
|
// A non-zero path_len means the firmware has a route (direct
|
||||||
|
// or flood) to this contact — i.e. we can deliver to it.
|
||||||
|
reachable: contact.path_len != 0,
|
||||||
};
|
};
|
||||||
peers.insert(contact_id, peer);
|
peers.insert(contact_id, peer);
|
||||||
}
|
}
|
||||||
@ -555,6 +655,18 @@ async fn handle_send_command(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
|
send_plain_native_text(
|
||||||
|
device,
|
||||||
|
&dest_pubkey_prefix,
|
||||||
|
&payload,
|
||||||
|
consecutive_write_failures,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
MeshCommand::SendRaw {
|
MeshCommand::SendRaw {
|
||||||
dest_pubkey_prefix,
|
dest_pubkey_prefix,
|
||||||
payload,
|
payload,
|
||||||
@ -608,5 +720,22 @@ async fn handle_send_command(
|
|||||||
MeshCommand::RefreshContacts => {
|
MeshCommand::RefreshContacts => {
|
||||||
refresh_contacts(device, state).await;
|
refresh_contacts(device, state).await;
|
||||||
}
|
}
|
||||||
|
MeshCommand::RemoveContact { pubkey } => {
|
||||||
|
if let Err(e) = device.remove_contact(&pubkey).await {
|
||||||
|
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MeshCommand::AddContact { pubkey, name } => {
|
||||||
|
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
|
||||||
|
// until a path is learned). last_advert=0 lets the firmware keep its
|
||||||
|
// own advert timestamp.
|
||||||
|
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
|
||||||
|
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,6 +150,32 @@ impl MeshtasticDevice {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix,
|
||||||
|
/// so there's no direct unicast mapping here. Best-effort fallback to a
|
||||||
|
/// channel send keeps the device interface uniform; native unicast is only
|
||||||
|
/// meaningful on the Meshcore transport.
|
||||||
|
pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||||
|
self.send_channel_text(0, msg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
|
||||||
|
/// device interface stays uniform.
|
||||||
|
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_contact(
|
||||||
|
&mut self,
|
||||||
|
_pubkey: &[u8; 32],
|
||||||
|
_contact_type: u8,
|
||||||
|
_flags: u8,
|
||||||
|
_out_path_len: u8,
|
||||||
|
_name: &str,
|
||||||
|
_last_advert: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
|
||||||
if self.contacts.is_empty() {
|
if self.contacts.is_empty() {
|
||||||
self.send_to_radio(&encode_want_config()).await?;
|
self.send_to_radio(&encode_want_config()).await?;
|
||||||
|
|||||||
@ -82,6 +82,9 @@ pub(crate) async fn upsert_federation_peer(
|
|||||||
snr: existing.as_ref().and_then(|p| p.snr),
|
snr: existing.as_ref().and_then(|p| p.snr),
|
||||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||||
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
|
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
|
||||||
|
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
|
||||||
|
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
|
||||||
|
reachable: true,
|
||||||
};
|
};
|
||||||
peers.insert(contact_id, peer);
|
peers.insert(contact_id, peer);
|
||||||
drop(peers);
|
drop(peers);
|
||||||
@ -584,6 +587,7 @@ impl MeshService {
|
|||||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||||
interval.tick().await; // skip first
|
interval.tick().await; // skip first
|
||||||
let mut last_announced_height: u64 = 0;
|
let mut last_announced_height: u64 = 0;
|
||||||
|
let mut last_announce_at: Option<std::time::Instant> = None;
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
@ -601,6 +605,18 @@ impl MeshService {
|
|||||||
// Poll Bitcoin Core for latest block
|
// Poll Bitcoin Core for latest block
|
||||||
match bitcoin_rpc_getblockcount(&client).await {
|
match bitcoin_rpc_getblockcount(&client).await {
|
||||||
Ok(height) if height > last_announced_height => {
|
Ok(height) if height > last_announced_height => {
|
||||||
|
// Advance the tip baseline immediately so a fast Bitcoin
|
||||||
|
// catch-up (a new block every poll) doesn't re-fire each tick.
|
||||||
|
last_announced_height = height;
|
||||||
|
// Throttle: at most one announcement per ~9 min. Real ~10 min
|
||||||
|
// blocks still propagate, but a rapid catch-up can no longer
|
||||||
|
// flood the shared LoRa channel.
|
||||||
|
if last_announce_at
|
||||||
|
.map(|t| t.elapsed() < Duration::from_secs(540))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
|
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
|
||||||
// Store in cache
|
// Store in cache
|
||||||
let payload = message_types::BlockHeaderPayload {
|
let payload = message_types::BlockHeaderPayload {
|
||||||
@ -646,30 +662,15 @@ impl MeshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Second pass: any peer if no Archy nodes found
|
// NOTE: intentionally NO fallback to arbitrary
|
||||||
if sent == 0 {
|
// peers. Block headers go ONLY to known Archy
|
||||||
for peer in peers.values() {
|
// (federated) nodes — never to random meshcore
|
||||||
if sent >= max_peers { break; }
|
// devices on the shared public channel.
|
||||||
if let Some(ref pk) = peer.pubkey_hex {
|
|
||||||
if let Ok(pk_bytes) = hex::decode(pk) {
|
|
||||||
if pk_bytes.len() >= 6 {
|
|
||||||
let mut prefix = [0u8; 6];
|
|
||||||
prefix.copy_from_slice(&pk_bytes[..6]);
|
|
||||||
let _ = bha_state.send_cmd(
|
|
||||||
listener::MeshCommand::SendRaw {
|
|
||||||
dest_pubkey_prefix: prefix,
|
|
||||||
payload: wire.clone(),
|
|
||||||
},
|
|
||||||
).await;
|
|
||||||
sent += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drop(peers);
|
drop(peers);
|
||||||
last_announced_height = height;
|
if sent > 0 {
|
||||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
last_announce_at = Some(std::time::Instant::now());
|
||||||
|
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to build block announcement: {}", e),
|
Err(e) => warn!("Failed to build block announcement: {}", e),
|
||||||
}
|
}
|
||||||
@ -1273,6 +1274,24 @@ impl MeshService {
|
|||||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||||
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
|
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
|
||||||
let seq = self.state.next_send_seq(contact_id).await;
|
let seq = self.state.next_send_seq(contact_id).await;
|
||||||
|
// Stock (non-archipelago) radio contacts — e.g. a phone running the
|
||||||
|
// MeshCore app — can't decode our typed envelope and would render it as
|
||||||
|
// garbled bytes. Send them the raw text as a plain native DM instead.
|
||||||
|
// Archipelago peers still get the typed envelope (seq/reply/reaction
|
||||||
|
// addressing + encryption).
|
||||||
|
if !self.is_archy_peer(contact_id).await {
|
||||||
|
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||||
|
self.state
|
||||||
|
.send_cmd(listener::MeshCommand::SendNativeText {
|
||||||
|
dest_pubkey_prefix: dest_prefix,
|
||||||
|
payload: text.as_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||||
|
return Ok(self
|
||||||
|
.record_sent_typed(contact_id, "text", text, None, seq)
|
||||||
|
.await);
|
||||||
|
}
|
||||||
let envelope =
|
let envelope =
|
||||||
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
|
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
|
||||||
let wire = envelope.to_wire()?;
|
let wire = envelope.to_wire()?;
|
||||||
@ -1280,6 +1299,22 @@ impl MeshService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
|
||||||
|
/// Federation-synthetic ids are always archy; radio contacts count as archy
|
||||||
|
/// only once we've learned their archipelago identity (DID or x25519 key,
|
||||||
|
/// from federation seeding or an identity exchange). Stock clients have
|
||||||
|
/// neither, so we send them plain text rather than typed envelopes.
|
||||||
|
async fn is_archy_peer(&self, contact_id: u32) -> bool {
|
||||||
|
if contact_id & 0x8000_0000 != 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let peers = self.state.peers.read().await;
|
||||||
|
peers
|
||||||
|
.get(&contact_id)
|
||||||
|
.map(|p| p.did.is_some() || p.x25519_pubkey.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a Sent MeshMessage for a typed envelope that has already been
|
/// Record a Sent MeshMessage for a typed envelope that has already been
|
||||||
/// transmitted by the caller. Used by the RPC layer after sending
|
/// transmitted by the caller. Used by the RPC layer after sending
|
||||||
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
||||||
|
|||||||
@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
|||||||
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
||||||
/// frames to such contacts.
|
/// frames to such contacts.
|
||||||
pub const CMD_RESET_PATH: u8 = 0x0D;
|
pub const CMD_RESET_PATH: u8 = 0x0D;
|
||||||
|
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
|
||||||
|
/// table. 144-byte frame (see `build_add_contact`).
|
||||||
|
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
|
||||||
|
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
|
||||||
|
/// firmware's persistent table (used by clear-all so wiped contacts actually
|
||||||
|
/// go away and only return when they re-advertise).
|
||||||
|
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
|
||||||
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
|
||||||
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
|
||||||
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
|
||||||
@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
|
|||||||
encode_frame(&data)
|
encode_frame(&data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
|
||||||
|
/// the firmware's persistent contact table.
|
||||||
|
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
|
||||||
|
let mut data = vec![CMD_REMOVE_CONTACT];
|
||||||
|
data.extend_from_slice(pubkey);
|
||||||
|
encode_frame(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
|
||||||
|
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
|
||||||
|
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
|
||||||
|
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
|
||||||
|
/// advert on its side too, so an empty name still resolves on get-contacts).
|
||||||
|
pub fn build_add_contact(
|
||||||
|
pubkey: &[u8; 32],
|
||||||
|
contact_type: u8,
|
||||||
|
flags: u8,
|
||||||
|
out_path_len: u8,
|
||||||
|
name: &str,
|
||||||
|
last_advert: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut data = Vec::with_capacity(144);
|
||||||
|
data.push(CMD_ADD_UPDATE_CONTACT);
|
||||||
|
data.extend_from_slice(pubkey); // 32
|
||||||
|
data.push(contact_type); // 1
|
||||||
|
data.push(flags); // 1
|
||||||
|
data.push(out_path_len); // 1
|
||||||
|
data.extend_from_slice(&[0u8; 64]); // out_path (64)
|
||||||
|
let mut name_buf = [0u8; 32];
|
||||||
|
let nb = name.as_bytes();
|
||||||
|
let n = nb.len().min(32);
|
||||||
|
name_buf[..n].copy_from_slice(&nb[..n]);
|
||||||
|
data.extend_from_slice(&name_buf); // name (32)
|
||||||
|
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
|
||||||
|
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
|
||||||
|
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
|
||||||
|
encode_frame(&data)
|
||||||
|
}
|
||||||
|
|
||||||
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
|
||||||
pub fn build_sync_next_message() -> Vec<u8> {
|
pub fn build_sync_next_message() -> Vec<u8> {
|
||||||
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
||||||
|
|||||||
@ -206,6 +206,24 @@ impl MeshcoreDevice {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
|
||||||
|
/// addressed by the first 6 bytes of its public key. Unlike the
|
||||||
|
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
|
||||||
|
/// the public channel, and a stock meshcore client receives it as a normal
|
||||||
|
/// DM. The contact must already exist in the firmware table (with a path).
|
||||||
|
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
|
||||||
|
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
|
||||||
|
self.send_raw(&frame_data).await?;
|
||||||
|
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||||
|
if frame.code == protocol::RESP_ERR {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Direct text send failed: {}",
|
||||||
|
protocol::parse_error(&frame.data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear the stored routing path for a contact so the firmware flood-
|
/// Clear the stored routing path for a contact so the firmware flood-
|
||||||
/// routes future messages instead of dropping them when path_len=0.
|
/// routes future messages instead of dropping them when path_len=0.
|
||||||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||||
@ -217,6 +235,47 @@ impl MeshcoreDevice {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a contact from the firmware's persistent contact table.
|
||||||
|
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||||
|
self.send_raw(&protocol::build_remove_contact(pubkey))
|
||||||
|
.await?;
|
||||||
|
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||||
|
if frame.code == protocol::RESP_ERR {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Remove contact failed: {}",
|
||||||
|
protocol::parse_error(&frame.data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
|
||||||
|
/// Used to import a heard advert so it shows up as a contact immediately.
|
||||||
|
pub async fn add_contact(
|
||||||
|
&mut self,
|
||||||
|
pubkey: &[u8; 32],
|
||||||
|
contact_type: u8,
|
||||||
|
flags: u8,
|
||||||
|
out_path_len: u8,
|
||||||
|
name: &str,
|
||||||
|
last_advert: u32,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.send_raw(&protocol::build_add_contact(
|
||||||
|
pubkey,
|
||||||
|
contact_type,
|
||||||
|
flags,
|
||||||
|
out_path_len,
|
||||||
|
name,
|
||||||
|
last_advert,
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
|
||||||
|
if frame.code == protocol::RESP_ERR {
|
||||||
|
anyhow::bail!("Add contact failed: {}", protocol::parse_error(&frame.data));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the list of known contacts from the device.
|
/// Get the list of known contacts from the device.
|
||||||
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
||||||
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
||||||
|
|||||||
@ -45,6 +45,15 @@ pub struct MeshPeer {
|
|||||||
pub last_heard: String,
|
pub last_heard: String,
|
||||||
/// Number of hops to reach this peer.
|
/// Number of hops to reach this peer.
|
||||||
pub hops: u8,
|
pub hops: u8,
|
||||||
|
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
|
||||||
|
/// 0 if unknown. Used to gauge reachability/recency in the UI.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_advert: u32,
|
||||||
|
/// Best-effort "currently reachable" flag: the radio has a route to this
|
||||||
|
/// contact (or it's a federation/identity peer reachable off-radio). A
|
||||||
|
/// contact with no path and no recent advert is shown as unreachable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reachable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direction of a mesh message.
|
/// Direction of a mesh message.
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export interface MeshPeer {
|
|||||||
snr: number | null
|
snr: number | null
|
||||||
last_heard: string
|
last_heard: string
|
||||||
hops: number
|
hops: number
|
||||||
|
last_advert?: number
|
||||||
|
reachable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshChannel {
|
export interface MeshChannel {
|
||||||
|
|||||||
@ -501,6 +501,7 @@ interface MergedPeer {
|
|||||||
primary_pubkey_hex: string | null
|
primary_pubkey_hex: string | null
|
||||||
primary_rssi: number | null
|
primary_rssi: number | null
|
||||||
is_archy: boolean
|
is_archy: boolean
|
||||||
|
reachable: boolean
|
||||||
// The original active-chat marker uses contact_id equality, so keep a
|
// The original active-chat marker uses contact_id equality, so keep a
|
||||||
// representative MeshPeer for the rest of the codepaths that still want
|
// representative MeshPeer for the rest of the codepaths that still want
|
||||||
// a single object (peer header rssi, prekey rotation, etc).
|
// a single object (peer header rssi, prekey rotation, etc).
|
||||||
@ -622,6 +623,7 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
|||||||
primary_pubkey_hex: peer.pubkey_hex,
|
primary_pubkey_hex: peer.pubkey_hex,
|
||||||
primary_rssi: peer.rssi,
|
primary_rssi: peer.rssi,
|
||||||
is_archy: isArchyNode(peer) || !!matchedFed,
|
is_archy: isArchyNode(peer) || !!matchedFed,
|
||||||
|
reachable: peer.reachable ?? true,
|
||||||
primary: peer,
|
primary: peer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -671,12 +673,27 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
|||||||
primary_pubkey_hex: fed.pubkey,
|
primary_pubkey_hex: fed.pubkey,
|
||||||
primary_rssi: null,
|
primary_rssi: null,
|
||||||
is_archy: true,
|
is_archy: true,
|
||||||
|
reachable: true,
|
||||||
primary: placeholder,
|
primary: placeholder,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Array.from(groups.values())
|
return Array.from(groups.values())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Contact search — filters the Peers list by name, DID, npub, or pubkey.
|
||||||
|
const peerSearch = ref('')
|
||||||
|
const displayedPeers = computed<MergedPeer[]>(() => {
|
||||||
|
const q = peerSearch.value.trim().toLowerCase()
|
||||||
|
if (!q) return mergedPeers.value
|
||||||
|
return mergedPeers.value.filter((mp) =>
|
||||||
|
mp.display_name.toLowerCase().includes(q) ||
|
||||||
|
(mp.short_did?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(mp.did?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(mp.npub?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
(mp.primary_pubkey_hex?.toLowerCase().includes(q) ?? false),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the
|
// Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the
|
||||||
// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top
|
// first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top
|
||||||
// bit, then set it as the federation marker. Producing the SAME id here means a
|
// bit, then set it as the federation marker. Producing the SAME id here means a
|
||||||
@ -1417,6 +1434,25 @@ function isImageMime(mime?: string): boolean {
|
|||||||
<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>
|
<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>
|
||||||
|
|
||||||
|
<!-- Contact search: filters the list below by name / DID / npub / pubkey -->
|
||||||
|
<div class="mesh-peer-search-wrap">
|
||||||
|
<input
|
||||||
|
v-model="peerSearch"
|
||||||
|
type="text"
|
||||||
|
class="mesh-peer-search"
|
||||||
|
placeholder="Search contacts…"
|
||||||
|
aria-label="Search contacts"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="peerSearch"
|
||||||
|
type="button"
|
||||||
|
class="mesh-peer-search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
title="Clear search"
|
||||||
|
@click="peerSearch = ''"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||||
No peers discovered yet.
|
No peers discovered yet.
|
||||||
</div>
|
</div>
|
||||||
@ -1454,8 +1490,11 @@ function isImageMime(mime?: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
|
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
|
||||||
|
No contacts match “{{ peerSearch.trim() }}”.
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="mp in mergedPeers" :key="mp.key"
|
v-for="mp in displayedPeers" :key="mp.key"
|
||||||
class="mesh-peer-row"
|
class="mesh-peer-row"
|
||||||
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
|
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@ -1466,6 +1505,7 @@ function isImageMime(mime?: string): boolean {
|
|||||||
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
|
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
|
||||||
<AnimatedLogo v-if="mp.is_archy" size="sm" />
|
<AnimatedLogo v-if="mp.is_archy" size="sm" />
|
||||||
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
|
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
|
||||||
|
<span class="mesh-peer-reach" :class="mp.reachable ? 'is-reachable' : 'is-unreachable'" :title="mp.reachable ? 'Reachable' : 'Not currently reachable'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mesh-peer-info">
|
<div class="mesh-peer-info">
|
||||||
<div class="mesh-peer-name">
|
<div class="mesh-peer-name">
|
||||||
|
|||||||
@ -62,6 +62,32 @@ function toggleAllowed(pubkey: string) {
|
|||||||
apply({ allowed_contacts: allowedContacts.value })
|
apply({ allowed_contacts: allowedContacts.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that
|
||||||
|
// isn't in the contact list yet (e.g. a phone/meshcore device).
|
||||||
|
const newPubkey = ref('')
|
||||||
|
const pubkeyError = ref('')
|
||||||
|
// Allowlisted keys that aren't one of our known contacts (manually added).
|
||||||
|
const extraAllowed = computed(() =>
|
||||||
|
allowedContacts.value.filter(
|
||||||
|
(k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
function addPubkey() {
|
||||||
|
const pk = newPubkey.value.trim().toLowerCase()
|
||||||
|
pubkeyError.value = ''
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(pk)) {
|
||||||
|
pubkeyError.value = 'Enter a 64-character hex ed25519 public key.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) {
|
||||||
|
newPubkey.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
allowedContacts.value = [...allowedContacts.value, pk]
|
||||||
|
newPubkey.value = ''
|
||||||
|
apply({ allowed_contacts: allowedContacts.value })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mesh.fetchAssistantStatus()
|
mesh.fetchAssistantStatus()
|
||||||
})
|
})
|
||||||
@ -206,7 +232,28 @@ function onPolicy() {
|
|||||||
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
|
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
|
||||||
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
|
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<!-- Manually-added pubkeys not in the contact list -->
|
||||||
|
<label
|
||||||
|
v-for="pk in extraAllowed"
|
||||||
|
:key="pk"
|
||||||
|
class="mesh-assistant-allow-row"
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked @change="toggleAllowed(pk)" />
|
||||||
|
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }}… (added)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add an arbitrary pubkey directly -->
|
||||||
|
<div class="mesh-assistant-addkey">
|
||||||
|
<input
|
||||||
|
v-model="newPubkey"
|
||||||
|
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
|
||||||
|
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
|
||||||
|
@keyup.enter="addPubkey"
|
||||||
|
/>
|
||||||
|
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-white/50 mt-2">
|
<p class="text-xs text-white/50 mt-2">
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
|
||||||
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
|
||||||
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
|
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; }
|
||||||
.mesh-tools-wrapper { display: contents; }
|
.mesh-tools-wrapper { display: contents; }
|
||||||
.mesh-tools-tab-bar { display: none; }
|
.mesh-tools-tab-bar { display: none; }
|
||||||
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
|
.mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; }
|
||||||
@ -85,7 +85,16 @@
|
|||||||
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
.mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||||
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
|
.mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); }
|
||||||
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
|
.mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); }
|
||||||
.mesh-peer-avatar { width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
|
.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; }
|
||||||
|
.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; }
|
||||||
|
.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; }
|
||||||
|
.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); }
|
||||||
|
.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); }
|
||||||
|
.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; }
|
||||||
|
.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; }
|
||||||
|
.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; }
|
||||||
|
.mesh-peer-reach.is-reachable { background: #34d399; }
|
||||||
|
.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); }
|
||||||
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
|
.mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; }
|
||||||
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
|
.mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; }
|
||||||
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
|
.mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; }
|
||||||
@ -236,6 +245,8 @@
|
|||||||
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
|
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
|
||||||
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
|
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
|
||||||
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; }
|
||||||
|
.mesh-assistant-addkey input { flex: 1; min-width: 0; }
|
||||||
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
|
||||||
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
|
||||||
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user