From e2bfa0e6ff7a94e5e16d8aad6109fea85dea6b24 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Wed, 25 Feb 2026 22:02:49 -0600 Subject: [PATCH] fix: sidecar project root resolution and Tauri frontend wiring - Set JOURNAL_PROJECT_ROOT and CWD when spawning sidecar so data lands at repo root instead of under src-tauri/ (fixes dev watcher hot-reload loop triggered by schema file writes) - Add backend client layer (auth.ts, client.ts, fragments.ts, types.ts) - Wire vault unlock flow with password prompt modal on locked DB - Update fragments store with full sidecar CRUD integration - Update EditorPanel and AppModal for fragment editing support - Gitignore runtime journal/ and logs/ directories Co-Authored-By: Oz --- .gitignore | 4 + Journal.App/src-tauri/src/lib.rs | 238 +++++++++++++++++- Journal.App/src/lib/backend/auth.ts | 9 + Journal.App/src/lib/backend/client.ts | 36 +++ Journal.App/src/lib/backend/fragments.ts | 85 +++++++ Journal.App/src/lib/backend/types.ts | 13 + .../src/lib/components/AppModal.svelte | 43 ++++ .../src/lib/components/EditorPanel.svelte | 118 +++++---- Journal.App/src/lib/stores/fragments.ts | 161 +++++++++++- Journal.App/src/routes/+page.svelte | 126 +++++++++- 10 files changed, 766 insertions(+), 67 deletions(-) create mode 100644 Journal.App/src/lib/backend/auth.ts create mode 100644 Journal.App/src/lib/backend/client.ts create mode 100644 Journal.App/src/lib/backend/fragments.ts create mode 100644 Journal.App/src/lib/backend/types.ts diff --git a/.gitignore b/.gitignore index 990d0ac..39886a3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,9 @@ secrets.json Thumbs.db desktop.ini +# Runtime journal data (created by sidecar at repo root) +journal/ +logs/ + # macOS .DS_Store diff --git a/Journal.App/src-tauri/src/lib.rs b/Journal.App/src-tauri/src/lib.rs index 4a277ef..c62fe2a 100644 --- a/Journal.App/src-tauri/src/lib.rs +++ b/Journal.App/src-tauri/src/lib.rs @@ -1,14 +1,238 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::env; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Mutex; +use tauri::Manager; + +#[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, +} + +struct ManagedSidecar { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl ManagedSidecar { + fn start() -> Result { + let sidecar_path = resolve_sidecar_path()?; + let root = project_root()?; + eprintln!("[sidecar] starting exe={} project_root={}", sidecar_path.display(), root.display()); + + let mut child = Command::new(sidecar_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .current_dir(&root) + .env("JOURNAL_PROJECT_ROOT", &root) + .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(status)) => { + eprintln!("[sidecar] exited status={status}"); + false + } + Err(err) => { + eprintln!("[sidecar] try_wait_error={err}"); + false + } + } + } + + fn send_command_line(&mut self, input_line: &str) -> Result { + self.stdin + .write_all(format!("{input_line}\n").as_bytes()) + .map_err(|err| format!("Failed writing to sidecar stdin: {err}"))?; + self.stdin + .flush() + .map_err(|err| format!("Failed flushing sidecar stdin: {err}"))?; + + let mut response_line = String::new(); + let read = self + .stdout + .read_line(&mut response_line) + .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) { + if let Ok(None) = self.child.try_wait() { + if let Err(err) = self.child.kill() { + eprintln!("[sidecar] kill_on_drop_error={err}"); + return; + } + if let Err(err) = self.child.wait() { + eprintln!("[sidecar] wait_on_drop_error={err}"); + } else { + eprintln!("[sidecar] stopped_on_drop"); + } + } + } +} + +#[derive(Default)] +struct SidecarState { + process: Mutex>, +} + +fn project_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 resolve_sidecar_path() -> Result { + let root = project_root()?; + 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()) +} + +fn send_with_managed_sidecar(state: &SidecarState, input_line: &str) -> Result { + let mut guard = state + .process + .lock() + .map_err(|_| "Failed to lock sidecar state.".to_string())?; + + 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()?); + } + + let Some(process) = guard.as_mut() else { + return Err("Sidecar process unavailable.".to_string()); + }; + + match process.send_command_line(input_line) { + Ok(line) => return Ok(line), + Err(err) => { + eprintln!("[sidecar] send_error attempt={attempt} error={err}"); + *guard = None; + if attempt == 2 { + return Err(err); + } + } + } + } + + Err("Failed to send command to sidecar.".to_string()) +} + +fn stop_managed_sidecar(state: &SidecarState) { + let Ok(mut guard) = state.process.lock() else { + eprintln!("[sidecar] stop_error=failed_to_lock_state"); + return; + }; + + if guard.take().is_some() { + eprintln!("[sidecar] stop_requested"); + } +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn sidecar_command( + state: tauri::State<'_, SidecarState>, + command: CommandEnvelope, +) -> Result { + if command.action.trim().is_empty() { + return Err("Missing action".to_string()); + } + + eprintln!( + "[sidecar_command] action={} correlationId={:?} id={:?}", + command.action, command.correlation_id, command.id + ); + + 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)?; + + eprintln!("[sidecar_command] response={response_line}"); + 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() { - tauri::Builder::default() + let app = tauri::Builder::default() + .manage(SidecarState::default()) .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .invoke_handler(tauri::generate_handler![sidecar_command]) + .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::(); + stop_managed_sidecar(state.inner()); + } + }); } diff --git a/Journal.App/src/lib/backend/auth.ts b/Journal.App/src/lib/backend/auth.ts new file mode 100644 index 0000000..8f8f20c --- /dev/null +++ b/Journal.App/src/lib/backend/auth.ts @@ -0,0 +1,9 @@ +import { sendCommand } from "./client"; + +export function hydrateWorkspace(password: string): Promise { + return sendCommand({ + action: "db.hydrate_workspace", + payload: { password } + }); +} + diff --git a/Journal.App/src/lib/backend/client.ts b/Journal.App/src/lib/backend/client.ts new file mode 100644 index 0000000..7254252 --- /dev/null +++ b/Journal.App/src/lib/backend/client.ts @@ -0,0 +1,36 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { BackendCommand, BackendResponse } from "./types"; + +function newCorrelationId(): string { + return `ui-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export async function sendCommand(command: BackendCommand): Promise { + const envelope: BackendCommand = { + ...command, + correlationId: command.correlationId ?? newCorrelationId() + }; + console.info("[backend] send", { + action: envelope.action, + correlationId: envelope.correlationId, + id: envelope.id + }); + const response = await invoke>("sidecar_command", { command: envelope }); + + if (!response.ok) { + console.error("[backend] error", { + action: envelope.action, + correlationId: envelope.correlationId, + id: envelope.id, + error: response.error + }); + throw new Error(response.error || "Backend command failed"); + } + + console.info("[backend] ok", { + action: envelope.action, + correlationId: envelope.correlationId, + id: envelope.id + }); + return response.data; +} diff --git a/Journal.App/src/lib/backend/fragments.ts b/Journal.App/src/lib/backend/fragments.ts new file mode 100644 index 0000000..16c4a9f --- /dev/null +++ b/Journal.App/src/lib/backend/fragments.ts @@ -0,0 +1,85 @@ +import { sendCommand } from "./client"; + +export type FragmentDto = { + id: string; + type: string; + description: string; + time: string; + tags: string[]; +}; + +export type CreateFragmentPayload = { + type: string; + description: string; + tags?: string[]; +}; + +export type UpdateFragmentPayload = { + type?: string; + description?: string; + tags?: string[]; + time?: string; +}; + +type FragmentDtoRaw = { + id?: string; + type?: string; + description?: string; + time?: string; + tags?: string[]; + Id?: string; + Type?: string; + Description?: string; + Time?: string; + Tags?: string[]; +}; + +function normalizeFragment(raw: FragmentDtoRaw): FragmentDto { + return { + id: raw.id ?? raw.Id ?? "", + type: raw.type ?? raw.Type ?? "", + description: raw.description ?? raw.Description ?? "", + time: raw.time ?? raw.Time ?? "", + tags: raw.tags ?? raw.Tags ?? [] + }; +} + +export async function listFragments(): Promise { + const data = await sendCommand({ + action: "fragments.list" + }); + return data.map(normalizeFragment).filter((item) => Boolean(item.id)); +} + +export async function getFragment(id: string): Promise { + const data = await sendCommand({ + action: "fragments.get", + id + }); + if (!data) return null; + const normalized = normalizeFragment(data); + return normalized.id ? normalized : null; +} + +export async function createFragment(payload: CreateFragmentPayload): Promise { + const data = await sendCommand({ + action: "fragments.create", + payload + }); + return normalizeFragment(data); +} + +export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise { + return sendCommand({ + action: "fragments.update", + id, + payload + }); +} + +export function deleteFragment(id: string): Promise { + return sendCommand({ + action: "fragments.delete", + id + }); +} diff --git a/Journal.App/src/lib/backend/types.ts b/Journal.App/src/lib/backend/types.ts new file mode 100644 index 0000000..87644cf --- /dev/null +++ b/Journal.App/src/lib/backend/types.ts @@ -0,0 +1,13 @@ +export type BackendCommand = { + action: string; + correlationId?: string; + id?: string; + type?: string; + tag?: string; + payload?: unknown; +}; + +export type BackendOk = { ok: true; data: T }; +export type BackendErr = { ok: false; error: string }; +export type BackendResponse = BackendOk | BackendErr; + diff --git a/Journal.App/src/lib/components/AppModal.svelte b/Journal.App/src/lib/components/AppModal.svelte index 148dada..2ced0ef 100644 --- a/Journal.App/src/lib/components/AppModal.svelte +++ b/Journal.App/src/lib/components/AppModal.svelte @@ -1,4 +1,6 @@ @@ -35,6 +53,17 @@ + {#if inputEnabled} + + {/if}