From d00281f0c7b3f7f78cb3ce2b1791d1ffcbe3aa6a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 26 Feb 2026 16:50:14 -0800 Subject: [PATCH] Fix critical integration issues for end-to-end functionality - Rewrite SidecarManager as singleton with OnceLock, reusing one Python process across all commands instead of spawning per call - Separate stdin/stdout ownership with dedicated BufReader to prevent data corruption between wait_for_ready and send_and_receive - Add ensure_running() for auto-start on first command - Fix asset protocol URL: use convertFileSrc() instead of manual encodeURIComponent which broke file paths with slashes - Add +layout.svelte with global dark theme, CSS reset, and custom scrollbar styling to prevent white flash on startup - Register AppState with Tauri .manage(), initialize SQLite database on app startup at ~/.voicetonotes/voice_to_notes.db - Wire project commands (create/get/list) to real database queries instead of placeholder stubs Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/ai.rs | 42 ++++---- src-tauri/src/commands/export.rs | 14 +-- src-tauri/src/commands/project.rs | 30 +++--- src-tauri/src/commands/transcribe.rs | 30 +----- src-tauri/src/lib.rs | 4 + src-tauri/src/sidecar/mod.rs | 153 ++++++++++++++++++--------- src-tauri/src/state.rs | 21 +++- src/routes/+layout.svelte | 39 +++++++ src/routes/+page.svelte | 6 +- 9 files changed, 205 insertions(+), 134 deletions(-) create mode 100644 src/routes/+layout.svelte diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index b474702..99e699e 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -1,19 +1,7 @@ use serde_json::{json, Value}; use crate::sidecar::messages::IPCMessage; -use crate::sidecar::SidecarManager; - -fn get_sidecar() -> Result { - let python_path = std::env::current_dir() - .map_err(|e| e.to_string())? - .join("../python") - .canonicalize() - .map_err(|e| format!("Cannot find python directory: {e}"))?; - - let manager = SidecarManager::new(); - manager.start(&python_path.to_string_lossy())?; - Ok(manager) -} +use crate::sidecar::sidecar; /// Send a chat message to the AI provider via the Python sidecar. #[tauri::command] @@ -22,14 +10,8 @@ pub fn ai_chat( transcript_context: Option, provider: Option, ) -> Result { - let manager = get_sidecar()?; - - let request_id = uuid::Uuid::new_v4().to_string(); - let payload = json!({ - "action": "chat", - "messages": messages, - "transcript_context": transcript_context.unwrap_or_default(), - }); + let manager = sidecar(); + manager.ensure_running()?; // If a specific provider is requested, set it first if let Some(p) = provider { @@ -41,7 +23,17 @@ pub fn ai_chat( let _ = manager.send_and_receive(&set_msg)?; } - let msg = IPCMessage::new(&request_id, "ai.chat", payload); + let request_id = uuid::Uuid::new_v4().to_string(); + let msg = IPCMessage::new( + &request_id, + "ai.chat", + json!({ + "action": "chat", + "messages": messages, + "transcript_context": transcript_context.unwrap_or_default(), + }), + ); + let response = manager.send_and_receive(&msg)?; if response.msg_type == "error" { @@ -57,7 +49,8 @@ pub fn ai_chat( /// List available AI providers. #[tauri::command] pub fn ai_list_providers() -> Result { - let manager = get_sidecar()?; + let manager = sidecar(); + manager.ensure_running()?; let request_id = uuid::Uuid::new_v4().to_string(); let msg = IPCMessage::new( @@ -73,7 +66,8 @@ pub fn ai_list_providers() -> Result { /// Configure an AI provider with API key/settings. #[tauri::command] pub fn ai_configure(provider: String, config: Value) -> Result { - let manager = get_sidecar()?; + let manager = sidecar(); + manager.ensure_running()?; let request_id = uuid::Uuid::new_v4().to_string(); let msg = IPCMessage::new( diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index 4b9bf35..fb91026 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -1,7 +1,7 @@ use serde_json::{json, Value}; use crate::sidecar::messages::IPCMessage; -use crate::sidecar::SidecarManager; +use crate::sidecar::sidecar; /// Export transcript to caption/text format via the Python sidecar. #[tauri::command] @@ -12,16 +12,8 @@ pub fn export_transcript( output_path: String, title: Option, ) -> Result { - let python_path = std::env::current_dir() - .map_err(|e| e.to_string())? - .join("../python") - .canonicalize() - .map_err(|e| format!("Cannot find python directory: {e}"))?; - - let python_path_str = python_path.to_string_lossy().to_string(); - - let manager = SidecarManager::new(); - manager.start(&python_path_str)?; + let manager = sidecar(); + manager.ensure_running()?; let request_id = uuid::Uuid::new_v4().to_string(); let msg = IPCMessage::new( diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index b4076f9..208ffd2 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -1,27 +1,23 @@ +use tauri::State; + use crate::db::models::Project; +use crate::db::queries; +use crate::state::AppState; #[tauri::command] -pub fn create_project(name: String) -> Result { - // TODO: Use actual database connection from app state - Ok(Project { - id: uuid::Uuid::new_v4().to_string(), - name, - created_at: chrono::Utc::now().to_rfc3339(), - updated_at: chrono::Utc::now().to_rfc3339(), - settings: None, - status: "active".to_string(), - }) +pub fn create_project(name: String, state: State) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + queries::create_project(&conn, &name).map_err(|e| e.to_string()) } #[tauri::command] -pub fn get_project(id: String) -> Result, String> { - // TODO: Use actual database connection from app state - let _ = id; - Ok(None) +pub fn get_project(id: String, state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + queries::get_project(&conn, &id).map_err(|e| e.to_string()) } #[tauri::command] -pub fn list_projects() -> Result, String> { - // TODO: Use actual database connection from app state - Ok(vec![]) +pub fn list_projects(state: State) -> Result, String> { + let conn = state.db.lock().map_err(|e| e.to_string())?; + queries::list_projects(&conn).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/transcribe.rs b/src-tauri/src/commands/transcribe.rs index 5b90b25..9e2239a 100644 --- a/src-tauri/src/commands/transcribe.rs +++ b/src-tauri/src/commands/transcribe.rs @@ -1,12 +1,9 @@ use serde_json::{json, Value}; use crate::sidecar::messages::IPCMessage; -use crate::sidecar::SidecarManager; +use crate::sidecar::sidecar; /// Start transcription of an audio file via the Python sidecar. -/// -/// This is a blocking command — it starts the sidecar if needed, -/// sends the transcribe request, and waits for the result. #[tauri::command] pub fn transcribe_file( file_path: String, @@ -14,17 +11,8 @@ pub fn transcribe_file( device: Option, language: Option, ) -> Result { - // Determine Python sidecar path (relative to app) - let python_path = std::env::current_dir() - .map_err(|e| e.to_string())? - .join("../python") - .canonicalize() - .map_err(|e| format!("Cannot find python directory: {e}"))?; - - let python_path_str = python_path.to_string_lossy().to_string(); - - let manager = SidecarManager::new(); - manager.start(&python_path_str)?; + let manager = sidecar(); + manager.ensure_running()?; let request_id = uuid::Uuid::new_v4().to_string(); let msg = IPCMessage::new( @@ -63,16 +51,8 @@ pub fn run_pipeline( max_speakers: Option, skip_diarization: Option, ) -> Result { - let python_path = std::env::current_dir() - .map_err(|e| e.to_string())? - .join("../python") - .canonicalize() - .map_err(|e| format!("Cannot find python directory: {e}"))?; - - let python_path_str = python_path.to_string_lossy().to_string(); - - let manager = SidecarManager::new(); - manager.start(&python_path_str)?; + let manager = sidecar(); + manager.ensure_running()?; let request_id = uuid::Uuid::new_v4().to_string(); let msg = IPCMessage::new( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e26668f..ff523ae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,12 +10,16 @@ use commands::project::{create_project, get_project, list_projects}; use commands::settings::{load_settings, save_settings}; use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop}; use commands::transcribe::{run_pipeline, transcribe_file}; +use state::AppState; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let app_state = AppState::new().expect("Failed to initialize app state"); + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .manage(app_state) .invoke_handler(tauri::generate_handler![ create_project, get_project, diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index 1e1741f..dd60840 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -2,38 +2,82 @@ pub mod ipc; pub mod messages; use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; +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 Python sidecar process lifecycle. +/// Uses separated stdin/stdout ownership to avoid BufReader conflicts. 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), } } + /// Ensure the sidecar is running, starting it if needed. + pub fn ensure_running(&self) -> Result<(), String> { + if self.is_running() { + return Ok(()); + } + + let python_path = std::env::current_dir() + .map_err(|e| e.to_string())? + .join("../python") + .canonicalize() + .map_err(|e| format!("Cannot find python directory: {e}"))?; + + self.start(&python_path.to_string_lossy()) + } + /// Spawn the Python sidecar process. pub fn start(&self, python_path: &str) -> Result<(), String> { - let child = Command::new("python3") + // Stop existing process if any + self.stop().ok(); + + let mut child = Command::new("python3") .arg("-m") .arg("voice_to_notes.main") .current_dir(python_path) .env("PYTHONPATH", python_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) // Let sidecar logs go to parent's stderr + .stderr(Stdio::inherit()) .spawn() .map_err(|e| format!("Failed to start sidecar: {e}"))?; - let mut proc = self.process.lock().map_err(|e| e.to_string())?; - *proc = Some(child); + // Take ownership of stdin and stdout separately + 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); + } // Wait for the "ready" message self.wait_for_ready()?; @@ -43,23 +87,28 @@ impl SidecarManager { /// Wait for the sidecar to send its ready message. fn wait_for_ready(&self) -> Result<(), String> { - let mut proc = self.process.lock().map_err(|e| e.to_string())?; - if let Some(ref mut child) = *proc { - if let Some(ref mut stdout) = child.stdout { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let line = line.map_err(|e| format!("Read error: {e}"))?; - if line.is_empty() { - continue; - } - if let Ok(msg) = serde_json::from_str::(&line) { - if msg.msg_type == "ready" { - return Ok(()); - } - } - // If we got a non-ready message, something's wrong but don't block forever - break; + 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-ready message: something is wrong + break; } } Err("Sidecar did not send ready message".to_string()) @@ -68,10 +117,10 @@ impl SidecarManager { /// Send a message to the sidecar and read the response. /// This is a blocking call. pub fn send_and_receive(&self, msg: &IPCMessage) -> Result { - let mut proc = self.process.lock().map_err(|e| e.to_string())?; - if let Some(ref mut child) = *proc { - // Write message to stdin - if let Some(ref mut stdin) = child.stdin { + // 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()) @@ -83,12 +132,13 @@ impl SidecarManager { } else { return Err("Sidecar stdin not available".to_string()); } + } - // Read response from stdout - if let Some(ref mut stdout) = child.stdout { - let mut reader = BufReader::new(stdout); + // 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(); - // Read lines until we get a response (skip progress messages, collect them) loop { line.clear(); let bytes_read = reader @@ -101,40 +151,45 @@ impl SidecarManager { if trimmed.is_empty() { continue; } - let response: IPCMessage = - serde_json::from_str(trimmed).map_err(|e| format!("Parse error: {e}"))?; + let response: IPCMessage = serde_json::from_str(trimmed) + .map_err(|e| format!("Parse error: {e}"))?; - // If it's a progress message, we could emit it as an event - // For now, skip progress and return the final result/error + // Skip progress messages, return the final result/error if response.msg_type != "progress" { return Ok(response); } } } else { - return Err("Sidecar stdout not available".to_string()); + Err("Sidecar stdout not available".to_string()) } - } else { - Err("Sidecar not running".to_string()) } } /// Stop the sidecar process. pub fn stop(&self) -> Result<(), String> { - let mut proc = self.process.lock().map_err(|e| e.to_string())?; - if let Some(ref mut child) = proc.take() { - // Close stdin to signal EOF - drop(child.stdin.take()); - // Wait briefly for clean exit, then kill - match child.wait() { - Ok(_) => Ok(()), - Err(e) => { - let _ = child.kill(); - Err(format!("Sidecar did not exit cleanly: {e}")) + // 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(); + } } } - } else { - Ok(()) } + Ok(()) } pub fn is_running(&self) -> bool { diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 81c6539..2ad86db 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,17 +3,28 @@ use std::sync::Mutex; use rusqlite::Connection; +use crate::db; +use crate::llama::LlamaManager; + /// Shared application state managed by Tauri. pub struct AppState { - pub db: Mutex>, + pub db: Mutex, pub data_dir: PathBuf, } impl AppState { - pub fn new(data_dir: PathBuf) -> Self { - Self { - db: Mutex::new(None), + pub fn new() -> Result { + let data_dir = LlamaManager::data_dir(); + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Cannot create data dir: {e}"))?; + + let db_path = data_dir.join("voice_to_notes.db"); + let conn = db::open_database(&db_path) + .map_err(|e| format!("Cannot open database: {e}"))?; + + Ok(Self { + db: Mutex::new(conn), data_dir, - } + }) } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..a655c08 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,39 @@ + + +{@render children()} + + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8e7d5a4..9b139a0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,5 @@