tauri-app: spawn record-daemon on startup if not present
All checks were successful
record-daemon / Build, check and test (push) Successful in 2m14s

This commit is contained in:
2026-05-17 01:11:46 +02:00
parent c61b79e84b
commit 5f967f0393
3 changed files with 186 additions and 2 deletions

View File

@@ -26,5 +26,5 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
directories = "6" directories = "6"
ffmpeg-sidecar = "2.4" ffmpeg-sidecar = "2.4"
tokio = { version = "1", features = ["net", "io-util", "time"] } tokio = { version = "1", features = ["net", "io-util", "time", "rt"] }

View File

@@ -4,7 +4,7 @@
//! using the same JSON-over-newline-delimited protocol. //! using the same JSON-over-newline-delimited protocol.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::time::Duration; 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 `<exe_dir>/../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<PathBuf> {
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 <exe_dir>/../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 <workspace>/tauri-app/src-tauri/target/debug/tauri-app.exe
// so we walk up to find <workspace>/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<bool, String> {
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 // Tauri commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -247,6 +399,15 @@ pub async fn daemon_is_running() -> bool {
client.is_daemon_running().await 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<bool, String> {
ensure_daemon_running(30).await
}
/// Get the daemon status. /// Get the daemon status.
#[tauri::command] #[tauri::command]
pub async fn daemon_get_status() -> Result<serde_json::Value, String> { pub async fn daemon_get_status() -> Result<serde_json::Value, String> {

View File

@@ -395,6 +395,28 @@ fn get_video_metadata(video_path: String) -> Result<Value, String> {
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .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![ .invoke_handler(tauri::generate_handler![
get_game_history, get_game_history,
get_timeline, get_timeline,
@@ -408,6 +430,7 @@ pub fn run() {
get_video_metadata, get_video_metadata,
// Daemon IPC commands // Daemon IPC commands
daemon_ipc::daemon_is_running, daemon_ipc::daemon_is_running,
daemon_ipc::daemon_ensure_running,
daemon_ipc::daemon_get_status, daemon_ipc::daemon_get_status,
daemon_ipc::daemon_get_settings, daemon_ipc::daemon_get_settings,
daemon_ipc::daemon_update_settings, daemon_ipc::daemon_update_settings,