Skip to main content

netget/client/webrtc/
mod.rs

1//! WebRTC client implementation (data channels only, no media)
2//!
3//! Enhancements:
4//! - WebSocket signaling support for automatic SDP exchange
5//! - Multi-channel support (create multiple data channels)
6//! - Binary data support (hex encoding/decoding)
7
8pub mod actions;
9
10pub use actions::WebRtcClientProtocol;
11
12use anyhow::{Context, Result};
13use futures::{SinkExt, StreamExt};
14use std::collections::HashMap;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use tokio::sync::{mpsc, Mutex};
18use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
19use tracing::{debug, error, info, trace, warn};
20use webrtc::api::interceptor_registry::register_default_interceptors;
21use webrtc::api::media_engine::MediaEngine;
22use webrtc::api::APIBuilder;
23use webrtc::data_channel::data_channel_message::DataChannelMessage;
24use webrtc::data_channel::RTCDataChannel;
25use webrtc::ice_transport::ice_server::RTCIceServer;
26use webrtc::interceptor::registry::Registry;
27use webrtc::peer_connection::configuration::RTCConfiguration;
28use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
29use webrtc::peer_connection::sdp::session_description::RTCSessionDescription;
30use webrtc::peer_connection::RTCPeerConnection;
31
32use crate::client::llm_budget::call_llm_for_client;
33use crate::client::webrtc::actions::{
34    WEBRTC_CLIENT_CHANNEL_OPENED_EVENT, WEBRTC_CLIENT_MESSAGE_RECEIVED_EVENT,
35    WEBRTC_CLIENT_SIGNALING_CONNECTED_EVENT,
36};
37use crate::llm::ollama_client::OllamaClient;
38use crate::llm::ClientLlmResult;
39use crate::protocol::Event;
40use crate::state::app_state::AppState;
41use crate::state::{ClientId, ClientStatus};
42
43/// Signaling mode for WebRTC connection setup
44#[derive(Debug, Clone, PartialEq)]
45pub enum SignalingMode {
46    /// Manual SDP exchange (user copy-paste)
47    Manual,
48    /// Automatic SDP exchange via WebSocket signaling server
49    WebSocket { url: String, peer_id: String },
50}
51
52/// Connection state for LLM processing
53///
54/// Two states, not the usual three. `Accumulating` was the third and is gone: it
55/// was only ever *entered*, never left, because nothing drained the queue that
56/// justified it — see the comment where the state is reset after an LLM call.
57#[derive(Debug, Clone, PartialEq)]
58enum ConnectionState {
59    Idle,
60    Processing,
61}
62
63/// Per-channel LLM state
64struct ChannelData {
65    state: ConnectionState,
66    /// Bounded FIFO drained by the active processor before returning to Idle.
67    queued_messages: std::collections::VecDeque<(String, bool)>, // (message, is_binary)
68    /// The live channel, used to send on it from outside the on_message closure
69    /// (injected commands go through this).
70    channel: Arc<RTCDataChannel>,
71}
72
73/// Per-client data for LLM handling
74struct ClientData {
75    shutdown: tokio_util::sync::CancellationToken,
76    memory: String,
77    channels: HashMap<String, ChannelData>, // channel_label -> data
78}
79
80/// Owns the async close operation even when AppState aborts the client tasks.
81#[derive(Clone)]
82pub struct PeerConnectionGuard(Arc<PeerCleanup>);
83struct PeerCleanup {
84    peer: Arc<RTCPeerConnection>,
85    shutdown: tokio_util::sync::CancellationToken,
86}
87impl PeerConnectionGuard {
88    pub fn new(peer: Arc<RTCPeerConnection>) -> Self {
89        Self(Arc::new(PeerCleanup {
90            peer,
91            shutdown: tokio_util::sync::CancellationToken::new(),
92        }))
93    }
94    pub fn cancellation_token(&self) -> tokio_util::sync::CancellationToken {
95        self.0.shutdown.clone()
96    }
97}
98impl Drop for PeerCleanup {
99    fn drop(&mut self) {
100        self.shutdown.cancel();
101        let peer = self.peer.clone();
102        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
103            runtime.spawn(async move {
104                if let Err(error) = peer.close().await {
105                    tracing::warn!("WebRTC close failed: {error}");
106                }
107            });
108        }
109    }
110}
111
112/// WebRTC client that connects via data channels (no media)
113pub struct WebRtcClient;
114
115impl WebRtcClient {
116    /// Connect to a WebRTC peer with integrated LLM actions
117    ///
118    /// Supports two signaling modes:
119    /// - Manual: remote_addr = "manual" (user exchanges SDP)
120    /// - WebSocket: remote_addr = "ws://server:port/peer_id" (automatic signaling)
121    pub async fn connect_with_llm_actions(
122        remote_addr: String,
123        llm_client: OllamaClient,
124        app_state: Arc<AppState>,
125        status_tx: mpsc::UnboundedSender<String>,
126        client_id: ClientId,
127        startup_params: Option<crate::protocol::StartupParams>,
128    ) -> Result<SocketAddr> {
129        info!(
130            "WebRTC client {} initializing for {}",
131            client_id, remote_addr
132        );
133
134        // Parse signaling mode from remote_addr
135        let signaling_mode =
136            if remote_addr.starts_with("ws://") || remote_addr.starts_with("wss://") {
137                // WebSocket signaling: ws://server:port/peer_id or wss://server:port/peer_id
138                let parts: Vec<&str> = remote_addr.rsplitn(2, '/').collect();
139                if parts.len() != 2 {
140                    return Err(anyhow::anyhow!(
141                        "Invalid WebSocket URL format. Expected: ws://server:port/peer_id"
142                    ));
143                }
144                let peer_id = parts[0].to_string();
145                let url = parts[1].to_string();
146                SignalingMode::WebSocket {
147                    url: format!("{}/", url), // Reconstruct base URL
148                    peer_id,
149                }
150            } else {
151                SignalingMode::Manual
152            };
153
154        info!(
155            "WebRTC client {} using signaling mode: {:?}",
156            client_id, signaling_mode
157        );
158
159        // Create a MediaEngine
160        let mut m = MediaEngine::default();
161
162        // Create an InterceptorRegistry and register default interceptors
163        let registry = Registry::new();
164        let registry = register_default_interceptors(registry, &mut m)?;
165
166        // Create the API object with the MediaEngine
167        let api = APIBuilder::new()
168            .with_media_engine(m)
169            .with_interceptor_registry(registry)
170            .build();
171
172        // Configure ICE servers. `ice_servers` was declared in `get_startup_parameters()`
173        // and never read, so the Google STUN server was hardcoded for every client; it is now
174        // the default and nothing more. An explicitly empty array means host candidates only,
175        // which is what a purely local peer wants.
176        let ice_urls: Vec<String> = match startup_params.as_ref() {
177            Some(params) => match params
178                .get_optional_array("ice_servers")
179                .map_err(|e| anyhow::anyhow!("WebRTC client parameter error: {e}"))?
180            {
181                Some(arr) => arr
182                    .iter()
183                    .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
184                    .filter(|s| !s.is_empty())
185                    .collect(),
186                None => Vec::new(),
187            },
188            // No ICE server by default.
189            //
190            // This used to default to `stun:stun.l.google.com:19302`, so starting a
191            // WebRTC client — for any reason, including a loopback test — sent a
192            // binding request to Google. The *server* was fixed for exactly this
193            // reason (see `src/server/webrtc/CLAUDE.md`) and the client was left
194            // behind, which is why `tests/client/webrtc/command_channel_test.rs` has
195            // to pass `"ice_servers": []` explicitly to keep the repo's
196            // localhost-only rule.
197            //
198            // An empty list is correct for host candidates, which is what a
199            // loopback or LAN peer uses. Pass `ice_servers` to reach anything
200            // behind NAT — the operator chooses which third party to talk to.
201            None => Vec::new(),
202        };
203        let config = RTCConfiguration {
204            ice_servers: ice_urls
205                .into_iter()
206                .map(|url| RTCIceServer {
207                    urls: vec![url],
208                    ..Default::default()
209                })
210                .collect(),
211            ..Default::default()
212        };
213
214        // Create peer connection
215        let peer_connection = Arc::new(api.new_peer_connection(config).await?);
216        let lifetime = PeerConnectionGuard::new(peer_connection.clone());
217        info!("WebRTC client {} created peer connection", client_id);
218
219        // Initialize client data
220        let client_data = Arc::new(Mutex::new(ClientData {
221            shutdown: lifetime.cancellation_token(),
222            memory: String::new(),
223            channels: HashMap::new(),
224        }));
225
226        // Create default data channel
227        let data_channel = peer_connection.create_data_channel("netget", None).await?;
228        info!("WebRTC client {} created data channel 'netget'", client_id);
229
230        // Setup data channel handlers
231        Self::setup_data_channel_handlers(
232            Arc::clone(&data_channel),
233            "netget".to_string(),
234            client_id,
235            Arc::clone(&client_data),
236            Arc::clone(&app_state),
237            status_tx.clone(),
238            llm_client.clone(),
239        )
240        .await;
241
242        // Add channel to client data
243        client_data.lock().await.channels.insert(
244            "netget".to_string(),
245            ChannelData {
246                state: ConnectionState::Idle,
247                queued_messages: std::collections::VecDeque::new(),
248                channel: Arc::clone(&data_channel),
249            },
250        );
251
252        // Handle connection state changes
253        let status_tx_state = status_tx.clone();
254        let app_state_state = Arc::clone(&app_state);
255        peer_connection.on_peer_connection_state_change(Box::new(
256            move |state: RTCPeerConnectionState| {
257                let status_tx = status_tx_state.clone();
258                let app_state = Arc::clone(&app_state_state);
259                Box::pin(async move {
260                    info!("WebRTC client {} connection state: {:?}", client_id, state);
261                    match state {
262                        RTCPeerConnectionState::Connected => {
263                            app_state
264                                .update_client_status(client_id, ClientStatus::Connected)
265                                .await;
266                            let _ = status_tx
267                                .send(format!("[CLIENT] WebRTC client {} connected", client_id));
268                            let _ = status_tx.send("__UPDATE_UI__".to_string());
269                        }
270                        RTCPeerConnectionState::Failed | RTCPeerConnectionState::Closed => {
271                            app_state
272                                .update_client_status(client_id, ClientStatus::Disconnected)
273                                .await;
274                            // The connection is gone: drop the command handle so the
275                            // dashboard stops offering [ send ] into it.
276                            app_state.remove_client_handle(client_id).await;
277                            let _ = status_tx
278                                .send(format!("[CLIENT] WebRTC client {} disconnected", client_id));
279                            let _ = status_tx.send("__UPDATE_UI__".to_string());
280                        }
281                        _ => {}
282                    }
283                })
284            },
285        ));
286
287        // Command and signaling tasks own the peer connection through real Arcs.
288        // Keep only serializable signaling metadata in the client state.
289        app_state
290            .with_client_mut(client_id, |client| {
291                client.set_protocol_field(
292                    "signaling_mode".to_string(),
293                    match &signaling_mode {
294                        SignalingMode::Manual => serde_json::json!("manual"),
295                        SignalingMode::WebSocket { url, peer_id } => serde_json::json!({
296                            "mode": "websocket",
297                            "url": url,
298                            "peer_id": peer_id
299                        }),
300                    },
301                );
302            })
303            .await;
304
305        // Command channel for injected actions (the dashboard's [ send ]). Registered BEFORE
306        // signaling runs: the WebSocket mode makes an LLM call on
307        // `webrtc_signaling_connected`, and a manual `*` rule parks that call until a human
308        // answers - [ send ] has to work for the whole park.
309        let command_rx =
310            crate::client::command_support::register_command_channel(&app_state, client_id).await;
311        let cmd_pc = Arc::clone(&peer_connection);
312        let cmd_data = Arc::clone(&client_data);
313        let cmd_state = Arc::clone(&app_state);
314        let cmd_status_tx = status_tx.clone();
315        let cmd_llm = llm_client.clone();
316        let cmd_lifetime = lifetime.clone();
317        let cmd_task = tokio::spawn(async move {
318            let _lifetime = cmd_lifetime;
319            Self::command_loop(
320                command_rx,
321                cmd_pc,
322                cmd_data,
323                client_id,
324                cmd_state,
325                cmd_status_tx,
326                cmd_llm,
327            )
328            .await;
329        });
330        app_state.register_client_task(client_id, cmd_task).await;
331
332        let signaling_result = match signaling_mode {
333            SignalingMode::Manual => {
334                // Manual mode: create offer and display to user
335                Self::manual_signaling(
336                    peer_connection.clone(),
337                    client_id,
338                    Arc::clone(&app_state),
339                    status_tx.clone(),
340                )
341                .await
342            }
343            SignalingMode::WebSocket { url, peer_id } => {
344                // WebSocket mode: connect to signaling server
345                Self::websocket_signaling(
346                    peer_connection.clone(),
347                    client_id,
348                    url,
349                    peer_id,
350                    Arc::clone(&app_state),
351                    status_tx.clone(),
352                    llm_client,
353                )
354                .await
355            }
356        };
357        if let Err(error) = signaling_result {
358            app_state.remove_client_handle(client_id).await;
359            let _ = peer_connection.close().await;
360            return Err(error);
361        }
362
363        // Spawn cleanup task
364        let app_state_cleanup = Arc::clone(&app_state);
365        // Registered with AppState so stop_client can abort this task —
366        // dropping a JoinHandle only detaches it in Tokio.
367        let task_registrar = app_state.clone();
368        let task_handle = tokio::spawn(async move {
369            loop {
370                tokio::time::sleep(std::time::Duration::from_secs(5)).await;
371
372                // Check if client was removed
373                if app_state_cleanup.get_client(client_id).await.is_none() {
374                    info!("WebRTC client {} stopped", client_id);
375                    // Also ends `command_loop`, whose channel closes with the handle.
376                    app_state_cleanup.remove_client_handle(client_id).await;
377                    break;
378                }
379            }
380        });
381        task_registrar
382            .register_client_task(client_id, task_handle)
383            .await;
384
385        // Return a dummy local address (WebRTC is peer-to-peer)
386        Ok("0.0.0.0:0".parse().unwrap())
387    }
388
389    /// Manual signaling: create offer and display to user
390    async fn manual_signaling(
391        peer_connection: Arc<RTCPeerConnection>,
392        client_id: ClientId,
393        app_state: Arc<AppState>,
394        status_tx: mpsc::UnboundedSender<String>,
395    ) -> Result<()> {
396        // Create offer
397        let offer = peer_connection.create_offer(None).await?;
398        peer_connection.set_local_description(offer).await?;
399
400        // Wait for ICE gathering to complete
401        let mut gather_complete = peer_connection.gathering_complete_promise().await;
402        let _ = gather_complete.recv().await;
403
404        // Get the local description with ICE candidates
405        let local_desc = peer_connection
406            .local_description()
407            .await
408            .context("No local description available")?;
409
410        // Store the SDP offer for the user to exchange with peer
411        let offer_json = serde_json::to_string_pretty(&local_desc)?;
412        app_state
413            .with_client_mut(client_id, |client| {
414                client.set_protocol_field("sdp_offer".to_string(), serde_json::json!(offer_json));
415            })
416            .await;
417
418        info!(
419            "WebRTC client {} generated SDP offer (manual mode)",
420            client_id
421        );
422        let _ = status_tx.send(format!(
423            "[CLIENT] WebRTC client {} waiting for SDP answer",
424            client_id
425        ));
426        let _ = status_tx.send(format!("SDP Offer (send to peer):\n{}", offer_json));
427        let _ = status_tx.send("__UPDATE_UI__".to_string());
428
429        Ok(())
430    }
431
432    /// WebSocket signaling: connect to signaling server and auto-exchange SDP
433    async fn websocket_signaling(
434        peer_connection: Arc<RTCPeerConnection>,
435        client_id: ClientId,
436        url: String,
437        peer_id: String,
438        app_state: Arc<AppState>,
439        status_tx: mpsc::UnboundedSender<String>,
440        llm_client: OllamaClient,
441    ) -> Result<()> {
442        info!(
443            "WebRTC client {} connecting to signaling server: {}",
444            client_id, url
445        );
446
447        // Connect to WebSocket signaling server
448        let (ws_stream, _) = connect_async(&url)
449            .await
450            .context("Failed to connect to signaling server")?;
451
452        let (mut ws_tx, mut ws_rx) = ws_stream.split();
453
454        info!("WebRTC client {} connected to signaling server", client_id);
455        let _ = status_tx.send(format!(
456            "[CLIENT] WebRTC client {} connected to signaling server",
457            client_id
458        ));
459
460        // Send registration message
461        let register_msg = serde_json::json!({
462            "type": "register",
463            "peer_id": peer_id,
464        });
465        ws_tx
466            .send(WsMessage::Text(register_msg.to_string()))
467            .await
468            .context("Failed to send registration")?;
469
470        debug!("WebRTC client {} registered as '{}'", client_id, peer_id);
471
472        // Trigger signaling connected event
473        let protocol = Arc::new(crate::client::webrtc::actions::WebRtcClientProtocol::new());
474        let event = Event::new(
475            &WEBRTC_CLIENT_SIGNALING_CONNECTED_EVENT,
476            serde_json::json!({
477                "peer_id": peer_id,
478                "server_url": url,
479            }),
480        );
481
482        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
483            match call_llm_for_client(
484                &llm_client,
485                &app_state,
486                client_id.to_string(),
487                &instruction,
488                "",
489                Some(&event),
490                protocol.as_ref(),
491                &status_tx,
492            )
493            .await
494            {
495                Ok(ClientLlmResult {
496                    actions,
497                    memory_updates,
498                }) => {
499                    if let Some(mem) = memory_updates {
500                        app_state.set_memory_for_client(client_id, mem).await;
501                    }
502                    // Signaling is connected but no data channel exists yet, so there is
503                    // genuinely nothing to send on. Say which actions could not run rather
504                    // than dropping them silently -- and note that the memory update WAS
505                    // being dropped too, so anything the model learned here was lost.
506                    if !actions.is_empty() {
507                        info!(
508                            "WebRTC client {}: {} action(s) answered at signaling-connected \
509                             cannot run yet -- no data channel is open; they are actionable \
510                             at webrtc_channel_opened",
511                            client_id,
512                            actions.len()
513                        );
514                    }
515                }
516                Err(e) => {
517                    warn!(
518                        "LLM error for WebRTC client {} signaling connected: {}",
519                        client_id, e
520                    );
521                }
522            }
523        }
524
525        // Create offer
526        let offer = peer_connection.create_offer(None).await?;
527        peer_connection.set_local_description(offer).await?;
528
529        // Wait for ICE gathering
530        let mut gather_complete = peer_connection.gathering_complete_promise().await;
531        let _ = gather_complete.recv().await;
532
533        let local_desc = peer_connection
534            .local_description()
535            .await
536            .context("No local description")?;
537
538        info!(
539            "WebRTC client {} generated SDP offer (WebSocket mode)",
540            client_id
541        );
542
543        // Send offer to signaling server (to be forwarded to remote peer)
544        // Note: This assumes a target peer ID is known. In practice, the LLM might provide this
545        // or it could be configured. For now, we'll store the offer and wait for the LLM to
546        // trigger sending it via an action.
547        let offer_json = serde_json::to_value(&local_desc)?;
548        app_state
549            .with_client_mut(client_id, |client| {
550                client.set_protocol_field("sdp_offer".to_string(), offer_json.clone());
551            })
552            .await;
553
554        // Spawn WebSocket message handler
555        let pc_for_ws = Arc::clone(&peer_connection);
556        let status_tx_ws = status_tx.clone();
557        let app_state_ws = Arc::clone(&app_state);
558
559        // Registered with AppState so stop_client can abort this task —
560        // dropping a JoinHandle only detaches it in Tokio.
561        let task_registrar = app_state.clone();
562        let task_handle = tokio::spawn(async move {
563            while let Some(msg_result) = ws_rx.next().await {
564                match msg_result {
565                    Ok(WsMessage::Text(text)) => {
566                        trace!(
567                            "WebRTC client {} received signaling message: {}",
568                            client_id,
569                            text
570                        );
571
572                        // Parse signaling message
573                        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(&text) {
574                            let msg_type = msg.get("type").and_then(|v| v.as_str());
575
576                            match msg_type {
577                                Some("registered") => {
578                                    info!(
579                                        "WebRTC client {} registered with signaling server",
580                                        client_id
581                                    );
582                                    let _ = status_tx_ws
583                                        .send(format!("[CLIENT] Registered as '{}'", peer_id));
584                                }
585                                Some("answer") => {
586                                    // Received SDP answer from remote peer
587                                    if let Some(sdp) = msg.get("sdp") {
588                                        info!("WebRTC client {} received SDP answer", client_id);
589
590                                        match serde_json::from_value::<RTCSessionDescription>(
591                                            sdp.clone(),
592                                        ) {
593                                            Ok(answer) => {
594                                                if let Err(e) =
595                                                    pc_for_ws.set_remote_description(answer).await
596                                                {
597                                                    error!(
598                                                        "WebRTC client {} failed to set remote description: {}",
599                                                        client_id, e
600                                                    );
601                                                } else {
602                                                    info!(
603                                                        "WebRTC client {} connection established via signaling",
604                                                        client_id
605                                                    );
606                                                    app_state_ws
607                                                        .update_client_status(
608                                                            client_id,
609                                                            ClientStatus::Connected,
610                                                        )
611                                                        .await;
612                                                }
613                                            }
614                                            Err(e) => {
615                                                error!(
616                                                    "WebRTC client {} failed to parse SDP answer: {}",
617                                                    client_id, e
618                                                );
619                                            }
620                                        }
621                                    }
622                                }
623                                Some("ice_candidate") => {
624                                    // Handle ICE candidate (not implemented in basic version)
625                                    trace!("WebRTC client {} received ICE candidate", client_id);
626                                }
627                                Some("error") => {
628                                    if let Some(error_msg) =
629                                        msg.get("message").and_then(|v| v.as_str())
630                                    {
631                                        error!(
632                                            "WebRTC client {} signaling error: {}",
633                                            client_id, error_msg
634                                        );
635                                        let _ = status_tx_ws.send(format!(
636                                            "[CLIENT] Signaling error: {}",
637                                            error_msg
638                                        ));
639                                    }
640                                }
641                                _ => {
642                                    trace!(
643                                        "WebRTC client {} unknown signaling message type",
644                                        client_id
645                                    );
646                                }
647                            }
648                        }
649                    }
650                    Ok(WsMessage::Close(_)) => {
651                        info!(
652                            "WebRTC client {} signaling server closed connection",
653                            client_id
654                        );
655                        break;
656                    }
657                    Err(e) => {
658                        error!("WebRTC client {} signaling error: {}", client_id, e);
659                        break;
660                    }
661                    _ => {}
662                }
663            }
664        });
665        task_registrar
666            .register_client_task(client_id, task_handle)
667            .await;
668
669        Ok(())
670    }
671
672    /// Setup handlers for a data channel
673    async fn setup_data_channel_handlers(
674        data_channel: Arc<RTCDataChannel>,
675        channel_label: String,
676        client_id: ClientId,
677        client_data: Arc<Mutex<ClientData>>,
678        app_state: Arc<AppState>,
679        status_tx: mpsc::UnboundedSender<String>,
680        llm_client: OllamaClient,
681    ) {
682        // On open handler
683        let client_data_on_open = Arc::downgrade(&client_data);
684        let app_state_on_open = Arc::clone(&app_state);
685        let status_tx_on_open = status_tx.clone();
686        let llm_on_open = llm_client.clone();
687        let label_on_open = channel_label.clone();
688        // The channel itself, so an action the model returns when the channel opens can
689        // actually be sent. Without it the answer had nowhere to go and was dropped.
690        let dc_on_open = Arc::downgrade(&data_channel);
691
692        data_channel.on_open(Box::new(move || {
693            let app_state = Arc::clone(&app_state_on_open);
694            let status_tx = status_tx_on_open.clone();
695            let client_data = client_data_on_open.upgrade();
696            let llm_client = llm_on_open.clone();
697            let label = label_on_open.clone();
698            let dc = dc_on_open.upgrade();
699
700            Box::pin(async move {
701                let (Some(client_data), Some(dc)) = (client_data, dc) else {
702                    return;
703                };
704                info!(
705                    "WebRTC client {} data channel '{}' opened",
706                    client_id, label
707                );
708                let _ = status_tx.send(format!(
709                    "[CLIENT] WebRTC client {} channel '{}' opened",
710                    client_id, label
711                ));
712                let _ = status_tx.send("__UPDATE_UI__".to_string());
713
714                // Call LLM with channel opened event
715                if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
716                    let protocol =
717                        Arc::new(crate::client::webrtc::actions::WebRtcClientProtocol::new());
718                    let event = Event::new(
719                        &WEBRTC_CLIENT_CHANNEL_OPENED_EVENT,
720                        serde_json::json!({
721                            "channel_label": label,
722                        }),
723                    );
724
725                    let memory = client_data.lock().await.memory.clone();
726
727                    let shutdown = client_data.lock().await.shutdown.clone();
728                    let call = call_llm_for_client(
729                        &llm_client,
730                        &app_state,
731                        client_id.to_string(),
732                        &instruction,
733                        &memory,
734                        Some(&event),
735                        protocol.as_ref(),
736                        &status_tx,
737                    );
738                    let result = tokio::select! { _ = shutdown.cancelled() => return, result = call => result };
739                    match result {
740                        Ok(ClientLlmResult {
741                            actions,
742                            memory_updates,
743                        }) => {
744                            if let Some(mem) = memory_updates {
745                                client_data.lock().await.memory = mem;
746                            }
747
748                            // Send what the model asked for. These were discarded, so a
749                            // model told to greet the peer as soon as the channel opened
750                            // could not: the opening message is exactly what this event
751                            // exists to prompt, and nothing came of it.
752                            //
753                            // Same execution shape as the on_message handler below; a
754                            // send raises no event, so this cannot loop.
755                            use crate::llm::actions::client_trait::{Client, ClientActionResult};
756                            for action in actions {
757                                match protocol.as_ref().execute_action(action) {
758                                    Ok(ClientActionResult::SendData(bytes)) => {
759                                        match dc.send(&bytes.into()).await {
760                                            Ok(n) => info!(
761                                                "WebRTC client {} sent {} bytes on '{}' \
762                                                 when the channel opened",
763                                                client_id, n, label
764                                            ),
765                                            Err(e) => error!(
766                                                "WebRTC client {} failed to send on '{}': {}",
767                                                client_id, label, e
768                                            ),
769                                        }
770                                    }
771                                    Ok(ClientActionResult::Disconnect) => {
772                                        info!(
773                                            "WebRTC client {} closing channel '{}' on the \
774                                             model's request",
775                                            client_id, label
776                                        );
777                                        let _ = dc.close().await;
778                                    }
779                                    Ok(_) => {}
780                                    Err(e) => error!(
781                                        "WebRTC client {} rejected its own channel-open \
782                                         action: {}",
783                                        client_id, e
784                                    ),
785                                }
786                            }
787                        }
788                        Err(e) => {
789                            error!(
790                                "LLM error for WebRTC client {} on channel open: {}",
791                                client_id, e
792                            );
793                        }
794                    }
795                }
796            })
797        }));
798
799        // On message handler
800        let client_data_on_msg = Arc::downgrade(&client_data);
801        let app_state_on_msg = Arc::clone(&app_state);
802        let status_tx_on_msg = status_tx.clone();
803        let llm_on_msg = llm_client.clone();
804        let label_on_msg = channel_label.clone();
805        let dc_on_msg = Arc::downgrade(&data_channel);
806
807        data_channel.on_message(Box::new(move |msg: DataChannelMessage| {
808            let app_state = Arc::clone(&app_state_on_msg);
809            let status_tx = status_tx_on_msg.clone();
810            let client_data = client_data_on_msg.upgrade();
811            let llm_client = llm_on_msg.clone();
812            let dc = dc_on_msg.upgrade();
813            let label = label_on_msg.clone();
814
815            Box::pin(async move {
816                let (Some(client_data), Some(dc)) = (client_data, dc) else { return; };
817                if msg.data.len() > 1024 * 1024 {
818                    warn!("WebRTC data message exceeds 1 MiB; closing channel");
819                    let _ = dc.close().await;
820                    return;
821                }
822                // Detect if message is binary
823                let mut is_binary = !msg.is_string;
824
825                let mut message_text = if is_binary {
826                    // Hex encode binary data
827                    hex::encode(&msg.data)
828                } else {
829                    String::from_utf8_lossy(&msg.data).to_string()
830                };
831
832                trace!(
833                    "WebRTC client {} received message on '{}': {} (binary: {})",
834                    client_id,
835                    label,
836                    if is_binary { "hex data" } else { &message_text },
837                    is_binary
838                );
839
840                // Handle data with LLM using per-channel state machine
841                let mut client_data_lock = client_data.lock().await;
842
843                // Get or create channel data
844                if !client_data_lock.channels.contains_key(&label) {
845                    warn!("WebRTC client {} received message on unknown channel '{}'", client_id, label);
846                    return;
847                }
848
849                let channel_state = client_data_lock
850                    .channels
851                    .get(&label)
852                    .map(|cd| cd.state.clone())
853                    .unwrap_or(ConnectionState::Idle);
854
855                match channel_state {
856                    ConnectionState::Idle => {
857                        // Process immediately
858                        if let Some(channel_data) = client_data_lock.channels.get_mut(&label) {
859                            channel_data.state = ConnectionState::Processing;
860                        }
861                        drop(client_data_lock);
862
863                        loop {
864                        // Drain all queued messages in order through the same event path.
865                        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
866                            let protocol = Arc::new(crate::client::webrtc::actions::WebRtcClientProtocol::new());
867                            let event = Event::new(
868                                &WEBRTC_CLIENT_MESSAGE_RECEIVED_EVENT,
869                                serde_json::json!({
870                                    "channel_label": label,
871                                    "message": message_text,
872                                    "is_binary": is_binary,
873                                }),
874                            );
875
876                            let memory = client_data.lock().await.memory.clone();
877
878                            let shutdown = client_data.lock().await.shutdown.clone();
879                            let call = call_llm_for_client(
880                                &llm_client,
881                                &app_state,
882                                client_id.to_string(),
883                                &instruction,
884                                &memory,
885                                Some(&event),
886                                protocol.as_ref(),
887                                &status_tx,
888                            );
889                            let result = tokio::select! { _ = shutdown.cancelled() => return, result = call => result };
890                            match result {
891                                Ok(ClientLlmResult { actions, memory_updates }) => {
892                                    // Update memory
893                                    if let Some(mem) = memory_updates {
894                                        client_data.lock().await.memory = mem;
895                                    }
896
897                                    // Execute actions
898                                    for action in actions {
899                                        use crate::llm::actions::client_trait::Client;
900                                        match protocol.as_ref().execute_action(action) {
901                                            Ok(crate::llm::actions::client_trait::ClientActionResult::SendData(bytes)) => {
902                                                // Send data (text or binary)
903                                                match dc.send(&bytes.into()).await {
904                                                    Ok(_) => {
905                                                        trace!("WebRTC client {} sent message on '{}'", client_id, label);
906                                                    }
907                                                    Err(e) => {
908                                                        error!("WebRTC client {} failed to send on '{}': {}", client_id, label, e);
909                                                    }
910                                                }
911                                            }
912                                            Ok(crate::llm::actions::client_trait::ClientActionResult::Disconnect) => {
913                                                info!("WebRTC client {} closing channel '{}'", client_id, label);
914                                                let _ = dc.close().await;
915                                            }
916                                            Ok(crate::llm::actions::client_trait::ClientActionResult::WaitForMore) => {
917                                                trace!("WebRTC client {} waiting for more data on '{}'", client_id, label);
918                                            }
919                                            _ => {}
920                                        }
921                                    }
922                                }
923                                Err(e) => {
924                                    error!("LLM error for WebRTC client {} on '{}': {}", client_id, label, e);
925                                }
926                            }
927                        }
928
929                        let mut client_data_lock = client_data.lock().await;
930                        if let Some(channel_data) = client_data_lock.channels.get_mut(&label) {
931                            if let Some((next, binary)) = channel_data.queued_messages.pop_front() {
932                                message_text = next;
933                                is_binary = binary;
934                                drop(client_data_lock);
935                                continue;
936                            }
937                            channel_data.state = ConnectionState::Idle;
938                        }
939                        break;
940                        }
941                    }
942                    ConnectionState::Processing => {
943                        // Queue the message
944                        if let Some(channel_data) = client_data_lock.channels.get_mut(&label) {
945                            let queued_bytes: usize = channel_data.queued_messages.iter().map(|(message, _)| message.len()).sum();
946                            if channel_data.queued_messages.len() >= 128 || queued_bytes.saturating_add(message_text.len()) > 8 * 1024 * 1024 {
947                                drop(client_data_lock);
948                                warn!("WebRTC pending message queue full; closing channel");
949                                let _ = dc.close().await;
950                                return;
951                            }
952                            channel_data.queued_messages.push_back((message_text, is_binary));
953                            trace!("WebRTC client {} queued message on '{}' (already processing)", client_id, label);
954                        }
955                    }
956                }
957            })
958        }));
959    }
960
961    // Three `pub async fn`s used to live here — `apply_answer`, `create_channel` and
962    // `send_on_channel` — and all three are gone. They had no callers anywhere in
963    // `src/` or `tests/`, and two of them resurrected an `RTCPeerConnection` from an
964    // integer read back out of `protocol_data`:
965    //
966    //     let pc = unsafe { Arc::from_raw(pc_ptr as *const RTCPeerConnection) };
967    //
968    // with no liveness check of any kind. The live paths for the first two are in
969    // `command_loop` below, which holds the real `Arc` and needs no `unsafe` at all;
970    // `send_on_channel` was a `// TODO: Get channel from stored client data`
971    // followed by `Ok(())`, i.e. a public function that reported success and sent
972    // nothing — the fail-open shape the root CLAUDE.md names as the most dangerous
973    // pattern in this codebase, sitting one function away from a live send path.
974
975    /// Drain injected commands (the dashboard's \[ send \]) until the channel closes - the
976    /// client was removed, or the peer connection failed - or an injected `disconnect` ends
977    /// the session.
978    ///
979    /// This is option (a) of the library-driven archetype: the `RTCPeerConnection` and every
980    /// `RTCDataChannel` are already behind `Arc`s that outlive `connect()`, so the command
981    /// loop holds clones of them and needs no restructuring. `RTCDataChannel::send` returns
982    /// the number of bytes it wrote to the SCTP stream, so an injected message reports a real
983    /// `Sent { bytes_sent }` rather than a guess.
984    #[allow(clippy::too_many_arguments)]
985    async fn command_loop(
986        mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
987        peer_connection: Arc<RTCPeerConnection>,
988        client_data: Arc<Mutex<ClientData>>,
989        client_id: ClientId,
990        app_state: Arc<AppState>,
991        status_tx: mpsc::UnboundedSender<String>,
992        llm_client: OllamaClient,
993    ) {
994        use crate::llm::actions::client_trait::{Client, ClientActionResult};
995        use crate::llm::actions::protocol_trait::Protocol;
996        use crate::state::client_handles::ClientSendOutcome;
997        use crate::state::AccessLogOwner;
998
999        let protocol = WebRtcClientProtocol::new();
1000
1001        while let Some(command) = command_rx.recv().await {
1002            let action = command.action.clone();
1003            // `execute_action` folds `send_message`/`send_binary` into a bare `SendData` and
1004            // drops the `channel` parameter it declares, so the target channel is read from
1005            // the action here. The default is the channel `connect()` always creates.
1006            let label = action
1007                .get("channel")
1008                .and_then(|v| v.as_str())
1009                .unwrap_or("netget")
1010                .to_string();
1011
1012            let outcome: Result<ClientSendOutcome> = match protocol.execute_action(action.clone()) {
1013                Err(e) => Ok(ClientSendOutcome::Rejected {
1014                    error: e.to_string(),
1015                }),
1016                Ok(ClientActionResult::SendData(bytes)) => {
1017                    let channel = {
1018                        client_data
1019                            .lock()
1020                            .await
1021                            .channels
1022                            .get(&label)
1023                            .map(|c| Arc::clone(&c.channel))
1024                    };
1025                    match channel {
1026                        None => Ok(ClientSendOutcome::Rejected {
1027                            error: format!(
1028                                "this client has no data channel labelled '{label}'; \
1029                                 open one with create_channel first"
1030                            ),
1031                        }),
1032                        Some(channel) => match channel.send(&bytes.into()).await {
1033                            Ok(bytes_sent) => Ok(ClientSendOutcome::Sent { bytes_sent }),
1034                            Err(e) => Err(anyhow::anyhow!(
1035                                "data channel '{}' is {} - send failed: {}",
1036                                label,
1037                                channel.ready_state(),
1038                                e
1039                            )),
1040                        },
1041                    }
1042                }
1043                Ok(ClientActionResult::Custom { name, data }) => match name.as_str() {
1044                    "create_channel" => {
1045                        let new_label = data
1046                            .get("channel_label")
1047                            .and_then(|v| v.as_str())
1048                            .unwrap_or("netget")
1049                            .to_string();
1050                        match peer_connection.create_data_channel(&new_label, None).await {
1051                            Ok(channel) => {
1052                                Self::setup_data_channel_handlers(
1053                                    Arc::clone(&channel),
1054                                    new_label.clone(),
1055                                    client_id,
1056                                    Arc::clone(&client_data),
1057                                    Arc::clone(&app_state),
1058                                    status_tx.clone(),
1059                                    llm_client.clone(),
1060                                )
1061                                .await;
1062                                client_data.lock().await.channels.insert(
1063                                    new_label.clone(),
1064                                    ChannelData {
1065                                        state: ConnectionState::Idle,
1066                                        queued_messages: std::collections::VecDeque::new(),
1067                                        channel,
1068                                    },
1069                                );
1070                                Ok(ClientSendOutcome::Executed {
1071                                    detail: format!(
1072                                        "data channel '{new_label}' created; it carries data \
1073                                         once the peer connection is established"
1074                                    ),
1075                                })
1076                            }
1077                            Err(e) => Err(anyhow::anyhow!("create_data_channel failed: {e}")),
1078                        }
1079                    }
1080                    "apply_answer" => {
1081                        let answer_json = data
1082                            .get("answer_json")
1083                            .and_then(|v| v.as_str())
1084                            .unwrap_or_default();
1085                        match serde_json::from_str::<RTCSessionDescription>(answer_json) {
1086                            Ok(answer) => {
1087                                match peer_connection.set_remote_description(answer).await {
1088                                    Ok(()) => Ok(ClientSendOutcome::Executed {
1089                                        detail: "SDP answer applied; ICE and DTLS now proceed"
1090                                            .to_string(),
1091                                    }),
1092                                    Err(e) => {
1093                                        Err(anyhow::anyhow!("set_remote_description failed: {e}"))
1094                                    }
1095                                }
1096                            }
1097                            Err(e) => Ok(ClientSendOutcome::Rejected {
1098                                error: format!("answer_json is not an SDP answer: {e}"),
1099                            }),
1100                        }
1101                    }
1102                    // Honest gap, and `Rejected` rather than `Executed` because that is
1103                    // the variant callers branch on. The detail string said "was NOT
1104                    // sent" in as many words while the outcome read as success, so the
1105                    // dashboard drew a tick and any programmatic caller matching on
1106                    // `Executed` concluded the offer had gone out. The signaling
1107                    // WebSocket sink is owned by `websocket_signaling`'s own task and is
1108                    // not reachable from here, and in manual mode there is no signaling
1109                    // connection at all.
1110                    "send_offer" => Ok(ClientSendOutcome::Rejected {
1111                        error: "send_offer cannot be injected: the signaling WebSocket sink \
1112                                belongs to the signaling task and is not reachable from a \
1113                                command, and manual mode has no signaling connection at all"
1114                            .to_string(),
1115                    }),
1116                    other => Ok(ClientSendOutcome::Rejected {
1117                        error: format!("WebRTC client cannot apply custom result '{other}'"),
1118                    }),
1119                },
1120                Ok(ClientActionResult::Disconnect) => {
1121                    let channels: Vec<Arc<RTCDataChannel>> = {
1122                        client_data
1123                            .lock()
1124                            .await
1125                            .channels
1126                            .values()
1127                            .map(|c| Arc::clone(&c.channel))
1128                            .collect()
1129                    };
1130                    for channel in channels {
1131                        let _ = channel.close().await;
1132                    }
1133                    if let Err(e) = peer_connection.close().await {
1134                        debug!("WebRTC client {} close: {}", client_id, e);
1135                    }
1136                    Ok(ClientSendOutcome::Disconnected)
1137                }
1138                Ok(ClientActionResult::WaitForMore) => Ok(ClientSendOutcome::Executed {
1139                    detail: "waiting for the next message; nothing sent".to_string(),
1140                }),
1141                Ok(ClientActionResult::NoAction) => Ok(ClientSendOutcome::Executed {
1142                    detail: "no_action: nothing sent".to_string(),
1143                }),
1144                Ok(ClientActionResult::Multiple(_)) => Ok(ClientSendOutcome::Rejected {
1145                    error: "WebRTC client does not support Multiple action results".to_string(),
1146                }),
1147            };
1148
1149            let outcome_json = match &outcome {
1150                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1151                Err(e) => serde_json::json!({"error": e.to_string()}),
1152            };
1153            app_state
1154                .record_access_log(
1155                    AccessLogOwner::Client(client_id.as_u32()),
1156                    protocol.protocol_name(),
1157                    None,
1158                    "injected_action",
1159                    action,
1160                    vec![outcome_json],
1161                )
1162                .await;
1163
1164            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1165            if let Err(e) = &outcome {
1166                error!("WebRTC client {} injected action failed: {}", client_id, e);
1167                let _ = status_tx.send(format!(
1168                    "[WARN] Client {} injected action failed: {}",
1169                    client_id, e
1170                ));
1171            }
1172            let _ = status_tx.send("__UPDATE_UI__".to_string());
1173            crate::client::command_support::reply(command, outcome);
1174
1175            if disconnect {
1176                app_state.remove_client_handle(client_id).await;
1177                break;
1178            }
1179        }
1180    }
1181}