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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,43 @@ 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"
UI="apps/desktop/src-tauri/ui-dist"
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"
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_UI_DIR="$UI" \
"$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
curl -sf "http://127.0.0.1:8799/" | head -c 200
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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
65 changes: 63 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -137,12 +138,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"],
}),
Expand Down Expand Up @@ -3823,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<string, string> = {
".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" });
4 changes: 3 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src-tauri/run-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
* 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";

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 = {}) {
Expand Down Expand Up @@ -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);
}
Loading
Loading