From 1132bba64c442398e2a2f5c1593e0babfad0f92c Mon Sep 17 00:00:00 2001 From: ApfelTeeSaft <91074565+ApfelTeeSaft@users.noreply.github.com> Date: Fri, 1 Aug 2025 10:53:20 +0200 Subject: [PATCH] Added WS for Local "API" Added Tokio to handle Websocket Requests from external programs, for example SpltNotes, did not have time to dig through the core, so someone else needs to finish this impkementation :p --- src-tauri/Cargo.toml | 6 +- src-tauri/src/main.rs | 251 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 7 deletions(-) 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