Compare commits

...

8 Commits

Author SHA1 Message Date
archipelago
242baf5deb fix(ui): on-screen error overlay so companion crashes are visible without a console
chrome://inspect isn't always reachable on the Android companion WebView, so the
real error stayed invisible. Add a plain-DOM, screenshot-able overlay (built
without Vue so it survives a crash in Vue itself) that shows the captured error
message + stack and a Copy button for the full window.__archyErrors buffer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:23:59 -04:00
archipelago
0ab160b5c3 docs: deploy state — all 6 nodes on 4a8f2198 build (#12/#2/#3/#10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:15:59 -04:00
archipelago
a6957a48f7 fix(netbird): wait for OIDC discovery before reporting install done (#10)
Right after install the dashboard SPA opens and, if it loads before NetBird's
embedded OIDC provider is serving, caches a bad auth state — the user appears
logged-in but can't log out until it self-corrects. Container "running" != OIDC
ready, so gate the install's Done phase on the management server's
/oauth2/.well-known/openid-configuration answering (best-effort, 60s cap, never
fails the install since the stack is already up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:57:37 -04:00
archipelago
2761f0d70f docs: handoff — session 2 progress (#12/#2/#3 code-complete, deploy held)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:52:07 -04:00
archipelago
a8c668ee0a fix(ui): stop mobile tab bar covering last row of content (#2)
On Cloud/files (and any scrolling view), the bottom of the list could sit behind
the fixed mobile tab bar. Cause: DashboardMobileNav measured the bar's
offsetHeight and wrote it to --mobile-tab-bar-height, but when the bar was hidden
or not yet laid out the measurement was 0 — and writing "0px" defeats the
", 88px" fallback in the .mobile-scroll-pad clearance calc (an explicit 0 is
still a set value), so the clearance collapsed and the ~88px bar overlapped the
last row.

- never write 0px: only set a real measured height, else remove the var so the
  88px fallback applies.
- re-measure after first paint (rAF) and after the WebView safe-area injection,
  so the clearance reflects the bar's final laid-out height.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:50:44 -04:00
archipelago
8f06d88fbf feat(wallet): pay for peer files from BOTH Cashu and Fedimint ecash (#3)
Paying for a peer file minted a Cashu-only token, so a node whose ecash balance
lived in Fedimint couldn't pay even with funds. Now both backends are tried:

- payer (content.download-peer-paid): mint a Cashu token first; on failure fall
  back to spending Fedimint notes. Only error if BOTH backends can't cover it.
- seller (verify_and_receive_payment): accept Fedimint notes as well as Cashu —
  anything not starting with "cashu" is redeemed via reissue_into_any.
- new fedimint_client::spend_from_any() — spend from whichever joined federation
  has the balance, returning the notes + federation id (mirrors reissue_into_any).
- wallet.ecash-balance now also reports fedimint_sats + combined total_sats; the
  pay-for-file pre-check uses the combined total so a Fedimint-funded node isn't
  wrongly blocked.

Compiles (cargo check + vue-tsc). Live cross-node federation validation pending
(dual-ecash phase 6) — needs two nodes sharing a federation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:13:23 -04:00
archipelago
b3633ec525 fix(ui): surface real error instead of generic toast + catch async errors
The global Vue errorHandler swallowed every crash into "Something went wrong.
Please refresh the page." — which hides exactly what we need to diagnose the
companion-app (Android WebView) post-login crash. Now:
- the toast shows the real (truncated) error message;
- a 25-entry ring buffer is kept on window.__archyErrors for retrieval where
  there's no console (companion WebView via chrome://inspect, or a debug view);
- window 'error' and 'unhandledrejection' listeners catch async/non-Vue errors
  that Vue's errorHandler misses (e.g. a JS API absent in an older WebView).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:05:51 -04:00
archipelago
f92e442bfc fix(mesh): collapse cross-transport twin contacts into one conversation (#12)
A node reachable both over LoRa and federation has two MeshPeer rows (radio
twin: low contact_id + firmware key; federation twin: high contact_id +
archipelago key), and messages key by peer_contact_id split across the two ids
— so opening one twin shows an empty thread (the .120->.89 symptom).

- backend: new group_peer_twins() helper groups peers by arch_pubkey_hex (set on
  BOTH twins by bind_federation_twins), keeps the radio id as the mesh-first
  send target, and unions messages across all twin ids. Wired into
  conversations.list / conversations.messages / mesh.contacts-list. +3 unit tests.
- frontend: the live chat list merges client-side (mergedPeers) and matched twins
  by the "Archy-z6Mk..." advert prefix, which the Meshtastic device rename broke
  (radio now advertises the server name). Merge by arch_pubkey_hex instead, which
  the backend reliably sets on both twins. Expose arch_pubkey_hex on MeshPeer.
- fix unrelated stale test: EcashTransaction test missing the new `kind` field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:01:14 -04:00
14 changed files with 490 additions and 26 deletions

View File

@ -383,10 +383,35 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Mint ecash payment token
let token_str = ecash::send_token(&self.config.data_dir, price_sats)
.await
.context("Failed to create ecash payment token — check wallet balance")?;
// Mint an ecash payment token, trying BOTH backends: Cashu first, then
// Fedimint. The seller's verify_payment_token accepts either, so a node
// whose balance lives in one system can still pay (#3). Surface the
// Cashu error only if BOTH paths fail.
let token_str = match ecash::send_token(&self.config.data_dir, price_sats).await {
Ok(t) => t,
Err(cashu_err) => {
match crate::wallet::fedimint_client::spend_from_any(
&self.config.data_dir,
price_sats,
)
.await
{
Ok((notes, _fed_id)) => notes,
Err(fedi_err) => {
tracing::warn!(
"paid download: no ecash backend could pay {price_sats} sats \
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
);
return Ok(serde_json::json!({
"error": format!(
"Couldn't pay {price_sats} sats from your ecash wallet \
(Cashu or Fedimint). Fund either wallet and try again."
)
}));
}
}
}
};
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;

View File

@ -95,12 +95,17 @@ impl RpcHandler {
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
let messages = svc.messages(None).await;
// Per-peer last message.
for peer in &peers {
// Collapse radio/federation twins into one conversation per identity
// so a node reachable both ways shows once, with its messages unioned
// across both twin contact_ids (#12).
let groups = mesh::group_peer_twins(&peers);
for group in &groups {
let peer = &group.canonical;
// Newest message across ALL twin contact_ids in this group.
let last = messages
.iter()
.rev()
.find(|m| m.peer_contact_id == peer.contact_id);
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
let is_federation = peer.contact_id & 0x8000_0000 != 0;
conversations.push(serde_json::json!({
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
@ -163,8 +168,16 @@ impl RpcHandler {
let filtered: Vec<_> = match kind {
"mesh" | "federation" => {
let contact_id: u32 = rest.parse().unwrap_or(0);
// Resolve this id's twin group and union messages across all of
// its contact_ids, so opening either twin shows the full thread
// (federation-injected + radio messages) (#12).
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
.into_iter()
.find(|g| g.contact_ids.contains(&contact_id))
.map(|g| g.contact_ids)
.unwrap_or_else(|| vec![contact_id]);
all.into_iter()
.filter(|m| m.peer_contact_id == contact_id)
.filter(|m| ids.contains(&m.peer_contact_id))
.collect()
}
"channel" => {

View File

@ -1133,9 +1133,13 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let state = svc.shared_state();
let contacts = state.contacts.read().await;
let peers = state.peers.read().await;
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
// Collapse radio/federation twins so a node reachable both ways shows as
// one contact instead of two (#12).
let groups = crate::mesh::group_peer_twins(&peer_vec);
let mut out: Vec<serde_json::Value> = Vec::new();
for peer in peers.values() {
for group in &groups {
let peer = &group.canonical;
if let Some(pk) = peer.pubkey_hex.as_ref() {
let entry = contacts.get(pk).cloned().unwrap_or_default();
out.push(serde_json::json!({

View File

@ -1903,6 +1903,14 @@ impl RpcHandler {
self.set_install_phase("netbird", InstallPhase::WaitingHealthy)
.await;
// Containers being "running" is NOT the same as the embedded OIDC
// provider being ready (#10). The dashboard SPA opens right after install
// and, if it loads before /oauth2/.well-known is served, caches a bad
// auth state — the user appears logged-in but can't log out until it
// self-corrects. Wait (best-effort) for OIDC discovery to answer before
// we report Done, so the first dashboard load sees a ready provider.
wait_for_netbird_oidc_ready(Duration::from_secs(60)).await;
self.set_install_phase("netbird", InstallPhase::PostInstall)
.await;
self.set_install_phase("netbird", InstallPhase::Done).await;
@ -1918,6 +1926,37 @@ impl RpcHandler {
}
}
/// Best-effort wait for NetBird's embedded OIDC provider to start serving its
/// discovery document. The management server publishes 8086:80 on the host and
/// is the issuer at `/oauth2`, so its `.well-known/openid-configuration` is the
/// signal that the dashboard's login/logout flow will work. Polls until a 2xx
/// or the timeout — NEVER fails the install (the stack is already running; this
/// only narrows the post-install race window in #10).
async fn wait_for_netbird_oidc_ready(timeout: Duration) {
let url = "http://127.0.0.1:8086/oauth2/.well-known/openid-configuration";
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
{
Ok(c) => c,
Err(_) => return,
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
info!("NetBird OIDC discovery is ready");
return;
}
}
if tokio::time::Instant::now() >= deadline {
info!("NetBird OIDC discovery not ready within timeout — proceeding anyway");
return;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn read_or_generate_b64_secret(name: &str) -> String {
let path = format!("/var/lib/archipelago/secrets/{}", name);
if let Ok(val) = tokio::fs::read_to_string(&path).await {

View File

@ -12,8 +12,23 @@ fn is_cashu_token(token: &str) -> bool {
impl RpcHandler {
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
let cashu_sats = wallet.balance();
// Spendable Fedimint balance too, so callers (e.g. the pay-for-file
// pre-check) see funds available across BOTH backends (#3). Best-effort:
// if fmcd isn't installed/joined this is just 0, never an error.
let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir)
.await
{
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
Ok(serde_json::json!({
"balance_sats": wallet.balance(),
// `balance_sats` stays Cashu-only for back-compat; `total_sats` is the
// spendable amount across Cashu + Fedimint.
"balance_sats": cashu_sats,
"cashu_sats": cashu_sats,
"fedimint_sats": fedimint_sats,
"total_sats": cashu_sats + fedimint_sats,
"proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(),
"mint_url": wallet.mint_url,
}))

View File

@ -127,6 +127,91 @@ pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, M
}
}
/// One logical contact after collapsing cross-transport twins (see
/// [`group_peer_twins`]). A node reachable both over LoRa and over federation
/// has two `MeshPeer` rows (different `contact_id`s) but is one conversation.
pub(crate) struct PeerGroup {
/// The peer row the UI should address. The radio twin when one exists (so
/// `send_typed_wire` stays mesh-first — LoRa if reachable, else federation
/// via the bound arch key), otherwise the federation row. Gap-healed so its
/// name / `arch_pubkey_hex` / `did` are populated from whichever twin had
/// them, and `reachable` is the OR across the group.
pub canonical: MeshPeer,
/// Every `contact_id` in the group. The conversation's messages are the
/// union of those keyed by any of these ids — federation-injected messages
/// land on the federation twin's id, radio messages on the radio twin's.
pub contact_ids: Vec<u32>,
}
/// Collapse cross-transport twin peers into one conversation per identity.
///
/// The same node commonly appears twice in the peer table: a radio twin (low
/// `contact_id`, firmware routing key) and a federation twin (high
/// `contact_id`, archipelago key), correlated by [`bind_federation_twins`]
/// which copies `arch_pubkey_hex` onto the radio twin but leaves both rows.
/// Messages are keyed by `peer_contact_id`, so they split across the two ids:
/// a federation-injected message sits on the federation row while the user may
/// open the radio row and see an empty thread (the `.120`→`.89` symptom).
///
/// Group peers by `arch_pubkey_hex` when set, else treat each peer as its own
/// singleton group keyed by `contact_id`. Grouping is done ONLY here at surface
/// time — never re-keyed at bind time — so outbound routing keeps the distinct
/// per-twin `contact_id`s and stays mesh-first. First-seen order is preserved
/// for stable downstream sorting.
pub(crate) fn group_peer_twins(peers: &[MeshPeer]) -> Vec<PeerGroup> {
let mut order: Vec<String> = Vec::new();
let mut groups: std::collections::HashMap<String, Vec<MeshPeer>> =
std::collections::HashMap::new();
for p in peers {
let key = match p.arch_pubkey_hex.as_deref() {
Some(arch) if !arch.is_empty() => format!("arch:{}", arch.to_ascii_lowercase()),
_ => format!("cid:{}", p.contact_id),
};
if !groups.contains_key(&key) {
order.push(key.clone());
}
groups.entry(key).or_default().push(p.clone());
}
let mut out = Vec::with_capacity(order.len());
for key in order {
let members = match groups.remove(&key) {
Some(m) if !m.is_empty() => m,
_ => continue,
};
let contact_ids: Vec<u32> = members.iter().map(|m| m.contact_id).collect();
// Canonical = the radio twin (lowest id below the federation base) when
// one exists, else the lowest id overall (a federation-only peer).
let canonical_src = members
.iter()
.filter(|m| m.contact_id < FEDERATION_CONTACT_ID_BASE)
.min_by_key(|m| m.contact_id)
.or_else(|| members.iter().min_by_key(|m| m.contact_id))
.expect("non-empty members");
let mut canonical = canonical_src.clone();
// Heal gaps from the twin: a radio row may lack the advert name, arch
// identity, or did that only the federation row carries.
if canonical.advert_name.trim().is_empty() {
if let Some(named) = members.iter().find(|m| !m.advert_name.trim().is_empty()) {
canonical.advert_name = named.advert_name.clone();
}
}
if canonical.arch_pubkey_hex.is_none() {
canonical.arch_pubkey_hex = members.iter().find_map(|m| m.arch_pubkey_hex.clone());
}
if canonical.did.is_none() {
canonical.did = members.iter().find_map(|m| m.did.clone());
}
// Reachable if ANY twin is reachable (radio path or off-radio federation).
canonical.reachable = members.iter().any(|m| m.reachable);
out.push(PeerGroup {
canonical,
contact_ids,
});
}
out
}
/// Upsert a mesh peer record representing a federation node so the UI can
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
/// Existing entries (same contact_id) are updated in place, preserving any
@ -1760,6 +1845,57 @@ async fn bitcoin_rpc_getblockheader_by_height(
mod tests {
use super::*;
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
MeshPeer {
contact_id,
advert_name: name.to_string(),
did: None,
pubkey_hex: Some(format!("fw{contact_id}")),
arch_pubkey_hex: arch.map(|s| s.to_string()),
x25519_pubkey: None,
rssi: None,
snr: None,
last_heard: String::new(),
hops: 0,
last_advert: 0,
reachable,
}
}
#[test]
fn test_group_peer_twins_collapses_radio_and_federation() {
let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false);
let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true);
let groups = group_peer_twins(&[radio, fed]);
assert_eq!(groups.len(), 1, "twins must collapse to one conversation");
let g = &groups[0];
// Canonical = the radio twin (mesh-first send), but reachability is the
// OR across twins (federation is reachable off-radio).
assert_eq!(g.canonical.contact_id, 42);
assert!(g.canonical.reachable);
// Both ids retained so messages can be unioned across them.
assert!(g.contact_ids.contains(&42) && g.contact_ids.contains(&0x8000_0001));
}
#[test]
fn test_group_peer_twins_keeps_distinct_identities_and_unbound_radio() {
// Two different identities + one radio peer that was never bound to a
// federation twin (arch = None) → three separate conversations.
let a = mk_peer(1, "Alice", Some("aa"), false);
let b = mk_peer(2, "Bob", Some("bb"), true);
let lonely = mk_peer(3, "Carol-radio", None, false);
let groups = group_peer_twins(&[a, b, lonely]);
assert_eq!(groups.len(), 3);
}
#[test]
fn test_group_peer_twins_federation_only_uses_federation_id() {
let fed = mk_peer(0x8000_00ff, "Arch Dev", Some("dead"), true);
let groups = group_peer_twins(&[fed]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].canonical.contact_id, 0x8000_00ff);
}
#[test]
fn test_mesh_config_default() {
let config = MeshConfig::default();

View File

@ -1118,6 +1118,24 @@ pub async fn verify_and_receive_payment(
return Ok(received);
}
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
// rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/
// cashuB, or the legacy form handled above), so anything else is treated as
// Fedimint notes and redeemed into a joined federation. reissue_into_any
// verifies the notes are unspent and credits this node's wallet.
if !token_str.starts_with("cashu") {
let (received, _fed_id) =
crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?;
if received < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
received,
required_sats
);
}
return Ok(received);
}
// Parse and validate cashuA token
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
@ -1507,6 +1525,7 @@ mod tests {
description: "test".into(),
mint_url: String::new(),
peer: String::new(),
kind: default_tx_kind(),
};
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("\"streamingpayment\""));

View File

@ -191,6 +191,75 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
Ok(())
}
/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can
/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands
/// the seller) and the federation id that minted them. Federations are tried in
/// registry order (default first); only one with sufficient balance is used so
/// the resulting notes redeem cleanly on the other side. Errors clearly when no
/// federation is joined or none has the balance — the caller falls back to (or
/// from) the Cashu path.
pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> {
if amount_sats == 0 {
anyhow::bail!("payment amount must be greater than zero");
}
let _ = ensure_default_federation(data_dir).await;
let client = FedimintClient::from_node(data_dir).await?;
// Same union-of-sources approach as reissue_into_any: the persisted registry
// and what fmcd actually reports joined can drift, so consider both.
let mut fed_ids: Vec<String> = Vec::new();
if let Ok(reg) = load_registry(data_dir).await {
for f in reg.federations {
if !fed_ids.contains(&f.federation_id) {
fed_ids.push(f.federation_id);
}
}
}
for id in client.joined_federation_ids().await {
if !fed_ids.contains(&id) {
fed_ids.push(id);
}
}
if fed_ids.is_empty() {
anyhow::bail!("No Fedimint federation joined to spend from");
}
let mut last_err = None;
for fed_id in &fed_ids {
// Skip federations that can't cover the amount so we don't mint a
// partial/failed spend and leave dangling reserved notes.
match client.federation_balance_sats(fed_id).await {
Ok(bal) if bal >= amount_sats => {}
Ok(_) => continue,
Err(e) => {
last_err = Some(e);
continue;
}
}
match client.spend(fed_id, amount_sats).await {
Ok(notes) => {
record_fedimint_tx(
data_dir,
crate::wallet::ecash::TransactionType::Send,
amount_sats,
fed_id,
"Sent Fedimint ecash",
)
.await;
return Ok((notes, fed_id.clone()));
}
Err(e) => last_err = Some(e),
}
}
Err(last_err
.map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}"))
.unwrap_or_else(|| {
anyhow::anyhow!(
"No joined Fedimint federation has {amount_sats} sats available"
)
}))
}
/// Redeem received Fedimint notes into a joined federation. fmcd's reissue is
/// per-federation, but a token only validates against the federation that
/// minted it, so we try each joined federation (default first) and return the

View File

@ -39,8 +39,44 @@ claude-login.html, chown 1000:1000, restart, verify sha256+health). Recreate fro
`2679725907` = federation_peer_contact_id(.120 pubkey 535fb91f…), name "Archy-X250-EXP".
It's a **duplicate-contact SURFACING** problem (user confirmed doubles).
## TODO (resume here)
### #12 Fix duplicate mesh contacts ← user chose this NEXT
## SESSION 2 PROGRESS (2026-06-20, code-complete — NOT yet deployed; user held deploy)
All committed to local `main`; NOT pushed to gitea-vps2/origin yet, NOT sideloaded.
- **#12 dup contacts DONE** (`f92e442b`, +3 unit tests pass). Backend `group_peer_twins()`
helper (mesh/mod.rs) dedups by `arch_pubkey_hex`, radio twin = canonical send id, unions
messages; wired into conversations.list/messages + mesh.contacts-list. **KEY FINDING:**
conversations.list/messages have NO frontend consumer — the live chat list renders the
*frontend* merge `mergedPeers` (Mesh.vue), which matched twins by the `Archy-z6Mk…` advert
prefix that the device RENAME broke. Real fix = merge by `arch_pubkey_hex` (now exposed on the
MeshPeer TS type). Should also clear `.120→.89` and likely **#5** (Arch Mobile on .116, same bug).
- **Companion crash diagnostic SHIPPED** (`b3633ec5`): main.ts global handler now shows the REAL
error + keeps a 25-entry `window.__archyErrors` ring buffer + catches async/unhandledrejection.
Still need to deploy + repro on the optiplex node (read `window.__archyErrors` via chrome://inspect)
to get the actual throw. User says LAN/mobile-browser fine → Tailscale-WebView-specific.
- **#3 dual-ecash pay-for-file DONE** (`8f06d88f`, compiles): payer tries Cashu→Fedimint, seller
accepts both (verify_and_receive_payment: non-"cashu" = reissue_into_any), new
fedimint_client::spend_from_any(), wallet.ecash-balance reports total_sats. LIVE federation
validation pending (two nodes sharing a federation).
- **#2 mobile scroll cutoff DONE** (`a8c668ee`): DashboardMobileNav wrote `--mobile-tab-bar-height:0px`
when the bar was hidden/unlaid-out, defeating the `,88px` fallback → bar covered last row. Now never
writes 0 (removes var → fallback), re-measures on rAF + post-WebView-injection. Backup hypothesis if
it persists: `.dashboard-view` is `min-h-screen`(100vh) → mobile-browser toolbar overlap, switch to dvh.
DEPLOYED 2026-06-20 to ALL 6 nodes — binary sha `4a8f2198…` (release build of commit a6957a48 +
this handoff), FE rebuilt, all sha-verified + service active: .116(local) .198 .228 .89 .5 .120.
.5/.120 needed a 30-min timeout (slow DERP). #10 netbird OIDC gate also shipped in this build.
REMAINING VERIFICATION (on real hardware, user-side):
- #12/#5: open mesh chat on .116 (and .89/.120) — confirm a federated node shows ONCE with its
messages (no radio/federation double), and that "Arch Mobile" messages now surface.
- #1 companion crash: open the companion app to the optiplex node over Tailscale, reproduce the
crash, then read the REAL error from `window.__archyErrors` (chrome://inspect the WebView) or the
now-detailed toast. That error is what's needed to write the actual fix. Confirm which node = optiplex.
- #3: pay for a peer file when the buyer's balance is only in Fedimint (needs two nodes in a federation).
- #2: check Cloud/files bottom rows clear the tab bar on mobile browser.
Commits are LOCAL on main (f92e442b/b3633ec5/8f06d88f/a8c668ee/a6957a48 + docs) — NOT pushed to
gitea-vps2/origin (no version bump; bug-bash sideload only).
## TODO (original resume — #12 now DONE above)
### #12 Fix duplicate mesh contacts ← DONE this session (see SESSION 2 PROGRESS)
Root cause: `handle_mesh_contacts_list` (api/rpc/mesh/typed_messages.rs:1126) and
`handle_conversations_list` (api/rpc/mesh/status.rs:89) emit **one row per `state.peers` entry** with
**no cross-transport dedup**. A node can have TWO peers: a radio peer (low contact_id, firmware key)

View File

@ -37,10 +37,83 @@ app.use(i18n)
// templates instead of hard-coding a `v` prefix.
app.config.globalProperties.$ver = displayVersion
app.config.errorHandler = (err, _instance, info) => {
console.error('[Vue Error]', err, info)
const { error } = useToast()
error('Something went wrong. Please refresh the page.')
// Keep a small ring buffer of the most recent errors on `window.__archyErrors`
// so a crash that only reproduces inside a specific runtime (e.g. the Android
// companion WebView, where there's no easy console) can be retrieved after the
// fact — read it from chrome://inspect, or we can surface it in a debug view.
interface ArchyErrorEntry { when: string; source: string; message: string; info?: string; stack?: string }
const errorLog: ArchyErrorEntry[] = []
;(window as unknown as { __archyErrors?: ArchyErrorEntry[] }).__archyErrors = errorLog
// On-screen, screenshot-able error overlay. Built with plain DOM (not a Vue
// component) so it still appears when the crash is in Vue itself or in a runtime
// with no dev console (the Android companion WebView — chrome://inspect isn't
// always reachable). The user can read/screenshot the real error and the stack
// straight off the device, and "Copy" puts the full ring buffer on the clipboard.
function showErrorOverlay() {
const id = 'archy-error-overlay'
let box = document.getElementById(id)
if (!box) {
box = document.createElement('div')
box.id = id
box.style.cssText = [
'position:fixed', 'left:8px', 'right:8px', 'bottom:8px', 'z-index:2147483647',
'max-height:45vh', 'overflow:auto', 'background:rgba(20,0,0,0.92)', 'color:#ffd7d7',
'font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace', 'padding:10px 12px',
'border:1px solid #ff5a5a', 'border-radius:10px', 'white-space:pre-wrap',
'word-break:break-word', '-webkit-user-select:text', 'user-select:text',
].join(';')
document.body.appendChild(box)
}
const latest = errorLog[errorLog.length - 1]
const dump = errorLog
.map((e) => `[${e.source}] ${e.message}${e.info ? ` (${e.info})` : ''}${e.stack ? `\n${e.stack.split('\n').slice(0, 4).join('\n')}` : ''}`)
.join('\n──\n')
box.innerHTML = ''
const bar = document.createElement('div')
bar.style.cssText = 'display:flex;gap:8px;justify-content:space-between;align-items:center;margin-bottom:6px'
const title = document.createElement('b')
title.textContent = `⚠ App error (${errorLog.length}) — screenshot or Copy`
title.style.color = '#ff8a8a'
const btns = document.createElement('div')
const mkBtn = (label: string, fn: () => void) => {
const b = document.createElement('button')
b.textContent = label
b.style.cssText = 'margin-left:6px;background:#ff5a5a;color:#1a0000;border:0;border-radius:6px;padding:3px 9px;font-weight:700'
b.onclick = fn
return b
}
btns.appendChild(mkBtn('Copy', () => { navigator.clipboard?.writeText(JSON.stringify(errorLog, null, 2)) }))
btns.appendChild(mkBtn('Dismiss', () => { box?.remove() }))
bar.appendChild(title); bar.appendChild(btns)
const body = document.createElement('div')
body.textContent = latest ? dump : 'no error captured'
box.appendChild(bar); box.appendChild(body)
}
function recordError(source: string, err: unknown, info?: string) {
const e = err as { message?: string; stack?: string } | undefined
const message = (e?.message ?? String(err)) || 'unknown error'
const entry: ArchyErrorEntry = { when: new Date().toISOString(), source, message, info, stack: e?.stack }
errorLog.push(entry)
if (errorLog.length > 25) errorLog.shift()
console.error(`[${source}]`, err, info ?? '')
// Surface the real message (truncated) instead of a generic toast — this is a
// test/bug-bash build, and "Something went wrong" hides exactly what we need.
const short = message.length > 140 ? `${message.slice(0, 140)}` : message
try {
useToast().error(`Something went wrong: ${short}`)
} catch { /* toast itself failed — the console + ring buffer still have it */ }
// Always show the on-device overlay so the error is visible without a console.
try { showErrorOverlay() } catch { /* overlay is best-effort */ }
}
app.config.errorHandler = (err, _instance, info) => recordError('Vue Error', err, info)
// Vue's errorHandler only catches errors raised synchronously inside Vue's
// lifecycle/reactivity. Async rejections and plain runtime errors (e.g. a JS
// API missing in an older WebView) slip past it, so catch those too.
window.addEventListener('error', (ev) => recordError('window.error', ev.error ?? ev.message))
window.addEventListener('unhandledrejection', (ev) => recordError('unhandledrejection', ev.reason))
app.mount('#app')

View File

@ -26,6 +26,12 @@ export interface MeshPeer {
advert_name: string
did: string | null
pubkey_hex: string | null
/** Verified archipelago ed25519 identity key. The backend binds this onto
* BOTH a node's twins the federation peer natively and the radio twin via
* `bind_federation_twins` so it's the most reliable key for collapsing the
* cross-transport duplicate (survives the device rename, unlike the advert
* name). Absent on radio peers that were never matched to a federation twin. */
arch_pubkey_hex?: string | null
rssi: number | null
snr: number | null
last_heard: string

View File

@ -560,6 +560,20 @@ function fedDidKeySuffix(did: string): string | null {
}
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
// Strongest signal: the verified archipelago identity key. The backend binds
// it onto BOTH twins (federation peer natively, radio twin via
// `bind_federation_twins`), so grouping by it collapses the cross-transport
// duplicate regardless of advert name this is what survives the Meshtastic
// device rename, which broke the `Archy-z6Mk` did-prefix match below. Prefer
// the matching federation node's `did:` key so this stays consistent with the
// federation-only placeholder pass (which dedups on `did:<did>`); fall back to
// an `arch:` key only when no federation entry is known for the identity.
if (peer.arch_pubkey_hex) {
for (const fed of fedNodesByDid.value.values()) {
if (fed.pubkey === peer.arch_pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
}
return { key: `arch:${peer.arch_pubkey_hex.toLowerCase()}`, matchedFed: null }
}
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
// pubkey cross-ref: a federation node may share the archipelago pubkey
// with this radio peer if it's the same physical node (rare today, since

View File

@ -916,14 +916,16 @@ async function payWithEcash() {
downloading.value = item.id
purchaseError.value = null
try {
// Check ecash balance first
// Check ecash balance first across BOTH backends (Cashu + Fedimint), since
// the payment tries each in turn (#3). Fall back to the Cashu-only field for
// older backends that don't report `total_sats`.
try {
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({
method: 'wallet.ecash-balance',
})
const balance = balanceRes?.balance_sats ?? 0
const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0
if (balance < price) {
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
purchaseError.value = `Insufficient ecash balance (${balance} sats across Cashu + Fedimint). Need ${price} sats. Fund either wallet, or pay from another wallet via QR.`
closePayModal()
return
}

View File

@ -188,9 +188,17 @@ defineExpose({
function updateTabBarHeight() {
if (typeof window === 'undefined') return
if (mobileTabBar.value) {
const height = mobileTabBar.value.offsetHeight
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${height}px`)
const el = mobileTabBar.value
// offsetHeight is 0 when the bar is hidden (desktop `md:hidden`) or not yet
// laid out. Writing `--mobile-tab-bar-height: 0px` would DEFEAT the `, 88px`
// fallback baked into the `.mobile-scroll-pad` clearance calc (an explicit
// 0px is still "set"), so the fixed tab bar ends up covering the last row of
// content the Cloud/files "bottom elements cut off" bug. Only write a real
// measured height; otherwise remove the var so the fallback applies.
if (el && el.offsetHeight > 0) {
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${el.offsetHeight}px`)
} else {
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
}
}
@ -201,10 +209,15 @@ function onResize() {
onMounted(() => {
updateTabBarHeight()
// Re-measure after the first paint: on mount the bar may not have its final
// laid-out height yet (fonts/safe-area padding still settling), which would
// leave the clearance var short.
requestAnimationFrame(updateTabBarHeight)
readSafeAreaTop()
window.addEventListener('resize', onResize)
// Re-read after WebView injection has had time to run
setTimeout(readSafeAreaTop, 500)
// Re-read after WebView injection has had time to run. The injected
// safe-area-bottom padding changes the bar's height, so re-measure too.
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
})
onBeforeUnmount(() => {