pub mod ipc; pub mod messages; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Mutex, OnceLock}; use crate::sidecar::messages::IPCMessage; /// Get the global sidecar manager singleton. pub fn sidecar() -> &'static SidecarManager { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(SidecarManager::new) } /// Manages the sidecar process lifecycle. /// /// Supports two modes: /// - **Production**: spawns a frozen PyInstaller binary (no Python required) /// - **Dev mode**: spawns system Python with `-m voice_to_notes.main` /// /// Dev mode is active when compiled in debug mode or when `VOICE_TO_NOTES_DEV=1`. pub struct SidecarManager { process: Mutex>, stdin: Mutex>, reader: Mutex>>, } impl SidecarManager { pub fn new() -> Self { Self { process: Mutex::new(None), stdin: Mutex::new(None), reader: Mutex::new(None), } } /// Check if we should use dev mode (system Python). fn is_dev_mode() -> bool { cfg!(debug_assertions) || std::env::var("VOICE_TO_NOTES_DEV").is_ok() } /// Resolve the frozen sidecar binary path (production mode). fn resolve_sidecar_path() -> Result { let exe = std::env::current_exe().map_err(|e| format!("Cannot get current exe: {e}"))?; let exe_dir = exe .parent() .ok_or_else(|| "Cannot get exe parent directory".to_string())?; let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" } else { "voice-to-notes-sidecar" }; // Tauri places externalBin next to the app binary let path = exe_dir.join(binary_name); if path.exists() { return Ok(path); } // Also check inside a subdirectory (onedir PyInstaller output) let subdir_path = exe_dir.join("voice-to-notes-sidecar").join(binary_name); if subdir_path.exists() { return Ok(subdir_path); } Err(format!( "Sidecar binary not found. Looked for:\n {}\n {}", path.display(), subdir_path.display(), )) } /// Find a working Python command for the current platform. fn find_python_command() -> &'static str { if cfg!(target_os = "windows") { "python" } else { "python3" } } /// Resolve the Python sidecar directory for dev mode. fn resolve_python_dir() -> Result { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let python_dir = std::path::Path::new(manifest_dir) .join("../python") .canonicalize() .map_err(|e| format!("Cannot find python directory: {e}"))?; if python_dir.exists() { return Ok(python_dir); } // Fallback: relative to current exe let exe = std::env::current_exe().map_err(|e| e.to_string())?; let alt = exe .parent() .ok_or_else(|| "No parent dir".to_string())? .join("../python") .canonicalize() .map_err(|e| format!("Cannot find python directory: {e}"))?; Ok(alt) } /// Ensure the sidecar is running, starting it if needed. pub fn ensure_running(&self) -> Result<(), String> { if self.is_running() { return Ok(()); } if Self::is_dev_mode() { self.start_python_dev() } else { match Self::resolve_sidecar_path() { Ok(path) => self.start_binary(&path), Err(e) => { eprintln!( "[sidecar-rs] Frozen binary not found ({e}), falling back to dev mode" ); self.start_python_dev() } } } } /// Spawn the frozen sidecar binary (production mode). fn start_binary(&self, path: &std::path::Path) -> Result<(), String> { self.stop().ok(); eprintln!("[sidecar-rs] Starting frozen sidecar: {}", path.display()); let child = Command::new(path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| format!("Failed to start sidecar binary: {e}"))?; self.attach(child)?; self.wait_for_ready() } /// Spawn the Python sidecar in dev mode (system Python). fn start_python_dev(&self) -> Result<(), String> { self.stop().ok(); let python_dir = Self::resolve_python_dir()?; let python_cmd = Self::find_python_command(); eprintln!( "[sidecar-rs] Starting dev sidecar: {} -m voice_to_notes.main ({})", python_cmd, python_dir.display() ); let child = Command::new(python_cmd) .arg("-m") .arg("voice_to_notes.main") .current_dir(&python_dir) .env("PYTHONPATH", &python_dir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .map_err(|e| format!("Failed to start Python sidecar: {e}"))?; self.attach(child)?; self.wait_for_ready() } /// Take ownership of a spawned child's stdin/stdout and store the process handle. fn attach(&self, mut child: Child) -> Result<(), String> { let stdin = child.stdin.take().ok_or("Failed to get sidecar stdin")?; let stdout = child.stdout.take().ok_or("Failed to get sidecar stdout")?; let buf_reader = BufReader::new(stdout); { let mut proc = self.process.lock().map_err(|e| e.to_string())?; *proc = Some(child); } { let mut s = self.stdin.lock().map_err(|e| e.to_string())?; *s = Some(stdin); } { let mut r = self.reader.lock().map_err(|e| e.to_string())?; *r = Some(buf_reader); } Ok(()) } /// Wait for the sidecar to send its ready message. fn wait_for_ready(&self) -> Result<(), String> { let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?; if let Some(ref mut reader) = *reader_guard { let mut line = String::new(); loop { line.clear(); let bytes = reader .read_line(&mut line) .map_err(|e| format!("Read error: {e}"))?; if bytes == 0 { return Err("Sidecar closed stdout before sending ready".to_string()); } let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Ok(msg) = serde_json::from_str::(trimmed) { if msg.msg_type == "ready" { return Ok(()); } } // Non-JSON or non-ready line — skip and keep waiting eprintln!( "[sidecar-rs] Skipping pre-ready line: {}", &trimmed[..trimmed.len().min(200)] ); continue; } } Err("Sidecar did not send ready message".to_string()) } /// Send a message to the sidecar and read the response. /// This is a blocking call. Progress messages are skipped. pub fn send_and_receive(&self, msg: &IPCMessage) -> Result { self.send_and_receive_with_progress(msg, |_| {}) } /// Send a message and receive the response, calling a callback for intermediate messages. /// Intermediate messages include progress, pipeline.segment, and pipeline.speaker_update. pub fn send_and_receive_with_progress( &self, msg: &IPCMessage, on_intermediate: F, ) -> Result where F: Fn(&IPCMessage), { // Write to stdin { let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?; if let Some(ref mut stdin) = *stdin_guard { let json = serde_json::to_string(msg).map_err(|e| e.to_string())?; stdin .write_all(json.as_bytes()) .map_err(|e| format!("Write error: {e}"))?; stdin .write_all(b"\n") .map_err(|e| format!("Write error: {e}"))?; stdin.flush().map_err(|e| format!("Flush error: {e}"))?; } else { return Err("Sidecar stdin not available".to_string()); } } // Read from stdout { let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?; if let Some(ref mut reader) = *reader_guard { let mut line = String::new(); loop { line.clear(); let bytes_read = reader .read_line(&mut line) .map_err(|e| format!("Read error: {e}"))?; if bytes_read == 0 { return Err("Sidecar closed stdout".to_string()); } let trimmed = line.trim(); if trimmed.is_empty() { continue; } let response: IPCMessage = serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?; // Forward intermediate messages via callback, return the final result/error let is_intermediate = matches!( response.msg_type.as_str(), "progress" | "pipeline.segment" | "pipeline.speaker_update" ); if is_intermediate { on_intermediate(&response); } else { return Ok(response); } } } else { Err("Sidecar stdout not available".to_string()) } } } /// Stop the sidecar process. pub fn stop(&self) -> Result<(), String> { // Drop stdin to signal EOF { let mut stdin_guard = self.stdin.lock().map_err(|e| e.to_string())?; *stdin_guard = None; } // Drop reader { let mut reader_guard = self.reader.lock().map_err(|e| e.to_string())?; *reader_guard = None; } // Wait for process to exit { let mut proc = self.process.lock().map_err(|e| e.to_string())?; if let Some(ref mut child) = proc.take() { match child.wait() { Ok(_) => {} Err(_) => { let _ = child.kill(); } } } } Ok(()) } pub fn is_running(&self) -> bool { let proc = self.process.lock().ok(); proc.map_or(false, |p| p.is_some()) } } impl Drop for SidecarManager { fn drop(&mut self) { let _ = self.stop(); } }