diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 352267e5..a2ee7b49 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -139,6 +139,16 @@ jobs: - run: pnpm install --frozen-lockfile + # The SEA sidecar clones a node binary; the arm64 runner cross-builds + # x86_64, so that leg needs a target-arch node of the exact same version + # (the build script rejects a skew). + - name: fetch x86_64 node for the SEA sidecar + if: matrix.args == '--target x86_64-apple-darwin' + run: | + V=$(node --version) + curl -fsSL "https://nodejs.org/dist/${V}/node-${V}-darwin-x64.tar.gz" | tar xz -C "$RUNNER_TEMP" + echo "ZPLAB_SEA_NODE=$RUNNER_TEMP/node-${V}-darwin-x64/bin/node" >> "$GITHUB_ENV" + - uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 8f1ada88..11fdbfbe 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -37,7 +37,9 @@ pnpm --filter @zplab/mcp-server exec tsx src/index.ts --http --port 4923 --token `Authorization: Bearer `; loopback-only binding plus Origin/Host checks guard against local drive-by and DNS-rebinding. -## Claude Desktop config +## MCP client config + +Most MCP clients (Claude Desktop and others) accept this `mcpServers` shape: ```json { diff --git a/packages/mcp-server/scripts/build-sea.mjs b/packages/mcp-server/scripts/build-sea.mjs index 85a32baa..de40dc8a 100644 --- a/packages/mcp-server/scripts/build-sea.mjs +++ b/packages/mcp-server/scripts/build-sea.mjs @@ -9,18 +9,23 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); -const outDir = join(pkgDir, "dist-sea"); const { values } = parseArgs({ options: { - // Node executable to clone for the target platform/arch. - node: { type: "string", default: process.execPath }, - out: { type: "string" }, + // Node to clone for the target arch; env form for the CI cross legs. + node: { type: "string", default: process.env.ZPLAB_SEA_NODE ?? process.execPath }, }, }); const isWindows = process.platform === "win32"; const isMac = process.platform === "darwin"; -const exeName = values.out ?? (isWindows ? "zplab-mcp.exe" : "zplab-mcp"); +// Under `tauri build` (env from the CLI) the exe goes where externalBin +// expects it; standalone runs keep dist-sea. Intermediates stay in dist-sea. +const triple = process.env.TAURI_ENV_TARGET_TRIPLE; +const workDir = join(pkgDir, "dist-sea"); +const outDir = triple ? join(dirname(dirname(pkgDir)), "src-tauri", "binaries") : workDir; +const exeName = triple + ? `zplab-mcp-${triple}${isWindows ? ".exe" : ""}` + : `zplab-mcp${isWindows ? ".exe" : ""}`; const run = (cmd, args) => execFileSync(cmd, args, { stdio: "inherit", cwd: pkgDir }); // Both tool bins are JS shims, so run them through node directly: no shell, @@ -28,9 +33,10 @@ const run = (cmd, args) => execFileSync(cmd, args, { stdio: "inherit", cwd: pkgD const require = createRequire(import.meta.url); const runBin = (spec, args) => run(process.execPath, [require.resolve(spec), ...args]); +mkdirSync(workDir, { recursive: true }); mkdirSync(outDir, { recursive: true }); -const bundle = join(outDir, "zplab-mcp.cjs"); -const blob = join(outDir, "zplab-mcp.blob"); +const bundle = join(workDir, "zplab-mcp.cjs"); +const blob = join(workDir, "zplab-mcp.blob"); const exe = join(outDir, exeName); runBin("esbuild/bin/esbuild", [ @@ -43,14 +49,13 @@ runBin("esbuild/bin/esbuild", [ ]); writeFileSync( - join(outDir, "sea-config.json"), + join(workDir, "sea-config.json"), JSON.stringify({ main: bundle, output: blob, disableExperimentalSEAWarning: true }), ); -run(process.execPath, ["--experimental-sea-config", join(outDir, "sea-config.json")]); +run(process.execPath, ["--experimental-sea-config", join(workDir, "sea-config.json")]); -// The blob is generated by the RUNNER's node but runs inside the target -// binary; the SEA format is version-coupled, so a skew fails at runtime. -// A foreign-arch target may not be executable here (then verify manually). +// The SEA format is version-coupled: a blob from the runner's node fails at +// runtime inside a different-version target binary. if (values.node !== process.execPath) { let targetVersion = null; try { @@ -62,10 +67,27 @@ if (values.node !== process.execPath) { throw new Error(`target node is ${targetVersion}, runner is ${process.version}`); } } +// A runner-arch node under a target-triple name would ship a binary the +// target machines cannot start. +if (triple) { + const tripleArch = triple.startsWith("aarch64") ? "arm64" : "x64"; + let nodeArch = process.arch; + if (values.node !== process.execPath) { + try { + nodeArch = execFileSync(values.node, ["-p", "process.arch"]).toString().trim(); + } catch { + nodeArch = "unknown"; + } + } + if (nodeArch !== "unknown" && nodeArch !== tripleArch) { + throw new Error( + `node is ${nodeArch} but the target triple needs ${tripleArch}; pass --node/ZPLAB_SEA_NODE`, + ); + } +} copyFileSync(values.node, exe); -// postject invalidates the executable's signature; macOS arm64 refuses to run -// unsigned binaries, so strip before injecting and ad-hoc re-sign after (the -// release carries no OS code signing, only the Tauri updater signature). +// postject invalidates the signature and macOS arm64 refuses unsigned +// binaries: strip before injecting, ad-hoc re-sign after. if (isMac) run("codesign", ["--remove-signature", exe]); runBin("postject/dist/cli.js", [ exe, diff --git a/packages/mcp-server/scripts/smoke-sea.mjs b/packages/mcp-server/scripts/smoke-sea.mjs index 5baa14a0..c3d0b236 100644 --- a/packages/mcp-server/scripts/smoke-sea.mjs +++ b/packages/mcp-server/scripts/smoke-sea.mjs @@ -47,15 +47,16 @@ const check = (name, cond, detail = "") => { }; const t0 = Date.now(); -// stderr inherited so a startup crash (port in use, bad binary) shows its -// real cause instead of only the listening timeout below. -const child = spawn(exe, ["--http", "--port", String(PORT), "--token", TOK], { - stdio: ["ignore", "pipe", "inherit"], +// stderr inherited so a startup crash shows its real cause; token over stdin +// exactly like the Tauri host hands it over. +const child = spawn(exe, ["--http", "--port", String(PORT), "--token-stdin"], { + stdio: ["pipe", "pipe", "inherit"], }); child.on("error", (e) => { console.error("spawn failed:", e.message); process.exit(1); }); +child.stdin.end(`${TOK}\n`); const events = []; let onListening; let onExited; diff --git a/packages/mcp-server/src/http.test.ts b/packages/mcp-server/src/http.test.ts index a2d36c16..054ab599 100644 --- a/packages/mcp-server/src/http.test.ts +++ b/packages/mcp-server/src/http.test.ts @@ -143,6 +143,16 @@ describe("mcp-server http transport", () => { expect(res.status).toBe(400); }); + it("rejects an oversized MCP-route body with 413", async () => { + const res = await post( + { jsonrpc: "2.0", id: 9, method: "tools/call", params: { pad: "x".repeat(17 * 1024 * 1024) } }, + { token: TOKEN }, + ).catch(() => null); + // Node may reset the socket mid-upload after the 413; both prove the cap. + if (res) expect(res.status).toBe(413); + else expect(res).toBeNull(); + }); + it("rejects an oversized design-response body with 413", async () => { const res = await fetch(`http://127.0.0.1:${server.port}/design-response`, { method: "POST", diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index b8a49039..93cff56d 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -6,9 +6,31 @@ import { buildServer } from "./server.js"; const HOST = "127.0.0.1"; -/** Cap for the app's design-response body. A design file with embedded - * graphics runs to a few MB; anything beyond this is not a real label. */ -const MAX_DESIGN_RESPONSE_BYTES = 16 * 1024 * 1024; +/** Body cap for every route: a real design file with graphics is a few MB, + * beyond that is only an authed local DoS vector. */ +const MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Read the whole body, capped. Resolves null after answering 413 and + * destroying the socket (the caller must bail out then). */ +function readBodyCapped(req: IncomingMessage, res: ServerResponse): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let size = 0; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + if (!res.headersSent) res.writeHead(413); + res.end(); + req.destroy(); + resolve(null); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", () => resolve(null)); + }); +} export interface HttpServerOptions { port: number; @@ -35,32 +57,21 @@ function hasValidToken(req: IncomingMessage, expected: string): boolean { /** The app's reply to a designRequest event. Bypasses the SDK transport (it is * not MCP JSON-RPC), so it re-checks the Host header for loopback itself; the * bearer token was already verified by the shared gate. */ -function handleDesignResponse(req: IncomingMessage, res: ServerResponse): void { +async function handleDesignResponse(req: IncomingMessage, res: ServerResponse): Promise { const host = (req.headers.host ?? "").replace(/:\d+$/, ""); if (req.method !== "POST" || (host !== "127.0.0.1" && host !== "localhost")) { res.writeHead(403).end(); return; } - const chunks: Buffer[] = []; - let size = 0; - req.on("data", (chunk: Buffer) => { - size += chunk.length; - if (size > MAX_DESIGN_RESPONSE_BYTES) { - res.writeHead(413).end(); - req.destroy(); - return; - } - chunks.push(chunk); - }); - req.on("end", () => { - let delivered = false; - try { - delivered = resolveDesignResponse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); - } catch { - // Malformed JSON falls through to the 400 below. - } - if (!res.writableEnded) res.writeHead(delivered ? 204 : 400).end(); - }); + const body = await readBodyCapped(req, res); + if (body === null) return; + let delivered = false; + try { + delivered = resolveDesignResponse(JSON.parse(body.toString("utf8"))); + } catch { + // Malformed JSON falls through to the 400 below. + } + if (!res.writableEnded) res.writeHead(delivered ? 204 : 400).end(); } /** Loopback-only Streamable HTTP server with mandatory bearer auth. A fresh @@ -80,7 +91,11 @@ export async function startHttpServer(options: HttpServerOptions): Promise { + if (!res.headersSent && res.writable) res.writeHead(500); + res.end(); + }); return; } @@ -98,14 +113,28 @@ export async function startHttpServer(options: HttpServerOptions): Promise transport.handleRequest(req, res)) - .catch(() => { - // Guard against a handler that already responded or closed the socket. - if (!res.headersSent && res.writable) res.writeHead(500); - res.end(); - }); + // Body read here (capped), then passed as parsedBody: a passive cap + // listener would start flowing before the transport reads, losing chunks. + void (async () => { + const body = await readBodyCapped(req, res); + if (body === null) return; + let parsedBody: unknown; + if (body.length > 0) { + try { + parsedBody = JSON.parse(body.toString("utf8")); + } catch { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + return; + } + } + await server.connect(transport); + await transport.handleRequest(req, res, parsedBody); + })().catch(() => { + // Guard against a handler that already responded or closed the socket. + if (!res.headersSent && res.writable) res.writeHead(500); + res.end(); + }); }); const boundPort = await new Promise((resolve, reject) => { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 0fc86a93..51054760 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -3,6 +3,26 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { buildServer } from "./server.js"; import { startHttpServer } from "./http.js"; +/** First stdin line as the bearer token, keeping it off argv (readable by + * any same-user process). The host closes stdin right after the line. */ +function readTokenFromStdin(): Promise { + return new Promise((resolve, reject) => { + let buf = ""; + const onData = (d: Buffer) => { + buf += d.toString("utf8"); + const i = buf.indexOf("\n"); + if (i >= 0) { + process.stdin.off("data", onData); + process.stdin.pause(); + resolve(buf.slice(0, i).trim()); + } + }; + process.stdin.on("data", onData); + process.stdin.once("end", () => reject(new Error("stdin closed before a token line"))); + process.stdin.once("error", reject); + }); +} + // No top-level await: the SEA sidecar build bundles this entry as CJS. async function main(): Promise { const { values } = parseArgs({ @@ -10,6 +30,7 @@ async function main(): Promise { http: { type: "boolean", default: false }, port: { type: "string" }, token: { type: "string" }, + "token-stdin": { type: "boolean", default: false }, }, }); @@ -19,12 +40,14 @@ async function main(): Promise { process.stderr.write("--http requires a valid --port\n"); process.exit(1); } - if (!values.token) { - process.stderr.write("--http requires --token\n"); + const token = values["token-stdin"] ? await readTokenFromStdin() : values.token; + if (!token) { + process.stderr.write("--http requires --token or --token-stdin\n"); process.exit(1); } - await startHttpServer({ port, token: values.token }); + await startHttpServer({ port, token }); } else { + // stdin is the JSON-RPC transport here, so --token-stdin cannot apply. const server = buildServer(); await server.connect(new StdioServerTransport()); } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index d67a76ee..de709c8a 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -151,8 +151,9 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { title: "Get current ZPLab design", description: "Read the design currently open in the ZPLab desktop app: the design file " + - "plus render-exact bounds and overlaps (the app reports measured barcode/text " + - "sizes, so nothing is approx). Only available when ZPLab launched this server.", + "plus bounds and overlaps using the app's render-measured barcode/text sizes " + + "(approx only flags anything not rendered yet). Only available when ZPLab " + + "launched this server.", inputSchema: {}, }, async () => { diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 7e119554..dfb36691 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -4,3 +4,4 @@ # updater signing key, lives only in GH secrets .tauri-signing.key .tauri-signing.key.pub +binaries diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7c..e127bf35 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,22 @@ fn main() { + // tauri-build requires the externalBin sidecar in every profile, but dev + // spawns from source: a debug placeholder keeps fresh-clone compiles going, + // and release deletes a leftover placeholder so it can never get bundled. + let triple = std::env::var("TARGET").unwrap_or_default(); + let ext = if triple.contains("windows") { + ".exe" + } else { + "" + }; + let path = std::path::PathBuf::from(format!("binaries/zplab-mcp-{triple}{ext}")); + let is_placeholder = std::fs::metadata(&path).is_ok_and(|m| m.len() == 0); + if std::env::var("PROFILE").as_deref() == Ok("debug") { + if !path.exists() { + let _ = std::fs::create_dir_all("binaries"); + let _ = std::fs::write(&path, b""); + } + } else if is_placeholder { + let _ = std::fs::remove_file(&path); + } tauri_build::build() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e74f328..273f3f2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,7 +24,8 @@ pub fn run() { credentials::credential_delete, mcp::mcp_start, mcp::mcp_stop, - mcp::mcp_status + mcp::mcp_status, + mcp::mcp_listeners_ready ]); // On the builder, not in setup(): config windows exist before the setup // closure runs, and window-state only restores/tracks via on_window_ready. diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 65a8ebe9..ea97f4d9 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -5,7 +5,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use std::time::Duration; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::oneshot; /// Tauri event carrying an openDraft design file to the editor. @@ -83,17 +83,49 @@ enum ChildStatus { Absent, } +/// A child event held back until the webview has its listeners up. +enum BufferedEvent { + OpenDraft(String), + DesignRequest(u64), +} + +fn emit_event(app: &AppHandle, ev: &BufferedEvent) { + match ev { + BufferedEvent::OpenDraft(payload) => { + let _ = app.emit(OPEN_DRAFT_EVENT, payload.clone()); + } + BufferedEvent::DesignRequest(id) => { + let _ = app.emit(DESIGN_REQUEST_EVENT, *id); + } + } +} + /// Managed handle to the running server child, if any. -#[derive(Default)] pub struct McpState { child: Mutex>, /// Held for the whole of mcp_start so concurrent starts serialize instead of /// racing the TOCTOU is_running() check. start_lock: tokio::sync::Mutex<()>, + /// Some = webview listeners not up yet, events queue here (a boot-started + /// server would otherwise emit into the void and silently drop a draft); + /// mcp_listeners_ready takes it to None and flushes. Webview-reload edge accepted. + event_buffer: Mutex>>, #[cfg(windows)] job: Mutex>, } +impl Default for McpState { + fn default() -> Self { + Self { + child: Mutex::new(None), + start_lock: tokio::sync::Mutex::new(()), + event_buffer: Mutex::new(Some(Vec::new())), + #[cfg(windows)] + job: Mutex::new(None), + } + } +} + impl McpState { /// Kill and reap the child if one is running. Idempotent. pub fn kill(&self) { @@ -156,15 +188,24 @@ fn suppress_console(cmd: &mut Command) { #[cfg(not(windows))] fn suppress_console(_cmd: &mut Command) {} -/// Whether this build can spawn the sidecar; the capability mcp_status reports -/// so the UI can hide the controls instead of offering a server that cannot -/// start. Must flip together with mcp_command when bundling lands. -pub const SIDECAR_AVAILABLE: bool = cfg!(debug_assertions); +/// Capability mcp_status reports so the UI can hide the controls. Dev always +/// can (source spawn); release only when the bundled binary is present. +#[cfg(debug_assertions)] +pub fn sidecar_available() -> bool { + true +} + +#[cfg(not(debug_assertions))] +pub fn sidecar_available() -> bool { + sidecar_path().is_some() +} /// The child that serves the MCP HTTP transport. Dev runs the workspace package -/// from source; release fails honestly until the sidecar binary is bundled. +/// from source; release spawns the bundled sidecar binary. +/// The token travels over stdin (--token-stdin), never argv: argv is readable +/// by any same-user process (/proc/pid/cmdline, WMI CommandLine). #[cfg(debug_assertions)] -fn mcp_command(port: u16, token: &str) -> Result { +fn mcp_command(port: u16) -> Result { // cwd is the repo root (src-tauri's parent) so the pnpm workspace filter // resolves the package. let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -188,18 +229,31 @@ fn mcp_command(port: u16, token: &str) -> Result { "--http", "--port", &port.to_string(), - "--token", - token, + "--token-stdin", ]); Ok(cmd) } +/// Bundled sidecar binary next to the app executable (tauri externalBin drops +/// it there on every platform, triple suffix stripped). #[cfg(not(debug_assertions))] -fn mcp_command(_port: u16, _token: &str) -> Result { - // Bundling the sidecar binary is a deferred follow-up; fail instead of - // spawning a dev toolchain that release machines do not have. Stable code the - // frontend maps to a localized message. - Err("sidecar_not_bundled".to_string()) +fn sidecar_path() -> Option { + let name = if cfg!(windows) { + "zplab-mcp.exe" + } else { + "zplab-mcp" + }; + let path = std::env::current_exe().ok()?.parent()?.join(name); + path.exists().then_some(path) +} + +#[cfg(not(debug_assertions))] +fn mcp_command(port: u16) -> Result { + // Stable code the frontend maps to a localized message. + let exe = sidecar_path().ok_or("sidecar_not_bundled")?; + let mut cmd = Command::new(exe); + cmd.args(["--http", "--port", &port.to_string(), "--token-stdin"]); + Ok(cmd) } /// Extract the design file payload from a child stdout line, or None if the @@ -249,10 +303,30 @@ fn forward_child_events( } continue; } - if let Some(payload) = open_draft_payload(&line) { - let _ = app.emit(OPEN_DRAFT_EVENT, payload); + let ev = if let Some(payload) = open_draft_payload(&line) { + BufferedEvent::OpenDraft(payload) } else if let Some(id) = design_request_id(&line) { - let _ = app.emit(DESIGN_REQUEST_EVENT, id); + BufferedEvent::DesignRequest(id) + } else { + continue; + }; + let state = app.state::(); + let mut buffer = state.event_buffer.lock().unwrap(); + match buffer.as_mut() { + Some(queue) => queue.push(ev), + None => emit_event(&app, &ev), + } + } +} + +/// The webview's listeners are registered: flush anything queued and emit +/// directly from now on. +#[tauri::command] +pub fn mcp_listeners_ready(app: AppHandle, state: State<'_, McpState>) { + let drained = state.event_buffer.lock().unwrap().take(); + if let Some(events) = drained { + for ev in &events { + emit_event(&app, ev); } } } @@ -283,10 +357,11 @@ pub async fn mcp_start( if state.is_running() { return Ok(()); } - let mut cmd = mcp_command(port, &token)?; + let mut cmd = mcp_command(port)?; suppress_console(&mut cmd); // Pipe stdout for the openDraft event channel; stderr stays inherited. - cmd.stdout(Stdio::piped()); + // stdin is piped solely for the one token line below. + cmd.stdout(Stdio::piped()).stdin(Stdio::piped()); #[cfg(unix)] { // Own process group so kill() can SIGKILL the whole pnpm/node tree. @@ -296,6 +371,12 @@ pub async fn mcp_start( let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn mcp server: {e}"))?; + // Hand the token over stdin, then drop the handle (EOF): the child reads + // exactly one line, and a failed write surfaces as the readiness timeout. + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + let _ = writeln!(stdin, "{token}"); + } // Known micro-race: a grandchild forked between spawn and AssignProcessToJobObject // escapes the job's kill-on-close. The canonical fix (CREATE_SUSPENDED + ResumeThread) // needs the primary-thread handle std::process::Child does not expose. @@ -340,7 +421,7 @@ pub struct McpStatus { pub fn mcp_status(state: State<'_, McpState>) -> McpStatus { McpStatus { running: state.is_running(), - available: SIDECAR_AVAILABLE, + available: sidecar_available(), } } @@ -396,6 +477,18 @@ mod tests { assert!(!is_listening_event("plain dev log line")); } + #[cfg(debug_assertions)] + #[test] + fn mcp_command_keeps_the_token_off_argv() { + let cmd = mcp_command(4923).unwrap(); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert!(args.contains(&"--token-stdin".to_string())); + assert!(!args.iter().any(|a| a == "--token")); + } + #[test] fn design_request_id_extracts_the_id() { assert_eq!( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b1f57eef..31028848 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ "frontendDist": "../dist", "devUrl": "http://localhost:5173", "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeBuildCommand": "pnpm build && pnpm --filter @zplab/mcp-server build:sea" }, "app": { "windows": [ @@ -37,6 +37,7 @@ "bundle": { "active": true, "targets": ["nsis", "app", "dmg", "appimage", "deb", "rpm"], + "externalBin": ["binaries/zplab-mcp"], "createUpdaterArtifacts": true, "publisher": "u8array", "copyright": "© 2026 u8array", diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 228ad06a..4678b98c 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -58,7 +58,7 @@ import { useT } from "../hooks/useT"; import { kbd } from "../lib/kbd"; import { useGlobalShortcuts } from "../hooks/useGlobalShortcuts"; import { useDesignFileActions } from "../hooks/useDesignFileActions"; -import { useMcpDesignRequest } from "../hooks/useMcpDesignRequest"; +import { useMcpBridge } from "../hooks/useMcpBridge"; import { useCsvImportActions } from "../hooks/useCsvImportActions"; import { useZplImportExport } from "../hooks/useZplImportExport"; import { useOutputPanel, OUTPUT_DEFAULT_H } from "../hooks/useOutputPanel"; @@ -162,7 +162,7 @@ export function AppShell() { const collisionDetection = makePaletteCollision(paletteEditing); useGlobalShortcuts(); - useMcpDesignRequest(); + useMcpBridge(); const { handleNew, handleSave, handleOpen, handleLoad, loadInputRef } = useDesignFileActions(); const { csvInputRef, diff --git a/src/hooks/useDesignFileActions.ts b/src/hooks/useDesignFileActions.ts index 7f39deb2..90194262 100644 --- a/src/hooks/useDesignFileActions.ts +++ b/src/hooks/useDesignFileActions.ts @@ -1,9 +1,8 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { useLabelStore } from "../store/labelStore"; -import { parseDesignFile, serializeDesign, designFileErrors } from "@zplab/core/lib/designFile"; +import { serializeDesign, designFileErrors } from "@zplab/core/lib/designFile"; import { readFileAsText } from "../lib/readFile"; import { pickFileText, pickViaMenu, saveTextFile, saveErrorMessage, DESIGN_FILTER } from "../lib/fileDialogs"; -import { isDesktopShell } from "../lib/platform"; export function useDesignFileActions() { const label = useLabelStore((s) => s.label); @@ -11,6 +10,7 @@ export function useDesignFileActions() { const variables = useLabelStore((s) => s.variables); const csvMapping = useLabelStore((s) => s.csvMapping); const loadDesign = useLabelStore((s) => s.loadDesign); + const loadDesignText = useLabelStore((s) => s.loadDesignText); const setUserError = useLabelStore((s) => s.setUserError); const clearUserError = useLabelStore((s) => s.clearUserError); const loadInputRef = useRef(null); @@ -30,60 +30,17 @@ export function useDesignFileActions() { .catch(() => setUserError(saveErrorMessage)); }; - const applyDesignText = (text: string) => { - const result = parseDesignFile(text); - if (!result.ok) { - setUserError(designFileErrors[result.error]); - return; - } - clearUserError(); - loadDesign( - result.value.label, - result.value.pages, - result.value.variables, - result.value.csvMapping, - ); - }; - // No clear here: a cancelled pick must leave any existing error in place; - // applyDesignText clears on a successful load instead. + // loadDesignText clears on a successful load instead. const handleOpen = () => { pickViaMenu( loadInputRef, () => pickFileText(DESIGN_FILTER), - (picked) => applyDesignText(picked.text), + (picked) => loadDesignText(picked.text), () => setUserError(designFileErrors.parse_error), ); }; - // The app-spawned MCP server pushes drafts through this Tauri event; route - // them through the same parse+load path as opening a file so an invalid - // payload surfaces identically. Read via ref so re-subscribing is unneeded. - const applyRef = useRef(applyDesignText); - useEffect(() => { - applyRef.current = applyDesignText; - }); - useEffect(() => { - if (!isDesktopShell) return; - let unlisten: (() => void) | undefined; - let cancelled = false; - void import("@tauri-apps/api/event") - .then(({ listen }) => - listen("mcp://open-draft", (event) => applyRef.current(event.payload)), - ) - .then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }) - // Bridge setup is desktop-only and non-actionable if it fails; swallow so - // it is not an unhandled rejection (matches the boot-start in main.tsx). - .catch(() => undefined); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - const handleLoad = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; @@ -96,7 +53,7 @@ export function useDesignFileActions() { setUserError(designFileErrors.parse_error); return; } - applyDesignText(text); + loadDesignText(text); }; return { handleNew, handleSave, handleOpen, handleLoad, loadInputRef }; diff --git a/src/hooks/useMcpDesignRequest.test.ts b/src/hooks/useMcpBridge.test.ts similarity index 96% rename from src/hooks/useMcpDesignRequest.test.ts rename to src/hooks/useMcpBridge.test.ts index 236e0bec..8c78e067 100644 --- a/src/hooks/useMcpDesignRequest.test.ts +++ b/src/hooks/useMcpBridge.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { respondToDesignRequest } from "./useMcpDesignRequest"; +import { respondToDesignRequest } from "./useMcpBridge"; import { useLabelStore } from "../store/labelStore"; import { setMeasuredBounds, clearMeasuredBounds } from "../components/Canvas/measuredBoundsCache"; diff --git a/src/hooks/useMcpBridge.ts b/src/hooks/useMcpBridge.ts new file mode 100644 index 00000000..fc6f1f47 --- /dev/null +++ b/src/hooks/useMcpBridge.ts @@ -0,0 +1,95 @@ +import { useEffect } from "react"; +import { serializeDesign } from "@zplab/core/lib/designFile"; +import { exportableLeaves } from "@zplab/core/types/Group"; +import { + measuredBoundsMap, + subscribeMeasuredBounds, +} from "../components/Canvas/measuredBoundsCache"; +import { postDesignResponse } from "../lib/mcpServer"; +import { isDesktopShell } from "../lib/platform"; +import { useLabelStore } from "../store/labelStore"; + +/** Re-measuring after an open_in_app swap is async (React commit + bwip), + * so an immediate snapshot could serve the OLD design's footprints. */ +const MEASURE_QUIESCE_MS = 150; +const MEASURE_SETTLE_CAP_MS = 1000; + +// Frame-less environments (tests, headless) have nothing to wait a frame for. +const nextFrame = (fn: () => void): void => { + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => fn()); + else setTimeout(fn, 0); +}; + +/** Resolve once the canvas has re-measured: two frames for the React commit, + * then no cache change for MEASURE_QUIESCE_MS (capped). */ +function measuredSettled(): Promise { + return new Promise((resolve) => { + let quiesce: ReturnType; + let unsubscribe: () => void = () => undefined; + const done = () => { + clearTimeout(quiesce); + clearTimeout(cap); + unsubscribe(); + resolve(); + }; + const cap = setTimeout(done, MEASURE_SETTLE_CAP_MS); + nextFrame(() => + nextFrame(() => { + quiesce = setTimeout(done, MEASURE_QUIESCE_MS); + unsubscribe = subscribeMeasuredBounds(() => { + clearTimeout(quiesce); + quiesce = setTimeout(done, MEASURE_QUIESCE_MS); + }); + }), + ); + }); +} + +/** POST design + measured footprints back to the sidecar; a failed reply + * surfaces via the sidecar's own request timeout. */ +export async function respondToDesignRequest(id: number): Promise { + await measuredSettled(); + const { label, pages, variables, csvMapping, mcpServerPort, mcpServerToken } = + useLabelStore.getState(); + const designFile: unknown = JSON.parse(serializeDesign(label, pages, variables, csvMapping)); + // A deleted object's leftover footprint must not ride along. + const liveIds = new Set(pages.flatMap((p) => exportableLeaves(p.objects)).map((o) => o.id)); + const measured = Object.fromEntries( + [...measuredBoundsMap()].filter(([objectId]) => liveIds.has(objectId)), + ); + await postDesignResponse(mcpServerPort, mcpServerToken, { id, designFile, measured }); +} + +/** Register both sidecar listeners, then have Rust flush the events it + * queued while none existed (see event_buffer in mcp.rs). */ +export function useMcpBridge(): void { + useEffect(() => { + if (!isDesktopShell) return; + const unlisteners: (() => void)[] = []; + let cancelled = false; + void (async () => { + const [{ listen }, { invoke }] = await Promise.all([ + import("@tauri-apps/api/event"), + import("@tauri-apps/api/core"), + ]); + const fns = await Promise.all([ + listen("mcp://open-draft", (e) => useLabelStore.getState().loadDesignText(e.payload)), + listen("mcp://design-request", (e) => { + void respondToDesignRequest(e.payload).catch(() => undefined); + }), + ]); + if (cancelled) { + for (const fn of fns) fn(); + return; + } + unlisteners.push(...fns); + await invoke("mcp_listeners_ready"); + })() + // Non-actionable on failure; swallow like the boot-start in main.tsx. + .catch(() => undefined); + return () => { + cancelled = true; + for (const fn of unlisteners) fn(); + }; + }, []); +} diff --git a/src/hooks/useMcpDesignRequest.ts b/src/hooks/useMcpDesignRequest.ts deleted file mode 100644 index 5ad6bcec..00000000 --- a/src/hooks/useMcpDesignRequest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect } from "react"; -import { serializeDesign } from "@zplab/core/lib/designFile"; -import { measuredBoundsMap } from "../components/Canvas/measuredBoundsCache"; -import { postDesignResponse } from "../lib/mcpServer"; -import { isDesktopShell } from "../lib/platform"; -import { useLabelStore } from "../store/labelStore"; - -/** Snapshot the store design plus the render-measured footprints and POST - * them back to the sidecar. A failed reply is not actionable here; the - * sidecar's request timeout turns it into a tool error for the caller. */ -export async function respondToDesignRequest(id: number): Promise { - const { label, pages, variables, csvMapping, mcpServerPort, mcpServerToken } = - useLabelStore.getState(); - const designFile: unknown = JSON.parse(serializeDesign(label, pages, variables, csvMapping)); - const measured = Object.fromEntries(measuredBoundsMap()); - await postDesignResponse(mcpServerPort, mcpServerToken, { id, designFile, measured }); -} - -/** Answer the sidecar's get_current_design requests. */ -export function useMcpDesignRequest(): void { - useEffect(() => { - if (!isDesktopShell) return; - let unlisten: (() => void) | undefined; - let cancelled = false; - void import("@tauri-apps/api/event") - .then(({ listen }) => - listen("mcp://design-request", (e) => { - void respondToDesignRequest(e.payload).catch(() => undefined); - }), - ) - .then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }) - // Bridge setup is desktop-only and non-actionable if it fails; swallow so - // it is not an unhandled rejection (matches the open-draft listener). - .catch(() => undefined); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); -} diff --git a/src/hooks/useMcpServer.ts b/src/hooks/useMcpServer.ts index fe4ba6e6..b13d74e2 100644 --- a/src/hooks/useMcpServer.ts +++ b/src/hooks/useMcpServer.ts @@ -79,12 +79,13 @@ export function useMcpServer(): McpServerController { setRun({ kind: "error", message: e instanceof Error ? e.message : String(e) }); }; - // Mount reconcile: a persisted opt-in that never started re-attempts so the - // actual reason shows. The tab only mounts when the build has the sidecar - // (TAB_GATES), so no availability re-check is needed here. + // Mount reconcile: a persisted opt-in that never started re-attempts so + // the reason shows. Keychain hydrate first: display and restart must see + // the stored token, not mint a fresh one over it. useEffect(() => { let mounted = true; enqueue(async (current) => { + await useLabelStore.getState().hydrateMcpToken(); // catch: a rejected status must not be an unhandled rejection. const status = await mcpServerStatus().catch(() => null); if (!status || !mounted || !current()) return; @@ -96,7 +97,8 @@ export function useMcpServer(): McpServerController { if (!status.available || !s.mcpServerEnabled) return; setRun({ kind: "starting" }); try { - await startMcpServer({ port: s.mcpServerPort, token: s.mcpServerToken }); + const restartToken = await s.ensureMcpToken(); + await startMcpServer({ port: s.mcpServerPort, token: restartToken }); if (mounted && current()) setRun({ kind: "running" }); } catch (e) { if (mounted && current()) fail(e); @@ -108,7 +110,6 @@ export function useMcpServer(): McpServerController { }, []); const toggle = (checked: boolean) => { - // Token is generated synchronously by setEnabled; the op reads it back fresh. if (checked) { setEnabled(true); setRun({ kind: "starting" }); @@ -119,8 +120,9 @@ export function useMcpServer(): McpServerController { if (!current()) return; // superseded: don't send a stale start/stop try { if (checked) { - const s = useLabelStore.getState(); - await startMcpServer({ port: s.mcpServerPort, token: s.mcpServerToken }); + const token = await useLabelStore.getState().ensureMcpToken(); + if (!current()) return; + await startMcpServer({ port: useLabelStore.getState().mcpServerPort, token }); if (current()) setRun({ kind: "running" }); } else { await stopMcpServer().catch(() => undefined); diff --git a/src/lib/credentialStore.ts b/src/lib/credentialStore.ts index 2d4a73a6..9da53eef 100644 --- a/src/lib/credentialStore.ts +++ b/src/lib/credentialStore.ts @@ -17,6 +17,36 @@ export async function getCredential(name: string): Promise { return localStorage.getItem(LS_PREFIX + name); } +/** Single-flight hydrate of one credential; the strategy hooks carry the + * policy (what a stored/empty/failed read means). A failed read retries on + * the next call. */ +export function makeCredentialHydrator(strategy: { + credName: string; + isLoaded: () => boolean; + onStored: (value: string) => void; + onEmpty: () => void; + onError: () => void; +}): () => Promise { + let inFlight: Promise | null = null; + return () => { + if (strategy.isLoaded()) return Promise.resolve(); + inFlight ??= (async () => { + try { + const stored = await getCredential(strategy.credName); + // A concurrent save may have set the value while we read; keep it. + if (strategy.isLoaded()) return; + if (stored) strategy.onStored(stored); + else strategy.onEmpty(); + } catch { + strategy.onError(); + } finally { + inFlight = null; + } + })(); + return inFlight; + }; +} + /** Store a credential; an empty/whitespace value deletes it. Throws (with the * backend's message) when the OS store is unavailable, e.g. a Linux session * without a Secret Service daemon. */ diff --git a/src/lib/mcpServer.ts b/src/lib/mcpServer.ts index fbaa8aa7..bcf0f8c5 100644 --- a/src/lib/mcpServer.ts +++ b/src/lib/mcpServer.ts @@ -14,9 +14,10 @@ export function generateMcpToken(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } -/** Claude-Desktop style config for the loopback Streamable HTTP server. The - * server serves the transport on any path, so the tested root `/` is the URL; - * every request must carry the bearer token. */ +/** Config snippet in the mcpServers JSON shape most MCP clients accept, for + * the loopback Streamable HTTP server. The server serves the transport on any + * path, so the tested root `/` is the URL; every request must carry the + * bearer token. */ export function mcpConfigSnippet(port: number, token: string): string { return JSON.stringify( { diff --git a/src/main.tsx b/src/main.tsx index 3cb9970b..d32fbabf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,9 +30,9 @@ async function bootstrap() { locale, applyLocale, hydrateLabelaryApiKey, + ensureMcpToken, mcpServerEnabled, mcpServerPort, - mcpServerToken, } = useLabelStore.getState(); // Load the API key from the OS credential store into memory before any // preview can fire; fire-and-forget so a slow keychain never delays paint. @@ -44,9 +44,12 @@ async function bootstrap() { .catch(() => undefined); // Bring the opt-in MCP server up on launch so an assistant can reach it // without opening settings; swallow errors so a failure never blocks paint - // (the settings tab re-attempts when the build has the sidecar). + // (the settings tab re-attempts when the build has the sidecar). The token + // hydrate must land first, so the chain is async but never awaited here. if (mcpServerEnabled) { - void startMcpServer({ port: mcpServerPort, token: mcpServerToken }).catch(() => undefined); + void ensureMcpToken() + .then((token) => startMcpServer({ port: mcpServerPort, token })) + .catch(() => undefined); } let timeoutId: number | undefined; await Promise.race([ diff --git a/src/store/labelStore.tripwire.test.ts b/src/store/labelStore.tripwire.test.ts index 9fe33119..2b5795e0 100644 --- a/src/store/labelStore.tripwire.test.ts +++ b/src/store/labelStore.tripwire.test.ts @@ -13,7 +13,7 @@ const EXPECTED_PERSIST_KEYS = [ 'locale', 'mcpServerEnabled', 'mcpServerPort', - 'mcpServerToken', + // mcpServerToken deliberately absent: keychain-held (see hydrateMcpToken). 'pages', 'paletteRows', 'paletteView', diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index e9d285f1..e4a23fa7 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -418,7 +418,6 @@ export const persistPartialize = (state: LabelState) => ({ showZplCommands: state.showZplCommands, mcpServerEnabled: state.mcpServerEnabled, mcpServerPort: state.mcpServerPort, - mcpServerToken: state.mcpServerToken, variables: state.variables, csvMapping: state.csvMapping, }); diff --git a/src/store/labelaryKey.test.ts b/src/store/labelaryKey.test.ts index 70ff4bea..c7bd8dbe 100644 --- a/src/store/labelaryKey.test.ts +++ b/src/store/labelaryKey.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -/** Mock the credential-store seam so these run in the node lane without Tauri - * or a real keychain. The store slice under test talks only to this seam. */ const getCredential = vi.fn<(name: string) => Promise>(); const setCredential = vi.fn<(name: string, value: string) => Promise>(); -vi.mock("../lib/credentialStore", () => ({ - getCredential: (name: string) => getCredential(name), - setCredential: (name: string, value: string) => setCredential(name, value), +// Mock one level deeper (the Tauri invoke seam) so the real credentialStore +// module, including makeCredentialHydrator, runs in the test. +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args: { name: string; value?: string }) => { + if (cmd === "credential_get") return getCredential(args.name); + if (cmd === "credential_set") return setCredential(args.name, args.value ?? ""); + if (cmd === "credential_delete") return setCredential(args.name, ""); + return Promise.reject(new Error(`unmocked command: ${cmd}`)); + }, })); // Pretend we're the desktop shell so a persisted 'printer' provider stays diff --git a/src/store/mcpToken.test.ts b/src/store/mcpToken.test.ts new file mode 100644 index 00000000..71c37a7a --- /dev/null +++ b/src/store/mcpToken.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const getCredential = vi.fn<(name: string) => Promise>(); +const setCredential = vi.fn<(name: string, value: string) => Promise>(); +// Mock one level deeper (the Tauri invoke seam) so the real credentialStore +// module, including makeCredentialHydrator, runs in the test. +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args: { name: string; value?: string }) => { + if (cmd === "credential_get") return getCredential(args.name); + if (cmd === "credential_set") return setCredential(args.name, args.value ?? ""); + if (cmd === "credential_delete") return setCredential(args.name, ""); + return Promise.reject(new Error(`unmocked command: ${cmd}`)); + }, +})); +vi.mock("../lib/platform", () => ({ isDesktopShell: true })); + +import { useLabelStore } from "./labelStore"; + +const FALLBACK_LS = "zpl-mcp-token-fallback"; +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + getCredential.mockReset(); + setCredential.mockReset(); + setCredential.mockResolvedValue(); + localStorage.removeItem(FALLBACK_LS); + useLabelStore.setState({ mcpServerToken: "", mcpServerTokenLoaded: false }); +}); + +describe("mcp token hydration", () => { + it("loads the keychain token and clears a stale fallback slot", async () => { + localStorage.setItem(FALLBACK_LS, "stale"); + getCredential.mockResolvedValue("kc-token"); + await useLabelStore.getState().hydrateMcpToken(); + expect(useLabelStore.getState().mcpServerToken).toBe("kc-token"); + expect(useLabelStore.getState().mcpServerTokenLoaded).toBe(true); + expect(localStorage.getItem(FALLBACK_LS)).toBeNull(); + }); + + it("migrates a pre-keychain localStorage-persisted token into the keychain", async () => { + useLabelStore.setState({ mcpServerToken: "legacy-token" }); + getCredential.mockResolvedValue(null); + await useLabelStore.getState().hydrateMcpToken(); + expect(useLabelStore.getState().mcpServerToken).toBe("legacy-token"); + expect(setCredential).toHaveBeenCalledWith("mcp-server-token", "legacy-token"); + }); + + it("uses the fallback slot when the credential store is unreadable", async () => { + localStorage.setItem(FALLBACK_LS, "fb-token"); + getCredential.mockRejectedValue(new Error("no daemon")); + await useLabelStore.getState().hydrateMcpToken(); + expect(useLabelStore.getState().mcpServerToken).toBe("fb-token"); + expect(useLabelStore.getState().mcpServerTokenLoaded).toBe(true); + }); + + it("stays unloaded on a read failure without a fallback (transient keychain error retries)", async () => { + getCredential.mockRejectedValueOnce(new Error("no daemon")); + await useLabelStore.getState().hydrateMcpToken(); + expect(useLabelStore.getState().mcpServerTokenLoaded).toBe(false); + }); + + it("keeps a newly generated token in the fallback slot when the keychain write fails", async () => { + setCredential.mockRejectedValue(new Error("no daemon")); + getCredential.mockResolvedValue(null); + const token = await useLabelStore.getState().ensureMcpToken(); + expect(token).not.toBe(""); + await flush(); + expect(localStorage.getItem(FALLBACK_LS)).toBe(token); + }); + + it("ensureMcpToken adopts a stored token instead of racing a fresh one over it", async () => { + // Slow keychain: enabling before the hydrate resolves must still end up + // with the STORED token (a fresh one would break the wired-up config). + let release: (v: string) => void = () => undefined; + getCredential.mockReturnValue(new Promise((r) => (release = r))); + const pending = useLabelStore.getState().ensureMcpToken(); + release("kc-token"); + expect(await pending).toBe("kc-token"); + expect(useLabelStore.getState().mcpServerToken).toBe("kc-token"); + expect(setCredential).not.toHaveBeenCalled(); + }); + + it("ensureMcpToken refuses to generate after a failed read (empty is not KNOWN empty)", async () => { + getCredential.mockRejectedValueOnce(new Error("transient")); + await expect(useLabelStore.getState().ensureMcpToken()).rejects.toThrow( + "credential store unavailable", + ); + expect(setCredential).not.toHaveBeenCalled(); + }); + + it("ensureMcpToken generates only when the store is KNOWN empty", async () => { + getCredential.mockResolvedValue(null); + const token = await useLabelStore.getState().ensureMcpToken(); + expect(token).toMatch(/^[0-9a-f]{32}$/); + expect(setCredential).toHaveBeenCalledWith("mcp-server-token", token); + }); +}); diff --git a/src/store/slices/labelConfigSlice.ts b/src/store/slices/labelConfigSlice.ts index 6e30849a..ecbd0597 100644 --- a/src/store/slices/labelConfigSlice.ts +++ b/src/store/slices/labelConfigSlice.ts @@ -4,6 +4,7 @@ import type { Page } from '@zplab/core/types/Group'; import type { Variable, CsvMapping } from '@zplab/core/types/Variable'; import { forgetImport } from '../../lib/csvImport'; import { dropLegacyFontBindings } from '@zplab/core/lib/customFonts'; +import { parseDesignFile, designFileErrors } from '@zplab/core/lib/designFile'; import { selectPreviewLocksEditor } from '../labelStore.selectors'; import { configPatchAffectsEmit } from '../labelStore.internals'; import { dropPageOverlays } from '@zplab/core/lib/pageOverlay'; @@ -27,6 +28,9 @@ export interface LabelConfigSlice { variables?: Variable[], csvMapping?: CsvMapping | null, ) => void; + /** Parse serialized design-file text and load it, routing a parse failure + * to userError; every text source (file open, MCP push) shares this path. */ + loadDesignText: (text: string) => void; /** Append pages to the current design without touching label config. * Switches focus to the first appended page. */ appendPages: (pages: Page[]) => void; @@ -76,6 +80,21 @@ export const createLabelConfigSlice: StateCreator { + const result = parseDesignFile(text); + if (!result.ok) { + get().setUserError(designFileErrors[result.error]); + return; + } + get().clearUserError(); + get().loadDesign( + result.value.label, + result.value.pages, + result.value.variables, + result.value.csvMapping, + ); + }, + appendPages: (pages) => set((state) => { if (selectPreviewLocksEditor(state)) return {}; diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index d9d3bd41..d63ce398 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -9,17 +9,26 @@ import { } from '../labelStore.internals'; import type { LabelState } from '../labelStore'; import { defaultPaletteRows } from '../../registry/paletteTypes'; -import { getCredential, setCredential } from '../../lib/credentialStore'; +import { makeCredentialHydrator, setCredential } from '../../lib/credentialStore'; import { generateMcpToken, stopMcpServer } from '../../lib/mcpServer'; import { selectEffectivePreviewProvider } from '../labelStore.selectors'; /** Credential-store account name for the Labelary API key. */ const LABELARY_KEY_CRED = 'labelary-api-key'; -/** Deduplicates concurrent startup/settings hydrations of the API key into one - * credential read. Module-scoped transient coordination, not source of truth; - * cleared when the read settles so a failed load can retry. */ -let hydrateInFlight: Promise | null = null; +/** Credential-store account name for the MCP loopback bearer token. */ +const MCP_TOKEN_CRED = 'mcp-server-token'; + +/** Fallback slot for systems without a working credential store: a token + * lost on reboot breaks wired-up client configs, which outweighs plaintext + * storage THERE. Cleared whenever a keychain write succeeds. */ +const MCP_TOKEN_FALLBACK_LS = 'zpl-mcp-token-fallback'; + +function persistMcpToken(token: string): void { + void setCredential(MCP_TOKEN_CRED, token) + .then(() => localStorage.removeItem(MCP_TOKEN_FALLBACK_LS)) + .catch(() => localStorage.setItem(MCP_TOKEN_FALLBACK_LS, token)); +} export interface CanvasSettings { showGrid: boolean; @@ -136,9 +145,11 @@ export interface UiSlice { mcpServerEnabled: boolean; mcpServerPort: number; /** Bearer token for the loopback server, generated on first enable and reused - * so a Claude Desktop config the user already wired up keeps working. - * Persisted but out of the settings-reset defaults (see labelaryHost). */ + * so an MCP-client config the user already wired up keeps working. Held + * in the OS credential store (like the Labelary key), transient here. */ mcpServerToken: string; + /** Keychain hydrate happened (or a fresh token was generated/saved). */ + mcpServerTokenLoaded: boolean; /** Build capability from mcp_status: whether this build can spawn the MCP * sidecar (false on web and sidecar-less releases). Stamped at boot; * null until the first status lands. Transient. */ @@ -207,6 +218,12 @@ export interface UiSlice { setMcpServerEnabled: (enabled: boolean) => void; setMcpServerPort: (port: number) => void; regenerateMcpToken: () => void; + /** Load the token from the credential store (single-flight); migrates a + * token still sitting in the old localStorage persist on first run. */ + hydrateMcpToken: () => Promise; + /** The token every server start must use: hydrated, or freshly generated + * only when the store is KNOWN empty. */ + ensureMcpToken: () => Promise; setMcpSidecarAvailable: (available: boolean) => void; setSidebarTab: (tab: SidebarTab) => void; setBlockDragMode: (mode: BlockDragMode) => void; @@ -290,9 +307,9 @@ export const createUiSlice: StateCreator = (set, ge labelaryHost: '', labelaryApiKey: '', labelaryApiKeyLoaded: false, - // Token is persisted but out of `defaultUiPrefs`, so a settings reset keeps - // it (regenerating would break a Claude Desktop config already pointing here). + // Keychain-held like the Labelary key; a settings reset keeps it. mcpServerToken: '', + mcpServerTokenLoaded: false, mcpSidecarAvailable: null, sidebarTab: 'properties', blockDragMode: 'frame', @@ -347,28 +364,15 @@ export const createUiSlice: StateCreator = (set, ge set({ labelaryApiKey: trimmed, labelaryApiKeyLoaded: true }); if (selectEffectivePreviewProvider(get()) === 'labelary') get().exitPreviewMode(); }, - hydrateLabelaryApiKey: () => { - if (get().labelaryApiKeyLoaded) return Promise.resolve(); - // Single-flight: the startup bootstrap and a settings-open retry can race; - // share one read so they can't issue two keychain prompts, and so the - // preview/print path can await the same load. Reset on completion so a - // failed read retries. This is transient coordination, not stored state. - hydrateInFlight ??= (async () => { - try { - const key = await getCredential(LABELARY_KEY_CRED); - // A user save during the read already set the key; don't clobber it. - if (!get().labelaryApiKeyLoaded) { - set({ labelaryApiKey: (key ?? '').trim(), labelaryApiKeyLoaded: true }); - } - } catch { - // Store unreadable (e.g. no Secret Service daemon): stay unloaded so a - // later settings-open or preview retries instead of caching keyless. - } finally { - hydrateInFlight = null; - } - })(); - return hydrateInFlight; - }, + // Losing this key is harmless (the user re-enters it), so there is no + // fallback: an unreadable store stays unloaded and a later open retries. + hydrateLabelaryApiKey: makeCredentialHydrator({ + credName: LABELARY_KEY_CRED, + isLoaded: () => get().labelaryApiKeyLoaded, + onStored: (key) => set({ labelaryApiKey: key.trim(), labelaryApiKeyLoaded: true }), + onEmpty: () => set({ labelaryApiKey: '', labelaryApiKeyLoaded: true }), + onError: () => undefined, + }), acknowledgeLabelaryNotice: () => set({ labelaryNoticeAcknowledged: true }), // Revoke consent so the Labelary gate closes again; re-enabling re-shows the // disclosure, keeping consent explicit and reversible. Tear down any live @@ -425,14 +429,49 @@ export const createUiSlice: StateCreator = (set, ge setPaletteView: (view) => set({ paletteView: view }), togglePaletteEditing: () => set((state) => ({ paletteEditing: !state.paletteEditing })), setShowZplCommands: (show) => set({ showZplCommands: show }), - setMcpServerEnabled: (enabled) => - set((state) => ({ - mcpServerEnabled: enabled, - mcpServerToken: - enabled && !state.mcpServerToken ? generateMcpToken() : state.mcpServerToken, - })), + setMcpServerEnabled: (enabled) => set({ mcpServerEnabled: enabled }), setMcpServerPort: (port) => set({ mcpServerPort: port }), - regenerateMcpToken: () => set({ mcpServerToken: generateMcpToken() }), + regenerateMcpToken: () => { + const token = generateMcpToken(); + persistMcpToken(token); + set({ mcpServerToken: token, mcpServerTokenLoaded: true }); + }, + ensureMcpToken: async () => { + await get().hydrateMcpToken(); + const existing = get().mcpServerToken; + if (existing) return existing; + // Still unloaded = the read FAILED, not known-empty: minting now could + // clobber the stored token wired-up client configs point at. + if (!get().mcpServerTokenLoaded) throw new Error('credential store unavailable'); + const token = generateMcpToken(); + persistMcpToken(token); + set({ mcpServerToken: token, mcpServerTokenLoaded: true }); + return token; + }, + hydrateMcpToken: makeCredentialHydrator({ + credName: MCP_TOKEN_CRED, + isLoaded: () => get().mcpServerTokenLoaded, + onStored: (token) => { + localStorage.removeItem(MCP_TOKEN_FALLBACK_LS); + set({ mcpServerToken: token.trim(), mcpServerTokenLoaded: true }); + }, + onEmpty: () => { + // Adopt the broken-store fallback or the pre-keychain persist (migration). + const legacy = localStorage.getItem(MCP_TOKEN_FALLBACK_LS) ?? get().mcpServerToken; + if (legacy) { + persistMcpToken(legacy); + set({ mcpServerToken: legacy, mcpServerTokenLoaded: true }); + } else { + set({ mcpServerTokenLoaded: true }); + } + }, + onError: () => { + // The fallback slot is the truth on broken-store systems; without one + // stay unloaded so a transient failure retries instead of minting. + const fallback = localStorage.getItem(MCP_TOKEN_FALLBACK_LS); + if (fallback) set({ mcpServerToken: fallback, mcpServerTokenLoaded: true }); + }, + }), setMcpSidecarAvailable: (available) => set({ mcpSidecarAvailable: available }), setSidebarTab: (tab) => set({ sidebarTab: tab }), setBlockDragMode: (mode) => set({ blockDragMode: mode }),