From ea25f6c9db089bc553370d0f354920b1f88f593e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 00:01:06 +0000 Subject: [PATCH 1/2] Harden Windows desktop API startup against Failed to fetch Spawn Node with CREATE_NO_WINDOW, keep http://tauri.localhost (no mixed content), broaden CORS for *.localhost, improve offline UI with log path, and smoke-test the sidecar in CI. Co-authored-by: Damon --- .github/workflows/ci.yml | 33 +++++++++++++ apps/api/src/index.ts | 12 ++++- apps/desktop/src-tauri/src/main.rs | 76 +++++++++++++++++++++++++----- apps/ui/src/App.tsx | 18 ++++++- apps/ui/src/lib/api.ts | 4 ++ 5 files changed, 128 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4248baa..61bcc6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,39 @@ jobs: projectPath: apps/desktop args: ${{ matrix.args }} includeUpdaterJson: false + - name: Smoke API sidecar + shell: bash + run: | + set -euo pipefail + SIDE="apps/desktop/src-tauri/sidecars" + if [[ "${{ runner.os }}" == "Windows" ]]; then + NODE="$SIDE/node.exe" + else + NODE="$SIDE/node" + chmod +x "$NODE" || true + fi + test -f "$NODE" + test -f "$SIDE/prm-api.mjs" + DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke" + rm -rf "$DATA" + mkdir -p "$DATA" + PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke \ + "$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 & + PID=$! + cleanup() { kill "$PID" 2>/dev/null || true; } + trap cleanup EXIT + for i in $(seq 1 50); do + if curl -sf "http://127.0.0.1:8799/api/health" >/dev/null; then + echo "sidecar healthy" + curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke" + echo + exit 0 + fi + sleep 0.2 + done + echo "sidecar failed to become healthy" >&2 + cat "$DATA/out.log" >&2 || true + exit 1 - name: Upload desktop installers uses: actions/upload-artifact@v4 with: diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 063e39a..5498183 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -137,12 +137,22 @@ const CORS_ORIGINS = [ "tauri://localhost", "https://tauri.localhost", "http://tauri.localhost", + "https://asset.localhost", + "http://asset.localhost", ]; +function corsOrigin(origin: string): string | null { + if (CORS_ORIGINS.includes(origin)) return origin; + // Windows/Android custom-protocol workaround hosts: http(s)://*.localhost + if (/^https?:\/\/(?:[a-z0-9-]+\.)*localhost(?::\d+)?$/i.test(origin)) return origin; + if (origin.startsWith("tauri://")) return origin; + return null; +} + app.use( "*", cors({ - origin: CORS_ORIGINS, + origin: (origin) => (origin ? corsOrigin(origin) : CORS_ORIGINS[0]), credentials: true, allowHeaders: ["Content-Type", "x-prm-token", "x-prm-launch-token"], }), diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 5e5a8df..2ada98d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -11,6 +11,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(windows)] +use std::os::windows::process::CommandExt; + use serde::Serialize; use tauri::{AppHandle, Manager, State, WebviewUrl, WebviewWindowBuilder}; @@ -18,6 +21,8 @@ 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"; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -143,12 +148,25 @@ fn maybe_spawn_api( let api_entry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../api/src/index.ts"); let port = api_port(); - let mut program: Option = None; - let mut args: Vec = Vec::new(); + let mut program: Option = None; + let mut args: Vec = Vec::new(); let mut cwd: Option = None; for dir in candidate_sidecar_dirs(resource_dir) { let script = dir.join("prm-api.mjs"); + append_log( + log_path, + &format!( + "PRM: probe sidecar dir {} script_exists={} node_exists={}", + dir.display(), + script.exists(), + if cfg!(windows) { + dir.join("node.exe").exists() + } else { + dir.join("node").exists() + } + ), + ); if !script.exists() { continue; } @@ -158,18 +176,22 @@ fn maybe_spawn_api( dir.join("node") }; if bundled_node.exists() { - program = Some(bundled_node.to_string_lossy().to_string()); + program = Some(bundled_node); } else { - program = Some("node".into()); + program = Some(PathBuf::from("node")); } - args = vec![script.to_string_lossy().to_string()]; + args = vec![script]; cwd = Some(dir); break; } if program.is_none() && force && api_entry.exists() { - program = Some("pnpm".into()); - args = vec!["exec".into(), "tsx".into(), "src/index.ts".into()]; + program = Some(PathBuf::from("pnpm")); + args = vec![ + PathBuf::from("exec"), + PathBuf::from("tsx"), + PathBuf::from("src/index.ts"), + ]; cwd = api_entry.parent()?.parent().map(|p| p.to_path_buf()); } @@ -185,13 +207,15 @@ fn maybe_spawn_api( append_log( log_path, &format!( - "PRM: spawning API sidecar program={program} cwd={} data={} port={port}", + "PRM: spawning API sidecar program={} cwd={} data={} port={port}", + program.display(), cwd.display(), data.display() ), ); - match Command::new(&program) + let mut command = Command::new(&program); + command .args(&args) .current_dir(&cwd) .env("PRM_LAUNCH_TOKEN", token) @@ -199,9 +223,14 @@ fn maybe_spawn_api( .env("PRM_PORT", port.to_string()) .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() + .stderr(Stdio::piped()); + // GUI subsystem apps on Windows must not attach a console to Node or spawn can hang/fail. + #[cfg(windows)] { + command.creation_flags(CREATE_NO_WINDOW); + } + + match command.spawn() { Ok(mut child) => { let log = log_path.to_path_buf(); if let Some(mut out) = child.stdout.take() { @@ -223,7 +252,25 @@ fn maybe_spawn_api( } }); } - append_log(log_path, &format!("PRM: spawned API sidecar ({program})")); + // Detect immediate crash (missing native module, bad path, etc.) + thread::sleep(Duration::from_millis(150)); + match child.try_wait() { + Ok(Some(status)) => { + append_log( + log_path, + &format!("PRM: API sidecar exited immediately with {status}"), + ); + return None; + } + Ok(None) => {} + Err(err) => { + append_log(log_path, &format!("PRM: try_wait failed: {err}")); + } + } + append_log( + log_path, + &format!("PRM: spawned API sidecar ({})", program.display()), + ); Some(child) } Err(err) => { @@ -315,11 +362,12 @@ fn main() { } let inject = format!( - "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};", + "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};window.__PRM_DESKTOP_LOG__={};window.__PRM_DESKTOP__=true;", 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()) }), + serde_json::to_string(&log_path.to_string_lossy()).unwrap_or_else(|_| "\"\"".into()), ); let url = if cfg!(debug_assertions) { @@ -328,11 +376,13 @@ fn main() { WebviewUrl::App("index.html".into()) }; if app.get_webview_window("main").is_none() { + // Keep http://tauri.localhost on Windows so fetch to http://127.0.0.1:8787 is not mixed-content blocked. WebviewWindowBuilder::new(app, "main", url) .title("Performance Review Manager") .inner_size(1280.0, 840.0) .resizable(true) .initialization_script(&inject) + .use_https_scheme(false) .build()?; } else if let Some(window) = app.get_webview_window("main") { let _ = window.eval(&inject); diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index fd3cef7..7c9fe9d 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -52,11 +52,27 @@ export function App() { ); if (error) { + const desktop = typeof window !== "undefined" && Boolean(window.__PRM_DESKTOP__ || window.__PRM_API_BASE_URL__); + const logPath = typeof window !== "undefined" ? window.__PRM_DESKTOP_LOG__ : undefined; + const apiBase = typeof window !== "undefined" ? window.__PRM_API_BASE_URL__ : undefined; return (

