carouselbot 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # CarouselBot MCP
2
2
 
3
- Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.bot). It exposes project, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
3
+ Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.bot). It exposes project, folder, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
4
4
 
5
5
  ```bash
6
6
  npx carouselbot@latest setup
7
7
  ```
8
8
 
9
+ Setup pins that exact package version in the generated MCP configuration. Rerun
10
+ the command when you intentionally want to update.
11
+
9
12
  For non-interactive agent setup, select the current client explicitly:
10
13
 
11
14
  ```bash
@@ -22,17 +25,33 @@ The same validated tool surface is available through the package CLI when a runn
22
25
  npx -y carouselbot@latest call get_design_guidance
23
26
  npx -y carouselbot@latest call list_editors
24
27
  npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
25
- npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation"}'
28
+ npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","folderPath":"/campaigns"}'
26
29
  ```
27
30
 
28
31
  Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists names compactly or returns selected schemas. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
29
32
 
33
+ Folders use exact canonical slash paths such as `/campaigns` and are derived from project membership. Pass `folderPath` to `create_project` to create a project in a folder. Use `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes are part of the virtual path and do not create a nested UI hierarchy. Because folders are implicit, moving the last project out removes the empty folder card automatically. `inspect_editor` returns each project's current folder path.
34
+
30
35
  Always use `list_editors` to check the browser connection. Do not open CarouselBot or click **Connect AI** through a sandboxed, remote, or agent-controlled browser: that is a different browser session and may not reach the local companion.
31
36
 
32
37
  Browser reconnection is automatic. Retry transient disconnects; use `restart` only for an explicit protocol mismatch or failed daemon health check. Hermes can refresh native tools with `/reload-mcp` and `/reload-skills`. Claude may need a new session to register a newly added server, but the CLI fallback works immediately.
33
38
 
34
39
  The companion binds only to `127.0.0.1`. There is no hosted relay: projects remain in browser IndexedDB and local images remain on the user's computer.
35
40
 
41
+ ## Installed fonts
42
+
43
+ CarouselBot can use fonts installed on the same Mac as the companion. Open a text layer's font control once and choose **Allow local fonts**. The permission is stored in that browser; until it is granted, agent calls return `FONT_PERMISSION_REQUIRED`.
44
+
45
+ Agents use project-scoped IDs rather than CSS family guesses:
46
+
47
+ ```text
48
+ list_local_fonts({ query: "Didot" })
49
+ import_font({ editSessionId, projectId, localFontId })
50
+ add_text({ editSessionId, projectId, slideId, text, fontId })
51
+ ```
52
+
53
+ `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight`, `fontStyle`, and variable-axis settings. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback.
54
+
36
55
  Any MCP client can launch it with:
37
56
 
38
57
  ```bash
@@ -20,6 +20,7 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
20
20
  - Align related text layers consistently. Center is a safe default; use left alignment for editorial layouts.
21
21
  - Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
22
22
  - Reuse a small palette and consistent type scale across the project.
23
+ - For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
23
24
 
24
25
  ## Working method
25
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/server": "2.0.0",
31
+ "fontkit": "^2.0.4",
31
32
  "zod": "^4.4.3"
32
33
  },
