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
+1
View File
@@ -4,6 +4,7 @@ node_modules/
# Build output # Build output
build/ build/
dist/ dist/
out/
*.cjs *.cjs
*.cjs.map *.cjs.map
+72
View File
@@ -0,0 +1,72 @@
/**
* electron-vite Configuration — Bradly Desktop App
*
* Configures three build targets:
* - main: Electron main process (Node.js)
* - preload: Sandboxed bridge scripts
* - renderer: React app (browser)
*/
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
export default defineConfig({
// ═══ Main Process (Node.js environment) ═══
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/main',
rollupOptions: {
input: {
index: path.resolve(__dirname, 'src/electron/main.ts'),
},
// Keep Remotion + Express packages external (they have native deps)
external: [
'@remotion/bundler',
'@remotion/renderer',
/^@remotion\/compositor/,
'express',
'multer',
'vite',
],
},
},
},
// ═══ Preload Scripts (sandboxed bridge) ═══
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/preload',
rollupOptions: {
input: {
index: path.resolve(__dirname, 'src/electron/preload.ts'),
},
},
},
},
// ═══ Renderer (React app — browser environment) ═══
renderer: {
root: '.',
plugins: [react(), tailwindcss()],
build: {
outDir: 'out/renderer',
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
hmr: process.env.DISABLE_HMR !== 'true',
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
},
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Electron Forge Configuration — Bradly Desktop App
*
* Handles packaging and creating distributable installers.
* Currently targets macOS (.dmg) only.
*/
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerDMG } from '@electron-forge/maker-dmg';
const config: ForgeConfig = {
packagerConfig: {
name: 'Bradly',
executableName: 'bradly',
asar: {
// Remotion compositor binaries MUST be outside app.asar
// because they are native executables that need direct filesystem access
unpack: '{**/node_modules/@remotion/compositor-*/**,**/node_modules/@remotion/renderer/**}',
},
// Ignore development files during packaging
ignore: [
/^\/src$/,
/^\/scripts$/,
/^\/uploads$/,
/^\/renders$/,
/^\/\.vscode$/,
/^\/\.git$/,
/^\/\.env/,
/^\/\.antigravity$/,
/^\/vite\.config\.ts$/,
/^\/electron\.vite\.config\.ts$/,
/^\/tsconfig\.json$/,
/^\/ABOUT\.md$/,
/^\/AGENTS\.md$/,
/^\/README\.md$/,
/^\/schema\.sql$/,
/^\/backend_endpoint\.py$/,
],
},
makers: [
new MakerDMG({
format: 'ULFO',
}),
],
};
export default config;
+5664
View File
File diff suppressed because it is too large Load Diff
+19 -7
View File
@@ -1,15 +1,23 @@
{ {
"name": "react-example", "name": "bradly",
"productName": "Bradly",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"main": "out/main/index.js",
"scripts": { "scripts": {
"dev": "tsx server.ts", "dev": "electron-vite dev",
"build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", "dev:web": "tsx server.ts",
"start": "node dist/server.cjs", "build": "electron-vite build",
"build:web": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs",
"start": "electron-vite preview",
"start:web": "node dist/server.cjs",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist server.js", "clean": "rm -rf dist out server.js",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"remotion:bundle": "node scripts/bundle-remotion.js",
"package": "npm run remotion:bundle && npm run build && electron-forge package",
"make": "npm run remotion:bundle && npm run build && electron-forge make"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -35,12 +43,16 @@
"vite": "^6.2.3" "vite": "^6.2.3"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.2",
"@electron-forge/maker-dmg": "^7.11.2",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"electron": "^41.7.1",
"electron-vite": "^5.0.0",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
+50
View File
@@ -0,0 +1,50 @@
/**
* Pre-build Remotion Bundle for Packaging
*
* This script runs BEFORE electron-forge packages the app.
* It pre-bundles the Remotion project so the packaged app
* doesn't need to call bundle() at runtime (which requires
* webpack and other heavy deps).
*
* Usage: node scripts/bundle-remotion.js
* Output: out/remotion-bundle/
*/
const path = require('path');
const fs = require('fs');
async function main() {
const outDir = path.join(__dirname, '..', 'out', 'remotion-bundle');
// Clean previous bundle
if (fs.existsSync(outDir)) {
fs.rmSync(outDir, { recursive: true });
}
console.log('📦 Building Remotion bundle for packaging...');
console.log(' Entry: src/Root.tsx');
console.log(' Output:', outDir);
const { bundle } = require('@remotion/bundler');
const entryPoint = path.join(__dirname, '..', 'src', 'Root.tsx');
const bundlePath = await bundle({
entryPoint,
outDir,
webpackOverride: (config) => config,
});
console.log('✅ Remotion bundle ready:', bundlePath);
// Verify the bundle exists
const indexPath = path.join(bundlePath, 'index.html');
if (!fs.existsSync(indexPath)) {
throw new Error(`Bundle verification failed: ${indexPath} not found`);
}
console.log('✅ Bundle verified (index.html exists)');
}
main().catch((err) => {
console.error('❌ Remotion bundle failed:', err);
process.exit(1);
});
+28 -17
View File
@@ -6,7 +6,7 @@ import { createServer as createViteServer } from "vite";
import multer from "multer"; import multer from "multer";
// ═══ Uploads directory ═══ // ═══ Uploads directory ═══
const UPLOADS_DIR = path.join(process.cwd(), "uploads"); const UPLOADS_DIR = process.env.BRADLY_UPLOADS_DIR || path.join(process.cwd(), "uploads");
if (!fs.existsSync(UPLOADS_DIR)) { if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true }); fs.mkdirSync(UPLOADS_DIR, { recursive: true });
} }
@@ -40,6 +40,14 @@ const mediaUpload = multer({
}); });
async function startServer() { async function startServer() {
const app = await createExpressApp();
const PORT = 3000;
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on http://localhost:${PORT}`);
});
}
export async function createExpressApp() {
const app = express(); const app = express();
const PORT = 3000; const PORT = 3000;
@@ -299,23 +307,26 @@ async function startServer() {
}); });
}); });
if (process.env.NODE_ENV !== "production") { if (!process.env.BRADLY_ELECTRON) {
const vite = await createViteServer({ if (process.env.NODE_ENV !== "production") {
server: { middlewareMode: true }, const vite = await createViteServer({
appType: "spa", server: { middlewareMode: true },
}); appType: "spa",
app.use(vite.middlewares); });
} else { app.use(vite.middlewares);
const distPath = path.join(process.cwd(), "dist"); } else {
app.use(express.static(distPath)); const distPath = path.join(process.cwd(), "dist");
app.get("*", (req, res) => { app.use(express.static(distPath));
res.sendFile(path.join(distPath, "index.html")); app.get("*", (req, res) => {
}); res.sendFile(path.join(distPath, "index.html"));
});
}
} }
app.listen(PORT, "0.0.0.0", () => { return app;
console.log(`Server running on http://localhost:${PORT}`);
});
} }
startServer(); // Direct execution (web mode only)
if (!process.env.BRADLY_ELECTRON) {
startServer();
}
+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 ═══ // ═══ Constants ═══
const MAX_CONCURRENT = 1; // Remotion renders are CPU-intensive 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 // Ensure renders directory exists
if (!fs.existsSync(RENDERS_DIR)) { if (!fs.existsSync(RENDERS_DIR)) {
@@ -109,6 +109,14 @@ export function addSSEClient(clientId: string, send: (data: string) => void): ()
// ═══ Bundle Management ═══ // ═══ Bundle Management ═══
async function ensureBundle(): Promise<string> { 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 (bundlePath) return bundlePath;
if (isBundling) { if (isBundling) {
// Wait for existing bundle // Wait for existing bundle
@@ -127,7 +135,8 @@ async function ensureBundle(): Promise<string> {
try { try {
const { bundle } = await import('@remotion/bundler'); 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({ bundlePath = await bundle({
entryPoint, entryPoint,
@@ -225,6 +234,9 @@ async function renderJob(job: RenderJob): Promise<void> {
const ext = job.format; const ext = job.format;
const outputPath = path.join(RENDERS_DIR, `${job.id}.${ext}`); 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})`); console.log(`🎬 Rendering [${job.id}] → ${job.format} (${job.width}×${job.height})`);
// Resolve the full composition config from the bundle // Resolve the full composition config from the bundle
@@ -253,6 +265,7 @@ async function renderJob(job: RenderJob): Promise<void> {
output: outputPath, output: outputPath,
imageFormat: job.format as 'png' | 'jpeg', imageFormat: job.format as 'png' | 'jpeg',
inputProps: job.inputProps, inputProps: job.inputProps,
binariesDirectory,
}); });
job.progress = 100; job.progress = 100;
@@ -267,6 +280,7 @@ async function renderJob(job: RenderJob): Promise<void> {
codec: job.format === 'webm' ? 'vp8' : 'h264', codec: job.format === 'webm' ? 'vp8' : 'h264',
outputLocation: outputPath, outputLocation: outputPath,
inputProps: job.inputProps, inputProps: job.inputProps,
binariesDirectory,
onProgress: ({ renderedFrames, encodedFrames }) => { onProgress: ({ renderedFrames, encodedFrames }) => {
const progress = Math.round( const progress = Math.round(
((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100 ((renderedFrames ?? encodedFrames ?? 0) / job.durationInFrames) * 100