Files
RUWIO/electron/main.cts
T
2026-06-07 11:09:39 +02:00

408 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, AbortController>()
// 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<typeof renderMp4Job>[0]['scene']
options?: Parameters<typeof renderMp4Job>[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<void> {
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'),
},
...(isDev
? [
{ type: 'separator' as const },
{
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()
})