use std::sync::Arc; use serde::{Deserialize, Serialize}; use tauri::{Manager, State}; use tracing::info; use itsgoin_core::identity::IdentityManager; use itsgoin_core::node::Node; use itsgoin_core::types::{NodeId, PeerSlotKind, Post, PostId, PostVisibility, VisibilityIntent}; /// The active Node, swappable on identity switch. type AppNode = Arc>>; /// The identity manager for multi-identity operations. type AppIdentity = Arc>; /// Helper: get a clone of the active Node from the RwLock. async fn get_node(state: &State<'_, AppNode>) -> Arc { state.read().await.clone() } /// Helper: same as get_node but also returns a reference-like wrapper for &Node usage. macro_rules! node { ($state:expr) => {{ let _n = get_node($state).await; _n }}; } // --- 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, /// The original intent: "public", "friends", "circle", "direct", or "unknown" intent_kind: 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, /// If the post is authored by one of our held posting identities, the /// persona's display_name. None for posts authored by peers (or if the /// local persona has no display name). as_persona: Option, } #[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 ReceiptSlotDto { slot_index: u32, node_id: Option, state: String, timestamp_ms: u64, emoji: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CommentSlotDto { slot_index: u32, author: String, author_name: Option, timestamp_ms: u64, content: String, } #[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 BadgeCountsDto { new_feed: usize, new_engagement: usize, unread_messages: usize, new_reacts: usize, new_comments: 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, duplicate_detected: 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 { // "is_me" now means: authored by ANY posting identity we hold, not just the // network key. Covers the multi-persona case from 0.6.4+. let (is_me, as_persona) = { let s = node.storage.get().await; match s.get_posting_identity(&post.author) { Ok(Some(pi)) => { let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) }; (true, name) } _ => (false, None), } }; let author_name = match node.resolve_display_name(&post.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; // Resolve intent kind from storage let intent_kind = { let storage = node.storage.get().await; match storage.get_post_intent(id) { Ok(Some(intent)) => match intent { VisibilityIntent::Public => "public".to_string(), VisibilityIntent::Friends => "friends".to_string(), VisibilityIntent::Circle(_) => "circle".to_string(), VisibilityIntent::Direct(_) => "direct".to_string(), VisibilityIntent::Control => "control".to_string(), VisibilityIntent::Profile => "profile".to_string(), VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(), VisibilityIntent::Announcement => "announcement".to_string(), }, _ => "unknown".to_string(), } }; // For own encrypted posts, show "encrypted" not "encrypted-for-me" // since intent_kind handles DM filtering now let (visibility, decrypted_content) = match vis { PostVisibility::Public => ("public".to_string(), None), PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted { Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())), Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())), None => ("encrypted".to_string(), None), }, // FoF Layer 3: FoFClosed body. Decrypted is None from the sync // feed-pre-decrypt helper; the frontend calls read_fof_closed_body // for any post with visibility == "fof-closed" to fill in the body. PostVisibility::FoFClosed => ("fof-closed".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.get().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.get().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, visibility, intent_kind, decrypted_content, attachments, recipients, reaction_counts, comment_count, as_persona, } } /// Decrypt a just-created post for immediate display. The post was authored /// by one of our held posting identities (default or a specific persona); /// look up that identity's secret to decrypt. async fn decrypt_just_created( node: &Node, post: &Post, vis: &PostVisibility, ) -> Option { match vis { PostVisibility::Public => None, PostVisibility::Encrypted { recipients } => { let author_identity = { let s = node.storage.get().await; s.get_posting_identity(&post.author).ok().flatten() }?; itsgoin_core::crypto::decrypt_post( &post.content, &author_identity.secret_seed, &author_identity.node_id, &author_identity.node_id, recipients, ) .ok() .flatten() } PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => { let seed_info = { let storage = node.storage.get().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 } } // FoF Layer 3: FoFClosed body decrypt happens via the dedicated // async read_fof_closed_body command. This sync helper returns // None and the frontend dispatches the FoF read explicitly. PostVisibility::FoFClosed => 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<'_, AppNode>) -> Result { let node = get_node(&state).await; let node_id_hex = hex::encode(node.node_id); let addr = node.endpoint_addr(); // Prefer external address (UPnP, public IPv6, observed) over local bind address let external_addr = node.network.http_addr(); let observed_addr = if external_addr.is_none() { let storage = node.storage.get().await; storage.get_peer_record(&node.node_id).ok().flatten() .and_then(|r| r.addresses.first().map(|a| a.to_string())) } else { None }; let connect_string = if let Some(ext) = external_addr.or(observed_addr) { format!("{}@{}", node_id_hex, ext) } else 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(), duplicate_detected: node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed), anchors, }) } #[tauri::command] async fn set_display_name( state: State<'_, AppNode>, name: String, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, name: String, bio: String, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, posting_id_hex: Option, ) -> Result { let node = get_node(&state).await; 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) = match posting_id_hex { Some(pid_hex) => { let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?; node.create_post_as(&pid, content, intent, vec![]).await } None => 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<'_, AppNode>, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, files: Vec<(String, String)>, posting_id_hex: Option, ) -> Result { let node = get_node(&state).await; 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) = match posting_id_hex { Some(pid_hex) => { let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?; node.create_post_as(&pid, content, intent, attachment_data).await } None => 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) } /// Posting identity DTO for IPC. #[derive(serde::Serialize)] struct PostingIdentityDto { #[serde(rename = "nodeId")] node_id: String, #[serde(rename = "displayName")] display_name: String, #[serde(rename = "createdAt")] created_at: u64, #[serde(rename = "isDefault")] is_default: bool, } #[tauri::command] async fn list_posting_identities( state: State<'_, AppNode>, ) -> Result, String> { let node = get_node(&state).await; let identities = node.list_posting_identities().await.map_err(|e| e.to_string())?; let default = { let s = node.storage.get().await; s.get_default_posting_id().ok().flatten() }; Ok(identities .into_iter() .map(|id| PostingIdentityDto { node_id: hex::encode(id.node_id), display_name: id.display_name, created_at: id.created_at, is_default: Some(id.node_id) == default, }) .collect()) } #[tauri::command] async fn create_posting_identity( state: State<'_, AppNode>, display_name: String, ) -> Result { let node = get_node(&state).await; let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?; Ok(PostingIdentityDto { node_id: hex::encode(id.node_id), display_name: id.display_name, created_at: id.created_at, is_default: false, }) } #[tauri::command] async fn delete_posting_identity( state: State<'_, AppNode>, node_id_hex: String, ) -> Result<(), String> { let node = get_node(&state).await; let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?; node.delete_posting_identity(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn set_default_posting_identity( state: State<'_, AppNode>, node_id_hex: String, ) -> Result<(), String> { let node = get_node(&state).await; let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?; node.set_default_posting_identity(&nid).await.map_err(|e| e.to_string()) } /// Create a post signed by a specific posting identity instead of the default. #[tauri::command] async fn create_post_as( state: State<'_, AppNode>, posting_id_hex: String, content: String, visibility: Option, circle_name: Option, recipient_hex: Option, ) -> Result { let node = get_node(&state).await; let posting_id = itsgoin_core::parse_node_id_hex(&posting_id_hex).map_err(|e| e.to_string())?; 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_as(&posting_id, 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) } /// Return the filesystem path of a blob if it exists locally (for streaming video/media). /// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served /// via the asset protocol — the frontend must use IPC-based get_blob instead. #[tauri::command] async fn get_blob_path( state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, ) -> Result, String> { let node = get_node(&state).await; 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())?; // If a post_id is provided, check if the post is encrypted — if so, can't serve raw file if let Some(ref pid_hex) = post_id_hex { if let Ok(pid_bytes) = hex::decode(pid_hex) { if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) { let storage = node.storage.get().await; if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) { if !matches!(vis, PostVisibility::Public) { return Ok(None); } } } } } Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string())) } /// Sanitize a filename for safe download: strip path separators and control characters. fn sanitize_download_filename(filename: &str) -> String { filename .replace(['/', '\\', '\0'], "_") .chars() .filter(|c| !c.is_control()) .collect::() } /// Helper: resolve a blob with optional decryption via post context. /// First tries local (with post-based decryption), then falls back to network fetch + decrypt. async fn resolve_blob_data( node: &Node, cid: &[u8; 32], post_id_hex: Option<&str>, ) -> Result, String> { // Parse post_id if provided let post_id = if let Some(pid_hex) = post_id_hex { let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?; Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?) } else { None }; // Try local blob with decryption if let Some(ref pid) = post_id { if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? { return Ok(data); } } else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? { return Ok(data); } // Try fetching from network if post_id provided if let Some(pid) = post_id { let post = { let storage = node.storage.get().await; storage.get_post(&pid).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"); if let Some(_fetched) = node .fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms) .await .map_err(|e| e.to_string())? { // Re-read with decryption if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? { return Ok(data); } } } } Err("blob not found".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<'_, AppNode>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { let node = get_node(&state).await; 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 = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; let safe_name = sanitize_download_filename(&filename); // Save to Downloads — use app cache dir on Android (no access to shared storage without SAF) let downloads = get_writable_download_dir(&node); std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?; let dest = downloads.join(&safe_name); 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()) } /// Get a writable directory for downloads/exports. /// On desktop: ~/Downloads. On Android: app data dir + "exports". fn get_writable_download_dir(node: &Node) -> std::path::PathBuf { #[cfg(target_os = "android")] { node.data_dir.join("exports") } #[cfg(not(target_os = "android"))] { let _ = node; dirs::download_dir() .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) .unwrap_or_else(|| std::path::PathBuf::from("/tmp")) } } /// Save a blob to Downloads without opening it. #[tauri::command] async fn save_blob( state: State<'_, AppNode>, cid_hex: String, post_id_hex: Option, filename: String, ) -> Result { let node = get_node(&state).await; 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 = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; let safe_name = sanitize_download_filename(&filename); let downloads = get_writable_download_dir(&node); std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?; let dest = downloads.join(&safe_name); 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<'_, AppNode>, cid_hex: String, post_id_hex: Option, ) -> Result { let node = get_node(&state).await; 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 = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?; use base64::Engine; Ok(base64::engine::general_purpose::STANDARD.encode(&data)) } #[tauri::command] async fn get_feed(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct FeedPageDto { posts: Vec, has_more: bool, oldest_ms: Option, } #[tauri::command] async fn get_feed_page( state: State<'_, AppNode>, before_ms: Option, limit: Option, ) -> Result { let node = get_node(&state).await; let page_size = limit.unwrap_or(20); // Fetch one extra to know if there are more let posts = node.get_feed_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?; let has_more = posts.len() > page_size; let page: Vec<_> = posts.into_iter().take(page_size).collect(); let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms); let dtos = post_to_dto_batch(&page, &node).await; Ok(FeedPageDto { posts: dtos, has_more, oldest_ms }) } #[tauri::command] async fn get_all_posts_page( state: State<'_, AppNode>, before_ms: Option, limit: Option, ) -> Result { let node = get_node(&state).await; let page_size = limit.unwrap_or(20); let posts = node.get_all_posts_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?; let has_more = posts.len() > page_size; let page: Vec<_> = posts.into_iter().take(page_size).collect(); let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms); let dtos = post_to_dto_batch(&page, &node).await; Ok(FeedPageDto { posts: dtos, has_more, oldest_ms }) } /// Batched DTO assembly: 3 bulk queries instead of 4 per post async fn post_to_dto_batch( posts: &[(itsgoin_core::types::PostId, itsgoin_core::types::Post, itsgoin_core::types::PostVisibility, Option)], node: &Node, ) -> Vec { use std::collections::HashMap; if posts.is_empty() { return vec![]; } let post_ids: Vec = posts.iter().map(|(id, _, _, _)| *id).collect(); // Batch queries — 3 queries total instead of 4 × N let (reaction_map, comment_map, intent_map, posting_identities) = { let storage = node.storage.get().await; let reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default(); let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default(); let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default(); let identities = storage.list_posting_identities().unwrap_or_default(); (reactions, comments, intents, identities) }; // Map posting-id -> display-name so we can tag author=persona posts. let persona_names: HashMap> = posting_identities .into_iter() .map(|pi| { let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) }; (pi.node_id, name) }) .collect(); // Batch resolve display names let mut name_cache: HashMap> = HashMap::new(); let mut dtos = Vec::with_capacity(posts.len()); for (id, post, vis, decrypted) in posts { let (is_me, as_persona) = match persona_names.get(&post.author) { Some(name) => (true, name.clone()), None => (false, None), }; let author_name = if let Some(cached) = name_cache.get(&post.author) { cached.clone() } else { let name = match node.resolve_display_name(&post.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; name_cache.insert(post.author, name.clone()); name }; let intent_kind = if let Some(intent_json) = intent_map.get(id) { match serde_json::from_str::(intent_json) { Ok(VisibilityIntent::Public) => "public", Ok(VisibilityIntent::Friends) => "friends", Ok(VisibilityIntent::Circle(_)) => "circle", Ok(VisibilityIntent::Direct(_)) => "direct", _ => "unknown", } } else { "unknown" }.to_string(); let (visibility, decrypted_content) = match vis { PostVisibility::Public => ("public".to_string(), None), PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted { Some(text) if is_me => ("encrypted".to_string(), Some(text.clone())), Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())), None => ("encrypted".to_string(), None), }, PostVisibility::FoFClosed => ("fof-closed".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(); let reaction_counts = reaction_map.get(id).cloned().unwrap_or_default() .into_iter() .map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me }) .collect(); let comment_count = comment_map.get(id).copied().unwrap_or(0); dtos.push(PostDto { id: hex::encode(id), author: hex::encode(post.author), author_name, content: post.content.clone(), timestamp_ms: post.timestamp_ms, is_me, visibility, intent_kind, decrypted_content, attachments, recipients, reaction_counts, comment_count, as_persona: as_persona.clone(), }); } dtos } #[tauri::command] async fn get_all_posts(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, connect_string: String, ) -> Result { let node = get_node(&state).await; 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.get().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<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; 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 (15s timeout) let node_clone = get_node(&state).await; tokio::spawn(async move { match tokio::time::timeout( std::time::Duration::from_secs(15), node_clone.sync_with(nid), ).await { Ok(Ok(())) => {} Ok(Err(e)) => tracing::debug!(error = %e, "Auto-sync after follow failed"), Err(_) => tracing::debug!("Auto-sync after follow timed out (15s)"), } }); Ok(()) } #[tauri::command] async fn unfollow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.unfollow(&nid).await.map_err(|e| e.to_string()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct DiscoverProfileDto { node_id: String, display_name: String, bio: String, updated_at_ms: u64, has_avatar: bool, } /// Named peers we could follow, derived from signed profile posts we've /// received via the CDN. Filters out self, follows, ignores, and unnamed /// profiles. Replaces the old network-presence "Discover" path which /// depended on peer liveness. #[tauri::command] async fn list_discover(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let profiles = node.list_discoverable_profiles().await.map_err(|e| e.to_string())?; Ok(profiles.into_iter().map(|p| DiscoverProfileDto { node_id: hex::encode(p.node_id), display_name: p.display_name, bio: p.bio, updated_at_ms: p.updated_at, has_avatar: p.avatar_cid.is_some(), }).collect()) } #[tauri::command] async fn ignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.ignore_peer(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn unignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.unignore_peer(&nid).await.map_err(|e| e.to_string()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct IgnoredPeerDto { node_id: String, display_name: Option, } #[tauri::command] async fn list_ignored_peers(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let ids = node.list_ignored_peers().await.map_err(|e| e.to_string())?; let mut out = Vec::with_capacity(ids.len()); for nid in &ids { let display_name = match node.resolve_display_name(nid).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; out.push(IgnoredPeerDto { node_id: hex::encode(nid), display_name }); } Ok(out) } // --- FoF Layer 1: Vouches --- #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct VouchGivenDto { node_id: String, display_name: String, granted_at_ms: u64, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct VouchReceivedDto { node_id: String, display_name: String, epoch: u32, received_at_ms: u64, } #[tauri::command] async fn vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.vouch_for_peer(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&node_id_hex)?; node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn list_vouches_given(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?; Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto { node_id: hex::encode(nid), display_name: name, granted_at_ms: at, }).collect()) } #[tauri::command] async fn list_vouches_received(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?; Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto { node_id: hex::encode(nid), display_name: name, epoch, received_at_ms: at, }).collect()) } // --- FoF Layer 2: comment-gated post + commenting --- #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct FoFPostCreatedDto { post_id: String, } #[tauri::command] async fn create_post_with_fof_comments( state: State<'_, AppNode>, content: String, ) -> Result { let node = get_node(&state).await; let (post_id, _post, _vis, _cek) = node .create_post_with_fof_comments(content, vec![]) .await .map_err(|e| e.to_string())?; Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) } #[tauri::command] async fn comment_on_fof_post( state: State<'_, AppNode>, post_id_hex: String, body: String, ) -> Result<(), String> { let node = get_node(&state).await; let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32] node.comment_on_fof_post(pid, body).await .map(|_| ()) .map_err(|e| e.to_string()) } #[tauri::command] async fn revoke_fof_commenter( state: State<'_, AppNode>, post_id_hex: String, pub_x_index: u32, reason_code: u8, ) -> Result<(), String> { let node = get_node(&state).await; let pid = parse_node_id(&post_id_hex)?; node.revoke_fof_commenter(pid, pub_x_index, reason_code).await .map_err(|e| e.to_string()) } // FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments. #[tauri::command] async fn create_post_fof_closed( state: State<'_, AppNode>, content: String, ) -> Result { let node = get_node(&state).await; let (post_id, _post, _cek) = node .create_post_fof_closed(content) .await .map_err(|e| e.to_string())?; Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) }) } /// Returns the decrypted body of a FoFClosed post if any local persona /// can unlock it. `None` means "ciphertext only" (not in the FoF set). #[tauri::command] async fn read_fof_closed_body( state: State<'_, AppNode>, post_id_hex: String, ) -> Result, String> { let node = get_node(&state).await; let pid = parse_node_id(&post_id_hex)?; node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string()) } // FoF Layer 4: V_me lifecycle + cascade + key-burn. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct VmeRotatedDto { new_epoch: u32, } #[tauri::command] async fn rotate_v_me(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?; Ok(VmeRotatedDto { new_epoch }) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CascadeRevokeResultDto { posts_revoked: usize, } #[tauri::command] async fn cascade_revoke_v_me_epoch( state: State<'_, AppNode>, retired_epoch: u32, reason_code: u8, ) -> Result { let node = get_node(&state).await; let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code) .await .map_err(|e| e.to_string())?; Ok(CascadeRevokeResultDto { posts_revoked: n }) } #[tauri::command] async fn key_burn_post_slot( state: State<'_, AppNode>, post_id_hex: String, slot_index: u32, new_v_x_hex: String, ) -> Result<(), String> { let node = get_node(&state).await; let pid = parse_node_id(&post_id_hex)?; let new_v_x_bytes = hex::decode(&new_v_x_hex) .map_err(|e| format!("invalid new_v_x hex: {}", e))?; let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into() .map_err(|_| "new_v_x must be 32 bytes".to_string())?; node.key_burn_post_slot(pid, slot_index, &new_v_x).await .map_err(|e| e.to_string()) } #[tauri::command] async fn list_follows(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let follows = node.list_follows().await.map_err(|e| e.to_string())?; // v0.6.2: since posting identities are anonymized from network // presence, "last activity" is now the last post we hold from that // author rather than the network peer last-seen timestamp. One batch // query for all follows. let activity = node.last_activity_for_follows().await.unwrap_or_default(); 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.get().await; let rec = storage.get_peer_record(nid).ok().flatten(); drop(storage); // Online presence is no longer reliable post-v0.6.1 (network id // is decoupled from posting id we're showing). Frontend should // treat this as a soft hint rather than truth. let is_online = node.network.is_connected(nid).await || node.network.has_session(nid).await; let last_activity_ms = *activity.get(nid).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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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.get().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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, name: String) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, name: String) -> Result<(), String> { let node = get_node(&state).await; node.delete_circle(name).await.map_err(|e| e.to_string()) } #[tauri::command] async fn add_circle_member( state: State<'_, AppNode>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, circle_name: String, node_id_hex: String, ) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id_hex: String) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, circle_name: String, node_id_hex: String, mode: Option, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, anchors: Vec, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let storage = node.storage.get().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, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ReleaseAnnouncementDto { channel: String, version: String, title: String, body: String, download_url: String, timestamp_ms: u64, is_newer_than_current: bool, } /// Check for a newer release on the user's selected update channel. /// Returns None if no announcement is stored for that channel or the /// stored announcement isn't actually newer than the running build. #[tauri::command] async fn check_release_announcement( state: State<'_, AppNode>, channel: String, ) -> Result, String> { let node = get_node(&state).await; let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?; let Some(stored) = stored else { return Ok(None); }; let Some(release) = stored.content.release.as_ref() else { return Ok(None); }; let current = env!("CARGO_PKG_VERSION"); let is_newer = version_is_newer(&release.version, current); if !is_newer { return Ok(None); } Ok(Some(ReleaseAnnouncementDto { channel: release.channel.clone(), version: release.version.clone(), title: stored.content.title.clone(), body: stored.content.body.clone(), download_url: release.download_url.clone(), timestamp_ms: stored.content.timestamp_ms, is_newer_than_current: true, })) } /// Simple dotted-semver compare. Returns true if `a` > `b`. /// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta" /// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3"). fn version_is_newer(a: &str, b: &str) -> bool { fn parse(v: &str) -> (Vec, bool) { let (core, pre) = match v.split_once('-') { Some((c, _)) => (c, true), None => (v, false), }; let nums: Vec = core.split('.').filter_map(|p| p.parse().ok()).collect(); (nums, pre) } let (na, pa) = parse(a); let (nb, pb) = parse(b); let len = na.len().max(nb.len()); for i in 0..len { let ai = *na.get(i).unwrap_or(&0); let bi = *nb.get(i).unwrap_or(&0); if ai != bi { return ai > bi; } } // Cores equal — a stable (no pre) outranks a pre-release. !pa && pb } /// Read the user's preferred update channel from local settings. /// Defaults to "stable" if never set. #[tauri::command] async fn get_update_channel(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let storage = node.storage.get().await; Ok(storage .get_setting("ui_update_channel") .map_err(|e| e.to_string())? .unwrap_or_else(|| "stable".to_string())) } /// Return the hex id of the disposable fresh-install persona (if it still /// exists and hasn't been claimed). Frontend uses this to filter the blank /// starter out of the Personas list so the user never sees a "ghost" /// persona waiting between install and their first-run choice. /// Returns an empty string when there's no disposable persona to hide. #[tauri::command] async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let storage = node.storage.get().await; Ok(storage.get_setting("first_run_auto_persona_id") .map_err(|e| e.to_string())? .unwrap_or_default()) } /// Persist the user's preferred update channel. #[tauri::command] async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> { if channel != "stable" && channel != "beta" { return Err(format!("invalid channel: {}", channel)); } let node = get_node(&state).await; let storage = node.storage.get().await; storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string()) } /// Open a URL in the user's default system browser. /// Desktop: spawns the platform opener (xdg-open / open / cmd start). /// Only https:// URLs are accepted to avoid being a generic command exec. /// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener /// when we need the banner to work on mobile. #[tauri::command] fn open_url_external(url: String) -> Result<(), String> { if !url.starts_with("https://") && !url.starts_with("http://") { return Err("only http(s) urls are allowed".to_string()); } #[cfg(target_os = "linux")] let res = std::process::Command::new("xdg-open").arg(&url).spawn(); #[cfg(target_os = "macos")] let res = std::process::Command::new("open").arg(&url).spawn(); #[cfg(target_os = "windows")] let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn(); #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let res: std::io::Result = Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "open_url_external not supported on this platform", )); res.map(|_| ()).map_err(|e| e.to_string()) } #[tauri::command] async fn list_known_anchors(state: State<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; let storage = node.storage.get().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 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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, node_id_hex: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; node.export_identity_hex().map_err(|e| e.to_string()) } #[tauri::command] async fn set_circle_profile( state: State<'_, AppNode>, circle_name: String, display_name: String, bio: String, avatar_cid: Option, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, circle_name: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, circle_name: String, ) -> Result<(), String> { let node = get_node(&state).await; node.delete_circle_profile(circle_name) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn set_public_visible( state: State<'_, AppNode>, visible: bool, ) -> Result<(), String> { let node = get_node(&state).await; node.set_public_visible(visible) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn resolve_display( state: State<'_, AppNode>, node_id_hex: String, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, ) -> Result { let node = get_node(&state).await; node.get_public_visible() .await .map_err(|e| e.to_string()) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CacheStatsDto { used_bytes: u64, max_bytes: u64, blob_count: u64, } #[tauri::command] async fn send_notification(title: String, body: String) -> Result<(), String> { #[cfg(not(target_os = "android"))] { let _ = notify_rust::Notification::new() .summary(&title) .body(&body) .appname("ItsGoin") .show(); } Ok(()) } #[tauri::command] async fn get_cache_stats(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; let (used, max, count) = node.get_cache_stats().await.map_err(|e| e.to_string())?; Ok(CacheStatsDto { used_bytes: used, max_bytes: max, blob_count: count, }) } #[tauri::command] async fn get_setting(state: State<'_, AppNode>, key: String) -> Result, String> { let node = get_node(&state).await; node.get_setting(&key).await.map_err(|e| e.to_string()) } #[tauri::command] async fn set_setting(state: State<'_, AppNode>, key: String, value: String) -> Result<(), String> { let node = get_node(&state).await; node.set_setting(&key, &value).await.map_err(|e| e.to_string()) } #[tauri::command] async fn mark_post_seen( state: State<'_, AppNode>, post_id: String, react_count: u32, comment_count: u32, ) -> Result<(), String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.set_seen_engagement(&pid, react_count, comment_count).await.map_err(|e| e.to_string()) } #[tauri::command] async fn mark_conversation_read( state: State<'_, AppNode>, partner_id: String, ) -> Result<(), String> { let node = get_node(&state).await; let nid = parse_node_id(&partner_id)?; let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); node.set_last_read_message(&nid, now_ms).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_seen_engagement( state: State<'_, AppNode>, post_id: String, ) -> Result { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let (rc, cc) = node.get_seen_engagement(&pid).await.map_err(|e| e.to_string())?; Ok(serde_json::json!({ "seenReactCount": rc, "seenCommentCount": cc, })) } #[tauri::command] async fn get_badge_counts( state: State<'_, AppNode>, last_feed_view_ms: u64, ) -> Result { let node = get_node(&state).await; let storage = node.storage.get().await; // Feed badge: count non-DM posts from others newer than last_feed_view_ms let feed_posts = storage.get_feed().map_err(|e| e.to_string())?; let new_feed = feed_posts.iter() .filter(|(id, p, _vis)| { p.author != node.node_id && p.timestamp_ms > last_feed_view_ms && !matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) }) .count(); // My Posts badge: count own non-DM posts with unseen engagement let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?; let mut new_engagement = 0usize; for (id, post, _vis) in &all_posts { if post.author != node.node_id { continue; } // Skip DMs if matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) { continue; } let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) .unwrap_or_default() .iter() .map(|(_, count, _)| *count) .sum(); let total_comments = storage.get_comment_count(id).unwrap_or(0); if total_reacts > 0 || total_comments > 0 { let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 || total_comments > seen_c as u64 { new_engagement += 1; } } } // Unread messages: count conversations with messages newer than last_read let mut unread_messages = 0usize; let dm_posts = all_posts.iter().filter(|(id, p, _)| { matches!( storage.get_post_intent(id).ok().flatten(), Some(VisibilityIntent::Direct(_)) ) || (p.author != node.node_id && matches!( storage.get_post_with_visibility(id).ok().flatten(), Some((_, PostVisibility::Encrypted { .. })) )) }); let mut seen_partners = std::collections::HashSet::new(); for (_id, post, _vis) in dm_posts { let partner = if post.author == node.node_id { // sent DM — skip for unread count continue; } else { post.author }; if seen_partners.contains(&partner) { continue; } seen_partners.insert(partner); let last_read = storage.get_last_read_message(&partner).unwrap_or(0); if post.timestamp_ms > last_read { unread_messages += 1; } } // Count new reacts and comments separately let mut new_reacts = 0usize; let mut new_comments = 0usize; for (id, post, _vis) in &all_posts { if post.author != node.node_id { continue; } let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id) .unwrap_or_default().iter().map(|(_, c, _)| *c).sum(); let total_comments = storage.get_comment_count(id).unwrap_or(0); let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0)); if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; } if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; } } Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments }) } #[tauri::command] async fn get_last_read_message( state: State<'_, AppNode>, partner_id_hex: String, ) -> Result { let node = get_node(&state).await; let nid = parse_node_id(&partner_id_hex)?; node.get_last_read_message(&nid).await.map_err(|e| e.to_string()) } #[tauri::command] async fn generate_share_link(state: State<'_, AppNode>, post_id_hex: String) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; node.sync_all().await.map_err(|e| e.to_string())?; Ok("Sync complete".to_string()) } #[tauri::command] async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result { let node = get_node(&state).await; 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, has_public_v6: bool, has_public_v4: bool, has_upnp: bool, } #[tauri::command] async fn get_network_summary(state: State<'_, AppNode>) -> Result { let node = get_node(&state).await; 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.get().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, has_public_v6: node.network.has_public_v6(), has_public_v4: node.network.is_anchor(), has_upnp: node.network.has_upnp(), }) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct OurInfoDto { node_id: String, addresses: Vec, nat_type: String, device_role: String, upnp: bool, http_capable: bool, http_addr: Option, } #[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 pick_file(app: tauri::AppHandle, title: String, filter_name: Option, filter_ext: Option>) -> Result, String> { #[cfg(not(any(target_os = "android", target_os = "ios")))] { use tauri_plugin_dialog::DialogExt; let mut builder = app.dialog().file().set_title(&title); if let (Some(name), Some(exts)) = (filter_name, filter_ext) { let ext_refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect(); builder = builder.add_filter(&name, &ext_refs); } let path = builder.blocking_pick_file(); Ok(path.map(|p| p.to_string())) } #[cfg(target_os = "android")] { // Android: SAF "open document" dialog. The dialog returns a content // URI, not a filesystem path, so we read the bytes via the plugin // and stage them in the app's private cache so existing import code // (which expects a path) can read the file normally. let _ = (title,); use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs}; let mime_types: Vec<&str> = match filter_ext.as_deref() { Some(exts) if exts.iter().any(|e| e == "zip") => vec!["application/zip", "application/octet-stream"], _ => vec!["*/*"], }; let api = app.android_fs(); let uris = api.show_open_file_dialog(None, &mime_types, false) .map_err(|e| format!("Open dialog failed: {}", e))?; let uri = match uris.into_iter().next() { Some(u) => u, None => return Ok(None), }; let data = api.read(&uri).map_err(|e| format!("Read failed: {}", e))?; // Stage in private cache so import_* can open it by path. let cache_dir = app.path().app_cache_dir() .map_err(|e| format!("no cache dir: {}", e))?; std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?; // Name includes a timestamp so repeated picks don't clobber. let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let filename = match filter_ext.as_deref() { Some(exts) if exts.iter().any(|e| e == "zip") => format!("import-{}.zip", stamp), _ => format!("import-{}", stamp), }; let dest = cache_dir.join(filename); std::fs::write(&dest, &data).map_err(|e| format!("Stage write failed: {}", e))?; Ok(Some(dest.to_string_lossy().to_string())) } #[cfg(target_os = "ios")] { let _ = (app, title, filter_name, filter_ext); Ok(None) } } #[tauri::command] async fn pick_folder(app: tauri::AppHandle, title: String) -> Result, String> { #[cfg(not(any(target_os = "android", target_os = "ios")))] { use tauri_plugin_dialog::DialogExt; let path = app.dialog().file().set_title(&title).blocking_pick_folder(); Ok(path.map(|p| p.to_string())) } #[cfg(any(target_os = "android", target_os = "ios"))] { let _ = (app, title); Ok(None) } } #[tauri::command] async fn get_our_info(state: State<'_, AppNode>) -> Result { 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 = 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 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, }); } // Add peer-observed external address (from anchor's your_observed_addr) if let Some(observed) = net.conn_handle().observed_external_addr().await { let s = observed.to_string(); if !addresses.iter().any(|a| a.addr == s) { let family = if observed.ip().is_ipv4() { "IPv4" } else { "IPv6" }; addresses.insert(0, AddressInfoDto { addr: s, family: family.to_string(), status: classify_addr(&observed, &nat_type, has_upnp, bind_addr.is_some(), true), }); } } 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)] #[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<'_, AppNode>) -> Result { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; 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<'_, AppNode>) -> Result { let node = get_node(&state).await; 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.get().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<'_, AppNode>) -> Result { let node = get_node(&state).await; // Write the sentinel at the APP-level data_dir (parent of the active // identity's dir). The startup sentinel check runs at the same level. // Earlier versions wrote to node.data_dir which is the identity subdir, // making the check miss on Android. let app_data_dir = node.data_dir.parent() .ok_or_else(|| "no parent data dir".to_string())? .to_path_buf(); let sentinel = app_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<'_, AppNode>, post_id: String, emoji: String, private: bool, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, emoji: String, ) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, content: String, ) -> Result { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, timestamp_ms: u64, new_content: String, ) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, timestamp_ms: u64, ) -> Result<(), String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, allow_comments: String, allow_reacts: String, ) -> Result<(), String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let comment_perm = match allow_comments.as_str() { "followers_only" | "audience_only" => itsgoin_core::types::CommentPermission::FollowersOnly, "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<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; 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<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; 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) } #[tauri::command] async fn write_message_receipt( state: State<'_, AppNode>, post_id: String, receipt_state: String, emoji: Option, ) -> Result<(), String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let state_val = itsgoin_core::types::ReceiptState::from_str_label(&receipt_state); node.write_receipt_slot(pid, state_val, emoji).await.map_err(|e| e.to_string()) } #[tauri::command] async fn write_message_comment( state: State<'_, AppNode>, post_id: String, content: String, ) -> Result<(), String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; node.write_comment_slot(pid, content).await.map_err(|e| e.to_string()) } #[tauri::command] async fn get_message_receipts( state: State<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let slots = node.read_receipt_slots(pid).await.map_err(|e| e.to_string())?; Ok(slots.into_iter().map(|s| ReceiptSlotDto { slot_index: s.slot_index, node_id: s.node_id.map(hex::encode), state: s.state.as_str().to_string(), timestamp_ms: s.timestamp_ms, emoji: s.emoji, }).collect()) } #[tauri::command] async fn get_message_comments( state: State<'_, AppNode>, post_id: String, ) -> Result, String> { let node = get_node(&state).await; let pid = hex_to_postid(&post_id)?; let slots = node.read_comment_slots(pid).await.map_err(|e| e.to_string())?; let mut dtos = Vec::new(); for s in slots { let author_name = match node.resolve_display_name(&s.author).await { Ok((name, _, _)) if !name.is_empty() => Some(name), _ => None, }; dtos.push(CommentSlotDto { slot_index: s.slot_index, author: hex::encode(s.author), author_name, timestamp_ms: s.timestamp_ms, content: s.content, }); } 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 --- // --- Identity management IPC commands --- #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct IdentityInfoDto { node_id: String, display_name: String, created_at: u64, last_used_at: u64, is_active: bool, } #[tauri::command] async fn list_identities(state: State<'_, AppIdentity>) -> Result, String> { let mgr = state.lock().await; let active_id = mgr.active_id(); let identities = mgr.list_identities().map_err(|e| e.to_string())?; Ok(identities.into_iter().map(|i| IdentityInfoDto { is_active: active_id.map_or(false, |a| a == i.node_id), node_id: i.node_id_hex, display_name: i.display_name, created_at: i.created_at, last_used_at: i.last_used_at, }).collect()) } #[tauri::command] async fn create_identity(state: State<'_, AppIdentity>, name: String) -> Result { let mgr = state.lock().await; let node_id = mgr.create_identity(&name).map_err(|e| e.to_string())?; Ok(hex::encode(node_id)) } #[tauri::command] async fn switch_identity( node_state: State<'_, AppNode>, id_state: State<'_, AppIdentity>, node_id_hex: String, ) -> Result { let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?; let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?; let mut mgr = id_state.lock().await; let new_node = mgr.switch_identity(&nid).await.map_err(|e| e.to_string())?; // Start background tasks on the new node new_node.start_accept_loop(); new_node.start_pull_cycle(300); new_node.start_diff_cycle(120); new_node.start_rebalance_cycle(600); new_node.start_growth_loop(); new_node.start_recovery_loop(); new_node.start_social_checkin_cycle(3600); new_node.start_anchor_register_cycle(600); new_node.start_upnp_renewal_cycle(); new_node.start_upnp_tcp_renewal_cycle(); new_node.start_http_server(); new_node.start_bootstrap_connectivity_check(); new_node.start_replication_cycle(600); let cache_max_bytes: u64 = { let storage = new_node.storage.get().await; storage.get_setting("cache_size_bytes") .ok() .flatten() .and_then(|s| s.parse().ok()) .unwrap_or(1_073_741_824u64) }; Node::start_eviction_cycle(Arc::clone(&new_node), 300, cache_max_bytes); // Hot-swap the active node { let mut current = node_state.write().await; *current = new_node; } Ok(format!("Switched to {}", &node_id_hex[..12])) } #[tauri::command] async fn delete_identity(state: State<'_, AppIdentity>, node_id_hex: String) -> Result { let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?; let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?; let mgr = state.lock().await; mgr.delete_identity(&nid).map_err(|e| e.to_string())?; Ok("Identity deleted".to_string()) } #[tauri::command] async fn import_identity_key(state: State<'_, AppIdentity>, key_hex: String, name: String) -> Result { let mgr = state.lock().await; let node_id = mgr.import_identity_from_key(&key_hex, &name).map_err(|e| e.to_string())?; Ok(hex::encode(node_id)) } #[tauri::command] async fn get_active_identity(state: State<'_, AppIdentity>) -> Result, String> { let mgr = state.lock().await; let active_id = mgr.active_id(); if active_id.is_none() { return Ok(None); } let identities = mgr.list_identities().map_err(|e| e.to_string())?; Ok(identities.into_iter().find(|i| Some(i.node_id) == active_id).map(|i| IdentityInfoDto { is_active: true, node_id: i.node_id_hex, display_name: i.display_name, created_at: i.created_at, last_used_at: i.last_used_at, })) } // --- Export/Import IPC --- #[tauri::command] async fn export_data( state: State<'_, AppNode>, scope: String, output_dir: String, ) -> Result { let node = get_node(&state).await; let export_scope = match scope.as_str() { "identity_only" => itsgoin_core::export::ExportScope::IdentityOnly, "posts_only" => itsgoin_core::export::ExportScope::PostsOnly, "posts_with_identity" => itsgoin_core::export::ExportScope::PostsWithIdentity, "everything" => itsgoin_core::export::ExportScope::Everything, _ => return Err("Invalid scope".to_string()), }; // Resolve output directory — on Android use app data dir, on desktop resolve relative to home let resolved_dir = { #[cfg(target_os = "android")] { let _ = &output_dir; node.data_dir.join("exports") } #[cfg(not(target_os = "android"))] { if std::path::Path::new(&output_dir).is_relative() { dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(&output_dir) } else { std::path::PathBuf::from(&output_dir) } } }; let result = itsgoin_core::export::export_data( &node.data_dir, &node.storage, &node.blob_store, &node.node_id, export_scope, &resolved_dir, ).await.map_err(|e| e.to_string())?; let paths: Vec = result.paths.iter() .map(|p| p.to_string_lossy().to_string()) .collect(); Ok(format!("Exported {} posts, {} blobs to {} file(s): {}", result.post_count, result.blob_count, paths.len(), paths.join(", "))) } /// Clear the duplicate identity flag and start sync tasks that were skipped. #[tauri::command] async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> { let node = get_node(&state).await; node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed); // Start the sync tasks that were skipped during bootstrap node.start_accept_loop(); node.start_pull_cycle(300); node.start_diff_cycle(120); node.start_rebalance_cycle(600); node.start_growth_loop(); node.start_recovery_loop(); node.start_social_checkin_cycle(3600); node.start_anchor_register_cycle(600); node.start_upnp_renewal_cycle(); node.start_upnp_tcp_renewal_cycle(); node.start_http_server(); node.start_bootstrap_connectivity_check(); node.start_replication_cycle(600); let cache_max_bytes: u64 = { let storage = node.storage.get().await; storage.get_setting("cache_size_bytes").ok().flatten() .and_then(|s| s.parse().ok()).unwrap_or(1_073_741_824u64) }; Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes); Ok(()) } /// On Android: save a file from the app's internal storage to a user-chosen location via SAF. /// On desktop: no-op (files are already in ~/Downloads). #[tauri::command] async fn share_file(app: tauri::AppHandle, file_path: String, mime_type: String) -> Result { #[cfg(target_os = "android")] { use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs}; let path = std::path::Path::new(&file_path); let filename = path.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "export.zip".to_string()); let data = std::fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?; let api = app.android_fs(); let uri = api.show_save_file_dialog(None, &filename, Some(&mime_type)) .map_err(|e| format!("Save dialog failed: {}", e))?; match uri { Some(uri) => { api.write(&uri, &data).map_err(|e| format!("Write failed: {}", e))?; Ok(format!("Saved to device")) } None => Ok("Cancelled".to_string()), } } #[cfg(not(target_os = "android"))] { let _ = (app, mime_type); // Desktop: just return the path — file is already accessible Ok(file_path) } } #[tauri::command] async fn import_summary(zip_path: String) -> Result { let summary = itsgoin_core::import::read_import_summary(std::path::Path::new(&zip_path)) .map_err(|e| e.to_string())?; serde_json::to_string(&summary).map_err(|e| e.to_string()) } #[tauri::command] async fn import_public_posts( state: State<'_, AppNode>, zip_path: String, ) -> Result { let node = get_node(&state).await; let result = itsgoin_core::import::import_public_posts( std::path::Path::new(&zip_path), &node.storage, &node.blob_store, &node.node_id, ).await.map_err(|e| e.to_string())?; Ok(result.message) } /// Import a bundle as personas on the current identity. The bundle's posting /// keys become additional personas; imported content keeps its original author /// and encrypted content becomes decryptable because we now hold those keys. #[tauri::command] async fn import_as_personas_cmd( state: State<'_, AppNode>, zip_path: String, ) -> Result { let node = get_node(&state).await; let result = itsgoin_core::import::import_as_personas( std::path::Path::new(&zip_path), &node.storage, &node.blob_store, ).await.map_err(|e| e.to_string())?; // Drop the disposable fresh-install persona if it's still pristine. // Safe-by-construction: the helper bails unless ALL of [no name, no // posts, no engagement, no longer the default] hold. let pruned = node.try_prune_first_run_auto_persona().await .unwrap_or(false); let msg = if pruned { format!("{} (cleared blank starter persona)", result.message) } else { result.message }; Ok(msg) } #[tauri::command] async fn import_as_new_identity( state: State<'_, AppIdentity>, zip_path: String, ) -> Result { let mgr = state.lock().await; let base_dir = mgr.base_dir().to_path_buf(); drop(mgr); let node_id = itsgoin_core::import::import_as_identity( std::path::Path::new(&zip_path), &base_dir, ).map_err(|e| e.to_string())?; Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12])) } #[tauri::command] async fn import_merge_with_key( state: State<'_, AppNode>, zip_path: String, key_hex: String, ) -> Result { let node = get_node(&state).await; let result = itsgoin_core::import::merge_with_key( std::path::Path::new(&zip_path), &key_hex, &node.storage, &node.blob_store, &node.node_id, &node.secret_seed(), ).await.map_err(|e| e.to_string())?; Ok(result.message) } #[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()) .plugin(tauri_plugin_dialog::init()) .plugin({ #[cfg(target_os = "android")] { tauri_plugin_android_fs::init() } #[cfg(not(target_os = "android"))] { tauri::plugin::Builder::::new("android-fs-stub").build() } }) .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. A "Reset All // Data" request wipes EVERYTHING under the app data dir so the // next launch starts truly fresh — new network key, new posting // key, no posts, no blobs, no identities. let sentinel = data_dir.join(".reset"); if sentinel.exists() { info!("Reset sentinel found — wiping all app data"); if let Ok(entries) = std::fs::read_dir(&data_dir) { for entry in entries.flatten() { let path = entry.path(); if path.ends_with(".reset") { continue; } if path.is_dir() { let _ = std::fs::remove_dir_all(&path); } else { let _ = std::fs::remove_file(&path); } } } let _ = std::fs::remove_file(&sentinel); } info!(data_dir = %data_dir.display(), "Opening node via IdentityManager"); let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios"); let profile = if is_mobile { itsgoin_core::types::DeviceProfile::Mobile } else { itsgoin_core::types::DeviceProfile::Desktop }; let (node, identity_mgr) = tauri::async_runtime::block_on(async { let mut mgr = IdentityManager::open(&data_dir, None, profile).await?; let n = if let Some(node) = mgr.active_node() { Arc::clone(node) } else { // No identity — create a temporary one so the app can start. // Frontend detects this via get_node_info and shows the first-run chooser. info!("No identity found — creating initial identity for first-run setup"); let node_id = mgr.create_identity("My Identity")?; mgr.switch_identity(&node_id).await?; Arc::clone(mgr.active_node().expect("just created")) }; Ok::<_, anyhow::Error>((n, mgr)) })?; // Start bootstrap + background tasks AFTER setup completes (non-blocking) let boot_node = Arc::clone(&node); let boot_data_dir = data_dir.clone(); tauri::async_runtime::spawn(async move { // Bootstrap: connect to anchors, NAT probe, referrals (slow — runs in background) if let Err(e) = boot_node.run_bootstrap(&boot_data_dir).await { tracing::warn!(error = %e, "Background bootstrap failed"); } // Skip sync if duplicate identity detected if boot_node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed) { tracing::warn!("Duplicate identity detected — skipping sync tasks"); return; } // Start all background networking tasks boot_node.start_accept_loop(); boot_node.start_pull_cycle(300); boot_node.start_diff_cycle(120); boot_node.start_rebalance_cycle(600); boot_node.start_growth_loop(); boot_node.start_recovery_loop(); boot_node.start_social_checkin_cycle(3600); boot_node.start_anchor_register_cycle(600); boot_node.start_upnp_renewal_cycle(); boot_node.start_upnp_tcp_renewal_cycle(); boot_node.start_http_server(); boot_node.start_bootstrap_connectivity_check(); boot_node.start_replication_cycle(600); let cache_max_bytes: u64 = { let storage = boot_node.storage.get().await; storage.get_setting("cache_size_bytes") .ok() .flatten() .and_then(|s| s.parse().ok()) .unwrap_or(1_073_741_824u64) }; Node::start_eviction_cycle(Arc::clone(&boot_node), 300, cache_max_bytes); }); // Manage both the swappable Node and the IdentityManager let app_node: AppNode = Arc::new(tokio::sync::RwLock::new(node)); let app_identity: AppIdentity = Arc::new(tokio::sync::Mutex::new(identity_mgr)); app.manage(app_node); app.manage(app_identity); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_node_info, set_display_name, set_profile, create_post, create_post_with_files, create_post_as, list_posting_identities, create_posting_identity, delete_posting_identity, set_default_posting_identity, get_blob, get_blob_path, save_and_open_blob, save_blob, get_feed, get_feed_page, get_all_posts_page, get_all_posts, get_stats, connect_peer, follow_node, unfollow_node, list_follows, list_peers, suggested_peers, list_discover, ignore_peer, unignore_peer, list_ignored_peers, vouch_for_peer, revoke_vouch_for_peer, list_vouches_given, list_vouches_received, create_post_with_fof_comments, comment_on_fof_post, revoke_fof_commenter, create_post_fof_closed, read_fof_closed_body, rotate_v_me, cascade_revoke_v_me_epoch, key_burn_post_slot, 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, check_release_announcement, get_update_channel, set_update_channel, open_url_external, get_first_run_auto_persona_id, 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_our_info, pick_file, pick_folder, 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, write_message_receipt, write_message_comment, get_message_receipts, get_message_comments, get_cache_stats, send_notification, get_setting, set_setting, mark_post_seen, mark_conversation_read, get_seen_engagement, get_badge_counts, get_last_read_message, generate_share_link, list_identities, create_identity, switch_identity, delete_identity, import_identity_key, get_active_identity, export_data, share_file, clear_duplicate_flag, import_summary, import_public_posts, import_as_new_identity, import_as_personas_cmd, import_merge_with_key, ]) .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(app_node) = app_handle.try_state::() { let app_node = app_node.inner().clone(); tauri::async_runtime::spawn(async move { let node = app_node.read().await.clone(); let removed = node.network.wake_health_check().await; if removed > 0 { tracing::info!(removed, "Wake health check: removed dead connections"); } }); } } }); }