Files
voice-to-notes/src-tauri/src/sidecar/mod.rs

359 lines
12 KiB
Rust
Raw Normal View History

pub mod ipc;
pub mod messages;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use crate::sidecar::messages::IPCMessage;
/// Resource directory set by the Tauri app during setup.
/// Used to locate the bundled sidecar binary and its companion files.
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Set the resource directory for sidecar resolution.
/// Must be called from the Tauri setup before any sidecar operations.
pub fn init_resource_dir(dir: PathBuf) {
RESOURCE_DIR.set(dir).ok();
}
/// Get the global sidecar manager singleton.
pub fn sidecar() -> &'static SidecarManager {
static INSTANCE: OnceLock<SidecarManager> = 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<Option<Child>>,
stdin: Mutex<Option<ChildStdin>>,
reader: Mutex<Option<BufReader<std::process::ChildStdout>>>,
}
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).
///
/// Searches for the PyInstaller-built sidecar in the Tauri resource directory
/// (set via `init_resource_dir`) and falls back to paths relative to the
/// current executable.
fn resolve_sidecar_path() -> Result<std::path::PathBuf, String> {
let binary_name = if cfg!(target_os = "windows") {
"voice-to-notes-sidecar.exe"
} else {
"voice-to-notes-sidecar"
};
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
// Primary: Tauri resource directory (set during app setup)
if let Some(resource_dir) = RESOURCE_DIR.get() {
// Resources are placed under sidecar/ subdirectory
candidates.push(resource_dir.join("sidecar").join(binary_name));
// Also check flat layout in resource dir
candidates.push(resource_dir.join(binary_name));
}
// Fallback: relative to the current executable
if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
// sidecar/ subdirectory next to exe (Windows MSI, Linux AppImage)
candidates.push(exe_dir.join("sidecar").join(binary_name));
// Flat layout next to exe
candidates.push(exe_dir.join(binary_name));
// PyInstaller onedir subdirectory
candidates.push(
exe_dir
.join("voice-to-notes-sidecar")
.join(binary_name),
);
}
}
for path in &candidates {
if path.exists() {
return Ok(path.canonicalize().unwrap_or_else(|_| path.clone()));
}
}
Err(format!(
"Sidecar binary not found. Checked:\n{}",
candidates
.iter()
.map(|p| format!(" {}", p.display()))
.collect::<Vec<_>>()
.join("\n"),
))
}
/// 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<std::path::PathBuf, String> {
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());
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::<IPCMessage>(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<IPCMessage, String> {
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<F>(
&self,
msg: &IPCMessage,
on_intermediate: F,
) -> Result<IPCMessage, String>
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();
}
}