diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 294f5d054..5331960ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,6 +19,10 @@ serde_json = "1" livesplit-core = { path = "../livesplit-core" } tauri-plugin-dialog = "2" tauri-plugin-http = "2" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = "0.20" +futures-util = "0.3" +uuid = { version = "1.0", features = ["v4"] } [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! @@ -28,4 +32,4 @@ custom-protocol = ["tauri/custom-protocol"] lto = true panic = "abort" codegen-units = 1 -strip = true +strip = true \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2e7c59c8e..c0b88446c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,22 +2,70 @@ use std::{ borrow::Cow, + collections::HashMap, future::Future, + net::SocketAddr, str::FromStr, sync::{Arc, RwLock}, + time::Duration, }; +use futures_util::{SinkExt, StreamExt}; use livesplit_core::{ event::{CommandSink, Event, Result}, hotkey::KeyCode, networking::server_protocol::Command, HotkeyConfig, HotkeySystem, TimeSpan, TimingMethod, }; +use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager, WebviewWindow}; +use tokio::sync::broadcast; +use tokio_tungstenite::{accept_async, tungstenite::Message}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum WebSocketMessage { + #[serde(rename = "heartbeat")] + Heartbeat { + timestamp: u64, + }, + #[serde(rename = "split")] + Split { + split_index: u32, + split_name: String, + timestamp: u64, + }, + #[serde(rename = "start")] + Start { + timestamp: u64, + }, + #[serde(rename = "reset")] + Reset { + timestamp: u64, + }, + #[serde(rename = "pause")] + Pause { + timestamp: u64, + }, + #[serde(rename = "resume")] + Resume { + timestamp: u64, + }, + #[serde(rename = "undo_split")] + UndoSplit { + timestamp: u64, + }, + #[serde(rename = "skip_split")] + SkipSplit { + timestamp: u64, + }, +} struct State { hotkey_system: RwLock>>, window: RwLock>, + websocket_tx: broadcast::Sender, } #[tauri::command] @@ -69,12 +117,146 @@ fn settings_changed(state: tauri::State<'_, State>, always_on_top: bool) { } } +#[tauri::command] +async fn start_websocket_server( + state: tauri::State<'_, State>, + port: u16, +) -> Result { + let addr = format!("127.0.0.1:{}", port); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?; + + let tx = state.websocket_tx.clone(); + + tokio::spawn(async move { + println!("WebSocket server listening on: {}", addr); + + while let Ok((stream, addr)) = listener.accept().await { + let tx = tx.clone(); + tokio::spawn(handle_connection(stream, addr, tx)); + } + }); + + Ok(format!("WebSocket server started on {}", addr)) +} + +async fn handle_connection( + stream: tokio::net::TcpStream, + addr: SocketAddr, + tx: broadcast::Sender, +) { + let ws_stream = match accept_async(stream).await { + Ok(ws) => ws, + Err(e) => { + println!("WebSocket connection error from {}: {}", addr, e); + return; + } + }; + + println!("New WebSocket connection from: {}", addr); + + let (mut ws_sender, mut ws_receiver) = ws_stream.split(); + let mut rx = tx.subscribe(); + + // Send initial heartbeat + let heartbeat = WebSocketMessage::Heartbeat { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64, + }; + + if let Ok(json) = serde_json::to_string(&heartbeat) { + if ws_sender.send(Message::Text(json)).await.is_err() { + return; + } + } + + // Spawn heartbeat task + let mut heartbeat_sender = ws_sender.clone(); + let heartbeat_task = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + let heartbeat = WebSocketMessage::Heartbeat { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64, + }; + + if let Ok(json) = serde_json::to_string(&heartbeat) { + if heartbeat_sender.send(Message::Text(json)).await.is_err() { + break; + } + } else { + break; + } + } + }); + + // Handle incoming messages and broadcast events + let broadcast_task = tokio::spawn(async move { + loop { + tokio::select! { + // Handle incoming WebSocket messages (if any) + msg = ws_receiver.next() => { + match msg { + Some(Ok(Message::Text(_))) => { + // For now, we just acknowledge text messages + // Could be used for client requests in the future + } + Some(Ok(Message::Close(_))) | None => { + println!("WebSocket connection closed: {}", addr); + break; + } + Some(Err(e)) => { + println!("WebSocket error from {}: {}", addr, e); + break; + } + _ => {} + } + } + // Broadcast events to client + event = rx.recv() => { + match event { + Ok(msg) => { + if let Ok(json) = serde_json::to_string(&msg) { + if ws_sender.send(Message::Text(json)).await.is_err() { + break; + } + } + } + Err(broadcast::error::RecvError::Closed) => break, + Err(broadcast::error::RecvError::Lagged(_)) => { + // Skip lagged messages + continue; + } + } + } + } + } + }); + + // Wait for either task to complete + tokio::select! { + _ = heartbeat_task => {}, + _ = broadcast_task => {}, + } + + println!("WebSocket connection handler finished for: {}", addr); +} + #[derive(Clone)] -struct TauriCommandSink(Arc>>); +struct TauriCommandSink { + window: Arc>>, + websocket_tx: broadcast::Sender, +} impl TauriCommandSink { fn send(&self, command: Command) { - self.0 + self.window .read() .unwrap() .as_ref() @@ -82,51 +264,98 @@ impl TauriCommandSink { .emit("command", command) .unwrap(); } + + fn send_websocket_event(&self, event: WebSocketMessage) { + let _ = self.websocket_tx.send(event); + } + + fn get_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 + } } impl CommandSink for TauriCommandSink { fn start(&self) -> impl Future + 'static { self.send(Command::Start); + self.send_websocket_event(WebSocketMessage::Start { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn split(&self) -> impl Future + 'static { self.send(Command::Split); + + // For now, we'll use placeholder values for split info + // In a real implementation, you'd get this from the timer state + self.send_websocket_event(WebSocketMessage::Split { + split_index: 0, // This should come from actual timer state + split_name: "Split".to_string(), // This should come from actual split data + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn split_or_start(&self) -> impl Future + 'static { self.send(Command::SplitOrStart); + // This could be either a start or split, ideally you'd check timer state + self.send_websocket_event(WebSocketMessage::Split { + split_index: 0, + split_name: "Split".to_string(), + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn reset(&self, save_attempt: Option) -> impl Future + 'static { self.send(Command::Reset { save_attempt }); + self.send_websocket_event(WebSocketMessage::Reset { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn undo_split(&self) -> impl Future + 'static { self.send(Command::UndoSplit); + self.send_websocket_event(WebSocketMessage::UndoSplit { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn skip_split(&self) -> impl Future + 'static { self.send(Command::SkipSplit); + self.send_websocket_event(WebSocketMessage::SkipSplit { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn toggle_pause_or_start(&self) -> impl Future + 'static { self.send(Command::TogglePauseOrStart); + // This could be either pause or resume, ideally you'd check timer state + self.send_websocket_event(WebSocketMessage::Pause { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn pause(&self) -> impl Future + 'static { self.send(Command::Pause); + self.send_websocket_event(WebSocketMessage::Pause { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } fn resume(&self) -> impl Future + 'static { self.send(Command::Resume); + self.send_websocket_event(WebSocketMessage::Resume { + timestamp: Self::get_timestamp(), + }); async { Ok(Event::Unknown) } } @@ -203,15 +432,24 @@ impl CommandSink for TauriCommandSink { } } -fn main() { - let sink = TauriCommandSink(Arc::new(RwLock::new(None))); +#[tokio::main] +async fn main() { + let (websocket_tx, _) = broadcast::channel(100); + + let sink = TauriCommandSink { + window: Arc::new(RwLock::new(None)), + websocket_tx: websocket_tx.clone(), + }; + let hotkey_system = RwLock::new(HotkeySystem::new(sink.clone()).ok()); + tauri::Builder::default() .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_dialog::init()) .manage(State { hotkey_system, window: RwLock::new(None), + websocket_tx, }) .setup(move |app| { let main_window = app.webview_windows().values().next().unwrap().clone(); @@ -220,7 +458,7 @@ fn main() { .write() .unwrap() .replace(main_window.clone()); - *sink.0.write().unwrap() = Some(main_window); + *sink.window.write().unwrap() = Some(main_window); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -229,7 +467,8 @@ fn main() { get_hotkey_config, resolve_hotkey, settings_changed, + start_websocket_server, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); -} +} \ No newline at end of file