diff --git a/bun.lock b/bun.lock index 3014287..a8b4d5e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@fontsource/space-grotesk": "^5.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", + "@thisbeyond/solid-dnd": "^0.7.5", "lucide-solid": "^0.469.0", "motion": "^12.0.0", "solid-js": "^1.9.3", @@ -255,6 +256,8 @@ "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], + "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/package.json b/package.json index a8684dd..35f0b16 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@fontsource/space-grotesk": "^5.2.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", + "@thisbeyond/solid-dnd": "^0.7.5", "lucide-solid": "^0.469.0", "motion": "^12.0.0", "solid-js": "^1.9.3", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2c3669..a044309 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "tauri-build", "tauri-plugin-shell", "tokio", + "webkit2gtk", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4431e3d..860b919 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ libp2p = { version = "0.54", features = [ "noise", "yamux", "tcp", + "dns", "tokio", "identify", "macros", @@ -56,3 +57,7 @@ dotenvy = "0.15" # async utilities futures = "0.3" + +# platform-specific: webview media permissions on linux +[target.'cfg(target_os = "linux")'.dependencies] +webkit2gtk = "2.0" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..d6ae74c 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..54c8a3d 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..4dc6642 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..6c1d310 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..194b9c7 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..063c3cb 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..b1f4c91 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..db7ebd4 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..2619376 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..fbd7bd2 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..92e8afb 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..dd2428b 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..f2d7a6c 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..a301f35 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..eb1f6a3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..375e730 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..2e43c10 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..fee7745 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..dedacf7 Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b18c09e Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2dceb6d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f81a6f3 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d473beb Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..f4ff190 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..619cf14 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d2cf59d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..33f4427 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ed49e16 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..793046d Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..70d6064 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..ea84298 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..4b9fa7b 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..3290bf6 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..4c4491d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..4c4491d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..04da6d5 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..a82208e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..0d6215b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..0d6215b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..0a91899 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..4c4491d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..faa5a09 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..faa5a09 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..7b150bb Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..1dce02e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..7b150bb Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..550c6df Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..a4720aa Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..95a78b4 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..d1dd00e Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index 8e06ffd..6fa01b5 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -1,10 +1,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tauri::State; +use tokio::net::TcpStream; +use tokio::time::{timeout, Duration}; use crate::node::gossip; use crate::node::{self, NodeCommand}; use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator}; +use crate::verification; use crate::AppState; #[tauri::command] @@ -19,11 +22,12 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re state.crdt_engine.clone(), state.storage.clone(), app, + state.voice_channels.clone(), ) .await?; // capture profile info for announcement before dropping identity lock - let profile_announcement = ProfileAnnouncement { + let mut profile_announcement = ProfileAnnouncement { peer_id: id.peer_id.to_string(), display_name: id.display_name.clone(), bio: id.bio.clone(), @@ -32,7 +36,11 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as u64, + verification_proof: id.verification_proof.clone(), + signature: String::new(), }; + profile_announcement.signature = + verification::sign_announcement(&id.keypair, &profile_announcement); drop(identity); { @@ -114,6 +122,41 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re .send(NodeCommand::RegisterRendezvous { namespace }) .await; } + + // subscribe to all existing dm conversation topics + let local_peer_str = { + let identity = state.identity.lock().await; + identity + .as_ref() + .map(|i| i.peer_id.to_string()) + .unwrap_or_default() + }; + if let Ok(conversations) = state.storage.load_all_dm_conversations() { + for (_, meta) in &conversations { + let dm_topic = gossip::topic_for_dm(&local_peer_str, &meta.peer_id); + let _ = handle + .command_tx + .send(NodeCommand::Subscribe { topic: dm_topic }) + .await; + } + } + + // subscribe to personal dm inbox so first-time dms from any peer land + let inbox_topic = gossip::topic_for_dm_inbox(&local_peer_str); + let _ = handle + .command_tx + .send(NodeCommand::Subscribe { topic: inbox_topic }) + .await; + + // register personal rendezvous namespace so any peer can discover + // and connect to us for dms even without sharing a community + let personal_ns = format!("dusk/peer/{}", local_peer_str); + let _ = handle + .command_tx + .send(NodeCommand::RegisterRendezvous { + namespace: personal_ns, + }) + .await; } Ok(()) @@ -242,3 +285,28 @@ fn find_community_for_channel( channel_id )) } + +// attempts tcp connections to well-known hosts to distinguish +// between a general internet outage and the relay being unreachable +#[tauri::command] +pub async fn check_internet_connectivity() -> Result { + let hosts = vec![ + ("www.apple.com", 80), + ("www.google.com", 80), + ("www.yahoo.com", 80), + ]; + + let connect_timeout = Duration::from_secs(5); + + let futures: Vec<_> = hosts + .into_iter() + .map(|(host, port)| { + let addr = format!("{}:{}", host, port); + timeout(connect_timeout, TcpStream::connect(addr)) + }) + .collect(); + + let results = futures::future::join_all(futures).await; + + Ok(results.iter().any(|r| matches!(r, Ok(Ok(_))))) +} diff --git a/src-tauri/src/commands/community.rs b/src-tauri/src/commands/community.rs index c43ad3c..744f43e 100644 --- a/src-tauri/src/commands/community.rs +++ b/src-tauri/src/commands/community.rs @@ -5,7 +5,7 @@ use tauri::State; use crate::node::gossip; use crate::node::NodeCommand; -use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta, Member}; +use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta, Member}; use crate::protocol::messages::PeerStatus; use crate::AppState; @@ -224,6 +224,8 @@ pub async fn create_channel( community_id: String, name: String, topic: String, + kind: Option, + category_id: Option, ) -> Result { let mut hasher = Sha256::new(); hasher.update(community_id.as_bytes()); @@ -236,12 +238,19 @@ pub async fn create_channel( let hash = hasher.finalize(); let channel_id = format!("ch_{}", &hex::encode(hash)[..12]); + let channel_kind = match kind.as_deref() { + Some("voice") | Some("Voice") => ChannelKind::Voice, + _ => ChannelKind::Text, + }; + let channel = ChannelMeta { id: channel_id, community_id: community_id.clone(), name, topic, - kind: ChannelKind::Text, + kind: channel_kind, + position: 0, + category_id, }; let mut engine = state.crdt_engine.lock().await; @@ -278,6 +287,59 @@ pub async fn get_channels( engine.get_channels(&community_id) } +#[tauri::command] +pub async fn create_category( + state: State<'_, AppState>, + community_id: String, + name: String, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(community_id.as_bytes()); + hasher.update(name.as_bytes()); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + hasher.update(now.to_le_bytes()); + let hash = hasher.finalize(); + let category_id = format!("cat_{}", &hex::encode(hash)[..12]); + + let category = CategoryMeta { + id: category_id, + community_id: community_id.clone(), + name, + position: 0, + }; + + let mut engine = state.crdt_engine.lock().await; + engine.create_category(&community_id, &category)?; + drop(engine); + + // broadcast the change via document sync + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let sync_topic = "dusk/sync".to_string(); + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: sync_topic, + data: community_id.as_bytes().to_vec(), + }) + .await; + } + + Ok(category) +} + +#[tauri::command] +pub async fn get_categories( + state: State<'_, AppState>, + community_id: String, +) -> Result, String> { + let engine = state.crdt_engine.lock().await; + engine.get_categories(&community_id) +} + #[tauri::command] pub async fn get_members( state: State<'_, AppState>, @@ -442,3 +504,30 @@ pub async fn generate_invite( Ok(invite.encode()) } + +#[tauri::command] +pub async fn reorder_channels( + state: State<'_, AppState>, + community_id: String, + channel_ids: Vec, +) -> Result, String> { + let mut engine = state.crdt_engine.lock().await; + let channels = engine.reorder_channels(&community_id, &channel_ids)?; + drop(engine); + + // broadcast the reordering to peers via document sync + // the change will propagate through the existing gossipsub sync mechanism + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let sync_topic = "dusk/sync".to_string(); + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: sync_topic, + data: community_id.as_bytes().to_vec(), + }) + .await; + } + + Ok(channels) +} diff --git a/src-tauri/src/commands/dm.rs b/src-tauri/src/commands/dm.rs new file mode 100644 index 0000000..180f621 --- /dev/null +++ b/src-tauri/src/commands/dm.rs @@ -0,0 +1,312 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use tauri::State; + +use crate::node::gossip; +use crate::node::NodeCommand; +use crate::protocol::messages::{ + DMConversationMeta, DMTypingIndicator, DirectMessage, GossipMessage, +}; +use crate::AppState; + +// send a direct message to a peer +// creates the conversation on disk if it doesn't exist, +// publishes the message over gossipsub on the pair topic +#[tauri::command] +pub async fn send_dm( + state: State<'_, AppState>, + peer_id: String, + content: String, +) -> Result { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let local_peer_id = id.peer_id.to_string(); + let display_name = id.display_name.clone(); + drop(identity); + + let msg = DirectMessage { + id: format!("dm_{}_{}", local_peer_id, now), + from_peer: local_peer_id.clone(), + to_peer: peer_id.clone(), + from_display_name: display_name.clone(), + content: content.clone(), + timestamp: now, + }; + + // derive the conversation id and persist the message + let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); + + state + .storage + .append_dm_message(&conversation_id, &msg) + .map_err(|e| format!("failed to persist dm: {}", e))?; + + // ensure conversation metadata exists on disk + // try to load existing meta to preserve peer's display name, + // fall back to what we know from the directory + let existing_meta = state.storage.load_dm_conversation(&conversation_id).ok(); + let peer_display_name = existing_meta + .as_ref() + .map(|m| m.display_name.clone()) + .unwrap_or_else(|| { + // look up in directory + state + .storage + .load_directory() + .ok() + .and_then(|d| d.get(&peer_id).map(|e| e.display_name.clone())) + .unwrap_or_else(|| peer_id.clone()) + }); + + let meta = DMConversationMeta { + peer_id: peer_id.clone(), + display_name: peer_display_name, + last_message: Some(content), + last_message_time: Some(now), + unread_count: existing_meta.map(|m| m.unread_count).unwrap_or(0), + }; + + state + .storage + .save_dm_conversation(&conversation_id, &meta) + .map_err(|e| format!("failed to save dm conversation: {}", e))?; + + // publish to the dm gossipsub topic + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let data = serde_json::to_vec(&GossipMessage::DirectMessage(msg.clone())) + .map_err(|e| format!("serialize error: {}", e))?; + + // 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 _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: pair_topic, + data: data.clone(), + }) + .await; + + // 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 + let inbox_topic = gossip::topic_for_dm_inbox(&peer_id); + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: inbox_topic, + data, + }) + .await; + + // discover the peer via rendezvous in case we're not connected over wan + let discover_ns = format!("dusk/peer/{}", peer_id); + let _ = handle + .command_tx + .send(NodeCommand::DiscoverRendezvous { + namespace: discover_ns, + }) + .await; + } + + Ok(msg) +} + +// load dm messages for a conversation with a specific peer +#[tauri::command] +pub async fn get_dm_messages( + state: State<'_, AppState>, + peer_id: String, + before: Option, + limit: Option, +) -> Result, String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let local_peer_id = id.peer_id.to_string(); + drop(identity); + + let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); + + state + .storage + .load_dm_messages(&conversation_id, before, limit.unwrap_or(50)) + .map_err(|e| format!("failed to load dm messages: {}", e)) +} + +// load all dm conversations for the sidebar +#[tauri::command] +pub async fn get_dm_conversations( + state: State<'_, AppState>, +) -> Result, String> { + let conversations = state + .storage + .load_all_dm_conversations() + .map_err(|e| format!("failed to load dm conversations: {}", e))?; + + Ok(conversations.into_iter().map(|(_, meta)| meta).collect()) +} + +// mark all messages in a dm conversation as read +#[tauri::command] +pub async fn mark_dm_read(state: State<'_, AppState>, peer_id: String) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let local_peer_id = id.peer_id.to_string(); + drop(identity); + + let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); + + let mut meta = state + .storage + .load_dm_conversation(&conversation_id) + .map_err(|e| format!("failed to load conversation: {}", e))?; + + meta.unread_count = 0; + + state + .storage + .save_dm_conversation(&conversation_id, &meta) + .map_err(|e| format!("failed to save conversation: {}", e)) +} + +// delete a dm conversation and all its messages +#[tauri::command] +pub async fn delete_dm_conversation( + state: State<'_, AppState>, + peer_id: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let local_peer_id = id.peer_id.to_string(); + drop(identity); + + let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); + + // unsubscribe from the dm topic + 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::Unsubscribe { topic }) + .await; + } + + state + .storage + .remove_dm_conversation(&conversation_id) + .map_err(|e| format!("failed to delete conversation: {}", e)) +} + +// send a typing indicator in a dm conversation +#[tauri::command] +pub async fn send_dm_typing(state: State<'_, AppState>, peer_id: String) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let local_peer_id = id.peer_id.to_string(); + drop(identity); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + let indicator = DMTypingIndicator { + from_peer: local_peer_id.clone(), + to_peer: peer_id.clone(), + timestamp: now, + }; + + 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 data = serde_json::to_vec(&GossipMessage::DMTyping(indicator)) + .map_err(|e| format!("serialize error: {}", e))?; + + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { topic, data }) + .await; + } + + Ok(()) +} + +// open a dm conversation with a peer (creates metadata on disk and subscribes to topic) +// used when clicking "message" on a peer's profile +#[tauri::command] +pub async fn open_dm_conversation( + state: State<'_, AppState>, + peer_id: String, + display_name: String, +) -> Result { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let local_peer_id = id.peer_id.to_string(); + drop(identity); + + let conversation_id = gossip::dm_conversation_id(&local_peer_id, &peer_id); + + // check if conversation already exists + if let Ok(existing) = state.storage.load_dm_conversation(&conversation_id) { + // 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; + 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) +} diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs index 02a5857..4ffde8a 100644 --- a/src-tauri/src/commands/identity.rs +++ b/src-tauri/src/commands/identity.rs @@ -5,10 +5,44 @@ use tauri::State; use crate::node::gossip; use crate::node::NodeCommand; use crate::protocol::identity::{DirectoryEntry, DuskIdentity, PublicIdentity}; -use crate::protocol::messages::{GossipMessage, ProfileRevocation}; +use crate::protocol::messages::{GossipMessage, ProfileAnnouncement, ProfileRevocation}; use crate::storage::UserSettings; +use crate::verification::{self, ChallengeSubmission}; use crate::AppState; +// build a signed profile announcement and publish it on the directory topic +// so all connected peers immediately learn about the updated profile. +// silently no-ops if the node isn't running yet. +async fn announce_profile(id: &DuskIdentity, state: &AppState) { + let mut announcement = ProfileAnnouncement { + peer_id: id.peer_id.to_string(), + display_name: id.display_name.clone(), + bio: id.bio.clone(), + public_key: hex::encode(id.keypair.public().encode_protobuf()), + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64, + verification_proof: id.verification_proof.clone(), + signature: String::new(), + }; + announcement.signature = verification::sign_announcement(&id.keypair, &announcement); + + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg = GossipMessage::ProfileAnnounce(announcement); + if let Ok(data) = serde_json::to_vec(&msg) { + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: gossip::topic_for_directory(), + data, + }) + .await; + } + } +} + #[tauri::command] pub async fn has_identity(state: State<'_, AppState>) -> Result { Ok(state.storage.has_identity()) @@ -37,8 +71,30 @@ pub async fn create_identity( state: State<'_, AppState>, display_name: String, bio: Option, + challenge_data: Option, ) -> Result { - let new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default()); + // require challenge data and re-validate behavioral analysis in rust + let challenge = challenge_data.ok_or("verification required")?; + let result = verification::analyze_challenge(&challenge); + if !result.is_human { + return Err("verification failed".to_string()); + } + + 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(), + )?; + + state + .storage + .save_verification_proof(&proof) + .map_err(|e| format!("failed to save verification proof: {}", e))?; + + new_identity.verification_proof = Some(proof); new_identity.save(&state.storage)?; // also save initial settings with this display name so they're in sync @@ -64,6 +120,8 @@ pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Re id.display_name = name; id.save(&state.storage)?; + announce_profile(id, &state).await; + Ok(()) } @@ -80,7 +138,12 @@ pub async fn update_profile( id.bio = bio; id.save(&state.storage)?; - Ok(id.public_identity()) + let public = id.public_identity(); + + // re-announce so connected peers see the change immediately + announce_profile(id, &state).await; + + Ok(public) } #[tauri::command] @@ -98,13 +161,23 @@ pub async fn save_settings( ) -> Result<(), String> { // also update the identity display name if it changed let mut identity = state.identity.lock().await; + let mut name_changed = false; if let Some(id) = identity.as_mut() { if id.display_name != settings.display_name { id.display_name = settings.display_name.clone(); id.save(&state.storage)?; + name_changed = true; } } + // re-announce if the display name was updated through settings + if name_changed { + if let Some(id) = identity.as_ref() { + announce_profile(id, &state).await; + } + } + drop(identity); + state .storage .save_settings(&settings) @@ -192,14 +265,16 @@ pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> { let id = identity.as_ref().ok_or("no identity loaded")?; // build the revocation message before we destroy the identity - let revocation = ProfileRevocation { + let mut revocation = ProfileRevocation { peer_id: id.peer_id.to_string(), public_key: hex::encode(id.keypair.public().encode_protobuf()), timestamp: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as u64, + signature: String::new(), }; + revocation.signature = verification::sign_revocation(&id.keypair, &revocation); // broadcast revocation on the directory gossip topic let node_handle = state.node_handle.lock().await; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c736c74..cd459bb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,5 @@ pub mod chat; pub mod community; +pub mod dm; pub mod identity; +pub mod voice; diff --git a/src-tauri/src/commands/voice.rs b/src-tauri/src/commands/voice.rs new file mode 100644 index 0000000..34a9946 --- /dev/null +++ b/src-tauri/src/commands/voice.rs @@ -0,0 +1,268 @@ +use tauri::State; + +use crate::node::gossip; +use crate::node::NodeCommand; +use crate::protocol::messages::{GossipMessage, VoiceMediaState, VoiceParticipant}; +use crate::AppState; + +#[tauri::command] +pub async fn join_voice_channel( + state: State<'_, AppState>, + community_id: String, + channel_id: String, +) -> Result, String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + + let peer_id = id.peer_id.to_string(); + let display_name = id.display_name.clone(); + drop(identity); + + let media_state = VoiceMediaState { + muted: false, + deafened: false, + video_enabled: false, + screen_sharing: false, + }; + + // subscribe to the voice topic for this channel + let voice_topic = gossip::topic_for_voice(&community_id, &channel_id); + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let _ = handle + .command_tx + .send(NodeCommand::Subscribe { + topic: voice_topic.clone(), + }) + .await; + + // publish our join announcement + let msg = GossipMessage::VoiceJoin { + community_id: community_id.clone(), + channel_id: channel_id.clone(), + peer_id: peer_id.clone(), + display_name: display_name.clone(), + media_state: media_state.clone(), + }; + let data = + serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: voice_topic, + data, + }) + .await; + } + + // add ourselves to the local voice channel tracking + let key = format!("{}:{}", community_id, channel_id); + let mut vc = state.voice_channels.lock().await; + let participants = vc.entry(key.clone()).or_insert_with(Vec::new); + participants.retain(|p| p.peer_id != peer_id); + participants.push(VoiceParticipant { + peer_id, + display_name, + media_state, + }); + + let result = participants.clone(); + drop(vc); + + log::info!("joined voice channel {}:{}", community_id, channel_id); + + Ok(result) +} + +#[tauri::command] +pub async fn leave_voice_channel( + state: State<'_, AppState>, + community_id: String, + channel_id: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let peer_id = id.peer_id.to_string(); + drop(identity); + + let voice_topic = gossip::topic_for_voice(&community_id, &channel_id); + + // publish our leave announcement before unsubscribing + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg = GossipMessage::VoiceLeave { + community_id: community_id.clone(), + channel_id: channel_id.clone(), + peer_id: peer_id.clone(), + }; + let data = + serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: voice_topic.clone(), + data, + }) + .await; + + // unsubscribe from the voice topic + let _ = handle + .command_tx + .send(NodeCommand::Unsubscribe { + topic: voice_topic, + }) + .await; + } + + // remove ourselves from local tracking + let key = format!("{}:{}", community_id, channel_id); + let mut vc = state.voice_channels.lock().await; + if let Some(participants) = vc.get_mut(&key) { + participants.retain(|p| p.peer_id != peer_id); + if participants.is_empty() { + vc.remove(&key); + } + } + drop(vc); + + log::info!("left voice channel {}:{}", community_id, channel_id); + + Ok(()) +} + +#[tauri::command] +pub async fn update_voice_media_state( + state: State<'_, AppState>, + community_id: String, + channel_id: String, + media_state: VoiceMediaState, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let peer_id = id.peer_id.to_string(); + drop(identity); + + let voice_topic = gossip::topic_for_voice(&community_id, &channel_id); + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg = GossipMessage::VoiceMediaStateUpdate { + community_id: community_id.clone(), + channel_id: channel_id.clone(), + peer_id: peer_id.clone(), + media_state: media_state.clone(), + }; + let data = + serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: voice_topic, + data, + }) + .await; + } + + // update local tracking + let key = format!("{}:{}", community_id, channel_id); + let mut vc = state.voice_channels.lock().await; + if let Some(participants) = vc.get_mut(&key) { + if let Some(p) = participants.iter_mut().find(|p| p.peer_id == peer_id) { + p.media_state = media_state; + } + } + drop(vc); + + Ok(()) +} + +#[tauri::command] +pub async fn send_voice_sdp( + state: State<'_, AppState>, + community_id: String, + channel_id: String, + to_peer: String, + sdp_type: String, + sdp: String, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let from_peer = id.peer_id.to_string(); + drop(identity); + + let voice_topic = gossip::topic_for_voice(&community_id, &channel_id); + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg = GossipMessage::VoiceSdp { + community_id, + channel_id, + from_peer, + to_peer, + sdp_type, + sdp, + }; + let data = + serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: voice_topic, + data, + }) + .await; + } + + Ok(()) +} + +#[tauri::command] +pub async fn send_voice_ice_candidate( + state: State<'_, AppState>, + community_id: String, + channel_id: String, + to_peer: String, + candidate: String, + sdp_mid: Option, + sdp_mline_index: Option, +) -> Result<(), String> { + let identity = state.identity.lock().await; + let id = identity.as_ref().ok_or("no identity loaded")?; + let from_peer = id.peer_id.to_string(); + drop(identity); + + let voice_topic = gossip::topic_for_voice(&community_id, &channel_id); + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let msg = GossipMessage::VoiceIceCandidate { + community_id, + channel_id, + from_peer, + to_peer, + candidate, + sdp_mid, + sdp_mline_index, + }; + let data = + serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; + let _ = handle + .command_tx + .send(NodeCommand::SendMessage { + topic: voice_topic, + data, + }) + .await; + } + + Ok(()) +} + +#[tauri::command] +pub async fn get_voice_participants( + state: State<'_, AppState>, + community_id: String, + channel_id: String, +) -> Result, String> { + let key = format!("{}:{}", community_id, channel_id); + let vc = state.voice_channels.lock().await; + let participants = vc.get(&key).cloned().unwrap_or_default(); + Ok(participants) +} diff --git a/src-tauri/src/crdt/document.rs b/src-tauri/src/crdt/document.rs index 6ceac07..c17907e 100644 --- a/src-tauri/src/crdt/document.rs +++ b/src-tauri/src/crdt/document.rs @@ -1,7 +1,7 @@ use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta}; +use crate::protocol::community::{CategoryMeta, ChannelKind, ChannelMeta, CommunityMeta}; use crate::protocol::messages::ChatMessage; // initialize a new community document with metadata and a default general channel @@ -24,15 +24,20 @@ pub fn init_community_doc( doc.put(&meta, "created_at", now as i64)?; let channels = doc.put_object(ROOT, "channels", ObjType::Map)?; + let _categories = doc.put_object(ROOT, "categories", ObjType::Map)?; let members = doc.put_object(ROOT, "members", ObjType::Map)?; let _roles = doc.put_object(ROOT, "roles", ObjType::Map)?; // create a default general channel - let general_id = format!("ch_{}", &hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12]); + let general_id = format!( + "ch_{}", + &hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12] + ); let general = doc.put_object(&channels, &general_id, ObjType::Map)?; doc.put(&general, "name", "general")?; doc.put(&general, "topic", "general discussion")?; doc.put(&general, "kind", "text")?; + doc.put(&general, "position", 0i64)?; let _messages = doc.put_object(&general, "messages", ObjType::List)?; // add the creator as the first member with owner role @@ -55,6 +60,23 @@ pub fn add_channel( .map(|(_, id)| id) .ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?; + // calculate next position if channel.position is 0 + let position = if channel.position == 0 { + let keys: Vec = doc.keys(&channels).collect(); + keys.iter() + .filter_map(|k| { + doc.get(&channels, k) + .ok() + .flatten() + .and_then(|(_, id)| get_i64(doc, &id, "position")) + }) + .max() + .map(|p| p + 1) + .unwrap_or(0) as u32 + } else { + channel.position + }; + let ch = doc.put_object(&channels, &channel.id, ObjType::Map)?; doc.put(&ch, "name", channel.name.as_str())?; doc.put(&ch, "topic", channel.topic.as_str())?; @@ -66,11 +88,93 @@ pub fn add_channel( ChannelKind::Voice => "voice", }, )?; + doc.put(&ch, "position", position as i64)?; + if let Some(ref cat_id) = channel.category_id { + doc.put(&ch, "category_id", cat_id.as_str())?; + } let _messages = doc.put_object(&ch, "messages", ObjType::List)?; Ok(()) } +// add a new category to the community document +pub fn add_category( + doc: &mut AutoCommit, + category: &CategoryMeta, +) -> Result<(), automerge::AutomergeError> { + let categories = doc + .get(ROOT, "categories")? + .map(|(_, id)| id) + .ok_or_else(|| { + // backwards compat: create categories map if it doesnt exist yet + automerge::AutomergeError::InvalidObjId("categories not found".to_string()) + }); + + let categories = match categories { + Ok(id) => id, + Err(_) => doc.put_object(ROOT, "categories", ObjType::Map)?, + }; + + // calculate next position if category.position is 0 + let position = if category.position == 0 { + let keys: Vec = doc.keys(&categories).collect(); + keys.iter() + .filter_map(|k| { + doc.get(&categories, k) + .ok() + .flatten() + .and_then(|(_, id)| get_i64(doc, &id, "position")) + }) + .max() + .map(|p| p + 1) + .unwrap_or(0) as u32 + } else { + category.position + }; + + let cat = doc.put_object(&categories, &category.id, ObjType::Map)?; + doc.put(&cat, "name", category.name.as_str())?; + doc.put(&cat, "position", position as i64)?; + + Ok(()) +} + +// read all categories from the community document +pub fn get_categories(doc: &AutoCommit, community_id: &str) -> Result, String> { + let categories_obj = doc.get(ROOT, "categories").map_err(|e| e.to_string())?; + + // backwards compat: older docs may not have categories + let categories_obj = match categories_obj { + Some((_, id)) => id, + None => return Ok(Vec::new()), + }; + + let mut result = Vec::new(); + let keys = doc.keys(&categories_obj); + + for key in keys { + let cat_obj = doc + .get(&categories_obj, &key) + .map_err(|e| e.to_string())? + .map(|(_, id)| id); + + if let Some(cat_id) = cat_obj { + let name = get_str(doc, &cat_id, "name").unwrap_or_default(); + let position = get_i64(doc, &cat_id, "position").unwrap_or(0) as u32; + + result.push(CategoryMeta { + id: key.to_string(), + community_id: community_id.to_string(), + name, + position, + }); + } + } + + result.sort_by_key(|c| c.position); + Ok(result) +} + // read all channels from the community document pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result, String> { let channels_obj = doc @@ -96,6 +200,8 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result ChannelKind::Voice, _ => ChannelKind::Text, }; + let position = get_i64(doc, &ch_id, "position").unwrap_or(0) as u32; + let category_id = get_str(doc, &ch_id, "category_id"); result.push(ChannelMeta { id: key.to_string(), @@ -103,10 +209,15 @@ pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result Result Result, String> { + let channels_obj = doc + .get(ROOT, "channels") + .map_err(|e| e.to_string())? + .map(|(_, id)| id) + .ok_or("channels key not found")?; + + // update position for each channel + for (index, channel_id) in channel_ids.iter().enumerate() { + if let Some((_, ch_obj)) = doc + .get(&channels_obj, channel_id) + .map_err(|e| e.to_string())? + { + doc.put(&ch_obj, "position", index as i64) + .map_err(|e| e.to_string())?; + } + } + + // return updated channels sorted by position + get_channels(doc, community_id) +} + // -- helpers for reading automerge values -- fn get_str(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option { @@ -270,7 +408,7 @@ pub fn get_message_by_id( .ok_or("channels key not found")?; let keys = doc.keys(&channels_obj); - + for channel_key in keys { let ch_obj = doc .get(&channels_obj, &channel_key) @@ -298,7 +436,8 @@ pub fn get_message_by_id( id: id.clone(), channel_id: channel_key.to_string(), author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(), - author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(), + author_name: get_str(doc, &msg_id, "author_name") + .unwrap_or_default(), content: get_str(doc, &msg_id, "content").unwrap_or_default(), timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64, edited: get_bool(doc, &msg_id, "edited").unwrap_or(false), @@ -315,10 +454,7 @@ pub fn get_message_by_id( } // delete a message by id from any channel in the community -pub fn delete_message_by_id( - doc: &mut AutoCommit, - message_id: &str, -) -> Result<(), String> { +pub fn delete_message_by_id(doc: &mut AutoCommit, message_id: &str) -> Result<(), String> { let channels_obj = doc .get(ROOT, "channels") .map_err(|e| e.to_string())? @@ -326,7 +462,7 @@ pub fn delete_message_by_id( .ok_or("channels key not found")?; let keys: Vec = doc.keys(&channels_obj).collect(); - + for channel_key in keys { let ch_obj = doc .get(&channels_obj, &channel_key) @@ -350,8 +486,7 @@ pub fn delete_message_by_id( if let Some(msg_obj_id) = msg_obj { let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default(); if id == message_id { - doc.delete(&msgs_id, i) - .map_err(|e| e.to_string())?; + doc.delete(&msgs_id, i).map_err(|e| e.to_string())?; return Ok(()); } } @@ -364,9 +499,7 @@ pub fn delete_message_by_id( } // get all members from the community document -pub fn get_members( - doc: &AutoCommit, -) -> Result, String> { +pub fn get_members(doc: &AutoCommit) -> Result, String> { let members_obj = doc .get(ROOT, "members") .map_err(|e| e.to_string())? @@ -385,7 +518,7 @@ pub fn get_members( if let Some(member_id) = member_obj { let display_name = get_str(doc, &member_id, "display_name").unwrap_or_default(); let joined_at = get_i64(doc, &member_id, "joined_at").unwrap_or(0) as u64; - + // get roles list let roles: Vec = doc .get(&member_id, "roles") @@ -419,10 +552,7 @@ pub fn get_members( } // remove a member from the community -pub fn remove_member( - doc: &mut AutoCommit, - peer_id: &str, -) -> Result<(), String> { +pub fn remove_member(doc: &mut AutoCommit, peer_id: &str) -> Result<(), String> { let members_obj = doc .get(ROOT, "members") .map_err(|e| e.to_string())? diff --git a/src-tauri/src/crdt/mod.rs b/src-tauri/src/crdt/mod.rs index 6e0750f..55b616d 100644 --- a/src-tauri/src/crdt/mod.rs +++ b/src-tauri/src/crdt/mod.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use automerge::AutoCommit; -use crate::protocol::community::{ChannelMeta, CommunityMeta}; +use crate::protocol::community::{CategoryMeta, ChannelMeta, CommunityMeta}; use crate::protocol::messages::ChatMessage; use crate::storage::DiskStorage; @@ -91,6 +91,50 @@ impl CrdtEngine { document::get_channels(doc, community_id) } + // reorder channels in a community + pub fn reorder_channels( + &mut self, + community_id: &str, + channel_ids: &[String], + ) -> Result, String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + let channels = document::reorder_channels(doc, community_id, channel_ids)?; + self.persist(community_id)?; + Ok(channels) + } + + // add a category to a community + pub fn create_category( + &mut self, + community_id: &str, + category: &CategoryMeta, + ) -> Result<(), String> { + let doc = self + .documents + .get_mut(community_id) + .ok_or("community not found")?; + + document::add_category(doc, category) + .map_err(|e| format!("failed to add category: {}", e))?; + + self.persist(community_id)?; + Ok(()) + } + + // get all categories in a community + pub fn get_categories(&self, community_id: &str) -> Result, String> { + let doc = self + .documents + .get(community_id) + .ok_or("community not found")?; + + document::get_categories(doc, community_id) + } + // append a message to a channel within a community pub fn append_message( &mut self, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5527864..c2cb315 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,12 +3,15 @@ mod crdt; mod node; mod protocol; mod storage; +mod verification; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use crate::crdt::CrdtEngine; use crate::protocol::identity::DuskIdentity; +use crate::protocol::messages::VoiceParticipant; use crate::storage::DiskStorage; // shared application state accessible from all tauri commands @@ -17,6 +20,8 @@ pub struct AppState { pub crdt_engine: Arc>, pub storage: Arc, pub node_handle: Arc>>, + // tracks which peers are in which voice channels, keyed by "community_id:channel_id" + pub voice_channels: Arc>>>, } impl AppState { @@ -36,6 +41,7 @@ impl AppState { crdt_engine, storage, node_handle: Arc::new(Mutex::new(None)), + voice_channels: Arc::new(Mutex::new(HashMap::new())), } } } @@ -48,6 +54,28 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .manage(AppState::new()) + .setup(|app| { + // grant microphone/camera permissions on linux webkitgtk + // without this, getUserMedia is denied by default + #[cfg(target_os = "linux")] + { + use tauri::Manager; + if let Some(window) = app.get_webview_window("main") { + window + .with_webview(|webview| { + use webkit2gtk::PermissionRequestExt; + use webkit2gtk::WebViewExt; + let wv = webview.inner(); + wv.connect_permission_request(|_webview, request| { + request.allow(); + true + }); + }) + .ok(); + } + } + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::identity::has_identity, commands::identity::load_identity, @@ -67,6 +95,7 @@ pub fn run() { commands::chat::send_typing, commands::chat::start_node, commands::chat::stop_node, + commands::chat::check_internet_connectivity, commands::community::create_community, commands::community::join_community, commands::community::leave_community, @@ -77,6 +106,22 @@ pub fn run() { commands::community::delete_message, commands::community::kick_member, commands::community::generate_invite, + commands::community::reorder_channels, + commands::community::create_category, + commands::community::get_categories, + commands::voice::join_voice_channel, + commands::voice::leave_voice_channel, + commands::voice::update_voice_media_state, + commands::voice::send_voice_sdp, + commands::voice::send_voice_ice_candidate, + commands::voice::get_voice_participants, + commands::dm::send_dm, + commands::dm::get_dm_messages, + commands::dm::get_dm_conversations, + commands::dm::mark_dm_read, + commands::dm::delete_dm_conversation, + commands::dm::send_dm_typing, + commands::dm::open_dm_conversation, ]) .run(tauri::generate_context!()) .expect("error while running dusk"); diff --git a/src-tauri/src/node/gossip.rs b/src-tauri/src/node/gossip.rs index b677f6d..61ec51d 100644 --- a/src-tauri/src/node/gossip.rs +++ b/src-tauri/src/node/gossip.rs @@ -32,3 +32,42 @@ pub fn topic_for_directory() -> String { pub fn topic_for_sync() -> String { "dusk/sync".to_string() } + +// voice signaling topic for webrtc sdp/ice exchange and presence +pub fn topic_for_voice(community_id: &str, channel_id: &str) -> String { + format!( + "dusk/community/{}/channel/{}/voice", + community_id, channel_id + ) +} + +// personal inbox topic for receiving first-time dms from peers we haven't +// subscribed to yet. every peer subscribes to their own inbox on startup. +pub fn topic_for_dm_inbox(peer_id: &str) -> String { + format!("dusk/dm/inbox/{}", peer_id) +} + +// dm topic between two peers, sorted alphabetically so both peers derive the same topic +pub fn topic_for_dm(peer_a: &str, peer_b: &str) -> String { + let (first, second) = if peer_a < peer_b { + (peer_a, peer_b) + } else { + (peer_b, peer_a) + }; + format!("dusk/dm/{}/{}", first, second) +} + +// derive a stable conversation id from two peer ids +pub fn dm_conversation_id(peer_a: &str, peer_b: &str) -> String { + let (first, second) = if peer_a < peer_b { + (peer_a, peer_b) + } else { + (peer_b, peer_a) + }; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + first.hash(&mut hasher); + second.hash(&mut hasher); + format!("dm_{:016x}", hasher.finish()) +} diff --git a/src-tauri/src/node/mod.rs b/src-tauri/src/node/mod.rs index 7fe7d0e..d642f31 100644 --- a/src-tauri/src/node/mod.rs +++ b/src-tauri/src/node/mod.rs @@ -3,7 +3,7 @@ pub mod discovery; pub mod gossip; pub mod swarm; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tauri::async_runtime::JoinHandle; use tauri::Emitter; @@ -11,12 +11,11 @@ use tokio::sync::Mutex; use crate::crdt::CrdtEngine; use crate::protocol::identity::DirectoryEntry; +use crate::verification; -// default relay address - override with DUSK_RELAY_ADDR env var -// format: /ip4//tcp//p2p/ -// left empty because 0.0.0.0 is a listen address, not a routable dial target. -// users must set DUSK_RELAY_ADDR to a reachable relay for WAN connectivity. -const DEFAULT_RELAY_ADDR: &str = ""; +// default public relay - override with DUSK_RELAY_ADDR env var +const DEFAULT_RELAY_ADDR: &str = + "/dns4/relay.duskchat.app/tcp/4001/p2p/12D3KooWGQkCkACcibJPKzus7Q6U1aYngfTuS4gwYwmJkJJtrSaw"; // relay reconnection parameters const RELAY_INITIAL_BACKOFF_SECS: u64 = 2; @@ -24,6 +23,9 @@ const RELAY_MAX_BACKOFF_SECS: u64 = 120; const RELAY_BACKOFF_MULTIPLIER: u64 = 2; // max time to hold pending rendezvous registrations before discarding (10 min) const PENDING_QUEUE_TTL_SECS: u64 = 600; +// grace period before warning the frontend about relay being down, +// prevents banner flashing on transient disconnections +const RELAY_WARN_GRACE_SECS: u64 = 8; // resolve the relay multiaddr from env or default fn relay_addr() -> Option { @@ -119,6 +121,50 @@ pub enum DuskEvent { }, #[serde(rename = "profile_revoked")] ProfileRevoked { peer_id: String }, + #[serde(rename = "relay_status")] + RelayStatus { connected: bool }, + #[serde(rename = "voice_participant_joined")] + VoiceParticipantJoined { + community_id: String, + channel_id: String, + peer_id: String, + display_name: String, + media_state: crate::protocol::messages::VoiceMediaState, + }, + #[serde(rename = "voice_participant_left")] + VoiceParticipantLeft { + community_id: String, + channel_id: String, + peer_id: String, + }, + #[serde(rename = "voice_media_state_changed")] + VoiceMediaStateChanged { + community_id: String, + channel_id: String, + peer_id: String, + media_state: crate::protocol::messages::VoiceMediaState, + }, + #[serde(rename = "voice_sdp_received")] + VoiceSdpReceived { + community_id: String, + channel_id: String, + from_peer: String, + sdp_type: String, + sdp: String, + }, + #[serde(rename = "voice_ice_candidate_received")] + VoiceIceCandidateReceived { + community_id: String, + channel_id: String, + from_peer: String, + candidate: String, + sdp_mid: Option, + sdp_mline_index: Option, + }, + #[serde(rename = "dm_received")] + DMReceived(crate::protocol::messages::DirectMessage), + #[serde(rename = "dm_typing")] + DMTyping { peer_id: String }, } // extract the community id from a gossipsub topic string @@ -128,12 +174,17 @@ fn community_id_from_topic(topic: &str) -> Option<&str> { .and_then(|rest| rest.split('/').next()) } +// voice channel participant tracking type alias for readability +pub type VoiceChannelMap = + Arc>>>; + // start the p2p node on a background task pub async fn start( keypair: libp2p::identity::Keypair, crdt_engine: Arc>, storage: Arc, app_handle: tauri::AppHandle, + voice_channels: VoiceChannelMap, ) -> Result { let mut swarm_instance = swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?; @@ -159,6 +210,9 @@ pub async fn start( let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr); // if a relay is configured, dial it immediately + // don't emit RelayStatus here -- the store defaults to connected=true so + // no warning shows during the initial handshake. the warning only appears + // if the dial actually fails (OutgoingConnectionError) or the connection drops. if let Some(ref addr) = relay_multiaddr { log::info!("dialing relay at {}", addr); if let Err(e) = swarm_instance.dial(addr.clone()) { @@ -172,6 +226,10 @@ pub async fn start( // track connected peers for accurate count let mut connected_peers: HashSet = HashSet::new(); + // dedup set for dm message ids -- messages arrive on both the pair topic + // and inbox topic so we need to skip duplicates + let mut seen_dm_ids: HashSet = HashSet::new(); + // track whether we have a relay reservation let mut relay_reservation_active = false; @@ -193,6 +251,9 @@ pub async fn start( // relay reconnection state with exponential backoff let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; + // deferred warning timer -- only notify the frontend after the grace + // period expires so transient disconnections don't flash the banner + let mut relay_warn_at: Option = None; // next instant at which we should attempt a relay reconnect let mut relay_retry_at: Option = if relay_multiaddr.is_some() { // schedule initial retry in case the first dial failed synchronously @@ -304,6 +365,18 @@ pub async fn start( }); } crate::protocol::messages::GossipMessage::ProfileAnnounce(profile) => { + // reject announcements with invalid signatures + if !verification::verify_announcement(&profile.public_key, &profile) { + log::warn!("rejected unsigned/invalid profile from {}", profile.peer_id); + continue; + } + + // reject unverified identities + if profile.verification_proof.is_none() { + log::warn!("rejected unverified profile from {}", profile.peer_id); + continue; + } + // cache the peer profile in our local directory let entry = DirectoryEntry { peer_id: profile.peer_id.clone(), @@ -326,6 +399,12 @@ pub async fn start( }); } crate::protocol::messages::GossipMessage::ProfileRevoke(revocation) => { + // reject revocations with invalid signatures + if !verification::verify_revocation(&revocation.public_key, &revocation) { + log::warn!("rejected unsigned revocation for {}", revocation.peer_id); + continue; + } + // peer is revoking their identity, remove them from our directory let _ = storage.remove_directory_entry(&revocation.peer_id); @@ -333,6 +412,133 @@ pub async fn start( peer_id: revocation.peer_id, }); } + crate::protocol::messages::GossipMessage::VoiceJoin { + community_id, channel_id, peer_id, display_name, media_state, + } => { + let participant = crate::protocol::messages::VoiceParticipant { + peer_id: peer_id.clone(), + display_name: display_name.clone(), + media_state: media_state.clone(), + }; + + // track the participant in shared voice state + let key = format!("{}:{}", community_id, channel_id); + let mut vc = voice_channels.lock().await; + let participants = vc.entry(key).or_insert_with(Vec::new); + // avoid duplicates if we receive a repeated join + participants.retain(|p| p.peer_id != peer_id); + participants.push(participant); + drop(vc); + + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantJoined { + community_id, channel_id, peer_id, display_name, media_state, + }); + } + crate::protocol::messages::GossipMessage::VoiceLeave { + community_id, channel_id, peer_id, + } => { + let key = format!("{}:{}", community_id, channel_id); + let mut vc = voice_channels.lock().await; + if let Some(participants) = vc.get_mut(&key) { + participants.retain(|p| p.peer_id != peer_id); + if participants.is_empty() { + vc.remove(&key); + } + } + drop(vc); + + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantLeft { + community_id, channel_id, peer_id, + }); + } + crate::protocol::messages::GossipMessage::VoiceMediaStateUpdate { + community_id, channel_id, peer_id, media_state, + } => { + // update tracked media state for this participant + let key = format!("{}:{}", community_id, channel_id); + let mut vc = voice_channels.lock().await; + if let Some(participants) = vc.get_mut(&key) { + if let Some(p) = participants.iter_mut().find(|p| p.peer_id == peer_id) { + p.media_state = media_state.clone(); + } + } + drop(vc); + + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceMediaStateChanged { + community_id, channel_id, peer_id, media_state, + }); + } + crate::protocol::messages::GossipMessage::VoiceSdp { + community_id, channel_id, from_peer, to_peer, sdp_type, sdp, + } => { + // only forward sdp messages addressed to us + let local_id = swarm_instance.local_peer_id().to_string(); + if to_peer == local_id { + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceSdpReceived { + community_id, channel_id, from_peer, sdp_type, sdp, + }); + } + } + crate::protocol::messages::GossipMessage::VoiceIceCandidate { + community_id, channel_id, from_peer, to_peer, candidate, sdp_mid, sdp_mline_index, + } => { + // only forward ice candidates addressed to us + let local_id = swarm_instance.local_peer_id().to_string(); + if to_peer == local_id { + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceIceCandidateReceived { + community_id, channel_id, from_peer, candidate, sdp_mid, sdp_mline_index, + }); + } + } + crate::protocol::messages::GossipMessage::DirectMessage(dm_msg) => { + // only process dms addressed to us (ignore our own echoes) + let local_id = swarm_instance.local_peer_id().to_string(); + if dm_msg.to_peer == local_id { + // dedup: messages arrive on both the pair topic and inbox + // topic so skip if we've already processed this one + if !seen_dm_ids.insert(dm_msg.id.clone()) { + continue; + } + // cap the dedup set to prevent unbounded memory growth + if seen_dm_ids.len() > 10000 { + seen_dm_ids.clear(); + } + + // if this arrived on the inbox topic, the sender might be + // someone we've never dm'd before -- auto-subscribe to the + // pair topic so subsequent messages use the direct channel + if topic_str.starts_with("dusk/dm/inbox/") { + let pair_topic = gossip::topic_for_dm(&dm_msg.from_peer, &dm_msg.to_peer); + let ident_topic = libp2p::gossipsub::IdentTopic::new(pair_topic); + let _ = swarm_instance.behaviour_mut().gossipsub.subscribe(&ident_topic); + } + + // persist the incoming message + let conversation_id = gossip::dm_conversation_id(&dm_msg.from_peer, &dm_msg.to_peer); + let _ = storage.append_dm_message(&conversation_id, &dm_msg); + + // update or create conversation metadata + let existing = storage.load_dm_conversation(&conversation_id).ok(); + let meta = crate::protocol::messages::DMConversationMeta { + peer_id: dm_msg.from_peer.clone(), + display_name: dm_msg.from_display_name.clone(), + last_message: Some(dm_msg.content.clone()), + last_message_time: Some(dm_msg.timestamp), + unread_count: existing.map(|m| m.unread_count + 1).unwrap_or(1), + }; + let _ = storage.save_dm_conversation(&conversation_id, &meta); + + let _ = app_handle.emit("dusk-event", DuskEvent::DMReceived(dm_msg)); + } + } + crate::protocol::messages::GossipMessage::DMTyping(indicator) => { + let local_id = swarm_instance.local_peer_id().to_string(); + if indicator.to_peer == local_id { + let _ = app_handle.emit("dusk-event", DuskEvent::DMTyping { + peer_id: indicator.from_peer, + }); + } + } } } } @@ -388,6 +594,8 @@ pub async fn start( )) => { log::info!("relay reservation accepted by {}", relay_peer_id); relay_reservation_active = true; + relay_warn_at = None; + let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: true }); // now that we have a relay reservation, process any pending // rendezvous registrations that were queued before the relay was ready @@ -487,6 +695,13 @@ pub async fn start( if Some(failed_peer) == relay_peer { log::warn!("failed to connect to relay: {}", error); log::info!("scheduling relay reconnect in {}s", relay_backoff_secs); + // defer the warning so transient failures don't flash the banner + if relay_warn_at.is_none() { + relay_warn_at = Some( + tokio::time::Instant::now() + + std::time::Duration::from_secs(RELAY_WARN_GRACE_SECS), + ); + } relay_retry_at = Some( tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs), ); @@ -516,8 +731,11 @@ pub async fn start( if Some(peer_id) == relay_peer && !relay_reservation_active { // reset backoff on successful connection relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; - // cancel any pending retry + // cancel any pending retry and deferred warning relay_retry_at = None; + relay_warn_at = None; + // clear the banner if it was already showing + let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: true }); if let Some(ref addr) = relay_multiaddr { let relay_circuit_addr = addr.clone() @@ -543,6 +761,33 @@ pub async fn start( libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => { if num_established == 0 { connected_peers.remove(&peer_id.to_string()); + + // remove disconnected peer from all voice channels and notify frontend + let peer_id_str = peer_id.to_string(); + let mut vc = voice_channels.lock().await; + let mut empty_keys = Vec::new(); + for (key, participants) in vc.iter_mut() { + let before_len = participants.len(); + participants.retain(|p| p.peer_id != peer_id_str); + if participants.len() < before_len { + // parse the key back into community_id and channel_id + if let Some((cid, chid)) = key.split_once(':') { + let _ = app_handle.emit("dusk-event", DuskEvent::VoiceParticipantLeft { + community_id: cid.to_string(), + channel_id: chid.to_string(), + peer_id: peer_id_str.clone(), + }); + } + } + if participants.is_empty() { + empty_keys.push(key.clone()); + } + } + for key in empty_keys { + vc.remove(&key); + } + drop(vc); + let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected { peer_id: peer_id.to_string(), }); @@ -556,6 +801,13 @@ pub async fn start( if Some(peer_id) == relay_peer { relay_reservation_active = false; log::warn!("lost connection to relay, scheduling reconnect in {}s", relay_backoff_secs); + // defer the warning so quick reconnections don't flash the banner + if relay_warn_at.is_none() { + relay_warn_at = Some( + tokio::time::Instant::now() + + std::time::Duration::from_secs(RELAY_WARN_GRACE_SECS), + ); + } relay_retry_at = Some( tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs), @@ -626,6 +878,18 @@ pub async fn start( } } + // deferred relay warning -- only tell the frontend after the grace + // period so transient disconnections don't flash the banner + _ = tokio::time::sleep_until( + relay_warn_at.unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(86400)) + ), if relay_warn_at.is_some() => { + relay_warn_at = None; + // grace period expired and we still don't have a relay connection + if !relay_reservation_active { + let _ = app_handle.emit("dusk-event", DuskEvent::RelayStatus { connected: false }); + } + } + cmd = command_rx.recv() => { match cmd { Some(NodeCommand::Shutdown) | None => break, diff --git a/src-tauri/src/node/swarm.rs b/src-tauri/src/node/swarm.rs index 5607d03..2e9512d 100644 --- a/src-tauri/src/node/swarm.rs +++ b/src-tauri/src/node/swarm.rs @@ -41,6 +41,8 @@ pub fn build_swarm( noise::Config::new, yamux::Config::default, )? + // resolve dns4/dns6 multiaddrs (needed for relay.duskchat.app) + .with_dns()? // add relay client transport so we can connect through relay circuits .with_relay_client(noise::Config::new, yamux::Config::default)? .with_behaviour(|key, relay_client| { @@ -71,10 +73,11 @@ pub fn build_swarm( kademlia, mdns, identify, - ping: ping::Behaviour::default(), + // ping every 30s to keep the relay connection alive + ping: ping::Behaviour::new(ping::Config::new().with_interval(Duration::from_secs(30))), } })? - .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60))) + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) .build(); Ok(swarm) diff --git a/src-tauri/src/protocol/community.rs b/src-tauri/src/protocol/community.rs index 84a1a95..36ed2e3 100644 --- a/src-tauri/src/protocol/community.rs +++ b/src-tauri/src/protocol/community.rs @@ -9,6 +9,15 @@ pub struct CommunityMeta { pub created_at: u64, } +// user-defined grouping for channels within a community +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CategoryMeta { + pub id: String, + pub community_id: String, + pub name: String, + pub position: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelMeta { pub id: String, @@ -16,6 +25,9 @@ pub struct ChannelMeta { pub name: String, pub topic: String, pub kind: ChannelKind, + pub position: u32, + // channels without a category sit at the top level + pub category_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/protocol/identity.rs b/src-tauri/src/protocol/identity.rs index e61113e..353b393 100644 --- a/src-tauri/src/protocol/identity.rs +++ b/src-tauri/src/protocol/identity.rs @@ -11,6 +11,7 @@ pub struct DuskIdentity { pub display_name: String, pub bio: String, pub created_at: u64, + pub verification_proof: Option, } impl DuskIdentity { @@ -29,6 +30,7 @@ impl DuskIdentity { display_name: display_name.to_string(), bio: bio.to_string(), created_at, + verification_proof: None, } } @@ -44,6 +46,7 @@ impl DuskIdentity { let peer_id = PeerId::from(keypair.public()); let profile = storage.load_profile().unwrap_or_default(); + let verification_proof = storage.load_verification_proof().ok().flatten(); Ok(Self { keypair, @@ -51,6 +54,7 @@ impl DuskIdentity { display_name: profile.display_name, bio: profile.bio, created_at: profile.created_at, + verification_proof, }) } @@ -86,6 +90,7 @@ impl DuskIdentity { public_key: hex::encode(public_key_bytes), bio: self.bio.clone(), created_at: self.created_at, + verification_proof: self.verification_proof.clone(), } } } @@ -97,6 +102,17 @@ pub struct PublicIdentity { pub public_key: String, pub bio: String, pub created_at: u64, + pub verification_proof: Option, +} + +// cryptographic proof that the identity was created through human verification +// the signature binds this proof to a specific keypair so it cannot be reused +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationProof { + pub metrics_hash: String, + pub signature: String, + pub timestamp: u64, + pub score: f64, } // profile data stored on disk alongside the keypair diff --git a/src-tauri/src/protocol/messages.rs b/src-tauri/src/protocol/messages.rs index 91d0992..f0dde6c 100644 --- a/src-tauri/src/protocol/messages.rs +++ b/src-tauri/src/protocol/messages.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use super::identity::VerificationProof; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub id: String, @@ -34,6 +36,8 @@ pub enum PeerStatus { } // peer profile announcement broadcast on the directory topic +// includes a verification proof and a signature over all fields +// so peers can reject unverified or spoofed identities #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileAnnouncement { pub peer_id: String, @@ -41,14 +45,64 @@ pub struct ProfileAnnouncement { pub bio: String, pub public_key: String, pub timestamp: u64, + pub verification_proof: Option, + pub signature: String, } // broadcast when a user resets their identity, tells peers to purge their data +// signed to prevent unauthorized revocation of another peer's identity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProfileRevocation { pub peer_id: String, pub public_key: String, pub timestamp: u64, + pub signature: String, +} + +// media state for a participant in a voice channel +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceMediaState { + pub muted: bool, + pub deafened: bool, + pub video_enabled: bool, + pub screen_sharing: bool, +} + +// a peer currently connected to a voice channel +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceParticipant { + pub peer_id: String, + pub display_name: String, + pub media_state: VoiceMediaState, +} + +// a direct message between two peers +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DirectMessage { + pub id: String, + pub from_peer: String, + pub to_peer: String, + pub from_display_name: String, + pub content: String, + pub timestamp: u64, +} + +// typing indicator scoped to a dm conversation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DMTypingIndicator { + pub from_peer: String, + pub to_peer: String, + pub timestamp: u64, +} + +// metadata for a persisted dm conversation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DMConversationMeta { + pub peer_id: String, + pub display_name: String, + pub last_message: Option, + pub last_message_time: Option, + pub unread_count: u32, } // envelope for all gossipsub-published messages @@ -58,8 +112,49 @@ pub enum GossipMessage { Typing(TypingIndicator), Presence(PresenceUpdate), MetaUpdate(super::community::CommunityMeta), - DeleteMessage { message_id: String }, - MemberKicked { peer_id: String }, + DeleteMessage { + message_id: String, + }, + MemberKicked { + peer_id: String, + }, ProfileAnnounce(ProfileAnnouncement), ProfileRevoke(ProfileRevocation), + DirectMessage(DirectMessage), + DMTyping(DMTypingIndicator), + VoiceJoin { + community_id: String, + channel_id: String, + peer_id: String, + display_name: String, + media_state: VoiceMediaState, + }, + VoiceLeave { + community_id: String, + channel_id: String, + peer_id: String, + }, + VoiceMediaStateUpdate { + community_id: String, + channel_id: String, + peer_id: String, + media_state: VoiceMediaState, + }, + VoiceSdp { + community_id: String, + channel_id: String, + from_peer: String, + to_peer: String, + sdp_type: String, + sdp: String, + }, + VoiceIceCandidate { + community_id: String, + channel_id: String, + from_peer: String, + to_peer: String, + candidate: String, + sdp_mid: Option, + sdp_mline_index: Option, + }, } diff --git a/src-tauri/src/storage/disk.rs b/src-tauri/src/storage/disk.rs index 469484c..1b714c6 100644 --- a/src-tauri/src/storage/disk.rs +++ b/src-tauri/src/storage/disk.rs @@ -6,7 +6,8 @@ use std::io; use std::path::PathBuf; use crate::protocol::community::CommunityMeta; -use crate::protocol::identity::{DirectoryEntry, ProfileData}; +use crate::protocol::identity::{DirectoryEntry, ProfileData, VerificationProof}; +use crate::protocol::messages::{DMConversationMeta, DirectMessage}; // user settings that persist across sessions #[derive(Debug, Clone, Serialize, Deserialize)] @@ -56,6 +57,7 @@ impl DiskStorage { fs::create_dir_all(base_dir.join("identity"))?; fs::create_dir_all(base_dir.join("communities"))?; fs::create_dir_all(base_dir.join("directory"))?; + fs::create_dir_all(base_dir.join("dms"))?; Ok(Self { base_dir }) } @@ -110,6 +112,25 @@ impl DiskStorage { self.base_dir.join("identity/keypair.bin").exists() } + // -- verification proof -- + + pub fn save_verification_proof(&self, proof: &VerificationProof) -> Result<(), io::Error> { + let json = serde_json::to_string_pretty(proof) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(self.base_dir.join("identity/verification.json"), json) + } + + pub fn load_verification_proof(&self) -> Result, io::Error> { + let path = self.base_dir.join("identity/verification.json"); + if !path.exists() { + return Ok(None); + } + let data = fs::read_to_string(path)?; + let proof = serde_json::from_str(&data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + Ok(Some(proof)) + } + // -- automerge documents -- pub fn save_document(&self, community_id: &str, doc_bytes: &[u8]) -> Result<(), io::Error> { @@ -225,7 +246,131 @@ impl DiskStorage { } } - // wipe all user data - identity, communities, directory, settings + // -- direct messages -- + + // save a dm conversation's metadata + pub fn save_dm_conversation( + &self, + conversation_id: &str, + meta: &DMConversationMeta, + ) -> Result<(), io::Error> { + let dir = self.base_dir.join(format!("dms/{}", conversation_id)); + fs::create_dir_all(&dir)?; + let json = serde_json::to_string_pretty(meta) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(dir.join("meta.json"), json) + } + + // load a single dm conversation's metadata + pub fn load_dm_conversation( + &self, + conversation_id: &str, + ) -> Result { + let path = self + .base_dir + .join(format!("dms/{}/meta.json", conversation_id)); + let data = fs::read_to_string(path)?; + serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } + + // load all dm conversations + pub fn load_all_dm_conversations( + &self, + ) -> Result, io::Error> { + let dms_dir = self.base_dir.join("dms"); + if !dms_dir.exists() { + return Ok(Vec::new()); + } + + let mut conversations = Vec::new(); + for entry in fs::read_dir(dms_dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(conv_id) = entry.file_name().to_str() { + let meta_path = entry.path().join("meta.json"); + if meta_path.exists() { + if let Ok(data) = fs::read_to_string(&meta_path) { + if let Ok(meta) = serde_json::from_str::(&data) { + conversations.push((conv_id.to_string(), meta)); + } + } + } + } + } + } + Ok(conversations) + } + + // remove a dm conversation and all its messages + pub fn remove_dm_conversation(&self, conversation_id: &str) -> Result<(), io::Error> { + let dir = self.base_dir.join(format!("dms/{}", conversation_id)); + if dir.exists() { + fs::remove_dir_all(&dir)?; + } + Ok(()) + } + + // append a message to a dm conversation's message log + pub fn append_dm_message( + &self, + conversation_id: &str, + message: &DirectMessage, + ) -> Result<(), io::Error> { + let dir = self.base_dir.join(format!("dms/{}", conversation_id)); + fs::create_dir_all(&dir)?; + + let messages_path = dir.join("messages.json"); + let mut messages: Vec = if messages_path.exists() { + let data = fs::read_to_string(&messages_path)?; + serde_json::from_str(&data).unwrap_or_default() + } else { + Vec::new() + }; + + messages.push(message.clone()); + + let json = serde_json::to_string_pretty(&messages) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + fs::write(&messages_path, json) + } + + // load dm messages with optional pagination + pub fn load_dm_messages( + &self, + conversation_id: &str, + before: Option, + limit: usize, + ) -> Result, io::Error> { + let messages_path = self + .base_dir + .join(format!("dms/{}/messages.json", conversation_id)); + if !messages_path.exists() { + return Ok(Vec::new()); + } + + let data = fs::read_to_string(&messages_path)?; + let messages: Vec = serde_json::from_str(&data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + let filtered: Vec = if let Some(before_ts) = before { + messages + .into_iter() + .filter(|m| m.timestamp < before_ts) + .collect() + } else { + messages + }; + + // return the last `limit` messages (most recent) + let start = if filtered.len() > limit { + filtered.len() - limit + } else { + 0 + }; + Ok(filtered[start..].to_vec()) + } + + // wipe all user data - identity, communities, directory, dms, settings // used when resetting identity to leave no traces on this client pub fn wipe_all_data(&self) -> Result<(), io::Error> { let identity_dir = self.base_dir.join("identity"); @@ -243,10 +388,16 @@ impl DiskStorage { fs::remove_dir_all(&directory_dir)?; } + let dms_dir = self.base_dir.join("dms"); + if dms_dir.exists() { + fs::remove_dir_all(&dms_dir)?; + } + // recreate the directory tree so the app can still function fs::create_dir_all(self.base_dir.join("identity"))?; fs::create_dir_all(self.base_dir.join("communities"))?; fs::create_dir_all(self.base_dir.join("directory"))?; + fs::create_dir_all(self.base_dir.join("dms"))?; Ok(()) } diff --git a/src-tauri/src/verification/mod.rs b/src-tauri/src/verification/mod.rs new file mode 100644 index 0000000..3041637 --- /dev/null +++ b/src-tauri/src/verification/mod.rs @@ -0,0 +1,436 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use libp2p::identity; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::protocol::identity::VerificationProof; +use crate::protocol::messages::{ProfileAnnouncement, ProfileRevocation}; + +// -- challenge data structures received from the frontend -- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MouseSample { + pub x: f64, + pub y: f64, + pub t: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SegmentData { + pub from_target: u32, + pub to_target: u32, + pub samples: Vec, + pub click_time: f64, + pub start_time: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetCircle { + pub id: u32, + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChallengeSubmission { + pub segments: Vec, + pub circles: Vec, + pub total_start_time: f64, + pub total_end_time: f64, +} + +pub struct AnalysisResult { + pub is_human: bool, + pub score: f64, +} + +const HUMAN_THRESHOLD: f64 = 0.35; + +// -- behavioral analysis functions -- +// these mirror the typescript implementations exactly, running in compiled rust +// so the analysis logic is not exposed in the inspectable webview + +fn score_timing_variance(segments: &[SegmentData]) -> f64 { + if segments.len() < 2 { + return 0.0; + } + + let intervals: Vec = segments.iter().map(|s| s.click_time - s.start_time).collect(); + let mean = intervals.iter().sum::() / intervals.len() as f64; + if mean == 0.0 { + return 0.0; + } + + let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::() / intervals.len() as f64; + let cv = variance.sqrt() / mean; + + // humans have natural variance in click timing + // bots tend to be metronomic or instantaneous + if cv < 0.03 { + 0.0 + } else if cv < 0.08 { + 0.3 + } else if cv < 0.12 { + 0.6 + } else { + 1.0 + } +} + +fn score_path_curvature(segments: &[SegmentData]) -> f64 { + let mut ratios = Vec::new(); + + for seg in segments { + if seg.samples.len() < 3 { + continue; + } + + let first = &seg.samples[0]; + let last = &seg.samples[seg.samples.len() - 1]; + let straight_dist = ((last.x - first.x).powi(2) + (last.y - first.y).powi(2)).sqrt(); + + // skip very short movements where curvature is meaningless + if straight_dist < 10.0 { + continue; + } + + let mut path_length = 0.0; + for i in 1..seg.samples.len() { + let dx = seg.samples[i].x - seg.samples[i - 1].x; + let dy = seg.samples[i].y - seg.samples[i - 1].y; + path_length += (dx * dx + dy * dy).sqrt(); + } + + ratios.push(path_length / straight_dist); + } + + if ratios.is_empty() { + return 0.0; + } + + let avg_ratio = ratios.iter().sum::() / ratios.len() as f64; + + // humans never move in perfectly straight lines + if avg_ratio < 1.02 { + 0.0 + } else if avg_ratio < 1.06 { + 0.3 + } else if avg_ratio < 1.10 { + 0.6 + } else if avg_ratio > 4.0 { + 0.5 + } else { + 1.0 + } +} + +fn score_speed_variance(segments: &[SegmentData]) -> f64 { + let mut all_speed_cvs = Vec::new(); + + for seg in segments { + if seg.samples.len() < 5 { + continue; + } + + let mut speeds = Vec::new(); + for i in 1..seg.samples.len() { + let dx = seg.samples[i].x - seg.samples[i - 1].x; + let dy = seg.samples[i].y - seg.samples[i - 1].y; + let dt = seg.samples[i].t - seg.samples[i - 1].t; + if dt > 0.0 { + speeds.push((dx * dx + dy * dy).sqrt() / dt); + } + } + + if speeds.len() < 3 { + continue; + } + + let mean = speeds.iter().sum::() / speeds.len() as f64; + if mean == 0.0 { + continue; + } + + let variance = speeds.iter().map(|v| (v - mean).powi(2)).sum::() / speeds.len() as f64; + let cv = variance.sqrt() / mean; + all_speed_cvs.push(cv); + } + + if all_speed_cvs.is_empty() { + return 0.0; + } + + let avg_cv = all_speed_cvs.iter().sum::() / all_speed_cvs.len() as f64; + + // humans accelerate and decelerate naturally + if avg_cv < 0.1 { + 0.0 + } else if avg_cv < 0.25 { + 0.4 + } else if avg_cv < 0.4 { + 0.7 + } else { + 1.0 + } +} + +fn score_approach_jitter(segments: &[SegmentData], circles: &[TargetCircle]) -> f64 { + let mut jitter_scores = Vec::new(); + + for (i, seg) in segments.iter().enumerate() { + if i >= circles.len() || seg.samples.len() < 5 { + continue; + } + + let target = &circles[i]; + + // isolate the last stretch approaching the target + let approach_samples: Vec<&MouseSample> = seg + .samples + .iter() + .filter(|s| { + let dx = s.x - target.x; + let dy = s.y - target.y; + (dx * dx + dy * dy).sqrt() < 60.0 + }) + .collect(); + + if approach_samples.len() < 4 { + continue; + } + + // count direction changes via cross product sign flips + let mut direction_changes = 0u32; + for j in 2..approach_samples.len() { + let dx1 = approach_samples[j - 1].x - approach_samples[j - 2].x; + let dy1 = approach_samples[j - 1].y - approach_samples[j - 2].y; + let dx2 = approach_samples[j].x - approach_samples[j - 1].x; + let dy2 = approach_samples[j].y - approach_samples[j - 1].y; + + let cross = dx1 * dy2 - dy1 * dx2; + if j > 2 { + let prev_dx1 = approach_samples[j - 2].x - approach_samples[j - 3].x; + let prev_dy1 = approach_samples[j - 2].y - approach_samples[j - 3].y; + let prev_cross = prev_dx1 * dy1 - prev_dy1 * dx1; + if cross * prev_cross < 0.0 { + direction_changes += 1; + } + } + } + + let jitter_ratio = direction_changes as f64 / (approach_samples.len() - 2).max(1) as f64; + jitter_scores.push(jitter_ratio); + } + + // not enough data to judge, give a neutral score + if jitter_scores.is_empty() { + return 0.5; + } + + let avg_jitter = jitter_scores.iter().sum::() / jitter_scores.len() as f64; + + // humans have micro-corrections from motor noise + if avg_jitter < 0.01 { + 0.2 + } else if avg_jitter < 0.05 { + 0.5 + } else { + 1.0 + } +} + +fn score_overall_timing(total_start: f64, total_end: f64) -> f64 { + let total_sec = (total_end - total_start) / 1000.0; + + if total_sec < 0.8 { + 0.0 + } else if total_sec < 1.5 { + 0.3 + } else if total_sec > 60.0 { + 0.5 + } else { + 1.0 + } +} + +pub fn analyze_challenge(data: &ChallengeSubmission) -> AnalysisResult { + let timing = score_timing_variance(&data.segments); + let curvature = score_path_curvature(&data.segments); + let speed = score_speed_variance(&data.segments); + let jitter = score_approach_jitter(&data.segments, &data.circles); + let overall = score_overall_timing(data.total_start_time, data.total_end_time); + + let score = timing * 0.25 + curvature * 0.25 + speed * 0.20 + jitter * 0.20 + overall * 0.10; + + AnalysisResult { + is_human: score >= HUMAN_THRESHOLD, + score, + } +} + +// -- proof generation -- + +pub fn generate_proof( + challenge: &ChallengeSubmission, + keypair: &identity::Keypair, + peer_id: &str, +) -> Result { + let result = analyze_challenge(challenge); + if !result.is_human { + return Err("behavioral analysis did not pass human threshold".to_string()); + } + + // hash the raw challenge data to create a fingerprint + let challenge_bytes = + serde_json::to_vec(challenge).map_err(|e| format!("failed to serialize challenge: {}", e))?; + let mut hasher = Sha256::new(); + hasher.update(&challenge_bytes); + let metrics_hash = hex::encode(hasher.finalize()); + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + // sign (metrics_hash || peer_id || timestamp) to bind the proof to this keypair + let sign_payload = format!("{}||{}||{}", metrics_hash, peer_id, timestamp); + let signature = keypair + .sign(sign_payload.as_bytes()) + .map_err(|e| format!("failed to sign proof: {}", e))?; + + Ok(VerificationProof { + metrics_hash, + signature: hex::encode(signature), + timestamp, + score: result.score, + }) +} + +// -- profile announcement signing -- + +// build the canonical payload that gets signed for an announcement +fn announcement_sign_payload( + peer_id: &str, + display_name: &str, + bio: &str, + public_key: &str, + timestamp: u64, + metrics_hash: &str, +) -> Vec { + format!( + "dusk-announce||{}||{}||{}||{}||{}||{}", + peer_id, display_name, bio, public_key, timestamp, metrics_hash + ) + .into_bytes() +} + +pub fn sign_announcement(keypair: &identity::Keypair, announcement: &ProfileAnnouncement) -> String { + let metrics_hash = announcement + .verification_proof + .as_ref() + .map(|p| p.metrics_hash.as_str()) + .unwrap_or(""); + + let payload = announcement_sign_payload( + &announcement.peer_id, + &announcement.display_name, + &announcement.bio, + &announcement.public_key, + announcement.timestamp, + metrics_hash, + ); + + match keypair.sign(&payload) { + Ok(sig) => hex::encode(sig), + Err(e) => { + log::error!("failed to sign announcement: {}", e); + String::new() + } + } +} + +pub fn verify_announcement(public_key_hex: &str, announcement: &ProfileAnnouncement) -> bool { + let pk_bytes = match hex::decode(public_key_hex) { + Ok(b) => b, + Err(_) => return false, + }; + + let public_key = match identity::PublicKey::try_decode_protobuf(&pk_bytes) { + Ok(pk) => pk, + Err(_) => return false, + }; + + let sig_bytes = match hex::decode(&announcement.signature) { + Ok(b) => b, + Err(_) => return false, + }; + + let metrics_hash = announcement + .verification_proof + .as_ref() + .map(|p| p.metrics_hash.as_str()) + .unwrap_or(""); + + let payload = announcement_sign_payload( + &announcement.peer_id, + &announcement.display_name, + &announcement.bio, + &announcement.public_key, + announcement.timestamp, + metrics_hash, + ); + + public_key.verify(&payload, &sig_bytes) +} + +// -- profile revocation signing -- + +fn revocation_sign_payload(peer_id: &str, public_key: &str, timestamp: u64) -> Vec { + format!("dusk-revoke||{}||{}||{}", peer_id, public_key, timestamp).into_bytes() +} + +pub fn sign_revocation(keypair: &identity::Keypair, revocation: &ProfileRevocation) -> String { + let payload = revocation_sign_payload( + &revocation.peer_id, + &revocation.public_key, + revocation.timestamp, + ); + + match keypair.sign(&payload) { + Ok(sig) => hex::encode(sig), + Err(e) => { + log::error!("failed to sign revocation: {}", e); + String::new() + } + } +} + +pub fn verify_revocation(public_key_hex: &str, revocation: &ProfileRevocation) -> bool { + let pk_bytes = match hex::decode(public_key_hex) { + Ok(b) => b, + Err(_) => return false, + }; + + let public_key = match identity::PublicKey::try_decode_protobuf(&pk_bytes) { + Ok(pk) => pk, + Err(_) => return false, + }; + + let sig_bytes = match hex::decode(&revocation.signature) { + Ok(b) => b, + Err(_) => return false, + }; + + let payload = revocation_sign_payload( + &revocation.peer_id, + &revocation.public_key, + revocation.timestamp, + ); + + public_key.verify(&payload, &sig_bytes) +} diff --git a/src/App.tsx b/src/App.tsx index f5d931d..5f0c003 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,13 @@ -import { Component, onMount, onCleanup, createSignal, Show } from "solid-js"; +import { + Component, + onMount, + onCleanup, + createSignal, + createEffect, + on, + Show, + For, +} from "solid-js"; import AppLayout from "./components/layout/AppLayout"; import OverlayMenu from "./components/navigation/OverlayMenu"; import MobileNav from "./components/navigation/MobileNav"; @@ -6,7 +15,10 @@ import Modal from "./components/common/Modal"; import Button from "./components/common/Button"; import SettingsModal from "./components/settings/SettingsModal"; import SignUpScreen from "./components/auth/SignUpScreen"; +import SplashScreen from "./components/auth/SplashScreen"; import UserDirectoryModal from "./components/directory/UserDirectoryModal"; +import ProfileCard from "./components/common/ProfileCard"; +import ProfileModal from "./components/common/ProfileModal"; import { overlayMenuOpen, @@ -28,6 +40,9 @@ import { setChannels, setActiveChannel, activeChannelId, + setCategories, + addCategory, + categories, } from "./stores/channels"; import { addMessage, @@ -46,6 +61,8 @@ import { setPeerCount, setNodeStatus, setIsConnected, + setRelayConnected, + relayConnected, } from "./stores/connection"; import { setDMConversations, @@ -53,6 +70,12 @@ import { addDMMessage, setActiveDM, updateDMLastMessage, + handleIncomingDM, + addDMTypingPeer, + clearDMTypingPeers, + clearDMMessages, + setDMMessages, + updateDMPeerDisplayName, } from "./stores/dms"; import { setKnownPeers, @@ -61,9 +84,21 @@ import { removePeer, clearDirectory, } from "./stores/directory"; +import { + handleVoiceParticipantJoined, + handleVoiceParticipantLeft, + handleVoiceMediaStateChanged, + handleVoiceSdpReceived, + handleVoiceIceCandidateReceived, +} from "./stores/voice"; import * as tauri from "./lib/tauri"; -import type { DuskEvent } from "./lib/types"; +import type { + DuskEvent, + ChallengeExport, + ChannelMeta, + DirectMessage, +} from "./lib/types"; import { resetSettings } from "./stores/settings"; const App: Component = () => { @@ -73,11 +108,101 @@ const App: Component = () => { const [tauriAvailable, setTauriAvailable] = createSignal(false); const [needsSignUp, setNeedsSignUp] = createSignal(false); const [appReady, setAppReady] = createSignal(false); + const [showSplash, setShowSplash] = createSignal(true); const [newCommunityName, setNewCommunityName] = createSignal(""); const [newCommunityDesc, setNewCommunityDesc] = createSignal(""); const [joinInviteCode, setJoinInviteCode] = createSignal(""); const [newChannelName, setNewChannelName] = createSignal(""); const [newChannelTopic, setNewChannelTopic] = createSignal(""); + const [newChannelKind, setNewChannelKind] = createSignal<"Text" | "Voice">( + "Text", + ); + const [newChannelCategoryId, setNewChannelCategoryId] = createSignal< + string | null + >(null); + const [newCategoryName, setNewCategoryName] = createSignal(""); + + // react to community switches by loading channels, members, and selecting first channel + createEffect( + on(activeCommunityId, async (communityId, prev) => { + if (communityId === prev) return; + if (!communityId) { + setChannels([]); + setCategories([]); + setActiveChannel(null); + clearMessages(); + setMembers([]); + return; + } + + if (tauriAvailable()) { + try { + const [chs, cats] = await Promise.all([ + tauri.getChannels(communityId), + tauri.getCategories(communityId), + ]); + setChannels(chs); + setCategories(cats); + + if (chs.length > 0) { + setActiveChannel(chs[0].id); + } else { + setActiveChannel(null); + clearMessages(); + } + + const mems = await tauri.getMembers(communityId); + setMembers(mems); + } catch (e) { + console.error("failed to load community data:", e); + } + } + }), + ); + + // react to channel switches by loading messages for the new channel + createEffect( + on(activeChannelId, async (channelId, prev) => { + if (channelId === prev) return; + if (!channelId) { + clearMessages(); + return; + } + + if (tauriAvailable()) { + try { + clearMessages(); + const msgs = await tauri.getMessages(channelId); + setMessages(msgs); + } catch (e) { + console.error("failed to load messages:", e); + } + } + }), + ); + + // react to dm switches by loading messages for the selected peer + createEffect( + on(activeDMPeerId, async (peerId, prev) => { + if (peerId === prev) return; + clearDMTypingPeers(); + + if (!peerId) { + clearDMMessages(); + return; + } + + if (tauriAvailable()) { + try { + clearDMMessages(); + const msgs = await tauri.getDMMessages(peerId); + setDMMessages(msgs); + } catch (e) { + console.error("failed to load dm messages:", e); + } + } + }), + ); onMount(async () => { cleanupResize = initResponsive(); @@ -142,6 +267,14 @@ const App: Component = () => { // directory not populated yet, that's fine } + // load existing dm conversations from disk + try { + const convos = await tauri.getDMConversations(); + setDMConversations(convos); + } catch { + // no dm history yet, that's fine + } + const communities = await tauri.getCommunities(); setCommunities(communities); @@ -157,19 +290,10 @@ const App: Component = () => { // from the backend will set the accurate state once peers are found. setNodeStatus("running"); + // the createEffect on activeCommunityId handles loading channels, + // messages, and members reactively when this is set if (communities.length > 0) { setActiveCommunity(communities[0].id); - const channels = await tauri.getChannels(communities[0].id); - setChannels(channels); - - if (channels.length > 0) { - setActiveChannel(channels[0].id); - const messages = await tauri.getMessages(channels[0].id); - setMessages(messages); - } - - const members = await tauri.getMembers(communities[0].id); - setMembers(members); } } catch (e) { console.error("initialization error:", e); @@ -219,11 +343,43 @@ const App: Component = () => { event.payload.display_name, event.payload.bio, ); + // keep dm conversation names in sync + updateDMPeerDisplayName( + event.payload.peer_id, + event.payload.display_name, + ); break; case "profile_revoked": // peer revoked their identity, remove them from our local directory removePeer(event.payload.peer_id); break; + case "relay_status": + setRelayConnected(event.payload.connected); + break; + case "dm_received": + handleIncomingDM(event.payload); + break; + case "dm_typing": + // only show typing if the sender is the active dm peer + if (event.payload.peer_id === activeDMPeerId()) { + addDMTypingPeer(event.payload.peer_id); + } + break; + case "voice_participant_joined": + handleVoiceParticipantJoined(event.payload); + break; + case "voice_participant_left": + handleVoiceParticipantLeft(event.payload); + break; + case "voice_media_state_changed": + handleVoiceMediaStateChanged(event.payload); + break; + case "voice_sdp_received": + handleVoiceSdpReceived(event.payload); + break; + case "voice_ice_candidate_received": + handleVoiceIceCandidateReceived(event.payload); + break; } } @@ -269,23 +425,38 @@ const App: Component = () => { tauri.sendTypingIndicator(channelId).catch(() => {}); } - function handleSendDM(content: string) { + async function handleSendDM(content: string) { const peerId = activeDMPeerId(); if (!peerId) return; - const id = identity(); - const msg = { - id: `dm_${Date.now()}`, - channel_id: `dm_${peerId}`, - author_id: id?.peer_id ?? "local", - author_name: id?.display_name ?? "you", - content, - timestamp: Date.now(), - edited: false, - }; + if (tauriAvailable()) { + try { + const msg = await tauri.sendDM(peerId, content); + addDMMessage(msg); + updateDMLastMessage(peerId, content, msg.timestamp); + } catch (e) { + console.error("failed to send dm:", e); + } + } else { + // demo mode fallback + const id = identity(); + const msg: DirectMessage = { + id: `dm_${Date.now()}`, + from_peer: id?.peer_id ?? "local", + to_peer: peerId, + from_display_name: id?.display_name ?? "you", + content, + timestamp: Date.now(), + }; + addDMMessage(msg); + updateDMLastMessage(peerId, content, msg.timestamp); + } + } - addDMMessage(msg); - updateDMLastMessage(peerId, content, msg.timestamp); + function handleDMTyping() { + const peerId = activeDMPeerId(); + if (!peerId || !tauriAvailable()) return; + tauri.sendDMTyping(peerId).catch(() => {}); } function handleOverlayNavigate(action: string) { @@ -318,17 +489,8 @@ const App: Component = () => { try { const community = await tauri.createCommunity(name, desc); addCommunity(community); + // the createEffect on activeCommunityId handles loading channels, messages, members setActiveCommunity(community.id); - - const channels = await tauri.getChannels(community.id); - setChannels(channels); - if (channels.length > 0) { - setActiveChannel(channels[0].id); - clearMessages(); - } - - const members = await tauri.getMembers(community.id); - setMembers(members); } catch (e) { console.error("failed to create community:", e); } @@ -350,6 +512,8 @@ const App: Component = () => { name: "general", topic: "general discussion", kind: "Text", + position: 0, + category_id: null, }, ]); setActiveChannel(chId); @@ -379,17 +543,8 @@ const App: Component = () => { try { const community = await tauri.joinCommunity(inviteCode); addCommunity(community); + // the createEffect on activeCommunityId handles loading channels, messages, members setActiveCommunity(community.id); - - const channels = await tauri.getChannels(community.id); - setChannels(channels); - if (channels.length > 0) { - setActiveChannel(channels[0].id); - clearMessages(); - } - - const members = await tauri.getMembers(community.id); - setMembers(members); } catch (e) { console.error("failed to join community:", e); } @@ -412,6 +567,8 @@ const App: Component = () => { name: "general", topic: "general discussion", kind: "Text", + position: 0, + category_id: null, }, ]); setActiveChannel(chId); @@ -435,35 +592,78 @@ const App: Component = () => { async function handleCreateChannel() { const name = newChannelName().trim(); const topic = newChannelTopic().trim(); + const kind = newChannelKind(); + const categoryId = newChannelCategoryId(); const communityId = activeCommunityId(); if (!name || !communityId) return; if (tauriAvailable()) { try { - const channel = await tauri.createChannel(communityId, name, topic); + const channel = await tauri.createChannel( + communityId, + name, + topic, + kind.toLowerCase(), + categoryId, + ); setChannels((prev) => [...prev, channel]); - setActiveChannel(channel.id); - clearMessages(); + // only auto-select text channels after creation + if (channel.kind === "Text") { + setActiveChannel(channel.id); + } } catch (e) { console.error("failed to create channel:", e); } } else { // demo mode const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`; - const channel = { + const channel: ChannelMeta = { id: chId, community_id: communityId, name, topic: topic || `${name} discussion`, - kind: "Text" as const, + kind, + position: 0, + category_id: categoryId, }; setChannels((prev) => [...prev, channel]); - setActiveChannel(chId); - clearMessages(); + if (kind === "Text") { + setActiveChannel(chId); + clearMessages(); + } } setNewChannelName(""); setNewChannelTopic(""); + setNewChannelKind("Text"); + setNewChannelCategoryId(null); + closeModal(); + } + + async function handleCreateCategory() { + const name = newCategoryName().trim(); + const communityId = activeCommunityId(); + if (!name || !communityId) return; + + if (tauriAvailable()) { + try { + const category = await tauri.createCategory(communityId, name); + addCategory(category); + } catch (e) { + console.error("failed to create category:", e); + } + } else { + // demo mode + const catId = `cat_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`; + addCategory({ + id: catId, + community_id: communityId, + name, + position: 0, + }); + } + + setNewCategoryName(""); closeModal(); } @@ -487,10 +687,18 @@ const App: Component = () => { closeModal(); } - async function handleSignUpComplete(displayName: string, bio: string) { + async function handleSignUpComplete( + displayName: string, + bio: string, + challengeData?: ChallengeExport, + ) { if (tauriAvailable()) { try { - const created = await tauri.createIdentity(displayName, bio); + const created = await tauri.createIdentity( + displayName, + bio, + challengeData, + ); setCurrentIdentity(created); updateSettings({ display_name: displayName }); @@ -532,13 +740,16 @@ const App: Component = () => { setCommunities([]); setActiveCommunity(null); setChannels([]); + setCategories([]); setActiveChannel(null); clearMessages(); setMembers([]); setDMConversations([]); setActiveDM(null); + clearDMTypingPeers(); setPeerCount(0); setIsConnected(false); + setRelayConnected(true); setNodeStatus("stopped"); localStorage.removeItem("dusk_user_settings"); @@ -553,6 +764,14 @@ const App: Component = () => { return (
+ + setShowSplash(false)} + identity={identity()} + relayConnected={relayConnected()} + /> + + @@ -563,8 +782,12 @@ const App: Component = () => { onSendMessage={handleSendMessage} onTyping={handleTyping} onSendDM={handleSendDM} + onDMTyping={handleDMTyping} /> + + + { title="create channel" >
+ {/* channel type selector */} +
+ +
+ + +
+
+
+ + {/* category selector */} + 0}> +
+ + +
+
+
+ +
+
+ + setNewCategoryName(e.currentTarget.value)} + /> +
+ +
+
+ + + + + + + + diff --git a/src/components/auth/HumanVerification.tsx b/src/components/auth/HumanVerification.tsx new file mode 100644 index 0000000..f1759f4 --- /dev/null +++ b/src/components/auth/HumanVerification.tsx @@ -0,0 +1,539 @@ +import type { Component } from "solid-js"; +import { createSignal, Show, For, onCleanup } from "solid-js"; +import { Shield, Check } from "lucide-solid"; +import Button from "../common/Button"; +import type { ChallengeExport } from "../../lib/types"; + +interface HumanVerificationProps { + onVerified: (data: ChallengeExport) => void; +} + +// -- data structures for mouse tracking and analysis -- + +interface TargetCircle { + id: number; + x: number; + y: number; +} + +interface MouseSample { + x: number; + y: number; + t: number; +} + +interface SegmentData { + fromTarget: number; + toTarget: number; + samples: MouseSample[]; + clickTime: number; + startTime: number; +} + +interface ChallengeData { + segments: SegmentData[]; + totalStartTime: number; + totalEndTime: number; +} + +type Phase = "ready" | "active" | "analyzing" | "passed" | "failed"; + +// -- constants -- + +const CONTAINER_WIDTH = 600; +const CONTAINER_HEIGHT = 400; +const CIRCLE_RADIUS = 24; +const MIN_DISTANCE = 120; +const PADDING = 48; +const TARGET_COUNT = 5; +const HUMAN_THRESHOLD = 0.35; + +// -- circle positioning -- + +function generateCirclePositions( + width: number, + height: number, +): TargetCircle[] { + const circles: TargetCircle[] = []; + const maxAttempts = 200; + + for (let id = 1; id <= TARGET_COUNT; id++) { + let placed = false; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const x = PADDING + Math.random() * (width - 2 * PADDING); + const y = PADDING + Math.random() * (height - 2 * PADDING); + + const tooClose = circles.some((c) => { + const dx = c.x - x; + const dy = c.y - y; + return Math.sqrt(dx * dx + dy * dy) < MIN_DISTANCE; + }); + + if (!tooClose) { + circles.push({ id, x, y }); + placed = true; + break; + } + } + + // fallback grid placement if rejection sampling exhausts attempts + if (!placed) { + const cols = 3; + const row = Math.floor((id - 1) / cols); + const col = (id - 1) % cols; + circles.push({ + id, + x: PADDING + col * ((width - 2 * PADDING) / (cols - 1)), + y: PADDING + row * ((height - 2 * PADDING) / 1), + }); + } + } + + return circles; +} + +// -- analysis functions -- +// each returns a score from 0.0 (bot-like) to 1.0 (human-like) + +function scoreTimingVariance(segments: SegmentData[]): number { + if (segments.length < 2) return 0; + + const intervals = segments.map((s) => s.clickTime - s.startTime); + const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length; + if (mean === 0) return 0; + + const variance = + intervals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / intervals.length; + const cv = Math.sqrt(variance) / mean; + + // humans have natural variance in click timing + // bots tend to be metronomic or instantaneous + if (cv < 0.03) return 0; + if (cv < 0.08) return 0.3; + if (cv < 0.12) return 0.6; + return 1.0; +} + +function scorePathCurvature(segments: SegmentData[]): number { + const ratios: number[] = []; + + for (const seg of segments) { + if (seg.samples.length < 3) continue; + + const first = seg.samples[0]; + const last = seg.samples[seg.samples.length - 1]; + const straightDist = Math.sqrt( + (last.x - first.x) ** 2 + (last.y - first.y) ** 2, + ); + + // skip very short movements where curvature is meaningless + if (straightDist < 10) continue; + + let pathLength = 0; + for (let i = 1; i < seg.samples.length; i++) { + const dx = seg.samples[i].x - seg.samples[i - 1].x; + const dy = seg.samples[i].y - seg.samples[i - 1].y; + pathLength += Math.sqrt(dx * dx + dy * dy); + } + + ratios.push(pathLength / straightDist); + } + + if (ratios.length === 0) return 0; + + const avgRatio = ratios.reduce((a, b) => a + b, 0) / ratios.length; + + // humans never move in perfectly straight lines + // motor control imprecision guarantees some curvature + if (avgRatio < 1.02) return 0; + if (avgRatio < 1.06) return 0.3; + if (avgRatio < 1.1) return 0.6; + if (avgRatio > 4.0) return 0.5; + return 1.0; +} + +function scoreSpeedVariance(segments: SegmentData[]): number { + const allSpeedCVs: number[] = []; + + for (const seg of segments) { + if (seg.samples.length < 5) continue; + + const speeds: number[] = []; + for (let i = 1; i < seg.samples.length; i++) { + const dx = seg.samples[i].x - seg.samples[i - 1].x; + const dy = seg.samples[i].y - seg.samples[i - 1].y; + const dt = seg.samples[i].t - seg.samples[i - 1].t; + if (dt > 0) { + speeds.push(Math.sqrt(dx * dx + dy * dy) / dt); + } + } + + if (speeds.length < 3) continue; + + const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length; + if (mean === 0) continue; + + const variance = + speeds.reduce((sum, v) => sum + (v - mean) ** 2, 0) / speeds.length; + const cv = Math.sqrt(variance) / mean; + allSpeedCVs.push(cv); + } + + if (allSpeedCVs.length === 0) return 0; + + const avgCV = allSpeedCVs.reduce((a, b) => a + b, 0) / allSpeedCVs.length; + + // humans accelerate and decelerate naturally along paths + // bots maintain constant velocity + if (avgCV < 0.1) return 0; + if (avgCV < 0.25) return 0.4; + if (avgCV < 0.4) return 0.7; + return 1.0; +} + +function scoreApproachJitter( + segments: SegmentData[], + circles: TargetCircle[], +): number { + const jitterScores: number[] = []; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + const target = circles[i]; + if (seg.samples.length < 5) continue; + + // isolate the last stretch of movement approaching the target + const approachSamples = seg.samples.filter((s) => { + const dx = s.x - target.x; + const dy = s.y - target.y; + return Math.sqrt(dx * dx + dy * dy) < 60; + }); + + if (approachSamples.length < 4) continue; + + // count direction changes via cross product sign flips + let directionChanges = 0; + for (let j = 2; j < approachSamples.length; j++) { + const dx1 = approachSamples[j - 1].x - approachSamples[j - 2].x; + const dy1 = approachSamples[j - 1].y - approachSamples[j - 2].y; + const dx2 = approachSamples[j].x - approachSamples[j - 1].x; + const dy2 = approachSamples[j].y - approachSamples[j - 1].y; + + const cross = dx1 * dy2 - dy1 * dx2; + if (j > 2) { + const prevDx1 = approachSamples[j - 2].x - approachSamples[j - 3].x; + const prevDy1 = approachSamples[j - 2].y - approachSamples[j - 3].y; + const prevCross = prevDx1 * dy1 - prevDy1 * dx1; + if (cross * prevCross < 0) directionChanges++; + } + } + + const jitterRatio = + directionChanges / Math.max(approachSamples.length - 2, 1); + jitterScores.push(jitterRatio); + } + + // not enough data to judge, give a neutral score + if (jitterScores.length === 0) return 0.5; + + const avgJitter = + jitterScores.reduce((a, b) => a + b, 0) / jitterScores.length; + + // humans have micro-corrections from motor noise (fitts's law) + // bots converge smoothly with zero directional jitter + if (avgJitter < 0.01) return 0.2; + if (avgJitter < 0.05) return 0.5; + return 1.0; +} + +function scoreOverallTiming(data: ChallengeData): number { + const totalMs = data.totalEndTime - data.totalStartTime; + const totalSec = totalMs / 1000; + + if (totalSec < 0.8) return 0; + if (totalSec < 1.5) return 0.3; + if (totalSec > 60) return 0.5; + return 1.0; +} + +function analyzeChallenge( + data: ChallengeData, + circles: TargetCircle[], +): { isHuman: boolean; score: number } { + const timing = scoreTimingVariance(data.segments); + const curvature = scorePathCurvature(data.segments); + const speed = scoreSpeedVariance(data.segments); + const jitter = scoreApproachJitter(data.segments, circles); + const overall = scoreOverallTiming(data); + + const score = + timing * 0.25 + + curvature * 0.25 + + speed * 0.2 + + jitter * 0.2 + + overall * 0.1; + + return { + isHuman: score >= HUMAN_THRESHOLD, + score, + }; +} + +// -- component -- + +const HumanVerification: Component = (props) => { + let containerRef: HTMLDivElement | undefined; + + const [phase, setPhase] = createSignal("ready"); + const [circles, setCircles] = createSignal( + generateCirclePositions(CONTAINER_WIDTH, CONTAINER_HEIGHT), + ); + const [currentTarget, setCurrentTarget] = createSignal(1); + const [completedCount, setCompletedCount] = createSignal(0); + const [wrongClickId, setWrongClickId] = createSignal(null); + const [failureMessage, setFailureMessage] = createSignal(""); + + // mutable tracking state, no reactivity needed + let challengeData: ChallengeData = { + segments: [], + totalStartTime: 0, + totalEndTime: 0, + }; + let currentSegmentSamples: MouseSample[] = []; + let currentSegmentStartTime = 0; + let wrongClickTimeout: ReturnType | undefined; + + onCleanup(() => { + if (wrongClickTimeout) clearTimeout(wrongClickTimeout); + }); + + function handleStart() { + const now = performance.now(); + challengeData = { + segments: [], + totalStartTime: now, + totalEndTime: 0, + }; + currentSegmentSamples = []; + currentSegmentStartTime = now; + setPhase("active"); + } + + function handleMouseMove(e: MouseEvent) { + if (phase() !== "active") return; + + const rect = containerRef!.getBoundingClientRect(); + currentSegmentSamples.push({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + t: performance.now(), + }); + } + + function handleCircleClick(circleId: number, e: MouseEvent) { + // prevent click events during non-active phases + if (phase() !== "active") return; + e.stopPropagation(); + + if (circleId !== currentTarget()) { + // wrong target feedback + setWrongClickId(circleId); + if (wrongClickTimeout) clearTimeout(wrongClickTimeout); + wrongClickTimeout = setTimeout(() => setWrongClickId(null), 400); + return; + } + + // correct target hit + const now = performance.now(); + const rect = containerRef!.getBoundingClientRect(); + + challengeData.segments.push({ + fromTarget: circleId - 1, + toTarget: circleId, + samples: [...currentSegmentSamples], + clickTime: now, + startTime: currentSegmentStartTime, + }); + + // reset for next segment + currentSegmentSamples = [ + { x: e.clientX - rect.left, y: e.clientY - rect.top, t: now }, + ]; + currentSegmentStartTime = now; + + const nextCount = completedCount() + 1; + setCompletedCount(nextCount); + + if (nextCount >= TARGET_COUNT) { + // all targets hit, begin analysis + challengeData.totalEndTime = now; + setPhase("analyzing"); + + setTimeout(() => { + const result = analyzeChallenge(challengeData, circles()); + if (result.isHuman) { + setPhase("passed"); + // package raw challenge data for the backend to re-validate + const exportData: ChallengeExport = { + segments: challengeData.segments.map((s) => ({ + fromTarget: s.fromTarget, + toTarget: s.toTarget, + samples: s.samples.map((m) => ({ x: m.x, y: m.y, t: m.t })), + clickTime: s.clickTime, + startTime: s.startTime, + })), + circles: circles().map((c) => ({ id: c.id, x: c.x, y: c.y })), + totalStartTime: challengeData.totalStartTime, + totalEndTime: challengeData.totalEndTime, + }; + setTimeout(() => props.onVerified(exportData), 600); + } else { + setFailureMessage( + "verification failed. please try again.", + ); + setPhase("failed"); + } + }, 1500); + } else { + setCurrentTarget(circleId + 1); + } + } + + function handleRetry() { + setCircles(generateCirclePositions(CONTAINER_WIDTH, CONTAINER_HEIGHT)); + setCurrentTarget(1); + setCompletedCount(0); + setWrongClickId(null); + setFailureMessage(""); + challengeData = { + segments: [], + totalStartTime: 0, + totalEndTime: 0, + }; + currentSegmentSamples = []; + currentSegmentStartTime = 0; + setPhase("ready"); + } + + function circleClasses(circleId: number): string { + const base = + "absolute flex items-center justify-center rounded-full w-12 h-12 text-[16px] font-bold cursor-pointer transition-colors duration-200 select-none"; + + if (wrongClickId() === circleId) { + return `${base} border-2 border-error text-white bg-orange-muted animate-target-shake`; + } + + if (circleId < currentTarget()) { + // completed + return `${base} border-2 border-white/10 text-white/15 animate-target-complete pointer-events-none`; + } + + if (circleId === currentTarget()) { + // active target + return `${base} border-2 border-orange text-white bg-orange-muted animate-target-pulse`; + } + + // pending + return `${base} border-2 border-white/20 text-white/30`; + } + + return ( +
+
+
+ +

+ human verfication +

+
+

complete the action below to verify your humanity

+
+ + {/* challenge area */} +
+ {/* ready state overlay */} + +
+

+ click the circles in order from 1 to 5. +

+ +
+
+ + {/* target circles, visible during ready (dimmed behind overlay) and active */} + + + {(circle, index) => ( +
handleCircleClick(circle.id, e)} + > + {circle.id} +
+ )} +
+
+ + {/* analyzing overlay */} + +
+

