itsgoin/crates/tauri-app/src/lib.rs
Scott Reimers 0abc244ee9 v0.3.4: Comment edit/delete, native notifications, forward-compatible protocol, UI fixes
Comment edit & delete:
- EditComment/DeleteComment BlobHeaderDiffOps with upstream+downstream propagation
- Trust-based: comment author can edit/delete, post author can delete
- Storage: edit_comment(), delete_comment() methods
- Frontend: inline edit (Enter/Escape), delete with confirm

Native notifications:
- tauri-plugin-notification for system notifications on all platforms
- Triggers for messages, new posts, reactions, and comments
- notif_reacts setting added, button-group toggles replace dropdowns
- _notifReady flag prevents startup spam

Protocol hardening:
- BlobHeaderDiffOp::Unknown variant with #[serde(other)] for forward compatibility
- Old nodes silently skip unknown ops instead of crashing

UI fixes:
- Self removed from Following list
- Offline follows in lightbox popup (auto-show if no one online)
- Sent DMs filtered from My Posts
- Comment threading scoped to closest .post (fixes duplicate ID issue)
- Select dropdown text legible in WebKitGTK (black on white options)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:47:53 -04:00

1872 lines
60 KiB
Rust

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