itsgoin/crates/tauri-app/src/lib.rs
Scott Reimers e756fabb11 feat: v0.8 Iteration C — narrow mesh, N1-N4 uniques index, anchor convection
Topology rework. The mesh's job is width; depth now comes from knowledge.

Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
  knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
  never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
  growth gate counted Local only, so growth stopped at 71 and 20 slots were
  inbound-only. Also closes register_connection paths that enforced no cap.

Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
  bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
  USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
  so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
  double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool

Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
  most recent viable prior callers, its address goes to the next 2, then
  rotates out; still-connected callers preferred; RelayIntroduce coordinated
  when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
  cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
  weights from local anchor density + refusal feedback. No thresholds, no
  jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
  peers; known_anchors demoted to a bootstrap cache

Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.

deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.

228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
2026-07-30 13:03:12 -04:00

3759 lines
128 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::{Manager, State};
use tracing::info;
use itsgoin_core::identity::IdentityManager;
use itsgoin_core::node::Node;
use itsgoin_core::types::{NodeId, Post, PostId, PostVisibility, VisibilityIntent};
/// The active Node, swappable on identity switch.
type AppNode = Arc<tokio::sync::RwLock<Arc<Node>>>;
/// The identity manager for multi-identity operations.
type AppIdentity = Arc<tokio::sync::Mutex<IdentityManager>>;
/// Helper: get a clone of the active Node from the RwLock.
async fn get_node(state: &State<'_, AppNode>) -> Arc<Node> {
state.read().await.clone()
}
/// Helper: same as get_node but also returns a reference-like wrapper for &Node usage.
macro_rules! node {
($state:expr) => {{
let _n = get_node($state).await;
_n
}};
}
// --- DTOs for the frontend (all IDs as hex strings) ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AttachmentDto {
cid: String,
mime_type: String,
size_bytes: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PostDto {
id: String,
author: String,
author_name: Option<String>,
content: String,
timestamp_ms: u64,
is_me: bool,
/// "public", "encrypted", or "encrypted-for-me"
visibility: String,
/// The original intent: "public", "friends", "circle", "direct", or "unknown"
intent_kind: String,
/// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient
decrypted_content: Option<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,
/// If the post is authored by one of our held posting identities, the
/// persona's display_name. None for posts authored by peers (or if the
/// local persona has no display name).
as_persona: Option<String>,
}
#[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 ReceiptSlotDto {
slot_index: u32,
node_id: Option<String>,
state: String,
timestamp_ms: u64,
emoji: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CommentSlotDto {
slot_index: u32,
author: String,
author_name: Option<String>,
timestamp_ms: u64,
content: 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 BadgeCountsDto {
new_feed: usize,
new_engagement: usize,
unread_messages: usize,
new_reacts: usize,
new_comments: usize,
/// v0.8 (A3): undismissed greetings in the inbox (Messages badge adds this).
greetings: 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,
duplicate_detected: 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 {
// "is_me" now means: authored by ANY posting identity we hold, not just the
// network key. Covers the multi-persona case from 0.6.4+.
let (is_me, as_persona) = {
let s = node.storage.get().await;
match s.get_posting_identity(&post.author) {
Ok(Some(pi)) => {
let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
(true, name)
}
_ => (false, None),
}
};
let author_name = match node.resolve_display_name(&post.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
// Resolve intent kind from storage
let intent_kind = {
let storage = node.storage.get().await;
match storage.get_post_intent(id) {
Ok(Some(intent)) => match intent {
VisibilityIntent::Public => "public".to_string(),
VisibilityIntent::Friends => "friends".to_string(),
VisibilityIntent::Circle(_) => "circle".to_string(),
VisibilityIntent::Direct(_) => "direct".to_string(),
VisibilityIntent::Control => "control".to_string(),
VisibilityIntent::Profile => "profile".to_string(),
VisibilityIntent::GroupKeyDistribute => "group_key_distribute".to_string(),
VisibilityIntent::Announcement => "announcement".to_string(),
},
_ => "unknown".to_string(),
}
};
// For own encrypted posts, show "encrypted" not "encrypted-for-me"
// since intent_kind handles DM filtering now
let (visibility, decrypted_content) = match vis {
PostVisibility::Public => ("public".to_string(), None),
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())),
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
None => ("encrypted".to_string(), None),
},
// FoF Layer 3: FoFClosed body. Decrypted is None from the sync
// feed-pre-decrypt helper; the frontend calls read_fof_closed_body
// for any post with visibility == "fof-closed" to fill in the body.
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
recipients.iter().map(|wk| hex::encode(wk.recipient)).collect()
}
_ => vec![],
};
let attachments = post
.attachments
.iter()
.map(|a| AttachmentDto {
cid: hex::encode(a.cid),
mime_type: a.mime_type.clone(),
size_bytes: a.size_bytes,
})
.collect();
// Engagement data
let reaction_counts = {
let storage = node.storage.get().await;
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
storage.get_reaction_counts(id, &our_ids).unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
};
let comment_count = {
let storage = node.storage.get().await;
storage.get_comment_count(id).unwrap_or(0)
};
PostDto {
id: hex::encode(id),
author: hex::encode(post.author),
author_name,
content: post.content.clone(),
timestamp_ms: post.timestamp_ms,
is_me,
visibility,
intent_kind,
decrypted_content,
attachments,
recipients,
reaction_counts,
comment_count,
as_persona,
}
}
/// Decrypt a just-created post for immediate display. The post was authored
/// by one of our held posting identities (default or a specific persona);
/// look up that identity's secret to decrypt.
async fn decrypt_just_created(
node: &Node,
post: &Post,
vis: &PostVisibility,
) -> Option<String> {
match vis {
PostVisibility::Public => None,
PostVisibility::Encrypted { recipients } => {
let author_identity = {
let s = node.storage.get().await;
s.get_posting_identity(&post.author).ok().flatten()
}?;
itsgoin_core::crypto::decrypt_post(
&post.content,
&author_identity.secret_seed,
&author_identity.node_id,
&author_identity.node_id,
recipients,
)
.ok()
.flatten()
}
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
let seed_info = {
let storage = node.storage.get().await;
storage.get_all_group_seeds_map().ok()
.and_then(|map| map.get(&(*group_id, *epoch)).copied())
};
if let Some((seed, pubkey)) = seed_info {
itsgoin_core::crypto::decrypt_group_post(
&post.content,
&seed,
&pubkey,
wrapped_cek,
)
.ok()
} else {
None
}
}
// FoF Layer 3: FoFClosed body decrypt happens via the dedicated
// async read_fof_closed_body command. This sync helper returns
// None and the frontend dispatches the FoF read explicitly.
PostVisibility::FoFClosed => None,
}
}
fn parse_node_id(hex_str: &str) -> Result<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<'_, AppNode>) -> Result<NodeInfoDto, String> {
let node = get_node(&state).await;
let node_id_hex = hex::encode(node.node_id);
let addr = node.endpoint_addr();
// Prefer external address (UPnP, public IPv6, observed) over local bind address
let external_addr = node.network.http_addr();
let observed_addr = if external_addr.is_none() {
let storage = node.storage.get().await;
storage.get_peer_record(&node.node_id).ok().flatten()
.and_then(|r| r.addresses.first().map(|a| a.to_string()))
} else {
None
};
let connect_string = if let Some(ext) = external_addr.or(observed_addr) {
format!("{}@{}", node_id_hex, ext)
} else if let Some(sock) = addr.ip_addrs().next() {
format!("{}@{}", node_id_hex, sock)
} else {
node_id_hex.clone()
};
let profile = node.my_profile().await.map_err(|e| e.to_string())?;
let anchors = profile
.as_ref()
.map(|p| p.anchors.iter().map(hex::encode).collect())
.unwrap_or_default();
Ok(NodeInfoDto {
node_id: node_id_hex,
connect_string,
display_name: profile.as_ref().map(|p| p.display_name.clone()),
has_profile: profile.is_some(),
duplicate_detected: node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed),
anchors,
})
}
#[tauri::command]
async fn set_display_name(
state: State<'_, AppNode>,
name: String,
) -> Result<ProfileDto, String> {
let node = get_node(&state).await;
let profile = node
.set_profile(name, String::new())
.await
.map_err(|e| e.to_string())?;
Ok(ProfileDto {
node_id: hex::encode(profile.node_id),
display_name: profile.display_name,
bio: profile.bio,
anchors: profile.anchors.iter().map(hex::encode).collect(),
})
}
#[tauri::command]
async fn set_profile(
state: State<'_, AppNode>,
name: String,
bio: String,
) -> Result<ProfileDto, String> {
let node = get_node(&state).await;
let profile = node
.set_profile(name, bio)
.await
.map_err(|e| e.to_string())?;
Ok(ProfileDto {
node_id: hex::encode(profile.node_id),
display_name: profile.display_name,
bio: profile.bio,
anchors: profile.anchors.iter().map(hex::encode).collect(),
})
}
#[tauri::command]
async fn create_post(
state: State<'_, AppNode>,
content: String,
visibility: Option<String>,
circle_name: Option<String>,
recipient_hex: Option<String>,
posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
let node = get_node(&state).await;
let intent = match visibility.as_deref() {
Some("friends") => VisibilityIntent::Friends,
Some("circle") => {
let name = circle_name.ok_or("circle_name required for circle visibility")?;
VisibilityIntent::Circle(name)
}
Some("direct") => {
let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
VisibilityIntent::Direct(vec![nid])
}
_ => VisibilityIntent::Public,
};
let (id, post, vis) = match posting_id_hex {
Some(pid_hex) => {
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
node.create_post_as(&pid, content, intent, vec![]).await
}
None => node.create_post_with_visibility(content, intent, vec![]).await,
}.map_err(|e| e.to_string())?;
let decrypted = decrypt_just_created(&node, &post, &vis).await;
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}
#[tauri::command]
async fn create_post_with_files(
state: State<'_, AppNode>,
content: String,
visibility: Option<String>,
circle_name: Option<String>,
recipient_hex: Option<String>,
files: Vec<(String, String)>,
posting_id_hex: Option<String>,
) -> Result<PostDto, String> {
let node = get_node(&state).await;
let intent = match visibility.as_deref() {
Some("friends") => VisibilityIntent::Friends,
Some("circle") => {
let name = circle_name.ok_or("circle_name required for circle visibility")?;
VisibilityIntent::Circle(name)
}
Some("direct") => {
let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
VisibilityIntent::Direct(vec![nid])
}
_ => VisibilityIntent::Public,
};
use base64::Engine;
let attachment_data: Vec<(Vec<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) = match posting_id_hex {
Some(pid_hex) => {
let pid = itsgoin_core::parse_node_id_hex(&pid_hex).map_err(|e| e.to_string())?;
node.create_post_as(&pid, content, intent, attachment_data).await
}
None => node.create_post_with_visibility(content, intent, attachment_data).await,
}.map_err(|e| e.to_string())?;
let decrypted = decrypt_just_created(&node, &post, &vis).await;
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}
/// Posting identity DTO for IPC.
#[derive(serde::Serialize)]
struct PostingIdentityDto {
#[serde(rename = "nodeId")]
node_id: String,
#[serde(rename = "displayName")]
display_name: String,
#[serde(rename = "createdAt")]
created_at: u64,
#[serde(rename = "isDefault")]
is_default: bool,
}
#[tauri::command]
async fn list_posting_identities(
state: State<'_, AppNode>,
) -> Result<Vec<PostingIdentityDto>, String> {
let node = get_node(&state).await;
let identities = node.list_posting_identities().await.map_err(|e| e.to_string())?;
let default = {
let s = node.storage.get().await;
s.get_default_posting_id().ok().flatten()
};
Ok(identities
.into_iter()
.map(|id| PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
created_at: id.created_at,
is_default: Some(id.node_id) == default,
})
.collect())
}
#[tauri::command]
async fn create_posting_identity(
state: State<'_, AppNode>,
display_name: String,
greetings_open: Option<bool>,
) -> Result<PostingIdentityDto, String> {
let node = get_node(&state).await;
let id = node
.create_posting_identity(display_name, greetings_open)
.await
.map_err(|e| e.to_string())?;
Ok(PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
created_at: id.created_at,
is_default: false,
})
}
#[tauri::command]
async fn delete_posting_identity(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
node.delete_posting_identity(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_default_posting_identity(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
node.set_default_posting_identity(&nid).await.map_err(|e| e.to_string())
}
/// Create a post signed by a specific posting identity instead of the default.
#[tauri::command]
async fn create_post_as(
state: State<'_, AppNode>,
posting_id_hex: String,
content: String,
visibility: Option<String>,
circle_name: Option<String>,
recipient_hex: Option<String>,
) -> Result<PostDto, String> {
let node = get_node(&state).await;
let posting_id = itsgoin_core::parse_node_id_hex(&posting_id_hex).map_err(|e| e.to_string())?;
let intent = match visibility.as_deref() {
Some("friends") => VisibilityIntent::Friends,
Some("circle") => {
let name = circle_name.ok_or("circle_name required for circle visibility")?;
VisibilityIntent::Circle(name)
}
Some("direct") => {
let hex = recipient_hex.ok_or("recipient_hex required for direct visibility")?;
let nid = itsgoin_core::parse_node_id_hex(&hex).map_err(|e| e.to_string())?;
VisibilityIntent::Direct(vec![nid])
}
_ => VisibilityIntent::Public,
};
let (id, post, vis) = node
.create_post_as(&posting_id, content, intent, vec![])
.await
.map_err(|e| e.to_string())?;
let decrypted = decrypt_just_created(&node, &post, &vis).await;
Ok(post_to_dto(&id, &post, &vis, decrypted.as_deref(), &node).await)
}
/// Return the filesystem path of a blob if it exists locally (for streaming video/media).
/// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served
/// via the asset protocol — the frontend must use IPC-based get_blob instead.
#[tauri::command]
async fn get_blob_path(
state: State<'_, AppNode>,
cid_hex: String,
post_id_hex: Option<String>,
) -> Result<Option<String>, String> {
let node = get_node(&state).await;
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
// If a post_id is provided, check if the post is encrypted — if so, can't serve raw file
if let Some(ref pid_hex) = post_id_hex {
if let Ok(pid_bytes) = hex::decode(pid_hex) {
if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) {
let storage = node.storage.get().await;
if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) {
if !matches!(vis, PostVisibility::Public) {
return Ok(None);
}
}
}
}
}
Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
}
/// Sanitize a filename for safe download: strip path separators and control characters.
fn sanitize_download_filename(filename: &str) -> String {
filename
.replace(['/', '\\', '\0'], "_")
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
}
/// Helper: resolve a blob with optional decryption via post context.
/// First tries local (with post-based decryption), then falls back to network fetch + decrypt.
async fn resolve_blob_data(
node: &Node,
cid: &[u8; 32],
post_id_hex: Option<&str>,
) -> Result<Vec<u8>, String> {
// Parse post_id if provided
let post_id = if let Some(pid_hex) = post_id_hex {
let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?;
Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?)
} else {
None
};
// Try local blob with decryption
if let Some(ref pid) = post_id {
if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
} else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
// Try fetching from network if post_id provided
if let Some(pid) = post_id {
let post = {
let storage = node.storage.get().await;
storage.get_post(&pid).map_err(|e| e.to_string())?
};
if let Some(post) = post {
let mime = post.attachments.iter()
.find(|a| a.cid == *cid)
.map(|a| a.mime_type.as_str())
.unwrap_or("application/octet-stream");
if let Some(_fetched) = node
.fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms)
.await
.map_err(|e| e.to_string())?
{
// Re-read with decryption
if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? {
return Ok(data);
}
}
}
}
Err("blob not found".to_string())
}
/// Save a blob to the Downloads folder and open it with the system handler.
#[tauri::command]
async fn save_and_open_blob(
state: State<'_, AppNode>,
cid_hex: String,
post_id_hex: Option<String>,
filename: String,
) -> Result<String, String> {
let node = get_node(&state).await;
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
let safe_name = sanitize_download_filename(&filename);
// Save to Downloads — use app cache dir on Android (no access to shared storage without SAF)
let downloads = get_writable_download_dir(&node);
std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
let dest = downloads.join(&safe_name);
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
// Open with system handler
let _ = open::that(&dest);
Ok(dest.to_string_lossy().to_string())
}
/// Get a writable directory for downloads/exports.
/// On desktop: ~/Downloads. On Android: app data dir + "exports".
fn get_writable_download_dir(node: &Node) -> std::path::PathBuf {
#[cfg(target_os = "android")]
{
node.data_dir.join("exports")
}
#[cfg(not(target_os = "android"))]
{
let _ = node;
dirs::download_dir()
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
}
}
/// Save a blob to Downloads without opening it.
#[tauri::command]
async fn save_blob(
state: State<'_, AppNode>,
cid_hex: String,
post_id_hex: Option<String>,
filename: String,
) -> Result<String, String> {
let node = get_node(&state).await;
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
let safe_name = sanitize_download_filename(&filename);
let downloads = get_writable_download_dir(&node);
std::fs::create_dir_all(&downloads).map_err(|e| e.to_string())?;
let dest = downloads.join(&safe_name);
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().to_string())
}
#[tauri::command]
async fn get_blob(
state: State<'_, AppNode>,
cid_hex: String,
post_id_hex: Option<String>,
) -> Result<String, String> {
let node = get_node(&state).await;
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
let cid: [u8; 32] = cid_bytes
.try_into()
.map_err(|_| "CID must be 32 bytes".to_string())?;
let data = resolve_blob_data(&node, &cid, post_id_hex.as_deref()).await?;
use base64::Engine;
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
}
#[tauri::command]
async fn get_feed(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
let node = get_node(&state).await;
let posts = node.get_feed().await.map_err(|e| e.to_string())?;
let mut dtos = Vec::with_capacity(posts.len());
for (id, p, vis, decrypted) in &posts {
dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await);
}
Ok(dtos)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FeedPageDto {
posts: Vec<PostDto>,
has_more: bool,
oldest_ms: Option<u64>,
}
#[tauri::command]
async fn get_feed_page(
state: State<'_, AppNode>,
before_ms: Option<u64>,
limit: Option<usize>,
) -> Result<FeedPageDto, String> {
let node = get_node(&state).await;
let page_size = limit.unwrap_or(20);
// Fetch one extra to know if there are more
let posts = node.get_feed_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
let has_more = posts.len() > page_size;
let page: Vec<_> = posts.into_iter().take(page_size).collect();
let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
let dtos = post_to_dto_batch(&page, &node).await;
Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
}
#[tauri::command]
async fn get_all_posts_page(
state: State<'_, AppNode>,
before_ms: Option<u64>,
limit: Option<usize>,
) -> Result<FeedPageDto, String> {
let node = get_node(&state).await;
let page_size = limit.unwrap_or(20);
let posts = node.get_all_posts_page(before_ms, page_size + 1).await.map_err(|e| e.to_string())?;
let has_more = posts.len() > page_size;
let page: Vec<_> = posts.into_iter().take(page_size).collect();
let oldest_ms = page.last().map(|(_, p, _, _)| p.timestamp_ms);
let dtos = post_to_dto_batch(&page, &node).await;
Ok(FeedPageDto { posts: dtos, has_more, oldest_ms })
}
/// Batched DTO assembly: 3 bulk queries instead of 4 per post
async fn post_to_dto_batch(
posts: &[(itsgoin_core::types::PostId, itsgoin_core::types::Post, itsgoin_core::types::PostVisibility, Option<String>)],
node: &Node,
) -> Vec<PostDto> {
use std::collections::HashMap;
if posts.is_empty() { return vec![]; }
let post_ids: Vec<itsgoin_core::types::PostId> = posts.iter().map(|(id, _, _, _)| *id).collect();
// Batch queries — 3 queries total instead of 4 × N
let (reaction_map, comment_map, intent_map, posting_identities) = {
let storage = node.storage.get().await;
let identities = storage.list_posting_identities().unwrap_or_default();
// Reactors are posting ids — "reacted_by_me" = any of our personas.
let our_ids: Vec<itsgoin_core::types::NodeId> = identities.iter().map(|p| p.node_id).collect();
let reactions = storage.get_reaction_counts_batch(&post_ids, &our_ids).unwrap_or_default();
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
let persona_names: HashMap<itsgoin_core::types::NodeId, Option<String>> = posting_identities
.into_iter()
.map(|pi| {
let name = if pi.display_name.is_empty() { None } else { Some(pi.display_name) };
(pi.node_id, name)
})
.collect();
// Batch resolve display names
let mut name_cache: HashMap<itsgoin_core::types::NodeId, Option<String>> = HashMap::new();
let mut dtos = Vec::with_capacity(posts.len());
for (id, post, vis, decrypted) in posts {
let (is_me, as_persona) = match persona_names.get(&post.author) {
Some(name) => (true, name.clone()),
None => (false, None),
};
let author_name = if let Some(cached) = name_cache.get(&post.author) {
cached.clone()
} else {
let name = match node.resolve_display_name(&post.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
name_cache.insert(post.author, name.clone());
name
};
let intent_kind = if let Some(intent_json) = intent_map.get(id) {
match serde_json::from_str::<VisibilityIntent>(intent_json) {
Ok(VisibilityIntent::Public) => "public",
Ok(VisibilityIntent::Friends) => "friends",
Ok(VisibilityIntent::Circle(_)) => "circle",
Ok(VisibilityIntent::Direct(_)) => "direct",
_ => "unknown",
}
} else {
"unknown"
}.to_string();
let (visibility, decrypted_content) = match vis {
PostVisibility::Public => ("public".to_string(), None),
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
Some(text) if is_me => ("encrypted".to_string(), Some(text.clone())),
Some(text) => ("encrypted-for-me".to_string(), Some(text.clone())),
None => ("encrypted".to_string(), None),
},
PostVisibility::FoFClosed => ("fof-closed".to_string(), None),
};
let recipients = match vis {
PostVisibility::Encrypted { recipients } => {
recipients.iter().map(|wk| hex::encode(wk.recipient)).collect()
}
_ => vec![],
};
let attachments = post.attachments.iter().map(|a| AttachmentDto {
cid: hex::encode(a.cid),
mime_type: a.mime_type.clone(),
size_bytes: a.size_bytes,
}).collect();
let reaction_counts = reaction_map.get(id).cloned().unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect();
let comment_count = comment_map.get(id).copied().unwrap_or(0);
dtos.push(PostDto {
id: hex::encode(id),
author: hex::encode(post.author),
author_name,
content: post.content.clone(),
timestamp_ms: post.timestamp_ms,
is_me,
visibility,
intent_kind,
decrypted_content,
attachments,
recipients,
reaction_counts,
comment_count,
as_persona: as_persona.clone(),
});
}
dtos
}
#[tauri::command]
async fn get_all_posts(state: State<'_, AppNode>) -> Result<Vec<PostDto>, String> {
let node = get_node(&state).await;
let posts = node.get_all_posts().await.map_err(|e| e.to_string())?;
let mut dtos = Vec::with_capacity(posts.len());
for (id, p, vis, decrypted) in &posts {
dtos.push(post_to_dto(id, p, vis, decrypted.as_deref(), &node).await);
}
Ok(dtos)
}
#[tauri::command]
async fn get_stats(state: State<'_, AppNode>) -> Result<StatsDto, String> {
let node = get_node(&state).await;
let stats = node.stats().await.map_err(|e| e.to_string())?;
Ok(StatsDto {
post_count: stats.post_count,
peer_count: stats.peer_count,
follow_count: stats.follow_count,
})
}
#[tauri::command]
async fn connect_peer(
state: State<'_, AppNode>,
connect_string: String,
) -> Result<String, String> {
let node = get_node(&state).await;
let (nid, addr) =
itsgoin_core::parse_connect_string(&connect_string).map_err(|e| e.to_string())?;
// Store peer with addresses
let ip_addrs: Vec<_> = addr.ip_addrs().copied().collect();
{
let storage = node.storage.get().await;
if ip_addrs.is_empty() {
storage.add_peer(&nid).map_err(|e| e.to_string())?;
} else {
storage
.upsert_peer(&nid, &ip_addrs, None)
.map_err(|e| e.to_string())?;
}
}
node.follow(&nid).await.map_err(|e| e.to_string())?;
node.sync_with_addr(addr).await.map_err(|e| e.to_string())?;
let name = node.get_display_name(&nid).await.unwrap_or(None);
let id_hex = hex::encode(nid);
let label = name.unwrap_or_else(|| id_hex[..12.min(id_hex.len())].to_string());
Ok(format!("Connected and synced with {}", label))
}
#[tauri::command]
async fn follow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.follow(&nid).await.map_err(|e| e.to_string())?;
// Auto-sync: pull posts from the followed peer in the background (15s timeout)
let node_clone = get_node(&state).await;
tokio::spawn(async move {
match tokio::time::timeout(
std::time::Duration::from_secs(15),
node_clone.sync_with(nid),
).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::debug!(error = %e, "Auto-sync after follow failed"),
Err(_) => tracing::debug!("Auto-sync after follow timed out (15s)"),
}
});
Ok(())
}
#[tauri::command]
async fn unfollow_node(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.unfollow(&nid).await.map_err(|e| e.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DiscoverProfileDto {
node_id: String,
display_name: String,
bio: String,
updated_at_ms: u64,
has_avatar: bool,
}
/// Named peers we could follow, derived from signed profile posts we've
/// received via the CDN. Filters out self, follows, ignores, and unnamed
/// profiles. Replaces the old network-presence "Discover" path which
/// depended on peer liveness.
#[tauri::command]
async fn list_discover(state: State<'_, AppNode>) -> Result<Vec<DiscoverProfileDto>, String> {
let node = get_node(&state).await;
let profiles = node.list_discoverable_profiles().await.map_err(|e| e.to_string())?;
Ok(profiles.into_iter().map(|p| DiscoverProfileDto {
node_id: hex::encode(p.node_id),
display_name: p.display_name,
bio: p.bio,
updated_at_ms: p.updated_at,
has_avatar: p.avatar_cid.is_some(),
}).collect())
}
#[tauri::command]
async fn ignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.ignore_peer(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn unignore_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.unignore_peer(&nid).await.map_err(|e| e.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IgnoredPeerDto {
node_id: String,
display_name: Option<String>,
}
#[tauri::command]
async fn list_ignored_peers(state: State<'_, AppNode>) -> Result<Vec<IgnoredPeerDto>, String> {
let node = get_node(&state).await;
let ids = node.list_ignored_peers().await.map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(ids.len());
for nid in &ids {
let display_name = match node.resolve_display_name(nid).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
out.push(IgnoredPeerDto { node_id: hex::encode(nid), display_name });
}
Ok(out)
}
// --- FoF Layer 1: Vouches ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchGivenDto {
node_id: String,
display_name: String,
granted_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VouchReceivedDto {
node_id: String,
display_name: String,
epoch: u32,
received_at_ms: u64,
}
#[tauri::command]
async fn vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.vouch_for_peer(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn revoke_vouch_for_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.revoke_vouch_and_rotate(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_vouches_given(state: State<'_, AppNode>) -> Result<Vec<VouchGivenDto>, String> {
let node = get_node(&state).await;
let rows = node.list_vouches_given().await.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(|(nid, name, at)| VouchGivenDto {
node_id: hex::encode(nid),
display_name: name,
granted_at_ms: at,
}).collect())
}
#[tauri::command]
async fn exit_app(app: tauri::AppHandle) {
// On Android, the foreground NodeService survives Activity exit by
// design (keeps network alive when backgrounded). When the user
// explicitly hits the in-app close button, also stop the service
// so we actually free the device's network/wakelock.
#[cfg(target_os = "android")]
itsgoin_core::android_wifi::stop_node_service();
app.exit(0);
}
#[tauri::command]
async fn list_vouches_received(state: State<'_, AppNode>) -> Result<Vec<VouchReceivedDto>, String> {
let node = get_node(&state).await;
let rows = node.list_vouches_received().await.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(|(nid, name, epoch, at)| VouchReceivedDto {
node_id: hex::encode(nid),
display_name: name,
epoch,
received_at_ms: at,
}).collect())
}
// --- FoF Layer 2: comment-gated post + commenting ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FoFPostCreatedDto {
post_id: String,
}
#[tauri::command]
async fn create_post_with_fof_comments(
state: State<'_, AppNode>,
content: String,
) -> Result<FoFPostCreatedDto, String> {
let node = get_node(&state).await;
let (post_id, _post, _vis, _cek) = node
.create_post_with_fof_comments(content, vec![])
.await
.map_err(|e| e.to_string())?;
Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}
#[tauri::command]
async fn comment_on_fof_post(
state: State<'_, AppNode>,
post_id_hex: String,
body: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?; // PostId is also [u8; 32]
node.comment_on_fof_post(pid, body).await
.map(|_| ())
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn revoke_fof_commenter(
state: State<'_, AppNode>,
post_id_hex: String,
pub_x_index: u32,
reason_code: u8,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
node.revoke_fof_commenter(pid, pub_x_index, reason_code).await
.map_err(|e| e.to_string())
}
// FoF Layer 3: Mode 1 (FoFClosed) — encrypted body + FoF comments.
#[tauri::command]
async fn create_post_fof_closed(
state: State<'_, AppNode>,
content: String,
) -> Result<FoFPostCreatedDto, String> {
let node = get_node(&state).await;
let (post_id, _post, _cek) = node
.create_post_fof_closed(content)
.await
.map_err(|e| e.to_string())?;
Ok(FoFPostCreatedDto { post_id: hex::encode(post_id) })
}
/// Returns the decrypted body of a FoFClosed post if any local persona
/// can unlock it. `None` means "ciphertext only" (not in the FoF set).
#[tauri::command]
async fn read_fof_closed_body(
state: State<'_, AppNode>,
post_id_hex: String,
) -> Result<Option<String>, String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
node.read_fof_closed_body(&pid).await.map_err(|e| e.to_string())
}
// FoF Layer 4: V_me lifecycle + cascade + key-burn.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct VmeRotatedDto {
new_epoch: u32,
}
#[tauri::command]
async fn rotate_v_me(state: State<'_, AppNode>) -> Result<VmeRotatedDto, String> {
let node = get_node(&state).await;
let new_epoch = node.rotate_v_me().await.map_err(|e| e.to_string())?;
Ok(VmeRotatedDto { new_epoch })
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CascadeRevokeResultDto {
posts_revoked: usize,
}
#[tauri::command]
async fn cascade_revoke_v_me_epoch(
state: State<'_, AppNode>,
retired_epoch: u32,
reason_code: u8,
) -> Result<CascadeRevokeResultDto, String> {
let node = get_node(&state).await;
let n = node.cascade_revoke_v_me_epoch(retired_epoch, reason_code)
.await
.map_err(|e| e.to_string())?;
Ok(CascadeRevokeResultDto { posts_revoked: n })
}
#[tauri::command]
async fn key_burn_post_slot(
state: State<'_, AppNode>,
post_id_hex: String,
slot_index: u32,
new_v_x_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
let new_v_x_bytes = hex::decode(&new_v_x_hex)
.map_err(|e| format!("invalid new_v_x hex: {}", e))?;
let new_v_x: [u8; 32] = new_v_x_bytes.as_slice().try_into()
.map_err(|_| "new_v_x must be 32 bytes".to_string())?;
node.key_burn_post_slot(pid, slot_index, &new_v_x).await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_follows(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await;
let follows = node.list_follows().await.map_err(|e| e.to_string())?;
// v0.6.2: since posting identities are anonymized from network
// presence, "last activity" is now the last post we hold from that
// author rather than the network peer last-seen timestamp. One batch
// query for all follows.
let activity = node.last_activity_for_follows().await.unwrap_or_default();
let mut dtos = Vec::with_capacity(follows.len());
for nid in &follows {
let display_name = match node.resolve_display_name(nid).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
// Try to get peer record for address info
let storage = node.storage.get().await;
let rec = storage.get_peer_record(nid).ok().flatten();
drop(storage);
// Online presence is no longer reliable post-v0.6.1 (network id
// is decoupled from posting id we're showing). Frontend should
// treat this as a soft hint rather than truth.
let is_online = node.network.is_connected(nid).await
|| node.network.has_session(nid).await;
let last_activity_ms = *activity.get(nid).unwrap_or(&0);
dtos.push(PeerDto {
node_id: hex::encode(nid),
display_name,
addresses: rec
.as_ref()
.map(|r| r.addresses.iter().map(|a| a.to_string()).collect())
.unwrap_or_default(),
introduced_by: rec
.as_ref()
.and_then(|r| r.introduced_by.map(|ib| hex::encode(ib))),
is_anchor: rec.as_ref().map(|r| r.is_anchor).unwrap_or(false),
last_seen: rec.as_ref().map(|r| r.last_seen).unwrap_or(0),
reach: String::new(),
is_online,
last_activity_ms,
});
}
Ok(dtos)
}
#[tauri::command]
async fn list_peers(state: State<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await;
let records = node
.list_peer_records()
.await
.map_err(|e| e.to_string())?;
// Build reach sets for classification
let mesh_ids: std::collections::HashSet<_> = node
.list_connections()
.await
.into_iter()
.map(|(nid, _, _)| nid)
.collect();
let (social_ids, n2_ids, n3_ids, n4_ids) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
.unwrap_or_default()
.into_iter()
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.list_reach_ids_at(2)
.unwrap_or_default()
.into_iter()
.collect();
let n3: std::collections::HashSet<_> = storage
.list_distinct_n3()
.unwrap_or_default()
.into_iter()
.collect();
let n4: std::collections::HashSet<_> = storage
.list_distinct_n4()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3, n4)
};
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 if n4_ids.contains(&rec.node_id) {
"n4"
} else {
"known"
};
dtos.push(PeerDto {
node_id: hex::encode(rec.node_id),
display_name,
addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
introduced_by: rec.introduced_by.map(|ib| {
introduced_by_name
.clone()
.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
}),
is_anchor: rec.is_anchor,
last_seen: rec.last_seen,
reach: reach.to_string(),
is_online: mesh_ids.contains(&rec.node_id),
last_activity_ms: rec.last_seen,
});
}
Ok(dtos)
}
#[tauri::command]
async fn suggested_peers(state: State<'_, AppNode>) -> Result<Vec<SuggestedPeerDto>, String> {
let node = get_node(&state).await;
let records = node
.list_peer_records()
.await
.map_err(|e| e.to_string())?;
let follows = node.list_follows().await.map_err(|e| e.to_string())?;
let follow_set: std::collections::HashSet<_> = follows.iter().collect();
let mut dtos = Vec::new();
for rec in &records {
// Suggested = gossip-discovered (introduced_by is set) and not yet followed
if rec.introduced_by.is_some() && !follow_set.contains(&rec.node_id) {
let display_name = match node.resolve_display_name(&rec.node_id).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
let introduced_by_name = if let Some(ib) = &rec.introduced_by {
match node.resolve_display_name(ib).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
}
} else {
None
};
dtos.push(SuggestedPeerDto {
node_id: hex::encode(rec.node_id),
display_name,
addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
introduced_by_name,
is_anchor: rec.is_anchor,
});
}
}
Ok(dtos)
}
#[tauri::command]
async fn list_circles(state: State<'_, AppNode>) -> Result<Vec<CircleDto>, String> {
let node = get_node(&state).await;
let circles = node.list_circles().await.map_err(|e| e.to_string())?;
Ok(circles
.into_iter()
.map(|c| CircleDto {
name: c.name,
members: c.members.iter().map(hex::encode).collect(),
created_at: c.created_at,
})
.collect())
}
#[tauri::command]
async fn create_circle(state: State<'_, AppNode>, name: String) -> Result<CircleDto, String> {
let node = get_node(&state).await;
node.create_circle(name.clone())
.await
.map_err(|e| e.to_string())?;
Ok(CircleDto {
name,
members: vec![],
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
})
}
#[tauri::command]
async fn delete_circle(state: State<'_, AppNode>, name: String) -> Result<(), String> {
let node = get_node(&state).await;
node.delete_circle(name).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn add_circle_member(
state: State<'_, AppNode>,
circle_name: String,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.add_to_circle(circle_name, nid)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn remove_circle_member(
state: State<'_, AppNode>,
circle_name: String,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
node.remove_from_circle(circle_name, nid)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_post(state: State<'_, AppNode>, post_id_hex: String) -> Result<(), String> {
let node = get_node(&state).await;
let post_id = parse_node_id(&post_id_hex)?;
node.delete_post(&post_id).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn revoke_circle_access(
state: State<'_, AppNode>,
circle_name: String,
node_id_hex: String,
mode: Option<String>,
) -> Result<usize, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
let rev_mode = match mode.as_deref() {
Some("reencrypt") => itsgoin_core::types::RevocationMode::ReEncrypt,
_ => itsgoin_core::types::RevocationMode::SyncAccessList,
};
node.revoke_circle_access(&circle_name, &nid, rev_mode)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_redundancy_info(state: State<'_, AppNode>) -> Result<RedundancyDto, String> {
let node = get_node(&state).await;
let (total, zero, one, two_plus) = node
.get_redundancy_summary()
.await
.map_err(|e| e.to_string())?;
Ok(RedundancyDto {
total,
zero_replicas: zero,
one_replica: one,
two_plus_replicas: two_plus,
})
}
#[tauri::command]
async fn set_anchors(
state: State<'_, AppNode>,
anchors: Vec<String>,
) -> Result<ProfileDto, String> {
let node = get_node(&state).await;
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<'_, AppNode>) -> Result<Vec<PeerDto>, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
let records = storage.list_anchor_peers().map_err(|e| e.to_string())?;
drop(storage);
let mut dtos = Vec::with_capacity(records.len());
for rec in &records {
let display_name = node.get_display_name(&rec.node_id).await.unwrap_or(None);
dtos.push(PeerDto {
node_id: hex::encode(rec.node_id),
display_name,
addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
introduced_by: rec.introduced_by.map(|ib| hex::encode(ib)),
is_anchor: rec.is_anchor,
last_seen: rec.last_seen,
reach: String::new(),
is_online: node.network.is_connected(&rec.node_id).await,
last_activity_ms: rec.last_seen,
});
}
Ok(dtos)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KnownAnchorDto {
node_id: String,
display_name: Option<String>,
addresses: Vec<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseAnnouncementDto {
channel: String,
version: String,
title: String,
body: String,
download_url: String,
timestamp_ms: u64,
is_newer_than_current: bool,
}
/// Check for a newer release on the user's selected update channel.
/// Returns None if no announcement is stored for that channel or the
/// stored announcement isn't actually newer than the running build.
#[tauri::command]
async fn check_release_announcement(
state: State<'_, AppNode>,
channel: String,
) -> Result<Option<ReleaseAnnouncementDto>, String> {
let node = get_node(&state).await;
let stored = node.latest_release_announcement(&channel).await.map_err(|e| e.to_string())?;
let Some(stored) = stored else { return Ok(None); };
let Some(release) = stored.content.release.as_ref() else { return Ok(None); };
let current = env!("CARGO_PKG_VERSION");
let is_newer = version_is_newer(&release.version, current);
if !is_newer { return Ok(None); }
Ok(Some(ReleaseAnnouncementDto {
channel: release.channel.clone(),
version: release.version.clone(),
title: stored.content.title.clone(),
body: stored.content.body.clone(),
download_url: release.download_url.clone(),
timestamp_ms: stored.content.timestamp_ms,
is_newer_than_current: true,
}))
}
/// Simple dotted-semver compare. Returns true if `a` > `b`.
/// Handles "0.6.2" vs "0.6.3" and suffixed labels like "0.6.3-beta"
/// (beta suffix treated as pre-release, so "0.6.3-beta" < "0.6.3").
fn version_is_newer(a: &str, b: &str) -> bool {
fn parse(v: &str) -> (Vec<u32>, bool) {
let (core, pre) = match v.split_once('-') {
Some((c, _)) => (c, true),
None => (v, false),
};
let nums: Vec<u32> = core.split('.').filter_map(|p| p.parse().ok()).collect();
(nums, pre)
}
let (na, pa) = parse(a);
let (nb, pb) = parse(b);
let len = na.len().max(nb.len());
for i in 0..len {
let ai = *na.get(i).unwrap_or(&0);
let bi = *nb.get(i).unwrap_or(&0);
if ai != bi { return ai > bi; }
}
// Cores equal — a stable (no pre) outranks a pre-release.
!pa && pb
}
/// Read the user's preferred update channel from local settings.
/// Defaults to "stable" if never set.
#[tauri::command]
async fn get_update_channel(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
Ok(storage
.get_setting("ui_update_channel")
.map_err(|e| e.to_string())?
.unwrap_or_else(|| "stable".to_string()))
}
/// Return the hex id of the disposable fresh-install persona (if it still
/// exists and hasn't been claimed). Frontend uses this to filter the blank
/// starter out of the Personas list so the user never sees a "ghost"
/// persona waiting between install and their first-run choice.
/// Returns an empty string when there's no disposable persona to hide.
#[tauri::command]
async fn get_first_run_auto_persona_id(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
Ok(storage.get_setting("first_run_auto_persona_id")
.map_err(|e| e.to_string())?
.unwrap_or_default())
}
/// Persist the user's preferred update channel.
#[tauri::command]
async fn set_update_channel(state: State<'_, AppNode>, channel: String) -> Result<(), String> {
if channel != "stable" && channel != "beta" {
return Err(format!("invalid channel: {}", channel));
}
let node = get_node(&state).await;
let storage = node.storage.get().await;
storage.set_setting("ui_update_channel", &channel).map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_session_relay_enabled(state: State<'_, AppNode>) -> Result<bool, String> {
let node = get_node(&state).await;
Ok(node.network.conn_handle().is_session_relay_enabled().await)
}
#[tauri::command]
async fn set_session_relay_enabled(state: State<'_, AppNode>, enabled: bool) -> Result<(), String> {
let node = get_node(&state).await;
{
let storage = node.storage.get().await;
storage
.set_setting("relay.session_relay_enabled", if enabled { "true" } else { "false" })
.map_err(|e| e.to_string())?;
}
node.network.conn_handle().set_session_relay_enabled(enabled).await;
Ok(())
}
// --- v0.8 (A3): registry search + greetings ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryEntryDto {
author_id: String,
display_name: String,
keywords: Vec<String>,
updated_at_ms: u64,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegistryStatusDto {
listed: bool,
keywords: Vec<String>,
expires_at_ms: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingDto {
/// Comment key part 1 (throwaway outer identity) — opaque to the UI,
/// passed back verbatim for reply/dismiss.
comment_author: String,
/// Comment key part 2.
post_id: String,
/// Comment key part 3.
timestamp_ms: u64,
/// The sender's REAL persona (recovered from inside the seal).
sender_persona_id: String,
sender_name: String,
content: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GreetingSlotDto {
bio_post_id: String,
accepts_greetings: bool,
}
/// Register the persona in the network registry (30d entry, auto-renews
/// while listed — the core eviction cycle re-signs ~every 25 days).
#[tauri::command]
async fn register_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
name: String,
keywords: Vec<String>,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.register_persona(&posting_id, &name, &keywords)
.await
.map_err(|e| e.to_string())
}
/// Remove the persona's registry entry (self-certifying signed delete).
#[tauri::command]
async fn unregister_persona(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.unregister_persona(&posting_id).await.map_err(|e| e.to_string())
}
/// Current "Listed" state for the consent panel: listed flag + the
/// keywords used at last registration + entry expiry (0 if none held).
#[tauri::command]
async fn get_registry_status(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<RegistryStatusDto, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
let storage = node.storage.get().await;
let listed = storage
.get_setting(&format!("registry_listed.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| v == "1")
.unwrap_or(false);
let keywords = storage
.get_setting(&format!("registry_keywords.{}", posting_id_hex))
.ok()
.flatten()
.map(|v| {
v.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect()
})
.unwrap_or_default();
let expires_at_ms = storage
.get_newest_registry_entry(&itsgoin_core::registry::REGISTRY_POST_ID, &posting_id)
.ok()
.flatten()
.map(|(_ts, exp)| exp)
.unwrap_or(0);
Ok(RegistryStatusDto { listed, keywords, expires_at_ms })
}
/// Search the registry: refreshes the comment chain from peers, then
/// queries locally (search cost lands on the searcher, design §27).
#[tauri::command]
async fn search_registry(
state: State<'_, AppNode>,
query: String,
) -> Result<Vec<RegistryEntryDto>, String> {
let node = get_node(&state).await;
let matches = node.search_registry(&query).await.map_err(|e| e.to_string())?;
Ok(matches
.into_iter()
.map(|m| RegistryEntryDto {
author_id: hex::encode(m.author),
display_name: m.name,
keywords: m.keywords,
updated_at_ms: m.timestamp_ms,
expires_at_ms: m.expires_at_ms,
})
.collect())
}
/// Locally-held greeting-slot info for an author's latest bio post.
async fn greeting_slot_local(node: &Node, author: &NodeId) -> Option<GreetingSlotDto> {
let storage = node.storage.get().await;
let bio_post_id = storage
.get_latest_profile_post_id_by_author(author)
.ok()
.flatten()?;
let post = storage.get_post(&bio_post_id).ok().flatten()?;
let accepts = post
.fof_gating
.as_ref()
.and_then(|g| g.open_slot.as_ref())
.map(|d| d.kind == itsgoin_core::types::OpenSlotKind::Greeting)
.unwrap_or(false);
Some(GreetingSlotDto {
bio_post_id: hex::encode(bio_post_id),
accepts_greetings: accepts,
})
}
/// Does this author's bio declare a Greeting open slot? Drives the
/// "Say hi" button in the bio modal. On-demand (A3 §3.2): if the bio
/// isn't local yet (e.g. a registry search result), worm-locate the
/// author and sync their posts once before answering.
#[tauri::command]
async fn get_greeting_slot(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<Option<GreetingSlotDto>, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
if let Some(dto) = greeting_slot_local(&node, &nid).await {
return Ok(Some(dto));
}
// Fetch-on-demand: existing worm lookup + one-shot sync, then retry.
if let Ok(Some(wr)) = node.worm_lookup(&nid).await {
let _ = node.sync_with(wr.node_id).await;
return Ok(greeting_slot_local(&node, &nid).await);
}
Ok(None)
}
/// Send a sealed first-contact greeting to a bio post's author.
/// Messaging-first: no vouch is involved anywhere in this flow.
#[tauri::command]
async fn send_greeting(
state: State<'_, AppNode>,
bio_post_id_hex: String,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let bio_post_id = hex_to_postid(&bio_post_id_hex)?;
node.send_greeting(bio_post_id, content).await.map_err(|e| e.to_string())
}
/// Reply to a received greeting — sealed to the greeting's fresh reply
/// key and dropped into its declared return path (never a long-term key).
#[tauri::command]
async fn reply_to_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.reply_to_greeting(comment_author, post_id, timestamp_ms, content)
.await
.map_err(|e| e.to_string())
}
/// Unsealed greetings (and replies) across all personas' bio posts,
/// newest first. Return-path/reply-key internals stay inside core.
#[tauri::command]
async fn list_greetings(state: State<'_, AppNode>) -> Result<Vec<GreetingDto>, String> {
let node = get_node(&state).await;
let records = node.list_greetings().await.map_err(|e| e.to_string())?;
Ok(records
.into_iter()
.map(|g| GreetingDto {
comment_author: hex::encode(g.comment_author),
post_id: hex::encode(g.post_id),
timestamp_ms: g.timestamp_ms,
sender_persona_id: hex::encode(g.sender_persona),
sender_name: g.sender_name,
content: g.text,
})
.collect())
}
/// Dismiss a greeting (local only — nothing propagates).
#[tauri::command]
async fn dismiss_greeting(
state: State<'_, AppNode>,
comment_author_hex: String,
post_id_hex: String,
timestamp_ms: u64,
) -> Result<(), String> {
let node = get_node(&state).await;
let comment_author = parse_node_id(&comment_author_hex)?;
let post_id = hex_to_postid(&post_id_hex)?;
node.dismiss_greeting(comment_author, post_id, timestamp_ms)
.await
.map_err(|e| e.to_string())
}
/// Per-persona greeting consent (republishes the bio on change).
#[tauri::command]
async fn set_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
open: bool,
) -> Result<(), String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.set_greetings_open(&posting_id, open).await.map_err(|e| e.to_string())
}
/// Current greeting-consent state (unset = ON, the pre-checked default).
#[tauri::command]
async fn get_greetings_open(
state: State<'_, AppNode>,
posting_id_hex: String,
) -> Result<bool, String> {
let node = get_node(&state).await;
let posting_id = parse_node_id(&posting_id_hex)?;
node.get_greetings_open(&posting_id).await.map_err(|e| e.to_string())
}
/// Open a URL in the user's default system browser.
/// Desktop: spawns the platform opener (xdg-open / open / cmd start).
/// Only https:// URLs are accepted to avoid being a generic command exec.
/// TODO: Android requires an Intent.ACTION_VIEW — add tauri-plugin-opener
/// when we need the banner to work on mobile.
#[tauri::command]
fn open_url_external(url: String) -> Result<(), String> {
if !url.starts_with("https://") && !url.starts_with("http://") {
return Err("only http(s) urls are allowed".to_string());
}
#[cfg(target_os = "linux")]
let res = std::process::Command::new("xdg-open").arg(&url).spawn();
#[cfg(target_os = "macos")]
let res = std::process::Command::new("open").arg(&url).spawn();
#[cfg(target_os = "windows")]
let res = std::process::Command::new("cmd").args(["/c", "start", "", &url]).spawn();
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let res: std::io::Result<std::process::Child> = Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"open_url_external not supported on this platform",
));
res.map(|_| ()).map_err(|e| e.to_string())
}
#[tauri::command]
async fn list_known_anchors(state: State<'_, AppNode>) -> Result<Vec<KnownAnchorDto>, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
let anchors = storage.list_known_anchors().map_err(|e| e.to_string())?;
drop(storage);
let mut dtos = Vec::with_capacity(anchors.len());
for (nid, addrs) in &anchors {
let display_name = node.get_display_name(nid).await.unwrap_or(None);
dtos.push(KnownAnchorDto {
node_id: hex::encode(nid),
display_name,
addresses: addrs.iter().map(|a| a.to_string()).collect(),
});
}
Ok(dtos)
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WormResultDto {
node_id: String,
addresses: Vec<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<'_, AppNode>) -> Result<Vec<ConnectionDto>, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut dtos = Vec::with_capacity(conns.len());
for (nid, slot, 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: slot.to_string(),
connected_at,
});
}
Ok(dtos)
}
#[tauri::command]
async fn worm_lookup(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<Option<WormResultDto>, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
match node.worm_lookup(&nid).await.map_err(|e| e.to_string())? {
Some(wr) => {
let found_via = if wr.node_id != nid {
Some(hex::encode(wr.node_id))
} else {
None
};
Ok(Some(WormResultDto {
node_id: hex::encode(nid),
addresses: wr.addresses,
reporter: hex::encode(wr.reporter),
freshness_ms: wr.freshness_ms,
found_via,
}))
}
None => Ok(None),
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SocialRouteDto {
node_id: String,
display_name: Option<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<'_, AppNode>) -> Result<Vec<SocialRouteDto>, String> {
let node = get_node(&state).await;
let routes = node.list_social_routes().await.map_err(|e| e.to_string())?;
let mut dtos = Vec::with_capacity(routes.len());
for r in &routes {
let display_name = match node.resolve_display_name(&r.node_id).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
dtos.push(SocialRouteDto {
node_id: hex::encode(r.node_id),
display_name,
addresses: r.addresses.iter().map(|a| a.to_string()).collect(),
peer_count: r.peer_addresses.len(),
relation: r.relation.to_string(),
status: r.status.to_string(),
last_connected_ms: r.last_connected_ms,
last_seen_ms: r.last_seen_ms,
reach_method: r.reach_method.to_string(),
});
}
Ok(dtos)
}
#[tauri::command]
async fn export_identity(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
node.export_identity_hex().map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_circle_profile(
state: State<'_, AppNode>,
circle_name: String,
display_name: String,
bio: String,
avatar_cid: Option<String>,
) -> Result<serde_json::Value, String> {
let node = get_node(&state).await;
let avatar = match avatar_cid {
Some(hex) => {
let bytes = hex::decode(&hex).map_err(|e| e.to_string())?;
Some(<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| "avatar_cid must be 32 bytes".to_string())?)
}
None => None,
};
let cp = node
.set_circle_profile(circle_name, display_name, bio, avatar)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"circleName": cp.circle_name,
"displayName": cp.display_name,
"bio": cp.bio,
"avatarCid": cp.avatar_cid.map(hex::encode),
"updatedAt": cp.updated_at,
}))
}
#[tauri::command]
async fn get_circle_profile(
state: State<'_, AppNode>,
circle_name: String,
) -> Result<Option<serde_json::Value>, String> {
let node = get_node(&state).await;
let cp = node
.get_circle_profile(&circle_name)
.await
.map_err(|e| e.to_string())?;
Ok(cp.map(|c| {
serde_json::json!({
"circleName": c.circle_name,
"displayName": c.display_name,
"bio": c.bio,
"avatarCid": c.avatar_cid.map(hex::encode),
"updatedAt": c.updated_at,
})
}))
}
#[tauri::command]
async fn delete_circle_profile(
state: State<'_, AppNode>,
circle_name: String,
) -> Result<(), String> {
let node = get_node(&state).await;
node.delete_circle_profile(circle_name)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_public_visible(
state: State<'_, AppNode>,
visible: bool,
) -> Result<(), String> {
let node = get_node(&state).await;
node.set_public_visible(visible)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn resolve_display(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<serde_json::Value, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&node_id_hex)?;
let (display_name, bio, avatar_cid) = node
.resolve_display_name(&nid)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"displayName": display_name,
"bio": bio,
"avatarCid": avatar_cid.map(hex::encode),
}))
}
#[tauri::command]
async fn get_public_visible(
state: State<'_, AppNode>,
) -> Result<bool, String> {
let node = get_node(&state).await;
node.get_public_visible()
.await
.map_err(|e| e.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CacheStatsDto {
used_bytes: u64,
max_bytes: u64,
blob_count: u64,
}
#[tauri::command]
async fn send_notification(title: String, body: String) -> Result<(), String> {
#[cfg(not(target_os = "android"))]
{
let _ = notify_rust::Notification::new()
.summary(&title)
.body(&body)
.appname("ItsGoin")
.show();
}
Ok(())
}
#[tauri::command]
async fn get_cache_stats(state: State<'_, AppNode>) -> Result<CacheStatsDto, String> {
let node = get_node(&state).await;
let (used, max, count) = node.get_cache_stats().await.map_err(|e| e.to_string())?;
Ok(CacheStatsDto {
used_bytes: used,
max_bytes: max,
blob_count: count,
})
}
#[tauri::command]
async fn get_setting(state: State<'_, AppNode>, key: String) -> Result<Option<String>, String> {
let node = get_node(&state).await;
node.get_setting(&key).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_setting(state: State<'_, AppNode>, key: String, value: String) -> Result<(), String> {
let node = get_node(&state).await;
node.set_setting(&key, &value).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn mark_post_seen(
state: State<'_, AppNode>,
post_id: String,
react_count: u32,
comment_count: u32,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
node.set_seen_engagement(&pid, react_count, comment_count).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn mark_conversation_read(
state: State<'_, AppNode>,
partner_id: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = parse_node_id(&partner_id)?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
node.set_last_read_message(&nid, now_ms).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_seen_engagement(
state: State<'_, AppNode>,
post_id: String,
) -> Result<serde_json::Value, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let (rc, cc) = node.get_seen_engagement(&pid).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"seenReactCount": rc,
"seenCommentCount": cc,
}))
}
/// Comment count for the engagement badges, EXCLUDING greeting open-slot
/// comments: greetings have their own dedicated badge/inbox, and counting
/// them here would double-count every greeting into "My Posts" while
/// pointing the user at a bio post where the comment renders as sealed
/// ciphertext instead of at the Greetings inbox.
fn non_greeting_comment_count(
storage: &itsgoin_core::storage::Storage,
post_id: &itsgoin_core::types::PostId,
post: &itsgoin_core::types::Post,
) -> u64 {
let mut count = storage.get_comment_count(post_id).unwrap_or(0);
if let Some(decl) = post.fof_gating.as_ref().and_then(|g| g.open_slot.as_ref()) {
if matches!(decl.kind, itsgoin_core::types::OpenSlotKind::Greeting) {
let greetings = storage
.count_unexpired_open_slot_comments(post_id, decl.slot_index)
.unwrap_or(0);
count = count.saturating_sub(greetings);
}
}
count
}
#[tauri::command]
async fn get_badge_counts(
state: State<'_, AppNode>,
last_feed_view_ms: u64,
) -> Result<BadgeCountsDto, String> {
let node = get_node(&state).await;
let storage = node.storage.get().await;
// "Own post" checks below are posting-class: posts are authored by our
// posting identities (personas), never the network NodeId.
let own_ids: Vec<itsgoin_core::types::NodeId> = storage.list_posting_identities()
.unwrap_or_default()
.into_iter().map(|p| p.node_id).collect();
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
!own_ids.contains(&p.author)
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
)
})
.count();
// My Posts badge: count own non-DM posts with unseen engagement
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if !own_ids.contains(&post.author) { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
// Greeting open-slot comments are excluded — they have their own
// dedicated Greetings badge (see non_greeting_comment_count).
let total_comments = non_greeting_comment_count(&storage, id, post);
if total_reacts > 0 || total_comments > 0 {
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
new_engagement += 1;
}
}
}
// Unread messages: count conversations with messages newer than last_read
let mut unread_messages = 0usize;
let dm_posts = all_posts.iter().filter(|(id, p, _)| {
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (!own_ids.contains(&p.author) && matches!(
storage.get_post_with_visibility(id).ok().flatten(),
Some((_, PostVisibility::Encrypted { .. }))
))
});
let mut seen_partners = std::collections::HashSet::new();
for (_id, post, _vis) in dm_posts {
let partner = if own_ids.contains(&post.author) {
// sent DM — skip for unread count
continue;
} else {
post.author
};
if seen_partners.contains(&partner) { continue; }
seen_partners.insert(partner);
let last_read = storage.get_last_read_message(&partner).unwrap_or(0);
if post.timestamp_ms > last_read {
unread_messages += 1;
}
}
// Count new reacts and comments separately
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if !own_ids.contains(&post.author) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &own_ids)
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
// Greeting open-slot comments excluded here too (dedicated badge).
let total_comments = non_greeting_comment_count(&storage, id, post);
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
}
// v0.8 (A3): greetings inbox size. Release the storage slot first —
// list_greetings takes its own guard internally.
drop(storage);
let greetings = node.list_greetings().await.map(|g| g.len()).unwrap_or(0);
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments, greetings })
}
#[tauri::command]
async fn get_last_read_message(
state: State<'_, AppNode>,
partner_id_hex: String,
) -> Result<u64, String> {
let node = get_node(&state).await;
let nid = parse_node_id(&partner_id_hex)?;
node.get_last_read_message(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn generate_share_link(state: State<'_, AppNode>, post_id_hex: String) -> Result<Option<String>, String> {
let node = get_node(&state).await;
let pid = parse_node_id(&post_id_hex)?;
node.generate_share_link(&pid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn sync_all(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
node.sync_all().await.map_err(|e| e.to_string())?;
Ok("Sync complete".to_string())
}
#[tauri::command]
async fn sync_from_peer(state: State<'_, AppNode>, node_id_hex: String) -> Result<String, String> {
let node = get_node(&state).await;
let bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
let nid: [u8; 32] = bytes.try_into().map_err(|_| "Invalid node ID length")?;
node.sync_with(nid).await.map_err(|e| e.to_string())?;
Ok("Sync complete".to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct NetworkSummaryDto {
mesh_count: usize,
temp_count: usize,
mesh_cap: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
n4_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
}
#[tauri::command]
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mesh = conns.iter().filter(|(_, slot, _)| slot.is_mesh()).count();
let temp = conns.len() - mesh;
let (n2, n3, n4) = {
let storage = node.storage.get().await;
(
storage.count_distinct_n2().unwrap_or(0),
storage.count_distinct_n3().unwrap_or(0),
storage.count_distinct_n4().unwrap_or(0),
)
};
Ok(NetworkSummaryDto {
mesh_count: mesh,
temp_count: temp,
mesh_cap: node.device_profile().mesh_slots(),
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
n4_distinct: n4,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OurInfoDto {
node_id: String,
addresses: Vec<AddressInfoDto>,
nat_type: String,
device_role: String,
upnp: bool,
http_capable: bool,
http_addr: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AddressInfoDto {
addr: String,
family: String, // "IPv4" or "IPv6"
status: String, // "Public", "NAT (easy)", "NAT (hard)", "UPnP", "LAN", "Server"
}
#[tauri::command]
async fn pick_file(app: tauri::AppHandle, title: String, filter_name: Option<String>, filter_ext: Option<Vec<String>>) -> Result<Option<String>, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_dialog::DialogExt;
let mut builder = app.dialog().file().set_title(&title);
if let (Some(name), Some(exts)) = (filter_name, filter_ext) {
let ext_refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect();
builder = builder.add_filter(&name, &ext_refs);
}
let path = builder.blocking_pick_file();
Ok(path.map(|p| p.to_string()))
}
#[cfg(target_os = "android")]
{
// Android: SAF "open document" dialog. The dialog returns a content
// URI, not a filesystem path, so we read the bytes via the plugin
// and stage them in the app's private cache so existing import code
// (which expects a path) can read the file normally.
let _ = (title,);
use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs};
let mime_types: Vec<&str> = match filter_ext.as_deref() {
Some(exts) if exts.iter().any(|e| e == "zip") => vec!["application/zip", "application/octet-stream"],
_ => vec!["*/*"],
};
let api = app.android_fs();
let uris = api.show_open_file_dialog(None, &mime_types, false)
.map_err(|e| format!("Open dialog failed: {}", e))?;
let uri = match uris.into_iter().next() {
Some(u) => u,
None => return Ok(None),
};
let data = api.read(&uri).map_err(|e| format!("Read failed: {}", e))?;
// Stage in private cache so import_* can open it by path.
let cache_dir = app.path().app_cache_dir()
.map_err(|e| format!("no cache dir: {}", e))?;
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
// Name includes a timestamp so repeated picks don't clobber.
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let filename = match filter_ext.as_deref() {
Some(exts) if exts.iter().any(|e| e == "zip") => format!("import-{}.zip", stamp),
_ => format!("import-{}", stamp),
};
let dest = cache_dir.join(filename);
std::fs::write(&dest, &data).map_err(|e| format!("Stage write failed: {}", e))?;
Ok(Some(dest.to_string_lossy().to_string()))
}
#[cfg(target_os = "ios")]
{
let _ = (app, title, filter_name, filter_ext);
Ok(None)
}
}
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle, title: String) -> Result<Option<String>, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use tauri_plugin_dialog::DialogExt;
let path = app.dialog().file().set_title(&title).blocking_pick_folder();
Ok(path.map(|p| p.to_string()))
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = (app, title);
Ok(None)
}
}
#[tauri::command]
async fn get_our_info(state: State<'_, AppNode>) -> Result<OurInfoDto, String> {
let node = get_node(&state).await;
let net = &node.network;
let nat_type = net.conn_handle().nat_type().await;
let has_upnp = net.has_upnp();
let _has_public_v6 = net.has_public_v6();
let bind_addr = net.bind_addr();
let mut addresses = Vec::new();
// Collect bound socket addresses (these are our local interfaces)
let bound: std::collections::HashSet<String> = net.bound_sockets()
.iter()
.map(|s| s.to_string())
.collect();
// Gather bound (local) addresses with classification
for sock in net.bound_sockets() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let status = classify_addr(&sock, &nat_type, has_upnp, bind_addr.is_some(), false);
addresses.push(AddressInfoDto {
addr: sock.to_string(),
family: family.to_string(),
status,
});
}
// Add UPnP external address if different from bound
if let Some(ref mapping) = net.upnp_mapping() {
let ext = mapping.external_addr;
if !addresses.iter().any(|a| a.addr == ext.to_string()) {
addresses.insert(0, AddressInfoDto {
addr: ext.to_string(),
family: if ext.ip().is_ipv4() { "IPv4" } else { "IPv6" }.to_string(),
status: "UPnP external".to_string(),
});
}
}
// Add iroh-discovered addresses not already listed
for sock in net.endpoint_addr().ip_addrs() {
if sock.ip().is_loopback() || sock.ip().is_unspecified() { continue; }
let s = sock.to_string();
if addresses.iter().any(|a| a.addr == s) { continue; }
let family = if sock.ip().is_ipv4() { "IPv4" } else { "IPv6" };
let is_observed = !bound.contains(&s);
let status = classify_addr(sock, &nat_type, has_upnp, bind_addr.is_some(), is_observed);
addresses.push(AddressInfoDto {
addr: s,
family: family.to_string(),
status,
});
}
// Add peer-observed external address (from anchor's your_observed_addr)
if let Some(observed) = net.conn_handle().observed_external_addr().await {
let s = observed.to_string();
if !addresses.iter().any(|a| a.addr == s) {
let family = if observed.ip().is_ipv4() { "IPv4" } else { "IPv6" };
addresses.insert(0, AddressInfoDto {
addr: s,
family: family.to_string(),
status: classify_addr(&observed, &nat_type, has_upnp, bind_addr.is_some(), true),
});
}
}
Ok(OurInfoDto {
node_id: hex::encode(net.node_id_bytes()),
addresses,
nat_type: nat_type.to_string(),
device_role: format!("{:?}", net.device_role()),
upnp: has_upnp,
http_capable: net.is_http_capable(),
http_addr: net.http_addr(),
})
}
fn classify_addr(sock: &std::net::SocketAddr, nat_type: &itsgoin_core::types::NatType, has_upnp: bool, is_server: bool, is_observed: bool) -> String {
use std::net::IpAddr;
let ip = sock.ip();
let is_v6 = ip.is_ipv6();
let is_pub = match ip {
IpAddr::V4(v4) => !v4.is_private() && !v4.is_loopback() && !v4.is_link_local(),
IpAddr::V6(v6) => !v6.is_loopback() && {
let seg = v6.segments();
seg[0] & 0xfe00 != 0xfe00 && seg[0] & 0xffc0 != 0xfe80
},
};
if is_server { return "Server".to_string(); }
if is_pub {
if is_observed && !is_v6 {
return match nat_type {
itsgoin_core::types::NatType::Public |
itsgoin_core::types::NatType::Easy => "External · easy punch".to_string(),
itsgoin_core::types::NatType::Hard => "External · hard punch (scan)".to_string(),
itsgoin_core::types::NatType::Unknown => "External".to_string(),
};
}
return "Public".to_string();
}
if is_v6 { return "Link-local".to_string(); }
"LAN".to_string()
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityEventDto {
timestamp_ms: u64,
level: String,
category: String,
message: String,
peer_id: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ActivityLogDto {
events: Vec<ActivityEventDto>,
rebalance_last_ms: u64,
rebalance_interval_secs: u64,
convection_last_ms: u64,
}
#[tauri::command]
async fn get_activity_log(state: State<'_, AppNode>) -> Result<ActivityLogDto, String> {
let node = get_node(&state).await;
let events = node.get_activity_log(200);
let (rebalance_last, convection_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,
convection_last_ms: convection_last,
})
}
#[tauri::command]
async fn trigger_rebalance(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
node.network.rebalance().await.map_err(|e| e.to_string())?;
let conns = node.list_connections().await;
Ok(format!("Rebalance complete — {} connections", conns.len()))
}
#[tauri::command]
async fn request_referrals(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
let node_id = node.node_id;
let mesh = node.network.conn_handle().mesh_count().await;
// Manual trigger uses the same class rule as the automatic path: below 2
// mesh peers this is an ENTRY request and is always served.
let class = itsgoin_core::protocol::ConvectionClass::for_mesh_count(mesh);
// Pool-mined anchors first (the uniques pools ARE the anchor directory),
// then the known_anchors bootstrap cache, then anchor-flagged peers.
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let mut out: Vec<(NodeId, Vec<std::net::SocketAddr>)> = Vec::new();
let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
for (nid, addrs) in storage.list_pool_anchors(16).unwrap_or_default() {
let socks: Vec<std::net::SocketAddr> = addrs.iter().filter_map(|a| a.parse().ok()).collect();
if !socks.is_empty() && seen.insert(nid) {
out.push((nid, socks));
}
}
for (nid, addrs) in storage.list_known_anchors().unwrap_or_default() {
if seen.insert(nid) {
out.push((nid, addrs));
}
}
for r in storage.list_anchor_peers().unwrap_or_default() {
if seen.insert(r.node_id) {
out.push((r.node_id, r.addresses));
}
}
out
};
if anchors.is_empty() {
return Ok("No known anchors".to_string());
}
let mut connected = 0usize;
let mut asked = 0usize;
let mut refused = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
if !node.network.is_peer_connected_or_session(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 node.network.connect_to_anchor(*anchor_nid, addr).await.is_err() {
continue;
}
}
match node.network.request_convection(anchor_nid, class).await {
Ok(response) => {
asked += 1;
if response.refused {
refused += 1;
} else {
connected += node.network.act_on_convection(anchor_nid, &response).await;
}
}
Err(_) => {}
}
}
Ok(format!(
"Convection: {} new peers from {} anchors ({} refused)",
connected, asked, refused
))
}
#[tauri::command]
async fn reset_data(state: State<'_, AppNode>) -> Result<String, String> {
let node = get_node(&state).await;
// Write the sentinel at the APP-level data_dir (parent of the active
// identity's dir). The startup sentinel check runs at the same level.
// Earlier versions wrote to node.data_dir which is the identity subdir,
// making the check miss on Android.
let app_data_dir = node.data_dir.parent()
.ok_or_else(|| "no parent data dir".to_string())?
.to_path_buf();
let sentinel = app_data_dir.join(".reset");
std::fs::write(&sentinel, b"reset").map_err(|e| e.to_string())?;
Ok("Reset scheduled. Restart the app to apply.".to_string())
}
// --- Engagement IPC commands ---
#[tauri::command]
async fn react_to_post(
state: State<'_, AppNode>,
post_id: String,
emoji: String,
private: bool,
) -> Result<ReactionDto, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let reaction = node.react_to_post(pid, emoji, private).await.map_err(|e| e.to_string())?;
Ok(ReactionDto {
reactor: hex::encode(reaction.reactor),
emoji: reaction.emoji,
post_id: hex::encode(reaction.post_id),
timestamp_ms: reaction.timestamp_ms,
encrypted_payload: reaction.encrypted_payload,
})
}
#[tauri::command]
async fn remove_reaction(
state: State<'_, AppNode>,
post_id: String,
emoji: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
node.remove_reaction(pid, emoji).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_reactions(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<ReactionDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let reactions = node.get_reactions(pid).await.map_err(|e| e.to_string())?;
Ok(reactions.into_iter().map(|r| ReactionDto {
reactor: hex::encode(r.reactor),
emoji: r.emoji,
post_id: hex::encode(r.post_id),
timestamp_ms: r.timestamp_ms,
encrypted_payload: r.encrypted_payload,
}).collect())
}
#[tauri::command]
async fn get_reaction_counts(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<ReactionCountDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let counts = node.get_reaction_counts(pid).await.map_err(|e| e.to_string())?;
Ok(counts.into_iter().map(|(emoji, count, reacted_by_me)| {
ReactionCountDto { emoji, count, reacted_by_me }
}).collect())
}
#[tauri::command]
async fn comment_on_post(
state: State<'_, AppNode>,
post_id: String,
content: String,
) -> Result<CommentDto, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let comment = node.comment_on_post(pid, content).await.map_err(|e| e.to_string())?;
let author_name = match node.resolve_display_name(&comment.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
Ok(CommentDto {
author: hex::encode(comment.author),
author_name,
post_id: hex::encode(comment.post_id),
content: comment.content,
timestamp_ms: comment.timestamp_ms,
})
}
#[tauri::command]
async fn edit_comment(
state: State<'_, AppNode>,
post_id: String,
timestamp_ms: u64,
new_content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
node.edit_comment(pid, timestamp_ms, new_content).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn delete_comment(
state: State<'_, AppNode>,
post_id: String,
timestamp_ms: u64,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
node.delete_comment(pid, timestamp_ms).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_comments(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<CommentDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let comments = node.get_comments(pid).await.map_err(|e| e.to_string())?;
let mut dtos = Vec::new();
for c in comments {
let author_name = match node.resolve_display_name(&c.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
dtos.push(CommentDto {
author: hex::encode(c.author),
author_name,
post_id: hex::encode(c.post_id),
content: c.content,
timestamp_ms: c.timestamp_ms,
});
}
Ok(dtos)
}
#[tauri::command]
async fn set_comment_policy(
state: State<'_, AppNode>,
post_id: String,
allow_comments: String,
allow_reacts: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let comment_perm = match allow_comments.as_str() {
"followers_only" | "audience_only" => itsgoin_core::types::CommentPermission::FollowersOnly,
"none" => itsgoin_core::types::CommentPermission::None,
_ => itsgoin_core::types::CommentPermission::Public,
};
let react_perm = match allow_reacts.as_str() {
"public" => itsgoin_core::types::ReactPermission::Public,
"private" => itsgoin_core::types::ReactPermission::Private,
"none" => itsgoin_core::types::ReactPermission::None,
_ => itsgoin_core::types::ReactPermission::Both,
};
let policy = itsgoin_core::types::CommentPolicy {
allow_comments: comment_perm,
allow_reacts: react_perm,
..Default::default()
};
node.set_comment_policy(pid, policy).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_comment_policy(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Option<CommentPolicyDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let policy = node.get_comment_policy(pid).await.map_err(|e| e.to_string())?;
Ok(policy.map(|p| CommentPolicyDto {
allow_comments: format!("{:?}", p.allow_comments).to_lowercase(),
allow_reacts: format!("{:?}", p.allow_reacts).to_lowercase(),
moderation: format!("{:?}", p.moderation).to_lowercase(),
blocklist: p.blocklist.iter().map(hex::encode).collect(),
}))
}
#[tauri::command]
async fn get_comment_thread(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<CommentDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let comments = node.get_comment_thread(pid).await.map_err(|e| e.to_string())?;
let mut dtos = Vec::new();
for c in comments {
let author_name = match node.resolve_display_name(&c.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
dtos.push(CommentDto {
author: hex::encode(c.author),
author_name,
post_id: hex::encode(c.post_id),
content: c.content,
timestamp_ms: c.timestamp_ms,
});
}
Ok(dtos)
}
#[tauri::command]
async fn write_message_receipt(
state: State<'_, AppNode>,
post_id: String,
receipt_state: String,
emoji: Option<String>,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let state_val = itsgoin_core::types::ReceiptState::from_str_label(&receipt_state);
node.write_receipt_slot(pid, state_val, emoji).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn write_message_comment(
state: State<'_, AppNode>,
post_id: String,
content: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
node.write_comment_slot(pid, content).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_message_receipts(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<ReceiptSlotDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let slots = node.read_receipt_slots(pid).await.map_err(|e| e.to_string())?;
Ok(slots.into_iter().map(|s| ReceiptSlotDto {
slot_index: s.slot_index,
node_id: s.node_id.map(hex::encode),
state: s.state.as_str().to_string(),
timestamp_ms: s.timestamp_ms,
emoji: s.emoji,
}).collect())
}
#[tauri::command]
async fn get_message_comments(
state: State<'_, AppNode>,
post_id: String,
) -> Result<Vec<CommentSlotDto>, String> {
let node = get_node(&state).await;
let pid = hex_to_postid(&post_id)?;
let slots = node.read_comment_slots(pid).await.map_err(|e| e.to_string())?;
let mut dtos = Vec::new();
for s in slots {
let author_name = match node.resolve_display_name(&s.author).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
dtos.push(CommentSlotDto {
slot_index: s.slot_index,
author: hex::encode(s.author),
author_name,
timestamp_ms: s.timestamp_ms,
content: s.content,
});
}
Ok(dtos)
}
fn hex_to_postid(hex_str: &str) -> Result<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 ---
// --- Identity management IPC commands ---
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct IdentityInfoDto {
node_id: String,
display_name: String,
created_at: u64,
last_used_at: u64,
is_active: bool,
}
#[tauri::command]
async fn list_identities(state: State<'_, AppIdentity>) -> Result<Vec<IdentityInfoDto>, String> {
let mgr = state.lock().await;
let active_id = mgr.active_id();
let identities = mgr.list_identities().map_err(|e| e.to_string())?;
Ok(identities.into_iter().map(|i| IdentityInfoDto {
is_active: active_id.map_or(false, |a| a == i.node_id),
node_id: i.node_id_hex,
display_name: i.display_name,
created_at: i.created_at,
last_used_at: i.last_used_at,
}).collect())
}
#[tauri::command]
async fn create_identity(state: State<'_, AppIdentity>, name: String) -> Result<String, String> {
let mgr = state.lock().await;
let node_id = mgr.create_identity(&name).map_err(|e| e.to_string())?;
Ok(hex::encode(node_id))
}
#[tauri::command]
async fn switch_identity(
node_state: State<'_, AppNode>,
id_state: State<'_, AppIdentity>,
node_id_hex: String,
) -> Result<String, String> {
let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;
let mut mgr = id_state.lock().await;
let new_node = mgr.switch_identity(&nid).await.map_err(|e| e.to_string())?;
// Start background tasks on the new node
new_node.start_accept_loop();
new_node.start_sync_cycle();
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_convection_loop();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
new_node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = new_node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&new_node), 300, cache_max_bytes);
// Hot-swap the active node
{
let mut current = node_state.write().await;
*current = new_node;
}
Ok(format!("Switched to {}", &node_id_hex[..12]))
}
#[tauri::command]
async fn delete_identity(state: State<'_, AppIdentity>, node_id_hex: String) -> Result<String, String> {
let nid_bytes = hex::decode(&node_id_hex).map_err(|e| e.to_string())?;
let nid: NodeId = nid_bytes.try_into().map_err(|_| "Invalid node ID".to_string())?;
let mgr = state.lock().await;
mgr.delete_identity(&nid).map_err(|e| e.to_string())?;
Ok("Identity deleted".to_string())
}
#[tauri::command]
async fn import_identity_key(state: State<'_, AppIdentity>, key_hex: String, name: String) -> Result<String, String> {
let mgr = state.lock().await;
let node_id = mgr.import_identity_from_key(&key_hex, &name).map_err(|e| e.to_string())?;
Ok(hex::encode(node_id))
}
#[tauri::command]
async fn get_active_identity(state: State<'_, AppIdentity>) -> Result<Option<IdentityInfoDto>, String> {
let mgr = state.lock().await;
let active_id = mgr.active_id();
if active_id.is_none() {
return Ok(None);
}
let identities = mgr.list_identities().map_err(|e| e.to_string())?;
Ok(identities.into_iter().find(|i| Some(i.node_id) == active_id).map(|i| IdentityInfoDto {
is_active: true,
node_id: i.node_id_hex,
display_name: i.display_name,
created_at: i.created_at,
last_used_at: i.last_used_at,
}))
}
// --- Export/Import IPC ---
#[tauri::command]
async fn export_data(
state: State<'_, AppNode>,
scope: String,
output_dir: String,
) -> Result<String, String> {
let node = get_node(&state).await;
let export_scope = match scope.as_str() {
"identity_only" => itsgoin_core::export::ExportScope::IdentityOnly,
"posts_only" => itsgoin_core::export::ExportScope::PostsOnly,
"posts_with_identity" => itsgoin_core::export::ExportScope::PostsWithIdentity,
"everything" => itsgoin_core::export::ExportScope::Everything,
_ => return Err("Invalid scope".to_string()),
};
// Resolve output directory — on Android use app data dir, on desktop resolve relative to home
let resolved_dir = {
#[cfg(target_os = "android")]
{
let _ = &output_dir;
node.data_dir.join("exports")
}
#[cfg(not(target_os = "android"))]
{
if std::path::Path::new(&output_dir).is_relative() {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(&output_dir)
} else {
std::path::PathBuf::from(&output_dir)
}
}
};
// node_id here is only used for the export file name; the post filter
// inside export_data now iterates all posting identities itself.
let result = itsgoin_core::export::export_data(
&node.data_dir,
&node.storage,
&node.blob_store,
&node.node_id,
export_scope,
&resolved_dir,
).await.map_err(|e| e.to_string())?;
let paths: Vec<String> = result.paths.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
Ok(format!("Exported {} posts, {} blobs to {} file(s): {}",
result.post_count, result.blob_count, paths.len(),
paths.join(", ")))
}
/// Clear the duplicate identity flag and start sync tasks that were skipped.
#[tauri::command]
async fn clear_duplicate_flag(state: State<'_, AppNode>) -> Result<(), String> {
let node = get_node(&state).await;
node.network.duplicate_detected.store(false, std::sync::atomic::Ordering::Relaxed);
// Start the sync tasks that were skipped during bootstrap
node.start_accept_loop();
node.start_sync_cycle();
node.start_diff_cycle(120);
node.start_rebalance_cycle(600);
node.start_growth_loop();
node.start_recovery_loop();
node.start_social_checkin_cycle(3600);
node.start_convection_loop();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = node.storage.get().await;
storage.get_setting("cache_size_bytes").ok().flatten()
.and_then(|s| s.parse().ok()).unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes);
Ok(())
}
/// On Android: save a file from the app's internal storage to a user-chosen location via SAF.
/// On desktop: no-op (files are already in ~/Downloads).
#[tauri::command]
async fn share_file(app: tauri::AppHandle, file_path: String, mime_type: String) -> Result<String, String> {
#[cfg(target_os = "android")]
{
use tauri_plugin_android_fs::{AndroidFsExt, AndroidFs};
let path = std::path::Path::new(&file_path);
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "export.zip".to_string());
let data = std::fs::read(path).map_err(|e| format!("Failed to read file: {}", e))?;
let api = app.android_fs();
let uri = api.show_save_file_dialog(None, &filename, Some(&mime_type))
.map_err(|e| format!("Save dialog failed: {}", e))?;
match uri {
Some(uri) => {
api.write(&uri, &data).map_err(|e| format!("Write failed: {}", e))?;
Ok(format!("Saved to device"))
}
None => Ok("Cancelled".to_string()),
}
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, mime_type);
// Desktop: just return the path — file is already accessible
Ok(file_path)
}
}
#[tauri::command]
async fn import_summary(zip_path: String) -> Result<String, String> {
let summary = itsgoin_core::import::read_import_summary(std::path::Path::new(&zip_path))
.map_err(|e| e.to_string())?;
serde_json::to_string(&summary).map_err(|e| e.to_string())
}
#[tauri::command]
async fn import_public_posts(
state: State<'_, AppNode>,
zip_path: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Imported posts are re-authored as US — that must be a POSTING identity
// (the network NodeId never authors posts).
let result = itsgoin_core::import::import_public_posts(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
&node.default_posting_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
/// Import a bundle as personas on the current identity. The bundle's posting
/// keys become additional personas; imported content keeps its original author
/// and encrypted content becomes decryptable because we now hold those keys.
#[tauri::command]
async fn import_as_personas_cmd(
state: State<'_, AppNode>,
zip_path: String,
) -> Result<String, String> {
let node = get_node(&state).await;
let result = itsgoin_core::import::import_as_personas(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
).await.map_err(|e| e.to_string())?;
// Drop the disposable fresh-install persona if it's still pristine.
// Safe-by-construction: the helper bails unless ALL of [no name, no
// posts, no engagement, no longer the default] hold.
let pruned = node.try_prune_first_run_auto_persona().await
.unwrap_or(false);
let msg = if pruned {
format!("{} (cleared blank starter persona)", result.message)
} else {
result.message
};
Ok(msg)
}
#[tauri::command]
async fn import_as_new_identity(
state: State<'_, AppIdentity>,
zip_path: String,
) -> Result<String, String> {
let mgr = state.lock().await;
let base_dir = mgr.base_dir().to_path_buf();
drop(mgr);
let node_id = itsgoin_core::import::import_as_identity(
std::path::Path::new(&zip_path),
&base_dir,
).map_err(|e| e.to_string())?;
Ok(format!("Identity {} imported — switch to it in Settings", &node_id[..12]))
}
#[tauri::command]
async fn import_merge_with_key(
state: State<'_, AppNode>,
zip_path: String,
key_hex: String,
) -> Result<String, String> {
let node = get_node(&state).await;
// Merged content is authored under a POSTING identity, never the
// network NodeId.
let result = itsgoin_core::import::merge_with_key(
std::path::Path::new(&zip_path),
&key_hex,
&node.storage,
&node.blob_store,
&node.default_posting_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
)
.init();
// On desktop, create our own runtime. On mobile, Tauri provides one.
let _rt_guard = if cfg!(target_os = "android") || cfg!(target_os = "ios") {
None
} else {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
tauri::async_runtime::set(rt.handle().clone());
Some(rt)
};
info!("Starting Tauri app");
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.plugin({
#[cfg(target_os = "android")]
{ tauri_plugin_android_fs::init() }
#[cfg(not(target_os = "android"))]
{ tauri::plugin::Builder::<tauri::Wry>::new("android-fs-stub").build() }
})
.setup(move |app| {
// Desktop: store data next to the AppImage/executable so each copy
// gets its own identity. Mobile: use the standard app data dir.
let data_dir = if cfg!(target_os = "android") || cfg!(target_os = "ios") {
app.path().app_data_dir()?
} else {
// APPIMAGE env var points to the .AppImage file itself
let exe_dir = std::env::var("APPIMAGE")
.ok()
.and_then(|p| std::path::PathBuf::from(p).parent().map(|d| d.to_path_buf()))
.or_else(|| std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())));
match exe_dir {
Some(dir) => dir.join("itsgoin-data"),
None => app.path().app_data_dir()?,
}
};
std::fs::create_dir_all(&data_dir)?;
// Check for reset sentinel from previous session. A "Reset All
// Data" request wipes EVERYTHING under the app data dir so the
// next launch starts truly fresh — new network key, new posting
// key, no posts, no blobs, no identities.
let sentinel = data_dir.join(".reset");
if sentinel.exists() {
info!("Reset sentinel found — wiping all app data");
if let Ok(entries) = std::fs::read_dir(&data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.ends_with(".reset") { continue; }
if path.is_dir() {
let _ = std::fs::remove_dir_all(&path);
} else {
let _ = std::fs::remove_file(&path);
}
}
}
let _ = std::fs::remove_file(&sentinel);
}
info!(data_dir = %data_dir.display(), "Opening node via IdentityManager");
let is_mobile = cfg!(target_os = "android") || cfg!(target_os = "ios");
let profile = if is_mobile {
itsgoin_core::types::DeviceProfile::Mobile
} else {
itsgoin_core::types::DeviceProfile::Desktop
};
let (node, identity_mgr) = tauri::async_runtime::block_on(async {
let mut mgr = IdentityManager::open(&data_dir, None, profile).await?;
let n = if let Some(node) = mgr.active_node() {
Arc::clone(node)
} else {
// No identity — create a temporary one so the app can start.
// Frontend detects this via get_node_info and shows the first-run chooser.
info!("No identity found — creating initial identity for first-run setup");
let node_id = mgr.create_identity("My Identity")?;
mgr.switch_identity(&node_id).await?;
Arc::clone(mgr.active_node().expect("just created"))
};
Ok::<_, anyhow::Error>((n, mgr))
})?;
// Start bootstrap + background tasks AFTER setup completes (non-blocking)
let boot_node = Arc::clone(&node);
let boot_data_dir = data_dir.clone();
tauri::async_runtime::spawn(async move {
// Bootstrap: connect to anchors, NAT probe, referrals (slow — runs in background)
if let Err(e) = boot_node.run_bootstrap(&boot_data_dir).await {
tracing::warn!(error = %e, "Background bootstrap failed");
}
// Skip sync if duplicate identity detected
if boot_node.network.duplicate_detected.load(std::sync::atomic::Ordering::Relaxed) {
tracing::warn!("Duplicate identity detected — skipping sync tasks");
return;
}
// Start all background networking tasks
boot_node.start_accept_loop();
boot_node.start_sync_cycle();
boot_node.start_diff_cycle(120);
boot_node.start_rebalance_cycle(600);
boot_node.start_growth_loop();
boot_node.start_recovery_loop();
boot_node.start_social_checkin_cycle(3600);
boot_node.start_convection_loop();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();
boot_node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = boot_node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&boot_node), 300, cache_max_bytes);
});
// Manage both the swappable Node and the IdentityManager
let app_node: AppNode = Arc::new(tokio::sync::RwLock::new(node));
let app_identity: AppIdentity = Arc::new(tokio::sync::Mutex::new(identity_mgr));
app.manage(app_node);
app.manage(app_identity);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_node_info,
set_display_name,
set_profile,
create_post,
create_post_with_files,
create_post_as,
list_posting_identities,
create_posting_identity,
delete_posting_identity,
set_default_posting_identity,
get_blob,
get_blob_path,
save_and_open_blob,
save_blob,
get_feed,
get_feed_page,
get_all_posts_page,
get_all_posts,
get_stats,
connect_peer,
follow_node,
unfollow_node,
list_follows,
list_peers,
suggested_peers,
list_discover,
ignore_peer,
unignore_peer,
list_ignored_peers,
vouch_for_peer,
revoke_vouch_for_peer,
list_vouches_given,
list_vouches_received,
create_post_with_fof_comments,
comment_on_fof_post,
revoke_fof_commenter,
create_post_fof_closed,
read_fof_closed_body,
rotate_v_me,
cascade_revoke_v_me_epoch,
key_burn_post_slot,
list_circles,
create_circle,
delete_circle,
add_circle_member,
remove_circle_member,
delete_post,
revoke_circle_access,
get_redundancy_info,
set_anchors,
list_anchor_peers,
list_known_anchors,
check_release_announcement,
get_update_channel,
set_update_channel,
open_url_external,
get_first_run_auto_persona_id,
list_connections,
worm_lookup,
list_social_routes,
export_identity,
set_circle_profile,
get_circle_profile,
delete_circle_profile,
set_public_visible,
resolve_display,
get_public_visible,
sync_all,
sync_from_peer,
get_network_summary,
get_our_info,
pick_file,
pick_folder,
get_activity_log,
trigger_rebalance,
request_referrals,
reset_data,
react_to_post,
remove_reaction,
get_reactions,
get_reaction_counts,
comment_on_post,
edit_comment,
delete_comment,
get_comments,
set_comment_policy,
get_comment_policy,
get_comment_thread,
write_message_receipt,
write_message_comment,
get_message_receipts,
get_message_comments,
get_cache_stats,
send_notification,
get_setting,
set_setting,
mark_post_seen,
mark_conversation_read,
get_seen_engagement,
get_badge_counts,
get_last_read_message,
generate_share_link,
list_identities,
create_identity,
switch_identity,
delete_identity,
import_identity_key,
get_active_identity,
export_data,
share_file,
clear_duplicate_flag,
import_summary,
import_public_posts,
import_as_new_identity,
import_as_personas_cmd,
import_merge_with_key,
exit_app,
get_session_relay_enabled,
set_session_relay_enabled,
register_persona,
unregister_persona,
get_registry_status,
search_registry,
get_greeting_slot,
send_greeting,
reply_to_greeting,
list_greetings,
dismiss_greeting,
set_greetings_open,
get_greetings_open,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::Resumed = event {
// App resumed from background (mobile sleep/wake) —
// probe connections and recover dead ones immediately
if let Some(app_node) = app_handle.try_state::<AppNode>() {
let app_node = app_node.inner().clone();
tauri::async_runtime::spawn(async move {
let node = app_node.read().await.clone();
let removed = node.network.wake_health_check().await;
if removed > 0 {
tracing::info!(removed, "Wake health check: removed dead connections");
}
});
}
}
});
}