integrate electron forge

This commit is contained in:
Ramnique Singh 2026-01-17 10:27:30 +05:30
parent 6abb3afc36
commit f72dee731a
14 changed files with 3388 additions and 32 deletions

View file

@ -1,2 +1,5 @@
node_modules/
dist/
dist/
# Staging directory for Electron Forge packaging (contains bundled main process, copied preload/renderer)
.package/
out/

View file

@ -0,0 +1,80 @@
# Electron Main Process - Build & Packaging
## Overview
This is the Electron main process for the Rowboat app.
## Why We Use esbuild Bundling
**Problem**: pnpm uses symlinks for workspace packages (`@x/core`, `@x/shared`).
Electron Forge's dependency walker (`flora-colossus`) cannot follow these symlinks,
causing "Failed to locate module" errors during packaging. Note: npm workspaces
also use symlinks, so this isn't pnpm-specific.
**Solution**: Bundle the entire main process into a single JS file using esbuild.
This inlines all dependencies (except `electron` itself), eliminating the need
for `node_modules` at runtime.
## Bundle Configuration (`bundle.mjs`)
The bundler uses these key settings:
- **Format: CommonJS** - Many dependencies use `require()` which doesn't work
with esbuild's ESM shim. CJS handles dynamic requires natively.
- **import.meta.url polyfill** - The source code uses ESM's `import.meta.url`
to derive `__dirname`, but CJS doesn't have `import.meta`. We solve this with:
- `banner`: Injects `var __import_meta_url = require('url').pathToFileURL(__filename).href;`
- `define`: Replaces all `import.meta.url` with `__import_meta_url`
- **External: electron** - Not bundled; provided by Electron runtime.
## Build Process
The build uses two Forge hooks in `forge.config.cjs`:
### 1. `generateAssets` Hook (Pre-packaging)
Prepares all build artifacts in a hidden `.package/` staging directory:
- Builds shared, renderer, preload, and main TypeScript
- Bundles main process with esbuild → `.package/dist-bundle/main.js`
- Copies preload/renderer dist to `.package/`
### 2. `packageAfterCopy` Hook (Post-copy)
After Forge copies source to output, this hook fixes it:
- Removes unbundled `dist/` (has unresolvable `@x/core` imports)
- Copies bundled `dist-bundle/`, `preload/`, `renderer/` from staging
- Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes
`"type": "module"` (since we bundle as CJS), removes dependencies
- Cleans up source files (tsconfig.json, src/, etc.)
**Why this approach?** Electron Forge ignores `packagerConfig.dir` and always
packages from the config file's directory. The `packageAfterCopy` hook is the
reliable way to customize the packaged output.
## Staged Build Directory (`.package/`)
- **Why not copy into apps/main directly?** Would pollute source with build artifacts
- **Why .cjs extension for forge.config?** package.json has `"type": "module"`,
but Forge loads configs with `require()`. The `.cjs` extension forces CommonJS.
- **Why hidden (`.` prefix)?** Prevents accidental conflicts with developer-created dirs
## Development vs Production Paths
| Mode | main.js location | preload path | renderer path |
|------|------------------|--------------|---------------|
| Dev | `dist/main.js` | `../../preload/dist/` | `../../renderer/dist/` |
| Prod | `dist-bundle/main.js` | `../preload/dist/` | `../renderer/dist/` |
Code uses `app.isPackaged` to select the correct paths at runtime.
## Build Commands
- `npm run start` - Development (runs from dist/, uses Vite dev server)
- `npm run package` - Creates .app bundle in `out/`
- `npm run make` - Creates DMG/ZIP in `out/make/`
## Troubleshooting
If the packaged app fails with module errors:
1. **Clean build**: `rm -rf out .package && npm run make`
2. **Reinstall fresh**: Delete `/Applications/Rowboat.app` before installing DMG
3. **Clear caches**: `rm -rf ~/Library/Caches/com.rowboat.app`

View file

