Compare commits

...

2 commits

Author SHA1 Message Date
Scott Reimers
be253e8001 Our Info panel, hole punch race fix, NAT profiles in relay introduction
- Network Diagnostics: "Our Info" button shows addresses with NAT status,
  device role, UPnP, HTTP capability. Addresses stacked for mobile.
- Hole punch race: re-check for existing connection before and after relay
  introduction to avoid wasting minutes on redundant punch attempts.
- Relay introduction now carries requester/target NAT mapping+filtering
  so hole punch strategy uses fresh profiles instead of stale stored ones.
  Critical for phones that switch between WiFi/cellular/VPN.
- STUN fix: filter DNS results to IPv4 (was resolving to IPv6 first on
  dual-stack, causing silent send failure and "NAT unknown").
- Welcome screen: Ready button with loading bar for instant feed access.
- LAN addresses show just "LAN" (no misleading punchability label).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:57:41 -04:00
Scott Reimers
e354ccc388 Welcome screen: add Ready button with loading bar for instant feed access
Progress bar animates during backend readiness check. Once local feed is
loaded from SQLite, button enables with teal highlight. Click switches to
feed tab with cached content — no network wait needed for returning users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:41:50 -04:00
8 changed files with 303 additions and 11 deletions

6
Cargo.lock generated
View file