API offline

-

Start the API on port 8787 (`pnpm dev:api`), then refresh.

+ {desktop ? ( + <> +

+ The desktop API sidecar did not become ready. Retry in a moment. If this persists, check the + sidecar log and reinstall from the latest CI artifact. +

+ {apiBase ?

Expected API: {apiBase}

: null} + {logPath ?

Log: {logPath}

: ( +

Log: %APPDATA%\com.prm.workbench\desktop-api.log (Windows)

+ )} + + ) : ( +

Start the API on port 8787 (`pnpm dev:api`), then refresh.

+ )}

{error}

{themeToggle}
diff --git a/apps/ui/src/lib/api.ts b/apps/ui/src/lib/api.ts index be3dbf1..61bff2d 100644 --- a/apps/ui/src/lib/api.ts +++ b/apps/ui/src/lib/api.ts @@ -5,6 +5,10 @@ declare global { __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; + /** Packaged desktop marker. */ + __PRM_DESKTOP__?: boolean; + /** Path to desktop-api.log on packaged builds. */ + __PRM_DESKTOP_LOG__?: string; } } From e547532ec0715a577c45cc8bbad9059f93191887 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 00:04:34 +0000 Subject: [PATCH 2/2] Serve desktop UI from API origin to fix Windows Failed to fetch Packaged builds now copy ui-dist into Tauri resources, pass PRM_UI_DIR to the sidecar, and open the webview at http://127.0.0.1:8787/ so /api is same-origin. Also harden Windows spawn logging and exe-relative sidecar discovery. Co-authored-by: Damon van der linde --- .github/workflows/ci.yml | 6 +- .gitignore | 1 + README.md | 2 +- apps/api/src/index.ts | 53 +++++++++++- apps/desktop/README.md | 4 +- apps/desktop/src-tauri/run-hook.mjs | 6 +- apps/desktop/src-tauri/src/main.rs | 110 ++++++++++++++++++------- apps/desktop/src-tauri/tauri.conf.json | 3 +- apps/ui/src/App.tsx | 16 +++- 9 files changed, 166 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61bcc6f..43ca4ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,7 @@ jobs: run: | set -euo pipefail SIDE="apps/desktop/src-tauri/sidecars" + UI="apps/desktop/src-tauri/ui-dist" if [[ "${{ runner.os }}" == "Windows" ]]; then NODE="$SIDE/node.exe" else @@ -95,10 +96,11 @@ jobs: fi test -f "$NODE" test -f "$SIDE/prm-api.mjs" + test -f "$UI/index.html" DATA="${RUNNER_TEMP:-/tmp}/prm-sidecar-smoke" rm -rf "$DATA" mkdir -p "$DATA" - PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke \ + PRM_DATA_DIR="$DATA" PRM_PORT=8799 PRM_LAUNCH_TOKEN=ci-smoke PRM_UI_DIR="$UI" \ "$NODE" "$SIDE/prm-api.mjs" >"$DATA/out.log" 2>&1 & PID=$! cleanup() { kill "$PID" 2>/dev/null || true; } @@ -108,6 +110,8 @@ jobs: echo "sidecar healthy" curl -sf "http://127.0.0.1:8799/api/workspace/status" -H "x-prm-launch-token: ci-smoke" echo + curl -sf "http://127.0.0.1:8799/" | head -c 200 + echo exit 0 fi sleep 0.2 diff --git a/.gitignore b/.gitignore index 562f402..87fa5be 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ apps/ui/dist apps/api/dist apps/desktop/src-tauri/target apps/desktop/src-tauri/sidecars/ +apps/desktop/src-tauri/ui-dist/ *.tsbuildinfo diff --git a/README.md b/README.md index 1d32376..cfa379a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ 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. +The desktop shell spawns a bundled Node API sidecar, serves the UI from that same origin (avoids WebView `Failed to fetch`), injects a 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) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5498183..62d135b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,7 +1,8 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { cors } from "hono/cors"; -import { basename } from "node:path"; +import { access, readFile } from "node:fs/promises"; +import { basename, extname, join, normalize, resolve, sep } from "node:path"; import { and, eq, isNull } from "drizzle-orm"; import { achievements, @@ -3833,6 +3834,56 @@ app.get("/api/exports/promotion/:id", (c) => { }); }); +/** Packaged desktop: serve the Vite UI from PRM_UI_DIR so the webview is same-origin with /api. */ +const uiDir = process.env.PRM_UI_DIR?.trim(); +if (uiDir) { + const root = resolve(uiDir); + const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".json": "application/json", + ".map": "application/json", + }; + const inside = (file: string) => { + const f = resolve(file); + return f === root || f.startsWith(root + sep); + }; + app.get("*", async (c) => { + const urlPath = decodeURIComponent(new URL(c.req.url).pathname); + if (urlPath.startsWith("/api")) return c.json({ error: "Not found" }, 404); + const rel = + !urlPath || urlPath === "/" + ? "index.html" + : normalize(urlPath.replace(/^[/\\]+/, "")).replace(/^(\.\.([/\\]|$))+/g, ""); + const candidates = [join(root, rel)]; + if (!extname(rel)) candidates.push(join(root, rel, "index.html")); + candidates.push(join(root, "index.html")); + for (const file of candidates) { + if (!inside(file)) continue; + try { + await access(file); + const body = await readFile(file); + const type = mime[extname(file).toLowerCase()] ?? "application/octet-stream"; + return new Response(body, { headers: { "content-type": type } }); + } catch { + /* try next */ + } + } + return c.text("Not found", 404); + }); + console.log(`PRM UI static root: ${root}`); +} + const port = Number(process.env.PRM_PORT ?? 8787); console.log(`PRM API listening on http://127.0.0.1:${port}`); serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }); diff --git a/apps/desktop/README.md b/apps/desktop/README.md index db8e887..29fcda5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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`, waits until `/api/health` responds, 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` / `PRM_UI_DIR`, waits until `/api/health` responds, then opens the webview at the API origin (same-origin `/api` + static UI from `ui-dist/`). On exit it kills the child — one process from the user’s point of view. + +On Windows the Node child is spawned with `CREATE_NO_WINDOW`. Sidecar stdout/stderr append to `%APPDATA%\com.prm.workbench\desktop-api.log`. 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. diff --git a/apps/desktop/src-tauri/run-hook.mjs b/apps/desktop/src-tauri/run-hook.mjs index fe60fb8..55ebec8 100644 --- a/apps/desktop/src-tauri/run-hook.mjs +++ b/apps/desktop/src-tauri/run-hook.mjs @@ -5,7 +5,7 @@ * when cwd is apps/desktop (CI / tauri-action). */ import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { cpSync, existsSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,6 +13,7 @@ const srcTauri = dirname(fileURLToPath(import.meta.url)); const desktopRoot = join(srcTauri, ".."); const repoRoot = join(desktopRoot, "../.."); const uiDist = join(repoRoot, "apps/ui/dist"); +const bundledUi = join(srcTauri, "ui-dist"); const hook = process.argv[2] ?? "build"; function run(command, args, opts = {}) { @@ -46,5 +47,8 @@ if (hook === "dev") { console.error("UI build missing — expected", join(uiDist, "index.html")); process.exit(1); } + rmSync(bundledUi, { recursive: true, force: true }); + cpSync(uiDist, bundledUi, { recursive: true }); console.log("UI dist ready:", uiDist); + console.log("Bundled UI for API same-origin serve:", bundledUi); } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 2ada98d..18624b1 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -118,6 +118,12 @@ fn resolve_launch_config(data: &Path) -> LaunchConfig { } } +fn exe_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.to_path_buf())) +} + fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { let mut dirs = Vec::new(); if let Ok(custom) = std::env::var("PRM_API_SIDECAR_DIR") { @@ -127,10 +133,38 @@ fn candidate_sidecar_dirs(resource_dir: Option<&Path>) -> Vec { dirs.push(res.join("sidecars")); dirs.push(res.to_path_buf()); } + if let Some(exe) = exe_dir() { + dirs.push(exe.join("sidecars")); + dirs.push(exe.join("resources").join("sidecars")); + dirs.push(exe.clone()); + dirs.push(exe.join("resources")); + } dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("sidecars")); dirs } +fn resolve_ui_dir(resource_dir: Option<&Path>) -> Option { + if let Ok(dir) = std::env::var("PRM_UI_DIR") { + let p = PathBuf::from(dir); + if p.join("index.html").exists() { + return Some(p); + } + } + let mut dirs = Vec::new(); + if let Some(res) = resource_dir { + dirs.push(res.join("ui-dist")); + dirs.push(res.to_path_buf()); + } + if let Some(exe) = exe_dir() { + dirs.push(exe.join("ui-dist")); + dirs.push(exe.join("resources").join("ui-dist")); + } + dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ui-dist")); + dirs + .into_iter() + .find(|d| d.join("index.html").exists()) +} + 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}"); @@ -142,6 +176,7 @@ fn maybe_spawn_api( token: &str, data: &Path, resource_dir: Option<&Path>, + ui_dir: Option<&Path>, log_path: &Path, ) -> Option { let force = std::env::var("PRM_SPAWN_API").ok().as_deref() == Some("1"); @@ -207,13 +242,30 @@ fn maybe_spawn_api( append_log( log_path, &format!( - "PRM: spawning API sidecar program={} cwd={} data={} port={port}", + "PRM: spawning API sidecar program={} cwd={} data={} port={port} ui={}", program.display(), cwd.display(), - data.display() + data.display(), + ui_dir.map(|p| p.display().to_string()).unwrap_or_else(|| "(none)".into()) ), ); + // Append child stdio to the same log (avoids pipe-buffer deadlocks on Windows). + let child_log = match fs::OpenOptions::new().create(true).append(true).open(log_path) { + Ok(f) => f, + Err(err) => { + append_log(log_path, &format!("PRM: could not open log for child stdio: {err}")); + return None; + } + }; + let child_err = match child_log.try_clone() { + Ok(f) => f, + Err(err) => { + append_log(log_path, &format!("PRM: could not clone log for stderr: {err}")); + return None; + } + }; + let mut command = Command::new(&program); command .args(&args) @@ -222,8 +274,11 @@ fn maybe_spawn_api( .env("PRM_DATA_DIR", data) .env("PRM_PORT", port.to_string()) .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + .stdout(Stdio::from(child_log)) + .stderr(Stdio::from(child_err)); + if let Some(ui) = ui_dir { + command.env("PRM_UI_DIR", ui); + } // GUI subsystem apps on Windows must not attach a console to Node or spawn can hang/fail. #[cfg(windows)] { @@ -232,28 +287,8 @@ fn maybe_spawn_api( match command.spawn() { 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}")); - } - }); - } // Detect immediate crash (missing native module, bad path, etc.) - thread::sleep(Duration::from_millis(150)); + thread::sleep(Duration::from_millis(200)); match child.try_wait() { Ok(Some(status)) => { append_log( @@ -342,10 +377,18 @@ fn main() { } let resource_dir = app.path().resource_dir().ok(); + let ui_dir = resolve_ui_dir(resource_dir.as_deref()); + if let Some(ref ui) = ui_dir { + append_log(&log_path, &format!("PRM: UI dir {}", ui.display())); + } else { + append_log(&log_path, "PRM: UI dir not found (will use bundled webview assets)"); + } + let child = maybe_spawn_api( &launch.launch_token, &data, resource_dir.as_deref(), + ui_dir.as_deref(), &log_path, ); if let Ok(mut guard) = app.state::().0.lock() { @@ -364,15 +407,26 @@ fn main() { let inject = format!( "window.__PRM_LAUNCH_TOKEN__={};window.__PRM_API_BASE_URL__={};window.__PRM_DESKTOP_LOG__={};window.__PRM_DESKTOP__=true;", 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()) - }), + // Same-origin when loading the API-served UI: leave base empty so /api is relative. + // Offline bundled webview still needs an absolute base to reach the sidecar. + serde_json::to_string(if ready && ui_dir.is_some() { + "" + } else { + launch.api_base_url.as_str() + }) + .unwrap_or_else(|_| serde_json::to_string(DEFAULT_API_BASE).unwrap_or_else(|_| "\"\"".into())), serde_json::to_string(&log_path.to_string_lossy()).unwrap_or_else(|_| "\"\"".into()), ); let url = if cfg!(debug_assertions) { WebviewUrl::External(DEV_UI_URL.parse().expect("dev ui url")) + } else if ready && ui_dir.is_some() { + // Same-origin with the API — avoids WebView2 CORS / mixed-content / Failed to fetch. + let page = format!("{}/", launch.api_base_url.trim_end_matches('/')); + append_log(&log_path, &format!("PRM: opening same-origin UI at {page}")); + WebviewUrl::External(page.parse().expect("api ui url")) } else { + append_log(&log_path, "PRM: opening bundled offline UI assets"); WebviewUrl::App("index.html".into()) }; if app.get_webview_window("main").is_none() { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 1dc6ab9..6060884 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -26,7 +26,8 @@ "icons/icon.ico" ], "resources": [ - "sidecars/**/*" + "sidecars/**/*", + "ui-dist/**/*" ], "longDescription": "Solo engineering manager performance review workbench with encrypted local vault.", "shortDescription": "EM performance review workbench", diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 7c9fe9d..31a97fc 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -55,6 +55,20 @@ export function App() { const desktop = typeof window !== "undefined" && Boolean(window.__PRM_DESKTOP__ || window.__PRM_API_BASE_URL__); const logPath = typeof window !== "undefined" ? window.__PRM_DESKTOP_LOG__ : undefined; const apiBase = typeof window !== "undefined" ? window.__PRM_API_BASE_URL__ : undefined; + async function retryDesktop() { + // Prefer same-origin UI served by the sidecar once it is healthy. + const base = (apiBase || "http://127.0.0.1:8787").replace(/\/$/, ""); + try { + const res = await fetch(`${base}/api/health`); + if (res.ok) { + window.location.href = `${base}/`; + return; + } + } catch { + /* fall through */ + } + await refresh(); + } return (
@@ -74,7 +88,7 @@ export function App() {

Start the API on port 8787 (`pnpm dev:api`), then refresh.

)}

{error}

- +
{themeToggle}