diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index ef5f7dd6..f7c25752 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -94,6 +94,8 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + VITE_PUBLIC_POSTHOG_KEY: ${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} + VITE_PUBLIC_POSTHOG_HOST: ${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} run: npm run publish working-directory: apps/x/apps/main diff --git a/apps/x/apps/main/agents.md b/apps/x/apps/main/agents.md deleted file mode 100644 index de25ff6c..00000000 --- a/apps/x/apps/main/agents.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 replaces source files with bundled/staged files: -- **Hook signature**: `async (config, buildPath, electronVersion, platform, arch)` - - `buildPath` already points to `Contents/Resources/app` (not the `.app` bundle root) -- Removes unbundled `dist/` directory (has unresolvable `@x/core` imports) -- Copies bundled `dist-bundle/` from `.package/` staging directory -- Copies `preload/` and `renderer/` directories from staging -- Updates `package.json`: sets `main` to `dist-bundle/main.js`, removes - `"type": "module"` (since we bundle as CJS), removes all dependencies/devDependencies -- Cleans up source files (src/, tsconfig.json, forge.config.cjs, agents.md, .gitignore, bundle.mjs) - -**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 by modifying files after Forge -copies the source directory but before the app bundle is finalized. - -## 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` diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index b95d4bda..2444e356 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -21,12 +21,11 @@ await esbuild.build({ bundle: true, platform: 'node', target: 'node20', - outfile: './.package/dist-bundle/main.js', + outfile: './.package/dist/main.cjs', 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 diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 060791cb..3a2b340f 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -27,15 +27,11 @@ module.exports = { // 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$/, + /src\//, + /node_modules\//, + /.gitignore/, + /bundle\.mjs/, + /tsconfig.json/, ], }, makers: [ @@ -142,111 +138,7 @@ module.exports = { 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() - // Read version from source package.json (updated by CI from git tag) - const sourcePackageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); - const packageJson = { - name: 'Rowboat', - version: sourcePackageJson.version, - 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 signature: async (config, buildPath, electronVersion, platform, arch) - // Called after Forge copies source directory to build output - // This is where we replace source files with bundled/staged files - packageAfterCopy: async (config, buildPath, electronVersion, platform, arch) => { - const fs = require('fs'); - const packageDir = path.join(__dirname, '.package'); - // buildPath already points to the app directory (Contents/Resources/app) - const appResourcesPath = buildPath; - - console.log('📦 Copying staged files from .package/ to packaged app...'); - - // Remove unbundled dist/ directory (source TypeScript output) - const unbundledDist = path.join(appResourcesPath, 'dist'); - if (fs.existsSync(unbundledDist)) { - console.log('Removing unbundled dist/...'); - fs.rmSync(unbundledDist, { recursive: true }); - } - - // Copy bundled dist-bundle/ from staging - const distBundleSrc = path.join(packageDir, 'dist-bundle'); - const distBundleDest = path.join(appResourcesPath, 'dist-bundle'); - if (fs.existsSync(distBundleSrc)) { - console.log('Copying dist-bundle/...'); - fs.mkdirSync(distBundleDest, { recursive: true }); - fs.cpSync(distBundleSrc, distBundleDest, { recursive: true }); - } - - // Copy preload/ from staging - const preloadSrc = path.join(packageDir, 'preload'); - const preloadDest = path.join(appResourcesPath, 'preload'); - if (fs.existsSync(preloadSrc)) { - console.log('Copying preload/...'); - // Remove old preload if it exists - if (fs.existsSync(preloadDest)) { - fs.rmSync(preloadDest, { recursive: true }); - } - fs.cpSync(preloadSrc, preloadDest, { recursive: true }); - } - - // Copy renderer/ from staging - const rendererSrc = path.join(packageDir, 'renderer'); - const rendererDest = path.join(appResourcesPath, 'renderer'); - if (fs.existsSync(rendererSrc)) { - console.log('Copying renderer/...'); - // Remove old renderer if it exists - if (fs.existsSync(rendererDest)) { - fs.rmSync(rendererDest, { recursive: true }); - } - fs.cpSync(rendererSrc, rendererDest, { recursive: true }); - } - - // Update package.json to point to bundled entry point - const packageJsonPath = path.join(appResourcesPath, 'package.json'); - if (fs.existsSync(packageJsonPath)) { - console.log('Updating package.json...'); - // Read version from source package.json (updated by CI from git tag) - const sourcePackageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); - const packageJson = { - name: 'Rowboat', - version: sourcePackageJson.version, - main: 'dist-bundle/main.js', - // Note: No "type": "module" since we bundle as CommonJS - // No dependencies/devDependencies since everything is bundled - }; - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); - } - - // Clean up source files that shouldn't be in packaged app - const filesToRemove = ['src', 'tsconfig.json', 'forge.config.cjs', 'agents.md', '.gitignore', 'bundle.mjs']; - for (const file of filesToRemove) { - const filePath = path.join(appResourcesPath, file); - if (fs.existsSync(filePath)) { - console.log(`Removing ${file}...`); - fs.rmSync(filePath, { recursive: true, force: true }); - } - } - - console.log('✅ Staged files copied to packaged app'); - } } }; \ No newline at end of file diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 46f3fe79..676f7269 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -2,10 +2,10 @@ "name": "Rowboat", "type": "module", "version": "0.1.0", - "main": "dist/main.js", + "main": ".package/dist/main.cjs", "scripts": { "start": "electron .", - "build": "rm -rf dist && tsc", + "build": "rm -rf dist && tsc && node bundle.mjs", "package": "electron-forge package --arch=arm64,x64 --platform=darwin", "make": "electron-forge make --arch=arm64,x64 --platform=darwin", "publish": "electron-forge publish --arch=arm64,x64 --platform=darwin" diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 92c1f1fc..7ae6ed46 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; -import { existsSync } from "node:fs"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; @@ -15,58 +14,51 @@ import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// #region agent log -fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:14',message:'__dirname resolved',data:{__dirname,__filename,isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{}); -// #endregion - // 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 + ? path.join(__dirname, "../preload/dist/preload.js") + : path.join(__dirname, "../../../preload/dist/preload.js"); console.log("preloadPath", preloadPath); -// #region agent log -fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:22',message:'preloadPath computed',data:{preloadPath,exists:existsSync(preloadPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{}); -// #endregion +const rendererPath = app.isPackaged + ? path.join(__dirname, "../renderer/dist") // Production + : path.join(__dirname, "../../../renderer/dist"); // Development +console.log("rendererPath", rendererPath); -// Register custom protocol for serving built renderer files in production +// Register custom protocol for serving built renderer files in production. +// This keeps SPA routes working when users deep link into the packaged app. function registerAppProtocol() { - protocol.handle('app', (request) => { - // #region agent log - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:26',message:'protocol handler called',data:{url:request.url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); - // #endregion - - // 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); + protocol.handle("app", (request) => { + const url = new URL(request.url); + + // url.pathname starts with "/" + let urlPath = url.pathname; + + // If it's "/" or a SPA route (no extension), serve index.html + if (urlPath === "/" || !path.extname(urlPath)) { + urlPath = "/index.html"; } - - // 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); - - // #region agent log - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:46',message:'renderer path resolution',data:{rendererDistPath,filePath,urlPath,exists:existsSync(filePath),rendererDistExists:existsSync(rendererDistPath)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{}); - // #endregion - + + const filePath = path.join(rendererPath, urlPath); return net.fetch(pathToFileURL(filePath).toString()); }); } +protocol.registerSchemesAsPrivileged([ + { + scheme: "app", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + allowServiceWorkers: true, + // optional but often helpful: + // stream: true, + }, + }, +]); + function createWindow() { const win = new BrowserWindow({ width: 1280, @@ -83,67 +75,44 @@ function createWindow() { // Open external links in system browser (not sandboxed Electron window) // This handles window.open() and target="_blank" links win.webContents.setWindowOpenHandler(({ url }) => { - // Open all URLs in system browser shell.openExternal(url); - return { action: 'deny' }; // Prevent Electron from opening a new window + return { action: "deny" }; }); // Handle navigation to external URLs (e.g., clicking a link without target="_blank") - win.webContents.on('will-navigate', (event, url) => { - // Allow internal navigation (app protocol or dev server) - const isInternal = url.startsWith('app://') || url.startsWith('http://localhost:5173'); + win.webContents.on("will-navigate", (event, url) => { + const isInternal = + url.startsWith("app://") || url.startsWith("http://localhost:5173"); if (!isInternal) { event.preventDefault(); shell.openExternal(url); } }); - // #region agent log - const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173'; - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:65',message:'createWindow called',data:{isPackaged:app.isPackaged,loadURL,preloadPath},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); - // #endregion - if (app.isPackaged) { - // Production: load from custom protocol (serves built renderer files) - win.loadURL('app://./'); - - // #region agent log - win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:69',message:'window load failed',data:{errorCode,errorDescription,validatedURL},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); - }); - win.webContents.on('did-finish-load', () => { - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:72',message:'window load finished',data:{url:win.webContents.getURL()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); - }); - // #endregion + win.loadURL("app://-/index.html"); } else { - // Development: load from Vite dev server - win.loadURL('http://localhost:5173'); + win.loadURL("http://localhost:5173"); } } app.whenReady().then(() => { - // #region agent log - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:74',message:'app.whenReady triggered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); - // #endregion - // Register custom protocol before creating window (for production builds) - registerAppProtocol(); - - // #region agent log - fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:77',message:'protocol registered',data:{isPackaged:app.isPackaged},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); - // #endregion - + if (app.isPackaged) { + registerAppProtocol(); + } + // Initialize auto-updater (only in production) if (app.isPackaged) { updateElectronApp({ updateSource: { type: UpdateSourceType.StaticStorage, - baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}` + baseUrl: `https://rowboat-desktop-app-releases.s3.amazonaws.com/releases/${process.platform}/${process.arch}`, }, - notifyUser: true // Shows native dialog when update is available + notifyUser: true, // Shows native dialog when update is available }); } - + setupIpcHandlers(); createWindow(); @@ -155,7 +124,6 @@ app.whenReady().then(() => { // Only starts once (guarded in startWorkspaceWatcher) startWorkspaceWatcher(); - // start runs watcher startRunsWatcher(); @@ -177,7 +145,7 @@ app.whenReady().then(() => { // start pre-built agent runner initPreBuiltRunner(); - app.on('activate', () => { + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); }