From b95a2a1025cf626fa36e3fbdc390a245bf78df22 Mon Sep 17 00:00:00 2001 From: pushkar-gr Date: Mon, 29 Dec 2025 12:51:05 +0530 Subject: [PATCH] feat: gracefully disconnect from a connection and cancle the handshake (closes #19, closes #23) --- src/config.rs | 2 + src/main.rs | 68 ++++++++++---- src/messaging/handshake.rs | 6 +- src/messaging/message_manager.rs | 150 +++++++++++++++++++++++++++++-- src/messaging/mod.rs | 2 +- src/net.rs | 8 +- src/web/shared_state.rs | 22 ++++- src/web/web_server.rs | 102 ++++++++++++++++++++- static/index.html | 7 ++ static/script.js | 100 +++++++++++++++++---- static/style.css | 21 ++++- 11 files changed, 431 insertions(+), 57 deletions(-) diff --git a/src/config.rs b/src/config.rs index 11b9ec7..534793c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,7 @@ pub struct Config { pub web_port: u16, pub handshake_timeout_secs: u64, pub punch_hole_secs: u64, + pub disconnect_timeout_ms: u64, } impl Config { @@ -17,6 +18,7 @@ impl Config { web_port: 8080, handshake_timeout_secs: 30, punch_hole_secs: 15, + disconnect_timeout_ms: 500, } } } diff --git a/src/main.rs b/src/main.rs index 086ac14..57cc789 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod web; use crate::{ config::Config, - messaging::message_manager::MessageManager, + messaging::message_manager::{MessageManager, StreamMessage}, web::{ shared_state::{AppEvent, AppState, Command, SharedState, Status}, web_server, @@ -44,8 +44,7 @@ async fn main() -> Result<()> { let (event_tx, _event_rx) = broadcast::channel::(32); // Initialize Shared State - // Note: We use the new constructor which automatically defaults internal fields. - let shared_state = Arc::new(RwLock::new(AppState::new(cmd_tx, event_tx))); + let shared_state = Arc::new(RwLock::new(AppState::new(cmd_tx.clone(), event_tx))); // Spawn Web Server let state_clone = Arc::clone(&shared_state); @@ -56,13 +55,33 @@ async fn main() -> Result<()> { } }); - // Start the Controller (Main Logic) - // We await this as it runs the main event loop + // Spawn signal handler for graceful shutdown + let cmd_tx_clone = cmd_tx.clone(); + let disconnect_timeout = config.disconnect_timeout_ms; + tokio::spawn(async move { + match tokio::signal::ctrl_c().await { + Ok(()) => { + info!("Received Ctrl+C signal, initiating graceful shutdown"); + // Send disconnect command + if let Err(e) = cmd_tx_clone.send(Command::Disconnect).await { + warn!("Failed to send disconnect command on shutdown: {}", e); + } + // Time for disconnect to complete + tokio::time::sleep(Duration::from_millis(disconnect_timeout)).await; + std::process::exit(0); + } + Err(e) => { + error!("Failed to listen for Ctrl+C: {}", e); + } + } + }); + + // Start the main controller if let Err(e) = start_controller(&config, &shared_state, cmd_rx).await { error!("Controller error: {:?}", e); } - // Wait for web server (optional, usually controller keeps app alive) + // Wait for web server let _ = web_server_handle.await; Ok(()) @@ -82,7 +101,6 @@ async fn start_controller( mut cmd_rx: mpsc::Receiver, ) -> Result<()> { // 1. Bind the UDP Socket - // We bind to 0.0.0.0 to listen on all interfaces. let socket = UdpSocket::bind(("0.0.0.0", config.client_port)).await?; let socket = Arc::new(socket); @@ -105,13 +123,11 @@ async fn start_controller( } // 3. Resolve Public IP via STUN - // Note: We pass a reference to the socket. net::resolve_public_ip now expects &UdpSocket. match net::resolve_public_ip(&socket, &config.stun_server).await { Ok(public_addr) => { info!("Public IP resolved via STUN: {}", public_addr); - // Update state safely using the setter. - // This triggers an event update so the UI displays the IP immediately. + // Update state shared_state.write().await.set_public_ip( public_addr, Some("Public IP resolved".into()), @@ -174,7 +190,7 @@ async fn start_controller( Command::SendMessage(msg) => { if message_manager.is_connected() { - match message_manager.send_message(msg.as_bytes()).await { + match message_manager.send_text(msg.clone()).await { Ok(_) => { shared_state.read().await.add_message(msg, true); }, @@ -184,23 +200,40 @@ async fn start_controller( warn!("Cannot send message: not connected to peer"); } } + + Command::Disconnect => { + debug!("Disconnect command received"); + if let Err(e) = message_manager.disconnect().await { + error!("Disconnect failed: {:?}", e); + } else { + info!("Successfully disconnected from peer"); + } + } } } None => { - info!("Command channel closed, shutting down"); + debug!("Command channel closed, shutting down"); break; } } } - // B. Handle Incoming KCP Messages (Only if connected) res = message_manager.receive_message(&mut recv_buf), if message_manager.is_connected() => { match res { Ok(n) => { - let msg_str = String::from_utf8_lossy(&recv_buf[..n]).to_string(); - debug!("Received message from peer: {}", msg_str); - shared_state.read().await.add_message(msg_str, false); + match bincode::deserialize::(&recv_buf[..n]) { + Ok(StreamMessage::Bye) => { + info!("Received BYE from peer. Disconnecting."); + let _ = message_manager.disconnect_on_bye_received().await; + } + Ok(StreamMessage::Text(content)) => { + shared_state.read().await.add_message(content, false); + } + Err(e) => { + warn!("Failed to deserialize KCP packet: {}", e); + } + } }, Err(e) => { error!("KCP stream error: {}", e); @@ -210,8 +243,7 @@ async fn start_controller( // C. Handle Keep-Alive (Heartbeat) _ = keep_alive_interval.tick() => { - // Only need to keep the NAT open if we are NOT connected. - // If we are connected, the MessageManager (chat session) handles traffic. + // Keep the NAT open if not connected. let status = shared_state.read().await.status; if status == Status::Disconnected { diff --git a/src/messaging/handshake.rs b/src/messaging/handshake.rs index 20f2ed4..c342535 100644 --- a/src/messaging/handshake.rs +++ b/src/messaging/handshake.rs @@ -10,7 +10,7 @@ use tracing::{debug, info, warn}; /// Represents handshake message being sent or received. #[derive(Serialize, Deserialize, Debug, PartialEq)] -enum HandshakeMsg { +pub enum HandshakeMsg { Syn, SynAck, Bye, @@ -116,12 +116,12 @@ pub async fn handshake( } HandshakeMsg::SynAck => { - info!("Received SYN-ACK from {}. Sending ACK.", sender); + info!("Received SYN-ACK from {}.", sender); // Transition to Connected state state.write().await.set_status( Status::Punching, - Some(format!("Received SYN-ACK from {}. Sending ACK.", sender)), + Some(format!("Received SYN-ACK from {}.", sender)), Some(secs_left), ); diff --git a/src/messaging/message_manager.rs b/src/messaging/message_manager.rs index 0928fd4..2ed3bdd 100644 --- a/src/messaging/message_manager.rs +++ b/src/messaging/message_manager.rs @@ -1,15 +1,16 @@ use super::{ super::web::shared_state::{SharedState, Status}, - handshake, + handshake::{self, HandshakeMsg}, }; use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::UdpSocket, }; use tokio_kcp::{KcpConfig, KcpNoDelayConfig, KcpStream}; -use tracing::{error, info}; +use tracing::{error, info, warn}; /// Manages the lifecycle of a P2P connection, handling the transition from raw UDP to reliable KCP. /// @@ -29,6 +30,15 @@ pub struct MessageManager { kcp_stream: Option, } +/// Represents a message being sent/received to/from a peer. +#[derive(Serialize, Deserialize, Debug)] +pub enum StreamMessage { + /// Regular chat content + Text(String), + /// Signal to close connection + Bye, +} + impl MessageManager { /// Creates a new `MessageManager` in a disconnected state. /// @@ -106,7 +116,7 @@ impl MessageManager { if let Some(peer_addr) = self.peer_addr { info!("Upgrading connection to KCP with {}", peer_addr); - // Configure KCP for low-latency (Turbo Mode) + // Configure KCP for low-latency let config = KcpConfig { nodelay: KcpNoDelayConfig { nodelay: true, @@ -133,12 +143,22 @@ impl MessageManager { } } + /// Sends a text message wrapped in the StreamMessage protocol + /// + /// # Arguments + /// + /// * `text` - Message to send. + pub async fn send_text(&mut self, text: String) -> Result<()> { + let payload = bincode::serialize(&StreamMessage::Text(text))?; + self.send_raw(&payload).await + } + /// Sends a binary message over the established KCP stream. /// /// # Arguments /// /// * `payload` - The bytes to send. - pub async fn send_message(&mut self, payload: &[u8]) -> Result<()> { + async fn send_raw(&mut self, payload: &[u8]) -> Result<()> { if let Some(stream) = &mut self.kcp_stream { stream.write_all(payload).await?; stream.flush().await?; @@ -212,6 +232,92 @@ impl MessageManager { } } + /// Gracefully disconnects from the peer by sending a Bye message and cleaning up resources. + /// + /// This method: + /// 1. Sends a Bye message to the peer (over KCP if connected, UDP as fallback) + /// 2. Closes the KCP stream if active + /// 3. Resets the connection state + /// 4. Updates shared state to Disconnected + /// + /// # Returns + /// + /// * `Ok(())` - Disconnection successful + /// * `Err` - If sending the Bye message fails (cleanup still proceeds) + pub async fn disconnect(&mut self) -> Result<()> { + self.disconnect_internal(true).await + } + + /// Disconnects from peer without sending Bye (used when receiving Bye from peer). + /// + /// This method performs cleanup without notifying the peer, since they already + /// initiated the disconnect. + /// + /// # Returns + /// + /// * `Ok(())` - Disconnection successful + pub async fn disconnect_on_bye_received(&mut self) -> Result<()> { + self.disconnect_internal(false).await + } + + /// Internal disconnect implementation with option to send Bye message. + /// + /// # Arguments + /// + /// * `send_bye` - If true, sends Bye message to peer before cleanup + async fn disconnect_internal(&mut self, send_bye: bool) -> Result<()> { + info!("Initiating graceful disconnect (send_bye: {})", send_bye); + + // Send Bye message to peer only if requested + if send_bye && let Some(peer_addr) = self.peer_addr { + let mut sent_via_kcp = false; + + // Try to send via KCP first if available + if self.kcp_stream.is_some() { + let bye_packet = bincode::serialize(&StreamMessage::Bye)?; + match self.send_raw(&bye_packet).await { + Ok(_) => { + info!("Sent Bye message to peer via KCP"); + sent_via_kcp = true; + } + Err(e) => { + warn!("Failed to send Bye via KCP: {}. Will try UDP fallback.", e); + } + } + } + + // 2. Fallback: UDP Raw (HandshakeMsg::Bye) + if !sent_via_kcp { + let udp_bye = bincode::serialize(&HandshakeMsg::Bye)?; + match self.client_socket.send_to(&udp_bye, peer_addr).await { + Ok(_) => info!("Sent HandshakeMsg::Bye via UDP"), + Err(e) => warn!("Failed to send Bye via UDP: {}", e), + } + } + } + + // Close KCP stream if active + if let Err(e) = self.close_kcp().await { + warn!("Error closing KCP stream during disconnect: {}", e); + } + + // Reset connection state + self.peer_addr = None; + + // Clear chat history + self.state.read().await.clear_chat(); + + // Update shared state + self.state.write().await.set_status( + Status::Disconnected, + Some("Disconnected from peer".into()), + None, + ); + + info!("Disconnect complete"); + Ok(()) + } + /// Closes the active KCP stream gracefully. /// /// This method: @@ -225,7 +331,7 @@ impl MessageManager { if let Some(mut stream) = self.kcp_stream.take() { info!("Initiating KCP stream shutdown..."); - // Attempt graceful shutdown. We log errors but don't fail the function + // Attempt graceful shutdown. Log errors but not fail the function if let Err(e) = stream.shutdown().await { error!("Error during KCP shutdown: {}", e); } else { @@ -289,7 +395,7 @@ mod tests { #[tokio::test] async fn test_send_fails_without_kcp() { let mut manager = create_test_manager().await; - let result = manager.send_message(b"hello").await; + let result = manager.send_text("hello".into()).await; assert!(result.is_err()); } @@ -346,4 +452,36 @@ mod tests { "Original socket should remain valid after cloned socket is dropped" ); } + + #[tokio::test] + async fn test_disconnect_without_connection() { + let mut manager = create_test_manager().await; + + // Disconnect without being connected should work (idempotent) + let result = manager.disconnect().await; + assert!(result.is_ok()); + + // Verify state was updated to Disconnected + let state_guard = manager.state.read().await; + assert_eq!(state_guard.status, Status::Disconnected); + } + + #[tokio::test] + async fn test_disconnect_with_peer_addr() { + let mut manager = create_test_manager().await; + + // Set a peer address (simulating a connection) + manager.peer_addr = Some("127.0.0.1:9999".parse().unwrap()); + + // Disconnect + let result = manager.disconnect().await; + assert!(result.is_ok()); + + // Verify peer_addr is cleared + assert!(manager.peer_addr.is_none()); + + // Verify state was updated to Disconnected + let state_guard = manager.state.read().await; + assert_eq!(state_guard.status, Status::Disconnected); + } } diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs index 171e7aa..61eef8b 100644 --- a/src/messaging/mod.rs +++ b/src/messaging/mod.rs @@ -1,2 +1,2 @@ -mod handshake; +pub mod handshake; pub mod message_manager; diff --git a/src/net.rs b/src/net.rs index f0ba33d..520c407 100644 --- a/src/net.rs +++ b/src/net.rs @@ -29,7 +29,7 @@ const STUN_TIMEOUT: Duration = Duration::from_secs(3); /// * `Ok(SocketAddr)` - Local IP and port /// * `Err` - If resolution fails pub async fn get_local_ip(local_port: u16) -> Result { - // Connect to Google's public DNS (we don't send any data) + // Connect to Google's public DNS let socket = UdpSocket::bind(("0.0.0.0", 0)).await?; socket.connect("8.8.8.8:80").await?; let local_addr = socket.local_addr()?; @@ -93,7 +93,7 @@ pub async fn resolve_public_ip( // 3. Wait for response with timeout let mut buf = [0u8; 1024]; - // We use a timeout here because UDP packets can be lost, and we don't want to hang forever. + // Use timeout as UDP packets can be lost. let (len, sender_addr) = timeout(STUN_TIMEOUT, socket.recv_from(&mut buf)) .await .context("STUN request timed out")? @@ -227,7 +227,7 @@ mod test { let mock_server = UdpSocket::bind("127.0.0.1:0").await.unwrap(); let server_addr = mock_server.local_addr().unwrap(); - // We expect a timeout error roughly after STUN_TIMEOUT + // Expect a timeout error roughly after STUN_TIMEOUT let result = resolve_public_ip(&socket, server_addr.to_string()).await; assert!(result.is_err()); @@ -307,7 +307,7 @@ mod test { let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); // 3. Define "Previous Address" (Result from STUN 1) - // We pretend STUN 1 said we are on port 9999. + // Pretend STUN 1 said we are on port 9999. let prev_addr: SocketAddr = "127.0.0.1:9999".parse().unwrap(); // 4. Run Detection diff --git a/src/web/shared_state.rs b/src/web/shared_state.rs index 09eb486..dc218a7 100644 --- a/src/web/shared_state.rs +++ b/src/web/shared_state.rs @@ -127,13 +127,14 @@ impl AppState { /// via the event channel. fn broadcast_status_change(&self, message: Option, timeout: Option) { let event = match self.status { - // When disconnected, we send the full state so the UI can sync up. + // When disconnected, sends the full state. Status::Disconnected => AppEvent::Disconnected { state: self.clone(), + message, }, - // During punching, we primarily send progress updates/timeouts. + // During punching, sends progress updates and timeouts. Status::Punching => AppEvent::Punching { timeout, message }, - // When connected, we send status messages. + // When connected, sends status messages. Status::Connected => AppEvent::Connected { message }, }; self.broadcast_event(event); @@ -144,6 +145,11 @@ impl AppState { let _ = self.event_tx.send(AppEvent::Message { content, from_me }); } + /// Clears the chat history in the UI. + pub fn clear_chat(&self) { + let _ = self.event_tx.send(AppEvent::ClearChat); + } + /// Broadcasts an event to the UI. fn broadcast_event(&self, event: AppEvent) { let _ = self.event_tx.send(event); @@ -172,9 +178,11 @@ pub enum NatType { pub enum AppEvent { /// Application is idle or disconnected. /// - /// Includes full state for UI synchronization. Disconnected { + /// Full state for UI synchronization. state: AppState, + /// Messages. + message: Option, }, /// Attempting NAT hole punching. @@ -195,6 +203,9 @@ pub enum AppEvent { content: String, from_me: bool, }, + + /// Clear chat history. + ClearChat, } /// Connection state of the P2P node. @@ -219,4 +230,7 @@ pub enum Command { /// Sends a message SendMessage(String), + + /// Disconnect from current peer + Disconnect, } diff --git a/src/web/web_server.rs b/src/web/web_server.rs index be94d9f..414098e 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -54,6 +54,7 @@ pub fn router(shared_state: SharedState) -> Router { // API Routes .route("/api/state", get(get_state)) .route("/api/connect", post(connect_peer)) + .route("/api/disconnect", post(disconnect_peer)) .route("/api/message", post(send_message)) .route("/api/events", get(sse_handler)) // Static File Serving (Fallback) @@ -106,7 +107,7 @@ async fn connect_peer( )); } - // Set the peer IP using the mutator to ensure the UI gets an update event + // Set the peer IP guard.set_peer_ip(peer_addr, Some("Target set via API".into()), None); } @@ -123,6 +124,32 @@ async fn connect_peer( Ok(StatusCode::OK) } +/// Handler for `POST /api/disconnect`. +/// Triggers graceful disconnection from the current peer. +async fn disconnect_peer( + State(state): State, +) -> Result { + debug!("Received disconnect request"); + + // Check if connected or punching + let status = state.read().await.status; + if status == Status::Disconnected { + return Err((StatusCode::BAD_REQUEST, "Already disconnected".to_string())); + } + + // Send command to controller + let cmd_tx = state.read().await.cmd_tx().clone(); + if let Err(e) = cmd_tx.send(Command::Disconnect).await { + error!("Failed to send Disconnect command: {}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal Controller Error".to_string(), + )); + } + + Ok(StatusCode::OK) +} + #[derive(Debug, Deserialize)] struct SendMessageRequest { message: String, @@ -378,7 +405,10 @@ mod tests { // Verify event was broadcast let event = event_rx.recv().await.unwrap(); match event { - AppEvent::Disconnected { state: app_state } => { + AppEvent::Disconnected { + state: app_state, + message: Some(_), + } => { assert_eq!( app_state.public_ip.unwrap().to_string(), "203.0.113.10:8080" @@ -419,8 +449,12 @@ mod tests { // Verify event contains new IP let event = event_rx.recv().await.unwrap(); + print!("{:?}", event); match event { - AppEvent::Disconnected { state: app_state } => { + AppEvent::Disconnected { + state: app_state, + message: Some(_), + } => { assert_eq!( app_state.public_ip.unwrap().to_string(), "203.0.113.20:8080" @@ -445,10 +479,70 @@ mod tests { // Verify event let event = event_rx.recv().await.unwrap(); match event { - AppEvent::Disconnected { state: app_state } => { + AppEvent::Disconnected { + state: app_state, + message: Some(_), + } => { assert_eq!(app_state.nat_type, NatType::Cone); } _ => panic!("Expected Disconnected event"), } } + + #[tokio::test] + async fn test_disconnect_when_disconnected_fails() { + let state = create_test_state(); + let app = router(state.clone()); + + let request = Request::builder() + .method("POST") + .uri("/api/disconnect") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_disconnect_when_connected_succeeds() { + let state = create_test_state(); + + // Set state to connected + { + state.write().await.status = Status::Connected; + } + + let app = router(state.clone()); + + let request = Request::builder() + .method("POST") + .uri("/api/disconnect") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_disconnect_when_punching_succeeds() { + let state = create_test_state(); + + // Set state to punching + { + state.write().await.status = Status::Punching; + } + + let app = router(state.clone()); + + let request = Request::builder() + .method("POST") + .uri("/api/disconnect") + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/static/index.html b/static/index.html index 623ecec..9f6aa15 100644 --- a/static/index.html +++ b/static/index.html @@ -117,6 +117,10 @@

GhostLink

> Initializing Hole Punching Sequence...
+ +
+ +
@@ -132,6 +136,9 @@

GhostLink

...
+
diff --git a/static/script.js b/static/script.js index 7a6839b..a66b752 100644 --- a/static/script.js +++ b/static/script.js @@ -8,7 +8,6 @@ const state = { isIpValid: false, isPortValid: false, sseSource: null, - lastSavedMessage: null, // For storing the message on timeout }; // --- DOM Elements --- @@ -42,6 +41,7 @@ const els = { vizPeerIp: document.getElementById('vizPeerIp'), punchLogs: document.getElementById('punchLogs'), punchTimeout: document.getElementById('punchTimeout'), + cancelPunchBtn: document.getElementById('cancelPunchBtn'), // New Cancel Button // Connected / Chat chatMessages: document.getElementById('chatMessages'), @@ -49,6 +49,7 @@ const els = { chatForm: document.getElementById('chatForm'), chatInput: document.getElementById('chatInput'), sendBtn: document.getElementById('sendBtn'), + disconnectBtn: document.getElementById('disconnectBtn'), // New Disconnect Button // Toast toast: document.getElementById('toast'), @@ -151,11 +152,11 @@ function handleStatusChange(statusStr, data = {}) { // CASE B: Standard AppState via REST API (/api/state) or top-level event fields // (Note: syncState calls handleStatusChange, so we avoid infinite recursion by not calling syncState back) - // Logic: Transitioning FROM Punching TO Disconnected - if (state.connectionStatus === 'punching' && normStatus === 'DISCONNECTED') { - if (state.lastSavedMessage) { - showToast(state.lastSavedMessage); - state.lastSavedMessage = null; + // Logic: Handling Disconnection Messages + // Simplified: Directly display message if present in the event payload + if (normStatus === 'DISCONNECTED') { + if (data.message) { + showToast(data.message); } } @@ -169,6 +170,9 @@ function handleStatusChange(statusStr, data = {}) { els.viewPunching.classList.remove('active'); els.viewConnected.classList.remove('active'); + // Reset buttons when state changes + resetDisconnectButtons(); + if (normStatus === 'PUNCHING') { enterPunchingState(data); } else if (normStatus === 'CONNECTED') { @@ -176,6 +180,16 @@ function handleStatusChange(statusStr, data = {}) { } else { // DISCONNECTED els.viewHome.classList.add('active'); + els.submitBtn.disabled = !(state.isIpValid && state.isPortValid); + els.submitBtn.innerText = "Establish Link"; + } +} + +function resetDisconnectButtons() { + if(els.disconnectBtn) els.disconnectBtn.disabled = false; + if(els.cancelPunchBtn) { + els.cancelPunchBtn.disabled = false; + els.cancelPunchBtn.innerText = "Cancel Connection"; } } @@ -192,10 +206,6 @@ async function enterPunchingState(data) { // Handle Timeout Display (from AppEvent::Punching { timeout }) if (data.timeout !== undefined && data.timeout !== null) { els.punchTimeout.innerText = `${data.timeout}s`; - - if (data.timeout === 0 && data.message) { - state.lastSavedMessage = data.message; - } } // Handle Logs (from AppEvent::Punching { message }) @@ -233,11 +243,15 @@ function connectSSE() { // { status: "PUNCHING", timeout: 10, message: "..." } // { status: "CONNECTED", message: "..." } // { status: "MESSAGE", content: "...", from_me: true/false } + // { status: "CLEAR_CHAT" } if (data.status) { if (data.status === 'MESSAGE') { // Handle chat message addChatMessage(data.content, data.from_me); + } else if (data.status === 'CLEAR_CHAT') { + // Handle clear chat event + clearChatUI(); } else { handleStatusChange(data.status, data); } @@ -328,6 +342,19 @@ function addLog(message) { // --- Chat Functions --- +function clearChatUI() { + // Reset chat container to initial state with welcome message + els.chatMessages.innerHTML = ` +
+
+ +
+

Connection Established

+

You can now send and receive messages securely via P2P

+
+ `; +} + /** * Adds a chat message to the chat UI * @param {string} content - Message content @@ -423,13 +450,45 @@ async function handleConnect(e) { if (!res.ok) throw new Error(); els.punchLogs.innerHTML = ''; - state.lastSavedMessage = null; + // No longer using lastSavedMessage } catch (err) { showToast("Connection failed to start"); - } finally { btn.innerText = "Establish Link"; btn.disabled = false; + } +} + +// --- Disconnect Logic --- + +async function handleDisconnect(e) { + if(e) e.preventDefault(); + + // Provide immediate UI feedback to prevent double-clicks + if(els.disconnectBtn) els.disconnectBtn.disabled = true; + if(els.cancelPunchBtn) { + els.cancelPunchBtn.disabled = true; + els.cancelPunchBtn.innerText = "Canceling..."; + } + + try { + const res = await fetch('/api/disconnect', { method: 'POST' }); + if (!res.ok) throw new Error("Disconnect failed"); + + // Success: We do nothing here. The backend will process the request, + // send a command, update state, and the SSE stream will push + // a "DISCONNECTED" event, which triggers `handleStatusChange` to switch the UI. + + } catch (err) { + console.error('Disconnect failed:', err); + showToast("Failed to disconnect"); + + // Re-enable buttons on failure so user can try again + if(els.disconnectBtn) els.disconnectBtn.disabled = false; + if(els.cancelPunchBtn) { + els.cancelPunchBtn.disabled = false; + els.cancelPunchBtn.innerText = "Cancel Connection"; + } } } @@ -554,11 +613,20 @@ function handlePortValidation() { function setupEventListeners() { if(els.copyBtn) els.copyBtn.addEventListener('click', copyToClipboard); if(els.copyLocalBtn) els.copyLocalBtn.addEventListener('click', copyLocalToClipboard); - els.connectForm.addEventListener('submit', handleConnect); - els.peerIpInput.addEventListener('input', () => handleIpValidation('input')); - els.peerIpInput.addEventListener('blur', () => handleIpValidation('blur')); - els.peerPortInput.addEventListener('input', handlePortValidation); + + if(els.connectForm) els.connectForm.addEventListener('submit', handleConnect); + + if(els.peerIpInput) { + els.peerIpInput.addEventListener('input', () => handleIpValidation('input')); + els.peerIpInput.addEventListener('blur', () => handleIpValidation('blur')); + } + if(els.peerPortInput) els.peerPortInput.addEventListener('input', handlePortValidation); + if(els.chatForm) els.chatForm.addEventListener('submit', handleChatSubmit); + + // New Disconnect Listeners + if(els.disconnectBtn) els.disconnectBtn.addEventListener('click', handleDisconnect); + if(els.cancelPunchBtn) els.cancelPunchBtn.addEventListener('click', handleDisconnect); } init(); diff --git a/static/style.css b/static/style.css index fd21b79..d19203c 100644 --- a/static/style.css +++ b/static/style.css @@ -1,4 +1,4 @@ -/* ... [Previous variable definitions kept the same] ... */ +/* ... [Variable] ... */ :root { --bg-color: #0f172a; --card-bg: rgba(30, 41, 59, 0.75); /* More transparent for glass effect */ @@ -170,6 +170,8 @@ body { padding: 8px; border-radius: 8px; transition: all 0.2s; display: flex; } .icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: var(--text-primary); } +.icon-btn.danger { color: var(--danger); } +.icon-btn.danger:hover:not(:disabled) { background: rgba(239, 68, 68, 0.1); color: #fff; } .icon-btn:disabled { opacity: 0.5; cursor: not-allowed; } .spin svg { animation: spin 1s linear infinite; } @keyframes spin { 100% { transform: rotate(360deg); } } @@ -210,6 +212,23 @@ input.valid { border-color: var(--success); } box-shadow: none; } +.btn-secondary { + padding: 0.75rem 1.5rem; background-color: transparent; color: var(--text-secondary); + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; + font-size: 0.9rem; font-weight: 600; cursor: pointer; + transition: all 0.2s ease; +} +.btn-secondary:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); +} +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + + /* --- PUNCHING VISUALIZATION --- */ .network-viz { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; position: relative; } .node { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; width: 100px; z-index: 2; }