mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
bootstrap new electron app
This commit is contained in:
parent
2491bacea1
commit
505e3ea620
89 changed files with 12397 additions and 8435 deletions
2
apps/x/apps/main/.gitignore
vendored
Normal file
2
apps/x/apps/main/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
19
apps/x/apps/main/package.json
Normal file
19
apps/x/apps/main/package.json
Normal 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
282
apps/x/apps/main/src/ipc.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
61
apps/x/apps/main/src/main.ts
Normal file
61
apps/x/apps/main/src/main.ts
Normal 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();
|
||||
});
|
||||
14
apps/x/apps/main/tsconfig.json
Normal file
14
apps/x/apps/main/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"node",
|
||||
"electron"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue