diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 21ec5496..351a6ebb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "@iarna/toml": "^2.2.5", "@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..b0e03438 --- /dev/null +++ b/apps/desktop/src/main/exporter-ipc.ts @@ -0,0 +1,65 @@ +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' }; + } + + // Tier 1: HTML succeeds, others throw EXPORTER_NOT_READY. We deliberately + // do NOT swallow the error here — let it propagate so the renderer can + // surface it as a toast (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 09f117f7..bdf1eff6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,7 +5,12 @@ 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'; +<<<<<<< HEAD import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc'; +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= +import { registerExporterIpc } from './exporter-ipc'; +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -81,7 +86,12 @@ function setupAutoUpdater(): void { void app.whenReady().then(async () => { await loadConfigOnBoot(); registerIpcHandlers(); +<<<<<<< HEAD registerOnboardingIpc(); +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= + registerExporterIpc(() => mainWindow); +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a86afc68..45f66992 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -6,6 +6,7 @@ import type { } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; +<<<<<<< HEAD export interface ValidateKeyResult { ok: true; modelCount: number; @@ -16,6 +17,16 @@ export interface ValidateKeyError { message: string; } +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= +export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip'; +export interface ExportInvokeResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -25,6 +36,8 @@ const api = { model: ModelRef; 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 7385a6ed..76c2a389 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -2,8 +2,15 @@ import { buildSrcdoc } from '@open-codesign/runtime'; import { BUILTIN_DEMOS } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; +<<<<<<< HEAD import { useEffect, useState } from 'react'; import { Onboarding } from './onboarding'; +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +import { useState } from 'react'; +======= +import { useState } from 'react'; +import { PreviewToolbar } from './components/PreviewToolbar'; +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) import { useCodesignStore } from './store'; export function App() { @@ -78,7 +85,6 @@ export function App() { ) : ( messages.map((m, i) => (
+
{previewHtml ? (