Compare commits
8 Commits
2a017623e9
...
d0ca53501c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ca53501c | ||
|
|
790da4bd0f | ||
|
|
cc2e055e09 | ||
|
|
549c6180a2 | ||
|
|
ec644ab90f | ||
|
|
f0fdc23cc9 | ||
|
|
9f2edf6b7a | ||
|
|
3a21243be7 |
29
CHANGELOG.md
29
CHANGELOG.md
@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.00-alpha (2026-06-18)
|
||||
|
||||
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
|
||||
|
||||
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
|
||||
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
|
||||
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
|
||||
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
|
||||
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
|
||||
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
|
||||
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
|
||||
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
|
||||
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
|
||||
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
|
||||
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
|
||||
|
||||
All the v1.7.99-alpha features are included as well:
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
|
||||
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
|
||||
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
|
||||
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
|
||||
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
|
||||
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
|
||||
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
|
||||
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
|
||||
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
|
||||
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
|
||||
|
||||
## v1.7.99-alpha (2026-06-17)
|
||||
|
||||
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
|
||||
|
||||
@ -36,9 +36,12 @@ app:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||
# relays to reach iroh-transport federations. Lock down once the default
|
||||
# federation's reachability model is finalized.
|
||||
network_policy: open
|
||||
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
|
||||
# wallet bridge targets. ("open" is not a valid policy — it made the loader
|
||||
# skip this whole manifest, so fmcd never ran and federations never joined.)
|
||||
# Lock down once the default federation's reachability model is finalized.
|
||||
network_policy: bridge
|
||||
|
||||
ports:
|
||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||
|
||||
@ -222,8 +222,12 @@ impl ApiHandler {
|
||||
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
|
||||
)),
|
||||
Err(e) => {
|
||||
// Surface the FULL error chain ({:#}) — the generic top-level
|
||||
// message hid the real cause (e.g. the LND REST connection
|
||||
// failing), which made this 503 undiagnosable.
|
||||
tracing::warn!("content invoice creation failed: {e:#}");
|
||||
let body = serde_json::json!({
|
||||
"error": format!("Could not create invoice: {e}")
|
||||
"error": format!("Could not create invoice: {e:#}")
|
||||
});
|
||||
Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
|
||||
@ -173,13 +173,42 @@ impl RpcHandler {
|
||||
"value": amount_sats.to_string(),
|
||||
"memo": memo,
|
||||
});
|
||||
let resp = client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create invoice")?;
|
||||
// LND's REST endpoint can briefly drop/reset connections under load
|
||||
// (swap pressure, just-restarted, TLS handshake races), which used to
|
||||
// hard-fail the buy-file invoice with an opaque 503. Retry the send a
|
||||
// few times with short backoff so a transient blip doesn't surface as
|
||||
// a payment failure. The surrounding error now carries the real cause.
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
let mut resp = None;
|
||||
for attempt in 0..3u32 {
|
||||
match client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&invoice_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
resp = Some(r);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!(
|
||||
"LND REST connect failed (attempt {}): {e}",
|
||||
attempt + 1
|
||||
));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
let resp = match resp {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Err(last_err.unwrap_or_else(|| {
|
||||
anyhow::anyhow!("Failed to reach LND REST to create invoice")
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
|
||||
@ -32,6 +32,7 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
"default_model": DEFAULT_MODEL,
|
||||
"ollama_detected": ollama_detected,
|
||||
"claude_available": claude_available,
|
||||
@ -64,8 +65,18 @@ impl RpcHandler {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// allowed_contacts: present + array => replace the allowlist (pubkey hex
|
||||
// strings); absent => leave unchanged.
|
||||
let allowed_contacts = params
|
||||
.get("allowed_contacts")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|e| e.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<String>>()
|
||||
});
|
||||
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend)
|
||||
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
|
||||
.await?;
|
||||
let cfg = svc.assistant_config().await;
|
||||
Ok(serde_json::json!({
|
||||
@ -73,6 +84,7 @@ impl RpcHandler {
|
||||
"model": cfg.model,
|
||||
"trusted_only": cfg.trusted_only,
|
||||
"backend": cfg.backend,
|
||||
"allowed_contacts": cfg.allowed_contacts,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -258,43 +258,45 @@ impl RpcHandler {
|
||||
if let Some(svc) = service.as_ref() {
|
||||
let state = svc.state();
|
||||
|
||||
// Snapshot the firmware pubkeys we currently know about, then
|
||||
// add them to the radio-contact blocklist. MeshCore's on-device
|
||||
// contact table is persistent and reads back stale rows on the
|
||||
// next refresh_contacts, so without this step `clear-all` only
|
||||
// wipes the app view for a few seconds before the old entries
|
||||
// reappear. The blocklist is also saved to disk so the filter
|
||||
// survives a restart.
|
||||
let firmware_pubkeys: Vec<String> = state
|
||||
// NOTE: `clear-all` intentionally does NOT build a radio-contact
|
||||
// blocklist. Permanently ignoring firmware contacts meant a cleared
|
||||
// peer could never return even when it re-advertised (it also broke
|
||||
// re-pairing a phone after a clear). Real per-contact blocking will
|
||||
// be a separate, explicit feature. Here we just wipe the app-side
|
||||
// view and ALSO clear any blocklist left over from older builds, so
|
||||
// previously-hidden contacts can re-appear when next heard. The
|
||||
// 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
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
// Federation-synthetic peers have their contact_id in the
|
||||
// high half of u32 and carry the archipelago key — those
|
||||
// aren't firmware contacts and must not go on the list.
|
||||
if p.contact_id & 0x8000_0000 != 0 {
|
||||
None
|
||||
} else {
|
||||
p.pubkey_hex.clone()
|
||||
}
|
||||
.filter(|p| p.contact_id & 0x8000_0000 == 0)
|
||||
.filter_map(|p| p.pubkey_hex.as_deref())
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.filter(|b| b.len() == 32)
|
||||
.map(|b| {
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(&b);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
{
|
||||
let mut set = state.radio_contact_blocklist.write().await;
|
||||
for pk in &firmware_pubkeys {
|
||||
set.insert(pk.clone());
|
||||
}
|
||||
for pk in firmware_pubkeys {
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
|
||||
.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.messages.write().await.clear();
|
||||
|
||||
@ -349,13 +349,37 @@ fn http_probe_cmd(url: &'static str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bitcoin UTXO cache (`-dbcache`) in MB, sized to host RAM.
|
||||
///
|
||||
/// A fixed large dbcache on a small box pushes bitcoind + the ~20 app
|
||||
/// containers past physical RAM and triggers system-wide swap thrash: the
|
||||
/// disk saturates, bitcoind can't answer its own RPC, and the dashboard
|
||||
/// backend's sqlite reads stall — surfacing as /rpc/v1 502s and a blank
|
||||
/// Bitcoin UI. Budget ~1/16 of RAM for the cache (floor 300 MB — bitcoind's
|
||||
/// own default is 450 — cap 4096 MB), mirroring scripts/container-specs.sh.
|
||||
pub(super) fn bitcoin_dbcache_mb() -> u64 {
|
||||
let total_mb = std::fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
c.lines()
|
||||
.find_map(|l| l.strip_prefix("MemTotal:"))
|
||||
.and_then(|v| v.split_whitespace().next())
|
||||
.and_then(|kb| kb.parse::<u64>().ok())
|
||||
})
|
||||
.map(|kb| kb / 1024)
|
||||
.unwrap_or(16000); // assume a comfortable host if /proc/meminfo is unreadable
|
||||
(total_mb / 16).clamp(300, 4096)
|
||||
}
|
||||
|
||||
/// Get per-app memory limit.
|
||||
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
|
||||
match app_id {
|
||||
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
|
||||
// headroom for mempool + connection buffers + script-verifier
|
||||
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
|
||||
// floor; ideally this would be host-RAM aware (next pass).
|
||||
// Heavy apps. Bitcoin: dbcache is now host-RAM-aware (see
|
||||
// bitcoin_dbcache_mb), so the daemon's footprint scales with the box.
|
||||
// This cgroup cap is an upper bound for mempool + connection buffers +
|
||||
// script-verifier memory + I/O; a tight cap (4g) previously caused
|
||||
// OOM-cascades during IBD, so keep 8g as a generous ceiling rather
|
||||
// than a tight limit — swap thrash is prevented at the dbcache layer.
|
||||
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
|
||||
// ElectrumX indexing spikes above its cache size due Python,
|
||||
// RocksDB, socket buffers, and reorg/history work. Keep cache
|
||||
@ -674,9 +698,10 @@ pub(super) async fn get_app_config(
|
||||
// RPC is reachable from the bitcoin-ui companion container.
|
||||
//
|
||||
// Sync-speed flags:
|
||||
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
|
||||
// diminishing returns. Container has --memory=8g now so
|
||||
// there's headroom for mempool + connections.
|
||||
// -dbcache — UTXO set cache, sized to host RAM via
|
||||
// bitcoin_dbcache_mb() (see there). A fixed 4GB cache swap-
|
||||
// thrashed small nodes into fleet-wide 502s; ~1/16 of RAM
|
||||
// keeps headroom for mempool + connections + the app stack.
|
||||
// -par=0 — use all available cores for script
|
||||
// verification (defaults to NCPU-1 capped at 16). Was
|
||||
// effectively pinned at 2 by --cpus=2 (now removed).
|
||||
@ -689,7 +714,7 @@ pub(super) async fn get_app_config(
|
||||
"-rpcport=8332".to_string(),
|
||||
"-printtoconsole=1".to_string(),
|
||||
"-datadir=/home/bitcoin/.bitcoin".to_string(),
|
||||
"-dbcache=4096".to_string(),
|
||||
format!("-dbcache={}", bitcoin_dbcache_mb()),
|
||||
"-par=0".to_string(),
|
||||
"-maxconnections=125".to_string(),
|
||||
]),
|
||||
|
||||
@ -13,8 +13,14 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const CACHE_REFRESH_SECS: u64 = 10;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
|
||||
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
|
||||
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
|
||||
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
|
||||
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
|
||||
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
|
||||
// timeout keeps last-known state fresh and clears the stale banner promptly.
|
||||
const CACHE_REFRESH_SECS: u64 = 5;
|
||||
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BitcoinNodeStatus {
|
||||
@ -147,25 +153,20 @@ pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
|
||||
|
||||
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.context("build Bitcoin status HTTP client")?;
|
||||
|
||||
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getblockchaininfo")?;
|
||||
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getnetworkinfo")
|
||||
.ok();
|
||||
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
|
||||
.await
|
||||
.context("getindexinfo")
|
||||
.ok();
|
||||
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
|
||||
.await
|
||||
.context("getzmqnotifications")
|
||||
.ok();
|
||||
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
|
||||
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
|
||||
// the next refresh. Only getblockchaininfo failing marks the status stale.
|
||||
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
|
||||
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
|
||||
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
|
||||
);
|
||||
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
|
||||
|
||||
Ok(BitcoinNodeStatus {
|
||||
ok: true,
|
||||
@ -173,9 +174,9 @@ async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
|
||||
updated_at_ms: now_ms(),
|
||||
error: None,
|
||||
blockchain_info: Some(blockchain_info),
|
||||
network_info,
|
||||
index_info,
|
||||
zmq_notifications,
|
||||
network_info: network_info.ok(),
|
||||
index_info: index_info.ok(),
|
||||
zmq_notifications: zmq_notifications.ok(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
//! asker is limited to one in-flight query.
|
||||
|
||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||
use super::super::types::MeshEvent;
|
||||
use super::bitcoin::send_to_peer;
|
||||
use super::{MeshCommand, MeshState};
|
||||
use crate::federation::TrustLevel;
|
||||
@ -42,6 +43,16 @@ pub(super) enum AssistReply {
|
||||
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||
ChannelText { channel: u8 },
|
||||
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
|
||||
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
|
||||
/// direct message, so the answer lands inline in that same conversation
|
||||
/// (encrypted, peer-addressed) rather than as a separate widget.
|
||||
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
|
||||
@ -169,6 +180,15 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit per-contact allowlist: a listed pubkey may ask regardless of
|
||||
// the trusted_only policy (block check above still wins).
|
||||
if let Some(ref pk) = pubkey_hex {
|
||||
let allowed = state.assistant.read().await.allowed_contacts.clone();
|
||||
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if !state.assistant.read().await.trusted_only {
|
||||
return true;
|
||||
}
|
||||
@ -205,6 +225,19 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
||||
let text = cap_channel(answer);
|
||||
send_channel_text(state, *channel, &text).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
let (text, _) = cap_reply(answer);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -224,6 +257,17 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
|
||||
AssistReply::ChannelText { channel } => {
|
||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
||||
}
|
||||
AssistReply::ChatText { contact_id } => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,6 +316,23 @@ async fn send_typed_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
|
||||
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
|
||||
/// than sending here, because the reply must be routed transport-aware:
|
||||
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
|
||||
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
|
||||
/// to POST over the peer's onion for a federation-synthetic contact_id. The
|
||||
/// radio-only path used to drop the reply for federation askers — the answer
|
||||
/// showed on the answering node but never reached the asker. A server-layer
|
||||
/// consumer fulfils this event via `send_message`, which also records the
|
||||
/// Sent bubble and allocates the seq.
|
||||
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
|
||||
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||
let _ = state
|
||||
|
||||
@ -353,28 +353,37 @@ pub(super) async fn store_plain_message(
|
||||
state.status.write().await.messages_received += 1;
|
||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` on the
|
||||
// channel is answered by this node's local model when the assistant is on.
|
||||
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
||||
// see it. The trust/rate gate lives in run_assist.
|
||||
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
|
||||
// by this node's local model when the assistant is on. The trust/rate gate
|
||||
// lives in run_assist. The reply goes back as a private NATIVE DM to the
|
||||
// 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 let Some(prompt) = strip_ai_trigger(text) {
|
||||
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 prompt = prompt.to_string();
|
||||
let name = peer_name.to_string();
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
contact_id,
|
||||
name,
|
||||
super::assist::AssistReply::ChannelText { channel: 0 },
|
||||
st,
|
||||
)
|
||||
.await;
|
||||
super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -383,7 +392,7 @@ pub(super) async fn store_plain_message(
|
||||
|
||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
||||
/// trimmed question after it, or `None` if the text isn't an AI command.
|
||||
fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
|
||||
let t = text.trim_start();
|
||||
for p in ["!ai ", "!ask "] {
|
||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||
@ -480,6 +489,9 @@ pub(super) async fn handle_identity_received(
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
// We just heard this peer's identity advert, so it's reachable.
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
|
||||
@ -679,6 +679,36 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
Some(envelope.seq),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
|
||||
// the normal 1:1 chat triggers this node's assistant, with the
|
||||
// answer sent back as a chat bubble in the same thread. The typed
|
||||
// DM carries the peer's federation identity (via sender_contact_id),
|
||||
// so the `trusted_only` gate in run_assist resolves correctly —
|
||||
// unlike the bare channel-text path, which only knows the radio key.
|
||||
if state.assistant.read().await.enabled {
|
||||
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
|
||||
if !prompt.is_empty() {
|
||||
let req_id = state.next_id().await;
|
||||
let prompt = prompt.to_string();
|
||||
let name = sender_name.to_string();
|
||||
let cid = sender_contact_id;
|
||||
let st = Arc::clone(state);
|
||||
tokio::spawn(async move {
|
||||
super::assist::run_assist(
|
||||
prompt,
|
||||
None,
|
||||
req_id,
|
||||
cid,
|
||||
name,
|
||||
super::assist::AssistReply::ChatText { contact_id: cid },
|
||||
st,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::AssistQuery) => {
|
||||
|
||||
@ -22,8 +22,33 @@ pub(super) async fn handle_frame(
|
||||
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
|
||||
info!(
|
||||
code = frame.code,
|
||||
data_len = frame.data.len(),
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@ -63,6 +63,14 @@ pub enum MeshCommand {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
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.
|
||||
BroadcastChannel {
|
||||
channel: u8,
|
||||
@ -71,6 +79,16 @@ pub enum MeshCommand {
|
||||
SendAdvert,
|
||||
/// Re-fetch contact list from the radio device.
|
||||
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.
|
||||
@ -148,6 +166,10 @@ pub struct AssistantConfig {
|
||||
pub trusted_only: bool,
|
||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||
pub backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
|
||||
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
|
||||
/// applies. A user-blocked contact is always denied even if listed here.
|
||||
pub allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||
|
||||
@ -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>> {
|
||||
match self {
|
||||
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.
|
||||
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(
|
||||
dest_pubkey_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
|
||||
/// caller constructed `MeshState` with a bad value — empty string yields
|
||||
/// 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] {
|
||||
let mut out = [0u8; 6];
|
||||
if state.our_ed_pubkey_hex.len() >= 12 {
|
||||
@ -195,39 +234,42 @@ async fn send_dm_via_channel(
|
||||
consecutive_write_failures: &mut u32,
|
||||
) {
|
||||
use base64::Engine;
|
||||
let sender_prefix = our_sender_prefix(state);
|
||||
// First try a single frame with the raw payload directly wrapped.
|
||||
// This keeps small plain-text messages at minimal overhead.
|
||||
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
|
||||
if single.len() <= 140 {
|
||||
match device.send_channel_text(0, single.as_bytes()).await {
|
||||
let _ = state; // native unicast carries no separate sender prefix
|
||||
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
|
||||
// contact, NOT a broadcast on the shared public channel. This is the fix
|
||||
// for the long-standing public-channel pollution — archy used to tunnel
|
||||
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
|
||||
// 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(()) => {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
len = payload.len(),
|
||||
wire_len = single.len(),
|
||||
"Sent mesh message (DM via channel)"
|
||||
"Sent mesh DM (native unicast)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Failed to send DM via channel: {}", e
|
||||
"Failed to send native DM: {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
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);
|
||||
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 chunk_data_size = 80;
|
||||
let chunk_data_size = 100;
|
||||
let chunks: Vec<&str> = encoded
|
||||
.as_bytes()
|
||||
.chunks(chunk_data_size)
|
||||
@ -239,18 +281,20 @@ async fn send_dm_via_channel(
|
||||
raw_len = payload.len(),
|
||||
b64_len = encoded.len(),
|
||||
chunks = total,
|
||||
"Sending chunked mesh message (DM via channel)"
|
||||
"Sending chunked mesh DM (native unicast)"
|
||||
);
|
||||
let mut any_err = false;
|
||||
for (idx, chunk) in chunks.iter().enumerate() {
|
||||
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.send_channel_text(0, wrapped.as_bytes()).await {
|
||||
if let Err(e) = device
|
||||
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
|
||||
.await
|
||||
{
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
chunk = idx,
|
||||
"Chunk DM-via-channel send failed: {}",
|
||||
"Chunk native DM send failed: {}",
|
||||
e
|
||||
);
|
||||
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.
|
||||
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
|
||||
match device.get_contacts().await {
|
||||
Ok(contacts) => {
|
||||
// Skip firmware contacts the user has explicitly wiped via
|
||||
// mesh.clear-all. MeshCore keeps its own persistent contact
|
||||
// table the app can't remove from, so we filter on read to
|
||||
// keep cleared entries out of the chat list.
|
||||
let blocklist = state.radio_contact_blocklist.read().await.clone();
|
||||
// Contact blocking is intentionally NOT applied here. A read-time
|
||||
// blocklist meant a wiped/re-paired contact could never come back
|
||||
// even when it re-advertised (it broke phone re-pairing after a
|
||||
// clear). Per-contact blocking will return later as an explicit,
|
||||
// user-controlled feature; until then every firmware contact is
|
||||
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||
let mut peers = state.peers.write().await;
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
if blocklist.contains(&contact.public_key_hex) {
|
||||
continue;
|
||||
}
|
||||
let contact_id = idx as u32;
|
||||
let existing = peers.get(&contact_id);
|
||||
let peer = super::super::types::MeshPeer {
|
||||
@ -289,6 +385,10 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
snr: None,
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
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);
|
||||
}
|
||||
@ -424,21 +524,16 @@ pub(super) async fn run_mesh_session(
|
||||
warn!("Failed to send initial advert: {}", e);
|
||||
}
|
||||
|
||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
||||
our_did,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
);
|
||||
if let Err(e) = device
|
||||
.send_channel_text(0, identity_advert.as_bytes())
|
||||
.await
|
||||
{
|
||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||
// participant on that channel — including plain Meshtastic/meshcore users who
|
||||
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
|
||||
// The inbound parser in frames.rs still accepts these from any legacy peer that
|
||||
// sends them, so trust-binding keeps working when a peer advertises; we simply
|
||||
// don't pollute the public channel ourselves. A dedicated control channel (or a
|
||||
// DM-targeted handshake) is the proper transport for this and is tracked
|
||||
// separately. See encode_identity_broadcast / parse_identity_broadcast.
|
||||
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
|
||||
|
||||
// Fetch existing contacts from the device
|
||||
refresh_contacts(&mut device, state).await;
|
||||
@ -507,11 +602,9 @@ pub(super) async fn run_mesh_session(
|
||||
} else {
|
||||
consecutive_write_failures = 0;
|
||||
}
|
||||
// Re-broadcast archipelago identity so peers that joined since
|
||||
// startup (or missed it) can bind our DID/keys.
|
||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
||||
}
|
||||
// (Identity re-broadcast on the public channel intentionally
|
||||
// removed — see the note at session startup. It spammed the
|
||||
// shared channel every advert tick.)
|
||||
refresh_contacts(&mut device, state).await;
|
||||
}
|
||||
|
||||
@ -562,6 +655,18 @@ async fn handle_send_command(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendNativeText {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
} => {
|
||||
send_plain_native_text(
|
||||
device,
|
||||
&dest_pubkey_prefix,
|
||||
&payload,
|
||||
consecutive_write_failures,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
@ -615,5 +720,22 @@ async fn handle_send_command(
|
||||
MeshCommand::RefreshContacts => {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
if self.contacts.is_empty() {
|
||||
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),
|
||||
last_heard: chrono::Utc::now().to_rfc3339(),
|
||||
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);
|
||||
drop(peers);
|
||||
@ -197,6 +200,10 @@ pub struct MeshConfig {
|
||||
/// local GPU) or "ollama" (a local model on this node).
|
||||
#[serde(default = "default_assistant_backend")]
|
||||
pub assistant_backend: String,
|
||||
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai` even
|
||||
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
|
||||
#[serde(default)]
|
||||
pub assistant_allowed_contacts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_assistant_backend() -> String {
|
||||
@ -224,6 +231,7 @@ impl Default for MeshConfig {
|
||||
assistant_model: None,
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -401,6 +409,7 @@ impl MeshService {
|
||||
model: config.assistant_model.clone(),
|
||||
trusted_only: config.assistant_trusted_only,
|
||||
backend: config.assistant_backend.clone(),
|
||||
allowed_contacts: config.assistant_allowed_contacts.clone(),
|
||||
},
|
||||
data_dir.to_path_buf(),
|
||||
);
|
||||
@ -578,6 +587,7 @@ impl MeshService {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await; // skip first
|
||||
let mut last_announced_height: u64 = 0;
|
||||
let mut last_announce_at: Option<std::time::Instant> = None;
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
@ -595,6 +605,18 @@ impl MeshService {
|
||||
// Poll Bitcoin Core for latest block
|
||||
match bitcoin_rpc_getblockcount(&client).await {
|
||||
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 {
|
||||
// Store in cache
|
||||
let payload = message_types::BlockHeaderPayload {
|
||||
@ -640,30 +662,15 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any peer if no Archy nodes found
|
||||
if sent == 0 {
|
||||
for peer in peers.values() {
|
||||
if sent >= max_peers { break; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE: intentionally NO fallback to arbitrary
|
||||
// peers. Block headers go ONLY to known Archy
|
||||
// (federated) nodes — never to random meshcore
|
||||
// devices on the shared public channel.
|
||||
drop(peers);
|
||||
last_announced_height = height;
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
if sent > 0 {
|
||||
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),
|
||||
}
|
||||
@ -1267,6 +1274,24 @@ impl MeshService {
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
|
||||
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 =
|
||||
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
@ -1274,6 +1299,22 @@ impl MeshService {
|
||||
.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
|
||||
/// transmitted by the caller. Used by the RPC layer after sending
|
||||
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
||||
@ -1401,6 +1442,7 @@ impl MeshService {
|
||||
model: Option<Option<String>>,
|
||||
trusted_only: Option<bool>,
|
||||
backend: Option<String>,
|
||||
allowed_contacts: Option<Vec<String>>,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let mut a = self.state.assistant.write().await;
|
||||
@ -1416,6 +1458,9 @@ impl MeshService {
|
||||
if let Some(b) = backend {
|
||||
a.backend = b;
|
||||
}
|
||||
if let Some(list) = allowed_contacts {
|
||||
a.allowed_contacts = list;
|
||||
}
|
||||
}
|
||||
// Persist by updating the on-disk config (the in-memory `self.config`
|
||||
// snapshot stays as-is; the live `state.assistant` is the runtime
|
||||
@ -1427,6 +1472,7 @@ impl MeshService {
|
||||
cfg.assistant_model = a.model.clone();
|
||||
cfg.assistant_trusted_only = a.trusted_only;
|
||||
cfg.assistant_backend = a.backend.clone();
|
||||
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
|
||||
}
|
||||
save_config(&self.data_dir, &cfg).await?;
|
||||
Ok(())
|
||||
|
||||
@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
|
||||
/// known" — without this, the firmware silently drops outbound TXT_MSG
|
||||
/// frames to such contacts.
|
||||
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_TX_POWER: u8 = 0x0C;
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn build_sync_next_message() -> Vec<u8> {
|
||||
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
|
||||
|
||||
@ -206,6 +206,24 @@ impl MeshcoreDevice {
|
||||
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-
|
||||
/// routes future messages instead of dropping them when path_len=0.
|
||||
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
|
||||
@ -217,6 +235,47 @@ impl MeshcoreDevice {
|
||||
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.
|
||||
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
|
||||
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
|
||||
|
||||
@ -45,6 +45,15 @@ pub struct MeshPeer {
|
||||
pub last_heard: String,
|
||||
/// Number of hops to reach this peer.
|
||||
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.
|
||||
@ -172,4 +181,13 @@ pub enum MeshEvent {
|
||||
to_contact_id: u32,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
|
||||
/// the 1:1 thread via the transport-aware `MeshService::send_message`
|
||||
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
|
||||
/// emits this because it can't route over federation itself — the signing
|
||||
/// key and Tor client live on MeshService. Consumed at the server layer.
|
||||
AssistChatReply {
|
||||
contact_id: u32,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
@ -305,6 +305,47 @@ impl Server {
|
||||
.rpc_handler()
|
||||
.set_mesh_service(mesh_service)
|
||||
.await;
|
||||
|
||||
// Mesh-AI assistant (#50): deliver `!ai`-in-chat answers via
|
||||
// the transport-aware send path. The listener can't route
|
||||
// over federation itself (send_message needs the signing key
|
||||
// + Tor client on MeshService), so it emits AssistChatReply
|
||||
// and we fulfil it here through the shared MeshService —
|
||||
// which POSTs over Tor for federation askers and falls back
|
||||
// to LoRa for radio askers, recording the Sent bubble.
|
||||
{
|
||||
let mesh_arc = api_handler.rpc_handler().mesh_service_arc();
|
||||
let mut reply_rx = {
|
||||
let guard = mesh_arc.read().await;
|
||||
guard.as_ref().map(|svc| svc.state().event_tx.subscribe())
|
||||
};
|
||||
if let Some(mut rx) = reply_rx.take() {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(crate::mesh::MeshEvent::AssistChatReply {
|
||||
contact_id,
|
||||
text,
|
||||
}) => {
|
||||
let guard = mesh_arc.read().await;
|
||||
if let Some(svc) = guard.as_ref() {
|
||||
if let Err(e) =
|
||||
svc.send_message(contact_id, &text).await
|
||||
{
|
||||
warn!("AI chat reply send failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(
|
||||
_,
|
||||
)) => continue,
|
||||
Err(_) => break, // sender dropped → mesh stopped
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
info!("📡 Mesh service initialized");
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@ -1175,8 +1175,13 @@ pub async fn get_balance(data_dir: &Path) -> Result<u64> {
|
||||
}
|
||||
|
||||
/// Default mint URL (local Fedimint).
|
||||
/// Default Cashu mint. Minibits is a well-known public Cashu mint — note this
|
||||
/// is a CASHU mint, distinct from the local Fedimint guardian (:8175), which is
|
||||
/// a separate ecash protocol managed under the Fedimint Federations tab. The
|
||||
/// old default pointed at :8175, which incorrectly surfaced the Fedimint URL in
|
||||
/// the Cashu mints list.
|
||||
fn default_mint_url() -> String {
|
||||
"http://127.0.0.1:8175".to_string()
|
||||
"https://mint.minibits.cash/Bitcoin".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -1359,11 +1364,11 @@ mod tests {
|
||||
async fn test_save_and_load_wallet_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut wallet = WalletState {
|
||||
mint_url: "http://127.0.0.1:8175".into(),
|
||||
mint_url: "https://mint.minibits.cash/Bitcoin".into(),
|
||||
..Default::default()
|
||||
};
|
||||
wallet.add_proofs(
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
vec![Proof {
|
||||
amount: 42,
|
||||
id: "ks1".into(),
|
||||
@ -1375,7 +1380,7 @@ mod tests {
|
||||
TransactionType::Mint,
|
||||
42,
|
||||
"Test mint",
|
||||
"http://127.0.0.1:8175",
|
||||
"https://mint.minibits.cash/Bitcoin",
|
||||
"",
|
||||
);
|
||||
|
||||
@ -1498,7 +1503,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_default_mint_url() {
|
||||
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
|
||||
assert_eq!(default_mint_url(), "https://mint.minibits.cash/Bitcoin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1517,9 +1522,11 @@ mod tests {
|
||||
.await
|
||||
.unwrap());
|
||||
// Trailing slash on the home URL still matches.
|
||||
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
|
||||
.await
|
||||
.unwrap());
|
||||
assert!(
|
||||
is_mint_trusted(tmp.path(), "https://mint.minibits.cash/Bitcoin/")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1553,7 +1560,7 @@ mod tests {
|
||||
let err = swap_between_mints(
|
||||
tmp.path(),
|
||||
&default_mint_url(),
|
||||
"http://127.0.0.1:8175/",
|
||||
"https://mint.minibits.cash/Bitcoin/",
|
||||
100,
|
||||
10,
|
||||
)
|
||||
|
||||
@ -95,7 +95,7 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
|
||||
{
|
||||
reg.federations.push(JoinedFederation {
|
||||
federation_id,
|
||||
name: None,
|
||||
name: Some("Archipelago Federation".to_string()),
|
||||
});
|
||||
save_registry(data_dir, ®).await?;
|
||||
}
|
||||
|
||||
@ -30,6 +30,8 @@ export interface MeshPeer {
|
||||
snr: number | null
|
||||
last_heard: string
|
||||
hops: number
|
||||
last_advert?: number
|
||||
reachable?: boolean
|
||||
}
|
||||
|
||||
export interface MeshChannel {
|
||||
@ -130,6 +132,7 @@ export interface AssistantStatus {
|
||||
model: string | null
|
||||
trusted_only: boolean
|
||||
backend: string
|
||||
allowed_contacts: string[]
|
||||
default_model: string
|
||||
ollama_detected: boolean
|
||||
claude_available: boolean
|
||||
@ -613,6 +616,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
allowed_contacts?: string[]
|
||||
}) {
|
||||
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
||||
method: 'mesh.assistant-configure',
|
||||
|
||||
@ -93,16 +93,23 @@
|
||||
@upload="handleUpload"
|
||||
@update:view-mode="viewMode = $event"
|
||||
/>
|
||||
<FileGrid
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
<!-- Re-key on the current folder path so the depth/zoom animation replays
|
||||
at every level (folder → subfolder → …), not just on first entry.
|
||||
Only the file content zooms; the header + breadcrumb nav above stay
|
||||
fixed in place. -->
|
||||
<Transition name="cloud-zoom" mode="out-in">
|
||||
<FileGrid
|
||||
:key="cloudStore.currentPath"
|
||||
:items="cloudStore.sortedItems"
|
||||
:loading="cloudStore.loading"
|
||||
:view-mode="viewMode"
|
||||
@navigate="navigateCloudPath"
|
||||
@delete="handleDelete"
|
||||
@play="handlePlay"
|
||||
@share="handleShare"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Audio player is now the global bottom bar (GlobalAudioPlayer in App.vue) -->
|
||||
</div>
|
||||
@ -386,3 +393,41 @@ function goBack() {
|
||||
router.push('/dashboard/cloud')
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Not scoped: the transition classes are applied to the FileGrid child's root
|
||||
element, which lives outside this component's style scope. Matches the
|
||||
`depth-forward` route transition feel (zoom in from depth + blur). -->
|
||||
<style>
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
transition:
|
||||
opacity 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
transform 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
filter 0.38s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
transform-origin: center center;
|
||||
will-change: opacity, transform, filter;
|
||||
}
|
||||
/* New folder zooms in from depth */
|
||||
.cloud-zoom-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
filter: blur(4px);
|
||||
}
|
||||
/* Previous folder recedes forward as it leaves */
|
||||
.cloud-zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.12);
|
||||
filter: blur(6px);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cloud-zoom-enter-active,
|
||||
.cloud-zoom-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.cloud-zoom-enter-from,
|
||||
.cloud-zoom-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -501,6 +501,7 @@ interface MergedPeer {
|
||||
primary_pubkey_hex: string | null
|
||||
primary_rssi: number | null
|
||||
is_archy: boolean
|
||||
reachable: boolean
|
||||
// The original active-chat marker uses contact_id equality, so keep a
|
||||
// representative MeshPeer for the rest of the codepaths that still want
|
||||
// a single object (peer header rssi, prekey rotation, etc).
|
||||
@ -622,6 +623,7 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
||||
primary_pubkey_hex: peer.pubkey_hex,
|
||||
primary_rssi: peer.rssi,
|
||||
is_archy: isArchyNode(peer) || !!matchedFed,
|
||||
reachable: peer.reachable ?? true,
|
||||
primary: peer,
|
||||
})
|
||||
}
|
||||
@ -671,12 +673,27 @@ const mergedPeers = computed<MergedPeer[]>(() => {
|
||||
primary_pubkey_hex: fed.pubkey,
|
||||
primary_rssi: null,
|
||||
is_archy: true,
|
||||
reachable: true,
|
||||
primary: placeholder,
|
||||
})
|
||||
}
|
||||
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
|
||||
// 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
|
||||
@ -867,6 +884,17 @@ function scrollChatToBottom() {
|
||||
}
|
||||
}
|
||||
|
||||
// Wheel over the chat must scroll ONLY the chat — never leak to the contacts
|
||||
// list or the page. CSS overscroll-behavior wasn't enough (the leak happens
|
||||
// even when the chat doesn't overflow), so consume the wheel and apply it to
|
||||
// the chat container directly. Used with `@wheel.prevent` so the default
|
||||
// (page/contacts) scroll never fires.
|
||||
function onChatWheel(e: WheelEvent) {
|
||||
const el = chatScrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTop += e.deltaY
|
||||
}
|
||||
|
||||
async function handleBroadcast() {
|
||||
broadcasting.value = true
|
||||
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
|
||||
@ -1417,6 +1445,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>
|
||||
</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">
|
||||
No peers discovered yet.
|
||||
</div>
|
||||
@ -1454,8 +1501,11 @@ function isImageMime(mime?: string): boolean {
|
||||
</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>
|
||||
</div>
|
||||
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
|
||||
No contacts match “{{ peerSearch.trim() }}”.
|
||||
</div>
|
||||
<div
|
||||
v-for="mp in mergedPeers" :key="mp.key"
|
||||
v-for="mp in displayedPeers" :key="mp.key"
|
||||
class="mesh-peer-row"
|
||||
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
|
||||
tabindex="0"
|
||||
@ -1466,6 +1516,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
|
||||
<AnimatedLogo v-if="mp.is_archy" size="sm" />
|
||||
<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 class="mesh-peer-info">
|
||||
<div class="mesh-peer-name">
|
||||
@ -1553,7 +1604,7 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt">
|
||||
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.prevent="onChatWheel">
|
||||
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
|
||||
No messages yet. Say hello!
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<AccountSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<SystemSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -157,6 +157,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
defineProps<{
|
||||
filteredApps: MarketplaceApp[]
|
||||
@ -181,11 +182,6 @@ defineEmits<{
|
||||
'install': [app: MarketplaceApp]
|
||||
'retry-nostr': []
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -98,6 +98,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FeaturedApp, MarketplaceApp } from './types'
|
||||
import { handleImageError } from '@/views/apps/appsConfig'
|
||||
|
||||
defineProps<{
|
||||
featuredApps: FeaturedApp[]
|
||||
@ -114,9 +115,4 @@ defineEmits<{
|
||||
'launch': [app: MarketplaceApp]
|
||||
'install': [app: MarketplaceApp]
|
||||
}>()
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.src = '/assets/img/logo-archipelago.svg'
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -11,6 +11,23 @@ const enabled = ref(false)
|
||||
const model = ref('') // '' = use the backend's default model
|
||||
const policy = ref<'trusted' | 'anyone'>('trusted')
|
||||
const backend = ref<'claude' | 'ollama'>('claude')
|
||||
const allowedContacts = ref<string[]>([])
|
||||
|
||||
// Preset Claude models offered in the dropdown ('' = backend default = Haiku).
|
||||
const CLAUDE_MODELS: { value: string; label: string }[] = [
|
||||
{ value: '', label: 'Default (Claude Haiku 4.5)' },
|
||||
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — fast & cheap' },
|
||||
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
|
||||
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8 — most capable' },
|
||||
]
|
||||
// Include any non-preset value the node already has so it isn't silently lost.
|
||||
const claudeModelOptions = computed(() => {
|
||||
const opts = [...CLAUDE_MODELS]
|
||||
if (model.value && !opts.some((o) => o.value === model.value)) {
|
||||
opts.push({ value: model.value, label: `${model.value} (custom)` })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
// Sync local controls from the fetched status.
|
||||
watch(
|
||||
@ -21,10 +38,56 @@ watch(
|
||||
model.value = s.model ?? ''
|
||||
policy.value = s.trusted_only ? 'trusted' : 'anyone'
|
||||
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
|
||||
allowedContacts.value = [...(s.allowed_contacts ?? [])]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Addressable contacts (have an archipelago/radio pubkey) for the allowlist.
|
||||
const contactOptions = computed(() =>
|
||||
mesh.peers
|
||||
.filter((p) => !!p.pubkey_hex)
|
||||
.map((p) => ({ pubkey: p.pubkey_hex as string, name: p.advert_name || (p.pubkey_hex as string).slice(0, 10) })),
|
||||
)
|
||||
|
||||
function isAllowed(pubkey: string) {
|
||||
return allowedContacts.value.some((k) => k.toLowerCase() === pubkey.toLowerCase())
|
||||
}
|
||||
function toggleAllowed(pubkey: string) {
|
||||
if (isAllowed(pubkey)) {
|
||||
allowedContacts.value = allowedContacts.value.filter((k) => k.toLowerCase() !== pubkey.toLowerCase())
|
||||
} else {
|
||||
allowedContacts.value = [...allowedContacts.value, pubkey]
|
||||
}
|
||||
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(() => {
|
||||
mesh.fetchAssistantStatus()
|
||||
})
|
||||
@ -45,6 +108,7 @@ async function apply(partial: {
|
||||
model?: string | null
|
||||
trusted_only?: boolean
|
||||
backend?: string
|
||||
allowed_contacts?: string[]
|
||||
}) {
|
||||
saving.value = true
|
||||
try {
|
||||
@ -131,7 +195,9 @@ function onPolicy() {
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Model</label>
|
||||
<input v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" :placeholder="defaultModel" @change="onModel" />
|
||||
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
|
||||
<option v-for="m in claudeModelOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mesh-assistant-field">
|
||||
@ -147,6 +213,49 @@ function onPolicy() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Per-contact allowlist: let specific contacts use !ai even when the
|
||||
policy is "trusted only" and they aren't federation-trusted. -->
|
||||
<div class="mesh-assistant-field">
|
||||
<label class="mesh-bitcoin-label">Always allow these contacts</label>
|
||||
<p class="text-xs text-white/40 mb-2">
|
||||
Listed contacts can use <code>!ai</code> regardless of the policy above.
|
||||
</p>
|
||||
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
|
||||
No contacts yet — they appear here once you have mesh/federation contacts.
|
||||
</div>
|
||||
<div v-else class="mesh-assistant-allowlist">
|
||||
<label
|
||||
v-for="c in contactOptions"
|
||||
:key="c.pubkey"
|
||||
class="mesh-assistant-allow-row"
|
||||
>
|
||||
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
|
||||
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||
</p>
|
||||
|
||||
@ -33,8 +33,8 @@
|
||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
||||
.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-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; }
|
||||
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; 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-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-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; }
|
||||
@ -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: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-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 :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; }
|
||||
@ -121,7 +130,7 @@
|
||||
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
|
||||
.mesh-chat-header-status { flex-shrink: 0; }
|
||||
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.mesh-chat-messages { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.mesh-chat-no-messages { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.25); font-size: 0.85rem; }
|
||||
.mesh-chat-bubble-wrapper { display: flex; }
|
||||
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
|
||||
@ -232,6 +241,12 @@
|
||||
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
|
||||
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
|
||||
.mesh-assistant-allowlist { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 6px; background: rgba(0,0,0,0.2); }
|
||||
.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-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-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; }
|
||||
|
||||
@ -228,6 +228,36 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.00-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.00-alpha</span>
|
||||
<span class="text-xs text-white/40">June 18, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.</p>
|
||||
<p>You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.</p>
|
||||
<p>The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.</p>
|
||||
<p>Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.</p>
|
||||
<p>In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.</p>
|
||||
<p>In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.</p>
|
||||
<p>Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).</p>
|
||||
<p>You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.</p>
|
||||
<p>New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.</p>
|
||||
<p>"Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.</p>
|
||||
<p>The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.</p>
|
||||
<p>Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.</p>
|
||||
<p>You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.</p>
|
||||
<p>Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.</p>
|
||||
<p>You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.</p>
|
||||
<p>Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.</p>
|
||||
<p>The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).</p>
|
||||
<p>Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.</p>
|
||||
<p>You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.</p>
|
||||
<p>Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.</p>
|
||||
<p>For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.99-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
|
||||
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@ -14,8 +12,6 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<InterfaceModeSection />
|
||||
<ClaudeAuthSection />
|
||||
<AIDataAccessSection />
|
||||
<SystemUpdatesSection />
|
||||
<AppRegistriesSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<BackupSection />
|
||||
|
||||
@ -39,6 +39,17 @@ detect_environment() {
|
||||
TOTAL_MEM_MB=$(($(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 16000000) / 1024))
|
||||
LOW_MEM=false
|
||||
[ "$TOTAL_MEM_MB" -lt 12000 ] && LOW_MEM=true
|
||||
# Bitcoin UTXO cache (dbcache) sized to host RAM, NOT a fixed value.
|
||||
# A large dbcache on a small box pushes total memory (bitcoind + the ~20 app
|
||||
# containers) past physical RAM and forces system-wide swap thrash: the disk
|
||||
# saturates, bitcoind can't answer its own RPC, and the dashboard backend's
|
||||
# sqlite reads stall — surfacing as fleet-wide /rpc/v1 502s and a blank
|
||||
# Bitcoin UI. The old binary LOW_MEM->2048 toggle still over-committed 8 GB
|
||||
# nodes. Budget ~1/16 of RAM for the cache, leaving the bulk for the OS +
|
||||
# containers; floor 300 MB (bitcoind default is 450), cap 4096 MB.
|
||||
BTC_DBCACHE=$(( TOTAL_MEM_MB / 16 ))
|
||||
[ "$BTC_DBCACHE" -lt 300 ] && BTC_DBCACHE=300
|
||||
[ "$BTC_DBCACHE" -gt 4096 ] && BTC_DBCACHE=4096
|
||||
HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
HOST_IP=${HOST_IP:-127.0.0.1}
|
||||
# Stable mDNS hostname for URLs that get baked into federation/consensus data.
|
||||
@ -175,8 +186,6 @@ load_spec_bitcoin-knots() {
|
||||
SPEC_TIER="1"
|
||||
SPEC_DATA_DIR="/var/lib/archipelago/bitcoin"
|
||||
SPEC_DATA_UID="100101:100101"
|
||||
local btc_dbcache=4096
|
||||
[ "${LOW_MEM:-false}" = "true" ] && btc_dbcache=2048
|
||||
local btc_rpc_headroom="-rpcthreads=16 -rpcworkqueue=256"
|
||||
local btc_txrelay_flags="-rpcwhitelistdefault=0"
|
||||
if [ -f "$SECRETS_DIR/bitcoin-rpc-txrelay-rpcauth" ]; then
|
||||
@ -184,9 +193,9 @@ load_spec_bitcoin-knots() {
|
||||
fi
|
||||
# Dynamic: prune on small disk
|
||||
if [ "${DISK_GB:-0}" -lt 1000 ]; then
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${btc_dbcache} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
SPEC_CUSTOM_ARGS="-server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
else
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
SPEC_CUSTOM_ARGS="-server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=${BTC_DBCACHE} -par=0 -maxconnections=125 ${btc_rpc_headroom} ${btc_txrelay_flags}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user