verifying...

+
+
+ + {/* passed overlay */} + +
+
+ +
+

verified

+
+
+
+ + {/* progress indicator */} + +

+ {completedCount()} / {TARGET_COUNT} +

+
+ + {/* failure state */} + +
+

{failureMessage()}

+ +
+
+
+ ); +}; + +export default HumanVerification; diff --git a/src/components/auth/SignUpScreen.tsx b/src/components/auth/SignUpScreen.tsx index 6464e0e..c51fc09 100644 --- a/src/components/auth/SignUpScreen.tsx +++ b/src/components/auth/SignUpScreen.tsx @@ -1,28 +1,41 @@ -import { Component, createSignal, Show } from "solid-js"; -import { Key, User, ArrowRight, Shield } from "lucide-solid"; +import { Component, createSignal, Show, Switch, Match } from "solid-js"; +import { ArrowRight } from "lucide-solid"; import Button from "../common/Button"; import Avatar from "../common/Avatar"; +import HumanVerification from "./HumanVerification"; +import type { ChallengeExport } from "../../lib/types"; interface SignUpScreenProps { - onComplete: (displayName: string, bio: string) => void; + onComplete: ( + displayName: string, + bio: string, + challengeData: ChallengeExport, + ) => void; } const SignUpScreen: Component = (props) => { const [displayName, setDisplayName] = createSignal(""); const [bio, setBio] = createSignal(""); - const [step, setStep] = createSignal<"welcome" | "profile">("welcome"); + const [step, setStep] = createSignal<"welcome" | "verification" | "profile">( + "welcome", + ); const [isCreating, setIsCreating] = createSignal(false); + const [challengeData, setChallengeData] = + createSignal(null); function handleBegin() { - setStep("profile"); + setStep("verification"); } async function handleCreate() { const name = displayName().trim(); if (!name) return; + const challenge = challengeData(); + if (!challenge) return; + setIsCreating(true); - props.onComplete(name, bio().trim()); + props.onComplete(name, bio().trim(), challenge); } function handleKeyDown(e: KeyboardEvent) { @@ -30,9 +43,10 @@ const SignUpScreen: Component = (props) => { e.preventDefault(); if (step() === "welcome") { handleBegin(); - } else if (displayName().trim()) { + } else if (step() === "profile" && displayName().trim()) { handleCreate(); } + // verification step is mouse-only, no keyboard shortcuts } } @@ -41,68 +55,19 @@ const SignUpScreen: Component = (props) => { class="h-screen w-screen bg-black flex items-center justify-center overflow-hidden" onKeyDown={handleKeyDown} > - + + {/* welcome screen */}

- dusk + welcome to dusk chat

- peer-to-peer communication. no servers, no surveillance, no - compromise. + truly private peer-to-peer messaging for the masses.

-
-
-
- -
-
-

- keypair identity -

-

- your identity is a cryptographic keypair generated on your - device. no email, no phone number, no corporate account. -

-
-
- -
-
- -
-
-

- your data, your hardware -

-

- everything is stored locally and synced directly between - peers. no central server ever touches your messages. -

-
-
- -
-
- -
-
-

- portable identity -

-

- take your identity anywhere. your keypair is yours forever - and works across any device running dusk. -

-
-
-
-
- } - > - {/* profile creation screen */} -
-

- create your identity -

-

- choose a display name for the network. you can change this later. -

+ - {/* live preview */} -
- -
-

- {displayName() || "your name"} -

- -

{bio()}

-
-

- peer id will be generated -

-
-
+ + { + setChallengeData(data); + setStep("profile"); + }} + /> + -
-
- - setDisplayName(e.currentTarget.value)} - maxLength={32} - autofocus + + {/* profile creation screen */} +
+

+ create your identity +

+

+ choose a display name for the network. you can change this later. +

+ + {/* live preview */} +
+ -

- {displayName().length}/32 -

+
+

+ {displayName() || "your name"} +

+ +

{bio()}

+
+

+ peer id will be generated +

+
-
- -