2026-02-26 15:53:09 -08:00
|
|
|
pub mod ipc;
|
|
|
|
|
pub mod messages;
|
|
|
|
|
|
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
2026-03-21 07:12:22 -07:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-02-26 16:50:14 -08:00
|
|
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
|
|
|
use std::sync::{Mutex, OnceLock};
|
2026-02-26 15:53:09 -08:00
|
|
|
|
2026-03-21 14:46:01 -07:00
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
use std::os::windows::process::CommandExt;
|
|
|
|
|
|
2026-02-26 15:53:09 -08:00
|
|
|
use crate::sidecar::messages::IPCMessage;
|
|
|
|
|
|
2026-03-21 06:55:44 -07:00
|
|
|
/// Resource directory set by the Tauri app during setup.
|
|
|
|
|
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
2026-03-21 07:12:22 -07:00
|
|
|
/// App data directory for extracting the sidecar archive.
|
2026-03-22 07:09:08 -07:00
|
|
|
pub(crate) static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
|
2026-03-21 06:55:44 -07:00
|
|
|
|
2026-03-21 07:12:22 -07:00
|
|
|
/// Initialize directories for sidecar resolution.
|
2026-03-21 06:55:44 -07:00
|
|
|
/// Must be called from the Tauri setup before any sidecar operations.
|
2026-03-21 07:12:22 -07:00
|
|
|
pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) {
|
|
|
|
|
RESOURCE_DIR.set(resource_dir).ok();
|
|
|
|
|
DATA_DIR.set(data_dir).ok();
|
2026-03-21 06:55:44 -07:00
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:50:14 -08:00
|
|
|
/// Get the global sidecar manager singleton.
|
|
|
|
|
pub fn sidecar() -> &'static SidecarManager {
|
|
|
|
|
static INSTANCE: OnceLock<SidecarManager> = OnceLock::new();
|
|
|
|
|
INSTANCE.get_or_init(SidecarManager::new)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
/// 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`.
|
2026-02-26 15:53:09 -08:00
|
|
|
pub struct SidecarManager {
|
|
|
|
|
process: Mutex<Option<Child>>,
|
2026-02-26 16:50:14 -08:00
|
|
|
stdin: Mutex<Option<ChildStdin>>,
|
|
|
|
|
reader: Mutex<Option<BufReader<std::process::ChildStdout>>>,
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SidecarManager {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
process: Mutex::new(None),
|
2026-02-26 16:50:14 -08:00
|
|
|
stdin: Mutex::new(None),
|
|
|
|
|
reader: Mutex::new(None),
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 07:57:51 -07:00
|
|
|
/// Read the locally installed sidecar version from `sidecar-version.txt`.
|
|
|
|
|
fn read_sidecar_version() -> Result<String, String> {
|
|
|
|
|
let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?;
|
|
|
|
|
let version_file = data_dir.join("sidecar-version.txt");
|
|
|
|
|
std::fs::read_to_string(&version_file)
|
|
|
|
|
.map_err(|_| {
|
|
|
|
|
"Sidecar not installed: sidecar-version.txt not found. Please download the sidecar."
|
|
|
|
|
.to_string()
|
|
|
|
|
})
|
|
|
|
|
.map(|v| v.trim().to_string())
|
|
|
|
|
.and_then(|v| {
|
|
|
|
|
if v.is_empty() {
|
|
|
|
|
Err(
|
|
|
|
|
"Sidecar version file is empty. Please re-download the sidecar."
|
|
|
|
|
.to_string(),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
Ok(v)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
/// Resolve the frozen sidecar binary path (production mode).
|
2026-03-21 06:55:44 -07:00
|
|
|
///
|
2026-03-22 07:57:51 -07:00
|
|
|
/// Reads the installed sidecar version from `sidecar-version.txt` and
|
|
|
|
|
/// looks for the binary in the corresponding `sidecar-{version}` directory.
|
|
|
|
|
/// If the version file doesn't exist, the sidecar hasn't been downloaded yet.
|
2026-03-21 07:12:22 -07:00
|
|
|
fn resolve_sidecar_path() -> Result<PathBuf, String> {
|
2026-03-20 21:33:43 -07:00
|
|
|
let binary_name = if cfg!(target_os = "windows") {
|
|
|
|
|
"voice-to-notes-sidecar.exe"
|
|
|
|
|
} else {
|
|
|
|
|
"voice-to-notes-sidecar"
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-22 05:55:49 -07:00
|
|
|
let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?;
|
2026-03-22 07:57:51 -07:00
|
|
|
let current_version = Self::read_sidecar_version()?;
|
2026-03-22 05:55:49 -07:00
|
|
|
let extract_dir = data_dir.join(format!("sidecar-{}", current_version));
|
2026-03-21 06:55:44 -07:00
|
|
|
|
2026-03-21 07:12:22 -07:00
|
|
|
let binary_path = extract_dir.join(binary_name);
|
|
|
|
|
|
|
|
|
|
// Already extracted — use it directly
|
|
|
|
|
if binary_path.exists() {
|
2026-03-22 07:57:51 -07:00
|
|
|
Self::cleanup_old_sidecars(data_dir, ¤t_version);
|
2026-03-21 07:12:22 -07:00
|
|
|
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()
|
|
|
|
|
));
|
2026-03-20 21:33:43 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-21 07:12:22 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 07:57:51 -07:00
|
|
|
Self::cleanup_old_sidecars(data_dir, ¤t_version);
|
2026-03-21 07:12:22 -07:00
|
|
|
Ok(binary_path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Locate the bundled sidecar.zip archive.
|
|
|
|
|
fn find_sidecar_zip() -> Result<PathBuf, String> {
|
|
|
|
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
|
|
|
|
|
|
|
|
if let Some(resource_dir) = RESOURCE_DIR.get() {
|
|
|
|
|
candidates.push(resource_dir.join("sidecar.zip"));
|
|
|
|
|
}
|
2026-03-21 06:55:44 -07:00
|
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
|
|
|
if let Some(exe_dir) = exe.parent() {
|
2026-03-21 07:12:22 -07:00
|
|
|
candidates.push(exe_dir.join("sidecar.zip"));
|
2026-03-21 06:55:44 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for path in &candidates {
|
|
|
|
|
if path.exists() {
|
2026-03-21 07:12:22 -07:00
|
|
|
return Ok(path.clone());
|
2026-03-21 06:55:44 -07:00
|
|
|
}
|
2026-02-26 16:50:14 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
Err(format!(
|
2026-03-21 07:12:22 -07:00
|
|
|
"Sidecar archive not found. Checked:\n{}",
|
2026-03-21 06:55:44 -07:00
|
|
|
candidates
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|p| format!(" {}", p.display()))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n"),
|
2026-03-20 21:33:43 -07:00
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 07:12:22 -07:00
|
|
|
/// Extract a zip archive to the given directory.
|
2026-03-22 07:09:08 -07:00
|
|
|
pub(crate) fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
|
2026-03-21 07:12:22 -07:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 05:55:49 -07:00
|
|
|
/// Remove old sidecar-* directories that don't match the current version.
|
|
|
|
|
/// Called after the current version's sidecar is confirmed ready.
|
2026-03-22 07:09:08 -07:00
|
|
|
pub(crate) fn cleanup_old_sidecars(data_dir: &Path, current_version: &str) {
|
2026-03-22 05:55:49 -07:00
|
|
|
let current_dir_name = format!("sidecar-{}", current_version);
|
|
|
|
|
|
|
|
|
|
let entries = match std::fs::read_dir(data_dir) {
|
|
|
|
|
Ok(entries) => entries,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("[sidecar-rs] Cannot read data dir for cleanup: {e}");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let name = entry.file_name();
|
|
|
|
|
let name_str = name.to_string_lossy();
|
|
|
|
|
|
|
|
|
|
if !name_str.starts_with("sidecar-") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if *name_str == current_dir_name {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if entry.path().is_dir() {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"[sidecar-rs] Removing old sidecar: {}",
|
|
|
|
|
entry.path().display()
|
|
|
|
|
);
|
|
|
|
|
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"[sidecar-rs] Failed to remove {}: {e}",
|
|
|
|
|
entry.path().display()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
/// 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)
|
2026-02-26 16:50:14 -08:00
|
|
|
.join("../python")
|
|
|
|
|
.canonicalize()
|
|
|
|
|
.map_err(|e| format!("Cannot find python directory: {e}"))?;
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
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)
|
2026-02-26 16:50:14 -08:00
|
|
|
}
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
/// 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 {
|
2026-03-21 06:55:44 -07:00
|
|
|
let path = Self::resolve_sidecar_path()?;
|
|
|
|
|
self.start_binary(&path)
|
2026-03-20 21:33:43 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Spawn the frozen sidecar binary (production mode).
|
|
|
|
|
fn start_binary(&self, path: &std::path::Path) -> Result<(), String> {
|
2026-02-26 16:50:14 -08:00
|
|
|
self.stop().ok();
|
2026-03-20 21:33:43 -07:00
|
|
|
eprintln!("[sidecar-rs] Starting frozen sidecar: {}", path.display());
|
2026-02-26 16:50:14 -08:00
|
|
|
|
2026-03-21 14:46:01 -07:00
|
|
|
// Log sidecar stderr to a file for diagnostics
|
|
|
|
|
let stderr_cfg = if let Some(data_dir) = DATA_DIR.get() {
|
2026-03-21 18:37:36 -07:00
|
|
|
let _ = std::fs::create_dir_all(data_dir);
|
2026-03-21 14:46:01 -07:00
|
|
|
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),
|
2026-03-21 18:37:36 -07:00
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("[sidecar-rs] Failed to create sidecar.log: {e}");
|
|
|
|
|
Stdio::inherit()
|
|
|
|
|
}
|
2026-03-21 14:46:01 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-21 18:37:36 -07:00
|
|
|
eprintln!("[sidecar-rs] DATA_DIR not set, sidecar stderr will not be logged");
|
2026-03-21 14:46:01 -07:00
|
|
|
Stdio::inherit()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let mut cmd = Command::new(path);
|
|
|
|
|
cmd.stdin(Stdio::piped())
|
2026-03-20 21:33:43 -07:00
|
|
|
.stdout(Stdio::piped())
|
2026-03-21 14:46:01 -07:00
|
|
|
.stderr(stderr_cfg);
|
|
|
|
|
|
|
|
|
|
// Hide the console window on Windows (CREATE_NO_WINDOW = 0x08000000)
|
|
|
|
|
#[cfg(target_os = "windows")]
|
|
|
|
|
cmd.creation_flags(0x08000000);
|
|
|
|
|
|
|
|
|
|
let child = cmd
|
2026-03-20 21:33:43 -07:00
|
|
|
.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)
|
2026-02-26 15:53:09 -08:00
|
|
|
.arg("-m")
|
|
|
|
|
.arg("voice_to_notes.main")
|
2026-03-20 21:33:43 -07:00
|
|
|
.current_dir(&python_dir)
|
|
|
|
|
.env("PYTHONPATH", &python_dir)
|
2026-02-26 15:53:09 -08:00
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
2026-02-26 16:50:14 -08:00
|
|
|
.stderr(Stdio::inherit())
|
2026-02-26 15:53:09 -08:00
|
|
|
.spawn()
|
2026-03-20 21:33:43 -07:00
|
|
|
.map_err(|e| format!("Failed to start Python sidecar: {e}"))?;
|
2026-02-26 15:53:09 -08:00
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
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> {
|
2026-02-26 16:50:14 -08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-02-26 15:53:09 -08:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wait for the sidecar to send its ready message.
|
|
|
|
|
fn wait_for_ready(&self) -> Result<(), String> {
|
2026-02-26 16:50:14 -08:00
|
|
|
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 {
|
2026-03-21 14:46:01 -07:00
|
|
|
// 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."
|
|
|
|
|
));
|
2026-02-26 16:50:14 -08:00
|
|
|
}
|
|
|
|
|
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(());
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 17:50:55 -08:00
|
|
|
// Non-JSON or non-ready line — skip and keep waiting
|
2026-03-20 13:56:32 -07:00
|
|
|
eprintln!(
|
|
|
|
|
"[sidecar-rs] Skipping pre-ready line: {}",
|
|
|
|
|
&trimmed[..trimmed.len().min(200)]
|
|
|
|
|
);
|
2026-02-26 17:50:55 -08:00
|
|
|
continue;
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err("Sidecar did not send ready message".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Send a message to the sidecar and read the response.
|
2026-02-26 17:14:25 -08:00
|
|
|
/// This is a blocking call. Progress messages are skipped.
|
2026-02-26 15:53:09 -08:00
|
|
|
pub fn send_and_receive(&self, msg: &IPCMessage) -> Result<IPCMessage, String> {
|
2026-02-26 17:14:25 -08:00
|
|
|
self.send_and_receive_with_progress(msg, |_| {})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 13:47:57 -07:00
|
|
|
/// Send a message and receive the response, calling a callback for intermediate messages.
|
|
|
|
|
/// Intermediate messages include progress, pipeline.segment, and pipeline.speaker_update.
|
2026-03-21 14:46:01 -07:00
|
|
|
///
|
|
|
|
|
/// If the sidecar has crashed (broken pipe), automatically restarts it and retries once.
|
2026-03-20 13:47:57 -07:00
|
|
|
pub fn send_and_receive_with_progress<F>(
|
|
|
|
|
&self,
|
|
|
|
|
msg: &IPCMessage,
|
|
|
|
|
on_intermediate: F,
|
|
|
|
|
) -> Result<IPCMessage, String>
|
2026-03-21 14:46:01 -07:00
|
|
|
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<F>(
|
|
|
|
|
&self,
|
|
|
|
|
msg: &IPCMessage,
|
|
|
|
|
on_intermediate: &F,
|
|
|
|
|
) -> Result<IPCMessage, String>
|
2026-03-20 13:47:57 -07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-20 13:56:32 -07:00
|
|
|
let response: IPCMessage =
|
|
|
|
|
serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?;
|
2026-03-20 13:47:57 -07:00
|
|
|
|
|
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 15:53:09 -08:00
|
|
|
/// Stop the sidecar process.
|
|
|
|
|
pub fn stop(&self) -> Result<(), String> {
|
2026-02-26 16:50:14 -08:00
|
|
|
// 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();
|
|
|
|
|
}
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-26 16:50:14 -08:00
|
|
|
Ok(())
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn is_running(&self) -> bool {
|
2026-03-21 14:46:01 -07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-26 15:53:09 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for SidecarManager {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = self.stop();
|
|
|
|
|
}
|
|
|
|
|
}
|