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>
1872 lines
60 KiB
Rust
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");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|