feat: add IPC logging for all command invocations

- Introduced a macro `ipc_log!` to log the invocation and result of Tauri IPC commands in the Rust backend.
- Updated existing commands in `dm.rs` and `identity.rs` to use the new logging macro.
- Wrapped the `invoke` function in the frontend to log all IPC calls and their results, including redacting sensitive arguments.
This commit is contained in:
cloudwithax 2026-02-16 08:29:22 -05:00
parent 70377f13b8
commit 197d8ec16c
6 changed files with 757 additions and 633 deletions

View File

@ -12,8 +12,11 @@ use crate::protocol::messages::{
use crate::verification; use crate::verification;
use crate::AppState; use crate::AppState;
use super::ipc_log;
#[tauri::command] #[tauri::command]
pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
ipc_log!("start_node", {
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity let id = identity
.as_ref() .as_ref()
@ -205,10 +208,12 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re
} }
Ok(()) Ok(())
})
} }
#[tauri::command] #[tauri::command]
pub async fn stop_node(state: State<'_, AppState>) -> Result<(), String> { pub async fn stop_node(state: State<'_, AppState>) -> Result<(), String> {
ipc_log!("stop_node", {
let mut node_handle = state.node_handle.lock().await; let mut node_handle = state.node_handle.lock().await;
if let Some(handle) = node_handle.take() { if let Some(handle) = node_handle.take() {
@ -224,6 +229,7 @@ pub async fn stop_node(state: State<'_, AppState>) -> Result<(), String> {
} }
Ok(()) Ok(())
})
} }
#[tauri::command] #[tauri::command]
@ -232,6 +238,7 @@ pub async fn send_message(
channel_id: String, channel_id: String,
content: String, content: String,
) -> Result<ChatMessage, String> { ) -> Result<ChatMessage, String> {
ipc_log!("send_message", {
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?; let id = identity.as_ref().ok_or("no identity loaded")?;
@ -271,6 +278,7 @@ pub async fn send_message(
} }
Ok(msg) Ok(msg)
})
} }
#[tauri::command] #[tauri::command]
@ -280,13 +288,16 @@ pub async fn get_messages(
before: Option<u64>, before: Option<u64>,
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<ChatMessage>, String> { ) -> Result<Vec<ChatMessage>, String> {
ipc_log!("get_messages", {
let engine = state.crdt_engine.lock().await; let engine = state.crdt_engine.lock().await;
let community_id = find_community_for_channel(&engine, &channel_id)?; let community_id = find_community_for_channel(&engine, &channel_id)?;
engine.get_messages(&community_id, &channel_id, before, limit.unwrap_or(50)) engine.get_messages(&community_id, &channel_id, before, limit.unwrap_or(50))
})
} }
#[tauri::command] #[tauri::command]
pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Result<(), String> { pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Result<(), String> {
ipc_log!("send_typing", {
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?; let id = identity.as_ref().ok_or("no identity loaded")?;
@ -318,11 +329,13 @@ pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Resu
} }
Ok(()) Ok(())
})
} }
// broadcast current user status to all joined communities // broadcast current user status to all joined communities
#[tauri::command] #[tauri::command]
pub async fn broadcast_presence(state: State<'_, AppState>, status: String) -> Result<(), String> { pub async fn broadcast_presence(state: State<'_, AppState>, status: String) -> Result<(), String> {
ipc_log!("broadcast_presence", {
let peer_status = match status.as_str() { let peer_status = match status.as_str() {
"online" => PeerStatus::Online, "online" => PeerStatus::Online,
"idle" => PeerStatus::Idle, "idle" => PeerStatus::Idle,
@ -343,6 +356,7 @@ pub async fn broadcast_presence(state: State<'_, AppState>, status: String) -> R
} }
Ok(()) Ok(())
})
} }
// find which community a channel belongs to by checking all loaded documents // find which community a channel belongs to by checking all loaded documents
@ -367,6 +381,7 @@ fn find_community_for_channel(
// between a general internet outage and the relay being unreachable // between a general internet outage and the relay being unreachable
#[tauri::command] #[tauri::command]
pub async fn check_internet_connectivity() -> Result<bool, String> { pub async fn check_internet_connectivity() -> Result<bool, String> {
ipc_log!("check_internet_connectivity", {
let hosts = vec![ let hosts = vec![
("www.apple.com", 80), ("www.apple.com", 80),
("www.google.com", 80), ("www.google.com", 80),
@ -386,4 +401,5 @@ pub async fn check_internet_connectivity() -> Result<bool, String> {
let results = futures::future::join_all(futures).await; let results = futures::future::join_all(futures).await;
Ok(results.iter().any(|r| matches!(r, Ok(Ok(_))))) Ok(results.iter().any(|r| matches!(r, Ok(Ok(_)))))
})
} }

View File

@ -3,6 +3,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use tauri::State; use tauri::State;
use super::ipc_log;
use crate::crdt::sync::{DocumentSnapshot, SyncMessage}; use crate::crdt::sync::{DocumentSnapshot, SyncMessage};
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
@ -100,74 +101,76 @@ pub async fn create_community(
name: String, name: String,
description: String, description: String,
) -> Result<CommunityMeta, String> { ) -> Result<CommunityMeta, String> {
let identity = state.identity.lock().await; ipc_log!("create_community", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() as u64; .as_millis() as u64;
// generate a deterministic community id from name + creator + timestamp // generate a deterministic community id from name + creator + timestamp
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(name.as_bytes()); hasher.update(name.as_bytes());
hasher.update(id.peer_id.to_bytes()); hasher.update(id.peer_id.to_bytes());
hasher.update(now.to_le_bytes()); hasher.update(now.to_le_bytes());
let hash = hasher.finalize(); let hash = hasher.finalize();
let community_id = format!("com_{}", &hex::encode(hash)[..16]); let community_id = format!("com_{}", &hex::encode(hash)[..16]);
let peer_id_str = id.peer_id.to_string(); let peer_id_str = id.peer_id.to_string();
drop(identity); drop(identity);
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
engine.create_community(&community_id, &name, &description, &peer_id_str)?; engine.create_community(&community_id, &name, &description, &peer_id_str)?;
let meta = engine.get_community_meta(&community_id)?; let meta = engine.get_community_meta(&community_id)?;
// save metadata cache // save metadata cache
let _ = state.storage.save_community_meta(&meta); let _ = state.storage.save_community_meta(&meta);
drop(engine); drop(engine);
// subscribe to community topics on the p2p node // subscribe to community topics on the p2p node
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let presence_topic = gossip::topic_for_presence(&community_id); let presence_topic = gossip::topic_for_presence(&community_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { .send(NodeCommand::Subscribe {
topic: presence_topic, topic: presence_topic,
}) })
.await; .await;
// subscribe to the default general channel // subscribe to the default general channel
let engine = state.crdt_engine.lock().await; let engine = state.crdt_engine.lock().await;
if let Ok(channels) = engine.get_channels(&community_id) { if let Ok(channels) = engine.get_channels(&community_id) {
for channel in &channels { for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id); let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { topic: msg_topic }) .send(NodeCommand::Subscribe { topic: msg_topic })
.await; .await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id); let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { .send(NodeCommand::Subscribe {
topic: typing_topic, topic: typing_topic,
}) })
.await; .await;
}
} }
// register on rendezvous so peers joining via invite can discover us
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.await;
} }
// register on rendezvous so peers joining via invite can discover us Ok(meta)
let namespace = format!("dusk/community/{}", community_id); })
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous { namespace })
.await;
}
Ok(meta)
} }
#[tauri::command] #[tauri::command]
@ -175,103 +178,105 @@ pub async fn join_community(
state: State<'_, AppState>, state: State<'_, AppState>,
invite_code: String, invite_code: String,
) -> Result<CommunityMeta, String> { ) -> Result<CommunityMeta, String> {
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?; ipc_log!("join_community", {
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
let local_peer_id = { let local_peer_id = {
let identity = state.identity.lock().await; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?; let id = identity.as_ref().ok_or("no identity loaded")?;
id.peer_id.to_string() id.peer_id.to_string()
}; };
// create a placeholder document that will be backfilled via crdt sync // create a placeholder document that will be backfilled via crdt sync
// once we connect to existing community members through the relay // once we connect to existing community members through the relay
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
let had_existing_doc = engine.has_community(&invite.community_id); let had_existing_doc = engine.has_community(&invite.community_id);
if !had_existing_doc { if !had_existing_doc {
engine.create_placeholder_community(&invite.community_id, &invite.community_name, "")?; engine.create_placeholder_community(&invite.community_id, &invite.community_name, "")?;
} }
// joining via invite must never keep elevated local roles from stale local docs // joining via invite must never keep elevated local roles from stale local docs
if had_existing_doc { if had_existing_doc {
if let Ok(members) = engine.get_members(&invite.community_id) { if let Ok(members) = engine.get_members(&invite.community_id) {
let local_has_elevated_role = members.iter().any(|member| { let local_has_elevated_role = members.iter().any(|member| {
member.peer_id == local_peer_id member.peer_id == local_peer_id
&& member && member
.roles .roles
.iter() .iter()
.any(|role| role == "owner" || role == "admin") .any(|role| role == "owner" || role == "admin")
}); });
if local_has_elevated_role { if local_has_elevated_role {
let roles = vec!["member".to_string()]; let roles = vec!["member".to_string()];
let _ = engine.set_member_role(&invite.community_id, &local_peer_id, &roles); let _ = engine.set_member_role(&invite.community_id, &local_peer_id, &roles);
}
} }
} }
}
let meta = engine.get_community_meta(&invite.community_id)?; let meta = engine.get_community_meta(&invite.community_id)?;
let _ = state.storage.save_community_meta(&meta); let _ = state.storage.save_community_meta(&meta);
// subscribe to gossipsub topics so we receive messages // subscribe to gossipsub topics so we receive messages
let channels = engine let channels = engine
.get_channels(&invite.community_id) .get_channels(&invite.community_id)
.unwrap_or_default(); .unwrap_or_default();
drop(engine); drop(engine);
// mark this community for one-time role hardening on first sync merge // mark this community for one-time role hardening on first sync merge
{ {
let mut guard = state.pending_join_role_guard.lock().await; let mut guard = state.pending_join_role_guard.lock().await;
guard.insert(invite.community_id.clone()); guard.insert(invite.community_id.clone());
} }
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
// subscribe to the community presence topic // subscribe to the community presence topic
let presence_topic = gossip::topic_for_presence(&invite.community_id); let presence_topic = gossip::topic_for_presence(&invite.community_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: presence_topic,
})
.await;
// subscribe to all channel topics
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&invite.community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&invite.community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { .send(NodeCommand::Subscribe {
topic: typing_topic, topic: presence_topic,
}) })
.await; .await;
// subscribe to all channel topics
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&invite.community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&invite.community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe {
topic: typing_topic,
})
.await;
}
// register on rendezvous so existing members can find us
let namespace = format!("dusk/community/{}", invite.community_id);
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
// discover existing members through rendezvous
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await;
} }
// register on rendezvous so existing members can find us // request a snapshot now so joins work even when peers were already connected
let namespace = format!("dusk/community/{}", invite.community_id); request_sync(&state).await;
let _ = handle
.command_tx
.send(NodeCommand::RegisterRendezvous {
namespace: namespace.clone(),
})
.await;
// discover existing members through rendezvous Ok(meta)
let _ = handle })
.command_tx
.send(NodeCommand::DiscoverRendezvous { namespace })
.await;
}
// request a snapshot now so joins work even when peers were already connected
request_sync(&state).await;
Ok(meta)
} }
#[tauri::command] #[tauri::command]
@ -279,90 +284,94 @@ pub async fn leave_community(
state: State<'_, AppState>, state: State<'_, AppState>,
community_id: String, community_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let local_peer_id = { ipc_log!("leave_community", {
let identity = state.identity.lock().await; let local_peer_id = {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
id.peer_id.to_string() let id = identity.as_ref().ok_or("no identity loaded")?;
}; id.peer_id.to_string()
};
// remove local user from the shared member list before leaving // remove local user from the shared member list before leaving
let mut removed_self = false; let mut removed_self = false;
let channels = { let channels = {
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
let channels = engine.get_channels(&community_id).unwrap_or_default(); let channels = engine.get_channels(&community_id).unwrap_or_default();
if let Ok(members) = engine.get_members(&community_id) { if let Ok(members) = engine.get_members(&community_id) {
if members.iter().any(|member| member.peer_id == local_peer_id) { if members.iter().any(|member| member.peer_id == local_peer_id) {
if engine.remove_member(&community_id, &local_peer_id).is_ok() { if engine.remove_member(&community_id, &local_peer_id).is_ok() {
removed_self = true; removed_self = true;
}
} }
} }
channels
};
if removed_self {
broadcast_sync(&state, &community_id).await;
} }
channels // unsubscribe from all community topics and stop advertising this namespace
}; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
if removed_self { let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
broadcast_sync(&state, &community_id).await; let _ = handle
} .command_tx
.send(NodeCommand::Unsubscribe {
topic: typing_topic,
})
.await;
}
// unsubscribe from all community topics and stop advertising this namespace let presence_topic = gossip::topic_for_presence(&community_id);
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
for channel in &channels {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle
.command_tx
.send(NodeCommand::Unsubscribe { topic: msg_topic })
.await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Unsubscribe { .send(NodeCommand::Unsubscribe {
topic: typing_topic, topic: presence_topic,
}) })
.await; .await;
let namespace = format!("dusk/community/{}", community_id);
let _ = handle
.command_tx
.send(NodeCommand::UnregisterRendezvous { namespace })
.await;
} }
let presence_topic = gossip::topic_for_presence(&community_id); // remove local cached community state so leave persists across restarts
let _ = handle let mut engine = state.crdt_engine.lock().await;
.command_tx engine.remove_community(&community_id)?;
.send(NodeCommand::Unsubscribe { drop(engine);
topic: presence_topic,
})
.await;
let namespace = format!("dusk/community/{}", community_id); let mut guard = state.pending_join_role_guard.lock().await;
let _ = handle guard.remove(&community_id);
.command_tx
.send(NodeCommand::UnregisterRendezvous { namespace })
.await;
}
// remove local cached community state so leave persists across restarts Ok(())
let mut engine = state.crdt_engine.lock().await; })
engine.remove_community(&community_id)?;
drop(engine);
let mut guard = state.pending_join_role_guard.lock().await;
guard.remove(&community_id);
Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn get_communities(state: State<'_, AppState>) -> Result<Vec<CommunityMeta>, String> { pub async fn get_communities(state: State<'_, AppState>) -> Result<Vec<CommunityMeta>, String> {
let engine = state.crdt_engine.lock().await; ipc_log!("get_communities", {
let mut communities = Vec::new(); let engine = state.crdt_engine.lock().await;
let mut communities = Vec::new();
for id in engine.community_ids() { for id in engine.community_ids() {
if let Ok(meta) = engine.get_community_meta(&id) { if let Ok(meta) = engine.get_community_meta(&id) {
communities.push(meta); communities.push(meta);
}
} }
}
Ok(communities) Ok(communities)
})
} }
#[tauri::command] #[tauri::command]
@ -374,57 +383,59 @@ pub async fn create_channel(
kind: Option<String>, kind: Option<String>,
category_id: Option<String>, category_id: Option<String>,
) -> Result<ChannelMeta, String> { ) -> Result<ChannelMeta, String> {
let mut hasher = Sha256::new(); ipc_log!("create_channel", {
hasher.update(community_id.as_bytes()); let mut hasher = Sha256::new();
hasher.update(name.as_bytes()); hasher.update(community_id.as_bytes());
let now = SystemTime::now() hasher.update(name.as_bytes());
.duration_since(UNIX_EPOCH) let now = SystemTime::now()
.unwrap() .duration_since(UNIX_EPOCH)
.as_millis() as u64; .unwrap()
hasher.update(now.to_le_bytes()); .as_millis() as u64;
let hash = hasher.finalize(); hasher.update(now.to_le_bytes());
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]); let hash = hasher.finalize();
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]);
let channel_kind = match kind.as_deref() { let channel_kind = match kind.as_deref() {
Some("voice") | Some("Voice") => ChannelKind::Voice, Some("voice") | Some("Voice") => ChannelKind::Voice,
_ => ChannelKind::Text, _ => ChannelKind::Text,
}; };
let channel = ChannelMeta { let channel = ChannelMeta {
id: channel_id, id: channel_id,
community_id: community_id.clone(), community_id: community_id.clone(),
name, name,
topic, topic,
kind: channel_kind, kind: channel_kind,
position: 0, position: 0,
category_id, category_id,
}; };
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
engine.create_channel(&community_id, &channel)?; engine.create_channel(&community_id, &channel)?;
drop(engine); drop(engine);
// subscribe to the new channel's topics // subscribe to the new channel's topics
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id); let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { topic: msg_topic }) .send(NodeCommand::Subscribe { topic: msg_topic })
.await; .await;
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id); let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Subscribe { .send(NodeCommand::Subscribe {
topic: typing_topic, topic: typing_topic,
}) })
.await; .await;
} }
broadcast_sync(&state, &community_id).await; broadcast_sync(&state, &community_id).await;
Ok(channel) Ok(channel)
})
} }
#[tauri::command] #[tauri::command]
@ -432,8 +443,10 @@ pub async fn get_channels(
state: State<'_, AppState>, state: State<'_, AppState>,
community_id: String, community_id: String,
) -> Result<Vec<ChannelMeta>, String> { ) -> Result<Vec<ChannelMeta>, String> {
let engine = state.crdt_engine.lock().await; ipc_log!("get_channels", {
engine.get_channels(&community_id) let engine = state.crdt_engine.lock().await;
engine.get_channels(&community_id)
})
} }
#[tauri::command] #[tauri::command]
@ -442,31 +455,33 @@ pub async fn create_category(
community_id: String, community_id: String,
name: String, name: String,
) -> Result<CategoryMeta, String> { ) -> Result<CategoryMeta, String> {
let mut hasher = Sha256::new(); ipc_log!("create_category", {
hasher.update(community_id.as_bytes()); let mut hasher = Sha256::new();
hasher.update(name.as_bytes()); hasher.update(community_id.as_bytes());
let now = SystemTime::now() hasher.update(name.as_bytes());
.duration_since(UNIX_EPOCH) let now = SystemTime::now()
.unwrap() .duration_since(UNIX_EPOCH)
.as_millis() as u64; .unwrap()
hasher.update(now.to_le_bytes()); .as_millis() as u64;
let hash = hasher.finalize(); hasher.update(now.to_le_bytes());
let category_id = format!("cat_{}", &hex::encode(hash)[..12]); let hash = hasher.finalize();
let category_id = format!("cat_{}", &hex::encode(hash)[..12]);
let category = CategoryMeta { let category = CategoryMeta {
id: category_id, id: category_id,
community_id: community_id.clone(), community_id: community_id.clone(),
name, name,
position: 0, position: 0,
}; };
let mut engine = state.crdt_engine.lock().await; let mut engine = state.crdt_engine.lock().await;
engine.create_category(&community_id, &category)?; engine.create_category(&community_id, &category)?;
drop(engine); drop(engine);
broadcast_sync(&state, &community_id).await; broadcast_sync(&state, &community_id).await;
Ok(category) Ok(category)
})
} }
#[tauri::command] #[tauri::command]