@ -2732,7 +2732,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]] [[package]]
name = "itsgoin-cli" name = "itsgoin-cli"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"hex", "hex",
@ -2744,7 +2744,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-core" name = "itsgoin-core"
version = "0.3.0" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
@ -2767,7 +2767,7 @@ dependencies = [
[[package]] [[package]]
name = "itsgoin-desktop" name = "itsgoin-desktop"
version = "0.4.4" version = "0.5.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",

View file

@ -3868,12 +3868,15 @@ impl ConnectionManager {
} }
} }
let our_profile = self.our_nat_profile();
let payload = RelayIntroducePayload { let payload = RelayIntroducePayload {
intro_id, intro_id,
target: *target, target: *target,
requester: self.our_node_id, requester: self.our_node_id,
requester_addresses: our_addrs, requester_addresses: our_addrs,
ttl, ttl,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
}; };
let (mut send, mut recv) = pc.connection.open_bi().await?; let (mut send, mut recv) = pc.connection.open_bi().await?;
@ -3909,6 +3912,8 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some("duplicate intro".to_string()), reject_reason: Some("duplicate intro".to_string()),
nat_mapping: None,
nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -3932,12 +3937,15 @@ impl ConnectionManager {
} }
} }
let our_profile = self.our_nat_profile();
let result = RelayIntroduceResultPayload { let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, intro_id: payload.intro_id,
accepted: true, accepted: true,
target_addresses: our_addrs, target_addresses: our_addrs,
relay_available: false, relay_available: false,
reject_reason: None, reject_reason: None,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -3963,7 +3971,20 @@ impl ConnectionManager {
let our_http_capable = self.http_capable; let our_http_capable = self.http_capable;
let our_http_addr = self.http_addr.clone(); let our_http_addr = self.http_addr.clone();
let our_nat_profile = self.our_nat_profile(); let our_nat_profile = self.our_nat_profile();
let peer_nat_profile = { // Prefer fresh NAT profile from relay payload over stale stored profile
let peer_nat_profile = if payload.nat_mapping.is_some() || payload.nat_filtering.is_some() {
let mapping = payload.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = payload.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
// Update storage with fresh profile
let s = self.storage.get().await;
let _ = s.set_peer_nat_profile(&requester, &fresh);
fresh
} else {
let s = self.storage.get().await; let s = self.storage.get().await;
s.get_peer_nat_profile(&requester) s.get_peer_nat_profile(&requester)
}; };
@ -4081,6 +4102,7 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some(format!("relay forward failed: {}", e)), reject_reason: Some(format!("relay forward failed: {}", e)),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -4155,6 +4177,7 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some(format!("relay forward to session failed: {}", e)), reject_reason: Some(format!("relay forward to session failed: {}", e)),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -4194,6 +4217,8 @@ impl ConnectionManager {
requester: payload.requester, requester: payload.requester,
requester_addresses: req_addrs, requester_addresses: req_addrs,
ttl: payload.ttl - 1, ttl: payload.ttl - 1,
nat_mapping: payload.nat_mapping.clone(),
nat_filtering: payload.nat_filtering.clone(),
}; };
let forward_result = async { let forward_result = async {
@ -4246,6 +4271,7 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some("target not reachable through relay".to_string()), reject_reason: Some("target not reachable through relay".to_string()),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -4273,6 +4299,7 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some("relay at capacity".to_string()), reject_reason: Some("relay at capacity".to_string()),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
requester_send.finish()?; requester_send.finish()?;
@ -4293,6 +4320,7 @@ impl ConnectionManager {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some("target not connected to relay".to_string()), reject_reason: Some("target not connected to relay".to_string()),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut requester_send, MessageType::RelayIntroduceResult, &result).await?;
requester_send.finish()?; requester_send.finish()?;
@ -5811,6 +5839,7 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload { let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: false, target_addresses: vec![], intro_id: payload.intro_id, accepted: false, target_addresses: vec![],
relay_available: false, reject_reason: Some("duplicate intro".to_string()), relay_available: false, reject_reason: Some("duplicate intro".to_string()),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -5836,7 +5865,22 @@ impl ConnectionManager {
let our_http_capable = cm.http_capable; let our_http_capable = cm.http_capable;
let our_http_addr = cm.http_addr.clone(); let our_http_addr = cm.http_addr.clone();
let our_nat_profile = cm.our_nat_profile(); let our_nat_profile = cm.our_nat_profile();
let peer_nat_profile = { let s = cm.storage.get().await; s.get_peer_nat_profile(&payload.requester) }; // Prefer fresh NAT profile from relay payload over stale stored profile
let peer_nat_profile = if payload.nat_mapping.is_some() || payload.nat_filtering.is_some() {
let mapping = payload.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = payload.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
let s = cm.storage.get().await;
let _ = s.set_peer_nat_profile(&payload.requester, &fresh);
fresh
} else {
let s = cm.storage.get().await;
s.get_peer_nat_profile(&payload.requester)
};
Some(RelayGathered::WeAreTarget { our_addrs, endpoint, storage, our_node_id, our_nat_type, our_http_capable, our_http_addr, our_nat_profile, peer_nat_profile }) Some(RelayGathered::WeAreTarget { our_addrs, endpoint, storage, our_node_id, our_nat_type, our_http_capable, our_http_addr, our_nat_profile, peer_nat_profile })
} else { } else {
// We are relay — gather target connection, requester observed addr, etc. // We are relay — gather target connection, requester observed addr, etc.
@ -5866,6 +5910,8 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload { let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: true, target_addresses: our_addrs, intro_id: payload.intro_id, accepted: true, target_addresses: our_addrs,
relay_available: false, reject_reason: None, relay_available: false, reject_reason: None,
nat_mapping: Some(our_nat_profile.mapping.to_string()),
nat_filtering: Some(our_nat_profile.filtering.to_string()),
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;
@ -5954,6 +6000,7 @@ impl ConnectionManager {
let result = RelayIntroduceResultPayload { let result = RelayIntroduceResultPayload {
intro_id: payload.intro_id, accepted: false, target_addresses: vec![], intro_id: payload.intro_id, accepted: false, target_addresses: vec![],
relay_available: false, reject_reason: Some(format!("relay forward failed: {}", e_msg)), relay_available: false, reject_reason: Some(format!("relay forward failed: {}", e_msg)),
nat_mapping: None, nat_filtering: None,
}; };
write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?; write_typed_message(&mut send, MessageType::RelayIntroduceResult, &result).await?;
send.finish()?; send.finish()?;

View file

@ -349,6 +349,11 @@ impl Network {
self.device_role self.device_role
} }
/// Get the explicit bind address (from --bind flag), if any.
pub fn bind_addr(&self) -> Option<std::net::SocketAddr> {
self.bind_addr
}
/// Whether this node can serve HTTP (has TCP reachability). /// Whether this node can serve HTTP (has TCP reachability).
pub fn is_http_capable(&self) -> bool { pub fn is_http_capable(&self) -> bool {
self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some() self.has_upnp_tcp || self.has_public_v6 || self.bind_addr.is_some()
@ -1952,7 +1957,13 @@ impl Network {
} }
} }
// 4. Try relay introduction + hole punch (no lock during I/O) // 4. Re-check connection — peer may have connected to us while we were trying direct
if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await {
debug!(peer = hex::encode(peer_id), "Peer connected to us while we were trying — using existing connection");
return Ok(conn);
}
// 5. Try relay introduction + hole punch (no lock during I/O)
let relay_candidates = self.conn_handle.find_relays_for(peer_id).await; let relay_candidates = self.conn_handle.find_relays_for(peer_id).await;
if relay_candidates.is_empty() { if relay_candidates.is_empty() {
@ -1975,10 +1986,28 @@ impl Network {
self.send_relay_introduce_standalone(relay_peer, peer_id, *ttl), self.send_relay_introduce_standalone(relay_peer, peer_id, *ttl),
).await; ).await;
// Re-check: peer may have connected while relay intro was in flight
if let Some(conn) = self.conn_handle.get_any_connection(peer_id).await {
debug!(peer = hex::encode(peer_id), "Peer connected during relay intro — using existing");
return Ok(conn);
}
match intro_result { match intro_result {
Ok(Ok(result)) if result.accepted => { Ok(Ok(ref result)) if result.accepted => {
let our_profile = self.conn_handle.our_nat_profile().await; let our_profile = self.conn_handle.our_nat_profile().await;
let peer_profile = { // Prefer fresh NAT profile from relay response over stale stored profile
let peer_profile = if result.nat_mapping.is_some() || result.nat_filtering.is_some() {
let mapping = result.nat_mapping.as_deref()
.map(crate::types::NatMapping::from_str_label)
.unwrap_or(crate::types::NatMapping::Unknown);
let filtering = result.nat_filtering.as_deref()
.map(crate::types::NatFiltering::from_str_label)
.unwrap_or(crate::types::NatFiltering::Unknown);
let fresh = crate::types::NatProfile::new(mapping, filtering);
let s = self.storage.get().await;
let _ = s.set_peer_nat_profile(peer_id, &fresh);
fresh
} else {
let s = self.storage.get().await; let s = self.storage.get().await;
s.get_peer_nat_profile(peer_id) s.get_peer_nat_profile(peer_id)
}; };
@ -2274,12 +2303,15 @@ impl Network {
} }
} }
let our_profile = self.conn_handle.our_nat_profile().await;
let payload = crate::protocol::RelayIntroducePayload { let payload = crate::protocol::RelayIntroducePayload {
intro_id, intro_id,
target: *target, target: *target,
requester: self.our_node_id, requester: self.our_node_id,
requester_addresses: our_addrs, requester_addresses: our_addrs,
ttl, ttl,
nat_mapping: Some(our_profile.mapping.to_string()),
nat_filtering: Some(our_profile.filtering.to_string()),
}; };
let (mut send, mut recv) = conn.open_bi().await?; let (mut send, mut recv) = conn.open_bi().await?;

View file

@ -461,6 +461,12 @@ pub struct RelayIntroducePayload {
pub requester_addresses: Vec<String>, pub requester_addresses: Vec<String>,
/// Max forwarding hops remaining (0 = relay must know target directly) /// Max forwarding hops remaining (0 = relay must know target directly)
pub ttl: u8, pub ttl: u8,
/// Requester's current NAT mapping type (for hole punch strategy)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_mapping: Option<String>,
/// Requester's current NAT filtering type
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_filtering: Option<String>,
} }
/// Target's response to a relay introduction (bi-stream response) /// Target's response to a relay introduction (bi-stream response)
@ -472,6 +478,12 @@ pub struct RelayIntroduceResultPayload {
/// Relay is willing to serve as stream relay fallback /// Relay is willing to serve as stream relay fallback
pub relay_available: bool, pub relay_available: bool,
pub reject_reason: Option<String>, pub reject_reason: Option<String>,
/// Target's current NAT mapping type (for hole punch strategy)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_mapping: Option<String>,
/// Target's current NAT filtering type
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nat_filtering: Option<String>,
} }
/// Open a relay pipe — intermediary splices two bi-streams (bi-stream) /// Open a relay pipe — intermediary splices two bi-streams (bi-stream)
@ -858,6 +870,8 @@ mod tests {
requester: [2u8; 32], requester: [2u8; 32],
requester_addresses: vec!["10.0.0.2:4433".to_string()], requester_addresses: vec!["10.0.0.2:4433".to_string()],
ttl: 1, ttl: 1,
nat_mapping: Some("eim".to_string()),
nat_filtering: Some("open".to_string()),
}; };
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
let decoded: RelayIntroducePayload = serde_json::from_str(&json).unwrap(); let decoded: RelayIntroducePayload = serde_json::from_str(&json).unwrap();
@ -876,6 +890,8 @@ mod tests {
target_addresses: vec!["10.0.0.1:4433".to_string(), "192.168.1.1:4433".to_string()], target_addresses: vec!["10.0.0.1:4433".to_string(), "192.168.1.1:4433".to_string()],
relay_available: true, relay_available: true,
reject_reason: None, reject_reason: None,
nat_mapping: Some("eim".to_string()),
nat_filtering: Some("open".to_string()),
}; };
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
let decoded: RelayIntroduceResultPayload = serde_json::from_str(&json).unwrap(); let decoded: RelayIntroduceResultPayload = serde_json::from_str(&json).unwrap();
@ -892,6 +908,8 @@ mod tests {
target_addresses: vec![], target_addresses: vec![],
relay_available: false, relay_available: false,
reject_reason: Some("target not reachable".to_string()), reject_reason: Some("target not reachable".to_string()),
nat_mapping: None,
nat_filtering: None,
}; };
let json2 = serde_json::to_string(&rejected).unwrap(); let json2 = serde_json::to_string(&rejected).unwrap();
let decoded2: RelayIntroduceResultPayload = serde_json::from_str(&json2).unwrap(); let decoded2: RelayIntroduceResultPayload = serde_json::from_str(&json2).unwrap();

View file

@ -103,8 +103,9 @@ fn parse_xor_mapped_address(resp: &[u8], txn_id: &[u8; 12]) -> Option<SocketAddr
/// Query a single STUN server and return the mapped address. /// Query a single STUN server and return the mapped address.
async fn stun_query(sock: &UdpSocket, server: &str) -> Option<SocketAddr> { async fn stun_query(sock: &UdpSocket, server: &str) -> Option<SocketAddr> {
use std::net::ToSocketAddrs; use std::net::ToSocketAddrs;
// Filter for IPv4 since our socket is bound to 0.0.0.0 (IPv4)
let server_addr = match server.to_socket_addrs() { let server_addr = match server.to_socket_addrs() {
Ok(mut addrs) => addrs.next()?, Ok(addrs) => addrs.filter(|a| a.is_ipv4()).next()?,
Err(e) => { Err(e) => {
debug!(server, error = %e, "STUN DNS resolution failed"); debug!(server, error = %e, "STUN DNS resolution failed");
return None; return None;

View file

@ -1623,6 +1623,120 @@ async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummary
}) })
} }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OurInfoDto {
node_id: String,
addresses: Vec<AddressInfoDto>,
nat_type: String,
device_role: String,
upnp: bool,
http_capable: bool,
http_addr: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AddressInfoDto {
addr: String,
family: String, // "IPv4" or "IPv6"
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
}
#[tauri::command]
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
let node = get_node(&state).await;
let net = &node.network;
let nat_type = net.conn_handle().nat_type().await;
let has_upnp = net.has_upnp();
let _has_public_v6 = net.has_public_v6();
let bind_addr = net.bind_addr();
let mut addresses = Vec::new();
// Collect bound socket addresses (these are our local interfaces)
let bound: std::collections::HashSet<String> = net.bound_sockets()
.iter()
.map(|s| s.to_string())
.collect();
// Gather bound (local) addresses with classification
for sock in net.bound_sockets() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let status = classify_addr(&sock, &nat_type, has_upnp, bind_addr.is_some(), false);
addresses.push(AddressInfoDto {
addr: sock.to_string(),
family: family.to_string(),
status,
});
}
// Add UPnP external address if different from bound
if let Some(ref mapping) = net.upnp_mapping() {
let ext = mapping.external_addr;
if !addresses.iter().any(|a| a.addr == ext.to_string()) {
addresses.insert(0, AddressInfoDto {
addr: ext.to_string(),
family: if ext.ip().is_ipv4() { "IPv4" } else { "IPv6" }.to_string(),
status: "UPnP external".to_string(),
});
}
}
// Add iroh-discovered addresses not already listed (STUN-observed externals)
for sock in net.endpoint_addr().ip_addrs() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let s = sock.to_string();
if addresses.iter().any(|a| a.addr == s) { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let is_observed = !bound.contains(&s);
let status = classify_addr(sock, &nat_type, has_upnp, bind_addr.is_some(), is_observed);
addresses.push(AddressInfoDto {
addr: s,
family: family.to_string(),
status,
});
}
Ok(OurInfoDto {
node_id: hex::encode(net.node_id_bytes()),
addresses,
nat_type: nat_type.to_string(),
device_role: format!("{:?}", net.device_role()),
upnp: has_upnp,
http_capable: net.is_http_capable(),
http_addr: net.http_addr(),
})
}
fn classify_addr(sock: &std::net::SocketAddr, nat_type: &itsgoin_core::types::NatType, has_upnp: bool, is_server: bool, is_observed: bool) -> String {
use std::net::IpAddr;
let ip = sock.ip();
let is_v6 = ip.is_ipv6();
let is_pub = match ip {
IpAddr::V4(v4) => !v4.is_private() && !v4.is_loopback() && !v4.is_link_local(),
IpAddr::V6(v6) => !v6.is_loopback() && {
let seg = v6.segments();
seg[0] & 0xfe00 != 0xfe00 && seg[0] & 0xffc0 != 0xfe80
},
};
if is_server { return "Server".to_string(); }
if is_pub {
if is_observed && !is_v6 {
return match nat_type {
itsgoin_core::types::NatType::Public |
itsgoin_core::types::NatType::Easy => "External · easy punch".to_string(),
itsgoin_core::types::NatType::Hard => "External · hard punch (scan)".to_string(),
itsgoin_core::types::NatType::Unknown => "External".to_string(),
};
}
return "Public".to_string();
}
if is_v6 { return "Link-local".to_string(); }
"LAN".to_string()
}
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ActivityEventDto { struct ActivityEventDto {
@ -2387,6 +2501,7 @@ pub fn run() {
sync_all, sync_all,
sync_from_peer, sync_from_peer,
get_network_summary, get_network_summary,
get_our_info,
get_activity_log, get_activity_log,
trigger_rebalance, trigger_rebalance,
request_referrals, request_referrals,

View file

@ -2986,9 +2986,14 @@ function openDiagnostics() {
<button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button> <button id="request-referrals-btn" class="btn btn-ghost btn-sm">Request Referrals</button>
</div> </div>
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center"> <div style="display:flex;gap:0.5rem;margin-top:0.5rem;flex-wrap:wrap;justify-content:center">
<button id="show-ourinfo-btn" class="btn btn-ghost btn-sm">Our Info</button>
<button id="show-connections-btn" class="btn btn-ghost btn-sm">Show Connections</button> <button id="show-connections-btn" class="btn btn-ghost btn-sm">Show Connections</button>
<button id="show-anchors-btn" class="btn btn-ghost btn-sm">Stored Anchors</button> <button id="show-anchors-btn" class="btn btn-ghost btn-sm">Stored Anchors</button>
</div> </div>
<div id="ourinfo-section" class="hidden">
<h4 class="subsection-title">Our Info</h4>
<div id="ourinfo-content"></div>
</div>
<div id="connections-section" class="hidden"> <div id="connections-section" class="hidden">
<h4 class="subsection-title">Mesh &amp; Session Connections</h4> <h4 class="subsection-title">Mesh &amp; Session Connections</h4>
<div id="connections-list"></div> <div id="connections-list"></div>
@ -3006,6 +3011,45 @@ function openDiagnostics() {
networkSummaryEl = $('#network-summary'); networkSummaryEl = $('#network-summary');
connectionsList = $('#connections-list'); connectionsList = $('#connections-list');
peersList = null; // Known peers removed peersList = null; // Known peers removed
// Wire Our Info toggle
$('#show-ourinfo-btn').addEventListener('click', async () => {
const section = $('#ourinfo-section');
const btn = $('#show-ourinfo-btn');
if (section.classList.contains('hidden')) {
section.classList.remove('hidden');
btn.textContent = 'Hide Our Info';
try {
const info = await invoke('get_our_info');
const httpVal = info.httpAddr || 'No';
let html = `<div class="diag-grid" style="margin-bottom:0.5rem">
<div class="diag-item"><span class="diag-label">NAT Type</span><span class="diag-value" style="font-size:0.85rem">${info.natType}</span></div>
<div class="diag-item"><span class="diag-label">Role</span><span class="diag-value" style="font-size:0.85rem">${info.deviceRole}</span></div>
<div class="diag-item"><span class="diag-label">UPnP</span><span class="diag-value" style="font-size:0.85rem">${info.upnp ? 'Yes' : 'No'}</span></div>
</div>
<div style="text-align:center;margin-bottom:0.75rem">
<span style="color:#888;font-size:0.75rem">HTTP</span>
<span style="color:#ccc;font-size:0.8rem;margin-left:0.5rem">${httpVal}</span>
</div>`;
html += '<div style="font-size:0.8rem">';
for (const a of info.addresses) {
const color = a.status.includes('Public') || a.status.includes('punchable') || a.status.includes('Server') ? '#7fdbca' :
a.status.includes('UPnP') || a.status.includes('External') ? '#5b8def' : '#888';
html += `<div style="padding:0.3rem 0;border-bottom:1px solid #1a1a2e">
<div style="color:#ccc;font-family:monospace;font-size:0.7rem;word-break:break-all">${a.addr}</div>
<div style="color:${color};font-size:0.7rem;margin-top:0.1rem">${a.family} · ${a.status}</div>
</div>`;
}
html += '</div>';
html += `<p style="color:#555;font-size:0.7rem;text-align:center;margin-top:0.5rem;word-break:break-all">Node: ${info.nodeId.substring(0, 16)}…</p>`;
$('#ourinfo-content').innerHTML = html;
} catch (e) {
$('#ourinfo-content').innerHTML = `<p class="empty-hint">Failed to load: ${e}</p>`;
}
} else {
section.classList.add('hidden');
btn.textContent = 'Our Info';
}
});
// Wire connections toggle // Wire connections toggle
$('#show-connections-btn').addEventListener('click', () => { $('#show-connections-btn').addEventListener('click', () => {
const section = $('#connections-section'); const section = $('#connections-section');
@ -3570,10 +3614,29 @@ async function init() {
} }
}, 2000); }, 2000);
// Ready button — click to go to feed
const readyBtn = document.getElementById('welcome-ready-btn');
const readyBar = document.getElementById('welcome-ready-bar');
if (readyBtn) {
readyBtn.addEventListener('click', () => {
if (readyBtn.disabled) return;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const feedTab = document.querySelector('.tab[data-tab="feed"]');
if (feedTab) feedTab.classList.add('active');
document.getElementById('view-feed').classList.add('active');
currentTab = 'feed';
_lastFeedViewMs = Date.now();
updateTabBadge('feed', 0);
});
}
// Wait for backend in the background, then load node info // Wait for backend in the background, then load node info
(async () => { (async () => {
for (let attempt = 0; attempt < 30; attempt++) { for (let attempt = 0; attempt < 30; attempt++) {
try { try {
// Animate progress bar toward 90% during readiness checks
if (readyBar) readyBar.style.width = Math.min(90, (attempt + 1) * 3) + '%';
await invoke('get_node_info'); await invoke('get_node_info');
break; break;
} catch (e) { } catch (e) {
@ -3586,9 +3649,19 @@ async function init() {
setupOverlay.classList.remove('hidden'); setupOverlay.classList.remove('hidden');
setupName.focus(); setupName.focus();
} }
// Reload feed now that backend is ready // Pre-load feed + messages from local DB (instant — no network needed)
loadFeed(true).catch(() => {}); await loadFeed(true).catch(() => {});
loadMessages(true).catch(() => {}); loadMessages(true).catch(() => {});
// Mark ready button as clickable
if (readyBar) readyBar.style.width = '100%';
if (readyBtn) {
readyBtn.disabled = false;
readyBtn.textContent = 'Ready — Go to Feed';
readyBtn.style.opacity = '1';
readyBtn.style.color = '#7fdbca';
readyBtn.style.borderColor = '#7fdbca';
readyBtn.style.cursor = 'pointer';
}
})(); })();
// Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam) // Mark notif ready after first welcome fetch succeeds (skip first 2 ticks to avoid spam)

View file

@ -49,6 +49,12 @@
<div><span id="welcome-reacts" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Reacts</div> <div><span id="welcome-reacts" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Reacts</div>
<div><span id="welcome-comments" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Comments</div> <div><span id="welcome-comments" style="font-size:1.4rem;font-weight:700;color:#5b8def;display:block">-</span>Comments</div>
</div> </div>
<div style="margin-top:2rem;max-width:280px;margin-left:auto;margin-right:auto">
<div style="background:#1a1a2e;border-radius:6px;height:6px;overflow:hidden;margin-bottom:0.75rem">
<div id="welcome-ready-bar" style="height:100%;background:#7fdbca;width:0%;transition:width 0.5s ease;border-radius:6px"></div>
</div>
<button id="welcome-ready-btn" disabled style="width:100%;padding:0.75rem 1.5rem;border:1px solid #333;border-radius:8px;background:#1a1a2e;color:#666;font-size:0.95rem;cursor:not-allowed;opacity:0.5;transition:all 0.3s ease">Loading...</button>
</div>
</div> </div>
</section> </section>