From 48e49ee6a5d6c40b57369b51b4d3ca1f92391d53 Mon Sep 17 00:00:00 2001 From: cloudwithax Date: Tue, 24 Feb 2026 20:59:19 -0500 Subject: [PATCH] feat(voice): add TURN credential support, ICE restart logic, and connection quality tracking Introduce a TURN credentials protocol for requesting time-limited relay server credentials from the relay node, improving NAT traversal for WebRTC voice connections. WebRTC layer improvements: - Add ICE candidate buffering before remote description is set - Implement automatic ICE restart with configurable max attempts - Add disconnection timeout with delayed restart recovery - Add per-peer connection state tracking and callbacks - Include public STUN servers (Google, Cloudflare) by default - Add detailed logging throughout the WebRTC lifecycle Voice store improvements: - Fetch TURN credentials from relay before establishing connections - Track per-peer RTCPeerConnectionState with reactive signals - Derive overall voice quality signal (good/connecting/degraded/failed) - Evaluate and surface degraded connection state to the UI - Clean up peer connection states on participant leave - Fix video toggle resource leak (stop unused audio tracks) Backend changes: - Add TurnCredentialRequest/Response protocol types - Wire turn_credentials behaviour into swarm and node event loop - Add get_turn_credentials Tauri command - Propagate errors from voice commands instead of silently dropping - Add input validation for SDP types in send_voice_sdp - Handle missing node handle as explicit error in all voice commands - Add DirectoryResponse::Error variant handling - Scope Linux WebKitGTK permission grants to UserMediaPermissionRequest UI improvements: - Show connection quality indicator in voice channel header - Add per-peer connection state ring on participant tiles - Display failed connection warning icon on participant tiles - Stack muted/deafened indicators vertically to avoid overlap - Show participant grid in degraded connection state Also remove unused imports across several frontend components. --- package.json | 4 +- src-tauri/src/commands/voice.rs | 169 ++++++- src-tauri/src/lib.rs | 13 +- src-tauri/src/node/behaviour.rs | 3 + src-tauri/src/node/mod.rs | 56 +++ src-tauri/src/node/swarm.rs | 10 + src-tauri/src/protocol/directory.rs | 1 + src-tauri/src/protocol/mod.rs | 1 + src-tauri/src/protocol/turn.rs | 21 + src/components/chat/MessageInput.tsx | 2 - src/components/layout/DMSidebar.tsx | 2 +- src/components/layout/HomeView.tsx | 1 - .../settings/CommunitySettingsModal.tsx | 4 +- src/components/voice/VoiceChannel.tsx | 64 ++- src/components/voice/VoiceParticipantTile.tsx | 50 +- src/lib/tauri.ts | 13 + src/lib/webrtc.ts | 444 ++++++++++++++---- src/stores/voice.ts | 239 +++++++--- 18 files changed, 897 insertions(+), 200 deletions(-) create mode 100644 src-tauri/src/protocol/turn.rs diff --git a/package.json b/package.json index 8abd6e6..1148637 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "tauri": "tauri", + "tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri", "tauri:linux": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri", "tauri:dev": "tauri dev", "tauri:dev:linux": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri dev", @@ -50,4 +50,4 @@ "@tailwindcss/vite": "^4.0.0", "tailwindcss": "^4.0.0" } -} +} \ No newline at end of file diff --git a/src-tauri/src/commands/voice.rs b/src-tauri/src/commands/voice.rs index b871177..47cbb63 100644 --- a/src-tauri/src/commands/voice.rs +++ b/src-tauri/src/commands/voice.rs @@ -3,6 +3,7 @@ use tauri::State; use crate::node::gossip; use crate::node::NodeCommand; use crate::protocol::messages::{GossipMessage, VoiceMediaState, VoiceParticipant}; +use crate::protocol::turn::TurnCredentialResponse; use crate::AppState; #[tauri::command] @@ -11,6 +12,11 @@ pub async fn join_voice_channel( community_id: String, channel_id: String, ) -> Result, String> { + eprintln!( + "[Voice] join_voice_channel called: community={}, channel={}", + community_id, channel_id + ); + let identity = state.identity.lock().await; let id = identity.as_ref().ok_or("no identity loaded")?; @@ -29,12 +35,16 @@ pub async fn join_voice_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 + handle .command_tx .send(NodeCommand::Subscribe { topic: voice_topic.clone(), }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to subscribe to voice topic: {}", e); + format!("Failed to subscribe to voice channel: {}", e) + })?; // publish our join announcement let msg = GossipMessage::VoiceJoin { @@ -45,13 +55,22 @@ pub async fn join_voice_channel( media_state: media_state.clone(), }; let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; - let _ = handle + handle .command_tx .send(NodeCommand::SendMessage { topic: voice_topic, data, }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to publish VoiceJoin: {}", e); + format!("Failed to send voice join announcement: {}", e) + })?; + + eprintln!("[Voice] Successfully published VoiceJoin for peer {}", peer_id); + } else { + eprintln!("[Voice] No node handle available — cannot join voice channel"); + return Err("Node not running — cannot join voice channel".to_string()); } // add ourselves to the local voice channel tracking @@ -68,6 +87,16 @@ pub async fn join_voice_channel( let result = participants.clone(); drop(vc); + // TODO: Participant list race condition + // The participant list returned here only includes locally-tracked peers. + // A newly joining peer will not see existing participants until they receive + // VoiceJoin gossip messages from those peers. To fix this properly, we need: + // 1. A new GossipMessage::VoiceParticipantsRequest variant + // 2. Existing peers respond to the request by re-broadcasting their VoiceJoin + // 3. Or implement a request/response protocol over gossipsub or a direct stream + // This requires changes to protocol/messages.rs and node/mod.rs (gossip handler). + // For now, the frontend should handle late-arriving VoiceJoin events gracefully. + log::info!("joined voice channel {}:{}", community_id, channel_id); Ok(result) @@ -79,6 +108,11 @@ pub async fn leave_voice_channel( community_id: String, channel_id: String, ) -> Result<(), String> { + eprintln!( + "[Voice] leave_voice_channel called: community={}, channel={}", + community_id, channel_id + ); + let identity = state.identity.lock().await; let id = identity.as_ref().ok_or("no identity loaded")?; let peer_id = id.peer_id.to_string(); @@ -95,19 +129,32 @@ pub async fn leave_voice_channel( peer_id: peer_id.clone(), }; let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; - let _ = handle + handle .command_tx .send(NodeCommand::SendMessage { topic: voice_topic.clone(), data, }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to publish VoiceLeave: {}", e); + format!("Failed to send voice leave announcement: {}", e) + })?; + + eprintln!("[Voice] Successfully published VoiceLeave for peer {}", peer_id); // unsubscribe from the voice topic - let _ = handle + handle .command_tx .send(NodeCommand::Unsubscribe { topic: voice_topic }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to unsubscribe from voice topic: {}", e); + format!("Failed to unsubscribe from voice channel: {}", e) + })?; + } else { + eprintln!("[Voice] No node handle available — cannot leave voice channel"); + return Err("Node not running — cannot leave voice channel".to_string()); } // remove ourselves from local tracking @@ -133,6 +180,11 @@ pub async fn update_voice_media_state( channel_id: String, media_state: VoiceMediaState, ) -> Result<(), String> { + eprintln!( + "[Voice] update_voice_media_state called: community={}, channel={}", + community_id, channel_id + ); + let identity = state.identity.lock().await; let id = identity.as_ref().ok_or("no identity loaded")?; let peer_id = id.peer_id.to_string(); @@ -148,13 +200,22 @@ pub async fn update_voice_media_state( media_state: media_state.clone(), }; let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; - let _ = handle + handle .command_tx .send(NodeCommand::SendMessage { topic: voice_topic, data, }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to publish VoiceMediaStateUpdate: {}", e); + format!("Failed to send media state update: {}", e) + })?; + + eprintln!("[Voice] Successfully published VoiceMediaStateUpdate for peer {}", peer_id); + } else { + eprintln!("[Voice] No node handle available — cannot update media state"); + return Err("Node not running — cannot update media state".to_string()); } // update local tracking @@ -179,6 +240,23 @@ pub async fn send_voice_sdp( sdp_type: String, sdp: String, ) -> Result<(), String> { + eprintln!( + "[Voice] send_voice_sdp called: community={}, channel={}, to_peer={}, sdp_type={}", + community_id, channel_id, to_peer, sdp_type + ); + + // Validate SDP type before doing anything else + match sdp_type.as_str() { + "offer" | "answer" | "pranswer" => {} + _ => { + eprintln!("[Voice] Invalid SDP type: {}", sdp_type); + return Err(format!( + "Invalid SDP type '{}': must be one of 'offer', 'answer', 'pranswer'", + sdp_type + )); + } + } + let identity = state.identity.lock().await; let id = identity.as_ref().ok_or("no identity loaded")?; let from_peer = id.peer_id.to_string(); @@ -190,19 +268,28 @@ pub async fn send_voice_sdp( let msg = GossipMessage::VoiceSdp { community_id, channel_id, - from_peer, + from_peer: from_peer.clone(), to_peer, sdp_type, sdp, }; let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; - let _ = handle + handle .command_tx .send(NodeCommand::SendMessage { topic: voice_topic, data, }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to publish VoiceSdp: {}", e); + format!("Failed to send voice SDP: {}", e) + })?; + + eprintln!("[Voice] Successfully published VoiceSdp from peer {}", from_peer); + } else { + eprintln!("[Voice] No node handle available — cannot send SDP"); + return Err("Node not running — cannot send SDP".to_string()); } Ok(()) @@ -218,6 +305,11 @@ pub async fn send_voice_ice_candidate( sdp_mid: Option, sdp_mline_index: Option, ) -> Result<(), String> { + eprintln!( + "[Voice] send_voice_ice_candidate called: community={}, channel={}, to_peer={}", + community_id, channel_id, to_peer + ); + let identity = state.identity.lock().await; let id = identity.as_ref().ok_or("no identity loaded")?; let from_peer = id.peer_id.to_string(); @@ -229,20 +321,29 @@ pub async fn send_voice_ice_candidate( let msg = GossipMessage::VoiceIceCandidate { community_id, channel_id, - from_peer, + from_peer: from_peer.clone(), to_peer, candidate, sdp_mid, sdp_mline_index, }; let data = serde_json::to_vec(&msg).map_err(|e| format!("serialize error: {}", e))?; - let _ = handle + handle .command_tx .send(NodeCommand::SendMessage { topic: voice_topic, data, }) - .await; + .await + .map_err(|e| { + eprintln!("[Voice] Failed to publish VoiceIceCandidate: {}", e); + format!("Failed to send voice ICE candidate: {}", e) + })?; + + eprintln!("[Voice] Successfully published VoiceIceCandidate from peer {}", from_peer); + } else { + eprintln!("[Voice] No node handle available — cannot send ICE candidate"); + return Err("Node not running — cannot send ICE candidate".to_string()); } Ok(()) @@ -254,8 +355,44 @@ pub async fn get_voice_participants( community_id: String, channel_id: String, ) -> Result, String> { + eprintln!( + "[Voice] get_voice_participants called: community={}, channel={}", + community_id, channel_id + ); + let key = format!("{}:{}", community_id, channel_id); let vc = state.voice_channels.lock().await; let participants = vc.get(&key).cloned().unwrap_or_default(); + + eprintln!( + "[Voice] Returning {} participants for {}", + participants.len(), + key + ); + Ok(participants) } + +#[tauri::command] +pub async fn get_turn_credentials( + state: State<'_, AppState>, +) -> Result { + eprintln!("[Voice] get_turn_credentials called"); + + let handle_ref = state.node_handle.lock().await; + let handle = handle_ref.as_ref().ok_or("node not running")?; + + let (tx, rx) = tokio::sync::oneshot::channel(); + + handle + .command_tx + .send(NodeCommand::GetTurnCredentials { reply: tx }) + .await + .map_err(|_| "failed to send get_turn_credentials command".to_string())?; + + // drop the lock before awaiting the response + drop(handle_ref); + + rx.await + .map_err(|_| "turn credentials response channel closed".to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88e8bdb..4e3b722 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,18 +63,26 @@ pub fn run() { .setup(|app| { // grant microphone/camera permissions on linux webkitgtk // without this, getUserMedia is denied by default + // only allow UserMediaPermissionRequest (mic/camera), deny everything else #[cfg(target_os = "linux")] { use tauri::Manager; if let Some(window) = app.get_webview_window("main") { window .with_webview(|webview| { + use webkit2gtk::glib::prelude::ObjectExt; use webkit2gtk::PermissionRequestExt; use webkit2gtk::WebViewExt; let wv = webview.inner(); wv.connect_permission_request(|_webview, request| { - request.allow(); - true + if request.is::() { + request.allow(); + true + } else { + // deny all other permission types (geolocation, etc.) + request.deny(); + true + } }); }) .ok(); @@ -150,6 +158,7 @@ pub fn run() { commands::voice::send_voice_sdp, commands::voice::send_voice_ice_candidate, commands::voice::get_voice_participants, + commands::voice::get_turn_credentials, commands::dm::send_dm, commands::dm::get_dm_messages, commands::dm::search_dm_messages, diff --git a/src-tauri/src/node/behaviour.rs b/src-tauri/src/node/behaviour.rs index b8e3c48..dc7cc0d 100644 --- a/src-tauri/src/node/behaviour.rs +++ b/src-tauri/src/node/behaviour.rs @@ -1,5 +1,6 @@ use crate::protocol::directory::{DirectoryRequest, DirectoryResponse}; use crate::protocol::gif::{GifRequest, GifResponse}; +use crate::protocol::turn::{TurnCredentialRequest, TurnCredentialResponse}; use libp2p::{ gossipsub, identify, kad, mdns, ping, relay, rendezvous, request_response::cbor, swarm::NetworkBehaviour, @@ -18,4 +19,6 @@ pub struct DuskBehaviour { pub gif_service: cbor::Behaviour, // directory search: register/search/remove profiles on the relay pub directory_service: cbor::Behaviour, + // turn credentials: request time-limited TURN server credentials from the relay + pub turn_credentials: cbor::Behaviour, } diff --git a/src-tauri/src/node/mod.rs b/src-tauri/src/node/mod.rs index e58cf01..c0c0457 100644 --- a/src-tauri/src/node/mod.rs +++ b/src-tauri/src/node/mod.rs @@ -223,6 +223,10 @@ pub enum NodeCommand { SetRelayDiscoverable { enabled: bool, }, + // request time-limited TURN server credentials from the relay + GetTurnCredentials { + reply: tokio::sync::oneshot::Sender>, + }, } // events emitted from the node to the tauri frontend @@ -488,6 +492,14 @@ pub async fn start( >, > = HashMap::new(); + // pending turn credential replies keyed by request_response request id + let mut pending_turn_credential_replies: HashMap< + libp2p::request_response::OutboundRequestId, + tokio::sync::oneshot::Sender< + Result, + >, + > = HashMap::new(); + // relay_discoverable flag -- read from storage once at startup let mut relay_discoverable = storage .load_settings() @@ -1401,6 +1413,10 @@ pub async fn start( crate::protocol::directory::DirectoryResponse::Ok => { let _ = reply.send(Ok(vec![])); } + crate::protocol::directory::DirectoryResponse::Error(msg) => { + log::warn!("directory service error from relay: {}", msg); + let _ = reply.send(Ok(vec![])); + } } } } @@ -1415,6 +1431,29 @@ pub async fn start( } libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::DirectoryService(_)) => {} + // turn credentials response from relay + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials( + libp2p::request_response::Event::Message { + message: libp2p::request_response::Message::Response { request_id, response }, + .. + } + )) => { + if let Some(reply) = pending_turn_credential_replies.remove(&request_id) { + let _ = reply.send(Ok(response)); + } + } + // turn credentials outbound failure + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials( + libp2p::request_response::Event::OutboundFailure { request_id, error, .. } + )) => { + log::warn!("turn credentials: outbound failure: {:?}", error); + if let Some(reply) = pending_turn_credential_replies.remove(&request_id) { + let _ = reply.send(Err(format!("turn credentials request failed: {:?}", error))); + } + } + // ignore inbound requests and other events for turn credentials + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::TurnCredentials(_)) => {} + _ => {} } } @@ -1777,6 +1816,23 @@ pub async fn start( } } } + Some(NodeCommand::GetTurnCredentials { reply }) => { + if let Some(rp) = relay_peer { + let local_peer_id = swarm_instance.local_peer_id().to_string(); + let request_id = swarm_instance + .behaviour_mut() + .turn_credentials + .send_request( + &rp, + crate::protocol::turn::TurnCredentialRequest { + peer_id: local_peer_id, + }, + ); + pending_turn_credential_replies.insert(request_id, reply); + } else { + let _ = reply.send(Err("not connected to relay".to_string())); + } + } } } } diff --git a/src-tauri/src/node/swarm.rs b/src-tauri/src/node/swarm.rs index 8c1f379..407a653 100644 --- a/src-tauri/src/node/swarm.rs +++ b/src-tauri/src/node/swarm.rs @@ -11,6 +11,9 @@ use libp2p::{ use super::behaviour::DuskBehaviour; use crate::protocol::directory::{DirectoryRequest, DirectoryResponse, DIRECTORY_PROTOCOL}; use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL}; +use crate::protocol::turn::{ + TurnCredentialRequest, TurnCredentialResponse, TURN_CREDENTIALS_PROTOCOL, +}; pub fn build_swarm( keypair: &identity::Keypair, @@ -92,6 +95,13 @@ pub fn build_swarm( request_response::Config::default() .with_request_timeout(Duration::from_secs(15)), ), + // turn credentials via request-response to the relay (outbound only) + turn_credentials: + cbor::Behaviour::::new( + [(TURN_CREDENTIALS_PROTOCOL, ProtocolSupport::Outbound)], + request_response::Config::default() + .with_request_timeout(Duration::from_secs(10)), + ), } })? .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) diff --git a/src-tauri/src/protocol/directory.rs b/src-tauri/src/protocol/directory.rs index 8e921b1..3aae9ce 100644 --- a/src-tauri/src/protocol/directory.rs +++ b/src-tauri/src/protocol/directory.rs @@ -17,6 +17,7 @@ pub enum DirectoryRequest { pub enum DirectoryResponse { Ok, Results(Vec), + Error(String), } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src-tauri/src/protocol/mod.rs b/src-tauri/src/protocol/mod.rs index a0c15bf..dec3d7d 100644 --- a/src-tauri/src/protocol/mod.rs +++ b/src-tauri/src/protocol/mod.rs @@ -3,3 +3,4 @@ pub mod directory; pub mod gif; pub mod identity; pub mod messages; +pub mod turn; diff --git a/src-tauri/src/protocol/turn.rs b/src-tauri/src/protocol/turn.rs new file mode 100644 index 0000000..465169a --- /dev/null +++ b/src-tauri/src/protocol/turn.rs @@ -0,0 +1,21 @@ +// turn credential protocol types for requesting time-limited TURN server +// credentials from the relay. the client sends a TurnCredentialRequest +// and receives a TurnCredentialResponse with HMAC-based credentials. + +use libp2p::StreamProtocol; + +pub const TURN_CREDENTIALS_PROTOCOL: StreamProtocol = + StreamProtocol::new("/dusk/turn-credentials/1.0.0"); + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TurnCredentialRequest { + pub peer_id: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TurnCredentialResponse { + pub username: String, + pub password: String, + pub ttl: u64, + pub uris: Vec, +} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 36992da..8bd3e73 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -8,7 +8,6 @@ import { Extension } from "@tiptap/core"; import Mention from "@tiptap/extension-mention"; import { tiptapToMarkdown } from "../../lib/markdown"; import { members } from "../../stores/members"; -import { identity } from "../../stores/identity"; import EmojiPicker from "./EmojiPicker"; import GifPicker from "./GifPicker"; import MentionList from "./MentionList"; @@ -65,7 +64,6 @@ const MessageInput: Component = (props) => { // build the mention items list from community members or dm peers function getMentionItems(query: string): MentionItem[] { const q = query.toLowerCase(); - const currentUser = identity(); // dm context uses the explicit peer list passed via props if (props.mentionPeers) { diff --git a/src/components/layout/DMSidebar.tsx b/src/components/layout/DMSidebar.tsx index 0aec985..bee91e2 100644 --- a/src/components/layout/DMSidebar.tsx +++ b/src/components/layout/DMSidebar.tsx @@ -1,6 +1,6 @@ import type { Component } from "solid-js"; import { For, Show, createSignal } from "solid-js"; -import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid"; +import { Search, X, Plus, Users } from "lucide-solid"; import { resolveMentionsPlainText } from "../../lib/mentions"; import { dmConversations, diff --git a/src/components/layout/HomeView.tsx b/src/components/layout/HomeView.tsx index 5338aff..9426706 100644 --- a/src/components/layout/HomeView.tsx +++ b/src/components/layout/HomeView.tsx @@ -9,7 +9,6 @@ import { import { knownPeers, friends } from "../../stores/directory"; import { onlinePeerIds } from "../../stores/members"; import { identity } from "../../stores/identity"; -import { peerCount, nodeStatus } from "../../stores/connection"; import { openModal } from "../../stores/ui"; import * as tauri from "../../lib/tauri"; import Avatar from "../common/Avatar"; diff --git a/src/components/settings/CommunitySettingsModal.tsx b/src/components/settings/CommunitySettingsModal.tsx index de7cdca..6f40ef6 100644 --- a/src/components/settings/CommunitySettingsModal.tsx +++ b/src/components/settings/CommunitySettingsModal.tsx @@ -42,7 +42,7 @@ import { clearMessages } from "../../stores/messages"; import * as tauri from "../../lib/tauri"; import Avatar from "../common/Avatar"; import Button from "../common/Button"; -import type { ChannelMeta, CategoryMeta, Member } from "../../lib/types"; +import type { ChannelMeta, Member } from "../../lib/types"; type CommunitySettingsSection = | "overview" @@ -87,8 +87,6 @@ const CommunitySettingsModal: Component = ( } }); - const community = () => activeCommunity(); - const sections: { id: CommunitySettingsSection; label: string; diff --git a/src/components/voice/VoiceChannel.tsx b/src/components/voice/VoiceChannel.tsx index 2e86324..c7676bc 100644 --- a/src/components/voice/VoiceChannel.tsx +++ b/src/components/voice/VoiceChannel.tsx @@ -7,6 +7,8 @@ import { remoteStreams, voiceConnectionState, voiceError, + voiceQuality, + peerConnectionStates, joinVoice, } from "../../stores/voice"; import { identity } from "../../stores/identity"; @@ -61,11 +63,51 @@ const VoiceChannel: Component = (props) => { return allParticipants().length; }; + // voice quality indicator config + const qualityConfig = () => { + const q = voiceQuality(); + switch (q) { + case "good": + return { color: "bg-green-500", text: "Connected" }; + case "connecting": + return { color: "bg-amber-400 animate-pulse", text: "Connecting..." }; + case "degraded": + return { color: "bg-orange-500", text: "Degraded" }; + case "failed": + return { color: "bg-red-500", text: "Connection Failed" }; + default: + return { color: "bg-white/40", text: "" }; + } + }; + + // look up per-peer connection state for a participant + const getPeerState = (peerId: string, isLocal: boolean) => { + if (isLocal) return "connected" as RTCPeerConnectionState; + return peerConnectionStates()[peerId]; + }; + return (
-