View File

@ -2,6 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tauri::State; use tauri::State;
use super::ipc_log;
use crate::node::gossip; use crate::node::gossip;
use crate::node::NodeCommand; use crate::node::NodeCommand;
use crate::protocol::messages::{ use crate::protocol::messages::{
@ -19,103 +20,105 @@ pub async fn send_dm(
peer_id: String, peer_id: String,
content: String, content: String,
) -> Result<DirectMessage, String> { ) -> Result<DirectMessage, String> {
let identity = state.identity.lock().await; ipc_log!("send_dm", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let id = identity.as_ref().ok_or("no identity loaded")?;
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() as u64; .as_millis() as u64;
let local_peer_id = id.peer_id.to_string(); let local_peer_id = id.peer_id.to_string();
let display_name = id.display_name.clone(); let display_name = id.display_name.clone();
drop(identity); drop(identity);
let msg = DirectMessage { let msg = DirectMessage {
id: format!("dm_{}_{}", local_peer_id, now), id: format!("dm_{}_{}", local_peer_id, now),
from_peer: local_peer_id.clone(), from_peer: local_peer_id.clone(),
to_peer: peer_id.clone(), to_peer: peer_id.clone(),
from_display_name: display_name.clone(), from_display_name: display_name.clone(),
content: content.clone(), content: content.clone(),
timestamp: now, timestamp: now,
}; };
// derive the conversation id and persist the message // derive the conversation id and persist the message
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
state state
.storage .storage
.append_dm_message(&conversation_id, &msg) .append_dm_message(&conversation_id, &msg)
.map_err(|e| format!("failed to persist dm: {}", e))?; .map_err(|e| format!("failed to persist dm: {}", e))?;
// ensure conversation metadata exists on disk // ensure conversation metadata exists on disk
// try to load existing meta to preserve peer's display name, // try to load existing meta to preserve peer's display name,
// fall back to what we know from the directory // fall back to what we know from the directory
let existing_meta = state.storage.load_dm_conversation(&conversation_id).ok(); let existing_meta = state.storage.load_dm_conversation(&conversation_id).ok();
let peer_display_name = existing_meta let peer_display_name = existing_meta
.as_ref() .as_ref()
.map(|m| m.display_name.clone()) .map(|m| m.display_name.clone())
.unwrap_or_else(|| { .unwrap_or_else(|| {
// look up in directory // look up in directory
state state
.storage .storage
.load_directory() .load_directory()
.ok() .ok()
.and_then(|d| d.get(&peer_id).map(|e| e.display_name.clone())) .and_then(|d| d.get(&peer_id).map(|e| e.display_name.clone()))
.unwrap_or_else(|| peer_id.clone()) .unwrap_or_else(|| peer_id.clone())
}); });
let meta = DMConversationMeta { let meta = DMConversationMeta {
peer_id: peer_id.clone(), peer_id: peer_id.clone(),
display_name: peer_display_name, display_name: peer_display_name,
last_message: Some(content), last_message: Some(content),
last_message_time: Some(now), last_message_time: Some(now),
unread_count: existing_meta.map(|m| m.unread_count).unwrap_or(0), unread_count: existing_meta.map(|m| m.unread_count).unwrap_or(0),
}; };
state state
.storage .storage
.save_dm_conversation(&conversation_id, &meta) .save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to save dm conversation: {}", e))?; .map_err(|e| format!("failed to save dm conversation: {}", e))?;
// publish to the dm gossipsub topic // publish to the dm gossipsub topic
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let data = serde_json::to_vec(&GossipMessage::DirectMessage(msg.clone())) let data = serde_json::to_vec(&GossipMessage::DirectMessage(msg.clone()))
.map_err(|e| format!("serialize error: {}", e))?; .map_err(|e| format!("serialize error: {}", e))?;
// publish to the pair topic (for when both peers are already subscribed) // publish to the pair topic (for when both peers are already subscribed)
let pair_topic = gossip::topic_for_dm(&local_peer_id, &peer_id); let pair_topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
topic: pair_topic, topic: pair_topic,
data: data.clone(), data: data.clone(),
}) })
.await; .await;
// also publish to the recipient's inbox topic to guarantee delivery // also publish to the recipient's inbox topic to guarantee delivery
// on first-time dms where the peer isn't subscribed to the pair topic yet // on first-time dms where the peer isn't subscribed to the pair topic yet
let inbox_topic = gossip::topic_for_dm_inbox(&peer_id); let inbox_topic = gossip::topic_for_dm_inbox(&peer_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { .send(NodeCommand::SendMessage {
topic: inbox_topic, topic: inbox_topic,
data, data,
}) })
.await; .await;
// discover the peer via rendezvous in case we're not connected over wan // discover the peer via rendezvous in case we're not connected over wan
let discover_ns = format!("dusk/peer/{}", peer_id); let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::DiscoverRendezvous { .send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns, namespace: discover_ns,
}) })
.await; .await;
} }
Ok(msg) Ok(msg)
})
} }
// load dm messages for a conversation with a specific peer // load dm messages for a conversation with a specific peer
@ -126,17 +129,19 @@ pub async fn get_dm_messages(
before: Option<u64>, before: Option<u64>,
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<DirectMessage>, String> { ) -> Result<Vec<DirectMessage>, String> {
let identity = state.identity.lock().await; ipc_log!("get_dm_messages", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
state state
.storage .storage
.load_dm_messages(&conversation_id, before, limit.unwrap_or(50)) .load_dm_messages(&conversation_id, before, limit.unwrap_or(50))
.map_err(|e| format!("failed to load dm messages: {}", e)) .map_err(|e| format!("failed to load dm messages: {}", e))
})
} }
// search dm messages on the backend using sqlite indexes // search dm messages on the backend using sqlite indexes
@ -152,33 +157,35 @@ pub async fn search_dm_messages(
date_before: Option<u64>, date_before: Option<u64>,
limit: Option<usize>, limit: Option<usize>,
) -> Result<Vec<DirectMessage>, String> { ) -> Result<Vec<DirectMessage>, String> {
let identity = state.identity.lock().await; ipc_log!("search_dm_messages", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
let from_peer = match from_filter.as_deref() { let from_peer = match from_filter.as_deref() {
Some("me") => Some(local_peer_id), Some("me") => Some(local_peer_id),
Some("them") => Some(peer_id.clone()), Some("them") => Some(peer_id.clone()),
_ => None, _ => None,
}; };
let params = DmSearchParams { let params = DmSearchParams {
query, query,
from_peer, from_peer,
media_filter, media_filter,
mentions_only: mentions_only.unwrap_or(false), mentions_only: mentions_only.unwrap_or(false),
date_after, date_after,
date_before, date_before,
limit: limit.unwrap_or(200), limit: limit.unwrap_or(200),
}; };
state state
.storage .storage
.search_dm_messages(&conversation_id, &params) .search_dm_messages(&conversation_id, &params)
.map_err(|e| format!("failed to search dm messages: {}", e)) .map_err(|e| format!("failed to search dm messages: {}", e))
})
} }
// load all dm conversations for the sidebar // load all dm conversations for the sidebar
@ -186,35 +193,39 @@ pub async fn search_dm_messages(
pub async fn get_dm_conversations( pub async fn get_dm_conversations(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<DMConversationMeta>, String> { ) -> Result<Vec<DMConversationMeta>, String> {
let conversations = state ipc_log!("get_dm_conversations", {
.storage let conversations = state
.load_all_dm_conversations() .storage
.map_err(|e| format!("failed to load dm conversations: {}", e))?; .load_all_dm_conversations()
.map_err(|e| format!("failed to load dm conversations: {}", e))?;
Ok(conversations.into_iter().map(|(_, meta)| meta).collect()) Ok(conversations.into_iter().map(|(_, meta)| meta).collect())
})
} }
// mark all messages in a dm conversation as read // mark all messages in a dm conversation as read
#[tauri::command] #[tauri::command]
pub async fn mark_dm_read(state: State<'_, AppState>, peer_id: String) -> Result<(), String> { pub async fn mark_dm_read(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
let identity = state.identity.lock().await; ipc_log!("mark_dm_read", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
let mut meta = state let mut meta = state
.storage .storage
.load_dm_conversation(&conversation_id) .load_dm_conversation(&conversation_id)
.map_err(|e| format!("failed to load conversation: {}", e))?; .map_err(|e| format!("failed to load conversation: {}", e))?;
meta.unread_count = 0; meta.unread_count = 0;
state state
.storage .storage
.save_dm_conversation(&conversation_id, &meta) .save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to save conversation: {}", e)) .map_err(|e| format!("failed to save conversation: {}", e))
})
} }
// delete a dm conversation and all its messages // delete a dm conversation and all its messages
@ -223,61 +234,65 @@ pub async fn delete_dm_conversation(
state: State<'_, AppState>, state: State<'_, AppState>,
peer_id: String, peer_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let identity = state.identity.lock().await; ipc_log!("delete_dm_conversation", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
// unsubscribe from the dm topic // unsubscribe from the dm topic
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id); let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::Unsubscribe { topic }) .send(NodeCommand::Unsubscribe { topic })
.await; .await;
} }
state state
.storage .storage
.remove_dm_conversation(&conversation_id) .remove_dm_conversation(&conversation_id)
.map_err(|e| format!("failed to delete conversation: {}", e)) .map_err(|e| format!("failed to delete conversation: {}", e))
})
} }
// send a typing indicator in a dm conversation // send a typing indicator in a dm conversation
#[tauri::command] #[tauri::command]
pub async fn send_dm_typing(state: State<'_, AppState>, peer_id: String) -> Result<(), String> { pub async fn send_dm_typing(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
let identity = state.identity.lock().await; ipc_log!("send_dm_typing", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let now = SystemTime::now() let now = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_millis() as u64; .as_millis() as u64;
let indicator = DMTypingIndicator { let indicator = DMTypingIndicator {
from_peer: local_peer_id.clone(), from_peer: local_peer_id.clone(),
to_peer: peer_id.clone(), to_peer: peer_id.clone(),
timestamp: now, timestamp: now,
}; };
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id); let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let data = serde_json::to_vec(&GossipMessage::DMTyping(indicator)) let data = serde_json::to_vec(&GossipMessage::DMTyping(indicator))
.map_err(|e| format!("serialize error: {}", e))?; .map_err(|e| format!("serialize error: {}", e))?;
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::SendMessage { topic, data }) .send(NodeCommand::SendMessage { topic, data })
.await; .await;
} }
Ok(()) Ok(())
})
} }
// open a dm conversation with a peer (creates metadata on disk and subscribes to topic) // open a dm conversation with a peer (creates metadata on disk and subscribes to topic)
@ -288,16 +303,51 @@ pub async fn open_dm_conversation(
peer_id: String, peer_id: String,
display_name: String, display_name: String,
) -> Result<DMConversationMeta, String> { ) -> Result<DMConversationMeta, String> {
let identity = state.identity.lock().await; ipc_log!("open_dm_conversation", {
let id = identity.as_ref().ok_or("no identity loaded")?; let identity = state.identity.lock().await;
let local_peer_id = id.peer_id.to_string(); let id = identity.as_ref().ok_or("no identity loaded")?;
drop(identity); let local_peer_id = id.peer_id.to_string();
drop(identity);
let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id);
// check if conversation already exists // check if conversation already exists
if let Ok(existing) = state.storage.load_dm_conversation(&conversation_id) { if let Ok(existing) = state.storage.load_dm_conversation(&conversation_id) {
// subscribe to make sure we're listening // subscribe to make sure we're listening
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic })
.await;
// discover the peer via rendezvous to ensure wan connectivity
let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns,
})
.await;
}
return Ok(existing);
}
let meta = DMConversationMeta {
peer_id: peer_id.clone(),
display_name,
last_message: None,
last_message_time: None,
unread_count: 0,
};
state
.storage
.save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to create dm conversation: {}", e))?;
// subscribe to the dm topic so we receive messages
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id); let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
@ -306,7 +356,8 @@ pub async fn open_dm_conversation(
.send(NodeCommand::Subscribe { topic }) .send(NodeCommand::Subscribe { topic })
.await; .await;
// discover the peer via rendezvous to ensure wan connectivity // discover the peer via rendezvous to establish wan connectivity
// through the relay circuit before any messages are sent
let discover_ns = format!("dusk/peer/{}", peer_id); let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle let _ = handle
.command_tx .command_tx
@ -315,41 +366,7 @@ pub async fn open_dm_conversation(
}) })
.await; .await;
} }
return Ok(existing);
}
let meta = DMConversationMeta { Ok(meta)
peer_id: peer_id.clone(), })
display_name,
last_message: None,
last_message_time: None,
unread_count: 0,
};
state
.storage
.save_dm_conversation(&conversation_id, &meta)
.map_err(|e| format!("failed to create dm conversation: {}", e))?;
// subscribe to the dm topic so we receive messages
let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle {
let topic = gossip::topic_for_dm(&local_peer_id, &peer_id);
let _ = handle
.command_tx
.send(NodeCommand::Subscribe { topic })
.await;
// discover the peer via rendezvous to establish wan connectivity
// through the relay circuit before any messages are sent
let discover_ns = format!("dusk/peer/{}", peer_id);
let _ = handle
.command_tx
.send(NodeCommand::DiscoverRendezvous {
namespace: discover_ns,
})
.await;
}
Ok(meta)
} }

