bootstrap new electron app

This commit is contained in:
Ramnique Singh 2025-12-29 15:30:57 +05:30
parent 2491bacea1
commit 505e3ea620
89 changed files with 12397 additions and 8435 deletions

2
apps/x/apps/main/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,19 @@
{
"name": "@x/main",
"type": "module",
"main": "dist/main.js",
"scripts": {
"start": "electron .",
"build": "rm -rf dist && tsc"
},
"dependencies": {
"@x/core": "workspace:*",
"@x/shared": "workspace:*",
"chokidar": "^4.0.3",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^25.0.3",
"electron": "^39.2.7"
}
}

282
apps/x/apps/main/src/ipc.ts Normal file
View file

@ -0,0 +1,282 @@
import { ipcMain, BrowserWindow } from 'electron';
import { ipc } from '@x/shared';
import { watcher as watcherCore, workspace } from '@x/core';
import { workspace as workspaceShared } from '@x/shared';
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
import * as runsCore from '@x/core/dist/runs/runs.js';
import { bus } from '@x/core/dist/runs/bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
/**
* Type-safe handler function for invoke channels
*/
type InvokeHandler<K extends InvokeChannels> = (
event: Electron.IpcMainInvokeEvent,
args: IPCChannels[K]['req']
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
/**
* Type-safe handler registration map
* Ensures all invoke channels have handlers
*/
type InvokeHandlers = {
[K in InvokeChannels]: InvokeHandler<K>;
};
/**
* Register all IPC handlers with type safety and runtime validation
*
* This function ensures:
* 1. All invoke channels have handlers (exhaustiveness checking)
* 2. Handler signatures match channel definitions
* 3. Request/response payloads are validated at runtime
*/
export function registerIpcHandlers(handlers: InvokeHandlers) {
// Register each handler with runtime validation
for (const [channel, handler] of Object.entries(handlers) as [
InvokeChannels,
InvokeHandler<InvokeChannels>
][]) {
ipcMain.handle(channel, async (event, rawArgs) => {
// Validate request payload
const args = ipc.validateRequest(channel, rawArgs);
// Call handler
const result = await handler(event, args);
// Validate response payload
return ipc.validateResponse(channel, result);
});
}
}
// ============================================================================
// Electron-Specific Utilities
// ============================================================================
/**
* Get application versions (Electron-specific)
*/
function getVersions(): {
chrome: string;
node: string;
electron: string;
} {
return {
chrome: process.versions.chrome,
node: process.versions.node,
electron: process.versions.electron,
};
}
// ============================================================================
// Workspace Watcher (with debouncing and lifecycle management)
// ============================================================================
let watcher: FSWatcher | null = null;
const changeQueue = new Set<string>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Emit workspace change event to all renderer windows
*/
function emitWorkspaceChangeEvent(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('workspace:didChange', event);
}
}
}
/**
* Process queued changes and emit events (debounced)
*/
function processChangeQueue(): void {
if (changeQueue.size === 0) {
return;
}
const paths = Array.from(changeQueue);
changeQueue.clear();
if (paths.length === 1) {
// For single path, try to determine kind from file stats
const relPath = paths[0]!;
try {
const absPath = workspace.resolveWorkspacePath(relPath);
fs.lstat(absPath)
.then((stats) => {
const kind = stats.isDirectory() ? 'dir' : 'file';
emitWorkspaceChangeEvent({ type: 'changed', path: relPath, kind });
})
.catch(() => {
// File no longer exists (edge case), emit without kind
emitWorkspaceChangeEvent({ type: 'changed', path: relPath });
});
} catch {
// Invalid path, ignore
}
} else {
// Emit bulkChanged for multiple paths
emitWorkspaceChangeEvent({ type: 'bulkChanged', paths });
}
}
/**
* Queue a path change for debounced emission
*/
function queueChange(relPath: string): void {
changeQueue.add(relPath);
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
processChangeQueue();
debounceTimer = null;
}, 150); // 150ms debounce
}
/**
* Handle workspace change event from core watcher
*/
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
// Debounce 'changed' events, emit others immediately
if (event.type === 'changed' && event.path) {
queueChange(event.path);
} else {
emitWorkspaceChangeEvent(event);
}
}
/**
* Start workspace watcher
* Watches ~/.rowboat recursively and emits change events to renderer
*
* This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes
* (both from IPC handlers and external changes like terminal/git).
*
* Safe to call multiple times - guards against duplicate watchers.
*/
export async function startWorkspaceWatcher(): Promise<void> {
if (watcher) {
// Watcher already running - safe to ignore subsequent calls
return;
}
watcher = await watcherCore.createWorkspaceWatcher(handleWorkspaceChange);
}
/**
* Stop workspace watcher
*/
export function stopWorkspaceWatcher(): void {
if (watcher) {
watcher.close();
watcher = null;
}
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
changeQueue.clear();
}
function emitRunEvent(event: z.infer<typeof RunEvent>): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('runs:events', event);
}
}
}
let runsWatcher: (() => void) | null = null;
export async function startRunsWatcher(): Promise<void> {
if (runsWatcher) {
return;
}
runsWatcher = await bus.subscribe('*', async (event) => {
emitRunEvent(event);
});
}
// ============================================================================
// Handler Implementations
// ============================================================================
/**
* Register all IPC handlers
* Add new handlers here as you add channels to IPCChannels
*/
export function setupIpcHandlers() {
registerIpcHandlers({
'app:getVersions': async () => {
// args is null for this channel (no request payload)
return getVersions();
},
'workspace:getRoot': async () => {
return workspace.getRoot();
},
'workspace:exists': async (_, args) => {
return workspace.exists(args.path);
},
'workspace:stat': async (_event, args) => {
return workspace.stat(args.path);
},
'workspace:readdir': async (_event, args) => {
return workspace.readdir(args.path, args.opts);
},
'workspace:readFile': async (_event, args) => {
return workspace.readFile(args.path, args.encoding);
},
'workspace:writeFile': async (_event, args) => {
return workspace.writeFile(args.path, args.data, args.opts);
},
'workspace:mkdir': async (_event, args) => {
return workspace.mkdir(args.path, args.recursive);
},
'workspace:rename': async (_event, args) => {
return workspace.rename(args.from, args.to, args.overwrite);
},
'workspace:copy': async (_event, args) => {
return workspace.copy(args.from, args.to, args.overwrite);
},
'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts);
},
'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor);
},
'mcp:executeTool': async (_event, args) => {
return { result: await mcpCore.executeTool(args.serverName, args.toolName, args.input) };
},
'runs:create': async (_event, args) => {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
return { success: true };
},
'runs:provideHumanInput': async (_event, args) => {
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
return { success: true };
},
'runs:stop': async (_event, args) => {
await runsCore.stop(args.runId);
return { success: true };
},
});
}

View file

@ -0,0 +1,61 @@
import { app, BrowserWindow } from "electron";
import path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const preloadPath = path.join(__dirname, "../../preload/dist/preload.js");
console.log("preloadPath", preloadPath);
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// IMPORTANT: keep Node out of renderer
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
preload: preloadPath,
},
});
win.loadURL("http://localhost:5173"); // load the dev server
}
app.whenReady().then(() => {
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();
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();
});

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": [
"node",
"electron"
]
},
"include": [
"src"
]
}