use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Stdio; use tauri::Manager; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::Mutex; #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct CommandEnvelope { action: String, #[serde(default)] correlation_id: Option, #[serde(default)] id: Option, #[serde(default)] r#type: Option, #[serde(default)] tag: Option, #[serde(default)] payload: Option, } #[derive(Deserialize, Serialize, Default)] struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] sidecar_root: Option, } struct ManagedSidecar { child: Child, stdin: ChildStdin, stdout: BufReader, } impl ManagedSidecar { fn start(root: &Path) -> Result { let sidecar_path = resolve_sidecar_path(root)?; let mut cmd = Command::new(sidecar_path); cmd.stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .current_dir(root) .env("JOURNAL_PROJECT_ROOT", root) .kill_on_drop(true); #[cfg(windows)] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW let mut child = cmd .spawn() .map_err(|err| format!("Failed to start sidecar process: {err}"))?; let stdin = child .stdin .take() .ok_or_else(|| "Unable to open sidecar stdin.".to_string())?; let stdout = child .stdout .take() .ok_or_else(|| "Unable to open sidecar stdout.".to_string())?; Ok(Self { child, stdin, stdout: BufReader::new(stdout), }) } fn is_running(&mut self) -> bool { match self.child.try_wait() { Ok(None) => true, Ok(Some(_)) => false, Err(_) => false, } } async fn send_command_line(&mut self, input_line: &str) -> Result { self.stdin .write_all(format!("{input_line}\n").as_bytes()) .await .map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?; self.stdin .flush() .await .map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?; let mut response_line = String::new(); let read = self .stdout .read_line(&mut response_line) .await .map_err(|err| format!("Failed reading sidecar stdout: {err}"))?; if read == 0 { return Err("Sidecar stdout closed unexpectedly.".to_string()); } let trimmed = response_line.trim().to_string(); if trimmed.is_empty() { return Err("Sidecar returned an empty response line.".to_string()); } Ok(trimmed) } } impl Drop for ManagedSidecar { fn drop(&mut self) {} } struct SidecarState { process: Mutex>, root_override: Mutex>, config_path: PathBuf, } fn load_settings(path: &Path) -> AppSettings { fs::read_to_string(path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> { let json = serde_json::to_string_pretty(settings) .map_err(|e| format!("Failed to serialize settings: {e}"))?; fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}")) } fn auto_detect_root() -> Result { let mut current = env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?; loop { if current.join("Journal.Sidecar").exists() { return Ok(current); } if !current.pop() { return Err("Unable to locate repository root containing Journal.Sidecar.".to_string()); } } } fn effective_root(root_override: &Option) -> Result { if let Some(root) = root_override { return Ok(root.clone()); } auto_detect_root() } fn resolve_sidecar_path(root: &Path) -> Result { let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe"); if debug_path.exists() { return Ok(debug_path); } let release_path = root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe"); if release_path.exists() { return Ok(release_path); } Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string()) } async fn send_with_managed_sidecar( state: &SidecarState, input_line: &str, ) -> Result { let root = { let root_override = state.root_override.lock().await; effective_root(&root_override)? }; let mut guard = state.process.lock().await; for attempt in 1..=2 { let should_start = match guard.as_mut() { Some(existing) => !existing.is_running(), None => true, }; if should_start { *guard = Some(ManagedSidecar::start(&root)?); } let Some(process) = guard.as_mut() else { return Err("Sidecar process unavailable.".to_string()); }; match process.send_command_line(input_line).await { Ok(line) => return Ok(line), Err(err) => { *guard = None; if attempt == 2 { return Err(err); } } } } Err("Failed to send command to sidecar.".to_string()) } async fn stop_managed_sidecar(state: &SidecarState) { let mut guard = state.process.lock().await; guard.take(); } #[tauri::command] async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result { let root_override = state.root_override.lock().await.clone(); let root = effective_root(&root_override)?; Ok(serde_json::json!({ "root": root.to_string_lossy(), "isCustom": root_override.is_some() })) } #[tauri::command] async fn set_sidecar_root( state: tauri::State<'_, SidecarState>, path: String, ) -> Result { let (new_override, root) = if path.trim().is_empty() { let detected = auto_detect_root()?; (None, detected) } else { let new_root = PathBuf::from(&path); if !new_root.exists() { return Err(format!( "Directory '{}' does not exist.", new_root.display() )); } resolve_sidecar_path(&new_root)?; (Some(new_root.clone()), new_root) }; // Stop the current sidecar so it restarts with new root { let mut guard = state.process.lock().await; guard.take(); } let is_custom = new_override.is_some(); *state.root_override.lock().await = new_override.clone(); save_settings( &state.config_path, &AppSettings { sidecar_root: new_override.map(|p| p.to_string_lossy().into_owned()), }, )?; Ok(serde_json::json!({ "root": root.to_string_lossy(), "isCustom": is_custom })) } #[tauri::command] async fn shutdown( state: tauri::State<'_, SidecarState>, app_handle: tauri::AppHandle, ) -> Result<(), String> { stop_managed_sidecar(state.inner()).await; app_handle.exit(0); Ok(()) } #[tauri::command] async fn sidecar_command( state: tauri::State<'_, SidecarState>, command: CommandEnvelope, ) -> Result { if command.action.trim().is_empty() { return Err("Missing action".to_string()); } let input_line = serde_json::to_string(&command) .map_err(|err| format!("Serialize command failed: {err}"))?; let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?; serde_json::from_str::(&response_line) .map_err(|err| format!("Invalid sidecar JSON response: {err}")) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ sidecar_command, shutdown, get_sidecar_root, set_sidecar_root, ]) .setup(|app| { let config_dir = app.path().app_config_dir()?; fs::create_dir_all(&config_dir).ok(); let config_path = config_dir.join("settings.json"); let settings = load_settings(&config_path); let root_override = settings.sidecar_root.map(PathBuf::from); app.manage(SidecarState { process: Mutex::new(None), root_override: Mutex::new(root_override), config_path, }); Ok(()) }) .build(tauri::generate_context!()) .expect("error while building tauri application"); app.run(|app_handle, event| { if let tauri::RunEvent::ExitRequested { .. } = event { let state = app_handle.state::(); if let Ok(mut guard) = state.process.try_lock() { guard.take(); }; } }); }