feat(desktop): export artifacts directly to PDF (#532)

* feat(desktop): export artifacts directly to PDF

* fix(desktop): PDF 내보내기 기본 여백 제거
This commit is contained in:
tenderpooh 2026-05-09 00:42:12 +09:00 committed by GitHub
parent 0b757c2452
commit 109722de3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 575 additions and 4 deletions

View file

@ -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<DesktopExportPdfInput> {
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;
}

View file

@ -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<DesktopExportPdfResult>;
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);

View file

@ -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>): void {
}
export async function startDaemonSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<DaemonSidecarHandle> {
const started = await startServer({ port: parsePort(process.env[DAEMON_PORT_ENV]), returnServer: true }) as
const started = await startServer({
desktopPdfExporter: async (input: DesktopExportPdfInput): Promise<DesktopExportPdfResult> => {
const desktopIpc = resolveAppIpcPath({
app: APP_KEYS.DESKTOP,
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
namespace: runtime.namespace,
});
return await requestJsonIpc<DesktopExportPdfResult>(
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") {

View file

@ -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'),
'<!doctype html><section class="slide">One</section>',
);
});
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: '<!doctype html><section class="slide">One</section>',
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: '<!doctype html><section class="slide">One</section>',
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: '<!doctype html><section class="slide">One</section>',
title: 'Seed Deck',
},
]);
} finally {
await new Promise<void>((resolve) => started.server.close(resolve));
}
});
});

View file

@ -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();

View file

@ -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<DesktopExportPdfResult> {
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 = `<base href="${escapeHtmlAttribute(baseHref)}">`;
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (match) => `${match}<head>${tag}</head>`);
return `<!doctype html><html><head>${tag}</head><body>${doc}</body></html>`;
}
function injectTitle(doc: string, title: string): string {
const tag = `<title>${escapeHtmlText(title)}</title>`;
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, tag);
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (match) => `${match}<head>${tag}</head>`);
return `<!doctype html><html><head>${tag}</head><body>${doc}</body></html>`;
}
function injectPrintStylesheet(doc: string, css: string): string {
const tag = `<style data-od-desktop-pdf>${css}</style>`;
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${tag}</head>`);
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
return `${tag}${doc}`;
}
async function waitForPrintableContent(window: BrowserWindow): Promise<void> {
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<PageSize> {
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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View file

@ -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<DesktopClickResult>;
console(): DesktopConsoleResult;
eval(input: DesktopEvalInput): Promise<DesktopEvalResult>;
exportPdf(input: DesktopExportPdfInput): Promise<DesktopExportPdfResult>;
screenshot(input: DesktopScreenshotInput): Promise<DesktopScreenshotResult>;
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);

View file

@ -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,
});
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>

View file

@ -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<ProjectPdfExportResult> {
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(

View file

@ -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 —

View file

@ -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}`);
}

View file

@ -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: "<!doctype html><section class=\"slide\">One</section>",
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: "<!doctype html><section class=\"slide\">One</section>",
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: "<p>x</p>", title: "x" },
type: SIDECAR_MESSAGES.EXPORT_PDF,
}),
).toThrow();
});
});