494 lines
15 KiB
Rust

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<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
r#type: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
payload: Option<Value>,
}
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<String>,
#[serde(default = "default_settings_tags")]
tags: Vec<String>,
#[serde(default = "default_fragment_types")]
fragment_types: Vec<String>,
#[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<String> {
DEFAULT_SETTINGS_TAGS
.iter()
.map(|v| (*v).to_string())
.collect()
}
fn default_fragment_types() -> Vec<String> {
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<String>, fallback: &[&str]) -> Vec<String> {
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>) -> 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<ChildStdout>,
}
impl ManagedSidecar {
fn start(root: &Path) -> Result<Self, String> {
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<String, String> {
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<Option<ManagedSidecar>>,
root_override: Mutex<Option<PathBuf>>,
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<PathBuf, String> {
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<PathBuf>) -> Result<PathBuf, String> {
if let Some(root) = root_override {
return Ok(root.clone());
}
auto_detect_root()
}
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
#[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<PathBuf> {
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<String, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
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<String>,
fragment_types: Vec<String>,
default_startup_view: Option<String>,
) -> Result<Value, String> {
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<Value, String> {
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::<Value>(&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::<SidecarState>();
if let Ok(mut guard) = state.process.try_lock() {
guard.take();
};
}
});
}