494 lines
15 KiB
Rust
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();
|
|
};
|
|
}
|
|
});
|
|
}
|