Compare commits
8 Commits
5f7e8dca80
...
242baf5deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242baf5deb | ||
|
|
0ab160b5c3 | ||
|
|
a6957a48f7 | ||
|
|
2761f0d70f | ||
|
|
a8c668ee0a | ||
|
|
8f06d88fbf | ||
|
|
b3633ec525 | ||
|
|
f92e442bfc |
@ -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)?;
|
||||
|
||||
@ -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" => {
|
||||
|
||||
@ -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!({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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\""));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user