rowboat/apps/x/apps/main/bundle.mjs
Gagan 66865b4f6b feat(x): ambient meeting detection + take-notes popup (Granola parity phase 2)
- mic-monitor Swift helper: polls CoreAudio for mic-in-use, with owning
  PIDs on macOS 14.4+ (process-object API) so calls attribute to the app
  that actually holds the mic (Chrome vs Zoom vs Slack); no TCC permission
  needed. Compiled best-effort by bundle.mjs; exits when stdin closes
- core meetings/detector: 5s mic debounce, PID→process attribution with
  running-app fallback, Granola labels (Huddle/Call/Meeting detected),
  calendar merge (event start −15min through end), once per mic session
  (30s idle reset), suppressed while Rowboat itself captures (recording
  or assistant voice call), gated by a meeting_detection notification
  category (Settings > Notifications)
- popup: lean Granola-style bar (title + platform + Take notes pill,
  floating ×) in a transparent non-activating panel at screen-saver level
  with fullscreen-auxiliary behavior — floats over fullscreen meetings on
  the cursor's display; activation policy restored immediately so the
  Dock icon survives and Take Notes can foreground Rowboat
- Take Notes routes into the existing take-meeting-notes flow with a
  synthetic or matched calendar event (source: detected); never records
  without a click
- tray icon bumped to 18pt
2026-07-13 21:50:39 +05:30

142 lines
6.8 KiB
JavaScript

/**
* 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';
import { readFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// 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;`;
const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
await esbuild.build({
entryPoints: ['./dist/main.js'],
bundle: true,
platform: 'node',
target: 'node20',
outfile: './.package/dist/main.cjs',
// electron is provided by the runtime. node-pty is a NATIVE module: it can't
// be inlined (its loader requires .node binaries + a spawn-helper relative to
// its own package dir), so it stays external and is copied into
// .package/node_modules below, where require() from dist/main.cjs finds it.
external: ['electron', 'node-pty'],
// Use CommonJS format - many dependencies use require() which doesn't work
// well with esbuild's ESM shim. CJS handles dynamic requires natively.
format: 'cjs',
// 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',
// Inject PostHog credentials at build time. Reuse the renderer's
// VITE_PUBLIC_* envs so packaging only needs one set of values.
// Empty strings disable analytics gracefully.
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''),
},
});
// Ship node-pty next to the bundle. Resolve through pnpm's symlink to the real
// package dir and copy only what's needed at runtime (compiled JS + prebuilt
// binaries). The macOS spawn-helper must be executable — pnpm extraction drops
// the bit, and a non-executable helper makes every PTY spawn fail.
const here = path.dirname(fileURLToPath(import.meta.url));
const ptySrc = fs.realpathSync(path.join(here, 'node_modules', 'node-pty'));
const ptyDest = path.join(here, '.package', 'node_modules', 'node-pty');
fs.rmSync(ptyDest, { recursive: true, force: true });
fs.mkdirSync(ptyDest, { recursive: true });
for (const item of ['package.json', 'lib', 'prebuilds']) {
fs.cpSync(path.join(ptySrc, item), path.join(ptyDest, item), { recursive: true, dereference: true });
}
const prebuildsDir = path.join(ptyDest, 'prebuilds');
for (const dir of fs.readdirSync(prebuildsDir)) {
const helper = path.join(prebuildsDir, dir, 'spawn-helper');
if (fs.existsSync(helper)) fs.chmodSync(helper, 0o755);
}
// Self-heal: node-pty ships prebuilt binaries only for darwin/win32, so on any
// host whose prebuild is absent (notably Linux) the staged package has no loadable
// pty.node and the app crashes on launch. Compile the native module for the host
// platform+arch if needed and stage it under prebuilds/<platform>-<arch>/, where
// node-pty's loader looks first. Keeps dev and CI working without a manual node-gyp
// step (the CI workflow's explicit build is the fast path; this is the safety net).
const hostTriple = `${process.platform}-${process.arch}`;
const stagedBinary = path.join(prebuildsDir, hostTriple, 'pty.node');
if (!fs.existsSync(stagedBinary)) {
const builtBinary = path.join(ptySrc, 'build', 'Release', 'pty.node');
if (!fs.existsSync(builtBinary)) {
console.log(`node-pty: no prebuilt binary for ${hostTriple}; compiling with node-gyp…`);
execSync('npx node-gyp rebuild', { cwd: ptySrc, stdio: 'inherit' });
}
if (!fs.existsSync(builtBinary)) {
throw new Error(`node-pty: failed to produce a native binary for ${hostTriple}`);
}
fs.mkdirSync(path.dirname(stagedBinary), { recursive: true });
fs.copyFileSync(builtBinary, stagedBinary);
console.log(`✅ node-pty: staged ${hostTriple}/pty.node`);
}
console.log('✅ node-pty staged in .package/node_modules');
// Compile the mic-monitor helper (ambient meeting detection) on macOS.
// Best-effort: without swiftc — or on other platforms — the app still works,
// ad-hoc meeting detection just stays off (main checks the binary exists).
if (process.platform === 'darwin') {
const swiftSrc = path.join(here, 'native', 'mic-monitor.swift');
const helperOut = path.join(here, '.package', 'dist', 'mic-monitor');
const upToDate = fs.existsSync(helperOut) &&
fs.statSync(helperOut).mtimeMs >= fs.statSync(swiftSrc).mtimeMs;
if (upToDate) {
console.log('✅ mic-monitor helper up to date');
} else {
try {
execSync(`swiftc -O "${swiftSrc}" -o "${helperOut}"`, { stdio: 'inherit' });
console.log('✅ mic-monitor helper compiled');
} catch {
console.warn('⚠️ mic-monitor helper not built (swiftc unavailable?) — meeting detection disabled');
}
}
}
// Bundle the vendored agent-slack CLI into a single self-contained script next
// to main.cjs. It runs as a child process (process.execPath with
// ELECTRON_RUN_AS_NODE=1), so it must exist as a real file on disk — it can't
// be inlined into main.cjs. Bundling here means the packaged app needs neither
// node_modules nor a global npm install.
const agentSlackPkg = JSON.parse(
await readFile(new URL('./node_modules/agent-slack/package.json', import.meta.url), 'utf8'),
);
await esbuild.build({
entryPoints: ['./node_modules/agent-slack/dist/index.js'],
bundle: true,
platform: 'node',
target: 'node22',
outfile: './.package/dist/agent-slack.cjs',
format: 'cjs',
banner: { js: cjsBanner },
define: {
'import.meta.url': '__import_meta_url',
// Without this constant the CLI's --version walks up the directory tree
// for a package.json and would find Rowboat's instead of agent-slack's.
'AGENT_SLACK_BUILD_VERSION': JSON.stringify(agentSlackPkg.version),
},
// The CLI probes bun:sqlite via dynamic import inside a try/catch and falls
// back to node:sqlite; keep it external so the probe fails at runtime the
// same way it does under plain node.
external: ['bun:sqlite'],
});
console.log(`✅ Main process bundled to .package/dist/main.cjs (+ agent-slack ${agentSlackPkg.version} CLI)`);