From 5f967f0393d3ac56e549a385db558925e6c591d6 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 17 May 2026 01:11:46 +0200 Subject: [PATCH] tauri-app: spawn record-daemon on startup if not present --- tauri-app/src-tauri/Cargo.toml | 2 +- tauri-app/src-tauri/src/daemon_ipc.rs | 163 +++++++++++++++++++++++++- tauri-app/src-tauri/src/lib.rs | 23 ++++ 3 files changed, 186 insertions(+), 2 deletions(-) diff --git a/tauri-app/src-tauri/Cargo.toml b/tauri-app/src-tauri/Cargo.toml index dbd7c7c..c351960 100644 --- a/tauri-app/src-tauri/Cargo.toml +++ b/tauri-app/src-tauri/Cargo.toml @@ -26,5 +26,5 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } directories = "6" ffmpeg-sidecar = "2.4" -tokio = { version = "1", features = ["net", "io-util", "time"] } +tokio = { version = "1", features = ["net", "io-util", "time", "rt"] } diff --git a/tauri-app/src-tauri/src/daemon_ipc.rs b/tauri-app/src-tauri/src/daemon_ipc.rs index 96527f7..979957c 100644 --- a/tauri-app/src-tauri/src/daemon_ipc.rs +++ b/tauri-app/src-tauri/src/daemon_ipc.rs @@ -4,7 +4,7 @@ //! using the same JSON-over-newline-delimited protocol. use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; // --------------------------------------------------------------------------- @@ -236,6 +236,158 @@ impl IpcClient { } } +// --------------------------------------------------------------------------- +// Daemon binary discovery & process spawning +// --------------------------------------------------------------------------- + +/// Find the record-daemon binary. +/// +/// Search order: +/// 1. Next to the current executable (bundled / installed mode) +/// 2. In `/../resources/` (macOS .app bundle) +/// 3. Relative to the executable – walk up to the workspace root and look for +/// `record-daemon/target/{release,debug}/` (development mode) +/// 4. Relative to the current working directory (fallback for dev mode) +fn find_daemon_binary() -> Option { + let exe_name = if cfg!(windows) { + "record-daemon.exe" + } else { + "record-daemon" + }; + + if let Ok(exe_path) = std::env::current_exe() { + // 1. Next to the current executable + if let Some(exe_dir) = exe_path.parent() { + let candidate = exe_dir.join(exe_name); + if candidate.exists() { + return Some(candidate); + } + } + + // 2. In /../resources/ + if let Some(exe_dir) = exe_path.parent().and_then(|p| p.parent()) { + let candidate = exe_dir.join("resources").join(exe_name); + if candidate.exists() { + return Some(candidate); + } + } + + // 3. Development mode – relative to the exe location. + // The exe is typically at /tauri-app/src-tauri/target/debug/tauri-app.exe + // so we walk up to find /record-daemon/target/{release,debug}/ + if let Some(exe_dir) = exe_path.parent() { + // Try walking up from the exe directory to find the daemon + let dev_suffixes = [ + Path::new("record-daemon/target/release"), + Path::new("record-daemon/target/debug"), + ]; + // Walk up at most 6 levels from the exe directory + let mut dir = exe_dir.to_path_buf(); + for _ in 0..6 { + for suffix in &dev_suffixes { + let candidate = dir.join(suffix).join(exe_name); + if candidate.exists() { + return Some(candidate); + } + } + if !dir.pop() { + break; + } + } + } + } + + // 4. Fallback – look relative to the current working directory + let cwd_dev_paths = [ + Path::new("../../record-daemon/target/release"), + Path::new("../../record-daemon/target/debug"), + Path::new("../record-daemon/target/release"), + Path::new("../record-daemon/target/debug"), + ]; + for dir in &cwd_dev_paths { + let candidate = dir.join(exe_name); + if candidate.exists() { + return Some(candidate); + } + } + + None +} + +/// Spawn the record-daemon as a detached background process. +/// +/// Returns `Ok(())` if the process was successfully spawned. +fn spawn_daemon() -> Result<(), String> { + let binary = find_daemon_binary() + .ok_or_else(|| "record-daemon binary not found".to_string())?; + + eprintln!("[daemon] Spawning record-daemon: {}", binary.display()); + + let mut cmd = std::process::Command::new(&binary); + cmd.arg("--foreground"); + + // Set the working directory to the daemon binary's directory so it can + // find its bundled resources (OBS plugins, etc.) relative to itself. + if let Some(bin_dir) = binary.parent() { + cmd.current_dir(bin_dir); + } + + // Detach from the parent process group so the daemon survives if the + // Tauri app is restarted. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NEW_PROCESS_GROUP (0x200) | DETACHED_PROCESS (0x8) + cmd.creation_flags(0x00000200 | 0x00000008); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Start in a new session so the daemon gets its own process group + cmd.process_group(0); + } + + // Redirect stdio so the daemon doesn't inherit our handles + cmd.stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + cmd.spawn() + .map_err(|e| format!("Failed to spawn record-daemon: {}", e))?; + + Ok(()) +} + +/// Ensure the record-daemon is running. +/// +/// If the daemon is already reachable via IPC, returns immediately. +/// Otherwise, spawns the daemon binary and waits up to `timeout_secs` seconds +/// for it to become ready. +pub async fn ensure_daemon_running(timeout_secs: u64) -> Result { + let client = IpcClient::new(); + + // Fast path – already running + if client.is_daemon_running().await { + return Ok(true); + } + + // Spawn the daemon + spawn_daemon()?; + + // Poll until the daemon responds or we time out + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + loop { + if client.is_daemon_running().await { + return Ok(true); + } + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + // --------------------------------------------------------------------------- // Tauri commands // --------------------------------------------------------------------------- @@ -247,6 +399,15 @@ pub async fn daemon_is_running() -> bool { client.is_daemon_running().await } +/// Ensure the record-daemon is running, starting it if necessary. +/// +/// Returns `true` if the daemon is running (either already was or just started), +/// `false` if it could not be started within the timeout. +#[tauri::command] +pub async fn daemon_ensure_running() -> Result { + ensure_daemon_running(30).await +} + /// Get the daemon status. #[tauri::command] pub async fn daemon_get_status() -> Result { diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 7813992..1e7a013 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -395,6 +395,28 @@ fn get_video_metadata(video_path: String) -> Result { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .setup(|app| { + // Auto-start the record-daemon on app launch. + // We spawn a background task so the UI isn't blocked while the + // daemon boots (it can take a few seconds to initialise OBS). + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + match daemon_ipc::ensure_daemon_running(30).await { + Ok(true) => { + eprintln!("[tauri] record-daemon is running"); + } + Ok(false) => { + eprintln!("[tauri] record-daemon did not become ready within the timeout"); + } + Err(e) => { + eprintln!("[tauri] failed to start record-daemon: {}", e); + } + } + // Drop the handle – no further use + drop(handle); + }); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ get_game_history, get_timeline, @@ -408,6 +430,7 @@ pub fn run() { get_video_metadata, // Daemon IPC commands daemon_ipc::daemon_is_running, + daemon_ipc::daemon_ensure_running, daemon_ipc::daemon_get_status, daemon_ipc::daemon_get_settings, daemon_ipc::daemon_update_settings,