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>
3402 lines
116 KiB
Rust
3402 lines
116 KiB
Rust
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");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|