33
34
  "publishConfig": {
@@ -23,8 +23,8 @@ If the native MCP tools are not registered in the current session, do not stop o
23
23
  ```bash
24
24
  npx -y carouselbot@latest call get_design_guidance
25
25
  npx -y carouselbot@latest call list_editors
26
- npx -y carouselbot@latest call list_tools --json '{"names":["add_slide","add_text"]}'
27
- npx -y carouselbot@latest call create_project --json '{"name":"My presentation"}'
26
+ npx -y carouselbot@latest call list_tools --json '{"names":["create_project","move_project","add_slide","add_text"]}'
27
+ npx -y carouselbot@latest call create_project --json '{"name":"My presentation","folderPath":"/campaigns"}'
28
28
  ```
29
29
 
30
30
  Every tool accepts the same JSON arguments as MCP. Use `list_tools` without arguments for compact discovery or pass `{"names":[...]}` to retrieve selected schemas. Prefer `apply_operations` for batches. `render_slide` writes its returned image to a temporary local `previewPath`; inspect that file and remove the temporary directory after the review. Hermes can load the native tools in place with `/reload-mcp` and refresh this skill with `/reload-skills`.
@@ -46,6 +46,17 @@ With only one agent and one editor, the server can create an implicit session fo
46
46
 
47
47
  Inspect the assigned editor before editing and keep the returned IDs and revision. Work on the assigned project and slide. Pass `expectedRevision` for sensitive mutations. If `STALE_PROJECT` is returned, the browser has reloaded the canonical IndexedDB copy; inspect again and retry with current IDs. Prefer `apply_operations` for compact related changes while preserving logical order.
48
48
 
49
+ When the user asks for a font installed on their Mac, use the deterministic two-ID flow:
50
+
51
+ 1. Call `list_local_fonts` with a family/style query and choose an exact face from its metadata. Never guess a CSS family or construct a `localFontId`.
52
+ 2. Call `import_font` with that opaque `localFontId`. Keep the returned project-scoped `fontId`.
53
+ 3. Pass the returned `fontId` to `add_text` or `update_text`. Import and application are separate calls for a newly selected face; do not invent a batch placeholder.
54
+ 4. Render and inspect the result. Use `list_project_fonts` to reuse faces already embedded in the project.
55
+
56
+ If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
57
+
58
+ Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes remain part of one virtual path rather than creating a nested UI hierarchy. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
59
+
49
60
  Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
50
61
 
51
62
  After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe overlay placement, and weak hierarchy before claiming the slide is finished. Use `export_slide` or `export_project` only when local files are requested; do not overwrite existing files unless authorized.
package/src/daemon.mjs CHANGED
@@ -2,17 +2,21 @@
2
2
  import { createServer } from "node:http";
3
3
  import { randomBytes, randomUUID } from "node:crypto";
4
4
  import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
- import { basename, extname } from "node:path";
5
+ import { basename, delimiter, extname } from "node:path";
6
6
  import {
7
7
  ALLOWED_ORIGINS, AUDIT_LOG_PATH, BRIDGE_HOST, BRIDGE_PORT, BRIDGE_URL, DAEMON_LOCK_PATH,
8
8
  DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION, PROTOCOL_VERSION, STATE_DIRECTORY,
9
9
  } from "./config.mjs";
10
+ import { createLocalFontService } from "./local-fonts.mjs";
10
11
 
11
12
  const MAX_JSON_BYTES = 40 * 1024 * 1024;
12
13
  const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
13
14
  const EDITOR_TTL_MS = Number(process.env.CAROUSELBOT_EDITOR_TTL_MS || process.env.SLIDE_STUDIO_EDITOR_TTL_MS) || 60_000;
14
15
  const CLIENT_TTL_MS = 45_000;
15
16
  const MEDIA_TTL_MS = 5 * 60_000;
17
+ const FONT_MEDIA_TTL_MS = 5 * 60_000;
18
+ const MAX_FONT_MEDIA_ITEMS = 32;
19
+ const MAX_FONT_MEDIA_BYTES = 256 * 1024 * 1024;
16
20
  const COMMAND_TIMEOUT_MS = 90_000;
17
21
  const EDIT_SESSION_TTL_MS = Number(process.env.CAROUSELBOT_EDIT_SESSION_TTL_MS || process.env.SLIDE_STUDIO_EDIT_SESSION_TTL_MS) || 5 * 60_000;
18
22
  const MAX_AUDIT_EVENTS = 500;
@@ -24,8 +28,17 @@ const editors = new Map();
24
28
  const clients = new Map();
25
29
  const inflight = new Map();
26
30
  const media = new Map();
31
+ const fontMedia = new Map();
27
32
  const editSessions = new Map();
28
33
  const auditEvents = [];
34
+ const configuredFontDirectories = String(process.env.CAROUSELBOT_FONT_DIRS || process.env.SLIDE_STUDIO_FONT_DIRS || "")
35
+ .split(delimiter)
36
+ .map((value) => value.trim())
37
+ .filter(Boolean);
38
+ const localFonts = createLocalFontService({
39
+ cacheDirectory: STATE_DIRECTORY,
40
+ ...(configuredFontDirectories.length ? { directories: configuredFontDirectories } : {}),
41
+ });
29
42
  let focusedEditorId = null;
30
43
  let lockHandle = null;
31
44
  let idleSince = null;
@@ -189,7 +202,7 @@ function browserCors(origin) {
189
202
  "Access-Control-Allow-Origin": origin,
190
203
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
191
204
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
192
- "Access-Control-Expose-Headers": "X-CarouselBot-Filename, X-Slide-Studio-Filename",
205
+ "Access-Control-Expose-Headers": "X-CarouselBot-Filename, X-Slide-Studio-Filename, X-CarouselBot-Local-Font-Id",
193
206
  "Access-Control-Allow-Private-Network": "true",
194
207
  "Access-Control-Max-Age": "600",
195
208
  "Cache-Control": "no-store",
@@ -203,6 +216,18 @@ function sendJson(response, statusCode, value, headers = {}) {
203
216
  response.end(body);
204
217
  }
205
218
 
219
+ function sendLocalFont(response, item, headers = {}) {
220
+ response.writeHead(200, {
221
+ ...headers,
222
+ "Content-Type": item.mimeType,
223
+ "Content-Length": item.buffer.length,
224
+ "X-CarouselBot-Filename": encodeURIComponent(item.filename),
225
+ "X-Slide-Studio-Filename": encodeURIComponent(item.filename),
226
+ "X-CarouselBot-Local-Font-Id": encodeURIComponent(item.font.localFontId),
227
+ });
228
+ response.end(item.buffer);
229
+ }
230
+
206
231
  function readJson(request) {
207
232
  return new Promise((resolve, reject) => {
208
233
  const chunks = [];
@@ -269,6 +294,7 @@ function disconnectEditor(editorId, message = "Browser editor disconnected.") {
269
294
  endEditorPoll(editor);
270
295
  for (const session of editSessions.values()) if (session.editorId === editorId) releaseEditSession(session.id, "editor disconnected");
271
296
  editors.delete(editorId);
297
+ for (const [id, item] of fontMedia) if (item.editorId === editorId) fontMedia.delete(id);
272
298
  if (focusedEditorId === editorId) focusedEditorId = null;
273
299
  for (const [requestId, pending] of inflight) {
274
300
  if (pending.editorId !== editorId) continue;
@@ -305,6 +331,22 @@ function selectEditor(clientId) {
305
331
  throw new Error("Multiple editors are connected and none is selected. Call list_editors, then select_editor.");
306
332
  }
307
333
 
334
+ function requireLocalFontPermission(editor) {
335
+ if (editor?.localFontsEnabled) return editor;
336
+ throw codedError("FONT_PERMISSION_REQUIRED", "Open CarouselBot and enable local fonts.");
337
+ }
338
+
339
+ function selectLocalFontEditor(clientId) {
340
+ return requireLocalFontPermission(selectEditor(clientId));
341
+ }
342
+
343
+ function localFontEditorForCall(clientId, editSessionId) {
344
+ if (!editSessionId) return selectLocalFontEditor(clientId);
345
+ const session = requireEditSession(editSessionId);
346
+ session.lastClientId = clientId;
347
+ return requireLocalFontPermission(editors.get(session.editorId));
348
+ }
349
+
308
350
  function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId }) {
309
351
  const client = clients.get(clientId) || { id: clientId, name: "MCP agent" };
310
352
  if (editSessionId) {
@@ -348,10 +390,21 @@ function callBrowser(clientId, toolName, operation, label, { editSessionId = nul
348
390
  return new Promise((resolve, reject) => {
349
391
  const timer = setTimeout(() => {
350
392
  inflight.delete(requestId);
393
+ if (operation?.fontMediaId) fontMedia.delete(operation.fontMediaId);
351
394
  recordAudit({ action: "tool.result", client, session, editorId: editor.id, projectId, toolName, status: "error", message: "Browser timeout" });
352
395
  reject(codedError("BROWSER_TIMEOUT", "The browser did not answer within 90 seconds."));
353
396
  }, COMMAND_TIMEOUT_MS);
354
- inflight.set(requestId, { resolve, reject, timer, editorId: editor.id, client, session, projectId, toolName });
397
+ inflight.set(requestId, {
398
+ resolve,
399
+ reject,
400
+ timer,
401
+ editorId: editor.id,
402
+ client,
403
+ session,
404
+ projectId,
405
+ toolName,
406
+ fontMediaId: operation?.fontMediaId || null,
407
+ });
355
408
  queueEditorEvent(editor, { kind: "command", requestId, toolName, operation, label, editSessionId: session?.id || null, agent: publicClient(client) });
356
409
  });
357
410
  }
@@ -382,6 +435,51 @@ async function prepareMedia(filePath) {
382
435
  return { mediaId: id, filename: basename(filePath), mimeType, size: buffer.length };
383
436
  }
384
437
 
438
+ function localFontFilename(font, mimeType) {
439
+ const extension = ({
440
+ "font/ttf": "ttf",
441
+ "font/otf": "otf",
442
+ "font/woff": "woff",
443
+ "font/woff2": "woff2",
444
+ })[mimeType] || "font";
445
+ const stem = String(font?.postscriptName || font?.localFontId || "local-font")
446
+ .replace(/[^a-z0-9._-]+/gi, "-")
447
+ .replace(/^-+|-+$/g, "") || "local-font";
448
+ return `${stem}.${extension}`;
449
+ }
450
+
451
+ async function readLocalFontFace(localFontId) {
452
+ if (!localFontId || typeof localFontId !== "string") throw codedError("FONT_NOT_FOUND", "A valid localFontId is required.");
453
+ const prepared = await localFonts.readFace(localFontId);
454
+ if (!prepared?.font || !prepared?.buffer) throw codedError("FONT_NOT_FOUND", `Local font is unavailable: ${localFontId}`);
455
+ const buffer = Buffer.isBuffer(prepared.buffer) ? prepared.buffer : Buffer.from(prepared.buffer);
456
+ const mimeType = prepared.mimeType || "application/octet-stream";
457
+ return { font: prepared.font, buffer, mimeType, filename: localFontFilename(prepared.font, mimeType) };
458
+ }
459
+
460
+ async function prepareFont(clientId, localFontId, editSessionId) {
461
+ const editor = localFontEditorForCall(clientId, editSessionId);
462
+ const prepared = await readLocalFontFace(localFontId);
463
+ const now = Date.now();
464
+ let retainedBytes = 0;
465
+ for (const [id, item] of fontMedia) {
466
+ if (item.expiresAt < now) fontMedia.delete(id);
467
+ else retainedBytes += item.buffer.length;
468
+ }
469
+ if (fontMedia.size >= MAX_FONT_MEDIA_ITEMS || retainedBytes + prepared.buffer.length > MAX_FONT_MEDIA_BYTES) {
470
+ throw codedError("FONT_TRANSFER_LIMIT", "Too many local fonts are waiting to be transferred. Finish the pending imports and retry.");
471
+ }
472
+ const fontMediaId = randomUUID();
473
+ fontMedia.set(fontMediaId, {
474
+ ...prepared,
475
+ id: fontMediaId,
476
+ editorId: editor.id,
477
+ localFontId: prepared.font.localFontId,
478
+ expiresAt: now + FONT_MEDIA_TTL_MS,
479
+ });
480
+ return { font: prepared.font, fontMediaId };
481
+ }
482
+
385
483
  async function writeExport(filePath, data, overwrite) {
386
484
  const buffer = Buffer.from(data, "base64");
387
485
  const handle = await open(filePath, overwrite ? "w" : "wx", 0o600).catch((error) => {
@@ -430,6 +528,11 @@ async function handleInternalCall(body) {
430
528
  const events = auditEvents.filter((event) => (!body.projectId || event.projectId === body.projectId) && (!body.status || event.status === body.status));
431
529
  return { events: events.slice(-limit).reverse(), localLogPath: AUDIT_LOG_PATH };
432
530
  }
531
+ if (body.action === "list_local_fonts") {
532
+ localFontEditorForCall(body.clientId, body.editSessionId);
533
+ return localFonts.list({ query: body.query, limit: body.limit, cursor: body.cursor, sort: body.sort });
534
+ }
535
+ if (body.action === "prepare_font") return prepareFont(body.clientId, body.localFontId, body.editSessionId);
433
536
  if (body.action === "prepare_media") return prepareMedia(body.path);
434
537
  if (body.action === "write_export") return writeExport(body.path, body.data, Boolean(body.overwrite));
435
538
  if (body.action === "notify") {
@@ -518,12 +621,14 @@ const server = createServer(async (request, response) => {
518
621
  }
519
622
  const editor = {
520
623
  id: body.editorId, queue: [], poll: null, pageUrl: body.pageUrl,
521
- pollTimer: null, state: body.state, lastSeen: Date.now(), cors, sessionToken: randomBytes(32).toString("base64url"),
624
+ pollTimer: null, state: body.state, lastSeen: Date.now(), cors,
625
+ localFontsEnabled: body.localFontsEnabled === true,
626
+ sessionToken: randomBytes(32).toString("base64url"),
522
627
  };
523
628
  editors.set(editor.id, editor);
524
629
  if (body.hasFocus && body.visibilityState === "visible") focusedEditorId = editor.id;
525
630
  log(`Editor connected (${editor.id.slice(0, 8)})`);
526
- return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, version: PACKAGE_VERSION, agents: activeClients().map(publicClient), editSessions: activeEditSessions().map(publicSession) }, cors);
631
+ return sendJson(response, 200, { ok: true, editorId: editor.id, sessionToken: editor.sessionToken, protocolVersion: PROTOCOL_VERSION, version: PACKAGE_VERSION, localFontsEnabled: editor.localFontsEnabled, agents: activeClients().map(publicClient), editSessions: activeEditSessions().map(publicSession) }, cors);
527
632
  }
528
633
  if (url.pathname === "/activate" && request.method === "POST") {
529
634
  const body = await readJson(request);
@@ -545,6 +650,55 @@ const server = createServer(async (request, response) => {
545
650
  disconnectEditor(editor.id);
546
651
  return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
547
652
  }
653
+ if (url.pathname === "/fonts/enable" && request.method === "POST") {
654
+ const body = await readJson(request);
655
+ const editor = requireEditor(request, response, body.editorId, cors);
656
+ if (!editor) return;
657
+ editor.localFontsEnabled = true;
658
+ return sendJson(response, 200, { enabled: true }, cors);
659
+ }
660
+ if (url.pathname === "/fonts" && request.method === "GET") {
661
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
662
+ if (!editor) return;
663
+ requireLocalFontPermission(editor);
664
+ const result = await localFonts.list({
665
+ query: url.searchParams.get("query") || "",
666
+ limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
667
+ cursor: url.searchParams.get("cursor") || null,
668
+ sort: url.searchParams.get("sort") || undefined,
669
+ });
670
+ return sendJson(response, 200, result, cors);
671
+ }
672
+ if (url.pathname === "/fonts/use" && request.method === "POST") {
673
+ const body = await readJson(request);
674
+ const editor = requireEditor(request, response, body.editorId, cors);
675
+ if (!editor) return;
676
+ requireLocalFontPermission(editor);
677
+ const font = await localFonts.markUsed(body.localFontId);
678
+ if (!font) throw codedError("FONT_NOT_FOUND", `Local font is unavailable: ${body.localFontId}`);
679
+ return sendJson(response, 200, { font }, cors);
680
+ }
681
+ if (url.pathname.startsWith("/fonts/") && request.method === "GET") {
682
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
683
+ if (!editor) return;
684
+ requireLocalFontPermission(editor);
685
+ const localFontId = decodeURIComponent(url.pathname.slice("/fonts/".length));
686
+ return sendLocalFont(response, await readLocalFontFace(localFontId), cors);
687
+ }
688
+ if (url.pathname.startsWith("/font-media/") && request.method === "GET") {
689
+ const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
690
+ if (!editor) return;
691
+ requireLocalFontPermission(editor);
692
+ const id = decodeURIComponent(url.pathname.slice("/font-media/".length));
693
+ const item = fontMedia.get(id);
694
+ if (!item || item.expiresAt < Date.now()) {
695
+ fontMedia.delete(id);
696
+ throw codedError("FONT_MEDIA_UNAVAILABLE", "Local font transfer is missing or expired.");
697
+ }
698
+ if (item.editorId !== editor.id) throw codedError("FONT_MEDIA_UNAVAILABLE", "Local font transfer is missing or expired.");
699
+ fontMedia.delete(id);
700
+ return sendLocalFont(response, item, cors);
701
+ }
548
702
  if (url.pathname === "/events" && request.method === "GET") {
549
703
  const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
550
704
  if (!editor) return;
@@ -580,6 +734,7 @@ const server = createServer(async (request, response) => {
580
734
  if (!pending || pending.editorId !== editor.id) return sendJson(response, 404, { error: "Unknown request." }, cors);
581
735
  inflight.delete(body.requestId);
582
736
  clearTimeout(pending.timer);
737
+ if (pending.fontMediaId) fontMedia.delete(pending.fontMediaId);
583
738
  if (body.state) editor.state = body.state;
584
739
  if (body.ok) {
585
740
  try {
@@ -612,7 +767,11 @@ const server = createServer(async (request, response) => {
612
767
  return sendJson(response, 404, { error: "Not found." }, cors);
613
768
  } catch (error) {
614
769
  const headers = cors || {};
615
- const statusCode = error.code === "ENOENT" ? 404 : error.code === "EACCES" ? 403 : 400;
770
+ const statusCode = ["ENOENT", "FONT_NOT_FOUND", "FONT_MEDIA_UNAVAILABLE"].includes(error.code)
771
+ ? 404
772
+ : ["EACCES", "FONT_PERMISSION_REQUIRED"].includes(error.code)
773
+ ? 403
774
+ : error.code === "FONT_TRANSFER_LIMIT" ? 429 : 400;
616
775
  return sendJson(response, statusCode, { error: error.message }, headers);
617
776
  }
618
777
  });
@@ -661,6 +820,7 @@ async function cleanup() {
661
820
  setInterval(() => {
662
821
  const now = Date.now();
663
822
  for (const [id, item] of media) if (item.expiresAt < now) media.delete(id);
823
+ for (const [id, item] of fontMedia) if (item.expiresAt < now) fontMedia.delete(id);
664
824
  for (const session of editSessions.values()) if (session.lastSeen < now - EDIT_SESSION_TTL_MS) releaseEditSession(session.id, "lease expired");
665
825
  let clientsChanged = false;
666
826
  for (const [id, client] of clients) if (client.lastSeen < now - CLIENT_TTL_MS) {
@@ -0,0 +1,574 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import {
4
+ chmod,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readdir,
9
+ realpath,
10
+ rename,
11
+ stat,
12
+ unlink,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { homedir, platform } from "node:os";
16
+ import { dirname, extname, join } from "node:path";
17
+ import * as fontkit from "fontkit";
18
+
19
+ const INDEX_VERSION = 1;
20
+ const CURSOR_VERSION = 1;
21
+ const DEFAULT_REFRESH_INTERVAL_MS = 30_000;
22
+ const MAX_FONT_FILE_BYTES = 256 * 1024 * 1024;
23
+ const MAX_EXTRACTED_FONT_BYTES = 128 * 1024 * 1024;
24
+ const MAX_DISCOVERED_FILES = 20_000;
25
+ const MAX_COLLECTION_FACES = 512;
26
+ const MAX_SFNT_TABLES = 4_096;
27
+ const PUBLIC_FONT_KEYS = [
28
+ "localFontId",
29
+ "family",
30
+ "fullName",
31
+ "postscriptName",
32
+ "subfamily",
33
+ "weight",
34
+ "italic",
35
+ "lastUsedAt",
36
+ "variableAxes",
37
+ ];
38
+ const FONT_EXTENSIONS = new Set([".ttf", ".otf", ".ttc", ".woff", ".woff2"]);
39
+ const STYLE_WEIGHTS = [
40
+ [/\b(?:thin|hairline)\b/i, 100],
41
+ [/\b(?:extra[ -]?light|ultra[ -]?light)\b/i, 200],
42
+ [/\blight\b/i, 300],
43
+ [/\b(?:medium)\b/i, 500],
44
+ [/\b(?:semi[ -]?bold|demi[ -]?bold)\b/i, 600],
45
+ [/\b(?:extra[ -]?bold|ultra[ -]?bold)\b/i, 800],
46
+ [/\b(?:black|heavy)\b/i, 900],
47
+ [/\bbold\b/i, 700],
48
+ ];
49
+ const STYLE_ORDER = [
50
+ /\bregular\b/i,
51
+ /\bbook\b/i,
52
+ /\bmedium\b/i,
53
+ /\b(?:semi[ -]?bold|demi[ -]?bold)\b/i,
54
+ /\bbold\b/i,
55
+ ];
56
+
57
+ export function defaultMacFontDirectories() {
58
+ if (platform() !== "darwin") return [];
59
+ return [
60
+ join(homedir(), "Library", "Fonts"),
61
+ "/Library/Fonts",
62
+ "/System/Library/Fonts",
63
+ "/System/Library/Fonts/Supplemental",
64
+ ];
65
+ }
66
+
67
+ function codedError(code, message) {
68
+ const error = new Error(`[${code}] ${message}`);
69
+ error.code = code;
70
+ return error;
71
+ }
72
+
73
+ function sha256(value, encoding = "hex") {
74
+ return createHash("sha256").update(value).digest(encoding);
75
+ }
76
+
77
+ function usageGenerationFor(usage) {
78
+ return sha256(JSON.stringify([...usage.entries()].sort(([left], [right]) => left.localeCompare(right))), "base64url").slice(0, 24);
79
+ }
80
+
81
+ function stableFontId(pathInternal, faceIndex, size, mtimeMs) {
82
+ const identity = `${pathInternal}\0${faceIndex}\0${size}\0${mtimeMs}`;
83
+ return `font_${sha256(identity, "base64url").slice(0, 24)}`;
84
+ }
85
+
86
+ function cleanString(value, fallback = "") {
87
+ const normalized = String(value ?? "")
88
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
89
+ .replace(/\s+/g, " ")
90
+ .trim()
91
+ .slice(0, 240);
92
+ return normalized || fallback;
93
+ }
94
+
95
+ function inferredWeight(font, subfamily) {
96
+ const os2Weight = Number(font?.["OS/2"]?.usWeightClass);
97
+ if (Number.isFinite(os2Weight) && os2Weight >= 1 && os2Weight <= 1_000) return Math.round(os2Weight);
98
+ const variationWeight = Number(font?.variationAxes?.wght?.default);
99
+ if (Number.isFinite(variationWeight) && variationWeight >= 1 && variationWeight <= 1_000) return Math.round(variationWeight);
100
+ const match = STYLE_WEIGHTS.find(([pattern]) => pattern.test(subfamily));
101
+ return match?.[1] || 400;
102
+ }
103
+
104
+ function variableAxes(font) {
105
+ return Object.entries(font?.variationAxes || {}).flatMap(([rawTag, value]) => {
106
+ const tag = cleanString(rawTag).slice(0, 4);
107
+ const min = Number(value?.min);
108
+ const max = Number(value?.max);
109
+ const defaultValue = Number(value?.default);
110
+ if (!/^[\x20-\x7e]{4}$/.test(tag) || ![min, max, defaultValue].every(Number.isFinite) || min > max) return [];
111
+ return [{
112
+ tag,
113
+ name: cleanString(value?.name, tag),
114
+ min,
115
+ max,
116
+ default: Math.min(max, Math.max(min, defaultValue)),
117
+ }];
118
+ }).sort((left, right) => left.tag.localeCompare(right.tag, "en"));
119
+ }
120
+
121
+ function metadataForFont(font, source, faceIndex) {
122
+ const postscriptName = cleanString(font?.postscriptName);
123
+ const fullName = cleanString(font?.fullName, postscriptName);
124
+ const family = cleanString(font?.familyName, fullName || postscriptName || "Unknown font");
125
+ const subfamily = cleanString(font?.subfamilyName, "Regular");
126
+ const fsSelection = font?.["OS/2"]?.fsSelection || {};
127
+ const italic = Boolean(fsSelection.italic || fsSelection.oblique || Number(font?.italicAngle));
128
+ const localFontId = stableFontId(source.pathInternal, faceIndex, source.size, source.mtimeMs);
129
+ return {
130
+ localFontId,
131
+ family,
132
+ fullName: fullName || family,
133
+ postscriptName: postscriptName || fullName || family,
134
+ sourcePostscriptName: postscriptName || null,
135
+ subfamily,
136
+ weight: inferredWeight(font, subfamily),
137
+ italic,
138
+ variableAxes: variableAxes(font),
139
+ pathInternal: source.pathInternal,
140
+ faceIndex,
141
+ size: source.size,
142
+ mtimeMs: source.mtimeMs,
143
+ fileFingerprint: source.fileFingerprint,
144
+ fingerprint: sha256(`${source.fileFingerprint}\0${faceIndex}\0${postscriptName}`),
145
+ extension: source.extension,
146
+ };
147
+ }
148
+
149
+ function publicFont(font, usage) {
150
+ const value = {
151
+ localFontId: font.localFontId,
152
+ family: font.family,
153
+ fullName: font.fullName,
154
+ postscriptName: font.postscriptName,
155
+ subfamily: font.subfamily,
156
+ weight: font.weight,
157
+ italic: Boolean(font.italic),
158
+ lastUsedAt: usage.get(font.localFontId) || null,
159
+ variableAxes: Array.isArray(font.variableAxes) ? font.variableAxes.map((axis) => ({ ...axis })) : [],
160
+ };
161
+ return Object.fromEntries(PUBLIC_FONT_KEYS.map((key) => [key, value[key]]));
162
+ }
163
+
164
+ function styleRank(font) {
165
+ const style = `${font.subfamily} ${font.italic ? "Italic" : ""}`;
166
+ const base = STYLE_ORDER.findIndex((pattern) => pattern.test(style));
167
+ return (font.italic ? 100 : 0) + (base < 0 ? 5 : base);
168
+ }
169
+
170
+ const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true });
171
+
172
+ function alphabetical(left, right) {
173
+ return collator.compare(left.family, right.family)
174
+ || styleRank(left) - styleRank(right)
175
+ || collator.compare(left.subfamily, right.subfamily)
176
+ || collator.compare(left.fullName, right.fullName)
177
+ || left.localFontId.localeCompare(right.localFontId);
178
+ }
179
+
180
+ function normalizedQuery(value) {
181
+ return cleanString(value).normalize("NFKC").toLocaleLowerCase("en-US").slice(0, 240);
182
+ }
183
+
184
+ function cursorValue(value) {
185
+ try {
186
+ if (typeof value !== "string" || !value || value.length > 2_048) throw new Error("invalid");
187
+ const decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
188
+ if (decoded?.v !== CURSOR_VERSION || !Number.isInteger(decoded.offset) || decoded.offset < 0) throw new Error("invalid");
189
+ return decoded;
190
+ } catch {
191
+ throw codedError("INVALID_FONT_CURSOR", "The local-font cursor is invalid or expired. Start listing again without a cursor.");
192
+ }
193
+ }
194
+
195
+ function encodeCursor(value) {
196
+ return Buffer.from(JSON.stringify({ v: CURSOR_VERSION, ...value })).toString("base64url");
197
+ }
198
+
199
+ async function readJson(path) {
200
+ if (!path) return null;
201
+ try { return JSON.parse(await readFile(path, "utf8")); }
202
+ catch { return null; }
203
+ }
204
+
205
+ async function writePrivateJson(path, value) {
206
+ if (!path) return;
207
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
208
+ const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
209
+ try {
210
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
211
+ await rename(temporary, path);
212
+ await chmod(path, 0o600).catch(() => {});
213
+ } finally {
214
+ await unlink(temporary).catch(() => {});
215
+ }
216
+ }
217
+
218
+ async function fontPaths(directories) {
219
+ const roots = [];
220
+ for (const candidate of directories) {
221
+ try { roots.push(await realpath(candidate)); }
222
+ catch { /* Missing font roots are normal. */ }
223
+ }
224
+ roots.sort();
225
+ const discovered = new Map();
226
+ const pending = [...new Set(roots)];
227
+ while (pending.length) {
228
+ const directory = pending.shift();
229
+ let entries;
230
+ try { entries = await readdir(directory, { withFileTypes: true }); }
231
+ catch { continue; }
232
+ entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
233
+ for (const entry of entries) {
234
+ const path = join(directory, entry.name);
235
+ if (entry.isDirectory()) {
236
+ pending.push(path);
237
+ continue;
238
+ }
239
+ if (!entry.isFile() || !FONT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
240
+ let canonical;
241
+ let metadata;
242
+ try {
243
+ canonical = await realpath(path);
244
+ metadata = await stat(canonical);
245
+ } catch { continue; }
246
+ if (!metadata.isFile() || metadata.size <= 0 || metadata.size > MAX_FONT_FILE_BYTES) continue;
247
+ discovered.set(canonical, {
248
+ pathInternal: canonical,
249
+ size: metadata.size,
250
+ mtimeMs: Number(metadata.mtimeMs),
251
+ extension: extname(canonical).toLowerCase(),
252
+ });
253
+ if (discovered.size > MAX_DISCOVERED_FILES) {
254
+ throw codedError("FONT_INDEX_TOO_LARGE", "Too many local font files were found to index safely.");
255
+ }
256
+ }
257
+ }
258
+ return [...discovered.values()].sort((left, right) => left.pathInternal.localeCompare(right.pathInternal));
259
+ }
260
+
261
+ async function readFileSafely(pathInternal) {
262
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
263
+ const handle = await open(pathInternal, flags);
264
+ try {
265
+ const metadata = await handle.stat();
266
+ if (!metadata.isFile() || metadata.size <= 0 || metadata.size > MAX_FONT_FILE_BYTES) {
267
+ throw codedError("FONT_UNAVAILABLE", "The selected local font is unavailable or too large.");
268
+ }
269
+ return { buffer: await handle.readFile(), metadata };
270
+ } finally {
271
+ await handle.close();
272
+ }
273
+ }
274
+
275
+ function mimeTypeFor(buffer, extension = "") {
276
+ const signature = buffer.subarray(0, 4).toString("latin1");
277
+ if (signature === "wOF2") return "font/woff2";
278
+ if (signature === "wOFF") return "font/woff";
279
+ if (signature === "OTTO") return "font/otf";
280
+ if (["\u0000\u0001\u0000\u0000", "true", "typ1"].includes(signature)) return "font/ttf";
281
+ if (extension === ".woff2") return "font/woff2";
282
+ if (extension === ".woff") return "font/woff";
283
+ if (extension === ".otf") return "font/otf";
284
+ return "font/ttf";
285
+ }
286
+
287
+ function checksum(buffer) {
288
+ let sum = 0;
289
+ for (let offset = 0; offset < buffer.length; offset += 4) {
290
+ let word = 0;
291
+ for (let index = 0; index < 4; index += 1) word = ((word << 8) | (buffer[offset + index] || 0)) >>> 0;
292
+ sum = (sum + word) >>> 0;
293
+ }
294
+ return sum;
295
+ }
296
+
297
+ function checkedRange(buffer, offset, length, label) {
298
+ if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > buffer.length) {
299
+ throw codedError("INVALID_FONT_COLLECTION", `The selected collection has an invalid ${label}.`);
300
+ }
301
+ }
302
+
303
+ function align4(value) {
304
+ return Math.ceil(value / 4) * 4;
305
+ }
306
+
307
+ /** Repack one TTC/OTC face as a standalone, browser-loadable SFNT font. */
308
+ export function extractTtcFace(value, faceIndex) {
309
+ const source = Buffer.isBuffer(value) ? value : Buffer.from(value);
310
+ checkedRange(source, 0, 12, "header");
311
+ if (source.subarray(0, 4).toString("ascii") !== "ttcf") {
312
+ throw codedError("INVALID_FONT_COLLECTION", "The selected font is not a TrueType/OpenType collection.");
313
+ }
314
+ const faceCount = source.readUInt32BE(8);
315
+ if (!faceCount || faceCount > MAX_COLLECTION_FACES) throw codedError("INVALID_FONT_COLLECTION", "The collection has an invalid face count.");
316
+ if (!Number.isInteger(faceIndex) || faceIndex < 0 || faceIndex >= faceCount) {
317
+ throw codedError("FONT_FACE_NOT_FOUND", `Font face ${faceIndex} does not exist in this collection.`);
318
+ }
319
+ checkedRange(source, 12, faceCount * 4, "face directory");
320
+ const faceOffset = source.readUInt32BE(12 + faceIndex * 4);
321
+ checkedRange(source, faceOffset, 12, "face header");
322
+ const sfntVersion = source.subarray(faceOffset, faceOffset + 4);
323
+ const signature = sfntVersion.toString("latin1");
324
+ if (!["\u0000\u0001\u0000\u0000", "true", "typ1", "OTTO"].includes(signature)) {
325
+ throw codedError("INVALID_FONT_COLLECTION", "The selected collection face is not an SFNT font.");
326
+ }
327
+ const tableCount = source.readUInt16BE(faceOffset + 4);
328
+ if (!tableCount || tableCount > MAX_SFNT_TABLES) throw codedError("INVALID_FONT_COLLECTION", "The collection face has an invalid table count.");
329
+ checkedRange(source, faceOffset + 12, tableCount * 16, "table directory");
330
+ const records = [];
331
+ const tags = new Set();
332
+ for (let index = 0; index < tableCount; index += 1) {
333
+ const recordOffset = faceOffset + 12 + index * 16;
334
+ const tagBytes = source.subarray(recordOffset, recordOffset + 4);
335
+ const tag = tagBytes.toString("latin1");
336
+ const offset = source.readUInt32BE(recordOffset + 8);
337
+ const length = source.readUInt32BE(recordOffset + 12);
338
+ checkedRange(source, offset, length, `table ${JSON.stringify(tag)}`);
339
+ if (tags.has(tag)) throw codedError("INVALID_FONT_COLLECTION", "The collection face contains duplicate table tags.");
340
+ tags.add(tag);
341
+ if (tag !== "DSIG") records.push({ tag, tagBytes: Buffer.from(tagBytes), offset, length });
342
+ }
343
+ const head = records.find((record) => record.tag === "head");
344
+ if (!head || head.length < 12) throw codedError("INVALID_FONT_COLLECTION", "The collection face has no valid head table.");
345
+
346
+ let outputLength = 12 + records.length * 16;
347
+ for (const record of records) {
348
+ outputLength = align4(outputLength);
349
+ record.outputOffset = outputLength;
350
+ outputLength += align4(record.length);
351
+ if (outputLength > MAX_EXTRACTED_FONT_BYTES) throw codedError("FONT_TOO_LARGE", "The selected font face is too large to transfer safely.");
352
+ }
353
+ const output = Buffer.alloc(outputLength);
354
+ sfntVersion.copy(output, 0);
355
+ output.writeUInt16BE(records.length, 4);
356
+ const highestPowerOfTwo = 2 ** Math.floor(Math.log2(records.length));
357
+ output.writeUInt16BE(highestPowerOfTwo * 16, 6);
358
+ output.writeUInt16BE(Math.log2(highestPowerOfTwo), 8);
359
+ output.writeUInt16BE(records.length * 16 - highestPowerOfTwo * 16, 10);
360
+
361
+ for (let index = 0; index < records.length; index += 1) {
362
+ const record = records[index];
363
+ source.copy(output, record.outputOffset, record.offset, record.offset + record.length);
364
+ if (record.tag === "head") output.writeUInt32BE(0, record.outputOffset + 8);
365
+ const directoryOffset = 12 + index * 16;
366
+ record.tagBytes.copy(output, directoryOffset);
367
+ output.writeUInt32BE(checksum(output.subarray(record.outputOffset, record.outputOffset + record.length)), directoryOffset + 4);
368
+ output.writeUInt32BE(record.outputOffset, directoryOffset + 8);
369
+ output.writeUInt32BE(record.length, directoryOffset + 12);
370
+ }
371
+ const adjustment = (0xb1b0afba - checksum(output)) >>> 0;
372
+ output.writeUInt32BE(adjustment, head.outputOffset + 8);
373
+ if (checksum(output) !== 0xb1b0afba) throw codedError("INVALID_FONT_COLLECTION", "The extracted font checksum could not be repaired.");
374
+ return output;
375
+ }
376
+
377
+ function safeFilename(font, mimeType) {
378
+ const extension = mimeType === "font/woff2" ? ".woff2" : mimeType === "font/woff" ? ".woff" : mimeType === "font/otf" ? ".otf" : ".ttf";
379
+ const stem = cleanString(font.postscriptName || font.fullName || "local-font", "local-font")
380
+ .replace(/[^a-z0-9._-]+/gi, "-")
381
+ .replace(/^-+|-+$/g, "")
382
+ .slice(0, 120) || "local-font";
383
+ return `${stem}${extension}`;
384
+ }
385
+
386
+ export function createLocalFontService({
387
+ directories = defaultMacFontDirectories(),
388
+ cacheDirectory = null,
389
+ cachePath = cacheDirectory ? join(cacheDirectory, "local-font-index-v1.json") : null,
390
+ usagePath = cacheDirectory ? join(cacheDirectory, "local-font-usage-v1.json") : null,
391
+ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
392
+ now = () => Date.now(),
393
+ } = {}) {
394
+ let records = new Map();
395
+ let files = [];
396
+ let generation = "empty";
397
+ let initialized = false;
398
+ let lastRefreshAt = -Infinity;
399
+ let refreshPromise = null;
400
+ let usagePromise = null;
401
+ let usage = new Map();
402
+ let usageGeneration = usageGenerationFor(usage);
403
+ let usageWrite = Promise.resolve();
404
+
405
+ async function loadUsage() {
406
+ if (usagePromise) return usagePromise;
407
+ usagePromise = (async () => {
408
+ const cached = await readJson(usagePath);
409
+ usage = new Map(Object.entries(cached?.usage || {}).flatMap(([id, timestamp]) => (
410
+ /^font_[A-Za-z0-9_-]{8,}$/.test(id) && Number.isFinite(Number(timestamp)) && Number(timestamp) > 0
411
+ ? [[id, Number(timestamp)]]
412
+ : []
413
+ )));
414
+ usageGeneration = usageGenerationFor(usage);
415
+ return usage;
416
+ })();
417
+ return usagePromise;
418
+ }
419
+
420
+ async function buildIndex() {
421
+ const candidates = await fontPaths(directories);
422
+ const cached = await readJson(cachePath);
423
+ const cachedFiles = new Map((cached?.version === INDEX_VERSION && Array.isArray(cached.files) ? cached.files : [])
424
+ .map((file) => [file.pathInternal, file]));
425
+ const nextFiles = [];
426
+ const seenFingerprints = new Set();
427
+ const nextRecords = new Map();
428
+ for (const candidate of candidates) {
429
+ const previous = cachedFiles.get(candidate.pathInternal);
430
+ let indexed;
431
+ if (previous && previous.size === candidate.size && previous.mtimeMs === candidate.mtimeMs && previous.extension === candidate.extension) {
432
+ indexed = previous;
433
+ } else {
434
+ try {
435
+ const { buffer } = await readFileSafely(candidate.pathInternal);
436
+ const fileFingerprint = sha256(buffer);
437
+ const parsed = fontkit.create(buffer);
438
+ const parsedFaces = Array.isArray(parsed?.fonts) ? parsed.fonts : [parsed];
439
+ if (!parsedFaces.length || parsedFaces.length > MAX_COLLECTION_FACES) throw new Error("Invalid face count");
440
+ const source = { ...candidate, fileFingerprint };
441
+ indexed = {
442
+ ...candidate,
443
+ fileFingerprint,
444
+ faces: parsedFaces.map((font, faceIndex) => metadataForFont(font, source, faceIndex)),
445
+ };
446
+ } catch {
447
+ indexed = { ...candidate, fileFingerprint: null, faces: [], invalid: true };
448
+ }
449
+ }
450
+ nextFiles.push(indexed);
451
+ if (!indexed.fileFingerprint || seenFingerprints.has(indexed.fileFingerprint)) continue;
452
+ seenFingerprints.add(indexed.fileFingerprint);
453
+ for (const face of indexed.faces || []) nextRecords.set(face.localFontId, face);
454
+ }
455
+ files = nextFiles;
456
+ records = nextRecords;
457
+ generation = sha256([...records.keys()].sort().join("\0"), "base64url").slice(0, 24);
458
+ lastRefreshAt = now();
459
+ initialized = true;
460
+ await writePrivateJson(cachePath, { version: INDEX_VERSION, generatedAt: lastRefreshAt, files });
461
+ return records;
462
+ }
463
+
464
+ async function refresh({ force = false } = {}) {
465
+ if (!force && initialized && now() - lastRefreshAt < refreshIntervalMs) return records;
466
+ if (!refreshPromise) refreshPromise = buildIndex().finally(() => { refreshPromise = null; });
467
+ return refreshPromise;
468
+ }
469
+
470
+ async function list({ query = "", limit = 50, cursor = null, sort = "recent_then_alphabetical" } = {}) {
471
+ await Promise.all([refresh(), loadUsage()]);
472
+ const safeQuery = normalizedQuery(query);
473
+ const safeSort = sort === "alphabetical" ? "alphabetical" : "recent_then_alphabetical";
474
+ const safeLimit = Math.max(1, Math.min(200, Number.isFinite(Number(limit)) ? Math.trunc(Number(limit)) : 50));
475
+ let offset = 0;
476
+ if (cursor) {
477
+ const decoded = cursorValue(cursor);
478
+ if (
479
+ decoded.generation !== generation
480
+ || decoded.query !== safeQuery
481
+ || decoded.sort !== safeSort
482
+ || (safeSort === "recent_then_alphabetical" && decoded.usageGeneration !== usageGeneration)
483
+ ) {
484
+ throw codedError("INVALID_FONT_CURSOR", "The local-font cursor is invalid or expired. Start listing again without a cursor.");
485
+ }
486
+ offset = decoded.offset;
487
+ }
488
+ let matches = [...records.values()].filter((font) => {
489
+ if (!safeQuery) return true;
490
+ return [font.family, font.fullName, font.postscriptName, font.subfamily]
491
+ .some((value) => normalizedQuery(value).includes(safeQuery));
492
+ });
493
+ if (safeSort === "alphabetical") matches.sort(alphabetical);
494
+ else {
495
+ const recent = matches
496
+ .filter((font) => usage.has(font.localFontId))
497
+ .sort((left, right) => usage.get(right.localFontId) - usage.get(left.localFontId) || alphabetical(left, right))
498
+ .slice(0, 8);
499
+ const recentIds = new Set(recent.map((font) => font.localFontId));
500
+ matches = [...recent, ...matches.filter((font) => !recentIds.has(font.localFontId)).sort(alphabetical)];
501
+ }
502
+ const page = matches.slice(offset, offset + safeLimit);
503
+ const nextOffset = offset + page.length;
504
+ return {
505
+ fonts: page.map((font) => publicFont(font, usage)),
506
+ nextCursor: nextOffset < matches.length
507
+ ? encodeCursor({
508
+ generation,
509
+ query: safeQuery,
510
+ sort: safeSort,
511
+ offset: nextOffset,
512
+ ...(safeSort === "recent_then_alphabetical" ? { usageGeneration } : {}),
513
+ })
514
+ : null,
515
+ };
516
+ }
517
+
518
+ async function resolve(localFontId) {
519
+ await Promise.all([refresh(), loadUsage()]);
520
+ const font = records.get(String(localFontId || ""));
521
+ return font ? { ...font, lastUsedAt: usage.get(font.localFontId) || null } : null;
522
+ }
523
+
524
+ async function readFace(localFontId) {
525
+ let font = await resolve(localFontId);
526
+ if (!font) return null;
527
+ let read;
528
+ try { read = await readFileSafely(font.pathInternal); }
529
+ catch {
530
+ await refresh({ force: true });
531
+ throw codedError("FONT_UNAVAILABLE", `${font.fullName} is not available on this device.`);
532
+ }
533
+ const currentMtime = Number(read.metadata.mtimeMs);
534
+ const currentFingerprint = sha256(read.buffer);
535
+ if (read.metadata.size !== font.size || currentMtime !== font.mtimeMs || currentFingerprint !== font.fileFingerprint) {
536
+ await refresh({ force: true });
537
+ throw codedError("FONT_UNAVAILABLE", `${font.fullName} changed on this device. List local fonts again and use its new ID.`);
538
+ }
539
+ let buffer = read.buffer;
540
+ if (buffer.subarray(0, 4).toString("ascii") === "ttcf") buffer = extractTtcFace(buffer, font.faceIndex);
541
+ const mimeType = mimeTypeFor(buffer, font.extension);
542
+ try {
543
+ const parsed = fontkit.create(buffer);
544
+ if (Array.isArray(parsed?.fonts) || (font.sourcePostscriptName && cleanString(parsed?.postscriptName) !== font.sourcePostscriptName)) {
545
+ throw new Error("Extracted face mismatch");
546
+ }
547
+ } catch {
548
+ throw codedError("FONT_UNAVAILABLE", `${font.fullName} could not be prepared for the browser.`);
549
+ }
550
+ return {
551
+ font: publicFont(font, usage),
552
+ buffer,
553
+ mimeType,
554
+ filename: safeFilename(font, mimeType),
555
+ };
556
+ }
557
+
558
+ async function markUsed(localFontId, at = now()) {
559
+ await Promise.all([refresh(), loadUsage()]);
560
+ const font = records.get(String(localFontId || ""));
561
+ if (!font) return null;
562
+ const timestamp = Number.isFinite(Number(at)) && Number(at) > 0 ? Number(at) : now();
563
+ usage.set(font.localFontId, timestamp);
564
+ usageGeneration = usageGenerationFor(usage);
565
+ usageWrite = usageWrite.then(() => writePrivateJson(usagePath, {
566
+ version: INDEX_VERSION,
567
+ usage: Object.fromEntries([...usage.entries()].sort(([left], [right]) => left.localeCompare(right))),
568
+ }));
569
+ await usageWrite;
570
+ return publicFont(font, usage);
571
+ }
572
+
573
+ return { list, resolve, readFace, markUsed, refresh };
574
+ }
@@ -6,6 +6,9 @@ import { EDITOR_URL, GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION } from "./conf
6
6
 
7
7
  const id = z.string().min(1).max(160);
8
8
  const optionalId = id.optional();
9
+ const folderPath = z.string().min(2).max(160)
10
+ .regex(/^\/(?!\/)\S(?:[\s\S]*\S)?$/, "Use a canonical folder path with one leading slash and no surrounding whitespace, for example /my-folder.")
11
+ .refine((value) => value !== "/." && value !== "/..", "Folder paths cannot use the reserved names /. or /..");
9
12
  const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
10
13
  const unit = z.number().min(-0.5).max(1.5);
11
14
  const positiveUnit = z.number().min(0.01).max(2.4);
@@ -13,12 +16,17 @@ const expectedRevision = z.number().int().min(0).optional().describe("Optional o
13
16
  const editSessionId = optionalId.describe("Edit session from begin_edit_session. Required for coordinated parallel editing.");
14
17
  const targetProject = { editSessionId, projectId: optionalId, expectedRevision };
15
18
  const targetSlide = { editSessionId, projectId: optionalId, slideId: optionalId, expectedRevision };
19
+ const fontId = z.union([id, z.null()]).optional().describe("Project font ID returned by import_font. Use null to restore the built-in font.");
16
20
  const textFields = {
17
21
  text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
18
22
  role: z.enum(["title", "subtitle", "body", "caption"]).optional().describe("Semantic size role. Recommended ranges: title 92-124, subtitle 68-84, body 54-68, caption 44-52."),
19
23
  size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
20
24
  outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
21
25
  backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
26
+ fontId,
27
+ fontWeight: z.number().int().min(1).max(1000).optional(),
28
+ fontStyle: z.enum(["normal", "italic"]).optional(),
29
+ fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional(),
22
30
  rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
23
31
  };
24
32
  const imageFields = {
@@ -35,8 +43,11 @@ function textResult(value, summary = value) {
35
43
  }
36
44
 
37
45
  function compactMutation(value) {
38
- const keys = ["id", "editSessionId", "editorId", "projectId", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
39
- return Object.fromEntries(keys.flatMap((key) => value?.[key] == null ? [] : [[key, value[key]]]));
46
+ const keys = ["id", "editSessionId", "editorId", "projectId", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
47
+ return Object.fromEntries(keys.flatMap((key) => {
48
+ if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
49
+ return value?.[key] == null ? [] : [[key, value[key]]];
50
+ }));
40
51
  }
41
52
 
42
53
  function clientIdentity(context, server) {
@@ -53,10 +64,10 @@ async function pathExists(value) {
53
64
 
54
65
  function operationLabel(toolName) {
55
66
  return ({
56
- create_project: "Creating a project…", update_project: "Updating the project…", delete_project: "Deleting a project…",
67
+ create_project: "Creating a project…", update_project: "Updating the project…", move_project: "Moving the project…", delete_project: "Deleting a project…",
57
68
  open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
58
69
  duplicate_slide: "Duplicating a slide…", reorder_slides: "Reordering slides…", delete_slide: "Deleting a slide…",
59
- add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_asset: "Importing a local image…",
70
+ add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_font: "Adding a local font…", import_asset: "Importing a local image…",
60
71
  update_asset: "Updating an image asset…", delete_asset: "Deleting an image asset…", add_image: "Placing an image…",
61
72
  update_image: "Updating an image…", delete_layers: "Deleting layers…", duplicate_layers: "Duplicating layers…",
62
73
  reorder_layers: "Reordering layers…", undo: "Undoing the last edit…", redo: "Redoing the last edit…",
@@ -67,11 +78,11 @@ function operationLabel(toolName) {
67
78
  async function browserOperation(companion, toolName, args) {
68
79
  const { editSessionId: sessionId, ...toolArgs } = args;
69
80
  const definition = definitions.get(toolName);
70
- const operation = await prepareOperation(companion, toolName, toolArgs);
81
+ const operation = await prepareOperation(companion, toolName, toolArgs, sessionId);
71
82
  return companion.call("browser", { toolName, operation, label: operationLabel(toolName), editSessionId: sessionId, mutating: Boolean(definition?.mutating) });
72
83
  }
73
84
 
74
- async function prepareOperation(companion, toolName, args) {
85
+ async function prepareOperation(companion, toolName, args, editSessionId = null) {
75
86
  const operation = { ...args };
76
87
  if (operation.backgroundPath) {
77
88
  const prepared = await companion.call("prepare_media", { path: absolutePath(operation.backgroundPath) });
@@ -83,10 +94,15 @@ async function prepareOperation(companion, toolName, args) {
83
94
  operation.mediaId = prepared.mediaId;
84
95
  delete operation.path;
85
96
  }
97
+ if (toolName === "import_font") {
98
+ const prepared = await companion.call("prepare_font", { localFontId: operation.localFontId, editSessionId });
99
+ operation.font = prepared.font;
100
+ operation.fontMediaId = prepared.fontMediaId;
101
+ }
86
102
  const type = ({
87
- create_project: "project.create", open_project: "project.open", update_project: "project.update", delete_project: "project.delete",
103
+ create_project: "project.create", open_project: "project.open", update_project: "project.update", move_project: "project.move", delete_project: "project.delete",
88
104
  add_slide: "slide.add", update_slide: "slide.update", duplicate_slide: "slide.duplicate", reorder_slides: "slide.reorder", delete_slide: "slide.delete",
89
- add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
105
+ add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_font: "font.import", list_project_fonts: "font.list", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
90
106
  add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
91
107
  undo: "history.undo", redo: "history.redo", set_view: "view.update", render_slide: "slide.render", inspect_editor: "editor.inspect",
92
108
  })[toolName];
@@ -151,11 +167,14 @@ export async function createCarouselBotMcpServer(companion) {
151
167
  register("list_edit_sessions", "List active edit reservations, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
152
168
  register("list_recent_operations", "Read the local sanitized operation audit. Text, prompts, paths, and image bytes are never logged.", z.object({ limit: z.number().int().min(1).max(200).default(50), projectId: optionalId, status: z.enum(["started", "ok", "error", "blocked"]).optional() }).strict(), (args) => companion.call("list_recent_operations", args), { readOnlyHint: true });
153
169
  register("inspect_editor", "Inspect projects, slides, assets, and every text/image layer without returning image bytes.", z.object({ ...targetSlide, includeAllProjects: z.boolean().default(true) }).strict(), (args) => browserOperation(companion, "inspect_editor", args), { readOnlyHint: true });
170
+ register("list_local_fonts", "Search fonts installed on this computer. Returns opaque local font IDs and never filesystem paths. The user must enable local fonts in CarouselBot first.", z.object({ editSessionId, query: z.string().max(200).optional(), limit: z.number().int().min(1).max(200).default(80), cursor: z.string().max(2048).optional(), sort: z.enum(["recent_then_alphabetical", "alphabetical"]).default("recent_then_alphabetical") }).strict(), (args) => companion.call("list_local_fonts", args), { readOnlyHint: true });
171
+ register("list_project_fonts", "List fonts already imported into one project, including whether each face is currently available.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "list_project_fonts", args), { readOnlyHint: true });
154
172
  register("show_notification", "Show a short visual notification in a connected editor for status or marketing demos.", z.object({ editSessionId, message: z.string().min(1).max(240), tone: z.enum(["agent", "success", "info", "error"]).default("agent") }).strict(), ({ editSessionId, ...args }) => companion.call("notify", { ...args, editSessionId }), { destructiveHint: false, idempotentHint: false });
155
173
 
156
- register("create_project", "Create an empty project without changing the user's current browser view. Its dashboard card appears live when the dashboard is open.", z.object({ editSessionId, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
174
+ register("create_project", "Create an empty project without changing the user's current browser view. Pass a canonical folderPath such as /my-folder to create it inside that folder; omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
157
175
  register("open_project", "Explicitly navigate the browser to a project and optionally a specific slide without changing content. Use only when the user asks to show it.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
158
176
  register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args), { destructiveHint: true });
177
+ register("move_project", "Move a project into a folder by canonical slash path, move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
159
178
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
160
179
 
161
180
  register("add_slide", "Add a slide using a solid color or local background image path. The browser follows it only when that project is already visible.", z.object({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }).strict(), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
@@ -167,6 +186,7 @@ export async function createCarouselBotMcpServer(companion) {
167
186
  register("add_text", "Add a text layer. Choose a semantic role and a size within its readable range. Width is preserved while height is fitted automatically with safe padding; boxed text defaults to the preferred per-line background.", z.object({ ...targetSlide, ...textFields, text: z.string().min(1).max(4000) }).strict(), (args) => browserOperation(companion, "add_text", args), { destructiveHint: false });
168
187
  register("update_text", "Update one or more text layers. Every updated layer automatically keeps its width and refits its height with safe padding, so a render-fit-render loop is unnecessary.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...textFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_text", args), { destructiveHint: true });
169
188
  register("fit_text_boxes", "Explicitly resize text boxes to their rendered content. add_text and update_text already fit height automatically; use mode=both only when you also want to shrink width.", z.object({ ...targetSlide, textIds: z.array(id).min(1).max(100), mode: z.enum(["height", "both"]).default("both") }).strict(), (args) => browserOperation(companion, "fit_text_boxes", args), { destructiveHint: true });
189
+ register("import_font", "Add one installed font face to a project using a localFontId returned by list_local_fonts. Exact face bytes remain on this computer and duplicate imports are reused.", z.object({ ...targetProject, projectId: id, localFontId: id }).strict(), (args) => browserOperation(companion, "import_font", args), { destructiveHint: false });
170
190
 
171
191
  register("import_asset", "Import a local image file into the active project's reusable asset library. Image bytes stay local.", z.object({ ...targetSlide, path: z.string().min(1), name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "import_asset", args), { destructiveHint: false });
172
192
  register("update_asset", "Rename a reusable image asset.", z.object({ ...targetProject, assetId: id, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_asset", args), { destructiveHint: true });
@@ -220,7 +240,7 @@ export async function createCarouselBotMcpServer(companion) {
220
240
  if (!definition?.mutating) throw new Error(`Tool cannot be batched: ${item.tool}`);
221
241
  const args = definition.inputSchema.parse(item.arguments);
222
242
  const { editSessionId: _ignored, ...toolArgs } = args;
223
- items.push({ toolName: item.tool, operation: await prepareOperation(companion, item.tool, toolArgs), label: operationLabel(item.tool) });
243
+ items.push({ toolName: item.tool, operation: await prepareOperation(companion, item.tool, toolArgs, sessionId), label: operationLabel(item.tool) });
224
244
  }
225
245
  return companion.call("batch", { items, editSessionId: sessionId });
226
246
  }, { destructiveHint: true });
package/src/setup.mjs CHANGED
@@ -62,8 +62,7 @@ export async function runSetup(arguments_) {
62
62
  const clientArgument = arguments_.find((value) => value.startsWith("--client="))?.slice("--client=".length);
63
63
  const requested = clientArgument ? clientArgument.split(",").map((value) => value.trim().toLowerCase()) : supported.filter(commandExists);
64
64
  const clients = [...new Set(requested)].filter((client) => supported.includes(client));
65
- const releaseTag = PACKAGE_VERSION.includes("-beta.") ? "beta" : "latest";
66
- const specifier = `${PACKAGE_NAME}@${releaseTag}`;
65
+ const specifier = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
67
66
  const dryRun = flags.has("--dry-run");
68
67
  const assumeYes = flags.has("--yes") || flags.has("-y");
69
68
  if (!clients.length) throw new Error("No supported agent CLI was detected. Use --client=claude,codex,hermes,opencode,openclaw or copy the generic stdio config below.");