pub mod ipc; pub mod messages; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Mutex, OnceLock}; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use crate::sidecar::messages::IPCMessage; /// Resource directory set by the Tauri app during setup. static RESOURCE_DIR: OnceLock = OnceLock::new(); /// App data directory for extracting the sidecar archive. static DATA_DIR: OnceLock = OnceLock::new(); /// Initialize directories for sidecar resolution. /// Must be called from the Tauri setup before any sidecar operations. pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) { RESOURCE_DIR.set(resource_dir).ok(); DATA_DIR.set(data_dir).ok(); } /// 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). /// /// First checks if the sidecar is already extracted to the app data directory. /// If not, looks for `sidecar.zip` in the Tauri resource directory and extracts it. fn resolve_sidecar_path() -> Result { let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" } else { "voice-to-notes-sidecar" }; // Versioned extraction directory prevents stale sidecar after app updates let extract_dir = DATA_DIR .get() .ok_or("App data directory not initialized")? .join(format!("sidecar-{}", env!("CARGO_PKG_VERSION"))); let binary_path = extract_dir.join(binary_name); // Already extracted — use it directly if binary_path.exists() { return Ok(binary_path); } // Find sidecar.zip in resource dir or next to exe let zip_path = Self::find_sidecar_zip()?; Self::extract_zip(&zip_path, &extract_dir)?; if !binary_path.exists() { return Err(format!( "Sidecar binary not found after extraction at {}", binary_path.display() )); } // Make executable on Unix #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = std::fs::metadata(&binary_path) { let mut perms = meta.permissions(); perms.set_mode(0o755); let _ = std::fs::set_permissions(&binary_path, perms); } } Ok(binary_path) } /// Locate the bundled sidecar.zip archive. fn find_sidecar_zip() -> Result { let mut candidates: Vec = Vec::new(); if let Some(resource_dir) = RESOURCE_DIR.get() { candidates.push(resource_dir.join("sidecar.zip")); } if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { candidates.push(exe_dir.join("sidecar.zip")); } } for path in &candidates { if path.exists() { return Ok(path.clone()); } } Err(format!( "Sidecar archive not found. Checked:\n{}", candidates .iter() .map(|p| format!(" {}", p.display())) .collect::>() .join("\n"), )) } /// Extract a zip archive to the given directory. fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> { eprintln!( "[sidecar-rs] Extracting sidecar from {} to {}", zip_path.display(), dest.display() ); // Clean destination so we don't mix old and new files if dest.exists() { std::fs::remove_dir_all(dest) .map_err(|e| format!("Failed to clean extraction dir: {e}"))?; } std::fs::create_dir_all(dest) .map_err(|e| format!("Failed to create extraction dir: {e}"))?; let file = std::fs::File::open(zip_path).map_err(|e| format!("Cannot open sidecar zip: {e}"))?; let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid sidecar zip: {e}"))?; for i in 0..archive.len() { let mut entry = archive .by_index(i) .map_err(|e| format!("Zip entry error: {e}"))?; let name = entry.name().to_string(); let outpath = dest.join(&name); if entry.is_dir() { std::fs::create_dir_all(&outpath) .map_err(|e| format!("Cannot create dir {}: {e}", outpath.display()))?; } else { if let Some(parent) = outpath.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; } let mut outfile = std::fs::File::create(&outpath) .map_err(|e| format!("Cannot create {}: {e}", outpath.display()))?; std::io::copy(&mut entry, &mut outfile) .map_err(|e| format!("Write error for {}: {e}", name))?; } } eprintln!("[sidecar-rs] Sidecar extracted successfully"); Ok(()) } /// 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 { let path = Self::resolve_sidecar_path()?; self.start_binary(&path) } } /// 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()); // Log sidecar stderr to a file for diagnostics let stderr_cfg = if let Some(data_dir) = DATA_DIR.get() { let _ = std::fs::create_dir_all(data_dir); let log_path = data_dir.join("sidecar.log"); eprintln!("[sidecar-rs] Sidecar stderr → {}", log_path.display()); match std::fs::File::create(&log_path) { Ok(f) => Stdio::from(f), Err(e) => { eprintln!("[sidecar-rs] Failed to create sidecar.log: {e}"); Stdio::inherit() } } } else { eprintln!("[sidecar-rs] DATA_DIR not set, sidecar stderr will not be logged"); Stdio::inherit() }; let mut cmd = Command::new(path); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(stderr_cfg); // Hide the console window on Windows (CREATE_NO_WINDOW = 0x08000000) #[cfg(target_os = "windows")] cmd.creation_flags(0x08000000); let child = cmd .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 { // Try to get the exit code for diagnostics let exit_info = { let mut proc = self.process.lock().map_err(|e| e.to_string())?; if let Some(ref mut child) = *proc { match child.try_wait() { Ok(Some(status)) => format!(" (exit status: {status})"), _ => String::new(), } } else { String::new() } }; return Err(format!( "Sidecar closed stdout before sending ready{exit_info}. \ The Python sidecar may have crashed on startup — check app logs for details." )); } 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. /// /// If the sidecar has crashed (broken pipe), automatically restarts it and retries once. pub fn send_and_receive_with_progress( &self, msg: &IPCMessage, on_intermediate: F, ) -> Result where F: Fn(&IPCMessage), { match self.send_and_receive_inner(msg, &on_intermediate) { Ok(response) => Ok(response), Err(e) if e.contains("Write error") || e.contains("closed stdout") || e.contains("not available") => { eprintln!("[sidecar-rs] Sidecar communication failed ({e}), restarting..."); self.cleanup_handles(); // Stop any zombie process { let mut proc = self.process.lock().map_err(|e| e.to_string())?; if let Some(ref mut child) = proc.take() { let _ = child.kill(); let _ = child.wait(); } } self.ensure_running()?; self.send_and_receive_inner(msg, &on_intermediate) } Err(e) => Err(e), } } /// Inner implementation of send_and_receive. fn send_and_receive_inner( &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 mut proc = match self.process.lock() { Ok(p) => p, Err(_) => return false, }; if let Some(ref mut child) = *proc { // Check if the process has exited match child.try_wait() { Ok(Some(_status)) => { // Process has exited — clean up handles eprintln!("[sidecar-rs] Sidecar process has exited"); drop(proc); let _ = self.cleanup_handles(); false } Ok(None) => true, // Still running Err(_) => false, } } else { false } } /// Clean up stdin/stdout/process handles after the sidecar has exited. fn cleanup_handles(&self) { if let Ok(mut s) = self.stdin.lock() { *s = None; } if let Ok(mut r) = self.reader.lock() { *r = None; } if let Ok(mut p) = self.process.lock() { *p = None; } } } impl Drop for SidecarManager { fn drop(&mut self) { let _ = self.stop(); } }