feat: integrate Electron for desktop app

- Add electron-vite + Electron Forge tooling
- Create Electron main process with embedded Express server
- Create preload script with native dialog IPC bridge
- Refactor server.ts to export createExpressApp() (dual web/electron)
- Adapt renderQueue.ts for packaged binaries + pre-built bundle
- Add ensureBrowser() for Chrome Headless Shell pre-download
- Add scripts/bundle-remotion.js for packaging
- Data persists in ~/Library/Application Support/Bradly/
- Web mode preserved via npm run dev:web
This commit is contained in:
2026-06-02 03:57:17 -05:00
parent b135a70cc7
commit 92a8cf78a9
10 changed files with 6137 additions and 26 deletions
+200
View File
@@ -0,0 +1,200 @@
/**
* Electron Main Process — Bradly Desktop App
*
* Responsibilities:
* - Creates the BrowserWindow
* - Starts the embedded Express server
* - Pre-downloads Chrome Headless Shell for Remotion
* - Manages IPC handlers for native features
*/
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
import path from 'path';
import fs from 'fs';
// Signal to server.ts / renderQueue.ts that we're in Electron
process.env.BRADLY_ELECTRON = 'true';
const EXPRESS_PORT = 3000;
let mainWindow: BrowserWindow | null = null;
// ═══ Path Setup for Electron ═══
function setupPaths() {
const userDataPath = app.getPath('userData');
// Uploads and renders persist in the user's app data directory
const uploadsDir = path.join(userDataPath, 'uploads');
const rendersDir = path.join(userDataPath, 'renders');
// Create directories if they don't exist
for (const dir of [uploadsDir, rendersDir]) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
process.env.BRADLY_UPLOADS_DIR = uploadsDir;
process.env.BRADLY_RENDERS_DIR = rendersDir;
// In packaged app, configure Remotion paths
if (app.isPackaged) {
const resourcesPath = process.resourcesPath;
// Pre-built Remotion bundle (created during packaging)
const remotionBundle = path.join(resourcesPath, 'remotion-bundle');
if (fs.existsSync(remotionBundle)) {
process.env.BRADLY_REMOTION_BUNDLE = remotionBundle;
}
// Compositor binaries (unpacked from asar)
const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
const arch = process.arch;
const compositorPath = path.join(
resourcesPath,
'app.asar.unpacked',
'node_modules',
`@remotion/compositor-${platform}-${arch}`,
);
if (fs.existsSync(compositorPath)) {
process.env.BRADLY_BINARIES_DIR = compositorPath;
}
}
console.log('📂 User data:', userDataPath);
console.log('📂 Uploads:', uploadsDir);
console.log('📂 Renders:', rendersDir);
}
// ═══ Express Server ═══
async function startExpressServer(): Promise<void> {
const { createExpressApp } = await import('../../server');
const expressApp = await createExpressApp();
return new Promise<void>((resolve, reject) => {
const server = expressApp.listen(EXPRESS_PORT, '127.0.0.1', () => {
console.log(`🚀 Express server on http://127.0.0.1:${EXPRESS_PORT}`);
resolve();
});
server.on('error', reject);
});
}
// ═══ Window Management ═══
function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1024,
minHeight: 600,
titleBarStyle: 'hiddenInset',
trafficLightPosition: { x: 15, y: 15 },
backgroundColor: '#0a0a0a',
show: false, // Show when ready to prevent visual flash
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false, // Required for preload to access Node APIs
},
});
// Show window when content is ready
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
// Load the app
if (process.env.NODE_ENV === 'development') {
// In dev, electron-vite serves the renderer on a dev server URL
const rendererUrl = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5173';
mainWindow.loadURL(rendererUrl);
// Open DevTools in dev mode
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
// In production, load from the embedded Express server
// which serves the built renderer assets
mainWindow.loadURL(`http://127.0.0.1:${EXPRESS_PORT}`);
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// ═══ IPC Handlers ═══
function setupIPC() {
// Native save dialog
ipcMain.handle('dialog:save', async (_event, options) => {
if (!mainWindow) return null;
const result = await dialog.showSaveDialog(mainWindow, options);
return result;
});
// Native open dialog
ipcMain.handle('dialog:open', async (_event, options) => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, options);
return result;
});
// App info
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
name: app.getName(),
userData: app.getPath('userData'),
isPackaged: app.isPackaged,
}));
}
// ═══ Remotion Browser Pre-download ═══
async function predownloadBrowser() {
try {
const { ensureBrowser } = await import('@remotion/renderer');
console.log('🌐 Ensuring Chrome Headless Shell is available...');
await ensureBrowser();
console.log('✅ Chrome Headless Shell ready');
} catch (err) {
console.warn('⚠️ Failed to pre-download browser (renders may download on first use):', err);
}
}
// ═══ App Lifecycle ═══
app.whenReady().then(async () => {
// 1. Configure paths
setupPaths();
// 2. Setup IPC handlers
setupIPC();
// 3. Start Express server
try {
await startExpressServer();
} catch (err) {
console.error('❌ Failed to start Express server:', err);
app.quit();
return;
}
// 4. Create the main window
createWindow();
// 5. Pre-download Chrome Headless Shell in background
predownloadBrowser();
// macOS: re-create window when dock icon is clicked
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed
app.on('window-all-closed', () => {
app.quit();
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Electron Preload Script — Bradly Desktop App
*
* Exposes a safe, typed API to the renderer process via contextBridge.
* The renderer (React app) can access these via `window.electronAPI`.
*
* Security: contextIsolation is enabled, nodeIntegration is disabled.
* Only explicitly exposed functions are available to the renderer.
*/
import { contextBridge, ipcRenderer } from 'electron';
const electronAPI = {
// ─── Platform Detection ───
isElectron: true as const,
platform: process.platform,
// ─── Native Dialogs ───
/** Show a native save dialog and return the chosen path */
showSaveDialog: (options: Electron.SaveDialogOptions) =>
ipcRenderer.invoke('dialog:save', options) as Promise<Electron.SaveDialogReturnValue>,
/** Show a native open dialog and return the chosen paths */
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke('dialog:open', options) as Promise<Electron.OpenDialogReturnValue>,
// ─── App Info ───
getAppInfo: () =>
ipcRenderer.invoke('app:info') as Promise<{
version: string;
name: string;
userData: string;
isPackaged: boolean;
}>,
};
// Expose to the renderer process
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// TypeScript: Augment Window interface for consumers
export type ElectronAPI = typeof electronAPI;
+16 -2
View File
@@ -53,7 +53,7 @@ export interface RenderJobCreateParams {
// ═══ Constants ═══
const MAX_CONCURRENT = 1; // Remotion renders are CPU-intensive
const RENDERS_DIR = path.join(process.cwd(), 'renders');
const RENDERS_DIR = process.env.BRADLY_RENDERS_DIR || path.join(process.cwd(), 'renders');
// Ensure renders directory exists
if (!fs.existsSync(RENDERS_DIR)) {
@@ -109,6 +109,14 @@ export function addSSEClient(clientId: string, send: (data: string) => void): ()
// ═══ Bundle Management ═══
async function ensureBundle(): Promise<string> {
// In packaged Electron, use pre-built bundle from app resources
if (process.env.BRADLY_REMOTION_BUNDLE) {
const prebuilt = process.env.BRADLY_REMOTION_BUNDLE;
if (fs.existsSync(prebuilt)) return prebuilt;
throw new Error(`Pre-built Remotion bundle not found at: ${prebuilt}`);
}
// Development: bundle on demand
if (bundlePath) return bundlePath;
if (isBundling) {
// Wait for existing bundle
@@ -127,7 +135,8 @@ async function ensureBundle(): Promise<string> {
try {
const { bundle } = await import('@remotion/bundler');
const entryPoint = path.join(process.cwd(), 'src', 'Root.tsx');
const entryPoint = process.env.BRADLY_REMOTION_ENTRY
|| path.join(process.cwd(), 'src', 'Root.tsx');
bundlePath = await bundle({
entryPoint,
@@ -225,6 +234,9 @@ async function renderJob(job: RenderJob): Promise<void> {
const ext = job.format;
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`);
// In packaged Electron, point to unpacked compositor binaries
const binariesDirectory = process.env.BRADLY_BINARIES_DIR || undefined;
console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
// Resolve the full composition config from the bundle
@@ -253,6 +265,7 @@ async function renderJob(job: RenderJob): Promise<void> {
output: outputPath,
imageFormat: job.format as 'png' | 'jpeg',
inputProps: job.inputProps,
binariesDirectory,
});
job.progress = 100;
@@ -267,6 +280,7 @@ async function renderJob(job: RenderJob): Promise<void> {
codec: job.format === 'webm' ? 'vp8' : 'h264',
outputLocation: outputPath,
inputProps: job.inputProps,
binariesDirectory,
onProgress: ({ renderedFrames, encodedFrames }) => {
const progress = Math.round(
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100