import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron' import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron' import { randomUUID } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' import { renderMp4Job } from './renderWorker.cjs' const { validateRenderRequest } = require('../shared/renderValidation.cjs') as { validateRenderRequest(scene: unknown, options: unknown): RenderValidation } const isDev = !app.isPackaged const isE2E = process.env.RUWIO_E2E === '1' const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200' const devServerOrigin = new URL(devServerUrl).origin const appIconPath = isDev ? path.join(process.cwd(), 'build', 'icon.png') : path.join(process.resourcesPath, 'build', 'icon.png') let mainWindow: BrowserWindowInstance | null = null const renderJobs = new Map() // Serialize desktop renders: one active export at a time, mirroring the // standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this, // but IPC is directly invokable, so enforce it in main too. let renderActive = false interface RenderValidation { ok: boolean error?: string scene?: Parameters[0]['scene'] options?: Parameters[0]['options'] } interface RenderMp4Payload { scene?: unknown fps?: unknown crf?: unknown preset?: unknown suggestedName?: unknown } function sendAppCommand(command: string): void { if (!mainWindow || mainWindow.isDestroyed()) return mainWindow.webContents.send('ruwio:menu-command', command) } declare global { // Playwright's Electron runner can evaluate in the main process and call this // env-gated hook without expanding the preload bridge surface. var __ruwioTestMenuCommand: ((command: string) => void) | undefined } function sendRenderProgress( target: Electron.WebContents, event: { jobId: string; progress: number; done: boolean; error?: string }, ): void { if (!target.isDestroyed()) target.send('ruwio:render-progress', event) } function sanitizeMp4Name(value: unknown): string { const raw = typeof value === 'string' && value.trim() ? value.trim() : 'animation.mp4' const noPath = raw.replace(/[\\/]+/g, '-') const safe = noPath.replace(/[^a-z0-9äöüß._-]+/gi, '-').replace(/^-+|-+$/g, '') const named = safe || 'animation.mp4' return named.toLowerCase().endsWith('.mp4') ? named : `${named}.mp4` } async function handleRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> { if (renderActive) { throw new Error('A render is already in progress') } renderActive = true try { return await runRenderMp4(event, payload) } finally { renderActive = false } } async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> { const validation = validateRenderRequest(payload?.scene, { fps: payload?.fps, crf: payload?.crf, preset: payload?.preset, }) if (!validation.ok || !validation.scene || !validation.options) { throw new Error(validation.error || 'invalid render request') } // The desktop path captures a hidden, on-screen BrowserWindow, which Chromium // can't composite/capture beyond the physical display bounds — a canvas larger // than the screen would silently yield truncated frames. Fail fast with a clear // message instead (finding #11). The web/server path uses a headless viewport // and has no such limit, so this is desktop-only. const { width: canvasWidth, height: canvasHeight } = validation.scene.canvas const { width: displayWidth, height: displayHeight } = screen.getPrimaryDisplay().size if (canvasWidth > displayWidth || canvasHeight > displayHeight) { throw new Error( `Canvas ${canvasWidth}×${canvasHeight} is larger than this display ` + `(${displayWidth}×${displayHeight}). Desktop export can't capture a canvas ` + `bigger than the screen — reduce the canvas size and try again.`, ) } const window = BrowserWindow.fromWebContents(event.sender) const saveOptions = { title: 'Export MP4', defaultPath: sanitizeMp4Name(payload?.suggestedName), filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], } const e2eSavePath = isE2E ? process.env.RUWIO_E2E_SAVE_PATH || path.join(app.getPath('temp'), saveOptions.defaultPath) : '' const saveResult = e2eSavePath ? { canceled: false, filePath: e2eSavePath } : window ? await dialog.showSaveDialog(window, saveOptions) : await dialog.showSaveDialog(saveOptions) if (saveResult.canceled || !saveResult.filePath) return null const jobId = randomUUID() const controller = new AbortController() renderJobs.set(jobId, controller) sendRenderProgress(event.sender, { jobId, progress: 0, done: false }) try { await renderMp4Job({ jobId, scene: validation.scene, options: validation.options, outputPath: saveResult.filePath, signal: controller.signal, onProgress: (progress) => { sendRenderProgress(event.sender, { jobId, progress, done: false }) }, }) sendRenderProgress(event.sender, { jobId, progress: 1, done: true }) return { path: saveResult.filePath } } catch (err) { const message = (err as Error).message || String(err) sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message }) throw err } finally { renderJobs.delete(jobId) } } async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise { if (typeof jobId !== 'string' || !jobId) return renderJobs.get(jobId)?.abort() } function parseUrl(raw: string): URL | null { try { return new URL(raw) } catch { return null } } function isAllowedAppNavigation(raw: string): boolean { const url = parseUrl(raw) if (!url) return false if (isDev) return url.origin === devServerOrigin if (url.protocol !== 'file:') return false const appEntry = path.join(app.getAppPath(), 'dist', 'index.html') return path.normalize(fileURLToPath(url)) === path.normalize(appEntry) } function isAllowedExternalUrl(raw: string): boolean { const url = parseUrl(raw) return url?.protocol === 'https:' } function createMainWindow(): void { mainWindow = new BrowserWindow({ width: 1280, height: 860, minWidth: 1024, minHeight: 700, title: 'RUWIO', icon: appIconPath, show: false, backgroundColor: '#14171f', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) mainWindow.once('ready-to-show', () => { mainWindow?.show() }) mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => { if (isAllowedExternalUrl(url)) { void shell.openExternal(url) } return { action: 'deny' } }) mainWindow.webContents.on('will-navigate', (event: Event, url: string) => { if (!isAllowedAppNavigation(url)) { event.preventDefault() } }) if (isDev) { void mainWindow.loadURL(devServerUrl) mainWindow.webContents.openDevTools({ mode: 'detach' }) } else { void mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html')) } } function createMenu(): void { const template: MenuItemConstructorOptions[] = [ { label: 'File', submenu: [ { label: 'New Project', accelerator: 'CmdOrCtrl+N', click: () => sendAppCommand('new-project'), }, { label: 'Open...', accelerator: 'CmdOrCtrl+O', click: () => sendAppCommand('open-project'), }, { label: 'Recent Projects', accelerator: 'CmdOrCtrl+Shift+O', click: () => sendAppCommand('recent-projects'), }, { type: 'separator' }, { label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => sendAppCommand('save-project'), }, { label: 'Save As...', accelerator: 'CmdOrCtrl+Shift+S', click: () => sendAppCommand('save-project-as'), }, { type: 'separator' }, { label: 'Export...', accelerator: 'CmdOrCtrl+E', click: () => sendAppCommand('export-project'), }, { type: 'separator' }, process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' }, ], }, { label: 'Edit', submenu: [ { label: 'Undo', accelerator: 'CmdOrCtrl+Z', click: () => sendAppCommand('undo'), }, { label: 'Redo', accelerator: 'CmdOrCtrl+Shift+Z', click: () => sendAppCommand('redo'), }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' }, { type: 'separator' }, { label: 'Copy Current Frame', accelerator: 'CmdOrCtrl+Alt+C', click: () => sendAppCommand('copy-current-frame'), }, ], }, { label: 'View', submenu: [ { role: 'reload' }, { role: 'forceReload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { label: 'Play / Pause', accelerator: 'Space', click: () => sendAppCommand('toggle-playback'), }, { label: 'Stop', accelerator: 'Escape', click: () => sendAppCommand('stop-playback'), }, { label: 'Jump to Start', accelerator: 'R', click: () => sendAppCommand('rewind'), }, { label: 'Fullscreen Preview', accelerator: 'Shift+Space', click: () => sendAppCommand('fullscreen-preview'), }, { type: 'separator' }, { label: 'Toggle Side Panels', accelerator: 'Tab', click: () => sendAppCommand('toggle-panels'), }, { label: 'Media Library', accelerator: 'CmdOrCtrl+M', click: () => sendAppCommand('media-library'), }, { label: 'Document Settings', accelerator: 'CmdOrCtrl+,', click: () => sendAppCommand('document-settings'), }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, ], }, { label: 'Window', submenu: [ { role: 'minimize' }, { role: 'zoom' }, ], }, { label: 'Help', submenu: [ { label: 'RUWIO Help', accelerator: 'F1', click: () => sendAppCommand('help'), }, { label: 'About RUWIO', click: () => sendAppCommand('about'), }, { type: 'separator' }, { label: 'Open Test Runner', accelerator: 'CmdOrCtrl+Shift+Alt+T', click: () => sendAppCommand('test-runner'), }, ], }, ] if (process.platform === 'darwin') { template.unshift({ label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }) } Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } app.setName('RUWIO') app.whenReady().then(() => { ipcMain.handle('ruwio:render-mp4', handleRenderMp4) ipcMain.handle('ruwio:render-cancel', handleRenderCancel) if (isE2E) { globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command) } createMenu() createMainWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })