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
This commit is contained in:
ApfelTeeSaft
2025-08-01 10:53:20 +02:00
parent d46f8b5eeb
commit 1132bba64c
2 changed files with 250 additions and 7 deletions
+5 -1
View File
@@ -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
+245 -6
View File
@@ -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<Option<HotkeySystem<TauriCommandSink>>>,
window: RwLock<Option<WebviewWindow>>,
websocket_tx: broadcast::Sender<WebSocketMessage>,
}
#[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<String, String> {
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<WebSocketMessage>,
) {
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<RwLock<Option<WebviewWindow>>>);
struct TauriCommandSink {
window: Arc<RwLock<Option<WebviewWindow>>>,
websocket_tx: broadcast::Sender<WebSocketMessage>,
}
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<Output = Result> + 'static {
self.send(Command::Start);
self.send_websocket_event(WebSocketMessage::Start {
timestamp: Self::get_timestamp(),
});
async { Ok(Event::Unknown) }
}
fn split(&self) -> impl Future<Output = Result> + '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<Output = Result> + '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<bool>) -> impl Future<Output = Result> + '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<Output = Result> + '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<Output = Result> + '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<Output = Result> + '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<Output = Result> + 'static {
self.send(Command::Pause);
self.send_websocket_event(WebSocketMessage::Pause {
timestamp: Self::get_timestamp(),
});
async { Ok(Event::Unknown) }
}
fn resume(&self) -> impl Future<Output = Result> + '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");
}
}