Voice Channel

+
+

Voice Channel

+ +
+ + + {qualityConfig().text} + +
+
+

{participantCount()} participant {participantCount() !== 1 ? "s" : ""} @@ -100,8 +142,13 @@ const VoiceChannel: Component = (props) => {

- {/* connected state with participants grid */} - + {/* connected / degraded state with participants grid */} +
{(participant) => ( @@ -111,6 +158,10 @@ const VoiceChannel: Component = (props) => { media_state={participant.media_state} stream={participant.stream} is_local={participant.is_local} + connectionState={getPeerState( + participant.peer_id, + participant.is_local, + )} /> )} @@ -118,7 +169,12 @@ const VoiceChannel: Component = (props) => {
- +
diff --git a/src/components/voice/VoiceParticipantTile.tsx b/src/components/voice/VoiceParticipantTile.tsx index b576e07..fd241aa 100644 --- a/src/components/voice/VoiceParticipantTile.tsx +++ b/src/components/voice/VoiceParticipantTile.tsx @@ -1,6 +1,6 @@ import type { Component } from "solid-js"; import { Show, createEffect, onCleanup } from "solid-js"; -import { MicOff, VolumeX } from "lucide-solid"; +import { MicOff, VolumeX, AlertTriangle } from "lucide-solid"; import Avatar from "../common/Avatar"; import { openProfileCard } from "../../stores/ui"; import type { VoiceMediaState } from "../../lib/types"; @@ -11,6 +11,7 @@ interface VoiceParticipantTileProps { media_state: VoiceMediaState; stream?: MediaStream | null; is_local?: boolean; + connectionState?: RTCPeerConnectionState; } const VoiceParticipantTile: Component = (props) => { @@ -38,9 +39,28 @@ const VoiceParticipantTile: Component = (props) => { ); }; + // per-peer connection state ring styling + const connectionRingClass = () => { + const state = props.connectionState; + switch (state) { + case "connected": + return "ring-2 ring-green-500/70"; + case "connecting": + case "new": + return "ring-2 ring-amber-400/70 animate-pulse"; + case "failed": + return "ring-2 ring-red-500/80"; + case "disconnected": + return "ring-2 ring-white/30"; + case "closed": + default: + return ""; + } + }; + return (
{ openProfileCard({ peerId: props.peer_id, @@ -75,15 +95,25 @@ const VoiceParticipantTile: Component = (props) => {
- -
- -
-
+ {/* media state indicators — stacked vertically to avoid overlap */} +
+ +
+ +
+
- -
- + +
+ +
+
+
+ + {/* connection failed warning */} + +
+
diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index d8b1dca..270bc6d 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -398,6 +398,19 @@ export async function getVoiceParticipants( return invoke("get_voice_participants", { communityId, channelId }); } +// -- turn credentials -- + +export interface TurnCredentials { + username: string; + password: string; + ttl: number; + uris: string[]; +} + +export async function getTurnCredentials(): Promise { + return invoke("get_turn_credentials"); +} + // -- direct messages -- export async function sendDM( diff --git a/src/lib/webrtc.ts b/src/lib/webrtc.ts index c724ee4..04ce22a 100644 --- a/src/lib/webrtc.ts +++ b/src/lib/webrtc.ts @@ -2,25 +2,77 @@ // manages one RTCPeerConnection per remote peer in a full mesh topology // this is a utility module with no signals - the voice store drives it -// no external stun/turn servers for now, rely on host candidates only -// this works for LAN peers and peers on the same network segment -const rtcConfig: RTCConfiguration = { - iceServers: [], -}; +const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ + // Public STUN servers (free, no auth needed) + { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }, + { urls: 'stun:stun.cloudflare.com:3478' }, + // TURN servers are added dynamically via getTurnCredentials() +]; + +/** Maximum ICE restart attempts before giving up on a peer */ +const MAX_ICE_RESTART_ATTEMPTS = 3; + +/** Delay before attempting ICE restart after disconnection (ms) */ +const DISCONNECT_TIMEOUT_MS = 5000; + +export interface PeerConnectionManagerConfig { + onNegotiationNeeded: (peerId: string, sdp: RTCSessionDescriptionInit) => void; + onIceCandidate: (peerId: string, candidate: RTCIceCandidate) => void; + onRemoteStream: (peerId: string, stream: MediaStream) => void; + onRemoteStreamRemoved: (peerId: string) => void; + onPeerConnectionStateChanged?: (peerId: string, state: RTCPeerConnectionState) => void; + iceServers?: RTCIceServer[]; +} + +/** Per-peer state tracking beyond just the RTCPeerConnection */ +interface PeerState { + pc: RTCPeerConnection; + /** ICE candidates received before remote description was set */ + candidateBuffer: RTCIceCandidateInit[]; + /** Number of ICE restart attempts for this peer */ + restartAttempts: number; + /** Timeout handle for delayed ICE restart after disconnection */ + disconnectTimer: ReturnType | null; +} export class PeerConnectionManager { - private connections: Map = new Map(); + private peers: Map = new Map(); private localStream: MediaStream | null = null; private screenStream: MediaStream | null = null; + private rtcConfig: RTCConfiguration; // the local peer id, used for glare resolution during simultaneous offers private localPeerId: string | null = null; - // callbacks set by the voice store to bridge webrtc events into reactive state + // ---- legacy callback properties (for backward compat with voice store) ---- + // these are used when the class is constructed without a config object onRemoteStream: ((peerId: string, stream: MediaStream) => void) | null = null; onRemoteStreamRemoved: ((peerId: string) => void) | null = null; onIceCandidate: ((peerId: string, candidate: RTCIceCandidate) => void) | null = null; - onNegotiationNeeded: ((peerId: string) => void) | null = null; + onNegotiationNeeded: ((peerId: string, sdp?: RTCSessionDescriptionInit) => void) | null = null; + onPeerConnectionStateChanged: ((peerId: string, state: RTCPeerConnectionState) => void) | null = null; + + private config: PeerConnectionManagerConfig | null = null; + + constructor(config?: PeerConnectionManagerConfig) { + const iceServers = config?.iceServers ?? DEFAULT_ICE_SERVERS; + this.rtcConfig = { + iceServers, + iceTransportPolicy: 'all', + }; + + if (config) { + this.config = config; + // Also set legacy callback properties from config for internal use + this.onRemoteStream = config.onRemoteStream; + this.onRemoteStreamRemoved = config.onRemoteStreamRemoved; + this.onIceCandidate = config.onIceCandidate; + this.onNegotiationNeeded = (peerId: string, sdp?: RTCSessionDescriptionInit) => { + if (sdp) config.onNegotiationNeeded(peerId, sdp); + }; + this.onPeerConnectionStateChanged = config.onPeerConnectionStateChanged ?? null; + } + } setLocalPeerId(peerId: string): void { this.localPeerId = peerId; @@ -34,20 +86,32 @@ export class PeerConnectionManager { this.screenStream = stream; } - // create a new peer connection for a remote peer - // uses lexicographic peer_id comparison for glare resolution: - // the peer with the smaller id is always the offerer - createConnection(peerId: string): RTCPeerConnection { - // close any existing connection to this peer before creating a new one - this.closeConnection(peerId); + // determine if we should be the offerer based on lexicographic peer_id comparison + shouldOffer(remotePeerId: string): boolean { + if (!this.localPeerId) return false; + return this.localPeerId < remotePeerId; + } - const pc = new RTCPeerConnection(rtcConfig); - this.connections.set(peerId, pc); + // create a new peer connection for a remote peer + // accepts an optional localStream parameter (for new API), falls back to this.localStream + createConnection(peerId: string, localStream?: MediaStream | null): RTCPeerConnection { + // close any existing connection to this peer before creating a new one + this.removeConnection(peerId); + + const pc = new RTCPeerConnection(this.rtcConfig); + const peerState: PeerState = { + pc, + candidateBuffer: [], + restartAttempts: 0, + disconnectTimer: null, + }; + this.peers.set(peerId, peerState); // add all local tracks to the new connection - if (this.localStream) { - for (const track of this.localStream.getTracks()) { - pc.addTrack(track, this.localStream); + const stream = localStream ?? this.localStream; + if (stream) { + for (const track of stream.getTracks()) { + pc.addTrack(track, stream); } } @@ -58,141 +122,293 @@ export class PeerConnectionManager { } // wire up event handlers + this.setupPeerEventHandlers(peerId, peerState); + + return pc; + } + + private setupPeerEventHandlers(peerId: string, peerState: PeerState): void { + const { pc } = peerState; + pc.onicecandidate = (event) => { - if (event.candidate && this.onIceCandidate) { - this.onIceCandidate(peerId, event.candidate); + if (event.candidate) { + console.log(`[WebRTC] ICE candidate for ${peerId}: ${event.candidate.type ?? 'null'} ${event.candidate.candidate.substring(0, 60)}...`); + if (this.onIceCandidate) { + this.onIceCandidate(peerId, event.candidate); + } } }; pc.ontrack = (event) => { + console.log(`[WebRTC] Remote track received from ${peerId}: kind=${event.track.kind}`); if (event.streams.length > 0 && this.onRemoteStream) { this.onRemoteStream(peerId, event.streams[0]); } }; - pc.onnegotiationneeded = () => { + pc.onnegotiationneeded = async () => { + console.log(`[WebRTC] Negotiation needed for ${peerId}`); if (this.onNegotiationNeeded) { - this.onNegotiationNeeded(peerId); + // If using new API (config-based), auto-create offer and pass SDP + if (this.config) { + if (!this.shouldOffer(peerId)) { + console.log(`[WebRTC] Skipping negotiation for ${peerId} (remote peer should offer)`); + return; + } + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + this.onNegotiationNeeded(peerId, offer); + } catch (err) { + console.error(`[WebRTC] Failed to create offer during negotiation for ${peerId}:`, err); + } + } else { + // Legacy API: just notify the caller (voice store handles offer creation) + this.onNegotiationNeeded(peerId); + } } }; pc.onconnectionstatechange = () => { - if (pc.connectionState === "failed" || pc.connectionState === "closed") { + const state = pc.connectionState; + console.log(`[WebRTC] Connection state for ${peerId}: ${state}`); + + // Fire per-peer connection state callback + if (this.onPeerConnectionStateChanged) { + this.onPeerConnectionStateChanged(peerId, state); + } + + if (state === 'failed' || state === 'closed') { if (this.onRemoteStreamRemoved) { this.onRemoteStreamRemoved(peerId); } } }; - return pc; + pc.oniceconnectionstatechange = () => { + const iceState = pc.iceConnectionState; + console.log(`[WebRTC] ICE connection state for ${peerId}: ${iceState}`); + + if (iceState === 'disconnected') { + // Start a timeout — if still disconnected after DISCONNECT_TIMEOUT_MS, attempt restart + this.clearDisconnectTimer(peerState); + peerState.disconnectTimer = setTimeout(() => { + peerState.disconnectTimer = null; + if (pc.iceConnectionState === 'disconnected') { + console.log(`[WebRTC] Peer ${peerId} still disconnected after timeout, attempting ICE restart`); + this.attemptIceRestart(peerId, peerState); + } + }, DISCONNECT_TIMEOUT_MS); + } else if (iceState === 'failed') { + // Immediately attempt ICE restart + this.clearDisconnectTimer(peerState); + console.log(`[WebRTC] ICE failed for ${peerId}, attempting ICE restart`); + this.attemptIceRestart(peerId, peerState); + } else if (iceState === 'connected' || iceState === 'completed') { + // Connection recovered — reset restart counter and clear timers + this.clearDisconnectTimer(peerState); + peerState.restartAttempts = 0; + } + }; + + pc.onicegatheringstatechange = () => { + console.log(`[WebRTC] ICE gathering state for ${peerId}: ${pc.iceGatheringState}`); + }; } - // determine if we should be the offerer based on lexicographic peer_id comparison - shouldOffer(remotePeerId: string): boolean { - if (!this.localPeerId) return false; - return this.localPeerId < remotePeerId; + private clearDisconnectTimer(peerState: PeerState): void { + if (peerState.disconnectTimer !== null) { + clearTimeout(peerState.disconnectTimer); + peerState.disconnectTimer = null; + } + } + + private async attemptIceRestart(peerId: string, peerState: PeerState): Promise { + if (peerState.restartAttempts >= MAX_ICE_RESTART_ATTEMPTS) { + console.error(`[WebRTC] Max ICE restart attempts (${MAX_ICE_RESTART_ATTEMPTS}) reached for ${peerId}, giving up`); + if (this.onRemoteStreamRemoved) { + this.onRemoteStreamRemoved(peerId); + } + if (this.onPeerConnectionStateChanged) { + this.onPeerConnectionStateChanged(peerId, 'failed'); + } + return; + } + + peerState.restartAttempts++; + console.log(`[WebRTC] ICE restart attempt ${peerState.restartAttempts}/${MAX_ICE_RESTART_ATTEMPTS} for ${peerId}`); + + try { + const offer = await peerState.pc.createOffer({ iceRestart: true }); + await peerState.pc.setLocalDescription(offer); + + if (this.onNegotiationNeeded) { + this.onNegotiationNeeded(peerId, offer); + } + } catch (err) { + console.error(`[WebRTC] Failed ICE restart for ${peerId}:`, err); + } + } + + /** Flush buffered ICE candidates after remote description is set */ + private async flushCandidateBuffer(peerId: string, peerState: PeerState): Promise { + if (peerState.candidateBuffer.length === 0) return; + + console.log(`[WebRTC] Flushing ${peerState.candidateBuffer.length} buffered ICE candidates for ${peerId}`); + const buffered = peerState.candidateBuffer.splice(0); + + for (const candidate of buffered) { + try { + await peerState.pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (err) { + console.error(`[WebRTC] Failed to add buffered ICE candidate for ${peerId}:`, err); + } + } } async createOffer(peerId: string): Promise { - const pc = this.connections.get(peerId); - if (!pc) { - throw new Error(`no connection for peer ${peerId}`); + const peerState = this.peers.get(peerId); + if (!peerState) { + throw new Error(`[WebRTC] No connection for peer ${peerId}`); } try { - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); + const offer = await peerState.pc.createOffer(); + await peerState.pc.setLocalDescription(offer); + console.log(`[WebRTC] Created offer for ${peerId}`); return offer; } catch (err) { - console.error(`failed to create offer for peer ${peerId}:`, err); + console.error(`[WebRTC] Failed to create offer for ${peerId}:`, err); throw err; } } + // handleOffer replaces createAnswer — sets remote description, creates answer, flushes candidates + async handleOffer( + peerId: string, + sdp: RTCSessionDescriptionInit, + localStream?: MediaStream | null, + ): Promise { + let peerState = this.peers.get(peerId); + if (!peerState) { + // Auto-create connection if it doesn't exist + this.createConnection(peerId, localStream); + peerState = this.peers.get(peerId)!; + } + + try { + await peerState.pc.setRemoteDescription(new RTCSessionDescription(sdp)); + console.log(`[WebRTC] Set remote offer for ${peerId}`); + + // Flush any buffered ICE candidates now that remote description is set + await this.flushCandidateBuffer(peerId, peerState); + + const answer = await peerState.pc.createAnswer(); + await peerState.pc.setLocalDescription(answer); + console.log(`[WebRTC] Created answer for ${peerId}`); + return answer; + } catch (err) { + console.error(`[WebRTC] Failed to handle offer from ${peerId}:`, err); + throw err; + } + } + + // Legacy alias for handleOffer (backward compat with voice store) async createAnswer( peerId: string, offer: RTCSessionDescriptionInit, ): Promise { - const pc = this.connections.get(peerId); - if (!pc) { - throw new Error(`no connection for peer ${peerId}`); + return this.handleOffer(peerId, offer); + } + + // handleAnswer replaces setRemoteAnswer — sets remote description and flushes candidates + async handleAnswer( + peerId: string, + sdp: RTCSessionDescriptionInit, + ): Promise { + const peerState = this.peers.get(peerId); + if (!peerState) { + throw new Error(`[WebRTC] No connection for peer ${peerId}`); } try { - await pc.setRemoteDescription(new RTCSessionDescription(offer)); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - return answer; + await peerState.pc.setRemoteDescription(new RTCSessionDescription(sdp)); + console.log(`[WebRTC] Set remote answer for ${peerId}`); + + // Flush any buffered ICE candidates now that remote description is set + await this.flushCandidateBuffer(peerId, peerState); } catch (err) { - console.error(`failed to create answer for peer ${peerId}:`, err); + console.error(`[WebRTC] Failed to handle answer from ${peerId}:`, err); throw err; } } + // Legacy alias for handleAnswer (backward compat with voice store) async setRemoteAnswer( peerId: string, answer: RTCSessionDescriptionInit, ): Promise { - const pc = this.connections.get(peerId); - if (!pc) { - throw new Error(`no connection for peer ${peerId}`); - } - - try { - await pc.setRemoteDescription(new RTCSessionDescription(answer)); - } catch (err) { - console.error(`failed to set remote answer for peer ${peerId}:`, err); - throw err; - } + return this.handleAnswer(peerId, answer); } async addIceCandidate( peerId: string, candidate: RTCIceCandidateInit, ): Promise { - const pc = this.connections.get(peerId); - if (!pc) { - // candidate arrived before connection was created, safe to ignore + const peerState = this.peers.get(peerId); + if (!peerState) { + // Candidate arrived before connection was created — buffer it in a temporary queue + // that will be checked when the connection is created. For now, log and drop. + console.warn(`[WebRTC] ICE candidate arrived for unknown peer ${peerId}, ignoring`); + return; + } + + // If remote description is not yet set, buffer the candidate + if (!peerState.pc.remoteDescription) { + console.log(`[WebRTC] Buffering ICE candidate for ${peerId} (no remote description yet)`); + peerState.candidateBuffer.push(candidate); return; } try { - await pc.addIceCandidate(new RTCIceCandidate(candidate)); + await peerState.pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (err) { - // ice candidates can arrive out of order or for stale connections - console.error(`failed to add ice candidate for peer ${peerId}:`, err); + // ICE candidates can arrive out of order or for stale connections + console.error(`[WebRTC] Failed to add ICE candidate for ${peerId}:`, err); } } - closeConnection(peerId: string): void { - const pc = this.connections.get(peerId); - if (pc) { - pc.onicecandidate = null; - pc.ontrack = null; - pc.onnegotiationneeded = null; - pc.onconnectionstatechange = null; - pc.close(); - this.connections.delete(peerId); + /** Perform an ICE restart for a specific peer. Returns new offer SDP or null on failure. */ + async restartIce(peerId: string): Promise { + const peerState = this.peers.get(peerId); + if (!peerState) { + console.error(`[WebRTC] Cannot restart ICE: no connection for peer ${peerId}`); + return null; + } + + try { + peerState.restartAttempts++; + console.log(`[WebRTC] Manual ICE restart for ${peerId} (attempt ${peerState.restartAttempts})`); + const offer = await peerState.pc.createOffer({ iceRestart: true }); + await peerState.pc.setLocalDescription(offer); + return offer; + } catch (err) { + console.error(`[WebRTC] Failed manual ICE restart for ${peerId}:`, err); + return null; } } - closeAll(): void { - for (const [peerId] of this.connections) { - this.closeConnection(peerId); - } - this.connections.clear(); - this.localStream = null; - this.screenStream = null; - } - - getConnection(peerId: string): RTCPeerConnection | undefined { - return this.connections.get(peerId); + /** Get the current connection state for a specific peer */ + getPeerState(peerId: string): RTCPeerConnectionState | undefined { + const peerState = this.peers.get(peerId); + return peerState?.pc.connectionState; } // replaces tracks on all existing connections - // used when toggling video or screen share mid-call - updateTracks(): void { - for (const [, pc] of this.connections) { + // overloaded: can be called with no args (legacy) or with (stream, kind) (new API) + updateTracks(stream?: MediaStream | null, kind?: 'audio' | 'video'): void { + for (const [, peerState] of this.peers) { + const { pc } = peerState; const senders = pc.getSenders(); // build the set of tracks we want active on each connection @@ -204,6 +420,16 @@ export class PeerConnectionManager { desiredTracks.push(...this.screenStream.getTracks()); } + // If called with specific stream and kind, handle targeted update + if (stream && kind) { + const newTracks = stream.getTracks().filter((t) => t.kind === kind); + for (const track of newTracks) { + if (!desiredTracks.some((t) => t.id === track.id)) { + desiredTracks.push(track); + } + } + } + // replace or add tracks that should be present for (const track of desiredTracks) { const existingSender = senders.find( @@ -212,19 +438,20 @@ export class PeerConnectionManager { if (!existingSender) { // check if there is a sender with the same kind we can replace const kindSender = senders.find( - (s) => s.track?.kind === track.kind || (!s.track && true), + (s) => s.track?.kind === track.kind || (s.track === null && track.kind !== undefined), ); if (kindSender) { kindSender.replaceTrack(track).catch((err) => { - console.error("failed to replace track:", err); + console.error('[WebRTC] Failed to replace track:', err); }); } else { // no existing sender for this kind, add a new one - const stream = track.kind === "video" && this.screenStream?.getVideoTracks().includes(track) - ? this.screenStream - : this.localStream; - if (stream) { - pc.addTrack(track, stream); + const parentStream = + track.kind === 'video' && this.screenStream?.getVideoTracks().includes(track) + ? this.screenStream + : this.localStream; + if (parentStream) { + pc.addTrack(track, parentStream); } } } @@ -237,10 +464,45 @@ export class PeerConnectionManager { try { pc.removeTrack(sender); } catch (err) { - console.error("failed to remove track:", err); + console.error('[WebRTC] Failed to remove track:', err); } } } } } + + // removeConnection (also aliased as closeConnection for backward compat) + removeConnection(peerId: string): void { + const peerState = this.peers.get(peerId); + if (peerState) { + this.clearDisconnectTimer(peerState); + const { pc } = peerState; + pc.onicecandidate = null; + pc.ontrack = null; + pc.onnegotiationneeded = null; + pc.onconnectionstatechange = null; + pc.oniceconnectionstatechange = null; + pc.onicegatheringstatechange = null; + pc.close(); + this.peers.delete(peerId); + } + } + + // Legacy alias for removeConnection (backward compat with voice store) + closeConnection(peerId: string): void { + this.removeConnection(peerId); + } + + closeAll(): void { + for (const [peerId] of this.peers) { + this.removeConnection(peerId); + } + this.peers.clear(); + this.localStream = null; + this.screenStream = null; + } + + getConnection(peerId: string): RTCPeerConnection | undefined { + return this.peers.get(peerId)?.pc; + } } diff --git a/src/stores/voice.ts b/src/stores/voice.ts index 02af870..8fb5faa 100644 --- a/src/stores/voice.ts +++ b/src/stores/voice.ts @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js"; +import { createSignal, createMemo } from "solid-js"; import type { VoiceMediaState, VoiceParticipant } from "../lib/types"; import { PeerConnectionManager } from "../lib/webrtc"; import { @@ -7,6 +7,7 @@ import { updateVoiceMediaState, sendVoiceSdp, sendVoiceIceCandidate, + getTurnCredentials, } from "../lib/tauri"; import { identity } from "./identity"; @@ -30,16 +31,35 @@ const [remoteStreams, setRemoteStreams] = createSignal< >(new Map()); const [screenStream, setScreenStream] = createSignal(null); +// per-peer WebRTC connection state tracking +const [peerConnectionStates, setPeerConnectionStates] = createSignal< + Record +>({}); + // tracks the voice connection lifecycle so the ui can show proper feedback export type VoiceConnectionState = | "idle" | "connecting" | "connected" + | "degraded" | "error"; const [voiceConnectionState, setVoiceConnectionState] = createSignal("idle"); const [voiceError, setVoiceError] = createSignal(null); +// overall voice connection quality summary derived from per-peer states +const voiceQuality = createMemo(() => { + const states = peerConnectionStates(); + const entries = Object.entries(states); + if (entries.length === 0) return "good"; + const connected = entries.filter(([, s]) => s === "connected").length; + const failed = entries.filter(([, s]) => s === "failed").length; + if (failed === entries.length) return "failed"; + if (failed > 0) return "degraded"; + if (connected === entries.length) return "good"; + return "connecting"; +}); + // derived signal for convenience export function isInVoice(): boolean { return voiceChannelId() !== null; @@ -48,71 +68,109 @@ export function isInVoice(): boolean { // single peer connection manager instance for the lifetime of a voice session let peerManager: PeerConnectionManager | null = null; +// evaluate overall voice connection state from per-peer states +function evaluateOverallVoiceState(): void { + const states = peerConnectionStates(); + const entries = Object.entries(states); + + // if no peers, we're the only participant — stay connected + if (entries.length === 0) { + // only update if we're currently in a voice channel (not leaving) + if (voiceChannelId() !== null && voiceConnectionState() !== "idle") { + setVoiceConnectionState("connected"); + } + return; + } + + const connected = entries.filter(([, s]) => s === "connected").length; + const failed = entries.filter(([, s]) => s === "failed").length; + + if (connected > 0 && failed > 0) { + setVoiceConnectionState("degraded"); + } else if (connected > 0) { + setVoiceConnectionState("connected"); + } else if (failed === entries.length) { + setVoiceConnectionState("error"); + } + // otherwise remain in "connecting" state (peers still negotiating) +} + // initialize the peer manager with callbacks wired to our handlers -function initPeerManager(): PeerConnectionManager { - const manager = new PeerConnectionManager(); +function initPeerManager(iceServers?: RTCIceServer[]): PeerConnectionManager { + const manager = new PeerConnectionManager({ + iceServers, + onRemoteStream: (peerId: string, stream: MediaStream) => { + console.log(`[Voice] Remote stream received from ${peerId}`); + setRemoteStreams((prev) => { + const next = new Map(prev); + next.set(peerId, stream); + return next; + }); + }, - manager.onRemoteStream = (peerId: string, stream: MediaStream) => { - setRemoteStreams((prev) => { - const next = new Map(prev); - next.set(peerId, stream); - return next; - }); - }; + onRemoteStreamRemoved: (peerId: string) => { + console.log(`[Voice] Remote stream removed for ${peerId}`); + setRemoteStreams((prev) => { + const next = new Map(prev); + next.delete(peerId); + return next; + }); + }, - manager.onRemoteStreamRemoved = (peerId: string) => { - setRemoteStreams((prev) => { - const next = new Map(prev); - next.delete(peerId); - return next; - }); - }; + onIceCandidate: async (peerId: string, candidate: RTCIceCandidate) => { + const communityId = voiceCommunityId(); + const channelId = voiceChannelId(); + if (!communityId || !channelId) return; - manager.onIceCandidate = async ( - peerId: string, - candidate: RTCIceCandidate, - ) => { - const communityId = voiceCommunityId(); - const channelId = voiceChannelId(); - if (!communityId || !channelId) return; + try { + await sendVoiceIceCandidate( + communityId, + channelId, + peerId, + candidate.candidate, + candidate.sdpMid, + candidate.sdpMLineIndex, + ); + } catch (err) { + console.error("[Voice] Failed to send ICE candidate:", err); + } + }, - try { - await sendVoiceIceCandidate( - communityId, - channelId, - peerId, - candidate.candidate, - candidate.sdpMid, - candidate.sdpMLineIndex, - ); - } catch (err) { - console.error("failed to send ice candidate:", err); - } - }; + onNegotiationNeeded: async ( + peerId: string, + sdp: RTCSessionDescriptionInit, + ) => { + const communityId = voiceCommunityId(); + const channelId = voiceChannelId(); + if (!communityId || !channelId) return; - manager.onNegotiationNeeded = async (peerId: string) => { - const communityId = voiceCommunityId(); - const channelId = voiceChannelId(); - if (!communityId || !channelId) return; + // the webrtc module handles glare resolution and creates the offer/restart SDP + // we just need to send it via the signaling channel + try { + console.log( + `[Voice] Sending ${sdp.type} SDP to ${peerId} (negotiation/restart)`, + ); + await sendVoiceSdp( + communityId, + channelId, + peerId, + sdp.type || "offer", + sdp.sdp || "", + ); + } catch (err) { + console.error("[Voice] Failed to send SDP during negotiation:", err); + } + }, - // only the peer with the lexicographically smaller id initiates the offer - if (!manager.shouldOffer(peerId)) { - return; - } - - try { - const offer = await manager.createOffer(peerId); - await sendVoiceSdp( - communityId, - channelId, - peerId, - offer.type || "offer", - offer.sdp || "", - ); - } catch (err) { - console.error("failed to send offer during renegotiation:", err); - } - }; + onPeerConnectionStateChanged: ( + peerId: string, + state: RTCPeerConnectionState, + ) => { + console.log(`[Voice] Peer ${peerId} connection state: ${state}`); + setPeerConnectionStates((prev) => ({ ...prev, [peerId]: state })); + evaluateOverallVoiceState(); + }, + }); return manager; } @@ -177,8 +235,29 @@ export async function joinVoice( const stream = await acquireLocalMedia(false); setLocalStream(stream); + // fetch TURN credentials from our relay server + let turnServers: RTCIceServer[] = []; + try { + const creds = await getTurnCredentials(); + turnServers = [{ + urls: creds.uris, + username: creds.username, + credential: creds.password, + }]; + console.log(`[Voice] Fetched TURN credentials (ttl=${creds.ttl}s, uris=${creds.uris.length})`); + } catch (e) { + console.warn('[Voice] Failed to fetch TURN credentials, proceeding without TURN:', e); + } + + // combine public STUN servers with dynamic TURN servers + const iceServers: RTCIceServer[] = [ + { urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }, + { urls: 'stun:stun.cloudflare.com:3478' }, + ...turnServers, + ]; + // initialize peer manager and set our local peer id for glare resolution - peerManager = initPeerManager(); + peerManager = initPeerManager(iceServers); const localPeerId = identity()?.peer_id; if (localPeerId) { peerManager.setLocalPeerId(localPeerId); @@ -191,13 +270,22 @@ export async function joinVoice( setVoiceChannelId(channelId); setVoiceCommunityId(communityId); setVoiceParticipants(participants); - setVoiceConnectionState("connected"); + + // determine how many remote peers we need to connect to + const remotePeers = participants.filter( + (p) => p.peer_id !== localPeerId, + ); + + if (remotePeers.length === 0) { + // we're the only participant — no peers to connect to, so we're connected + console.log("[Voice] No remote peers, marking as connected"); + setVoiceConnectionState("connected"); + } + // otherwise stay in "connecting" until onPeerConnectionStateChanged fires // create peer connections for all existing participants // we only initiate offers if our peer id is lexicographically smaller - for (const participant of participants) { - if (participant.peer_id === localPeerId) continue; - + for (const participant of remotePeers) { peerManager.createConnection(participant.peer_id); if (peerManager.shouldOffer(participant.peer_id)) { @@ -212,14 +300,14 @@ export async function joinVoice( ); } catch (err) { console.error( - `failed to create offer for ${participant.peer_id}:`, + `[Voice] Failed to create offer for ${participant.peer_id}:`, err, ); } } } } catch (err) { - console.error("failed to join voice channel:", err); + console.error("[Voice] Failed to join voice channel:", err); // surface a readable error message to the ui const message = err instanceof Error ? err.message : String(err); setVoiceError(message); @@ -248,8 +336,9 @@ export async function leaveVoice(): Promise { releaseLocalMedia(); releaseScreenShare(); - // clear remote streams + // clear remote streams and peer connection states setRemoteStreams(new Map()); + setPeerConnectionStates({}); // tell the backend to leave if (communityId && channelId) { @@ -381,6 +470,10 @@ export async function toggleVideo(): Promise { if (videoTrack && localStream()) { // add video track to existing stream localStream()!.addTrack(videoTrack); + // stop the unused audio tracks from the new stream to prevent resource leak + for (const audioTrack of videoStream.getAudioTracks()) { + audioTrack.stop(); + } } else if (videoTrack) { setLocalStream(videoStream); } @@ -601,12 +694,20 @@ export function handleVoiceParticipantLeft(payload: { peerManager.closeConnection(payload.peer_id); } - // remove remote stream + // remove remote stream and peer connection state setRemoteStreams((prev) => { const next = new Map(prev); next.delete(payload.peer_id); return next; }); + setPeerConnectionStates((prev) => { + const next = { ...prev }; + delete next[payload.peer_id]; + return next; + }); + + // re-evaluate overall voice state after peer removal + evaluateOverallVoiceState(); } export function handleVoiceMediaStateChanged(payload: { @@ -708,4 +809,6 @@ export { screenStream, voiceConnectionState, voiceError, + peerConnectionStates, + voiceQuality, };