diff --git a/apps/daemon/src/pdf-export.ts b/apps/daemon/src/pdf-export.ts new file mode 100644 index 000000000..7726d9c30 --- /dev/null +++ b/apps/daemon/src/pdf-export.ts @@ -0,0 +1,59 @@ +import path from 'node:path'; + +import type { DesktopExportPdfInput } from '@open-design/sidecar-proto'; + +import { readProjectFile } from './projects.js'; + +export interface BuildDesktopPdfExportInputOptions { + daemonUrl: string; + deck?: boolean; + fileName: string; + projectId: string; + projectsRoot: string; + title?: string; +} + +export async function buildDesktopPdfExportInput( + options: BuildDesktopPdfExportInputOptions, +): Promise { + const file = await readProjectFile(options.projectsRoot, options.projectId, options.fileName); + const title = displayTitle(options.title, options.fileName); + return { + baseHref: rawBaseHref(options.daemonUrl, options.projectId, options.fileName), + deck: options.deck === true, + defaultFilename: `${safeFilename(title, 'artifact')}.pdf`, + html: file.buffer.toString('utf8'), + title, + }; +} + +function displayTitle(title: string | undefined, fileName: string): string { + if (typeof title === 'string' && title.trim().length > 0) return title.trim(); + const base = path.posix.basename(fileName); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(0, dot) : base || 'artifact'; +} + +function rawBaseHref(daemonUrl: string, projectId: string, fileName: string): string { + const dir = path.posix.dirname(fileName.replace(/^\/+/, '')); + const safeProjectId = encodeURIComponent(projectId); + const rawBase = `${daemonUrl.replace(/\/+$/, '')}/api/projects/${safeProjectId}/raw/`; + if (!dir || dir === '.') return rawBase; + return `${rawBase}${encodePathSegments(dir)}/`; +} + +function encodePathSegments(value: string): string { + return value + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +function safeFilename(name: string, fallback: string): string { + const slug = (name || fallback) + .replace(/[^\w.\-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + return slug || fallback; +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 652f52c51..b4191dac1 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -1,4 +1,5 @@ // @ts-nocheck +import type { DesktopExportPdfInput, DesktopExportPdfResult } from '@open-design/sidecar-proto'; import express from 'express'; import multer from 'multer'; import { execFile, spawn } from 'node:child_process'; @@ -68,6 +69,7 @@ import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; import { loadCraftSections } from './craft.js'; import { stageActiveSkill } from './cwd-aliases.js'; +import { buildDesktopPdfExportInput } from './pdf-export.js'; import { generateMedia } from './media.js'; import { searchResearch, ResearchError } from './research/index.js'; import { renderResearchCommandContract } from './prompts/research-contract.js'; @@ -1724,6 +1726,15 @@ export function createSseResponse( }; } +export type DesktopPdfExporter = (input: DesktopExportPdfInput) => Promise; + +export interface StartServerOptions { + desktopPdfExporter?: DesktopPdfExporter | null; + host?: string; + port?: number; + returnServer?: boolean; +} + function resolveChatRunInactivityTimeoutMs() { const raw = Number(process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS); if (!Number.isFinite(raw)) return 2 * 60 * 1000; @@ -1736,7 +1747,12 @@ function resolveChatRunShutdownGraceMs() { return Math.max(0, Math.floor(raw)); } -export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false } = {}) { +export async function startServer({ + port = 7456, + host = process.env.OD_BIND_HOST || '127.0.0.1', + returnServer = false, + desktopPdfExporter = null, +}: StartServerOptions = {}) { let resolvedPort = port; let daemonShuttingDown = false; const extraAllowedOrigins = configuredAllowedOrigins(); @@ -4098,6 +4114,41 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST } }); + app.post('/api/projects/:id/export/pdf', async (req, res) => { + if (typeof desktopPdfExporter !== 'function') { + return sendApiError( + res, + 501, + 'UPSTREAM_UNAVAILABLE', + 'desktop PDF export is only available in the desktop runtime', + ); + } + try { + const { fileName, title, deck } = req.body || {}; + if (typeof fileName !== 'string' || fileName.length === 0) { + return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required'); + } + const input = await buildDesktopPdfExportInput({ + daemonUrl, + deck: deck === true, + fileName, + projectId: req.params.id, + projectsRoot: PROJECTS_DIR, + title: typeof title === 'string' ? title : undefined, + }); + const result = await desktopPdfExporter(input); + res.json(result); + } catch (err) { + const status = err && err.code === 'ENOENT' ? 404 : 400; + sendApiError( + res, + status, + status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST', + String(err?.message || err), + ); + } + }); + app.delete('/api/projects/:id/raw/*', async (req, res) => { try { const project = getProject(db, req.params.id); diff --git a/apps/daemon/src/sidecar/server.ts b/apps/daemon/src/sidecar/server.ts index 4200ff625..ccaa3474d 100644 --- a/apps/daemon/src/sidecar/server.ts +++ b/apps/daemon/src/sidecar/server.ts @@ -1,14 +1,20 @@ import type { Server } from "node:http"; import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, SIDECAR_ENV, SIDECAR_MESSAGES, normalizeDaemonSidecarMessage, type DaemonStatusSnapshot, + type DesktopExportPdfInput, + type DesktopExportPdfResult, type SidecarStamp, } from "@open-design/sidecar-proto"; import { createJsonIpcServer, + requestJsonIpc, + resolveAppIpcPath, type JsonIpcServerHandle, type SidecarRuntimeContext, } from "@open-design/sidecar"; @@ -97,7 +103,22 @@ function attachParentMonitor(stop: () => Promise): void { } export async function startDaemonSidecar(runtime: SidecarRuntimeContext): Promise { - const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as + const started = await startServer({ + desktopPdfExporter: async (input: DesktopExportPdfInput): Promise => { + const desktopIpc = resolveAppIpcPath({ + app: APP_KEYS.DESKTOP, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + namespace: runtime.namespace, + }); + return await requestJsonIpc( + desktopIpc, + { input, type: SIDECAR_MESSAGES.EXPORT_PDF }, + { timeoutMs: 600_000 }, + ); + }, + port: parsePort(process.env[DAEMON_PORT_ENV]), + returnServer: true, + }) as | string | StartedDaemonServer; if (typeof started === "string") { diff --git a/apps/daemon/tests/pdf-export.test.ts b/apps/daemon/tests/pdf-export.test.ts new file mode 100644 index 000000000..de9cfe932 --- /dev/null +++ b/apps/daemon/tests/pdf-export.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { buildDesktopPdfExportInput } from '../src/pdf-export.js'; +import { startServer } from '../src/server.js'; + +describe('buildDesktopPdfExportInput', () => { + let projectsRoot = ''; + const projectId = 'proj-pdf-test'; + + beforeEach(async () => { + projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-pdf-export-')); + await mkdir(path.join(projectsRoot, projectId, 'deck', 'assets'), { recursive: true }); + await writeFile( + path.join(projectsRoot, projectId, 'deck', 'index.html'), + '
One
', + ); + }); + + afterEach(() => { + if (projectsRoot) rmSync(projectsRoot, { recursive: true, force: true }); + }); + + it('reads the project file and derives a raw-route baseHref from the file directory', async () => { + const input = await buildDesktopPdfExportInput({ + daemonUrl: 'http://127.0.0.1:7456', + deck: true, + fileName: 'deck/index.html', + projectId, + projectsRoot, + title: 'Seed Deck', + }); + + expect(input).toEqual({ + baseHref: 'http://127.0.0.1:7456/api/projects/proj-pdf-test/raw/deck/', + deck: true, + defaultFilename: 'Seed-Deck.pdf', + html: '
One
', + title: 'Seed Deck', + }); + }); + + it('falls back to the file basename when the caller omits a title', async () => { + const input = await buildDesktopPdfExportInput({ + daemonUrl: 'http://127.0.0.1:7456', + deck: false, + fileName: 'deck/index.html', + projectId, + projectsRoot, + }); + + expect(input.title).toBe('index'); + expect(input.defaultFilename).toBe('index.pdf'); + }); +}); + +describe('POST /api/projects/:id/export/pdf', () => { + it('forwards the project HTML file to the configured desktop PDF exporter', async () => { + const projectId = `proj-pdf-route-${Date.now()}`; + const calls: unknown[] = []; + const started = await startServer({ + port: 0, + returnServer: true, + desktopPdfExporter: async (input: unknown) => { + calls.push(input); + return { ok: true, path: '/tmp/seed.pdf' }; + }, + }) as { server: { close(cb: () => void): void }; url: string }; + + try { + await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/files`, { + body: JSON.stringify({ + content: '
One
', + name: 'deck/index.html', + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + + const response = await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/export/pdf`, { + body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, path: '/tmp/seed.pdf' }); + expect(calls).toEqual([ + { + baseHref: `${started.url}/api/projects/${encodeURIComponent(projectId)}/raw/deck/`, + deck: true, + defaultFilename: 'Seed-Deck.pdf', + html: '
One
', + title: 'Seed Deck', + }, + ]); + } finally { + await new Promise((resolve) => started.server.close(resolve)); + } + }); +}); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index aa555a8e4..b634da89a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -11,6 +11,7 @@ import { normalizeDesktopSidecarMessage, type DesktopClickInput, type DesktopEvalInput, + type DesktopExportPdfInput, type DesktopScreenshotInput, type SidecarStamp, type WebStatusSnapshot, @@ -135,6 +136,8 @@ export async function runDesktopMain( return desktop.console(); case SIDECAR_MESSAGES.CLICK: return await desktop.click(request.input as DesktopClickInput); + case SIDECAR_MESSAGES.EXPORT_PDF: + return await desktop.exportPdf(request.input as DesktopExportPdfInput); case SIDECAR_MESSAGES.SHUTDOWN: setImmediate(() => { shutdownAndExit(); diff --git a/apps/desktop/src/main/pdf-export.ts b/apps/desktop/src/main/pdf-export.ts new file mode 100644 index 000000000..c53dc7c1f --- /dev/null +++ b/apps/desktop/src/main/pdf-export.ts @@ -0,0 +1,169 @@ +import { writeFile } from "node:fs/promises"; + +import { BrowserWindow, dialog } from "electron"; +import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto"; + +type PageSize = { height: number; width: number }; + +const DECK_PAGE_SIZE: PageSize = { width: 13.333333, height: 7.5 }; +const MAX_PAGE_INCHES = 200; + +const DECK_PRINT_CSS = ` +@media print { + @page { size: 1920px 1080px; margin: 0; } + html, body { + width: 1920px !important; + height: auto !important; + overflow: visible !important; + background: #fff !important; + } + body { + display: block !important; + scroll-snap-type: none !important; + transform: none !important; + } + .slide, [data-screen-label], section.slide, .deck-slide, .ppt-slide { + flex: none !important; + width: 1920px !important; + height: 1080px !important; + min-height: 1080px !important; + max-height: 1080px !important; + page-break-after: always; + break-after: page; + scroll-snap-align: none !important; + transform: none !important; + position: relative !important; + overflow: hidden !important; + } + .slide:last-child, [data-screen-label]:last-child { page-break-after: auto; break-after: auto; } + .deck-counter, .deck-hint, .deck-nav, + [aria-label="Previous slide"], [aria-label="Next slide"] { + display: none !important; + } +} +`; + +export async function exportPdfFromHtml(input: DesktopExportPdfInput): Promise { + const save = await dialog.showSaveDialog({ + defaultPath: input.defaultFilename, + filters: [ + { name: "PDF", extensions: ["pdf"] }, + { name: "All Files", extensions: ["*"] }, + ], + title: "Save PDF", + }); + if (save.canceled || !save.filePath) return { canceled: true, ok: true }; + + const window = new BrowserWindow({ + height: input.deck ? 1080 : 900, + show: false, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + width: input.deck ? 1920 : 1440, + }); + + try { + await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(buildPrintableDocument(input))}`); + await waitForPrintableContent(window); + const pageSize = input.deck ? DECK_PAGE_SIZE : await inferPageSize(window); + const pdf = await window.webContents.printToPDF({ + margins: { bottom: 0, left: 0, right: 0, top: 0 }, + pageSize, + preferCSSPageSize: true, + printBackground: true, + }); + await writeFile(save.filePath, pdf); + return { ok: true, path: save.filePath }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error), ok: false }; + } finally { + if (!window.isDestroyed()) window.destroy(); + } +} + +function buildPrintableDocument(input: DesktopExportPdfInput): string { + const source = injectBaseHref(input.html, input.baseHref); + const withTitle = injectTitle(source, input.title); + return input.deck ? injectPrintStylesheet(withTitle, DECK_PRINT_CSS) : withTitle; +} + +function injectBaseHref(doc: string, baseHref: string | undefined): string { + if (!baseHref) return doc; + const tag = ``; + if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (match) => `${match}${tag}`); + if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (match) => `${match}${tag}`); + return `${tag}${doc}`; +} + +function injectTitle(doc: string, title: string): string { + const tag = `${escapeHtmlText(title)}`; + if (/]*>.*?<\/title>/is.test(doc)) return doc.replace(/]*>.*?<\/title>/is, tag); + if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (match) => `${match}${tag}`); + if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (match) => `${match}${tag}`); + return `${tag}${doc}`; +} + +function injectPrintStylesheet(doc: string, css: string): string { + const tag = ``; + if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${tag}`); + if (/]*>/i.test(doc)) return doc.replace(/]*>/i, (match) => `${match}${tag}`); + return `${tag}${doc}`; +} + +async function waitForPrintableContent(window: BrowserWindow): Promise { + await window.webContents.executeJavaScript( + `Promise.all([ + document.fonts && document.fonts.ready ? document.fonts.ready.catch(function(){}) : Promise.resolve(), + Promise.all(Array.from(document.images || []).map(function(img) { + if (img.complete) return Promise.resolve(); + return new Promise(function(resolve) { + img.addEventListener('load', resolve, { once: true }); + img.addEventListener('error', resolve, { once: true }); + }); + })) + ]).then(function(){ return true; })`, + true, + ); +} + +async function inferPageSize(window: BrowserWindow): Promise { + const size = await window.webContents.executeJavaScript( + `(() => { + const de = document.documentElement; + const body = document.body || de; + return { + width: Math.max(de.scrollWidth, body.scrollWidth, de.clientWidth, 1440), + height: Math.max(de.scrollHeight, body.scrollHeight, de.clientHeight, 900) + }; + })()`, + true, + ) as { height?: unknown; width?: unknown }; + const widthPx = typeof size.width === "number" && Number.isFinite(size.width) ? size.width : 1440; + const heightPx = typeof size.height === "number" && Number.isFinite(size.height) ? size.height : 900; + return { + width: clamp(widthPx / 96, 1, MAX_PAGE_INCHES), + height: clamp(heightPx / 96, 1, MAX_PAGE_INCHES), + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index dde789244..49b8ebefb 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -3,6 +3,9 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { BrowserWindow, dialog, ipcMain, shell } from "electron"; +import type { DesktopExportPdfInput, DesktopExportPdfResult } from "@open-design/sidecar-proto"; + +import { exportPdfFromHtml } from "./pdf-export.js"; const PENDING_POLL_MS = 120; const RUNNING_POLL_MS = 2000; @@ -59,6 +62,7 @@ export type DesktopRuntime = { click(input: DesktopClickInput): Promise; console(): DesktopConsoleResult; eval(input: DesktopEvalInput): Promise; + exportPdf(input: DesktopExportPdfInput): Promise; screenshot(input: DesktopScreenshotInput): Promise; show(): void; status(): DesktopStatusSnapshot; @@ -415,6 +419,9 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom return { error: error instanceof Error ? error.message : String(error), ok: false }; } }, + exportPdf(input) { + return exportPdfFromHtml(input); + }, async screenshot(input) { if (window.isDestroyed()) throw new Error("desktop window is destroyed"); const outputPath = normalizeScreenshotPath(input.path); diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 0d473dff3..7d63ceada 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -36,6 +36,7 @@ import { exportAsJsx, exportAsMd, exportAsPdf, + exportProjectAsPdf, exportProjectAsZip, exportReactComponentAsHtml, exportReactComponentAsZip, @@ -4340,7 +4341,13 @@ function HtmlViewer({ role="menuitem" onClick={() => { setShareMenuOpen(false); - exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }); + void exportProjectAsPdf({ + deck: effectiveDeck, + fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }), + filePath: file.name, + projectId, + title: exportTitle, + }); }} > diff --git a/apps/web/src/runtime/exports.ts b/apps/web/src/runtime/exports.ts index aebc06b4d..9f471a2d0 100644 --- a/apps/web/src/runtime/exports.ts +++ b/apps/web/src/runtime/exports.ts @@ -66,6 +66,36 @@ export function exportAsMd(source: string, title: string): void { triggerDownload(blob, `${safeFilename(title, 'artifact')}.md`); } +export type ProjectPdfExportResult = 'desktop' | 'fallback'; + +export async function exportProjectAsPdf(opts: { + deck: boolean; + fallbackPdf: () => void; + filePath: string; + projectId: string; + title: string; +}): Promise { + try { + const resp = await fetch(`/api/projects/${encodeURIComponent(opts.projectId)}/export/pdf`, { + body: JSON.stringify({ + deck: opts.deck, + fileName: opts.filePath, + title: opts.title, + }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + if (!resp.ok) throw new Error(`desktop PDF export unavailable (${resp.status})`); + const body = await resp.json().catch(() => ({})); + if (body && body.ok === false) throw new Error(body.error || 'desktop PDF export failed'); + return 'desktop'; + } catch (err) { + console.warn('[exportProjectAsPdf] falling back to browser print:', err); + opts.fallbackPdf(); + return 'fallback'; + } +} + type ReactSourceExtension = '.jsx' | '.tsx'; export function exportAsJsx( diff --git a/apps/web/tests/runtime/exports.test.ts b/apps/web/tests/runtime/exports.test.ts index b17ba32b0..c5d367caa 100644 --- a/apps/web/tests/runtime/exports.test.ts +++ b/apps/web/tests/runtime/exports.test.ts @@ -5,6 +5,7 @@ import { buildSandboxedPreviewDocument, exportAsMd, exportAsPdf, + exportProjectAsPdf, openSandboxedPreviewInNewTab, } from '../../src/runtime/exports'; @@ -72,6 +73,51 @@ describe('archiveFilenameFrom', () => { }); }); +describe('exportProjectAsPdf', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('uses the daemon desktop PDF export API before falling back to browser print', async () => { + const fallback = vi.fn(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 }))); + + const result = await exportProjectAsPdf({ + deck: true, + fallbackPdf: fallback, + filePath: 'deck/index.html', + projectId: 'proj-1', + title: 'Seed Deck', + }); + + expect(result).toBe('desktop'); + expect(fallback).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', { + body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + }); + + it('falls back to browser print when the desktop PDF export API is unavailable', async () => { + const fallback = vi.fn(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'unavailable' } }), { status: 501 }))); + + const result = await exportProjectAsPdf({ + deck: false, + fallbackPdf: fallback, + filePath: 'index.html', + projectId: 'proj-1', + title: 'Landing', + }); + + expect(result).toBe('fallback'); + expect(fallback).toHaveBeenCalledTimes(1); + }); +}); + // `exportAsMd` is a pass-through (the file body is the artifact source // verbatim, only the extension and Content-Type flip). Tests exercise it // end-to-end by stubbing the few DOM globals `triggerDownload` touches — diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts index 5218ac11f..6bb9de09d 100644 --- a/packages/sidecar-proto/src/index.ts +++ b/packages/sidecar-proto/src/index.ts @@ -71,6 +71,7 @@ export const SIDECAR_MESSAGES = Object.freeze({ CLICK: "click", CONSOLE: "console", EVAL: "eval", + EXPORT_PDF: "export-pdf", SCREENSHOT: "screenshot", SHUTDOWN: "shutdown", STATUS: "status", @@ -157,12 +158,28 @@ export type DesktopClickResult = { found: boolean; }; +export type DesktopExportPdfInput = { + baseHref?: string; + deck: boolean; + defaultFilename: string; + html: string; + title: string; +}; + +export type DesktopExportPdfResult = { + canceled?: boolean; + error?: string; + ok: boolean; + path?: string; +}; + export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS }; export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN }; export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL }; export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: typeof SIDECAR_MESSAGES.SCREENSHOT }; export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE }; export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK }; +export type DesktopExportPdfMessage = { input: DesktopExportPdfInput; type: typeof SIDECAR_MESSAGES.EXPORT_PDF }; export type DaemonSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage; export type WebSidecarMessage = SidecarStatusMessage | SidecarShutdownMessage; @@ -172,7 +189,8 @@ export type DesktopSidecarMessage = | DesktopEvalMessage | DesktopScreenshotMessage | DesktopConsoleMessage - | DesktopClickMessage; + | DesktopClickMessage + | DesktopExportPdfMessage; export type ShutdownResult = { accepted: true; @@ -336,6 +354,23 @@ function normalizeDesktopClickInput(input: unknown): DesktopClickInput { return { selector: normalizeNonEmptyString(value.selector, "desktop click selector") }; } +function normalizeBoolean(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`); + return value; +} + +function normalizeDesktopExportPdfInput(input: unknown): DesktopExportPdfInput { + const value = assertObject(input, "desktop PDF export input"); + assertKnownKeys(value, ["baseHref", "deck", "defaultFilename", "html", "title"], "desktop PDF export input"); + return { + ...(value.baseHref == null ? {} : { baseHref: normalizeNonEmptyString(value.baseHref, "desktop PDF export baseHref") }), + deck: normalizeBoolean(value.deck, "desktop PDF export deck"), + defaultFilename: normalizeNonEmptyString(value.defaultFilename, "desktop PDF export defaultFilename"), + html: normalizeNonEmptyString(value.html, "desktop PDF export html"), + title: normalizeNonEmptyString(value.title, "desktop PDF export title"), + }; +} + function normalizeMessageType(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) { throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`); @@ -381,6 +416,9 @@ export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMe case SIDECAR_MESSAGES.CLICK: assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); return { input: normalizeDesktopClickInput(value.input), type }; + case SIDECAR_MESSAGES.EXPORT_PDF: + assertKnownKeys(value, ["input", "type"], "desktop sidecar message"); + return { input: normalizeDesktopExportPdfInput(value.input), type }; default: throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`); } diff --git a/packages/sidecar-proto/tests/index.test.ts b/packages/sidecar-proto/tests/index.test.ts index 2e47a59d1..59402d44f 100644 --- a/packages/sidecar-proto/tests/index.test.ts +++ b/packages/sidecar-proto/tests/index.test.ts @@ -74,4 +74,40 @@ describe("open-design sidecar contract", () => { expect(() => normalizeDesktopSidecarMessage({ input: { expression: 42 }, type: SIDECAR_MESSAGES.EVAL })).toThrow(); expect(() => normalizeDesktopSidecarMessage({ input: { selector: "" }, type: SIDECAR_MESSAGES.CLICK })).toThrow(); }); + + it("validates desktop PDF export IPC message inputs", () => { + expect( + normalizeDesktopSidecarMessage({ + input: { + baseHref: "http://127.0.0.1:7456/api/projects/proj/raw/deck/", + deck: true, + defaultFilename: "Seed Deck.pdf", + html: "
One
", + title: "Seed Deck", + }, + type: SIDECAR_MESSAGES.EXPORT_PDF, + }), + ).toEqual({ + input: { + baseHref: "http://127.0.0.1:7456/api/projects/proj/raw/deck/", + deck: true, + defaultFilename: "Seed Deck.pdf", + html: "
One
", + title: "Seed Deck", + }, + type: "export-pdf", + }); + expect(() => + normalizeDesktopSidecarMessage({ + input: { deck: true, defaultFilename: "x.pdf", html: "", title: "x" }, + type: SIDECAR_MESSAGES.EXPORT_PDF, + }), + ).toThrow(); + expect(() => + normalizeDesktopSidecarMessage({ + input: { deck: "yes", defaultFilename: "x.pdf", html: "

x

", title: "x" }, + type: SIDECAR_MESSAGES.EXPORT_PDF, + }), + ).toThrow(); + }); });