itsgoin/crates/tauri-app/src/lib.rs
Scott Reimers ce710a6596 feat(fof-layer4): Tauri commands + Settings "Rotate my vouch key" UI
Three new Tauri commands surface Layer 4 to the frontend:

- rotate_v_me() -> { newEpoch }: generates next V_me epoch +
  republishes bio. Old epoch retained in vouch_keys_own; existing
  vouchees receive the new key on their next bio-scan.
- cascade_revoke_v_me_epoch(retired_epoch, reason_code) ->
  { postsRevoked }: bulk per-post revocation across every author
  post that sealed slots under (self, retired_epoch). Useful as a
  follow-up after rotate_v_me when the author wants to actively
  cut off comment access on old posts.
- key_burn_post_slot(post_id_hex, slot_index, new_v_x_hex): the
  leaked-V_me primitive. Re-seals one slot under a different V_x.

Frontend (Settings → Vouches):
- New "Rotate my vouch key" button + status pill below the Given /
  Received lists.
- Confirmation prompt explains the grandfather-by-default semantics:
  "old posts remain readable to anyone who held the old key — cascade-
  revoke separately if you want to cut off old-content access."
- Wired once per settings-tab activation.

Cascade-revoke and key-burn surfaces aren't visualized yet (require
per-post selection UI); the Tauri commands are available for follow-up
UI work or scripting via the desktop dev console.

Workspace builds clean; 148 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:18:31 -06:00

