Compare commits

..

8 Commits

Author SHA1 Message Date
archipelago
d0ca53501c feat(ui): cloud folder zoom transition on path change
Re-key FileGrid on the current folder path and wrap it in a cloud-zoom
Transition so the depth/zoom animation replays at every folder level; the
header + breadcrumb nav stay fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:40:16 -04:00
archipelago
790da4bd0f fix(wallet): Minibits default Cashu mint, resilient peer-file invoices, named default federation
- Cashu default mint was the local Fedimint guardian (:8175), wrongly surfacing
  a Fedimint URL in the Cashu mints list. Default is now Minibits
  (https://mint.minibits.cash/Bitcoin) — Cashu and Fedimint are distinct
  protocols (Fedimint lives under its own tab).
- Peer-file (buy) invoice creation: retry the LND REST call (3× / 400ms) so a
  transient LND-REST blip (swap pressure / just-restarted / TLS race) no longer
  hard-fails as an opaque 503, and surface the real error chain ({:#}) in the
  response + logs instead of a generic "Failed to create invoice".
- Autojoined default federation now shows a friendly name ("Archipelago
  Federation") in the Fedimint tab instead of a bare federation id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:23:56 -04:00
archipelago
cc2e055e09 fix(bitcoin,ui): RAM-aware dbcache to stop swap-thrash 502s + snappier status + icon placeholder
Sizes bitcoind -dbcache to host RAM (~1/16, floor 300MB, cap 4096) instead of a
fixed 2048/4096. A multi-GB UTXO cache on an 8GB node running the full app stack
pushed memory past physical RAM and triggered system-wide swap thrash: the disk
saturated, bitcoind could not answer its own RPC, and the dashboard backend's
sqlite reads stalled — surfacing as fleet-wide /rpc/v1 502s and a blank Bitcoin
UI. Applied in scripts/container-specs.sh (reconciler path) and the config.rs
bitcoin-core path.

Bitcoin status cache now polls every 5s (was 10/15) with an 8s timeout (was 20s)
and fetches the four RPCs concurrently, so the cached snapshot tracks bitcoind's
responsive windows during IBD and the UI stops dwelling on "reconnecting...".

Unifies the divergent discover AppGrid/FeaturedApps image-error handlers onto the
canonical placeholder fallback so missing app icons render the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:14:47 -04:00
archipelago
549c6180a2 chore(ui): sync What's New modal for v1.8.00-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:12:12 -04:00
archipelago
ec644ab90f docs: changelog v1.8.00-alpha — mesh DM privacy, contact import/search/reachability
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:10:29 -04:00
archipelago
f0fdc23cc9 feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search
- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel
  broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the
  public channel, and decodable by stock clients. Plain text (split, not MC-chunked)
  to non-archipelago contacts; typed envelopes to archy peers.
- !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0.
- Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is
  added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert.
- clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of
  blocklisting; blocking filter removed entirely. Wiped contacts return when reachable.
- Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows
  a reachability dot.
- Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button.
- send_message routes stock contacts as plain native text (fixes garbled envelopes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:08:52 -04:00
archipelago
9f2edf6b7a docs: changelog for v1.8.00-alpha (carry forward v1.7.99 features + mesh/fedimint fixes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 04:20:10 -04:00
archipelago
3a21243be7 fix(mesh,ui,fedimint): mesh-AI chat trigger + transport-aware reply, stop ARCHY:2 public-channel spam, AI allowlist + model dropdown, Fedimint client manifest, settings reorder, chat scroll
- mesh: stop broadcasting ARCHY:2 identity on the public channel (startup + every advert tick); receive path still parses inbound. No more public-channel spam.
- mesh assistant: trigger on !ai/!ask typed in 1:1 chat (was only the dead AssistQuery path + bare channel text); route the reply transport-aware via MeshService::send_message (Tor for federation peers, LoRa for radio) through a new AssistChatReply event consumed at the server layer — fixes replies never reaching federation askers.
- mesh assistant: per-contact !ai allowlist (allowed_contacts) bypassing trusted_only; config + RPC + is_sender_allowed.
- fedimint-clientd manifest: network_policy open -> bridge (invalid value made the loader skip the whole manifest, so fmcd never ran and federations never joined/listed).
- ui: AI panel — Claude model dropdown (Haiku/Sonnet/Opus presets) + allowlist contact picker.
- ui: Settings — App Updates + App Registry moved under Account.
- ui: mesh chat — overscroll-behavior: contain so chat scroll no longer bleeds to the contacts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 03:33:37 -04:00
33 changed files with 1074 additions and 199 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

@ -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(),
]),

View File

@ -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(),
})
}

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

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

View File

@ -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");
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

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

View File

@ -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, &reg).await?;
}

View File

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

View File

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

View File

@ -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 = ''"
>&times;</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>

View File

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

View File

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

View File

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

View File

@ -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 &lt;question&gt;</code> on the mesh channel.
</p>

View File

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

View File

@ -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 &lt;your question&gt;" 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 &amp; 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">

View File

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

View File

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