@ -0,0 +1,38 @@
/**
* Bundles the compiled main process into a single JavaScript file.
*
* Why we bundle:
* - pnpm uses symlinks for workspace packages (@x/core, @x/shared)
* - Electron Forge's dependency walker (flora-colossus) cannot follow these symlinks
* - Bundling inlines all dependencies into a single file, eliminating node_modules
*
* This script is called by the generateAssets hook in forge.config.js before packaging.
*/
import * as esbuild from 'esbuild';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it.
// The banner defines __import_meta_url at the top of the bundle,
// and we use define to replace all import.meta.url references with it.
const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`;
await esbuild.build({
entryPoints: ['./dist/main.js'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: './.package/dist-bundle/main.js',
external: ['electron'], // Provided by Electron runtime
// Use CommonJS format - many dependencies use require() which doesn't work
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
format: 'cjs',
sourcemap: true,
// Inject the polyfill variable at the top
banner: { js: cjsBanner },
// Replace import.meta.url directly with our polyfill variable
define: {
'import.meta.url': '__import_meta_url',
},
});
console.log('✅ Main process bundled to .package/dist-bundle/main.js');

View file

@ -0,0 +1,201 @@
// Electron Forge config file
// NOTE: Must be .cjs (CommonJS) because package.json has "type": "module"
// Forge loads configs with require(), which fails on ESM files
const path = require('path');
module.exports = {
packagerConfig: {
name: 'Rowboat',
executableName: 'rowboat',
icon: './icons/icon', // .icns extension added automatically
appBundleId: 'com.rowboat.app',
appCategoryType: 'public.app-category.productivity',
// Since we bundle everything with esbuild, we don't need node_modules at all.
// These settings prevent Forge's dependency walker (flora-colossus) from trying
// to analyze/copy node_modules, which fails with pnpm's symlinked workspaces.
prune: false,
ignore: [
// Skip any node_modules that might exist
/node_modules/,
// Skip source files
/\.ts$/,
/\.tsx$/,
// Skip the staging directory
/\.package/,
// Skip the bundle script
/bundle\.mjs$/,
],
},
makers: [
{
name: '@electron-forge/maker-dmg',
config: {
format: 'ULFO',
name: 'Rowboat',
}
},
{
name: '@electron-forge/maker-zip',
platforms: ['darwin'],
// ZIP is used by Squirrel.Mac for auto-updates
}
],
hooks: {
// Hook signature: (forgeConfig, platform, arch)
// Note: Console output only shows if DEBUG or CI env vars are set
generateAssets: async (forgeConfig, platform, arch) => {
const { execSync } = require('child_process');
const fs = require('fs');
const packageDir = path.join(__dirname, '.package');
// Clean staging directory (ensures fresh build every time)
console.log('Cleaning staging directory...');
if (fs.existsSync(packageDir)) {
fs.rmSync(packageDir, { recursive: true });
}
fs.mkdirSync(packageDir, { recursive: true });
// Build shared (TypeScript compilation)
console.log('Building shared...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../../packages/shared'),
stdio: 'inherit'
});
// Build renderer (Vite build)
console.log('Building renderer...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../renderer'),
stdio: 'inherit'
});
// Build preload (TypeScript compilation)
console.log('Building preload...');
execSync('pnpm run build', {
cwd: path.join(__dirname, '../preload'),
stdio: 'inherit'
});
// Build main (TypeScript compilation)
console.log('Building main (tsc)...');
execSync('pnpm run build', {
cwd: __dirname,
stdio: 'inherit'
});
// Bundle main process with esbuild (inlines all dependencies)
console.log('Bundling main process...');
execSync('node bundle.mjs', {
cwd: __dirname,
stdio: 'inherit'
});
// Copy preload dist into staging directory
console.log('Copying preload...');
const preloadSrc = path.join(__dirname, '../preload/dist');
const preloadDest = path.join(packageDir, 'preload/dist');
fs.mkdirSync(preloadDest, { recursive: true });
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
// Copy renderer dist into staging directory
console.log('Copying renderer...');
const rendererSrc = path.join(__dirname, '../renderer/dist');
const rendererDest = path.join(packageDir, 'renderer/dist');
fs.mkdirSync(rendererDest, { recursive: true });
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
// Copy icons into staging directory
console.log('Copying icons...');
const iconsSrc = path.join(__dirname, 'icons');
const iconsDest = path.join(packageDir, 'icons');
if (fs.existsSync(iconsSrc)) {
fs.mkdirSync(iconsDest, { recursive: true });
fs.cpSync(iconsSrc, iconsDest, { recursive: true });
}
// Generate package.json in staging directory
// This tells Electron where to find the entry point
// Note: No "type": "module" since we bundle as CommonJS for compatibility
// with dependencies that use dynamic require()
const packageJson = {
name: '@x/main',
version: '0.1.0',
main: 'dist-bundle/main.js',
};
fs.writeFileSync(
path.join(packageDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
console.log('✅ All assets staged in .package/');
},
// Hook runs after Forge copies source to output directory
// We use this to replace the unbundled code with our bundled version
// Hook signature: (forgeConfig, buildPath, electronVersion, platform, arch)
packageAfterCopy: async (forgeConfig, buildPath, electronVersion, platform, arch) => {
const fs = require('fs');
const packageDir = path.join(__dirname, '.package');
// buildPath is the app directory inside the packaged output
// e.g., out/Rowboat-darwin-arm64/Rowboat.app/Contents/Resources/app
console.log('Fixing packaged app at:', buildPath);
// 1. Remove the unbundled dist/ directory (it has imports to @x/core, @x/shared)
const distDir = path.join(buildPath, 'dist');
if (fs.existsSync(distDir)) {
console.log('Removing unbundled dist/...');
fs.rmSync(distDir, { recursive: true });
}
// 2. Copy the bundled dist-bundle/ from staging
console.log('Copying bundled dist-bundle/...');
const bundleSrc = path.join(packageDir, 'dist-bundle');
const bundleDest = path.join(buildPath, 'dist-bundle');
fs.cpSync(bundleSrc, bundleDest, { recursive: true });
// 3. Copy preload from staging
console.log('Copying preload/...');
const preloadSrc = path.join(packageDir, 'preload');
const preloadDest = path.join(buildPath, 'preload');
fs.cpSync(preloadSrc, preloadDest, { recursive: true });
// 4. Copy renderer from staging
console.log('Copying renderer/...');
const rendererSrc = path.join(packageDir, 'renderer');
const rendererDest = path.join(buildPath, 'renderer');
fs.cpSync(rendererSrc, rendererDest, { recursive: true });
// 5. Update package.json to point to bundled entry
console.log('Updating package.json...');
const packageJsonPath = path.join(buildPath, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
packageJson.main = 'dist-bundle/main.js';
// Remove workspace dependencies (they're bundled now)
delete packageJson.dependencies;
delete packageJson.devDependencies;
delete packageJson.scripts;
// Remove "type": "module" - we bundle as CommonJS for compatibility
// with dependencies that use dynamic require()
delete packageJson.type;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
// 6. Clean up source files that shouldn't be in production
const filesToRemove = ['tsconfig.json', 'forge.config.cjs', 'agents.md'];
for (const file of filesToRemove) {
const filePath = path.join(buildPath, file);
if (fs.existsSync(filePath)) {
fs.rmSync(filePath);
}
}
const srcDir = path.join(buildPath, 'src');
if (fs.existsSync(srcDir)) {
fs.rmSync(srcDir, { recursive: true });
}
console.log('✅ Packaged app fixed with bundled code');
}
}
};

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,10 +1,14 @@
{
"name": "@x/main",
"type": "module",
"version": "0.1.0",
"main": "dist/main.js",
"scripts": {
"start": "electron .",
"build": "rm -rf dist && tsc"
"start": "electron-forge start",
"build": "rm -rf dist && tsc",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish"
},
"dependencies": {
"@x/core": "workspace:*",
@ -14,6 +18,12 @@
},
"devDependencies": {
"@types/node": "^25.0.3",
"electron": "^39.2.7"
"electron": "^39.2.7",
"esbuild": "^0.24.2",
"@electron-forge/cli": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1"
}
}

View file

@ -1,7 +1,7 @@
import { app, BrowserWindow } from "electron";
import { app, BrowserWindow, protocol, net } from "electron";
import path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path";
import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js";
import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js";
@ -13,9 +13,42 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const preloadPath = path.join(__dirname, "../../preload/dist/preload.js");
// Path resolution differs between development and production:
// - Development: main.js runs from dist/, preload is at ../../preload/dist/ (sibling dir)
// - Production: main.js runs from .package/dist-bundle/, preload is at ../preload/dist/ (copied into .package/)
const preloadPath = app.isPackaged
? path.join(__dirname, "../preload/dist/preload.js") // Production
: path.join(__dirname, "../../preload/dist/preload.js"); // Development
console.log("preloadPath", preloadPath);
// Register custom protocol for serving built renderer files in production
function registerAppProtocol() {
protocol.handle('app', (request) => {
// Remove 'app://' prefix and get the path
let urlPath = request.url.slice('app://'.length);
// Remove leading './' if present
if (urlPath.startsWith('./')) {
urlPath = urlPath.slice(2);
}
// Default to index.html for root or SPA routes (no file extension)
if (!urlPath || urlPath === '/' || !path.extname(urlPath)) {
urlPath = 'index.html';
}
// Resolve to the renderer dist directory
// - Development: main.js at dist/, renderer at ../../renderer/dist/ (sibling dir)
// - Production: main.js at .package/dist-bundle/, renderer at ../renderer/dist/ (copied into .package/)
const rendererDistPath = app.isPackaged
? path.join(__dirname, '../renderer/dist')
: path.join(__dirname, '../../renderer/dist');
const filePath = path.join(rendererDistPath, urlPath);
return net.fetch(pathToFileURL(filePath).toString());
});
}
function createWindow() {
const win = new BrowserWindow({
width: 800,
@ -29,10 +62,19 @@ function createWindow() {
},
});
win.loadURL("http://localhost:5173"); // load the dev server
if (app.isPackaged) {
// Production: load from custom protocol (serves built renderer files)
win.loadURL('app://./');
} else {
// Development: load from Vite dev server
win.loadURL('http://localhost:5173');
}
}
app.whenReady().then(() => {
// Register custom protocol before creating window (for production builds)
registerAppProtocol();
setupIpcHandlers();
createWindow();

View file

@ -558,9 +558,10 @@ function App() {
}
}
const handlePromptSubmit = async ({ text }: PromptInputMessage) => {
const handlePromptSubmit = async (message: PromptInputMessage) => {
if (isProcessing) return
const { text } = message;
const userMessage = text.trim()
if (!userMessage) return

View file

@ -22,6 +22,7 @@ import {
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
import { Shimmer } from '@/components/ai-elements/shimmer'
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool'
import { type PromptInputMessage } from '@/components/ai-elements/prompt-input'
interface ChatMessage {
id: string
@ -106,7 +107,7 @@ interface ChatSidebarProps {
isProcessing: boolean
message: string
onMessageChange: (message: string) => void
onSubmit: (message: { text: string; files: never[] }) => void
onSubmit: (message: PromptInputMessage) => void
contextUsage: LanguageModelUsage
maxTokens: number
usedTokens: number

View file

@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
base: './', // Use relative paths for assets (required for Electron custom protocol)
plugins: [
react(),
tailwindcss(),
@ -14,4 +15,7 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: 'dist',
},
})

View file

@ -2,6 +2,7 @@
"name": "x",
"private": true,
"type": "module",
"version": "0.1.0",
"scripts": {
"dev": "npm run deps && concurrently -k \"npm:renderer\" \"npm:main\"",
"renderer": "cd apps/renderer && npm run dev",

3016
apps/x/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,4 +4,7 @@ packages:
onlyBuiltDependencies:
- electron
- electron-winstaller
- esbuild
- fs-xattr
- macos-alias