3402 lines
116 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, PeerSlotKind, 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,
}
#[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;
storage.get_reaction_counts(id, &node.node_id).unwrap_or_default()
.into_iter()
.map(|(emoji, count, reacted_by_me)| ReactionCountDto { emoji, count, reacted_by_me })
.collect()
};
let comment_count = {
let storage = node.storage.get().await;
storage.get_comment_count(id).unwrap_or(0)
};
PostDto {
id: hex::encode(id),
author: hex::encode(post.author),
author_name,
content: post.content.clone(),
timestamp_ms: post.timestamp_ms,
is_me,
visibility,
intent_kind,
decrypted_content,
attachments,
recipients,
reaction_counts,
comment_count,
as_persona,
}
}
/// Decrypt a just-created post for immediate display. The post was authored
/// by one of our held posting identities (default or a specific persona);
/// look up that identity's secret to decrypt.
async fn decrypt_just_created(
node: &Node,
post: &Post,
vis: &PostVisibility,
) -> Option<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,
) -> Result<PostingIdentityDto, String> {
let node = get_node(&state).await;
let id = node.create_posting_identity(display_name).await.map_err(|e| e.to_string())?;
Ok(PostingIdentityDto {
node_id: hex::encode(id.node_id),
display_name: id.display_name,
created_at: id.created_at,
is_default: false,
})
}
#[tauri::command]
async fn delete_posting_identity(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
node.delete_posting_identity(&nid).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_default_posting_identity(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<(), String> {
let node = get_node(&state).await;
let nid = itsgoin_core::parse_node_id_hex(&node_id_hex).map_err(|e| e.to_string())?;
node.set_default_posting_identity(&nid).await.map_err(|e| e.to_string())
}
/// Create a post signed by a specific posting identity instead of the default.
#[tauri::command]
async fn create_post_as(
state: State<'_, AppNode>,
posting_id_hex: String,
content: String,
visibility: Option<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 reactions = storage.get_reaction_counts_batch(&post_ids, &node.node_id).unwrap_or_default();
let comments = storage.get_comment_counts_batch(&post_ids).unwrap_or_default();
let intents = storage.get_post_intents_batch(&post_ids).unwrap_or_default();
let identities = storage.list_posting_identities().unwrap_or_default();
(reactions, comments, intents, identities)
};
// Map posting-id -> display-name so we can tag author=persona posts.
let persona_names: HashMap<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 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) = {
let storage = node.storage.get().await;
let social: std::collections::HashSet<_> = storage
.list_social_routes()
.unwrap_or_default()
.into_iter()
.map(|r| r.node_id)
.collect();
let n2: std::collections::HashSet<_> = storage
.build_n2_share()
.unwrap_or_default()
.into_iter()
.collect();
let n3: std::collections::HashSet<_> = storage
.list_distinct_n3()
.unwrap_or_default()
.into_iter()
.collect();
(social, n2, n3)
};
let mut dtos = Vec::with_capacity(records.len());
for rec in &records {
let display_name = match node.resolve_display_name(&rec.node_id).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
};
let introduced_by_name = if let Some(ib) = &rec.introduced_by {
match node.resolve_display_name(ib).await {
Ok((name, _, _)) if !name.is_empty() => Some(name),
_ => None,
}
} else {
None
};
let reach = if mesh_ids.contains(&rec.node_id) {
"mesh"
} else if social_ids.contains(&rec.node_id) {
"n1"
} else if n2_ids.contains(&rec.node_id) {
"n2"
} else if n3_ids.contains(&rec.node_id) {
"n3"
} else {
"known"
};
dtos.push(PeerDto {
node_id: hex::encode(rec.node_id),
display_name,
addresses: rec.addresses.iter().map(|a| a.to_string()).collect(),
introduced_by: rec.introduced_by.map(|ib| {
introduced_by_name
.clone()
.unwrap_or_else(|| hex::encode(ib)[..12].to_string())
}),
is_anchor: rec.is_anchor,
last_seen: rec.last_seen,
reach: reach.to_string(),
is_online: mesh_ids.contains(&rec.node_id),
last_activity_ms: rec.last_seen,
});
}
Ok(dtos)
}
#[tauri::command]
async fn suggested_peers(state: State<'_, AppNode>) -> Result<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())
}
/// 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_kind, connected_at) in conns {
let display_name = node.get_display_name(&nid).await.unwrap_or(None);
dtos.push(ConnectionDto {
node_id: hex::encode(nid),
display_name,
slot_kind: format!("{:?}", slot_kind),
connected_at,
});
}
Ok(dtos)
}
#[tauri::command]
async fn worm_lookup(
state: State<'_, AppNode>,
node_id_hex: String,
) -> Result<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,
}))
}
#[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;
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
p.author != node.node_id
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
)
})
.count();
// My Posts badge: count own non-DM posts with unseen engagement
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
if total_reacts > 0 || total_comments > 0 {
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
new_engagement += 1;
}
}
}
// Unread messages: count conversations with messages newer than last_read
let mut unread_messages = 0usize;
let dm_posts = all_posts.iter().filter(|(id, p, _)| {
matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) || (p.author != node.node_id && matches!(
storage.get_post_with_visibility(id).ok().flatten(),
Some((_, PostVisibility::Encrypted { .. }))
))
});
let mut seen_partners = std::collections::HashSet::new();
for (_id, post, _vis) in dm_posts {
let partner = if post.author == node.node_id {
// sent DM — skip for unread count
continue;
} else {
post.author
};
if seen_partners.contains(&partner) { continue; }
seen_partners.insert(partner);
let last_read = storage.get_last_read_message(&partner).unwrap_or(0);
if post.timestamp_ms > last_read {
unread_messages += 1;
}
}
// Count new reacts and comments separately
let mut new_reacts = 0usize;
let mut new_comments = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
.unwrap_or_default().iter().map(|(_, c, _)| *c).sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 { new_reacts += (total_reacts - seen_r as u64) as usize; }
if total_comments > seen_c as u64 { new_comments += (total_comments - seen_c as u64) as usize; }
}
Ok(BadgeCountsDto { new_feed, new_engagement, unread_messages, new_reacts, new_comments })
}
#[tauri::command]
async fn get_last_read_message(
state: State<'_, AppNode>,
partner_id_hex: String,
) -> Result<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 {
preferred_count: usize,
local_count: usize,
wide_count: usize,
total_connections: usize,
n2_distinct: usize,
n3_distinct: usize,
has_public_v6: bool,
has_public_v4: bool,
has_upnp: bool,
}
#[tauri::command]
async fn get_network_summary(state: State<'_, AppNode>) -> Result<NetworkSummaryDto, String> {
let node = get_node(&state).await;
let conns = node.list_connections().await;
let mut preferred = 0usize;
let mut local = 0usize;
let mut wide = 0usize;
for (_nid, slot_kind, _at) in &conns {
match slot_kind {
PeerSlotKind::Preferred => preferred += 1,
PeerSlotKind::Local => local += 1,
PeerSlotKind::Wide => wide += 1,
}
}
let (n2, n3) = {
let storage = node.storage.get().await;
let n2 = storage.count_distinct_n2().unwrap_or(0);
let n3 = storage.count_distinct_n3().unwrap_or(0);
(n2, n3)
};
Ok(NetworkSummaryDto {
preferred_count: preferred,
local_count: local,
wide_count: wide,
total_connections: conns.len(),
n2_distinct: n2,
n3_distinct: n3,
has_public_v6: node.network.has_public_v6(),
has_public_v4: node.network.is_anchor(),
has_upnp: node.network.has_upnp(),
})
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OurInfoDto {
node_id: String,
addresses: Vec<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,
anchor_register_last_ms: u64,
anchor_register_interval_secs: 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, anchor_last) = node.timer_state();
let dto_events: Vec<ActivityEventDto> = events.into_iter().map(|e| {
ActivityEventDto {
timestamp_ms: e.timestamp_ms,
level: format!("{:?}", e.level).to_lowercase(),
category: format!("{:?}", e.category).to_lowercase(),
message: e.message,
peer_id: e.peer_id.map(hex::encode),
}
}).collect();
Ok(ActivityLogDto {
events: dto_events,
rebalance_last_ms: rebalance_last,
rebalance_interval_secs: 600,
anchor_register_last_ms: anchor_last,
anchor_register_interval_secs: 600,
})
}
#[tauri::command]
async fn trigger_rebalance(state: State<'_, 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;
// Try known_anchors table first (populated by anchor register cycle),
// fall back to anchor peers from the peers table (is_anchor = true)
let anchors: Vec<(NodeId, Vec<std::net::SocketAddr>)> = {
let storage = node.storage.get().await;
let known = storage.list_known_anchors().unwrap_or_default();
if !known.is_empty() {
known
} else {
storage.list_anchor_peers().unwrap_or_default()
.into_iter()
.map(|r| (r.node_id, r.addresses))
.collect()
}
};
if anchors.is_empty() {
return Ok("No known anchors".to_string());
}
let mut total = 0usize;
let mut reachable = 0usize;
for (anchor_nid, anchor_addrs) in &anchors {
if *anchor_nid == node_id {
continue;
}
// Connect to anchor if not already connected
if !node.network.is_peer_connected(anchor_nid).await {
let endpoint_id = match itsgoin_core::EndpointId::from_bytes(anchor_nid) {
Ok(eid) => eid,
Err(_) => continue,
};
let mut addr = itsgoin_core::EndpointAddr::from(endpoint_id);
for sa in anchor_addrs {
addr = addr.with_ip_addr(*sa);
}
if let Err(_) = node.network.connect_to_peer(*anchor_nid, addr).await {
continue;
}
}
match node.network.request_anchor_referrals(anchor_nid).await {
Ok(referrals) => {
reachable += 1;
for referral in &referrals {
if referral.node_id == node_id {
continue;
}
if let Some(addr_str) = referral.addresses.first() {
let connect_str = format!(
"{}@{}",
hex::encode(referral.node_id),
addr_str,
);
if let Ok((rid, raddr)) = itsgoin_core::parse_connect_string(&connect_str) {
match node.network.connect_to_peer(rid, raddr).await {
Ok(()) => {}
Err(_) => {
// Direct connect failed (NAT) — try hole punch via anchor
let _ = node.network.connect_via_introduction(rid, *anchor_nid).await;
}
}
total += 1;
}
}
}
}
Err(_) => {}
}
}
Ok(format!("Got {} referrals from {} anchors", total, reachable))
}
#[tauri::command]
async fn reset_data(state: State<'_, AppNode>) -> Result<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_pull_cycle(300);
new_node.start_diff_cycle(120);
new_node.start_rebalance_cycle(600);
new_node.start_growth_loop();
new_node.start_recovery_loop();
new_node.start_social_checkin_cycle(3600);
new_node.start_anchor_register_cycle(600);
new_node.start_upnp_renewal_cycle();
new_node.start_upnp_tcp_renewal_cycle();
new_node.start_http_server();
new_node.start_bootstrap_connectivity_check();
new_node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = new_node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&new_node), 300, cache_max_bytes);
// Hot-swap the active node
{
let mut current = node_state.write().await;
*current = new_node;
}
Ok(format!("Switched to {}", &node_id_hex[..12]))
}
#[tauri::command]
async fn delete_identity(state: State<'_, AppIdentity>, node_id_hex: String) -> Result<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)
}
}
};
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_pull_cycle(300);
node.start_diff_cycle(120);
node.start_rebalance_cycle(600);
node.start_growth_loop();
node.start_recovery_loop();
node.start_social_checkin_cycle(3600);
node.start_anchor_register_cycle(600);
node.start_upnp_renewal_cycle();
node.start_upnp_tcp_renewal_cycle();
node.start_http_server();
node.start_bootstrap_connectivity_check();
node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = node.storage.get().await;
storage.get_setting("cache_size_bytes").ok().flatten()
.and_then(|s| s.parse().ok()).unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&node), 300, cache_max_bytes);
Ok(())
}
/// On Android: save a file from the app's internal storage to a user-chosen location via SAF.
/// On desktop: no-op (files are already in ~/Downloads).
#[tauri::command]
async fn share_file(app: tauri::AppHandle, file_path: String, mime_type: String) -> Result<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;
let result = itsgoin_core::import::import_public_posts(
std::path::Path::new(&zip_path),
&node.storage,
&node.blob_store,
&node.node_id,
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
/// Import a bundle as personas on the current identity. The bundle's posting
/// keys become additional personas; imported content keeps its original author
/// and encrypted content becomes decryptable because we now hold those keys.
#[tauri::command]
async fn import_as_personas_cmd(
state: State<'_, AppNode>,
zip_path: String,
) -> Result<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;
let result = itsgoin_core::import::merge_with_key(
std::path::Path::new(&zip_path),
&key_hex,
&node.storage,
&node.blob_store,
&node.node_id,
&node.secret_seed(),
).await.map_err(|e| e.to_string())?;
Ok(result.message)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,iroh=warn,swarm_discovery=warn".parse().unwrap()),
)
.init();
// On desktop, create our own runtime. On mobile, Tauri provides one.
let _rt_guard = if cfg!(target_os = "android") || cfg!(target_os = "ios") {
None
} else {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime");
tauri::async_runtime::set(rt.handle().clone());
Some(rt)
};
info!("Starting Tauri app");
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_dialog::init())
.plugin({
#[cfg(target_os = "android")]
{ tauri_plugin_android_fs::init() }
#[cfg(not(target_os = "android"))]
{ tauri::plugin::Builder::<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_pull_cycle(300);
boot_node.start_diff_cycle(120);
boot_node.start_rebalance_cycle(600);
boot_node.start_growth_loop();
boot_node.start_recovery_loop();
boot_node.start_social_checkin_cycle(3600);
boot_node.start_anchor_register_cycle(600);
boot_node.start_upnp_renewal_cycle();
boot_node.start_upnp_tcp_renewal_cycle();
boot_node.start_http_server();
boot_node.start_bootstrap_connectivity_check();
boot_node.start_replication_cycle(600);
let cache_max_bytes: u64 = {
let storage = boot_node.storage.get().await;
storage.get_setting("cache_size_bytes")
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(1_073_741_824u64)
};
Node::start_eviction_cycle(Arc::clone(&boot_node), 300, cache_max_bytes);
});
// Manage both the swappable Node and the IdentityManager
let app_node: AppNode = Arc::new(tokio::sync::RwLock::new(node));
let app_identity: AppIdentity = Arc::new(tokio::sync::Mutex::new(identity_mgr));
app.manage(app_node);
app.manage(app_identity);
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_node_info,
set_display_name,
set_profile,
create_post,
create_post_with_files,
create_post_as,
list_posting_identities,
create_posting_identity,
delete_posting_identity,
set_default_posting_identity,
get_blob,
get_blob_path,
save_and_open_blob,
save_blob,
get_feed,
get_feed_page,
get_all_posts_page,
get_all_posts,
get_stats,
connect_peer,
follow_node,
unfollow_node,
list_follows,
list_peers,
suggested_peers,
list_discover,
ignore_peer,
unignore_peer,
list_ignored_peers,
vouch_for_peer,
revoke_vouch_for_peer,
list_vouches_given,
list_vouches_received,
create_post_with_fof_comments,
comment_on_fof_post,
revoke_fof_commenter,
create_post_fof_closed,
read_fof_closed_body,
rotate_v_me,
cascade_revoke_v_me_epoch,
key_burn_post_slot,
list_circles,
create_circle,
delete_circle,
add_circle_member,
remove_circle_member,
delete_post,
revoke_circle_access,
get_redundancy_info,
set_anchors,
list_anchor_peers,
list_known_anchors,
check_release_announcement,
get_update_channel,
set_update_channel,
open_url_external,
get_first_run_auto_persona_id,
list_connections,
worm_lookup,
list_social_routes,
export_identity,
set_circle_profile,
get_circle_profile,
delete_circle_profile,
set_public_visible,
resolve_display,
get_public_visible,
sync_all,
sync_from_peer,
get_network_summary,
get_our_info,
pick_file,
pick_folder,
get_activity_log,
trigger_rebalance,
request_referrals,
reset_data,
react_to_post,
remove_reaction,
get_reactions,
get_reaction_counts,
comment_on_post,
edit_comment,
delete_comment,
get_comments,
set_comment_policy,
get_comment_policy,
get_comment_thread,
write_message_receipt,
write_message_comment,
get_message_receipts,
get_message_comments,
get_cache_stats,
send_notification,
get_setting,
set_setting,
mark_post_seen,
mark_conversation_read,
get_seen_engagement,
get_badge_counts,
get_last_read_message,
generate_share_link,
list_identities,
create_identity,
switch_identity,
delete_identity,
import_identity_key,
get_active_identity,
export_data,
share_file,
clear_duplicate_flag,
import_summary,
import_public_posts,
import_as_new_identity,
import_as_personas_cmd,
import_merge_with_key,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::Resumed = event {
// App resumed from background (mobile sleep/wake) —
// probe connections and recover dead ones immediately
if let Some(app_node) = app_handle.try_state::<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");
}
});
}
}
});
}