diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c5c12ff..0b9f20f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "dependencies": { "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", + "@open-codesign/exporters": "workspace:*", "@open-codesign/providers": "workspace:*", "@open-codesign/runtime": "workspace:*", "@open-codesign/shared": "workspace:*", diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts new file mode 100644 index 00000000..53a06cd7 --- /dev/null +++ b/apps/desktop/src/main/exporter-ipc.ts @@ -0,0 +1,64 @@ +import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters'; +import { CodesignError } from '@open-codesign/shared'; +import { type BrowserWindow, dialog, ipcMain } from 'electron'; + +const FORMAT_FILTERS: Record = { + html: [{ name: 'HTML', extensions: ['html'] }], + pdf: [{ name: 'PDF', extensions: ['pdf'] }], + pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }], + zip: [{ name: 'ZIP archive', extensions: ['zip'] }], +}; + +export interface ExportRequest { + format: ExporterFormat; + htmlContent: string; + defaultFilename?: string; +} + +export interface ExportResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + +function parseRequest(raw: unknown): ExportRequest { + if (raw === null || typeof raw !== 'object') { + throw new CodesignError('export expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const format = r['format']; + const html = r['htmlContent']; + const defaultFilename = r['defaultFilename']; + if (format !== 'html' && format !== 'pdf' && format !== 'pptx' && format !== 'zip') { + throw new CodesignError(`Unknown export format: ${String(format)}`, 'EXPORTER_UNKNOWN'); + } + if (typeof html !== 'string' || html.length === 0) { + throw new CodesignError('export requires non-empty htmlContent', 'IPC_BAD_INPUT'); + } + const out: ExportRequest = { format, htmlContent: html }; + if (typeof defaultFilename === 'string' && defaultFilename.length > 0) { + out.defaultFilename = defaultFilename; + } + return out; +} + +export function registerExporterIpc(getWindow: () => BrowserWindow | null): void { + ipcMain.handle('codesign:export', async (_evt, raw: unknown): Promise => { + const req = parseRequest(raw); + const win = getWindow(); + const opts: Electron.SaveDialogOptions = { + title: `Export design as ${req.format.toUpperCase()}`, + defaultPath: req.defaultFilename ?? `design.${req.format}`, + filters: FORMAT_FILTERS[req.format], + }; + const picked = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts); + if (picked.canceled || !picked.filePath) { + return { status: 'cancelled' }; + } + + // All four formats ship in tier 1; the heavy deps load lazily inside + // exportArtifact. Errors propagate to the renderer as toasts (PRINCIPLES §10). + const result = await exportArtifact(req.format, req.htmlContent, picked.filePath); + return { status: 'saved', path: result.path, bytes: result.bytes }; + }); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f23cae02..797664d0 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,7 @@ import { detectProviderFromKey } from '@open-codesign/providers'; import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; import { BrowserWindow, app, ipcMain, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { registerExporterIpc } from './exporter-ipc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -78,6 +79,7 @@ function setupAutoUpdater(): void { void app.whenReady().then(() => { registerIpcHandlers(); + registerExporterIpc(() => mainWindow); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1c32bf40..eaeaf510 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,13 @@ import type { ChatMessage, ModelRef } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; +export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip'; +export interface ExportInvokeResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -11,6 +18,8 @@ const api = { apiKey: string; baseUrl?: string; }) => ipcRenderer.invoke('codesign:generate', payload), + export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) => + ipcRenderer.invoke('codesign:export', payload) as Promise, checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('codesign:download-update'), installUpdate: () => ipcRenderer.invoke('codesign:install-update'), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index e761430c..71809e40 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { BUILTIN_DEMOS } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; import { useState } from 'react'; +import { PreviewToolbar } from './components/PreviewToolbar'; import { useCodesignStore } from './store'; export function App() { @@ -58,7 +59,6 @@ export function App() { ) : ( messages.map((m, i) => (
+
{previewHtml ? (