openpencil/apps/desktop/ipc-handlers.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

238 lines
7.3 KiB
TypeScript

import { ipcMain, dialog, type BrowserWindow } from 'electron';
import { resolve, extname, sep } from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { app } from 'electron';
import {
getUpdaterState,
checkForAppUpdates,
quitAndInstall,
getAutoUpdateEnabled,
setAutoUpdateEnabled,
setUpdaterState,
clearUpdateTimer,
startUpdateTimer,
} from './auto-updater';
import { getLogDir } from './logger';
import {
buildUnsavedChangesDialogOptions,
mapUnsavedChangesResponse,
} from './unsaved-changes-dialog';
interface IpcDeps {
getMainWindow: () => BrowserWindow | null;
getPendingFilePath: () => string | null;
clearPendingFilePath: () => void;
prefsCache: Record<string, string>;
schedulePrefsWrite: () => void;
writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise<void>;
}
export function setupIPC(deps: IpcDeps): void {
const {
getMainWindow,
getPendingFilePath,
clearPendingFilePath,
prefsCache,
schedulePrefsWrite,
writeAppSettings,
} = deps;
ipcMain.handle('dialog:openFile', async () => {
const mainWindow = getMainWindow();
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open .op file',
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) return null;
const filePath = result.filePaths[0];
const content = await readFile(filePath, 'utf-8');
return { filePath, content };
});
ipcMain.handle('dialog:openDirectory', async () => {
const mainWindow = getMainWindow();
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open Git repository folder',
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0];
});
ipcMain.handle(
'dialog:saveFile',
async (_event, payload: { content: string; defaultPath?: string }) => {
const mainWindow = getMainWindow();
if (!mainWindow) return null;
const result = await dialog.showSaveDialog(mainWindow, {
title: 'Save .op file',
defaultPath: payload.defaultPath,
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
});
if (result.canceled || !result.filePath) return null;
await writeFile(result.filePath, payload.content, 'utf-8');
return result.filePath;
},
);
ipcMain.handle(
'dialog:saveToPath',
async (_event, payload: { filePath: string; content: string }) => {
const resolved = resolve(payload.filePath);
if (resolved.includes('\0')) {
throw new Error('Invalid file path');
}
const ext = extname(resolved).toLowerCase();
if (ext !== '.op' && ext !== '.pen') {
throw new Error('Only .op and .pen file extensions are allowed');
}
const allowedRoots = [app.getPath('home'), app.getPath('temp')];
const inAllowedDir = allowedRoots.some(
(root) => resolved === root || resolved.startsWith(root + sep),
);
if (!inAllowedDir) {
throw new Error('File path must be within the user home or temp directory');
}
await writeFile(resolved, payload.content, 'utf-8');
return resolved;
},
);
ipcMain.handle('file:getPending', () => {
const filePath = getPendingFilePath();
if (filePath) {
clearPendingFilePath();
return filePath;
}
return null;
});
ipcMain.handle('file:read', async (_event, filePath: string) => {
const resolved = resolve(filePath);
const ext = extname(resolved).toLowerCase();
if (ext !== '.op' && ext !== '.pen') return null;
try {
const content = await readFile(resolved, 'utf-8');
return { filePath: resolved, content };
} catch {
return null;
}
});
ipcMain.handle('dialog:openImageFile', async () => {
const mainWindow = getMainWindow();
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: 'Open image file',
filters: [
{
name: 'Image Files',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'],
},
],
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) return null;
const filePath = result.filePaths[0];
return {
filePath,
name: filePath.split(/[\\/]/).pop() ?? 'image',
content:
extname(filePath).toLowerCase() === '.svg' ? await readFile(filePath, 'utf-8') : null,
};
});
ipcMain.handle(
'dialog:confirmUnsavedChanges',
async (
_event,
payload: {
message: string;
detail?: string;
yesLabel: string;
noLabel: string;
cancelLabel: string;
},
) => {
const mainWindow = getMainWindow();
if (!mainWindow) return 'cancel';
const { response } = await dialog.showMessageBox(
mainWindow,
buildUnsavedChangesDialogOptions(payload),
);
return mapUnsavedChangesResponse(response);
},
);
// Theme sync for Windows/Linux title bar overlay
ipcMain.handle(
'theme:set',
(_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux';
if (!isWinOrLinux) return;
const isLinux = process.platform === 'linux';
const fallbackBg = theme === 'dark' ? '#111' : '#fff';
const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46';
mainWindow.setTitleBarOverlay({
color: isLinux ? colors?.bg || fallbackBg : 'rgba(0,0,0,0)',
symbolColor: colors?.fg || fallbackFg,
});
},
);
// Renderer preferences
ipcMain.handle('prefs:getAll', () => ({ ...prefsCache }));
ipcMain.handle('prefs:set', (_event, key: string, value: string) => {
prefsCache[key] = value;
schedulePrefsWrite();
});
ipcMain.handle('prefs:remove', (_event, key: string) => {
delete prefsCache[key];
schedulePrefsWrite();
});
// Recent files sync from renderer → main (for native menu)
ipcMain.on(
'recent-files:sync',
(_event, files: Array<{ fileName: string; filePath: string }>) => {
(global as any).__recentFiles = files;
// Rebuild menu so "Open Recent" reflects current state
import('./app-menu').then(({ buildAppMenu }) => buildAppMenu());
},
);
ipcMain.handle('log:getDir', () => getLogDir());
// Updater IPC
ipcMain.handle('updater:getState', () => getUpdaterState());
ipcMain.handle('updater:checkForUpdates', async () => {
await checkForAppUpdates(true);
return getUpdaterState();
});
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall());
ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled());
ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => {
setAutoUpdateEnabled(enabled);
await writeAppSettings({ autoUpdate: enabled });
if (enabled) {
startUpdateTimer();
setUpdaterState({ status: 'idle' });
} else {
clearUpdateTimer();
setUpdaterState({ status: 'disabled' });
}
return enabled;
});
}