Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pnpm desktop:build

Packaged builds on `main` CI upload installers as Actions artifacts (`desktop-windows-x86_64`, `desktop-macos-aarch64`, `desktop-linux-x86_64`). Details: [`apps/desktop/README.md`](./apps/desktop/README.md).

The desktop shell spawns a bundled Node API sidecar, injects `window.__PRM_API_BASE_URL__` + launch token into the webview, and stores vault data under the OS app-data directory (not a CI build path). If unlock hangs, check `desktop-api.log` next to the vault.

### Cursor MCP (bulk load)

`@prm/mcp` unlocks the vault and writes dossier evidence. Pair it with Jira, Bitbucket, or other MCPs when you need a cold-start backfill instead of typing every note by hand.
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,10 @@ export async function seedWorkspace(opts: {
};
}

if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("seed.ts")) {
// Only when running `tsx src/seed.ts` directly — never when bundled into the desktop
// sidecar entry (`prm-api.mjs`), where import.meta.url === argv[1] would also match.
const entry = process.argv[1] ?? "";
if (/(^|[/\\])seed\.(ts|js|mjs)$/.test(entry)) {
seedWorkspace()
.then((r) => {
console.log("Seeded workspace:", r);
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ pnpm desktop:dev

1. Writes a shared `PRM_LAUNCH_TOKEN` under `$PRM_DATA_DIR/.launch_token` (default `data/`)
2. Starts API + UI with that env so the API requires `x-prm-launch-token`
3. The Tauri process reads the same token, injects `window.__PRM_LAUNCH_TOKEN__`, and exposes `get_launch_config` IPC
3. The Tauri process reads the same token, injects `window.__PRM_LAUNCH_TOKEN__` and `window.__PRM_API_BASE_URL__`, and exposes `get_launch_config` IPC

Browser-only `pnpm dev` does **not** set the launch token (middleware stays off).
Browser-only `pnpm dev` does **not** set the launch token (middleware stays off). The UI uses relative `/api` under Vite (proxied). Packaged builds must use the injected absolute API base URL — relative `/api` has no proxy in the Tauri webview.

Optional: set `PRM_SPAWN_API=1` so the shell also starts the API via `pnpm exec tsx` (useful when not using `desktop-dev.sh`).

Expand All @@ -38,7 +38,9 @@ pnpm desktop:build
1. Bundles `@prm/api` into `src-tauri/sidecars/prm-api.mjs` (esbuild) and copies a portable **Node** binary + `better-sqlite3` next to it
2. Builds the UI into `apps/ui/dist` (Tauri `frontendDist` is `../../ui/dist` from `src-tauri`)

At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, and kills the child on exit — one process from the user’s point of view.
At runtime the shell resolves `sidecars/` from the app resource dir (or `PRM_API_SIDECAR_DIR`), spawns bundled Node with the API entry, sets `PRM_LAUNCH_TOKEN` / `PRM_DATA_DIR`, waits until `/api/health` responds, and kills the child on exit — one process from the user’s point of view.

Packaged data lives in the OS app-data directory (for example `%APPDATA%\com.prm.workbench` on Windows). Sidecar stdout/stderr is appended to `desktop-api.log` there for debugging startup failures.

CI builds the same matrix (Ubuntu / macOS / Windows) on pushes to `main`. Each green desktop job uploads an Actions artifact (retained 30 days):

Expand Down
197 changes: 166 additions & 31 deletions apps/desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,20 @@

use std::{
fs,
io::Read,
io::{Read, Write},
net::TcpStream,
path::{Path, PathBuf},
process::{Child, Command, Stdio},
sync::Mutex,
thread,
time::{Duration, Instant},
};

use serde::Serialize;
use tauri::{Manager, State, WebviewUrl, WebviewWindowBuilder};
use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder};

const DEFAULT_API_HOST: &str = "127.0.0.1";
const DEFAULT_API_PORT: u16 = 8787;
const DEFAULT_API_BASE: &str = "http://127.0.0.1:8787";
const DEV_UI_URL: &str = "http://localhost:5173";

Expand All @@ -33,11 +38,32 @@ fn get_launch_config(state: State<'_, LaunchState>) -> Result<LaunchConfig, Stri
.map_err(|_| "launch config lock poisoned".into())
}

fn data_dir() -> PathBuf {
fn api_port() -> u16 {
std::env::var("PRM_PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_API_PORT)
}

fn api_base_url() -> String {
std::env::var("PRM_API_BASE_URL").unwrap_or_else(|_| {
format!("http://{DEFAULT_API_HOST}:{}", api_port())
})
}

fn resolve_data_dir(app: &AppHandle) -> PathBuf {
if let Ok(dir) = std::env::var("PRM_DATA_DIR") {
return PathBuf::from(dir);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../data")
// Dev: keep repo `data/` next to the monorepo for shared browser/desktop use.
if cfg!(debug_assertions) {
return PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../data");
}
// Packaged: never use CARGO_MANIFEST_DIR (that is a CI/build-machine path).
match app.path().app_data_dir() {
Ok(dir) => dir,
Err(_) => std::env::temp_dir().join("prm-workbench-data"),
}
}

fn read_launch_token(path: &Path) -> Option<String> {
Expand Down Expand Up @@ -71,8 +97,8 @@ fn random_token() -> String {
buf.iter().map(|b| format!("{b:02x}")).collect()
}

fn resolve_launch_config() -> LaunchConfig {
let token_path = data_dir().join(".launch_token");
fn resolve_launch_config(data: &Path) -> LaunchConfig {
let token_path = data.join(".launch_token");
let from_env = std::env::var("PRM_LAUNCH_TOKEN")
.ok()
.map(|s| s.trim().to_string())
Expand All @@ -82,7 +108,7 @@ fn resolve_launch_config() -> LaunchConfig {
.unwrap_or_else(random_token);
let _ = write_launch_token(&token_path, &token);
LaunchConfig {
api_base_url: std::env::var("PRM_API_BASE_URL").unwrap_or_else(|_| DEFAULT_API_BASE.into()),
api_base_url: api_base_url(),
launch_token: token,
}
}
Expand All @@ -100,10 +126,22 @@ fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec<PathBuf> {
dirs
}

fn append_log(path: &Path, line: &str) {
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "{line}");
}
}

/// Spawn the local Node API when a bundled sidecar exists or `PRM_SPAWN_API=1`.
fn maybe_spawn_api(token: &str, resource_dir: Option<&Path>) -> Option<Child> {
fn maybe_spawn_api(
token: &str,
data: &Path,
resource_dir: Option<&Path>,
log_path: &Path,
) -> Option<Child> {
let force = std::env::var("PRM_SPAWN_API").ok().as_deref() == Some("1");
let api_entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/src/index.ts");
let port = api_port();

let mut program: Option<String> = None;
let mut args: Vec<String> = Vec::new();
Expand Down Expand Up @@ -137,56 +175,153 @@ fn maybe_spawn_api(token: &str, resource_dir: Option<&Path>) -> Option<Child> {

let (program, args, cwd) = match (program, cwd) {
(Some(p), Some(c)) => (p, args, c),
_ => return None,
_ => {
append_log(log_path, "PRM: no API sidecar found (prm-api.mjs missing)");
return None;
}
};

let data = data_dir();
let _ = fs::create_dir_all(&data);
let _ = fs::create_dir_all(data);
append_log(
log_path,
&format!(
"PRM: spawning API sidecar program={program} cwd={} data={} port={port}",
cwd.display(),
data.display()
),
);

match Command::new(&program)
.args(&args)
.current_dir(&cwd)
.env("PRM_LAUNCH_TOKEN", token)
.env("PRM_DATA_DIR", &data)
.env("PRM_PORT", "8787")
.env("PRM_DATA_DIR", data)
.env("PRM_PORT", port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => {
eprintln!("PRM: spawned API sidecar ({program})");
Ok(mut child) => {
let log = log_path.to_path_buf();
if let Some(mut out) = child.stdout.take() {
thread::spawn(move || {
let mut buf = String::new();
let _ = out.read_to_string(&mut buf);
if !buf.trim().is_empty() {
append_log(&log, &format!("PRM API stdout:\n{buf}"));
}
});
}
let log = log_path.to_path_buf();
if let Some(mut err) = child.stderr.take() {
thread::spawn(move || {
let mut buf = String::new();
let _ = err.read_to_string(&mut buf);
if !buf.trim().is_empty() {
append_log(&log, &format!("PRM API stderr:\n{buf}"));
}
});
}
append_log(log_path, &format!("PRM: spawned API sidecar ({program})"));
Some(child)
}
Err(err) => {
eprintln!("PRM: failed to spawn API sidecar: {err}");
append_log(log_path, &format!("PRM: failed to spawn API sidecar: {err}"));
None
}
}
}

fn main() {
let launch = resolve_launch_config();
std::env::set_var("PRM_LAUNCH_TOKEN", &launch.launch_token);
let token_for_spawn = launch.launch_token.clone();
fn api_health_ok(port: u16) -> bool {
let addr = format!("{DEFAULT_API_HOST}:{port}");
let Ok(mut stream) = TcpStream::connect_timeout(
&addr.parse().expect("api addr"),
Duration::from_millis(400),
) else {
return false;
};
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
let req = format!(
"GET /api/health HTTP/1.1\r\nHost: {DEFAULT_API_HOST}:{port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
let body = String::from_utf8_lossy(&buf);
body.contains("200") && body.contains("ok")
}

let inject = format!(
"window.__PRM_LAUNCH_TOKEN__={};",
serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into())
fn wait_for_api(port: u16, timeout: Duration, log_path: &Path) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
if api_health_ok(port) {
append_log(
log_path,
&format!("PRM: API healthy on :{port} after {:?}", start.elapsed()),
);
return true;
}
thread::sleep(Duration::from_millis(200));
}
append_log(
log_path,
&format!("PRM: API did not become healthy on :{port} within {timeout:?}"),
);
false
}

fn main() {
tauri::Builder::default()
.manage(LaunchState(Mutex::new(launch)))
.manage(LaunchState(Mutex::new(LaunchConfig {
api_base_url: DEFAULT_API_BASE.into(),
launch_token: String::new(),
})))
.manage(ApiChild(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![get_launch_config])
.setup(move |app| {
let data = resolve_data_dir(app.handle());
let _ = fs::create_dir_all(&data);
let log_path = data.join("desktop-api.log");
let launch = resolve_launch_config(&data);
std::env::set_var("PRM_LAUNCH_TOKEN", &launch.launch_token);
std::env::set_var("PRM_DATA_DIR", &data);

if let Ok(mut guard) = app.state::<LaunchState>().0.lock() {
*guard = launch.clone();
}

let resource_dir = app.path().resource_dir().ok();
let child = maybe_spawn_api(&token_for_spawn, resource_dir.as_deref());
if let Some(state) = app.try_state::<ApiChild>() {
if let Ok(mut guard) = state.0.lock() {
*guard = child;
}
let child = maybe_spawn_api(
&launch.launch_token,
&data,
resource_dir.as_deref(),
&log_path,
);
if let Ok(mut guard) = app.state::<ApiChild>().0.lock() {
*guard = child;
}

let port = api_port();
let ready = wait_for_api(port, Duration::from_secs(25), &log_path);
if !ready {
eprintln!(
"PRM: API sidecar not healthy yet — see {}",
log_path.display()
);
}

let inject = format!(
"window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};",
serde_json::to_string(&launch.launch_token).unwrap_or_else(|_| "\"\"".into()),
serde_json::to_string(&launch.api_base_url).unwrap_or_else(|_| {
serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into())
}),
);

let url = if cfg!(debug_assertions) {
WebviewUrl::External(DEV_UI_URL.parse().expect("dev ui url"))
} else {
Expand Down
3 changes: 2 additions & 1 deletion apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { useEffect, useState } from "react";
import type { WorkspaceStatus } from "@prm/shared";
import { api, getToken, setToken } from "./lib/api";
import { api, getToken, setToken, waitForApi } from "./lib/api";
import { useTheme } from "./hooks/useTheme";
import { UnlockPage } from "./pages/UnlockPage";
import { HomePage } from "./pages/HomePage";
Expand All @@ -26,6 +26,7 @@ export function App() {

async function refresh() {
try {
await waitForApi();
const s = await api<WorkspaceStatus>("/api/workspace/status");
setStatus(s);
setError(null);
Expand Down
38 changes: 37 additions & 1 deletion apps/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const TOKEN_KEY = "prm_token";
declare global {
interface Window {
__PRM_LAUNCH_TOKEN__?: string;
/** Absolute API origin injected by the Tauri shell (e.g. http://127.0.0.1:8787). */
__PRM_API_BASE_URL__?: string;
}
}

Expand All @@ -21,6 +23,40 @@ function launchToken(): string | null {
return window.__PRM_LAUNCH_TOKEN__?.trim() || null;
}

/** Packaged desktop sets this; browser/Vite leaves it empty so `/api` stays relative (proxy). */
export function apiBaseUrl(): string {
if (typeof window === "undefined") return "";
return window.__PRM_API_BASE_URL__?.trim().replace(/\/$/, "") || "";
}

/** Resolve an API path for fetch (absolute in Tauri, relative under Vite). */
export function apiUrl(path: string): string {
if (/^https?:\/\//i.test(path)) return path;
const normalized = path.startsWith("/") ? path : `/${path}`;
const base = apiBaseUrl();
return base ? `${base}${normalized}` : normalized;
}

function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Wait until the local API answers /api/health (desktop sidecar warmup). */
export async function waitForApi(opts?: { attempts?: number; delayMs?: number }): Promise<boolean> {
const attempts = opts?.attempts ?? (apiBaseUrl() ? 40 : 1);
const delayMs = opts?.delayMs ?? 250;
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(apiUrl("/api/health"), { method: "GET" });
if (res.ok) return true;
} catch {
/* still starting */
}
if (i < attempts - 1) await sleep(delayMs);
}
return false;
}

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && !(init.body instanceof FormData)) {
Expand All @@ -30,7 +66,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (token) headers.set("x-prm-token", token);
const launch = launchToken();
if (launch) headers.set("x-prm-launch-token", launch);
const res = await fetch(path, { ...init, headers });
const res = await fetch(apiUrl(path), { ...init, headers });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error((err as { error?: string }).error || "Request failed");
Expand Down
Loading
Loading