use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashSet; 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, } const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"]; const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"]; const DEFAULT_STARTUP_VIEW: &str = "entries"; #[derive(Deserialize, Serialize)] struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] sidecar_root: Option, #[serde(default = "default_settings_tags")] tags: Vec, #[serde(default = "default_fragment_types")] fragment_types: Vec, #[serde(default = "default_startup_view")] default_startup_view: String, } impl Default for AppSettings { fn default() -> Self { Self { sidecar_root: None, tags: default_settings_tags(), fragment_types: default_fragment_types(), default_startup_view: default_startup_view(), } } } fn default_settings_tags() -> Vec { DEFAULT_SETTINGS_TAGS .iter() .map(|v| (*v).to_string()) .collect() } fn default_fragment_types() -> Vec { DEFAULT_FRAGMENT_TYPES .iter() .map(|v| (*v).to_string()) .collect() } fn default_startup_view() -> String { DEFAULT_STARTUP_VIEW.to_string() } fn normalize_items(values: Vec, fallback: &[&str]) -> Vec { let mut seen = HashSet::new(); let mut normalized = Vec::new(); for item in values { let trimmed = item.trim(); if trimmed.is_empty() { continue; } let key = trimmed.to_lowercase(); if seen.insert(key) { normalized.push(trimmed.to_string()); } } if normalized.is_empty() { return fallback.iter().map(|v| (*v).to_string()).collect(); } normalized } fn normalize_startup_view(value: Option) -> String { let normalized = value .unwrap_or_else(default_startup_view) .trim() .to_lowercase(); match normalized.as_str() { "entries" | "calendar" | "fragments" | "todos" | "lists" => normalized, _ => default_startup_view(), } } 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 { #[cfg(windows)] let exe_name = "Journal.Sidecar.exe"; #[cfg(not(windows))] let exe_name = "Journal.Sidecar"; // 1. If root is explicitly pointing to the executable file itself: if root.is_file() && root.file_name().and_then(|n| n.to_str()) == Some(exe_name) { return Ok(root.to_path_buf()); } // 2. Direct paths relative to the folder: let direct_paths = [ root.join(exe_name), root.join("output").join(exe_name), root.join("publish").join(exe_name), root.join("Journal.Sidecar/bin/Debug/net10.0").join(exe_name), root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish").join(exe_name), ]; for path in direct_paths { if path.exists() { return Ok(path); } } // 3. Fallback: recursively search the known sidecar folder let sidecar_src_root = root.join("Journal.Sidecar"); if let Some(path) = find_sidecar_executable(&sidecar_src_root, exe_name) { return Ok(path); } // 4. Fallback: recursively search the provided root itself if let Some(path) = find_sidecar_executable(root, exe_name) { return Ok(path); } Err(format!("{exe_name} not found in {}. Build Journal.Sidecar first.", root.display())) } fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option { if !search_root.is_dir() { return None; } let mut stack = vec![search_root.to_path_buf()]; while let Some(dir) = stack.pop() { let Ok(entries) = fs::read_dir(&dir) else { continue; }; for entry in entries { let Ok(entry) = entry else { continue; }; let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if name == "node_modules" || name == ".git" || name == ".vs" { continue; } } stack.push(path); continue; } let is_sidecar_exe = path .file_name() .and_then(|name| name.to_str()) .map(|name| name.eq_ignore_ascii_case(exe_name)) .unwrap_or(false); if is_sidecar_exe { return Some(path); } } } None } 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(); let mut settings = load_settings(&state.config_path); settings.sidecar_root = new_override.map(|p| p.to_string_lossy().into_owned()); save_settings(&state.config_path, &settings)?; Ok(serde_json::json!({ "root": root.to_string_lossy(), "isCustom": is_custom })) } #[tauri::command] async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result { let settings = load_settings(&state.config_path); let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS); let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES); let startup_view = normalize_startup_view(Some(settings.default_startup_view)); Ok(serde_json::json!({ "tags": tags, "fragmentTypes": fragment_types, "defaultStartupView": startup_view })) } #[tauri::command] async fn set_ui_settings( state: tauri::State<'_, SidecarState>, tags: Vec, fragment_types: Vec, default_startup_view: Option, ) -> Result { let mut settings = load_settings(&state.config_path); settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS); settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES); settings.default_startup_view = normalize_startup_view(default_startup_view); save_settings(&state.config_path, &settings)?; Ok(serde_json::json!({ "tags": settings.tags, "fragmentTypes": settings.fragment_types, "defaultStartupView": settings.default_startup_view })) } #[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_dialog::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ sidecar_command, shutdown, get_sidecar_root, set_sidecar_root, get_ui_settings, set_ui_settings, ]) .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(); }; } }); }