rowboat/apps/x/apps/main/src/main.ts

275 lines
8.5 KiB
TypeScript

import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session } from "electron";
import path from "node:path";
import {
setupIpcHandlers,
startRunsWatcher,
startServicesWatcher,
startWorkspaceWatcher,
stopRunsWatcher,
stopServicesWatcher,
stopWorkspaceWatcher
} from "./ipc.js";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname } from "node:path";
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";
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
import { execSync, exec } from "node:child_process";
import { promisify } from "node:util";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// run this as early in the main process as possible
if (started) app.quit();
// Fix PATH for packaged Electron apps on macOS/Linux.
// Packaged apps inherit a minimal environment that doesn't include paths from
// the user's shell profile (nvm, Homebrew, etc.). Spawn the user's login shell
// to resolve the full PATH, using delimiters to safely extract it from any
// surrounding shell output (motd, greeting messages, etc.).
if (process.platform !== 'win32') {
try {
const userShell = process.env.SHELL || '/bin/zsh';
const delimiter = '__ROWBOAT_PATH__';
const output = execSync(
`${userShell} -lc 'echo -n "${delimiter}$PATH${delimiter}"'`,
{ encoding: 'utf-8', timeout: 5000 },
);
const match = output.match(new RegExp(`${delimiter}(.+?)${delimiter}`));
if (match?.[1]) {
process.env.PATH = match[1];
}
} catch {
// Silently fall back to the existing PATH if shell resolution fails
}
}
// Path resolution differs between development and production:
const preloadPath = app.isPackaged
? path.join(__dirname, "../preload/dist/preload.js")
: path.join(__dirname, "../../../preload/dist/preload.js");
console.log("preloadPath", preloadPath);
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.
// This keeps SPA routes working when users deep link into the packaged app.
function registerAppProtocol() {
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";
}
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,
height: 800,
show: false, // Don't show until ready
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 12, y: 12 },
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: preloadPath,
},
});
// Grant microphone and display-capture permissions
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
if (permission === 'media' || permission === 'display-capture') {
callback(true);
} else {
callback(false);
}
});
// Auto-approve display media requests and route system audio as loopback.
// Electron requires a video source in the callback even if we only want audio.
// We pass the first available screen source; the renderer discards the video track.
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
const sources = await desktopCapturer.getSources({ types: ['screen'] });
if (sources.length === 0) {
callback({});
return;
}
callback({ video: sources[0], audio: 'loopback' });
});
// Show window when content is ready to prevent blank screen
win.once("ready-to-show", () => {
win.maximize();
win.show();
});
// Open external links in system browser (not sandboxed Electron window)
// This handles window.open() and target="_blank" links
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
// Handle navigation to external URLs (e.g., clicking a link without target="_blank")
win.webContents.on("will-navigate", (event, url) => {
const isInternal =
url.startsWith("app://") || url.startsWith("http://localhost:5173");
if (!isInternal) {
event.preventDefault();
shell.openExternal(url);
}
});
if (app.isPackaged) {
win.loadURL("app://-/index.html");
} else {
win.loadURL("http://localhost:5173");
}
}
app.whenReady().then(async () => {
// Register custom protocol before creating window (for production builds)
if (app.isPackaged) {
registerAppProtocol();
}
// Initialize auto-updater (only in production)
if (app.isPackaged) {
updateElectronApp({
updateSource: {
type: UpdateSourceType.ElectronPublicUpdateService,
repo: "rowboatlabs/rowboat",
},
notifyUser: true, // Shows native dialog when update is available
});
}
// Ensure agent-slack CLI is available
try {
execSync('agent-slack --version', { stdio: 'ignore', timeout: 5000 });
} catch {
try {
console.log('agent-slack not found, installing...');
await execAsync('npm install -g agent-slack', { timeout: 60000 });
console.log('agent-slack installed successfully');
} catch (e) {
console.error('Failed to install agent-slack:', e);
}
}
// Initialize all config files before UI can access them
await initConfigs();
setupIpcHandlers();
createWindow();
// Start workspace watcher as a main-process service
// Watcher runs independently and catches ALL filesystem changes:
// - Changes made via IPC handlers (workspace:writeFile, etc.)
// - External changes (terminal, git, other editors)
// Only starts once (guarded in startWorkspaceWatcher)
startWorkspaceWatcher();
// start runs watcher
startRunsWatcher();
// start services watcher
startServicesWatcher();
// start gmail sync
initGmailSync();
// start calendar sync
initCalendarSync();
// start fireflies sync
initFirefliesSync();
// start granola sync
initGranolaSync();
// start knowledge graph builder
initGraphBuilder();
// start email labeling service
initEmailLabeling();
// start note tagging service
initNoteTagging();
// start inline task service (@rowboat: mentions)
initInlineTasks();
// start background agent runner (scheduled agents)
initAgentRunner();
// start agent notes learning service
initAgentNotes();
// start chrome extension sync server
initChromeSync();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
// Clean up watcher on app quit
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
});