View File

@ -11,6 +11,8 @@ use crate::storage::UserSettings;
use crate::verification::{self, ChallengeSubmission}; use crate::verification::{self, ChallengeSubmission};
use crate::AppState; use crate::AppState;
use super::ipc_log;
// build a signed profile announcement and publish it on the directory topic // build a signed profile announcement and publish it on the directory topic
// so all connected peers immediately learn about the updated profile. // so all connected peers immediately learn about the updated profile.
// silently no-ops if the node isn't running yet. // silently no-ops if the node isn't running yet.
@ -46,25 +48,27 @@ async fn announce_profile(id: &DuskIdentity, state: &AppState) {
#[tauri::command] #[tauri::command]
pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> { pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> {
Ok(state.storage.has_identity()) ipc_log!("has_identity", Ok(state.storage.has_identity()))
} }
#[tauri::command] #[tauri::command]
pub async fn load_identity(state: State<'_, AppState>) -> Result<Option<PublicIdentity>, String> { pub async fn load_identity(state: State<'_, AppState>) -> Result<Option<PublicIdentity>, String> {
let mut identity = state.identity.lock().await; ipc_log!("load_identity", {
let mut identity = state.identity.lock().await;
if identity.is_some() { if identity.is_some() {
return Ok(identity.as_ref().map(|id| id.public_identity())); Ok(identity.as_ref().map(|id| id.public_identity()))
} } else {
match DuskIdentity::load(&state.storage) {
match DuskIdentity::load(&state.storage) { Ok(loaded) => {
Ok(loaded) => { let public = loaded.public_identity();
let public = loaded.public_identity(); *identity = Some(loaded);
*identity = Some(loaded); Ok(Some(public))
Ok(Some(public)) }
Err(_) => Ok(None),
}
} }
Err(_) => Ok(None), })
}
} }
#[tauri::command] #[tauri::command]
@ -74,56 +78,61 @@ pub async fn create_identity(
bio: Option<String>, bio: Option<String>,
challenge_data: Option<ChallengeSubmission>, challenge_data: Option<ChallengeSubmission>,
) -> Result<PublicIdentity, String> { ) -> Result<PublicIdentity, String> {
// require challenge data and re-validate behavioral analysis in rust ipc_log!("create_identity", {
let challenge = challenge_data.ok_or("verification required")?; // require challenge data and re-validate behavioral analysis in rust
let result = verification::analyze_challenge(&challenge); let challenge = challenge_data.ok_or("verification required")?;
if !result.is_human { let result = verification::analyze_challenge(&challenge);
return Err("verification failed".to_string()); if !result.is_human {
} Err("verification failed".to_string())
} else {
let mut new_identity =
DuskIdentity::generate(&display_name, &bio.unwrap_or_default());
let mut new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default()); // generate a cryptographic proof binding the verification to this keypair
let proof = verification::generate_proof(
&challenge,
&new_identity.keypair,
&new_identity.peer_id.to_string(),
)?;
// generate a cryptographic proof binding the verification to this keypair state
let proof = verification::generate_proof( .storage
&challenge, .save_verification_proof(&proof)
&new_identity.keypair, .map_err(|e| format!("failed to save verification proof: {}", e))?;
&new_identity.peer_id.to_string(),
)?;
state new_identity.verification_proof = Some(proof);
.storage new_identity.save(&state.storage)?;
.save_verification_proof(&proof)
.map_err(|e| format!("failed to save verification proof: {}", e))?;
new_identity.verification_proof = Some(proof); // also save initial settings with this display name so they're in sync
new_identity.save(&state.storage)?; let mut settings = state.storage.load_settings().unwrap_or_default();
settings.display_name = display_name.clone();
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save initial settings: {}", e))?;
// also save initial settings with this display name so they're in sync let public = new_identity.public_identity();
let mut settings = state.storage.load_settings().unwrap_or_default(); let mut identity = state.identity.lock().await;
settings.display_name = display_name.clone(); *identity = Some(new_identity);
state
.storage
.save_settings(&settings)
.map_err(|e| format!("failed to save initial settings: {}", e))?;
let public = new_identity.public_identity(); Ok(public)
let mut identity = state.identity.lock().await; }
*identity = Some(new_identity); })
Ok(public)
} }
#[tauri::command] #[tauri::command]
pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Result<(), String> { pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Result<(), String> {
let mut identity = state.identity.lock().await; ipc_log!("update_display_name", {
let id = identity.as_mut().ok_or("no identity loaded")?; let mut identity = state.identity.lock().await;
let id = identity.as_mut().ok_or("no identity loaded")?;
id.display_name = name; id.display_name = name;
id.save(&state.storage)?; id.save(&state.storage)?;
announce_profile(id, &state).await; announce_profile(id, &state).await;
Ok(()) Ok(())
})
} }
#[tauri::command] #[tauri::command]
@ -132,27 +141,31 @@ pub async fn update_profile(
display_name: String, display_name: String,
bio: String, bio: String,
) -> Result<PublicIdentity, String> { ) -> Result<PublicIdentity, String> {
let mut identity = state.identity.lock().await; ipc_log!("update_profile", {
let id = identity.as_mut().ok_or("no identity loaded")?; let mut identity = state.identity.lock().await;
let id = identity.as_mut().ok_or("no identity loaded")?;
id.display_name = display_name; id.display_name = display_name;
id.bio = bio; id.bio = bio;
id.save(&state.storage)?; id.save(&state.storage)?;
let public = id.public_identity(); let public = id.public_identity();
// re-announce so connected peers see the change immediately // re-announce so connected peers see the change immediately
announce_profile(id, &state).await; announce_profile(id, &state).await;
Ok(public) Ok(public)
})
} }
#[tauri::command] #[tauri::command]
pub async fn load_settings(state: State<'_, AppState>) -> Result<UserSettings, String> { pub async fn load_settings(state: State<'_, AppState>) -> Result<UserSettings, String> {
state ipc_log!("load_settings", {
.storage state
.load_settings() .storage
.map_err(|e| format!("failed to load settings: {}", e)) .load_settings()
.map_err(|e| format!("failed to load settings: {}", e))
})
} }
#[tauri::command] #[tauri::command]
@ -160,75 +173,79 @@ pub async fn save_settings(
state: State<'_, AppState>, state: State<'_, AppState>,
settings: UserSettings, settings: UserSettings,
) -> Result<(), String> { ) -> Result<(), String> {
// check if status changed so we can broadcast the new presence ipc_log!("save_settings", {
let old_status = state // check if status changed so we can broadcast the new presence
.storage let old_status = state
.load_settings() .storage
.map(|s| s.status) .load_settings()
.unwrap_or_else(|_| "online".to_string()); .map(|s| s.status)
let status_changed = old_status != settings.status; .unwrap_or_else(|_| "online".to_string());
let status_changed = old_status != settings.status;
// also update the identity display name if it changed // also update the identity display name if it changed
let mut identity = state.identity.lock().await; let mut identity = state.identity.lock().await;
let mut name_changed = false; let mut name_changed = false;
if let Some(id) = identity.as_mut() { if let Some(id) = identity.as_mut() {
if id.display_name != settings.display_name { if id.display_name != settings.display_name {
id.display_name = settings.display_name.clone(); id.display_name = settings.display_name.clone();
id.save(&state.storage)?; id.save(&state.storage)?;
name_changed = true; name_changed = true;
}
} }
}
// re-announce if the display name was updated through settings // re-announce if the display name was updated through settings
if name_changed { if name_changed {
if let Some(id) = identity.as_ref() { if let Some(id) = identity.as_ref() {
announce_profile(id, &state).await; announce_profile(id, &state).await;
}
} }
} drop(identity);
drop(identity);
// broadcast presence if status changed // broadcast presence if status changed
if status_changed { if status_changed {
use crate::node::NodeCommand; use crate::node::NodeCommand;
use crate::protocol::messages::PeerStatus; use crate::protocol::messages::PeerStatus;
let peer_status = match settings.status.as_str() { let peer_status = match settings.status.as_str() {
"idle" => PeerStatus::Idle, "idle" => PeerStatus::Idle,
"dnd" => PeerStatus::Dnd, "dnd" => PeerStatus::Dnd,
"invisible" => PeerStatus::Offline, "invisible" => PeerStatus::Offline,
_ => PeerStatus::Online, _ => PeerStatus::Online,
}; };
let node_handle = state.node_handle.lock().await; let node_handle = state.node_handle.lock().await;
if let Some(ref handle) = *node_handle { if let Some(ref handle) = *node_handle {
let _ = handle let _ = handle
.command_tx .command_tx
.send(NodeCommand::BroadcastPresence { .send(NodeCommand::BroadcastPresence {
status: peer_status, status: peer_status,
}) })
.await; .await;
}
} }
}
state state
.storage .storage
.save_settings(&settings) .save_settings(&settings)
.map_err(|e| format!("failed to save settings: {}", e)) .map_err(|e| format!("failed to save settings: {}", e))
})
} }
// -- user directory commands -- // -- user directory commands --
#[tauri::command] #[tauri::command]
pub async fn get_known_peers(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> { pub async fn get_known_peers(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
let entries = state ipc_log!("get_known_peers", {
.storage let entries = state
.load_directory() .storage
.map_err(|e| format!("failed to load directory: {}", e))?; .load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let mut peers: Vec<DirectoryEntry> = entries.into_values().collect(); let mut peers: Vec<DirectoryEntry> = entries.into_values().collect();
// sort by last seen (most recent first) // sort by last seen (most recent first)
peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
Ok(peers) Ok(peers)
})
} }
#[tauri::command] #[tauri::command]
@ -236,50 +253,56 @@ pub async fn search_directory(
state: State<'_, AppState>, state: State<'_, AppState>,
query: String, query: String,
) -> Result<Vec<DirectoryEntry>, String> { ) -> Result<Vec<DirectoryEntry>, String> {
let entries = state ipc_log!("search_directory", {
.storage let entries = state
.load_directory() .storage
.map_err(|e| format!("failed to load directory: {}", e))?; .load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let query_lower = query.to_lowercase(); let query_lower = query.to_lowercase();
let mut results: Vec<DirectoryEntry> = entries let mut results: Vec<DirectoryEntry> = entries
.into_values() .into_values()
.filter(|entry| { .filter(|entry| {
entry.display_name.to_lowercase().contains(&query_lower) entry.display_name.to_lowercase().contains(&query_lower)
|| entry.peer_id.to_lowercase().contains(&query_lower) || entry.peer_id.to_lowercase().contains(&query_lower)
}) })
.collect(); .collect();
results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen)); results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
Ok(results) Ok(results)
})
} }
#[tauri::command] #[tauri::command]
pub async fn get_friends(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> { pub async fn get_friends(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
let entries = state ipc_log!("get_friends", {
.storage let entries = state
.load_directory() .storage
.map_err(|e| format!("failed to load directory: {}", e))?; .load_directory()
.map_err(|e| format!("failed to load directory: {}", e))?;
let mut friends: Vec<DirectoryEntry> = entries let mut friends: Vec<DirectoryEntry> = entries
.into_values() .into_values()
.filter(|entry| entry.is_friend) .filter(|entry| entry.is_friend)
.collect(); .collect();
friends.sort_by(|a, b| { friends.sort_by(|a, b| {
a.display_name a.display_name
.to_lowercase() .to_lowercase()
.cmp(&b.display_name.to_lowercase()) .cmp(&b.display_name.to_lowercase())
}); });
Ok(friends) Ok(friends)
})
} }
#[tauri::command] #[tauri::command]
pub async fn add_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> { pub async fn add_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
state ipc_log!("add_friend", {
.storage state
.set_friend_status(&peer_id, true) .storage
.map_err(|e| format!("failed to add friend: {}", e)) .set_friend_status(&peer_id, true)
.map_err(|e| format!("failed to add friend: {}", e))
})
} }
#[tauri::command] #[tauri::command]

