use std::sync::Arc; use serde::{Deserialize, Serialize}; use tauri::{Manager, State}; use tracing::info; use itsgoin_core::node::Node; use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent}; type AppState = Arc; // --- DTOs for the frontend (all IDs as hex strings) --- #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AttachmentDto { cid: String, mime_type: String, size_bytes: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct PostDto { id: String, author: String, author_name: Option, content: String, timestamp_ms: u64, is_me: bool, /// "public", "encrypted", or "encrypted-for-me" visibility: String, /// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient decrypted_content: Option, attachments: Vec, /// Hex node IDs of recipients (non-empty only for Encrypted posts) recipients: Vec, /// Reaction counts: [(emoji, count, reacted_by_me)] reaction_counts: Vec, /// Number of comments on this post comment_count: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ReactionCountDto { emoji: String, count: u64, reacted_by_me: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ReactionDto { reactor: String, emoji: String, post_id: String, timestamp_ms: u64, encrypted_payload: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CommentDto { author: String, author_name: Option, post_id: String, content: String, timestamp_ms: u64, } #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct CommentPolicyDto { allow_comments: String, allow_reacts: String, moderation: String, blocklist: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CircleDto { name: String, members: Vec, created_at: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StatsDto { post_count: usize, peer_count: usize, follow_count: usize, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct RedundancyDto { total: usize, zero_replicas: usize, one_replica: usize, two_plus_replicas: usize, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NodeInfoDto { node_id: String, connect_string: String, display_name: Option, has_profile: bool, anchors: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct PeerDto { node_id: String, display_name: Option, addresses: Vec, introduced_by: Option, is_anchor: bool, last_seen: u64, /// Reach level: "mesh", "n1", "n2", "n3", or "known" reach: String, /// Whether we have a mesh or session connection right now is_online: bool, /// Timestamp (ms) of last activity (connection, push, or pull) last_activity_ms: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProfileDto { node_id: String, display_name: String, bio: String, anchors: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SuggestedPeerDto { node_id: String, display_name: Option, addresses: Vec, introduced_by_name: Option, is_anchor: bool, } // --- Helpers --- async fn post_to_dto( id: &PostId, post: &Post, vis: &PostVisibility, decrypted: Option<&str>, node: &Node, ) -> PostDto { let author_name = match node.resolve_display_name(&post.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; let (visibility, decrypted_content) = match vis { PostVisibility::Public => ("public".to_string(), None), PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted { Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())), None => ("encrypted".to_string(), None), }, }; let recipients = match vis { PostVisibility::Encrypted { recipients } => { recipients.iter().map(|wk| hex::encode(wk.recipient)).collect() } _ => vec![], }; let attachments = post .attachments .iter() .map(|a| AttachmentDto { cid: hex::encode(a.cid), mime_type: a.mime_type.clone(), size_bytes: a.size_bytes, }) .collect(); // Engagement data let reaction_counts = { let storage = node.storage.lock().await; storage.get_reaction_counts(id, &node.node_id).unwrap_or_default() .into_iter() .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .collect() }; let comment_count = { let storage = node.storage.lock().await; storage.get_comment_count(id).unwrap_or(0) }; PostDto { id: hex::encode(id), author: hex::encode(post.author), author_name, content: post.content.clone(), timestamp_ms: post.timestamp_ms, is_me: &post.author == &node.node_id, visibility, decrypted_content, attachments, recipients, reaction_counts, comment_count, } } /// Decrypt a just-created post for immediate display. async fn decrypt_just_created( node: &Node, post: &Post, vis: &PostVisibility, ) -> Option { match vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { itsgoin_core::crypto::decrypt_post( &post.content, &node.secret_seed_bytes(), &node.node_id, &node.node_id, recipients, ) .ok() .flatten() } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { let seed_info = { let storage = node.storage.lock().await; storage.get_all_group_seeds_map().ok() .and_then(|map| map.get(&(*group_id, *epoch)).copied()) }; if let Some((seed, pubkey)) = seed_info { itsgoin_core::crypto::decrypt_group_post( &post.content, &seed, &pubkey, wrapped_cek, ) .ok() } else { None } } } } fn parse_node_id(hex_str: &str) -> Result { itsgoin_core::parse_node_id_hex(hex_str).map_err(|e| e.to_string()) } // --- Tauri commands --- #[tauri::command] async fn get_node_info(state: State<'_, AppState>) -> Result { let node = state.inner(); let node_id_hex = hex::encode(node.node_id); let addr = node.endpoint_addr(); let connect_string = if let Some(sock) = addr.ip_addrs().next() { format!("{}@{}", node_id_hex, sock) } else { node_id_hex.clone() }; let profile = node.my_profile().await.map_err(|e| e.to_string())?; let anchors = profile .as_ref() .map(|p| p.anchors.iter().map(hex::encode).collect()) .unwrap_or_default(); Ok(NodeInfoDto { node_id: node_id_hex, connect_string, display_name: profile.as_ref().map(|p| p.display_name.clone()), has_profile: profile.is_some(), anchors, }) } #[tauri::command] async fn set_display_name( state: State<'_, AppState>, name: String, ) -> Result { let node = state.inner(); let profile = node .set_profile(name, String::new()) .await .map_err(|e| e.to_string())?; Ok(ProfileDto { node_id: hex::encode(profile.node_id), display_name: profile.display_name, bio: profile.bio, anchors: profile.anchors.iter().map(hex::encode).collect(), }) } #[tauri::command] async fn set_profile( state: State<'_, AppState>, name: String, bio: String, ) -> Result { let node = state.inner(); let profile = node .set_profile(name, bio) .await .map_err(|e| e.to_string())?; Ok(ProfileDto { node_id: hex::encode(profile.node_id), display_name: profile.display_name, bio: profile.bio, anchors: profile.anchors.iter().map(hex::encode).collect(), }) } #[tauri::command] async fn create_post( state: State<'_, AppState>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, ) -> Result { let node = state.inner(); let intent = match visibility.as_deref() { Some("friends") => VisibilityIntent::Friends, Some("circle") => { let name = circle_name.ok_or("circle_name required for circle visibility")?; VisibilityIntent::Circle(name) } Some("direct") => { let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?; let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?; VisibilityIntent::Direct(vec![nid]) } _ => VisibilityIntent::Public, }; let (id, post, vis) = node .create_post_with_visibility(content, intent, vec![]) .await .map_err(|e| e.to_string())?; let decrypted = decrypt_just_created(node, &post, &vis).await; Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), node).await) } #[tauri::command] async fn create_post_with_files( state: State<'_, AppState>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, files: Vec<(String, String)>, ) -> Result { let node = state.inner(); let intent = match visibility.as_deref() { Some("friends") => VisibilityIntent::Friends, Some("circle") => { let name = circle_name.ok_or("circle_name required for circle visibility")?; VisibilityIntent::Circle(name) } Some("direct") => { let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?; let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?; VisibilityIntent::Direct(vec![nid]) } _ => VisibilityIntent::Public, }; use base64::Engine; let attachment_data: Vec<(Vec, String)> = files .into_iter() .map(|(b64, mime)| { let data = base64::engine::general_purpose::STANDARD .decode(&b64) .map_err(|e| format!("invalid base64: {}", e))?; Ok((data, mime)) }) .collect::, String>>()?; let (id, post, vis) = node .create_post_with_visibility(content, intent, attachment_data) .await .map_err(|e| e.to_string())?; let decrypted = decrypt_just_created(node, &post, &vis).await; Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), node).await) } /// Return the filesystem path of a blob if it exists locally (for streaming video/media). #[tauri::command] async fn get_blob_path( state: State<'_, AppState>, cid_hex: String, ) -> Result, String> { let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; Ok(state.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string())) } /// Save a blob to the Downloads folder and open it with the system handler. #[tauri::command] async fn save_and_open_blob( state: State<'_, AppState>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { let node = state.inner(); let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; // Get blob data (local or fetch from network) let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { d } else if let Some(pid_hex) = post_id_hex { let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?; let post = { let storage = node.storage.lock().await; storage.get_post(&post_id).map_err(|e| e.to_string())? }; if let Some(post) = post { let mime = post.attachments.iter() .find(|a| a.cid == cid) .map(|a| a.mime_type.as_str()) .unwrap_or("application/octet-stream"); node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms) .await.map_err(|e| e.to_string())? .ok_or_else(|| "blob not found".to_string())? } else { return Err("post not found".to_string()); } } else { return Err("blob not found".to_string()); }; // Save to Downloads let downloads = dirs::download_dir() .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); let dest = downloads.join(&filename); tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?; // Open with system handler let _ = open::that(&dest); Ok(dest.to_string_lossy().to_string()) } /// Save a blob to Downloads without opening it. #[tauri::command] async fn save_blob( state: State<'_, AppState>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { let node = state.inner(); let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { d } else if let Some(pid_hex) = post_id_hex { let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?; let post = { let storage = node.storage.lock().await; storage.get_post(&post_id).map_err(|e| e.to_string())? }; if let Some(post) = post { let mime = post.attachments.iter() .find(|a| a.cid == cid) .map(|a| a.mime_type.as_str()) .unwrap_or("application/octet-stream"); node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms) .await.map_err(|e| e.to_string())? .ok_or_else(|| "blob not found".to_string())? } else { return Err("post not found".to_string()); } } else { return Err("blob not found".to_string()); }; let downloads = dirs::download_dir() .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); let dest = downloads.join(&filename); tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?; Ok(dest.to_string_lossy().to_string()) } #[tauri::command] async fn get_blob( state: State<'_, AppState>, cid_hex: String, post_id_hex: Option, ) -> Result { let node = state.inner(); let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?; let cid: [u8; 32] = cid_bytes .try_into() .map_err(|_| "CID must be 32 bytes".to_string())?; // Check local first (also touches last_accessed_at) if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? { use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&data)); } // Try fetching from author → replica peers if post_id provided if let Some(pid_hex) = post_id_hex { let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?; let post_id: [u8; 32] = pid_bytes .try_into() .map_err(|_| "post_id must be 32 bytes".to_string())?; let post = { let storage = node.storage.lock().await; storage.get_post(&post_id).map_err(|e| e.to_string())? }; if let Some(post) = post { // Find the mime type from the post's attachments let mime_type = post.attachments.iter() .find(|a| a.cid == cid) .map(|a| a.mime_type.as_str()) .unwrap_or("application/octet-stream"); if let Some(data) = node .fetch_blob_with_fallback(&cid, &post_id, &post.author, mime_type, post.timestamp_ms) .await .map_err(|e| e.to_string())? { use base64::Engine; return Ok(base64::engine::general_purpose::STANDARD.encode(&data)); } } } Err("blob not found".to_string()) } #[tauri::command] async fn get_feed(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let posts = node.get_feed().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(posts.len()); for (id, p, vis, decrypted) in &posts { dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), node).await); } Ok(dtos) } #[tauri::command] async fn get_all_posts(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let posts = node.get_all_posts().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(posts.len()); for (id, p, vis, decrypted) in &posts { dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), node).await); } Ok(dtos) } #[tauri::command] async fn get_stats(state: State<'_, AppState>) -> Result { let node = state.inner(); let stats = node.stats().await.map_err(|e| e.to_string())?; Ok(StatsDto { post_count: stats.post_count, peer_count: stats.peer_count, follow_count: stats.follow_count, }) } #[tauri::command] async fn connect_peer( state: State<'_, AppState>, connect_string: String, ) -> Result { let node = state.inner(); let (nid, addr) = itsgoin_core::parse_connect_string(&connect_string).map_err(|e| e.to_string())?; // Store peer with addresses let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect(); { let storage = node.storage.lock().await; if ip_addrs.is_empty() { storage.add_peer(&nid).map_err(|e| e.to_string())?; } else { storage .upsert_peer(&nid, &ip_addrs, None) .map_err(|e| e.to_string())?; } } node.follow(&nid).await.map_err(|e| e.to_string())?; node.sync_with_addr(addr).await.map_err(|e| e.to_string())?; let name = node.get_display_name(&nid).await.unwrap_or(None); let id_hex = hex::encode(nid); let label = name.unwrap_or_else(|| id_hex[..12.min(id_hex.len())].to_string()); Ok(format!("Connected and synced with {}", label)) } #[tauri::command] async fn follow_node(state: State<'_, AppState>, node_id_hex: String) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.follow(&nid).await.map_err(|e| e.to_string())?; // Auto-sync: pull posts from the followed peer in the background let node_clone = state.inner().clone(); tokio::spawn(async move { if let Err(e) = node_clone.sync_with(nid).await { tracing::debug!(error = %e, "Auto-sync after follow failed (peer may not be connected)"); } }); Ok(()) } #[tauri::command] async fn unfollow_node(state: State<'_, AppState>, node_id_hex: String) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.unfollow(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn list_follows(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let follows = node.list_follows().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(follows.len()); for nid in &follows { let display_name = match node.resolve_display_name(nid).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; // Try to get peer record for address info let storage = node.storage.lock().await; let rec = storage.get_peer_record(nid).ok().flatten(); drop(storage); let is_online = node.network.is_connected(nid).await || node.network.has_session(nid).await; let last_activity_ms = if let Some(arc) = node.network.conn_handle().get_peer_last_activity(nid).await { arc.load(std::sync::atomic::Ordering::Relaxed) } else { rec.as_ref().map(|r| r.last_seen).unwrap_or(0) }; dtos.push(PeerDto { node_id: hex::encode(nid), display_name, addresses: rec .as_ref() .map(|r| r.addresses.iter().map(|a| a.to_string()).collect()) .unwrap_or_default(), introduced_by: rec .as_ref() .and_then(|r| r.introduced_by.map(|ib| hex::encode(ib))), is_anchor: rec.as_ref().map(|r| r.is_anchor).unwrap_or(false), last_seen: rec.as_ref().map(|r| r.last_seen).unwrap_or(0), reach: String::new(), is_online, last_activity_ms, }); } Ok(dtos) } #[tauri::command] async fn list_peers(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let records = node .list_peer_records() .await .map_err(|e| e.to_string())?; // Build reach sets for classification let mesh_ids: std::collections::HashSet<_> = node .list_connections() .await .into_iter() .map(|(nid, _, _)| nid) .collect(); let (social_ids, n2_ids, n3_ids) = { let storage = node.storage.lock().await; let social: std::collections::HashSet<_> = storage .list_social_routes() .unwrap_or_default() .into_iter() .map(|r| r.node_id) .collect(); let n2: std::collections::HashSet<_> = storage .build_n2_share() .unwrap_or_default() .into_iter() .collect(); let n3: std::collections::HashSet<_> = storage .list_distinct_n3() .unwrap_or_default() .into_iter() .collect(); (social, n2, n3) }; let mut dtos = Vec::with_capacity(records.len()); for rec in &records { let display_name = match node.resolve_display_name(&rec.node_id).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; let introduced_by_name = if let Some(ib) = &rec.introduced_by { match node.resolve_display_name(ib).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, } } else { None }; let reach = if mesh_ids.contains(&rec.node_id) { "mesh" } else if social_ids.contains(&rec.node_id) { "n1" } else if n2_ids.contains(&rec.node_id) { "n2" } else if n3_ids.contains(&rec.node_id) { "n3" } else { "known" }; dtos.push(PeerDto { node_id: hex::encode(rec.node_id), display_name, addresses: rec.addresses.iter().map(|a| a.to_string()).collect(), introduced_by: rec.introduced_by.map(|ib| { introduced_by_name .clone() .unwrap_or_else(|| hex::encode(ib)[..12].to_string()) }), is_anchor: rec.is_anchor, last_seen: rec.last_seen, reach: reach.to_string(), is_online: mesh_ids.contains(&rec.node_id), last_activity_ms: rec.last_seen, }); } Ok(dtos) } #[tauri::command] async fn suggested_peers(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let records = node .list_peer_records() .await .map_err(|e| e.to_string())?; let follows = node.list_follows().await.map_err(|e| e.to_string())?; let follow_set: std::collections::HashSet<_> = follows.iter().collect(); let mut dtos = Vec::new(); for rec in &records { // Suggested = gossip-discovered (introduced_by is set) and not yet followed if rec.introduced_by.is_some() && !follow_set.contains(&rec.node_id) { let display_name = match node.resolve_display_name(&rec.node_id).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; let introduced_by_name = if let Some(ib) = &rec.introduced_by { match node.resolve_display_name(ib).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, } } else { None }; dtos.push(SuggestedPeerDto { node_id: hex::encode(rec.node_id), display_name, addresses: rec.addresses.iter().map(|a| a.to_string()).collect(), introduced_by_name, is_anchor: rec.is_anchor, }); } } Ok(dtos) } #[tauri::command] async fn list_circles(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let circles = node.list_circles().await.map_err(|e| e.to_string())?; Ok(circles .into_iter() .map(|c| CircleDto { name: c.name, members: c.members.iter().map(hex::encode).collect(), created_at: c.created_at, }) .collect()) } #[tauri::command] async fn create_circle(state: State<'_, AppState>, name: String) -> Result { let node = state.inner(); node.create_circle(name.clone()) .await .map_err(|e| e.to_string())?; Ok(CircleDto { name, members: vec![], created_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64, }) } #[tauri::command] async fn delete_circle(state: State<'_, AppState>, name: String) -> Result<(), String> { let node = state.inner(); node.delete_circle(name).await.map_err(|e| e.to_string()) } #[tauri::command] async fn add_circle_member( state: State<'_, AppState>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.add_to_circle(circle_name, nid) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn remove_circle_member( state: State<'_, AppState>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.remove_from_circle(circle_name, nid) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn delete_post(state: State<'_, AppState>, post_id_hex: String) -> Result<(), String> { let node = state.inner(); let post_id = parse_node_id(&post_id_hex)?; node.delete_post(&post_id).await.map_err(|e| e.to_string()) } #[tauri::command] async fn revoke_circle_access( state: State<'_, AppState>, circle_name: String, node_id_hex: String, mode: Option, ) -> Result { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; let rev_mode = match mode.as_deref() { Some("reencrypt") => itsgoin_core::types::RevocationMode::ReEncrypt, _ => itsgoin_core::types::RevocationMode::SyncAccessList, }; node.revoke_circle_access(&circle_name, &nid, rev_mode) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_redundancy_info(state: State<'_, AppState>) -> Result { let node = state.inner(); let (total, zero, one, two_plus) = node .get_redundancy_summary() .await .map_err(|e| e.to_string())?; Ok(RedundancyDto { total, zero_replicas: zero, one_replica: one, two_plus_replicas: two_plus, }) } #[tauri::command] async fn set_anchors( state: State<'_, AppState>, anchors: Vec, ) -> Result { let node = state.inner(); let anchor_ids: Vec = anchors .iter() .map(|h| parse_node_id(h)) .collect::, _>>()?; let profile = node .set_anchors(anchor_ids) .await .map_err(|e| e.to_string())?; Ok(ProfileDto { node_id: hex::encode(profile.node_id), display_name: profile.display_name, bio: profile.bio, anchors: profile.anchors.iter().map(hex::encode).collect(), }) } #[tauri::command] async fn list_anchor_peers(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let storage = node.storage.lock().await; let records = storage.list_anchor_peers().map_err(|e| e.to_string())?; drop(storage); let mut dtos = Vec::with_capacity(records.len()); for rec in &records { let display_name = node.get_display_name(&rec.node_id).await.unwrap_or(None); dtos.push(PeerDto { node_id: hex::encode(rec.node_id), display_name, addresses: rec.addresses.iter().map(|a| a.to_string()).collect(), introduced_by: rec.introduced_by.map(|ib| hex::encode(ib)), is_anchor: rec.is_anchor, last_seen: rec.last_seen, reach: String::new(), is_online: node.network.is_connected(&rec.node_id).await, last_activity_ms: rec.last_seen, }); } Ok(dtos) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct KnownAnchorDto { node_id: String, display_name: Option, addresses: Vec, } #[tauri::command] async fn list_known_anchors(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let storage = node.storage.lock().await; let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?; drop(storage); let mut dtos = Vec::with_capacity(anchors.len()); for (nid, addrs) in &anchors { let display_name = node.get_display_name(nid).await.unwrap_or(None); dtos.push(KnownAnchorDto { node_id: hex::encode(nid), display_name, addresses: addrs.iter().map(|a| a.to_string()).collect(), }); } Ok(dtos) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AudienceDto { node_id: String, display_name: Option, direction: String, status: String, requested_at: u64, approved_at: Option, } #[tauri::command] async fn list_audience(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let records = node .list_audience( itsgoin_core::types::AudienceDirection::Inbound, None, ) .await .map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(records.len()); for r in &records { let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None); let direction = match r.direction { itsgoin_core::types::AudienceDirection::Inbound => "inbound", itsgoin_core::types::AudienceDirection::Outbound => "outbound", }; let status = match r.status { itsgoin_core::types::AudienceStatus::Pending => "pending", itsgoin_core::types::AudienceStatus::Approved => "approved", itsgoin_core::types::AudienceStatus::Denied => "denied", }; dtos.push(AudienceDto { node_id: hex::encode(r.node_id), display_name, direction: direction.to_string(), status: status.to_string(), requested_at: r.requested_at, approved_at: r.approved_at, }); } Ok(dtos) } #[tauri::command] async fn list_audience_outbound(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let records = node .list_audience( itsgoin_core::types::AudienceDirection::Outbound, None, ) .await .map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(records.len()); for r in &records { let display_name = node.get_display_name(&r.node_id).await.unwrap_or(None); let status = match r.status { itsgoin_core::types::AudienceStatus::Pending => "pending", itsgoin_core::types::AudienceStatus::Approved => "approved", itsgoin_core::types::AudienceStatus::Denied => "denied", }; dtos.push(AudienceDto { node_id: hex::encode(r.node_id), display_name, direction: "outbound".to_string(), status: status.to_string(), requested_at: r.requested_at, approved_at: r.approved_at, }); } Ok(dtos) } #[tauri::command] async fn request_audience( state: State<'_, AppState>, node_id_hex: String, ) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.request_audience(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn approve_audience( state: State<'_, AppState>, node_id_hex: String, ) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.approve_audience(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn remove_audience( state: State<'_, AppState>, node_id_hex: String, ) -> Result<(), String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; node.remove_audience(&nid).await.map_err(|e| e.to_string()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct WormResultDto { node_id: String, addresses: Vec, reporter: String, freshness_ms: u64, /// If resolved through a needle_peer (recent peer of target), this is the needle's hex ID found_via: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ConnectionDto { node_id: String, display_name: Option, slot_kind: String, connected_at: u64, } #[tauri::command] async fn list_connections(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let conns = node.list_connections().await; let mut dtos = Vec::with_capacity(conns.len()); for (nid, slot_kind, connected_at) in conns { let display_name = node.get_display_name(&nid).await.unwrap_or(None); dtos.push(ConnectionDto { node_id: hex::encode(nid), display_name, slot_kind: format!("{:?}", slot_kind), connected_at, }); } Ok(dtos) } #[tauri::command] async fn worm_lookup( state: State<'_, AppState>, node_id_hex: String, ) -> Result, String> { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; match node.worm_lookup(&nid).await.map_err(|e| e.to_string())? { Some(wr) => { let found_via = if wr.node_id != nid { Some(hex::encode(wr.node_id)) } else { None }; Ok(Some(WormResultDto { node_id: hex::encode(nid), addresses: wr.addresses, reporter: hex::encode(wr.reporter), freshness_ms: wr.freshness_ms, found_via, })) } None => Ok(None), } } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SocialRouteDto { node_id: String, display_name: Option, addresses: Vec, peer_count: usize, relation: String, status: String, last_connected_ms: u64, last_seen_ms: u64, reach_method: String, } #[tauri::command] async fn list_social_routes(state: State<'_, AppState>) -> Result, String> { let node = state.inner(); let routes = node.list_social_routes().await.map_err(|e| e.to_string())?; let mut dtos = Vec::with_capacity(routes.len()); for r in &routes { let display_name = match node.resolve_display_name(&r.node_id).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; dtos.push(SocialRouteDto { node_id: hex::encode(r.node_id), display_name, addresses: r.addresses.iter().map(|a| a.to_string()).collect(), peer_count: r.peer_addresses.len(), relation: r.relation.to_string(), status: r.status.to_string(), last_connected_ms: r.last_connected_ms, last_seen_ms: r.last_seen_ms, reach_method: r.reach_method.to_string(), }); } Ok(dtos) } #[tauri::command] async fn export_identity(state: State<'_, AppState>) -> Result { let node = state.inner(); node.export_identity_hex().map_err(|e| e.to_string()) } #[tauri::command] async fn set_circle_profile( state: State<'_, AppState>, circle_name: String, display_name: String, bio: String, avatar_cid: Option, ) -> Result { let node = state.inner(); let avatar = match avatar_cid { Some(hex) => { let bytes = hex::decode(&hex).map_err(|e| e.to_string())?; Some(<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| "avatar_cid must be 32 bytes".to_string())?) } None => None, }; let cp = node .set_circle_profile(circle_name, display_name, bio, avatar) .await .map_err(|e| e.to_string())?; Ok(serde_json::json!({ "circleName": cp.circle_name, "displayName": cp.display_name, "bio": cp.bio, "avatarCid": cp.avatar_cid.map(hex::encode), "updatedAt": cp.updated_at, })) } #[tauri::command] async fn get_circle_profile( state: State<'_, AppState>, circle_name: String, ) -> Result, String> { let node = state.inner(); let cp = node .get_circle_profile(&circle_name) .await .map_err(|e| e.to_string())?; Ok(cp.map(|c| { serde_json::json!({ "circleName": c.circle_name, "displayName": c.display_name, "bio": c.bio, "avatarCid": c.avatar_cid.map(hex::encode), "updatedAt": c.updated_at, }) })) } #[tauri::command] async fn delete_circle_profile( state: State<'_, AppState>, circle_name: String, ) -> Result<(), String> { let node = state.inner(); node.delete_circle_profile(circle_name) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn set_public_visible( state: State<'_, AppState>, visible: bool, ) -> Result<(), String> { let node = state.inner(); node.set_public_visible(visible) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn resolve_display( state: State<'_, AppState>, node_id_hex: String, ) -> Result { let node = state.inner(); let nid = parse_node_id(&node_id_hex)?; let (display_name, bio, avatar_cid) = node .resolve_display_name(&nid) .await .map_err(|e| e.to_string())?; Ok(serde_json::json!({ "displayName": display_name, "bio": bio, "avatarCid": avatar_cid.map(hex::encode), })) } #[tauri::command] async fn get_public_visible( state: State<'_, AppState>, ) -> Result { let node = state.inner(); node.get_public_visible() .await .map_err(|e| e.to_string()) } #[tauri::command] async fn get_setting(state: State<'_, AppState>, key: String) -> Result, String> { let node = state.inner(); node.get_setting(&key).await.map_err(|e| e.to_string()) } #[tauri::command] async fn set_setting(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> { let node = state.inner(); node.set_setting(&key, &value).await.map_err(|e| e.to_string()) } #[tauri::command] async fn generate_share_link(state: State<'_, AppState>, post_id_hex: String) -> Result, String> { let node = state.inner(); let pid = parse_node_id(&post_id_hex)?; node.generate_share_link(&pid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn sync_all(state: State<'_, AppState>) -> Result { let node = state.inner(); node.sync_all().await.map_err(|e| e.to_string())?; Ok("Sync complete".to_string()) } #[tauri::command] async fn sync_from_peer(state: State<'_, AppState>, node_id_hex: String) -> Result { let node = state.inner(); let bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?; let nid: [u8; 32] = bytes.try_into().map_err(|_| "Invalid node ID length")?; node.sync_with(nid).await.map_err(|e| e.to_string())?; Ok("Sync complete".to_string()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct NetworkSummaryDto { preferred_count: usize, local_count: usize, wide_count: usize, total_connections: usize, n2_distinct: usize, n3_distinct: usize, } #[tauri::command] async fn get_network_summary(state: State<'_, AppState>) -> Result { let node = state.inner(); let conns = node.list_connections().await; let mut preferred = 0usize; let mut local = 0usize; let mut wide = 0usize; for (_nid, slot_kind, _at) in &conns { match slot_kind { PeerSlotKind::Preferred => preferred += 1, PeerSlotKind::Local => local += 1, PeerSlotKind::Wide => wide += 1, } } let (n2, n3) = { let storage = node.storage.lock().await; let n2 = storage.count_distinct_n2().unwrap_or(0); let n3 = storage.count_distinct_n3().unwrap_or(0); (n2, n3) }; Ok(NetworkSummaryDto { preferred_count: preferred, local_count: local, wide_count: wide, total_connections: conns.len(), n2_distinct: n2, n3_distinct: n3, }) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ActivityEventDto { timestamp_ms: u64, level: String, category: String, message: String, peer_id: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ActivityLogDto { events: Vec, rebalance_last_ms: u64, rebalance_interval_secs: u64, anchor_register_last_ms: u64, anchor_register_interval_secs: u64, } #[tauri::command] async fn get_activity_log(state: State<'_, AppState>) -> Result { let node = state.inner(); let events = node.get_activity_log(200); let (rebalance_last, anchor_last) = node.timer_state(); let dto_events: Vec = events.into_iter().map(|e| { ActivityEventDto { timestamp_ms: e.timestamp_ms, level: format!("{:?}", e.level).to_lowercase(), category: format!("{:?}", e.category).to_lowercase(), message: e.message, peer_id: e.peer_id.map(hex::encode), } }).collect(); Ok(ActivityLogDto { events: dto_events, rebalance_last_ms: rebalance_last, rebalance_interval_secs: 600, anchor_register_last_ms: anchor_last, anchor_register_interval_secs: 600, }) } #[tauri::command] async fn trigger_rebalance(state: State<'_, AppState>) -> Result { let node = state.inner(); node.network.rebalance().await.map_err(|e| e.to_string())?; let conns = node.list_connections().await; Ok(format!("Rebalance complete — {} connections", conns.len())) } #[tauri::command] async fn request_referrals(state: State<'_, AppState>) -> Result { let node = state.inner(); let node_id = node.node_id; // Try known_anchors table first (populated by anchor register cycle), // fall back to anchor peers from the peers table (is_anchor = true) let anchors: Vec<(NodeId, Vec)> = { let storage = node.storage.lock().await; let known = storage.list_known_anchors().unwrap_or_default(); if !known.is_empty() { known } else { storage.list_anchor_peers().unwrap_or_default() .into_iter() .map(|r| (r.node_id, r.addresses)) .collect() } }; if anchors.is_empty() { return Ok("No known anchors".to_string()); } let mut total = 0usize; let mut reachable = 0usize; for (anchor_nid, anchor_addrs) in &anchors { if *anchor_nid == node_id { continue; } // Connect to anchor if not already connected if !node.network.is_peer_connected(anchor_nid).await { let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) { Ok(eid) => eid, Err(_) => continue, }; let mut addr = itsgoin_core::EndpointAddr::from(endpoint_id); for sa in anchor_addrs { addr = addr.with_ip_addr(*sa); } if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await { continue; } } match node.network.request_anchor_referrals(anchor_nid).await { Ok(referrals) => { reachable += 1; for referral in &referrals { if referral.node_id == node_id { continue; } if let Some(addr_str) = referral.addresses.first() { let connect_str = format!( "{}@{}", hex::encode(referral.node_id), addr_str, ); if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) { match node.network.connect_to_peer(rid, raddr).await { Ok(()) => {} Err(_) => { // Direct connect failed (NAT) — try hole punch via anchor let _ = node.network.connect_via_introduction(rid, *anchor_nid).await; } } total += 1; } } } } Err(_) => {} } } Ok(format!("Got {} referrals from {} anchors", total, reachable)) } #[tauri::command] async fn reset_data(state: State<'_, AppState>) -> Result { let node = state.inner(); let sentinel = node.data_dir.join(".reset"); std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?; Ok("Reset scheduled. Restart the app to apply.".to_string()) } // --- Engagement IPC commands --- #[tauri::command] async fn react_to_post( state: State<'_, AppState>, post_id: String, emoji: String, private: bool, ) -> Result { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let reaction = node.react_to_post(pid, emoji, private).await.map_err(|e| e.to_string())?; Ok(ReactionDto { reactor: hex::encode(reaction.reactor), emoji: reaction.emoji, post_id: hex::encode(reaction.post_id), timestamp_ms: reaction.timestamp_ms, encrypted_payload: reaction.encrypted_payload, }) } #[tauri::command] async fn remove_reaction( state: State<'_, AppState>, post_id: String, emoji: String, ) -> Result<(), String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; node.remove_reaction(pid, emoji).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_reactions( state: State<'_, AppState>, post_id: String, ) -> Result, String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let reactions = node.get_reactions(pid).await.map_err(|e| e.to_string())?; Ok(reactions.into_iter().map(|r| ReactionDto { reactor: hex::encode(r.reactor), emoji: r.emoji, post_id: hex::encode(r.post_id), timestamp_ms: r.timestamp_ms, encrypted_payload: r.encrypted_payload, }).collect()) } #[tauri::command] async fn get_reaction_counts( state: State<'_, AppState>, post_id: String, ) -> Result, String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let counts = node.get_reaction_counts(pid).await.map_err(|e| e.to_string())?; Ok(counts.into_iter().map(|(emoji, count, reacted_by_me)| { ReactionCountDto { emoji, count, reacted_by_me } }).collect()) } #[tauri::command] async fn comment_on_post( state: State<'_, AppState>, post_id: String, content: String, ) -> Result { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let comment = node.comment_on_post(pid, content).await.map_err(|e| e.to_string())?; let author_name = match node.resolve_display_name(&comment.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; Ok(CommentDto { author: hex::encode(comment.author), author_name, post_id: hex::encode(comment.post_id), content: comment.content, timestamp_ms: comment.timestamp_ms, }) } #[tauri::command] async fn edit_comment( state: State<'_, AppState>, post_id: String, timestamp_ms: u64, new_content: String, ) -> Result<(), String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; node.edit_comment(pid, timestamp_ms, new_content).await.map_err(|e| e.to_string()) } #[tauri::command] async fn delete_comment( state: State<'_, AppState>, post_id: String, timestamp_ms: u64, ) -> Result<(), String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; node.delete_comment(pid, timestamp_ms).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_comments( state: State<'_, AppState>, post_id: String, ) -> Result, String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let comments = node.get_comments(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); for c in comments { let author_name = match node.resolve_display_name(&c.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; dtos.push(CommentDto { author: hex::encode(c.author), author_name, post_id: hex::encode(c.post_id), content: c.content, timestamp_ms: c.timestamp_ms, }); } Ok(dtos) } #[tauri::command] async fn set_comment_policy( state: State<'_, AppState>, post_id: String, allow_comments: String, allow_reacts: String, ) -> Result<(), String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let comment_perm = match allow_comments.as_str() { "audience_only" => itsgoin_core::types::CommentPermission::AudienceOnly, "none" => itsgoin_core::types::CommentPermission::None, _ => itsgoin_core::types::CommentPermission::Public, }; let react_perm = match allow_reacts.as_str() { "public" => itsgoin_core::types::ReactPermission::Public, "private" => itsgoin_core::types::ReactPermission::Private, "none" => itsgoin_core::types::ReactPermission::None, _ => itsgoin_core::types::ReactPermission::Both, }; let policy = itsgoin_core::types::CommentPolicy { allow_comments: comment_perm, allow_reacts: react_perm, ..Default::default() }; node.set_comment_policy(pid, policy).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_comment_policy( state: State<'_, AppState>, post_id: String, ) -> Result, String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let policy = node.get_comment_policy(pid).await.map_err(|e| e.to_string())?; Ok(policy.map(|p| CommentPolicyDto { allow_comments: format!("{:?}", p.allow_comments).to_lowercase(), allow_reacts: format!("{:?}", p.allow_reacts).to_lowercase(), moderation: format!("{:?}", p.moderation).to_lowercase(), blocklist: p.blocklist.iter().map(hex::encode).collect(), })) } #[tauri::command] async fn get_comment_thread( state: State<'_, AppState>, post_id: String, ) -> Result, String> { let node = state.inner(); let pid = hex_to_postid(&post_id)?; let comments = node.get_comment_thread(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); for c in comments { let author_name = match node.resolve_display_name(&c.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; dtos.push(CommentDto { author: hex::encode(c.author), author_name, post_id: hex::encode(c.post_id), content: c.content, timestamp_ms: c.timestamp_ms, }); } Ok(dtos) } fn hex_to_postid(hex_str: &str) -> Result { let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex: {}", e))?; if bytes.len() != 32 { return Err("post_id must be 32 bytes".to_string()); } let mut id = [0u8; 32]; id.copy_from_slice(&bytes); Ok(id) } // --- App setup --- #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()), ) .init(); // On desktop, create our own runtime. On mobile, Tauri provides one. let _rt_guard = if cfg!(target_os = "android") || cfg!(target_os = "ios") { None } else { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("Failed to create tokio runtime"); tauri::async_runtime::set(rt.handle().clone()); Some(rt) }; info!("Starting Tauri app"); tauri::Builder::default() .plugin(tauri_plugin_notification::init()) .setup(move |app| { // Desktop: store data next to the AppImage/executable so each copy // gets its own identity. Mobile: use the standard app data dir. let data_dir = if cfg!(target_os = "android") || cfg!(target_os = "ios") { app.path().app_data_dir()? } else { // APPIMAGE env var points to the .AppImage file itself let exe_dir = std::env::var("APPIMAGE") .ok() .and_then(|p| std::path::PathBuf::from(p).parent().map(|d| d.to_path_buf())) .or_else(|| std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()))); match exe_dir { Some(dir) => dir.join("itsgoin-data"), None => app.path().app_data_dir()?, } }; std::fs::create_dir_all(&data_dir)?; // Check for reset sentinel from previous session let sentinel = data_dir.join(".reset"); if sentinel.exists() { info!("Reset sentinel found — clearing data"); let _ = std::fs::remove_file(data_dir.join("itsgoin.db")); let _ = std::fs::remove_dir_all(data_dir.join("blobs")); let _ = std::fs::remove_file(&sentinel); } info!(data_dir = %data_dir.display(), "Opening node"); let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); let node = tauri::async_runtime::block_on(async { let n = if is_mobile { Node::open_mobile(&data_dir).await } else { Node::open(&data_dir).await }?; let n = Arc::new(n); // Start background networking (v2: mesh connections) // Must be inside block_on so tokio::spawn has a runtime context n.start_accept_loop(); n.start_pull_cycle(300); // 5 min pull cycle n.start_diff_cycle(120); // 2 min routing diff n.start_rebalance_cycle(600); // 10 min rebalance n.start_growth_loop(); // reactive mesh growth n.start_recovery_loop(); // reactive anchor reconnect on mesh loss n.start_social_checkin_cycle(3600); // 1 hour social checkin n.start_anchor_register_cycle(600); // 10 min anchor register n.start_upnp_renewal_cycle(); // UPnP lease renewal (if mapped) n.start_upnp_tcp_renewal_cycle(); // UPnP TCP lease renewal (for HTTP serving) n.start_http_server(); // HTTP post delivery (if publicly reachable) n.start_bootstrap_connectivity_check(); // 24h isolation check Ok::<_, anyhow::Error>(n) })?; app.manage(node); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_node_info, set_display_name, set_profile, create_post, create_post_with_files, get_blob, get_blob_path, save_and_open_blob, save_blob, get_feed, get_all_posts, get_stats, connect_peer, follow_node, unfollow_node, list_follows, list_peers, suggested_peers, list_circles, create_circle, delete_circle, add_circle_member, remove_circle_member, delete_post, revoke_circle_access, get_redundancy_info, set_anchors, list_anchor_peers, list_known_anchors, list_audience, list_audience_outbound, request_audience, approve_audience, remove_audience, list_connections, worm_lookup, list_social_routes, export_identity, set_circle_profile, get_circle_profile, delete_circle_profile, set_public_visible, resolve_display, get_public_visible, sync_all, sync_from_peer, get_network_summary, get_activity_log, trigger_rebalance, request_referrals, reset_data, react_to_post, remove_reaction, get_reactions, get_reaction_counts, comment_on_post, edit_comment, delete_comment, get_comments, set_comment_policy, get_comment_policy, get_comment_thread, get_setting, set_setting, generate_share_link, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { if let tauri::RunEvent::Resumed = event { // App resumed from background (mobile sleep/wake) — // probe connections and recover dead ones immediately if let Some(node) = app_handle.try_state::() { let node = node.inner().clone(); tauri::async_runtime::spawn(async move { let removed = node.network.wake_health_check().await; if removed > 0 { tracing::info!(removed, "Wake health check: removed dead connections"); } }); } } }); }