View File

@ -1,3 +1,20 @@
// logs every tauri ipc command invocation and its result to the terminal
macro_rules! ipc_log {
($cmd:expr, $body:expr) => {{
let start = std::time::Instant::now();
log::info!("[ipc] -> {}", $cmd);
let result = $body;
let elapsed = start.elapsed();
match &result {
Ok(_) => log::info!("[ipc] <- {} ok ({:.1?})", $cmd, elapsed),
Err(e) => log::error!("[ipc] <- {} err ({:.1?}): {}", $cmd, elapsed, e),
}
result
}};
}
pub(crate) use ipc_log;
pub mod chat; pub mod chat;
pub mod community; pub mod community;
pub mod dm; pub mod dm;

View File

@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { import type {
PublicIdentity, PublicIdentity,
@ -19,6 +19,42 @@ import type {
GifResponse, GifResponse,
} from "./types"; } from "./types";
// wrapped invoke that logs all ipc calls and errors
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const startTime = performance.now();
const logPrefix = `[ipc] ${cmd}`;
// log the call with args (redacting sensitive data)
const safeArgs = args ? redactSensitiveArgs(args) : undefined;
console.log(`${logPrefix} called`, safeArgs ? { args: safeArgs } : "");
try {
const result = await tauriInvoke<T>(cmd, args);
const duration = (performance.now() - startTime).toFixed(2);
console.log(`${logPrefix} success (${duration}ms)`, result);
return result;
} catch (error) {
const duration = (performance.now() - startTime).toFixed(2);
console.error(`${logPrefix} error (${duration}ms)`, error);
throw error;
}
}
// redact potentially sensitive values from args before logging
function redactSensitiveArgs(args: Record<string, unknown>): Record<string, unknown> {
const sensitiveKeys = ["password", "secret", "token", "key", "credential", "private"];
const redacted = { ...args };
for (const key of Object.keys(redacted)) {
const lowerKey = key.toLowerCase();
if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) {
redacted[key] = "[REDACTED]";
}
}
return redacted;
}
// -- identity -- // -- identity --
export async function hasIdentity(): Promise<boolean> { export async function hasIdentity(): Promise<boolean> {