eigent/electron/main/index.ts
Douglas Lai 8f4f575db9
feat: add native menus, shortcuts, and window chrome (#1858)
Co-authored-by: 4pmtong <web_chentong@163.com>
2026-08-21 20:39:55 +08:00

4105 lines
131 KiB
TypeScript

// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import axios from 'axios';
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeTheme,
protocol,
screen,
session,
shell,
} from 'electron';
import log from 'electron-log';
import FormData from 'form-data';
import fsp from 'fs/promises';
import mime from 'mime';
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
import crypto from 'node:crypto';
import fs, { existsSync } from 'node:fs';
import http from 'node:http';
import os, { homedir } from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import kill from 'tree-kill';
import {
APP_COMMAND_CHANNEL,
APP_COMMAND_HANDLED_CHANNEL,
APP_SHELL_NOT_READY_CHANNEL,
APP_SHELL_READY_CHANNEL,
APP_SHELL_READY_PROBE_CHANNEL,
isAppCommandHandled,
isAppShellLifecycleMessage,
type AppCommandId,
} from '../../src/shared/appCommands';
import { FILE_PREVIEW_LIMITS } from '../../src/shared/filePreviewContract';
import {
NATIVE_MENU_LOCALE_CHANNEL,
type NativeMenuLocale,
} from '../../src/shared/nativeMenu';
import {
isWindowCloseResponse,
WINDOW_CLOSE_RESPONSE_CHANNEL,
} from '../../src/shared/windowClose';
import { AppShellReadinessGate } from './appShellReadinessGate';
import { CloseCoordinator } from './closeCoordinator';
import { installApplicationMenu } from './commands/applicationMenu';
import {
installContextMenu,
type ContextMenuSurfaceKind,
} from './commands/contextMenu';
import {
applyNativeMenuLocaleChange,
getNativeMenuMessages,
resolveNativeMenuLocale,
} from './commands/nativeMenuMessages';
import { copyBrowserData } from './copy';
import { getOrCreateDesktopInstanceId } from './desktopIdentity';
import { resolveFileByteRange } from './fileRange';
import { FileReader } from './fileReader';
import {
checkToolInstalled,
findAvailablePort,
killProcessOnPort,
startBackend,
} from './init';
import {
checkAndInstallDepsOnUpdate,
getInstallationStatus,
PromiseReturnType,
} from './install-deps';
import {
authorizeLocalFilePath,
authorizeLocalPreviewPath,
isMainRendererSender,
} from './localFileSecurity';
import { filePathFromLocalFileUrl } from './localFileUrl';
import {
authorizeWorkspaceLocalNode,
openWorkspaceLocalFile,
rememberBoundedPathGrant,
revealAuthorizedLocalNode,
revealUserVisibleLocalNode,
revealWorkspaceLocalNode,
} from './localPathActions';
import { setRoundedCorners } from './native/macos-window';
import { RendererAppCommandCoordinator } from './rendererAppCommandCoordinator';
import { registerReviewChangesIpcHandlers } from './reviewChanges';
import {
completeCodexOAuthCallback,
getCodexResolverEnv,
registerCodexSubscriptionAuthIpcHandlers,
} from './subscriptionAuth';
import { disposeAllTerminals, registerTerminalIpcHandlers } from './terminal';
import { registerUpdateIpcHandlers, update } from './update';
import {
getEmailFolderPath,
getEnvPath,
maskProxyUrl,
readGlobalEnvKey,
removeEnvKey,
updateEnvBlock,
} from './utils/envUtil';
import { createDiagnosticsZip, zipDirectories, zipFolder } from './utils/log';
import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig';
import {
checkVenvExistsForPreCheck,
getBackendPath,
isBinaryExists,
} from './utils/process';
import { WebViewManager } from './webview';
import { loadWindowStartupState, persistWindowState } from './windowState';
import {
closeWorkspaceSecretBroker,
ensureWorkspaceSecretBroker,
getDefaultWorkspaceSecretVault,
registerWorkspaceSecretIpcHandlers,
} from './workspaceSecrets';
const userData = app.getPath('userData');
// ==================== constants ====================
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MAIN_DIST = path.join(__dirname, '../..');
const RENDERER_DIST = path.join(MAIN_DIST, 'dist');
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
const VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(MAIN_DIST, 'public')
: RENDERER_DIST;
// ==================== global variables ====================
let win: BrowserWindow | null = null;
let createWindowPromise: Promise<void> | null = null;
let webViewManager: WebViewManager | null = null;
let fileReader: FileReader | null = null;
let python_process: ChildProcessWithoutNullStreams | null = null;
let backendPort: number = 5001;
let backendStartPromise: Promise<BackendStartResult> | null = null;
const localControlCapability = crypto.randomBytes(32).toString('base64url');
let desktopInstanceId: string | null = null;
let browser_port = 9222;
let use_external_cdp = false;
let proxyUrl: string | null = null;
const LEGACY_DESKTOP_INSTANCE_STORAGE_KEY = 'eigent_desktop_instance_id';
const activeLocalFileRoots = new Set<string>();
let protocolUrlQueue: string[] = [];
let isWindowReady = false;
let nativeMenuLocale: NativeMenuLocale | null = null;
const appShellReadinessGate = new AppShellReadinessGate({
announceReadyProbe: () => {
const target = win;
if (!target || target.isDestroyed() || target.webContents.isDestroyed()) {
return;
}
target.webContents.send(APP_SHELL_READY_PROBE_CHANNEL);
},
});
const rendererAppCommands = new RendererAppCommandCoordinator({
send: (request) => {
const target = win;
if (!target || target.isDestroyed() || target.webContents.isDestroyed()) {
throw new Error('main renderer is unavailable');
}
target.webContents.send(APP_COMMAND_CHANNEL, request);
},
diagnostic: (message) => log.warn(message),
});
const closeCoordinator = new CloseCoordinator({
defaultIntent: process.platform === 'darwin' ? 'close-window' : 'quit-app',
quit: () => app.quit(),
shouldGuard: () => rendererAppCommands.isReady(),
diagnostic: (message) => log.info(message),
});
let isQuitCleanupInProgress = false;
let windowStateSaveTimer: NodeJS.Timeout | null = null;
const allowDeveloperTools =
!app.isPackaged || app.commandLine.hasSwitch('enable-devtools');
function getCurrentNativeMenuMessages() {
nativeMenuLocale ??= resolveNativeMenuLocale(app.getLocale());
return getNativeMenuMessages(nativeMenuLocale);
}
function installSurfaceContextMenu(
contents: Electron.WebContents,
ownerWindow: BrowserWindow,
surfaceKind: ContextMenuSurfaceKind
): () => void {
return installContextMenu({
contents,
getMessages: getCurrentNativeMenuMessages,
isDevelopment: allowDeveloperTools,
menuApi: Menu,
ownerWindow,
surfaceKind,
});
}
async function dispatchRendererAppCommand(
commandId: AppCommandId
): Promise<void> {
try {
if (!win || win.isDestroyed()) {
await createWindow();
} else if (createWindowPromise) {
await createWindowPromise;
}
const target = win;
if (!target || target.isDestroyed()) return;
if (!target.isVisible()) target.show();
target.focus();
rendererAppCommands.dispatch(commandId);
} catch (error) {
log.error(`[APP COMMAND] Failed to dispatch ${commandId}:`, error);
}
}
function installNativeApplicationMenu(): void {
const platform =
process.platform === 'darwin' || process.platform === 'win32'
? process.platform
: 'linux';
installApplicationMenu(Menu, {
appName: app.getName(),
dispatchRendererCommand: (commandId) => {
void dispatchRendererAppCommand(commandId);
},
isDevelopment: allowDeveloperTools,
messages: getCurrentNativeMenuMessages(),
onOpenExternalError: (error) => {
log.error('[APPLICATION MENU] Failed to open external URL:', error);
},
openExternal: (url) => shell.openExternal(url),
platform,
requestClose: () => closeCoordinator.request('close-window'),
requestQuit: () => closeCoordinator.request('quit-app'),
});
}
function persistBrowserWindowState(target: BrowserWindow | null = win): void {
if (windowStateSaveTimer) {
clearTimeout(windowStateSaveTimer);
windowStateSaveTimer = null;
}
if (!target || target.isDestroyed()) return;
try {
persistWindowState(userData, target);
} catch (error) {
log.warn('[WINDOW STATE] Failed to persist window state:', error);
}
}
function scheduleWindowStateSave(target: BrowserWindow): void {
if (windowStateSaveTimer) clearTimeout(windowStateSaveTimer);
windowStateSaveTimer = setTimeout(() => {
windowStateSaveTimer = null;
persistBrowserWindowState(target);
}, 250);
}
/**
* Real paths the user picked in a native file dialog this session.
*
* Chat attachments are chosen from anywhere on disk, so they are legitimately
* outside every Space root. The dialog itself is the user's authorization, and
* recording the exact picked path lets those attachments stay revealable
* without granting the renderer free rein over the file system.
*/
const userSelectedLocalPaths = new Set<string>();
const USER_SELECTED_PATH_LIMIT = 512;
function rememberUserSelectedLocalPath(realPath: string): void {
rememberBoundedPathGrant(
userSelectedLocalPaths,
realPath,
USER_SELECTED_PATH_LIMIT
);
}
function resolveDesktopInstanceId(legacyRendererId?: string | null): string {
if (!desktopInstanceId) {
desktopInstanceId = getOrCreateDesktopInstanceId(
userData,
legacyRendererId
);
}
return desktopInstanceId;
}
async function primeDesktopInstanceIdFromRenderer(): Promise<void> {
if (desktopInstanceId || !win || win.isDestroyed()) return;
let legacyRendererId: string | null = null;
try {
legacyRendererId = await win.webContents.executeJavaScript(
`window.localStorage.getItem(${JSON.stringify(
LEGACY_DESKTOP_INSTANCE_STORAGE_KEY
)})`,
true
);
} catch (error) {
log.warn('Unable to read legacy renderer device identity', error);
}
resolveDesktopInstanceId(legacyRendererId);
}
const PREVIEW_WEBVIEW_PARTITION = 'persist:session-preview';
const isHttpOrHttpsUrl = (url: unknown): url is string => {
if (typeof url !== 'string') return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
function assertMainRendererSender(event: Electron.IpcMainInvokeEvent): void {
if (
!win ||
win.isDestroyed() ||
!isMainRendererSender(event.sender.id, win.webContents.id) ||
event.senderFrame !== event.sender.mainFrame
) {
throw new Error('This operation is restricted to the main renderer');
}
}
function localFileAllowedRoots(): string[] {
// Only active Space roots plus the renderer's static application assets are
// readable through localfile://. In particular, HOME, userData and the OS
// temp directory are not trust boundaries for agent-authored HTML.
return [...activeLocalFileRoots, RENDERER_DIST, VITE_PUBLIC];
}
async function requireAuthorizedPreviewFile(
event: Electron.IpcMainInvokeEvent,
filePath: string
): Promise<string> {
assertMainRendererSender(event);
const authorization = await authorizeLocalPreviewPath(
filePath,
activeLocalFileRoots,
[RENDERER_DIST, VITE_PUBLIC]
);
if (!authorization.allowed) {
throw new Error('Preview file is outside the active workspace');
}
return authorization.filePath;
}
// CDP Browser Pool
interface CdpBrowser {
id: string;
port: number;
isExternal: boolean;
name?: string;
addedAt: number;
}
let cdp_browser_pool: CdpBrowser[] = [];
let cdpLastAssignedPort = 9223; // tracks the highest port ever assigned, never decreases
let cdpHealthCheckTimer: ReturnType<typeof setInterval> | null = null;
const CDP_POOL_FILE = path.join(os.homedir(), '.eigent', 'cdp-browsers.json');
type BackendStartOptions = {
forceRestart?: boolean;
};
type BackendStartResult =
| { success: true; port: number }
| { success: false; error: string };
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isBrokenConsolePipeError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
((error as NodeJS.ErrnoException).code === 'EPIPE' ||
(error as NodeJS.ErrnoException).code === 'ERR_STREAM_DESTROYED')
);
}
function disableConsoleLogTransport(): void {
if (log.transports.console.level !== false) {
log.transports.console.level = false;
}
}
function handleProcessPipeError(error: Error): void {
if (isBrokenConsolePipeError(error)) {
disableConsoleLogTransport();
return;
}
setImmediate(() => {
throw error;
});
}
process.stdout.on('error', handleProcessPipeError);
process.stderr.on('error', handleProcessPipeError);
function isPythonProcessRunning(): boolean {
return Boolean(
python_process &&
!python_process.killed &&
python_process.exitCode === null &&
python_process.signalCode === null
);
}
function notifyBackendReady(result: BackendStartResult): void {
if (!win || win.isDestroyed()) {
return;
}
win.webContents.send(
'backend-ready',
result.success
? {
success: true,
port: result.port,
}
: {
success: false,
error: result.error,
}
);
}
function checkBackendHealth(port: number): Promise<boolean> {
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
const req = http.get(
`http://127.0.0.1:${port}/health`,
{ timeout: 1000 },
(res) => {
res.resume();
resolve(res.statusCode === 200);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/** Persist pool to disk. */
function saveCdpPool(): void {
try {
fs.writeFileSync(CDP_POOL_FILE, JSON.stringify(cdp_browser_pool, null, 2));
} catch (e) {
log.error(`[CDP POOL] Failed to save pool: ${e}`);
}
}
/** Load pool from disk. Mark all as external (process handles are lost after restart). */
function loadCdpPool(): void {
try {
if (fs.existsSync(CDP_POOL_FILE)) {
const data = JSON.parse(fs.readFileSync(CDP_POOL_FILE, 'utf-8'));
cdp_browser_pool = (data as CdpBrowser[]).map((b) => ({
...b,
isExternal: true,
}));
cdpLastAssignedPort = cdp_browser_pool.reduce(
(max, b) => Math.max(max, b.port),
cdpLastAssignedPort
);
log.info(
`[CDP POOL] Loaded ${cdp_browser_pool.length} browser(s) from disk, lastAssignedPort=${cdpLastAssignedPort}`
);
}
} catch (e) {
log.error(`[CDP POOL] Failed to load pool: ${e}`);
cdp_browser_pool = [];
}
}
/** Push current pool to frontend. */
function notifyCdpPoolChanged(): void {
if (win && !win.isDestroyed()) {
log.info(
`[CDP POOL] Pushing pool update to frontend (size=${cdp_browser_pool.length})`
);
win.webContents.send('cdp-pool-changed', cdp_browser_pool);
} else {
log.warn('[CDP POOL] Cannot notify: win is null or destroyed');
}
}
/** Probe a CDP port. Returns true if alive. */
async function isCdpPortAlive(port: number): Promise<boolean> {
try {
const resp = await axios.get(`http://localhost:${port}/json/version`, {
timeout: 1500,
});
return resp.status === 200;
} catch {
return false;
}
}
/** Run one health-check cycle: remove dead browsers, persist & notify if changed. */
async function runPoolHealthCheck(): Promise<void> {
if (cdp_browser_pool.length === 0) return;
// Probe a snapshot so add/remove IPC handlers can run safely in parallel.
const snapshot = [...cdp_browser_pool];
const results = await Promise.all(
snapshot.map((b) => isCdpPortAlive(b.port))
);
const deadIds = snapshot
.filter((_, idx) => !results[idx])
.map((browser) => browser.id);
if (deadIds.length === 0) return;
const deadIdSet = new Set(deadIds);
const removedBrowsers = cdp_browser_pool.filter((b) => deadIdSet.has(b.id));
if (removedBrowsers.length === 0) return;
cdp_browser_pool = cdp_browser_pool.filter((b) => !deadIdSet.has(b.id));
const deadPorts = removedBrowsers.map((b) => b.port);
if (deadPorts.length > 0) {
log.info(
`[CDP POOL] Health-check removed dead ports: ${deadPorts.join(', ')}. pool_size=${cdp_browser_pool.length}`
);
saveCdpPool();
notifyCdpPoolChanged();
}
}
/** Start periodic health check (call after window is created). */
function startCdpHealthCheck(): void {
if (cdpHealthCheckTimer) {
clearInterval(cdpHealthCheckTimer);
cdpHealthCheckTimer = null;
}
log.info('[CDP POOL] Starting health check (interval=3s)');
// Run once immediately
runPoolHealthCheck();
cdpHealthCheckTimer = setInterval(runPoolHealthCheck, 3000);
}
function stopCdpHealthCheck(): void {
if (cdpHealthCheckTimer) {
clearInterval(cdpHealthCheckTimer);
cdpHealthCheckTimer = null;
}
}
/** Close a browser via CDP Browser.close() WebSocket command. Best-effort.
* Uses raw Node.js http upgrade (no external ws dependency needed).
* IMPORTANT: Never close the Electron app's own CDP port. */
async function closeBrowserViaCdp(port: number): Promise<void> {
// Guard: refuse to close the Electron app's own CDP port
if (port === browser_port) {
log.warn(
`[CDP CLOSE] Refusing to close port ${port} (Electron app's own CDP port)`
);
return;
}
try {
const resp = await axios.get(`http://localhost:${port}/json/version`, {
timeout: 2000,
});
const wsUrl: string | undefined = resp.data?.webSocketDebuggerUrl;
if (!wsUrl) {
log.warn(`[CDP CLOSE] No webSocketDebuggerUrl for port ${port}`);
return;
}
const url = new URL(wsUrl);
const key = crypto.randomBytes(16).toString('base64');
await new Promise<void>((resolve) => {
let resolved = false;
const done = () => {
if (!resolved) {
resolved = true;
resolve();
}
};
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': key,
},
},
() => done()
);
const timer = setTimeout(() => {
req.destroy();
done();
}, 3000);
req.on('upgrade', (_res, socket) => {
// Handle socket errors to prevent uncaught exceptions
socket.on('error', () => {});
// Build a masked WebSocket text frame with Browser.close
const payload = Buffer.from(
JSON.stringify({ id: 1, method: 'Browser.close' })
);
const mask = crypto.randomBytes(4);
const header = Buffer.alloc(6);
header[0] = 0x81; // FIN + text opcode
header[1] = 0x80 | payload.length; // MASK bit + length (<126)
mask.copy(header, 2);
const masked = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i++) {
masked[i] = payload[i] ^ mask[i & 3];
}
socket.write(Buffer.concat([header, masked]));
log.info(`[CDP CLOSE] Sent Browser.close to port ${port}`);
// Give Chrome a moment to process, then clean up
setTimeout(() => {
clearTimeout(timer);
socket.destroy();
done();
}, 500);
});
req.on('error', (err) => {
log.warn(`[CDP CLOSE] Request error for port ${port}: ${err.message}`);
clearTimeout(timer);
done();
});
req.end();
});
log.info(`[CDP CLOSE] Successfully closed browser on port ${port}`);
} catch (err) {
log.warn(`[CDP CLOSE] Best-effort close failed for port ${port}: ${err}`);
}
}
// ==================== path config ====================
const preload = path.join(__dirname, '../preload/index.mjs');
const indexHtml = path.join(RENDERER_DIST, 'index.html');
const logPath = log.transports.file.getFile().path;
// Profile initialization promise
let profileInitPromise: Promise<void>;
// Set remote debugging port
// Storage strategy:
// 1. Main window: partition 'persist:main_window' in app userData → Eigent account (persistent)
// 2. WebView: partition 'persist:user_login' in app userData → will import cookies from tool_controller via session API
// 3. tool_controller: ~/.eigent/browser_profiles/profile_user_login → source of truth for login cookies
// 4. CDP browser: uses separate profile (doesn't share with main app)
profileInitPromise = findAvailablePort(browser_port).then(async (port) => {
browser_port = port;
app.commandLine.appendSwitch('remote-debugging-port', port + '');
// Create isolated profile for CDP browser only
const browserProfilesBase = path.join(
os.homedir(),
'.eigent',
'browser_profiles'
);
const cdpProfile = path.join(browserProfilesBase, `cdp_profile_${port}`);
try {
await fsp.mkdir(cdpProfile, { recursive: true });
log.info(`[CDP BROWSER] Created CDP profile directory at ${cdpProfile}`);
} catch (error) {
log.error(`[CDP BROWSER] Failed to create directory: ${error}`);
}
// Set user-data-dir for Chrome DevTools Protocol only
app.commandLine.appendSwitch('user-data-dir', cdpProfile);
log.info(`[CDP BROWSER] Chrome DevTools Protocol enabled on port ${port}`);
log.info(`[CDP BROWSER] CDP profile directory: ${cdpProfile}`);
log.info(`[STORAGE] Main app userData: ${app.getPath('userData')}`);
});
// Memory optimization settings
app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096');
app.commandLine.appendSwitch('force-gpu-mem-available-mb', '512');
app.commandLine.appendSwitch('max_old_space_size', '4096');
app.commandLine.appendSwitch('enable-features', 'MemoryPressureReduction');
app.commandLine.appendSwitch('renderer-process-limit', '8');
// Disable Fontations (Rust-based font engine) to prevent crashes on macOS
app.commandLine.appendSwitch('disable-features', 'Fontations');
// ==================== Proxy configuration ====================
// Read proxy from global .env file on startup
proxyUrl = readGlobalEnvKey('HTTP_PROXY');
if (proxyUrl) {
log.info(`[PROXY] Applying proxy configuration: ${maskProxyUrl(proxyUrl)}`);
app.commandLine.appendSwitch('proxy-server', proxyUrl);
} else {
log.info('[PROXY] No proxy configured');
}
// ==================== Anti-fingerprint settings ====================
// Disable automation controlled indicator to avoid detection
app.commandLine.appendSwitch('disable-blink-features', 'AutomationControlled');
// Override User Agent to remove Electron/eigent identifiers
// Dynamically generate User Agent based on actual platform and Chrome version
const getPlatformUA = () => {
// Use actual Chrome version from Electron instead of hardcoded value
const chromeVersion = process.versions.chrome || '131.0.0.0';
switch (process.platform) {
case 'darwin':
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
case 'win32':
return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
case 'linux':
return `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
default:
return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
}
};
const normalUserAgent = getPlatformUA();
app.userAgentFallback = normalUserAgent;
// ==================== protocol privileges ====================
// Register custom protocol privileges before app ready
protocol.registerSchemesAsPrivileged([
{
scheme: 'localfile',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: false,
bypassCSP: false,
},
},
]);
// ==================== app config ====================
process.env.APP_ROOT = MAIN_DIST;
process.env.VITE_PUBLIC = VITE_PUBLIC;
// Always follow OS appearance so renderer `prefers-color-scheme` stays accurate.
nativeTheme.themeSource = 'system';
// Set log level
log.transports.console.level = 'info';
log.transports.file.level = 'info';
log.transports.console.format = '[{level}]{text}';
log.transports.file.format = '[{level}]{text}';
// Disable GPU Acceleration for Windows 7
if (os.release().startsWith('6.1')) app.disableHardwareAcceleration();
// Set application name for Windows 10+ notifications
if (process.platform === 'win32') app.setAppUserModelId(app.getName());
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
// ==================== protocol config ====================
const setupProtocolHandlers = () => {
if (process.env.NODE_ENV === 'development') {
const isDefault = app.isDefaultProtocolClient('eigent', process.execPath, [
path.resolve(process.argv[1]),
]);
if (!isDefault) {
app.setAsDefaultProtocolClient('eigent', process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient('eigent');
}
};
// ==================== protocol url handle ====================
function handleProtocolUrl(url: string) {
log.info('enter handleProtocolUrl');
// If window is not ready, queue the URL
if (!isWindowReady || !win || win.isDestroyed()) {
log.info('Window not ready, queuing protocol URL');
protocolUrlQueue.push(url);
return;
}
void processProtocolUrl(url);
}
// Process a single protocol URL
async function processProtocolUrl(url: string) {
const urlObj = new URL(url);
const code = urlObj.searchParams.get('code');
const token = urlObj.searchParams.get('token');
const share_token = urlObj.searchParams.get('share_token');
log.info('urlObj', {
protocol: urlObj.protocol,
host: urlObj.host,
pathname: urlObj.pathname,
});
log.info('code present', Boolean(code));
log.info('token present', Boolean(token));
log.info('share_token present', Boolean(share_token));
if (win && !win.isDestroyed()) {
log.info('urlObj.pathname', urlObj.pathname);
if (urlObj.pathname === '/oauth') {
log.info('oauth');
const provider = urlObj.searchParams.get('provider');
const code = urlObj.searchParams.get('code');
const codexResult = await completeCodexOAuthCallback(urlObj);
if (codexResult.handled) {
win.webContents.send(
'subscription-auth:codex-status-changed',
codexResult.error_code
? { error_code: codexResult.error_code }
: undefined
);
return;
}
log.info('protocol oauth', provider, Boolean(code));
win.webContents.send('oauth-authorized', { provider, code });
return;
}
if (token) {
log.info('protocol token received');
win.webContents.send('auth-token-received', token);
return;
}
if (code) {
log.info('protocol code received');
win.webContents.send('auth-code-received', code);
}
if (share_token) {
win.webContents.send('auth-share-token-received', share_token);
}
} else {
log.error('window not available');
}
}
// Process all queued protocol URLs
function processQueuedProtocolUrls() {
if (protocolUrlQueue.length > 0) {
log.info('Processing queued protocol URLs:', protocolUrlQueue.length);
// Verify window is ready before processing
if (!win || win.isDestroyed() || !isWindowReady) {
log.warn(
'Window not ready for processing queued URLs, keeping URLs in queue'
);
return;
}
const urls = [...protocolUrlQueue];
protocolUrlQueue = [];
urls.forEach((url) => {
void processProtocolUrl(url);
});
}
}
// ==================== auth callback server ====================
// Local HTTP server for receiving auth callbacks from external login (eigent.ai)
// Works in both dev and production mode, avoids eigent:// protocol issues in dev
let authCallbackServer: http.Server | null = null;
let authCallbackPort: number | null = null;
async function startAuthCallbackServer() {
if (authCallbackServer) return authCallbackPort;
const port = await findAvailablePort(19836, 19900);
authCallbackServer = http.createServer((req, res) => {
const url = new URL(req.url || '', `http://localhost:${port}`);
if (url.pathname === '/auth/callback') {
const token = url.searchParams.get('token');
log.info('Auth callback URL:', req.url);
log.info('Auth callback token present:', !!token);
log.info('Auth callback win available:', !!win && !win.isDestroyed());
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`
<!DOCTYPE html>
<html><head><title>Login Successful</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f4f4f9; color: #333; }
.container { padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; }
</style></head>
<body><div class="container">
<h1>Login Successful</h1>
<p>You can close this tab and return to Eigent.</p>
</div></body></html>
`);
if (token && win && !win.isDestroyed()) {
log.info('Auth callback received token');
win.webContents.send('auth-token-received', token);
win.show();
win.focus();
}
} else {
res.writeHead(404);
res.end('Not Found');
}
});
authCallbackServer.listen(port);
authCallbackPort = port;
log.info(`Auth callback server started on port ${port}`);
return port;
}
// ==================== single instance lock ====================
const setupSingleInstanceLock = () => {
// The lock is already acquired at module level (requestSingleInstanceLock
// above). Calling it again here would release and re-acquire the lock,
// creating a window where a second instance could start. We only need
// to register the event handlers.
app.on('second-instance', (event, argv) => {
log.info('second-instance', argv);
const url = argv.find((arg) => arg.startsWith('eigent://'));
if (url) handleProtocolUrl(url);
if (win) win.show();
});
app.on('open-url', (event, url) => {
log.info('open-url');
event.preventDefault();
handleProtocolUrl(url);
});
};
// ==================== initialize config ====================
const initializeApp = () => {
setupProtocolHandlers();
setupSingleInstanceLock();
};
/**
* Registers all IPC handlers once when the app starts
* This prevents "Attempted to register a second handler" errors
* when windows are reopened
*/
// Get backup log path
const getBackupLogPath = () => {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'logs', 'main.log');
};
// Constants define
const BROWSER_PATHS = {
win32: {
chrome: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
edge: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
firefox: 'C:\\Program Files\\Mozilla Firefox\\firefox.exe',
qq: 'C:\\Program Files\\Tencent\\QQBrowser\\QQBrowser.exe',
'360': path.join(
homedir(),
'AppData\\Local\\360Chrome\\Chrome\\Application\\360chrome.exe'
),
arc: path.join(homedir(), 'AppData\\Local\\Arc\\User Data\\Arc.exe'),
dia: path.join(homedir(), 'AppData\\Local\\Dia\\Application\\dia.exe'),
fellou: path.join(
homedir(),
'AppData\\Local\\Fellou\\Application\\fellou.exe'
),
},
darwin: {
chrome: '/Applications/Google Chrome.app',
edge: '/Applications/Microsoft Edge.app',
firefox: '/Applications/Firefox.app',
safari: '/Applications/Safari.app',
arc: '/Applications/Arc.app',
dia: '/Applications/Dia.app',
fellou: '/Applications/Fellou.app',
},
} as const;
// Tool function
const getSystemLanguage = async () => {
const locale = app.getLocale();
return locale === 'zh-CN' ? 'zh-cn' : 'en';
};
const checkManagerInstance = (manager: any, name: string) => {
if (!manager) {
throw new Error(`${name} not initialized`);
}
return manager;
};
function registerIpcHandlers() {
registerCodexSubscriptionAuthIpcHandlers(ipcMain);
registerTerminalIpcHandlers();
registerReviewChangesIpcHandlers();
registerWorkspaceSecretIpcHandlers(
ipcMain,
getDefaultWorkspaceSecretVault(),
assertMainRendererSender
);
// ==================== auth callback ====================
ipcMain.handle('get-auth-callback-url', async () => {
const port = await startAuthCallbackServer();
return `http://localhost:${port}/auth/callback`;
});
// ==================== basic info handler ====================
ipcMain.handle('get-browser-port', () => {
log.info('Getting browser port');
return browser_port;
});
ipcMain.handle('get-embedded-browser-runtime', () => {
const targets = webViewManager?.listAvailableBrowserToolkitTargets() ?? [];
const targetAvailable = targets.length > 0;
log.info(
`[PROJECT BROWSER] Embedded runtime requested: port=${browser_port}, available_targets=${targets.length}`
);
return {
port: browser_port,
targetAvailable,
targets,
};
});
// Set browser port
ipcMain.handle(
'set-browser-port',
(event, port: number, isExternal: boolean = false) => {
log.info(`Setting browser port to ${port}, external: ${isExternal}`);
browser_port = port;
use_external_cdp = isExternal;
return { success: true, port: browser_port, use_external_cdp };
}
);
// Get external CDP flag
ipcMain.handle('get-use-external-cdp', () => {
log.info(`Getting use_external_cdp: ${use_external_cdp}`);
return use_external_cdp;
});
// ==================== CDP Browser Pool Management ====================
// Get all browsers in the pool
ipcMain.handle('get-cdp-browsers', () => {
log.debug(`[CDP POOL] GET pool (size=${cdp_browser_pool.length})`);
return cdp_browser_pool;
});
// Add browser to pool
ipcMain.handle(
'add-cdp-browser',
(event, port: number, isExternal: boolean, name?: string) => {
const existing = cdp_browser_pool.find((b) => b.port === port);
if (existing) {
log.warn(
`[CDP POOL] ADD rejected: port ${port} already exists (id=${existing.id})`
);
return {
success: false,
error: 'Browser with this port already exists',
};
}
const newBrowser: CdpBrowser = {
id: `cdp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
port,
isExternal,
name,
addedAt: Date.now(),
};
cdp_browser_pool.push(newBrowser);
saveCdpPool();
notifyCdpPoolChanged();
log.info(
`[CDP POOL] ADD: port=${port}, isExternal=${isExternal}, id=${newBrowser.id}, pool_size=${cdp_browser_pool.length}`
);
return { success: true, browser: newBrowser };
}
);
// Remove browser from pool (also closes the browser via CDP)
ipcMain.handle(
'remove-cdp-browser',
async (event, browserId: string, closeBrowser: boolean = true) => {
const index = cdp_browser_pool.findIndex((b) => b.id === browserId);
if (index === -1) {
log.warn(`[CDP POOL] REMOVE: browser not found: ${browserId}`);
return { success: false, error: 'Browser not found' };
}
const removed = cdp_browser_pool.splice(index, 1)[0];
// Close the browser via CDP (best-effort)
if (closeBrowser) {
await closeBrowserViaCdp(removed.port);
}
saveCdpPool();
notifyCdpPoolChanged();
log.info(
`[CDP POOL] REMOVE: port=${removed.port}, id=${removed.id}, closed=${closeBrowser}, pool_size=${cdp_browser_pool.length}`
);
return { success: true, browser: removed };
}
);
// Launch CDP browser with automatic port assignment
ipcMain.handle('launch-cdp-browser', async () => {
try {
// 1. Always increment port from the last assigned port
// Port 9223 is reserved for the login browser
let port: number | null = null;
for (let p = cdpLastAssignedPort + 1; p < 9300; p++) {
if (!(await isCdpPortAlive(p))) {
port = p;
break;
}
}
// Wrap around if we hit the ceiling
if (port === null) {
for (let p = 9224; p <= cdpLastAssignedPort && p < 9300; p++) {
if (
!cdp_browser_pool.some((b) => b.port === p) &&
!(await isCdpPortAlive(p))
) {
port = p;
break;
}
}
}
if (port === null) {
return { success: false, error: 'No available port in 9224-9299' };
}
// 2. Find Playwright Chromium executable
const platform = process.platform;
let cacheDir: string;
if (platform === 'darwin')
cacheDir = path.join(homedir(), 'Library/Caches/ms-playwright');
else if (platform === 'linux')
cacheDir = path.join(homedir(), '.cache/ms-playwright');
else if (platform === 'win32')
cacheDir = path.join(homedir(), 'AppData/Local/ms-playwright');
else
return { success: false, error: `Unsupported platform: ${platform}` };
if (!existsSync(cacheDir)) {
return {
success: false,
error:
'Playwright Chromium not found. Please run: npx playwright install chromium',
};
}
const chromiumDirs = fs
.readdirSync(cacheDir)
.filter((d) => d.startsWith('chromium-'))
.sort()
.reverse();
if (chromiumDirs.length === 0) {
return {
success: false,
error:
'No Playwright Chromium found. Run: npx playwright install chromium',
};
}
const platformPaths: Record<string, (base: string) => string[]> = {
darwin: (base) => [
path.join(
base,
'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium'
),
path.join(
base,
'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
),
path.join(base, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium'),
path.join(
base,
'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing'
),
],
linux: (base) => [path.join(base, 'chrome-linux/chrome')],
win32: (base) => [
path.join(base, 'chrome-win64/chrome.exe'),
path.join(base, 'chrome-win/chrome.exe'),
],
};
let chromeExe: string | null = null;
for (const dir of chromiumDirs) {
const base = path.join(cacheDir, dir);
const candidates = platformPaths[platform](base);
const found = candidates.find((p) => existsSync(p));
if (found) {
chromeExe = found;
break;
}
}
if (!chromeExe) {
return { success: false, error: 'Chromium executable not found' };
}
// 3. Launch browser
const userDataDir = path.join(
app.getPath('userData'),
`cdp_browser_profile_${port}`
);
if (!existsSync(userDataDir)) {
await fsp.mkdir(userDataDir, { recursive: true });
}
const proc = spawn(
chromeExe,
[
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-blink-features=AutomationControlled',
'about:blank',
],
{ detached: false, stdio: 'ignore' }
);
proc.on('error', (err) =>
log.error(`[CDP LAUNCH] Process error port=${port}: ${err}`)
);
// 4. Poll for readiness (max 5s)
let data: any = null;
const start = Date.now();
while (Date.now() - start < 5000) {
try {
const resp = await axios.get(
`http://localhost:${port}/json/version`,
{ timeout: 1000 }
);
if (resp.status === 200) {
data = resp.data;
break;
}
} catch {}
await new Promise((r) => setTimeout(r, 300));
}
if (!data) {
proc.kill();
return {
success: false,
error: `Browser not responding on port ${port} after 5s`,
};
}
// 5. Add to pool automatically
const newBrowser: CdpBrowser = {
id: `cdp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
port,
isExternal: false,
name: `Launched Browser (${port})`,
addedAt: Date.now(),
};
cdp_browser_pool.push(newBrowser);
cdpLastAssignedPort = port;
saveCdpPool();
notifyCdpPoolChanged();
log.info(
`[CDP LAUNCH] Success: port=${port}, id=${newBrowser.id}, pool_size=${cdp_browser_pool.length}`
);
return { success: true, port, data };
} catch (err: any) {
log.error(`[CDP LAUNCH] Failed: ${err}`);
return { success: false, error: err.message };
}
});
ipcMain.handle('get-app-version', () => app.getVersion());
ipcMain.handle('get-backend-port', () => backendPort);
ipcMain.handle('get-local-control-capability', (event) => {
if (!win || event.sender.id !== win.webContents.id) {
throw new Error(
'Local control capability is restricted to the main renderer'
);
}
return localControlCapability;
});
ipcMain.handle('get-desktop-instance-id', (event, legacyRendererId) => {
if (!win || event.sender.id !== win.webContents.id) {
throw new Error('Desktop identity is restricted to the main renderer');
}
return resolveDesktopInstanceId(
typeof legacyRendererId === 'string' ? legacyRendererId : null
);
});
ipcMain.handle(
'set-local-file-preview-roots',
async (event, roots: unknown) => {
assertMainRendererSender(event);
// TODO(security): replace this renderer-declared, process-global root set
// with main-authoritative, per-surface capabilities. A compromised main
// renderer can currently register a broad existing directory (including
// a filesystem root), and the replace semantics let concurrent preview
// surfaces clear one another's grants. This check is bug containment,
// not a renderer-compromise boundary.
if (!Array.isArray(roots) || roots.length > 4) {
throw new Error('Invalid local file preview roots');
}
const nextRoots = new Set<string>();
for (const root of roots) {
if (typeof root !== 'string' || !path.isAbsolute(root)) {
throw new Error('Local file preview roots must be absolute paths');
}
const realRoot = await fsp.realpath(root);
const stats = await fsp.stat(realRoot);
if (!stats.isDirectory()) {
throw new Error('Local file preview roots must be directories');
}
nextRoots.add(realRoot);
}
activeLocalFileRoots.clear();
nextRoots.forEach((root) => activeLocalFileRoots.add(root));
return { success: true, roots: activeLocalFileRoots.size };
}
);
// ==================== restart app handler ====================
ipcMain.handle('restart-app', async () => {
log.info('[RESTART] Restarting app to apply user profile changes');
// Clean up Python process first
await cleanupPythonProcess();
// Schedule relaunch after a short delay
setTimeout(() => {
closeCoordinator.markAppQuitInProgress();
app.relaunch();
app.quit();
}, 100);
});
ipcMain.handle('restart-backend', async () => {
try {
const result = await restartBackendService();
if (result.success) {
log.info('Backend restart completed successfully');
}
return result;
} catch (error) {
log.error('Failed to restart backend:', error);
return { success: false, error: formatErrorMessage(error) };
}
});
ipcMain.handle('get-system-language', getSystemLanguage);
ipcMain.handle('is-fullscreen', () => win?.isFullScreen() || false);
ipcMain.handle('get-home-dir', () => {
const platform = process.platform;
return platform === 'win32' ? process.env.USERPROFILE : process.env.HOME;
});
// ==================== command execution handler ====================
ipcMain.handle('get-email-folder-path', async (event, email: string) => {
return getEmailFolderPath(email);
});
ipcMain.handle(
'execute-command',
async (event, command: string, email: string) => {
log.info('execute-command', command);
const { MCP_REMOTE_CONFIG_DIR } = getEmailFolderPath(email);
try {
const { spawn } = await import('child_process');
const commandWithHost = command;
log.info(' start execute command:', commandWithHost);
// Parse command and arguments
const [cmd, ...args] = commandWithHost.split(' ');
log.info('start execute command:', commandWithHost.split(' '));
console.log(cmd, args);
return new Promise((resolve) => {
const child = spawn(cmd, args, {
cwd: process.cwd(),
env: { ...process.env, MCP_REMOTE_CONFIG_DIR },
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
// Realtime listen standard output
child.stdout.on('data', (data) => {
const output = data.toString();
stdout += output;
log.info('Real-time output:', output.trim());
});
// Realtime listen error output
child.stderr.on('data', (data) => {
const output = data.toString();
stderr += output;
if (output.includes('OAuth callback server running at')) {
const url = output
.split('OAuth callback server running at')[1]
.trim();
log.info('detect OAuth callback URL:', url);
// Notify frontend to callback URL
if (win && !win.isDestroyed()) {
const match = url.match(/^https?:\/\/[^:\n]+:\d+/);
const cleanedUrl = match ? match[0] : null;
log.info('cleanedUrl', cleanedUrl);
win.webContents.send('oauth-callback-url', {
url: cleanedUrl,
provider: 'notion', // TODO: can be set dynamically according to actual situation
});
}
}
if (output.includes('Press Ctrl+C to exit')) {
child.kill();
}
log.info(' real-time error output:', output.trim());
});
// Listen process exit
child.on('close', (code) => {
log.info(` command execute complete, exit code: ${code}`);
resolve({ success: code === null, stdout, stderr });
});
// Listen process error
child.on('error', (error) => {
log.error(' command execute error:', error);
resolve({ success: false, error: error.message });
});
});
} catch (error: any) {
log.error(' command execute failed:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('read-file-dataurl', async (event, filePath) => {
try {
const authorizedPath = await requireAuthorizedPreviewFile(
event,
filePath
);
const stats = await fsp.stat(authorizedPath);
if (stats.size > FILE_PREVIEW_LIMITS.imageBytes) {
throw new Error(
`FILE_PREVIEW_TOO_LARGE:${stats.size}:${FILE_PREVIEW_LIMITS.imageBytes}`
);
}
const file = fs.readFileSync(authorizedPath);
const mimeType =
mime.getType(path.extname(authorizedPath)) ||
'application/octet-stream';
return `data:${mimeType};base64,${file.toString('base64')}`;
} catch (error: any) {
log.error('Failed to read file as data URL:', filePath, error);
throw new Error(`Failed to read file: ${error.message}`);
}
});
// ==================== log export handler ====================
ipcMain.handle('export-log', async () => {
try {
let targetLogPath = logPath;
if (!fs.existsSync(targetLogPath)) {
const backupPath = getBackupLogPath();
if (fs.existsSync(backupPath)) {
targetLogPath = backupPath;
} else {
return { success: false, error: 'no log file' };
}
}
await fsp.access(targetLogPath, fs.constants.R_OK);
const stats = await fsp.stat(targetLogPath);
if (stats.size === 0) {
return { success: true, data: 'log file is empty' };
}
const logContent = await fsp.readFile(targetLogPath, 'utf-8');
// Get app version and system version
const appVersion = app.getVersion();
const platform = process.platform;
const arch = process.arch;
const systemVersion = `${platform}-${arch}`;
const defaultFileName = `eigent-${appVersion}-${systemVersion}-${Date.now()}.log`;
// Show save dialog
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'save log file',
defaultPath: defaultFileName,
filters: [{ name: 'log file', extensions: ['log', 'txt'] }],
});
if (canceled || !filePath) {
return { success: false, error: '' };
}
await fsp.writeFile(filePath, logContent, 'utf-8');
return { success: true, savedPath: filePath };
} catch (error: any) {
return { success: false, error: error.message };
}
});
// Camel (backend) logs live per task at
// ~/.eigent/<identity>/[project_<id>/]task_<taskId>/camel_logs.
// Targets the task the user last ran when provided; otherwise exports all.
ipcMain.handle(
'export-camel-log',
async (
_event,
email: string,
taskId?: string,
projectId?: string,
userId?: string | number | null
) => {
try {
if (typeof email !== 'string' || !email) {
return { success: false, error: 'Missing email' };
}
const manager = checkManagerInstance(fileReader, 'FileReader');
const camelLogEntries = manager.getCamelLogEntries(
email,
taskId,
projectId,
userId
);
if (camelLogEntries.length === 0) {
return { success: false, error: 'no log file' };
}
const appVersion = app.getVersion();
const defaultFileName = `eigent-camel-logs-${appVersion}-${Date.now()}.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save Camel logs',
defaultPath: defaultFileName,
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
});
if (canceled || !filePath) {
return { success: false, error: '' };
}
await zipDirectories(filePath, camelLogEntries);
return { success: true, savedPath: filePath };
} catch (error: any) {
log.error('export-camel-log failed:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('get-diagnostics-info', async () => {
return {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
};
});
ipcMain.handle(
'export-diagnostics-zip',
async (
_event,
payload: { description: string; steps?: string } | undefined
) => {
try {
const description =
typeof payload?.description === 'string'
? payload.description.trim()
: '';
if (!description) {
return { success: false, error: 'Description is required' };
}
const steps =
typeof payload?.steps === 'string' ? payload.steps.trim() : '';
const logFiles: { src: string; destName: string }[] = [];
if (fs.existsSync(logPath)) {
logFiles.push({ src: logPath, destName: 'electron-main.log' });
}
const backupResolved = getBackupLogPath();
if (
fs.existsSync(backupResolved) &&
path.resolve(backupResolved) !== path.resolve(logPath)
) {
logFiles.push({
src: backupResolved,
destName: 'electron-userdata-logs.log',
});
}
if (logFiles.length === 0) {
return { success: false, error: 'no log file' };
}
const appVersion = app.getVersion();
const platform = process.platform;
const arch = process.arch;
const bugReportText = [
'Eigent bug report',
'=================',
'',
`App version: ${appVersion}`,
`OS: ${platform} (${arch})`,
'',
'Description',
'-----------',
description,
'',
...(steps
? ['Steps to reproduce', '-------------------', steps, '']
: []),
].join('\n');
const defaultFileName = `eigent-diagnostics-${appVersion}-${Date.now()}.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save diagnostics',
defaultPath: defaultFileName,
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
});
if (canceled || !filePath) {
return { success: false, error: '' };
}
await createDiagnosticsZip(filePath, bugReportText, logFiles);
return { success: true, savedPath: filePath };
} catch (error: any) {
log.error('export-diagnostics-zip failed:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('open-mailto', async (_event, url: string) => {
try {
if (typeof url !== 'string' || !url.startsWith('mailto:')) {
return { success: false, error: 'Invalid mailto URL' };
}
await shell.openExternal(url);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
ipcMain.handle('open-external', async (_event, url: string) => {
try {
if (!isHttpOrHttpsUrl(url)) {
return { success: false, error: 'Invalid external URL' };
}
await shell.openExternal(url);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
ipcMain.handle(
'upload-log',
async (
event,
email: string,
taskId: string,
baseUrl: string,
token: string
) => {
let zipPath: string | null = null;
try {
// Validate required parameters
if (!email || !taskId || !baseUrl || !token) {
return { success: false, error: 'Missing required parameters' };
}
// Sanitize taskId to prevent path traversal attacks
const sanitizedTaskId = taskId.replace(/[^a-zA-Z0-9_-]/g, '');
if (!sanitizedTaskId) {
return { success: false, error: 'Invalid task ID' };
}
const { MCP_REMOTE_CONFIG_DIR } = getEmailFolderPath(email);
const logFolderName = `task_${sanitizedTaskId}`;
const logFolderPath = path.join(MCP_REMOTE_CONFIG_DIR, logFolderName);
// Check if log folder exists
if (!fs.existsSync(logFolderPath)) {
return { success: false, error: 'Log folder not found' };
}
zipPath = path.join(MCP_REMOTE_CONFIG_DIR, `${logFolderName}.zip`);
await zipFolder(logFolderPath, zipPath);
// Create form data with file stream
const formData = new FormData();
const fileStream = fs.createReadStream(zipPath);
formData.append('file', fileStream);
formData.append('task_id', sanitizedTaskId);
// Upload with timeout
const response = await axios.post(
baseUrl + '/api/chat/logs',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`,
},
timeout: 60000, // 60 second timeout
maxContentLength: Infinity,
maxBodyLength: Infinity,
}
);
fileStream.destroy();
if (response.status === 200) {
return { success: true, data: response.data };
} else {
return { success: false, error: response.data };
}
} catch (error: any) {
log.error('Failed to upload log:', error);
return { success: false, error: error.message || 'Upload failed' };
} finally {
// Clean up zip file
if (zipPath && fs.existsSync(zipPath)) {
try {
fs.unlinkSync(zipPath);
} catch (cleanupError) {
log.error('Failed to clean up zip file:', cleanupError);
}
}
}
}
);
// ==================== MCP manage handler ====================
ipcMain.handle('mcp-install', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (_error) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args
.split(',')
.map((arg: string) => arg.trim())
.filter((arg: string) => arg !== '');
}
}
addMcp(name, mcp);
return { success: true };
});
ipcMain.handle('mcp-remove', async (event, name) => {
removeMcp(name);
return { success: true };
});
ipcMain.handle('mcp-update', async (event, name, mcp) => {
// Convert args from JSON string to array if needed
if (mcp.args && typeof mcp.args === 'string') {
try {
mcp.args = JSON.parse(mcp.args);
} catch (_error) {
// If parsing fails, split by comma as fallback
mcp.args = mcp.args
.split(',')
.map((arg: string) => arg.trim())
.filter((arg: string) => arg !== '');
}
}
updateMcp(name, mcp);
return { success: true };
});
ipcMain.handle('mcp-list', async () => {
return readMcpConfig();
});
// ==================== browser related handler ====================
// TODO: next version implement
ipcMain.handle('check-install-browser', async () => {
try {
const platform = process.platform;
const results: Record<string, boolean> = {};
const paths = BROWSER_PATHS[platform as keyof typeof BROWSER_PATHS];
if (!paths) {
log.warn(`not support current platform: ${platform}`);
return {};
}
for (const [browser, execPath] of Object.entries(paths)) {
results[browser] = existsSync(execPath);
}
return results;
} catch (error: any) {
log.error('Failed to check browser installation:', error);
return {};
}
});
ipcMain.handle('start-browser-import', async (event, args) => {
const isWin = process.platform === 'win32';
const localAppData = process.env.LOCALAPPDATA || '';
const appData = process.env.APPDATA || '';
const home = os.homedir();
const candidates: Record<string, string> = {
chrome: isWin
? `${localAppData}\\Google\\Chrome\\User Data\\Default`
: `${home}/Library/Application Support/Google/Chrome/Default`,
edge: isWin
? `${localAppData}\\Microsoft\\Edge\\User Data\\Default`
: `${home}/Library/Application Support/Microsoft Edge/Default`,
firefox: isWin
? `${appData}\\Mozilla\\Firefox\\Profiles`
: `${home}/Library/Application Support/Firefox/Profiles`,
qq: `${localAppData}\\Tencent\\QQBrowser\\User Data\\Default`,
'360': `${localAppData}\\360Chrome\\Chrome\\User Data\\Default`,
arc: isWin
? `${localAppData}\\Arc\\User Data\\Default`
: `${home}/Library/Application Support/Arc/Default`,
dia: `${localAppData}\\Dia\\User Data\\Default`,
fellou: `${localAppData}\\Fellou\\User Data\\Default`,
safari: `${home}/Library/Safari`,
};
// Filter unchecked browser
Object.keys(candidates).forEach((key) => {
const browser = args.find((item: any) => item.browserId === key);
if (!browser || !browser.checked) {
delete candidates[key];
}
});
const result: Record<string, string | null> = {};
for (const [name, p] of Object.entries(candidates)) {
result[name] = fs.existsSync(p) ? p : null;
}
const electronUserDataPath = app.getPath('userData');
for (const [browserName, browserPath] of Object.entries(result)) {
if (!browserPath) continue;
await copyBrowserData(browserName, browserPath, electronUserDataPath);
}
return { success: true };
});
// ==================== window control handler ====================
ipcMain.on('window-close', (event, data: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
const isForceQuit =
Boolean(data) &&
typeof data === 'object' &&
(data as { isForceQuit?: unknown }).isForceQuit === true;
if (isForceQuit) {
return closeCoordinator.request('quit-app');
}
return closeCoordinator.request(
process.platform === 'darwin' ? 'close-window' : 'quit-app'
);
});
ipcMain.on(WINDOW_CLOSE_RESPONSE_CHANNEL, (event, response: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
if (!isWindowCloseResponse(response)) return;
closeCoordinator.respond(response);
});
ipcMain.on(NATIVE_MENU_LOCALE_CHANNEL, (event, locale: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
applyNativeMenuLocaleChange(nativeMenuLocale, locale, (nextLocale) => {
nativeMenuLocale = nextLocale;
installNativeApplicationMenu();
});
});
ipcMain.on(APP_SHELL_READY_CHANNEL, (event, message: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
if (
!appShellReadinessGate.canAcceptReady() ||
!isAppShellLifecycleMessage(message)
) {
return;
}
rendererAppCommands.markReady(message.epoch);
closeCoordinator.markRendererReady();
log.info(`[APP SHELL] Renderer ready epoch=${message.epoch}`);
});
ipcMain.on(APP_SHELL_NOT_READY_CHANNEL, (event, message: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
if (!isAppShellLifecycleMessage(message)) return;
if (rendererAppCommands.markNotReady('listener-unmounted', message.epoch)) {
closeCoordinator.markRendererUnavailable('listener-unmounted');
log.info(`[APP SHELL] Renderer listeners removed epoch=${message.epoch}`);
}
});
ipcMain.on(APP_COMMAND_HANDLED_CHANNEL, (event, receipt: unknown) => {
if (!isMainRendererSender(event.sender.id, win?.webContents.id)) return;
if (!isAppCommandHandled(receipt)) return;
if (!rendererAppCommands.handleReceipt(receipt)) {
log.warn(
`[APP COMMAND] Ignored stale handled receipt request=${receipt.requestId} epoch=${receipt.epoch}`
);
}
});
ipcMain.on('window-minimize', () => win?.minimize());
ipcMain.on('window-toggle-maximize', () => {
if (win?.isMaximized()) {
win?.unmaximize();
} else {
win?.maximize();
}
});
// ==================== file operation handler ====================
ipcMain.handle('select-file', async (event, options = {}) => {
assertMainRendererSender(event);
const result = await dialog.showOpenDialog(win!, {
properties: ['openFile', 'multiSelections'],
...options,
});
if (!result.canceled && result.filePaths.length > 0) {
const files = result.filePaths.map((filePath) => ({
filePath,
fileName: filePath.split(/[/\\]/).pop() || '',
}));
// Picking a file here is the user's own grant to reveal it later, even
// though attachments routinely live outside every Space root.
await Promise.all(
result.filePaths.map(async (filePath) => {
const realPath = await fsp.realpath(filePath).catch(() => null);
if (realPath) rememberUserSelectedLocalPath(realPath);
})
);
return {
success: true,
files,
fileCount: files.length,
};
}
return {
success: false,
canceled: result.canceled,
};
});
ipcMain.handle('select-agent-plugin-source', async (event) => {
assertMainRendererSender(event);
const sourceChoice = await dialog.showMessageBox(win!, {
type: 'question',
title: 'Import Agent Plugin',
message: 'Choose the Agent Plugin source',
detail:
'A plugin directory is the standard package format. Archives are supported only as an Eigent import transport.',
buttons: ['Plugin directory', 'Archive', 'Cancel'],
defaultId: 0,
cancelId: 2,
noLink: true,
});
if (sourceChoice.response === 2) {
return { canceled: true };
}
const sourceKind = sourceChoice.response === 0 ? 'directory' : 'archive';
const result = await dialog.showOpenDialog(win!, {
title:
sourceKind === 'directory'
? 'Select Agent Plugin directory'
: 'Select Agent Plugin archive',
properties: sourceKind === 'directory' ? ['openDirectory'] : ['openFile'],
filters:
sourceKind === 'archive'
? [
{
name: 'Agent Plugin archives',
extensions: ['zip'],
},
]
: undefined,
});
if (result.canceled || result.filePaths.length !== 1) {
return { canceled: true };
}
const selectedPath = await fsp.realpath(result.filePaths[0]);
const selectedStat = await fsp.stat(selectedPath);
if (
(sourceKind === 'directory' && !selectedStat.isDirectory()) ||
(sourceKind === 'archive' &&
(!selectedStat.isFile() ||
path.extname(selectedPath).toLowerCase() !== '.zip'))
) {
throw new Error('Selected Agent Plugin source has an invalid type');
}
return {
canceled: false,
source_path: selectedPath,
display_name: path.basename(selectedPath),
source_kind: sourceKind,
};
});
// Handle drag-and-drop files - convert File objects to file paths
ipcMain.handle(
'process-dropped-files',
async (event, fileData: Array<{ name: string; path?: string }>) => {
assertMainRendererSender(event);
try {
// In Electron with contextIsolation, we need to get file paths differently
// The renderer will send us file metadata, and we'll use webUtils if needed
const files = fileData
.filter((f) => f.path) // Only process files with valid paths
.map((f) => {
const filePath = fs.realpathSync(f.path!);
// Drag-and-drop is an explicit user gesture equivalent to picking
// the exact path in the native file dialog.
rememberUserSelectedLocalPath(filePath);
return { filePath, fileName: f.name };
});
if (files.length === 0) {
return {
success: false,
error: 'No valid file paths found',
};
}
return {
success: true,
files,
};
} catch (error: any) {
log.error('Failed to process dropped files:', error);
return {
success: false,
error: error.message,
};
}
}
);
// Persist a pasted file (e.g. a clipboard image) so it can join the
// path-based attachment flow; pasted File objects carry no filesystem path.
ipcMain.handle(
'save-pasted-file',
async (event, fileName: string, data: ArrayBuffer) => {
assertMainRendererSender(event);
try {
const pastedDir = path.join(app.getPath('temp'), 'eigent-pasted');
await fsp.mkdir(pastedDir, { recursive: true });
const stamp = new Date()
.toISOString()
.replace(/[-:]/g, '')
.replace(/\..+/, '')
.replace('T', '-');
const safeName = (fileName || 'pasted-file').replace(/[^\w.-]+/g, '_');
const unique = crypto.randomUUID();
const filePath = path.join(pastedDir, `${stamp}-${unique}-${safeName}`);
await fsp.writeFile(filePath, Buffer.from(new Uint8Array(data)));
const realPath = await fsp.realpath(filePath);
// The paste gesture created this exact attachment on the user's behalf.
rememberUserSelectedLocalPath(realPath);
return { success: true, filePath: realPath, fileName: safeName };
} catch (error: any) {
log.error('Failed to save pasted file:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('reveal-in-folder', async (event, targetPath: string) => {
assertMainRendererSender(event);
try {
// Same containment rules as 'reveal-local-path'; this channel additionally
// serves chat attachments, which the user picked outside the workspace.
return await revealUserVisibleLocalNode(
targetPath,
[...activeLocalFileRoots],
userSelectedLocalPaths,
shell
);
} catch (error) {
log.error('Reveal in folder failed', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to reveal path',
};
}
});
ipcMain.handle('reveal-local-path', async (event, targetPath: string) => {
assertMainRendererSender(event);
return revealWorkspaceLocalNode(
targetPath,
[...activeLocalFileRoots],
shell
);
});
ipcMain.handle('open-local-file', async (event, targetPath: string) => {
assertMainRendererSender(event);
return openWorkspaceLocalFile(targetPath, [...activeLocalFileRoots], shell);
});
// Skills: all operations via Brain REST API (backend). No IPC.
// ==================== read file handler ====================
ipcMain.handle('read-file', async (_event, filePath: string) => {
try {
log.info('Reading file:', filePath);
// Check if file exists
if (!fs.existsSync(filePath)) {
log.error('File does not exist:', filePath);
return { success: false, error: 'File does not exist' };
}
const stats = await fsp.stat(filePath);
if (stats.isDirectory()) {
log.error('Path is a directory, not a file:', filePath);
return { success: false, error: 'Path is a directory, not a file' };
}
const fileContent = await fsp.readFile(filePath);
return {
success: true,
data: fileContent,
size: fileContent.length,
};
} catch (error: any) {
log.error('Failed to read file:', filePath, error);
return {
success: false,
error: error.message || 'Failed to read file',
};
}
});
// ==================== delete folder handler ====================
ipcMain.handle('delete-folder', async (event, email: string) => {
const { MCP_REMOTE_CONFIG_DIR } = getEmailFolderPath(email);
try {
log.info('Deleting folder:', MCP_REMOTE_CONFIG_DIR);
// Check if folder exists
if (!fs.existsSync(MCP_REMOTE_CONFIG_DIR)) {
log.error('Folder does not exist:', MCP_REMOTE_CONFIG_DIR);
return { success: false, error: 'Folder does not exist' };
}
// Check if it's actually a directory
const stats = await fsp.stat(MCP_REMOTE_CONFIG_DIR);
if (!stats.isDirectory()) {
log.error('Path is not a directory:', MCP_REMOTE_CONFIG_DIR);
return { success: false, error: 'Path is not a directory' };
}
// Delete folder recursively
await fsp.rm(MCP_REMOTE_CONFIG_DIR, { recursive: true, force: true });
log.info('Folder deleted successfully:', MCP_REMOTE_CONFIG_DIR);
return {
success: true,
message: 'Folder deleted successfully',
};
} catch (error: any) {
log.error('Failed to delete folder:', MCP_REMOTE_CONFIG_DIR, error);
return {
success: false,
error: error.message || 'Failed to delete folder',
};
}
});
// ==================== get MCP config path handler ====================
ipcMain.handle('get-mcp-config-path', async (event, email: string) => {
try {
const { MCP_REMOTE_CONFIG_DIR, tempEmail } = getEmailFolderPath(email);
log.info('Getting MCP config path for email:', email);
log.info('MCP config path:', MCP_REMOTE_CONFIG_DIR);
return {
success: MCP_REMOTE_CONFIG_DIR,
path: MCP_REMOTE_CONFIG_DIR,
tempEmail: tempEmail,
};
} catch (error: any) {
log.error('Failed to get MCP config path:', error);
return {
success: false,
error: error.message || 'Failed to get MCP config path',
};
}
});
// ==================== IDE integration handler ====================
ipcMain.handle(
'get-project-folder-path',
async (
_event,
email: string,
projectId: string,
userId?: string | number | null
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
const result = manager.createProjectStructure(email, projectId, userId);
return result.path;
}
);
ipcMain.handle(
'open-in-ide',
async (event, targetPath: string, ide: string) => {
assertMainRendererSender(event);
if (ide !== 'vscode' && ide !== 'cursor' && ide !== 'system') {
return { success: false, error: 'Unsupported editor' };
}
const node = await authorizeWorkspaceLocalNode(targetPath, [
...activeLocalFileRoots,
]);
if (!node.success) return node;
if (ide === 'system') {
return revealAuthorizedLocalNode(node, shell);
}
const getIDECommand = (): string => {
const platform = process.platform;
const homeDir = homedir();
if (ide === 'vscode') {
if (platform === 'darwin') {
// macOS: Check common VS Code CLI paths
const vscodePaths = [
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
'/usr/local/bin/code',
];
for (const p of vscodePaths) {
if (existsSync(p)) return p;
}
log.warn('[IDE] VS Code not found on macOS');
return '';
} else if (platform === 'win32') {
// Windows: Check common VS Code paths
const vscodePaths = [
path.join(
homeDir,
'AppData',
'Local',
'Programs',
'Microsoft VS Code',
'Code.exe'
),
'C:\\Program Files\\Microsoft VS Code\\Code.exe',
];
for (const p of vscodePaths) {
if (existsSync(p)) return p;
}
log.warn('[IDE] VS Code not found on Windows');
return '';
}
return 'code'; // Linux
} else if (ide === 'cursor') {
if (platform === 'darwin') {
// macOS: Check common Cursor CLI paths
const cursorPaths = [
'/Applications/Cursor.app/Contents/Resources/app/bin/cursor',
'/usr/local/bin/cursor',
];
for (const p of cursorPaths) {
if (existsSync(p)) return p;
}
log.warn('[IDE] Cursor not found on macOS');
return '';
} else if (platform === 'win32') {
// Windows: Check common Cursor paths
const cursorPaths = [
path.join(
homeDir,
'AppData',
'Local',
'Programs',
'Cursor',
'Cursor.exe'
),
path.join(homeDir, 'AppData', 'Local', 'Cursor', 'Cursor.exe'),
];
for (const p of cursorPaths) {
if (existsSync(p)) return p;
}
log.warn('[IDE] Cursor not found on Windows');
return '';
}
return 'cursor'; // Linux
}
return '';
};
const cmd = getIDECommand();
if (!cmd) {
const editorName = ide === 'vscode' ? 'Visual Studio Code' : 'Cursor';
return { success: false, error: `${editorName} is not installed` };
}
return new Promise<{ success: boolean; error?: string }>((resolve) => {
const child = spawn(cmd, [node.path], {
shell: false,
stdio: 'ignore',
detached: true,
});
child.unref();
child.once('error', (error) => {
log.warn(`[IDE] Failed to open ${node.path} with ${cmd}:`, error);
resolve({ success: false, error: error.message });
});
child.once('spawn', () => {
resolve({ success: true });
});
});
}
);
// ==================== env handler ====================
ipcMain.handle('get-env-path', async (_event, email) => {
return getEnvPath(email);
});
ipcMain.handle('get-env-has-key', async (_event, email, key) => {
const ENV_PATH = getEnvPath(email);
let content = '';
try {
content = fs.existsSync(ENV_PATH)
? fs.readFileSync(ENV_PATH, 'utf-8')
: '';
} catch (error) {
log.error('env-remove error:', error);
}
let lines = content.split(/\r?\n/);
return { success: lines.some((line) => line.startsWith(key + '=')) };
});
ipcMain.handle('env-write', async (_event, email, { key, value }) => {
const ENV_PATH = getEnvPath(email);
let content = '';
try {
content = fs.existsSync(ENV_PATH)
? fs.readFileSync(ENV_PATH, 'utf-8')
: '';
} catch (error) {
log.error('env-write error:', error);
}
let lines = content.split(/\r?\n/);
lines = updateEnvBlock(lines, { [key]: value });
fs.writeFileSync(ENV_PATH, lines.join('\n'), 'utf-8');
// Also write to global .env file for backend process to read
const GLOBAL_ENV_PATH = path.join(os.homedir(), '.eigent', '.env');
let globalContent = '';
try {
globalContent = fs.existsSync(GLOBAL_ENV_PATH)
? fs.readFileSync(GLOBAL_ENV_PATH, 'utf-8')
: '';
} catch (error) {
log.error('global env-write read error:', error);
}
let globalLines = globalContent.split(/\r?\n/);
globalLines = updateEnvBlock(globalLines, { [key]: value });
try {
fs.writeFileSync(GLOBAL_ENV_PATH, globalLines.join('\n'), 'utf-8');
log.info(`env-write: wrote ${key} to both user and global .env files`);
} catch (error) {
log.error('global env-write error:', error);
}
return { success: true };
});
ipcMain.handle('env-remove', async (_event, email, key) => {
log.info('env-remove', key);
const ENV_PATH = getEnvPath(email);
let content = '';
try {
content = fs.existsSync(ENV_PATH)
? fs.readFileSync(ENV_PATH, 'utf-8')
: '';
} catch (error) {
log.error('env-remove error:', error);
}
let lines = content.split(/\r?\n/);
lines = removeEnvKey(lines, key);
fs.writeFileSync(ENV_PATH, lines.join('\n'), 'utf-8');
log.info('env-remove success', ENV_PATH);
// Also remove from global .env file
const GLOBAL_ENV_PATH = path.join(os.homedir(), '.eigent', '.env');
try {
let globalContent = fs.existsSync(GLOBAL_ENV_PATH)
? fs.readFileSync(GLOBAL_ENV_PATH, 'utf-8')
: '';
let globalLines = globalContent.split(/\r?\n/);
globalLines = removeEnvKey(globalLines, key);
fs.writeFileSync(GLOBAL_ENV_PATH, globalLines.join('\n'), 'utf-8');
log.info(
`env-remove: removed ${key} from both user and global .env files`
);
} catch (error) {
log.error('global env-remove error:', error);
}
return { success: true };
});
// ==================== read global env handler ====================
const ALLOWED_GLOBAL_ENV_KEYS = new Set(['HTTP_PROXY', 'HTTPS_PROXY']);
ipcMain.handle('read-global-env', async (_event, key: string) => {
if (!ALLOWED_GLOBAL_ENV_KEYS.has(key)) {
log.warn(`[ENV] Blocked read of disallowed global env key: ${key}`);
return { value: null };
}
return { value: readGlobalEnvKey(key) };
});
// ==================== new window handler ====================
ipcMain.handle('open-win', (_, arg) => {
const childWindow = new BrowserWindow({
webPreferences: {
preload,
nodeIntegration: true,
contextIsolation: false,
},
});
if (VITE_DEV_SERVER_URL) {
childWindow.loadURL(`${VITE_DEV_SERVER_URL}#${arg}`);
} else {
childWindow.loadFile(indexHtml, { hash: arg });
}
});
// ==================== FileReader handler ====================
ipcMain.handle(
'open-file',
async (
event,
type: string,
filePath: string,
isShowSourceCode: boolean
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
const authorizedPath = await requireAuthorizedPreviewFile(
event,
filePath
);
return manager.openFile(type, authorizedPath, isShowSourceCode);
}
);
ipcMain.handle(
'get-file-preview-metadata',
async (event, filePath: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
const authorizedPath = await requireAuthorizedPreviewFile(
event,
filePath
);
return manager.getPreviewMetadata(authorizedPath);
}
);
ipcMain.handle('preview-csv-file', async (event, filePath: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
const authorizedPath = await requireAuthorizedPreviewFile(event, filePath);
return manager.previewCsvFile(authorizedPath);
});
ipcMain.handle(
'preview-text-file',
async (event, filePath: string, limit?: number) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
const authorizedPath = await requireAuthorizedPreviewFile(
event,
filePath
);
return manager.previewTextFile(authorizedPath, limit);
}
);
ipcMain.handle('download-file', async (_, url: string) => {
try {
const https = await import('https');
const http = await import('http');
// extract file name from URL
const urlObj = new URL(url);
const fileName = urlObj.pathname.split('/').pop() || 'download';
// get download directory
const downloadPath = path.join(app.getPath('downloads'), fileName);
// create write stream
const fileStream = fs.createWriteStream(downloadPath);
// choose module according to protocol
const client = url.startsWith('https:') ? https : http;
return new Promise((resolve, reject) => {
const request = client.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
}
response.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
shell.showItemInFolder(downloadPath);
resolve({ success: true, path: downloadPath });
});
fileStream.on('error', (err) => {
reject(err);
});
});
request.on('error', (err) => {
reject(err);
});
});
} catch (error: any) {
log.error('Download file error:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle(
'get-file-list',
async (
_,
email: string,
taskId: string,
projectId?: string,
userId?: string | number | null
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getFileList(email, taskId, projectId, userId);
}
);
ipcMain.handle(
'get-camel-log-file-list',
async (
_,
email: string,
taskId: string,
projectId?: string,
userId?: string | number | null
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getCamelLogFileList(email, taskId, projectId, userId);
}
);
ipcMain.handle(
'delete-task-files',
async (_, email: string, taskId: string, projectId?: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.deleteTaskFiles(email, taskId, projectId);
}
);
// New project management handlers
ipcMain.handle(
'create-project-structure',
async (
_,
email: string,
projectId: string,
userId?: string | number | null
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.createProjectStructure(email, projectId, userId);
}
);
ipcMain.handle('get-project-list', async (_, email: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getProjectList(email);
});
ipcMain.handle(
'get-tasks-in-project',
async (_, email: string, projectId: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getTasksInProject(email, projectId);
}
);
ipcMain.handle(
'move-task-to-project',
async (_, email: string, taskId: string, projectId: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.moveTaskToProject(email, taskId, projectId);
}
);
ipcMain.handle(
'get-workspace-file-list',
async (event, workspaceRoot: string, requestedRelativePaths: unknown) => {
if (
typeof workspaceRoot !== 'string' ||
!Array.isArray(requestedRelativePaths) ||
requestedRelativePaths.length > 500 ||
requestedRelativePaths.some((value) => typeof value !== 'string')
) {
throw new Error('Invalid workspace file resolver request');
}
const manager = checkManagerInstance(fileReader, 'FileReader');
const authorizedRoot = await requireAuthorizedPreviewFile(
event,
workspaceRoot
);
return manager.getWorkspaceFileList(
authorizedRoot,
requestedRelativePaths
);
}
);
ipcMain.handle(
'get-project-file-list',
async (
_,
email: string,
projectId: string,
userId?: string | number | null
) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getProjectFileList(email, projectId, userId);
}
);
ipcMain.handle('get-log-folder', async (_, email: string) => {
const manager = checkManagerInstance(fileReader, 'FileReader');
return manager.getLogFolder(email);
});
// ==================== WebView handler ====================
const webviewHandlers = [
{ name: 'capture-webview', method: 'captureWebview' },
{ name: 'create-webview', method: 'createWebview' },
{ name: 'hide-webview', method: 'hideWebview' },
{ name: 'show-webview', method: 'showWebview' },
{ name: 'change-view-size', method: 'changeViewSize' },
{ name: 'hide-all-webview', method: 'hideAllWebview' },
{ name: 'get-active-webview', method: 'getActiveWebview' },
{ name: 'set-size', method: 'setSize' },
{ name: 'get-show-webview', method: 'getShowWebview' },
{ name: 'webview-destroy', method: 'destroyWebview' },
];
webviewHandlers.forEach(({ name, method }) => {
ipcMain.handle(name, async (_, ...args) => {
const manager = checkManagerInstance(webViewManager, 'WebViewManager');
return manager[method as keyof typeof manager](...args);
});
});
// ==================== dependency install handler ====================
ipcMain.handle('install-dependencies', async () => {
try {
if (win === null) throw new Error('Window is null');
// Prevent concurrent installations
if (isInstallationInProgress) {
log.info('[DEPS INSTALL] Installation already in progress, waiting...');
await installationLock;
return {
success: true,
message: 'Installation completed by another process',
};
}
log.info('[DEPS INSTALL] Manual installation/retry triggered');
// Set lock
isInstallationInProgress = true;
installationLock = checkAndInstallDepsOnUpdate({
win,
forceInstall: true,
}).finally(() => {
isInstallationInProgress = false;
});
const result = await installationLock;
if (!result.success) {
log.error('[DEPS INSTALL] Manual installation failed:', result.message);
// Note: Failure event already sent by installDependencies function
return { success: false, error: result.message };
}
log.info('[DEPS INSTALL] Manual installation succeeded');
// IMPORTANT: Send install-dependencies-complete success event
if (!win.isDestroyed()) {
win.webContents.send('install-dependencies-complete', {
success: true,
code: 0,
});
log.info(
'[DEPS INSTALL] Sent install-dependencies-complete event after retry'
);
}
// Start backend after retry with cleanup
await startBackendAfterInstall();
return { success: true, isInstalled: result.success };
} catch (error) {
log.error('[DEPS INSTALL] Manual installation error:', error);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('check-tool-installed', async () => {
try {
const isInstalled = await checkToolInstalled();
return { success: true, isInstalled: isInstalled.success };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('get-installation-status', async () => {
try {
const { isInstalling, hasLockFile } = await getInstallationStatus();
return {
success: true,
isInstalling,
hasLockFile,
timestamp: Date.now(),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ==================== register update related handler ====================
registerUpdateIpcHandlers(() => closeCoordinator.markAppQuitInProgress());
}
// ==================== ensure eigent directories ====================
const ensureEigentDirectories = () => {
const eigentBase = path.join(os.homedir(), '.eigent');
const requiredDirs = [
eigentBase,
path.join(eigentBase, 'bin'),
path.join(eigentBase, 'cache'),
path.join(eigentBase, 'venvs'),
path.join(eigentBase, 'runtime'),
path.join(eigentBase, 'skills'),
];
for (const dir of requiredDirs) {
if (!fs.existsSync(dir)) {
log.info(`Creating directory: ${dir}`);
fs.mkdirSync(dir, { recursive: true });
}
}
log.info('.eigent directory structure ensured');
};
// ==================== skills (used at startup and by IPC) ====================
const SKILLS_ROOT = path.join(os.homedir(), '.eigent', 'skills');
const SKILL_FILE = 'SKILL.md';
const EXAMPLE_SKILL_MARKER = '.eigent-example-skill';
const getExampleSkillsSourceDir = (): string => {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'example-skills');
}
const devPath = path.join(MAIN_DIST, 'resources', 'example-skills');
if (existsSync(devPath)) return devPath;
return path.join(app.getAppPath(), 'resources', 'example-skills');
};
async function copyDirRecursive(src: string, dst: string): Promise<void> {
await fsp.mkdir(dst, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
// Skip symlinks to prevent copying files from outside the source tree
if (entry.isSymbolicLink()) continue;
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(srcPath, dstPath);
} else {
await fsp.copyFile(srcPath, dstPath);
}
}
}
function parseSkillName(content: string): string | null {
const match = content.match(/^\s*name\s*:\s*(.+)$/m);
return match?.[1]?.trim().replace(/^['"]|['"]$/g, '') || null;
}
async function readSkillName(skillDir: string): Promise<string | null> {
try {
const content = await fsp.readFile(
path.join(skillDir, SKILL_FILE),
'utf-8'
);
return parseSkillName(content);
} catch {
return null;
}
}
async function isManagedExampleSkill(
dstDir: string,
srcDir: string
): Promise<boolean> {
if (existsSync(path.join(dstDir, EXAMPLE_SKILL_MARKER))) return true;
const [dstName, srcName] = await Promise.all([
readSkillName(dstDir),
readSkillName(srcDir),
]);
return !!dstName && dstName === srcName;
}
async function writeExampleSkillMarker(
dstDir: string,
sourceDirName: string
): Promise<void> {
await fsp.writeFile(
path.join(dstDir, EXAMPLE_SKILL_MARKER),
`source=${sourceDirName}\n`,
'utf-8'
);
}
async function listRegularFiles(
root: string,
ignoredNames = new Set<string>()
): Promise<Map<string, string>> {
const files = new Map<string, string>();
const walk = async (dir: string) => {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink() || ignoredNames.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
files.set(path.relative(root, fullPath), fullPath);
}
}
};
await walk(root);
return files;
}
async function dirContentsMatch(src: string, dst: string): Promise<boolean> {
const [srcFiles, dstFiles] = await Promise.all([
listRegularFiles(src),
listRegularFiles(dst, new Set([EXAMPLE_SKILL_MARKER])),
]);
if (srcFiles.size !== dstFiles.size) return false;
for (const [relativePath, srcPath] of srcFiles) {
const dstPath = dstFiles.get(relativePath);
if (!dstPath) return false;
const [srcContent, dstContent] = await Promise.all([
fsp.readFile(srcPath),
fsp.readFile(dstPath),
]);
if (!srcContent.equals(dstContent)) return false;
}
return true;
}
async function syncDefaultSkillsFromBundle(): Promise<void> {
if (!existsSync(SKILLS_ROOT)) {
await fsp.mkdir(SKILLS_ROOT, { recursive: true });
}
const exampleDir = getExampleSkillsSourceDir();
if (!existsSync(exampleDir)) {
log.warn('Example skills source dir missing:', exampleDir);
return;
}
const sourceEntries = await fsp.readdir(exampleDir, { withFileTypes: true });
let copiedCount = 0;
let updatedCount = 0;
for (const e of sourceEntries) {
if (!e.isDirectory() || e.name.startsWith('.')) continue;
const skillMd = path.join(exampleDir, e.name, SKILL_FILE);
if (!existsSync(skillMd)) continue;
const destDir = path.join(SKILLS_ROOT, e.name);
const srcDir = path.join(exampleDir, e.name);
if (!existsSync(destDir)) {
await copyDirRecursive(srcDir, destDir);
await writeExampleSkillMarker(destDir, e.name);
copiedCount++;
continue;
}
const destStats = await fsp.stat(destDir).catch(() => null);
if (!destStats?.isDirectory()) continue;
if (!(await isManagedExampleSkill(destDir, srcDir))) {
log.warn('Skipping default skill sync due to local conflict:', destDir);
continue;
}
if (await dirContentsMatch(srcDir, destDir)) {
await writeExampleSkillMarker(destDir, e.name);
continue;
}
await fsp.rm(destDir, { recursive: true, force: true });
await copyDirRecursive(srcDir, destDir);
await writeExampleSkillMarker(destDir, e.name);
updatedCount++;
}
if (copiedCount > 0 || updatedCount > 0) {
log.info(
`Synced default skill(s) to ~/.eigent/skills: copied=${copiedCount} updated=${updatedCount} from`,
exampleDir
);
}
}
async function seedDefaultSkillsIfEmpty(): Promise<void> {
await syncDefaultSkillsFromBundle();
}
// ==================== Shared backend startup logic ====================
// Starts backend after installation completes
// Used by both initial startup and retry flows
const startBackendAfterInstall = async () => {
log.info('[DEPS INSTALL] Starting backend...');
// Add a small delay to ensure any previous processes are fully cleaned up
await new Promise((resolve) => setTimeout(resolve, 500));
// Existing installations originally stored the device id in renderer
// localStorage. Migrate it before the Brain environment first resolves the
// new main-process-owned identity file.
await primeDesktopInstanceIdFromRenderer();
await checkAndStartBackend();
};
// ==================== installation lock ====================
let isInstallationInProgress = false;
let installationLock: Promise<PromiseReturnType> = Promise.resolve({
message: 'No installation needed',
success: true,
});
// ==================== window create ====================
async function createWindow() {
const existingWindow =
win && !win.isDestroyed() ? win : BrowserWindow.getAllWindows()[0];
if (existingWindow && !existingWindow.isDestroyed()) {
win = existingWindow;
win.focus();
return;
}
if (createWindowPromise) {
await createWindowPromise;
if (win && !win.isDestroyed()) {
win.focus();
}
return;
}
createWindowPromise = createWindowInternal().finally(() => {
createWindowPromise = null;
});
return createWindowPromise;
}
async function createWindowInternal() {
const isMac = process.platform === 'darwin';
const isWindows = process.platform === 'win32';
// Ensure .eigent directories exist before anything else
ensureEigentDirectories();
await seedDefaultSkillsIfEmpty();
// Load persisted CDP browser pool from disk
loadCdpPool();
log.info(
`[PROJECT BROWSER WINDOW] Creating BrowserWindow which will start Chrome with CDP on port ${browser_port}`
);
log.info(
`[PROJECT BROWSER WINDOW] Current user data path: ${app.getPath(
'userData'
)}`
);
log.info(
`[PROJECT BROWSER WINDOW] Command line switch user-data-dir: ${app.commandLine.getSwitchValue(
'user-data-dir'
)}`
);
const startupWindowState = loadWindowStartupState({
userDataPath: userData,
displays: screen.getAllDisplays(),
primaryDisplay: screen.getPrimaryDisplay(),
});
log.info(
`[WINDOW STATE] Restoring ${startupWindowState.source}: ${JSON.stringify(
startupWindowState.bounds
)}`
);
// Platform-specific window configuration
// Windows: native frame and solid background. macOS/Linux: frameless; macOS corner radius via native hook.
appShellReadinessGate.markDocumentLoading();
rendererAppCommands.markNotReady('window-created');
win = new BrowserWindow({
title: 'Eigent',
...startupWindowState.bounds,
minWidth: startupWindowState.minimumSize.width,
minHeight: startupWindowState.minimumSize.height,
// Use native frame on Windows for better native integration
frame: isWindows ? true : false,
show: false, // Don't show until content is ready to avoid white screen
// Only use transparency on macOS and Linux (not supported well on Windows)
transparent: !isWindows,
// Solid on Windows; macOS solid without vibrancy; Linux unchanged semi-transparent tint
backgroundColor: isWindows
? nativeTheme.shouldUseDarkColors
? '#1e1e1e'
: '#ffffff'
: isMac
? nativeTheme.shouldUseDarkColors
? '#1e1e1e'
: '#f5f5f5'
: '#f5f5f580',
// macOS-specific title bar styling
titleBarStyle: isMac ? 'hidden' : undefined,
trafficLightPosition: isMac ? { x: 10, y: 12 } : undefined,
icon: path.join(VITE_PUBLIC, 'favicon.ico'),
// Rounded corners on macOS and Linux (as original)
roundedCorners: !isWindows,
// Keep the Windows/Linux menu discoverable through Alt without changing
// the existing clean shell chrome.
...(!isMac && {
autoHideMenuBar: true,
}),
webPreferences: {
// Use a dedicated partition for main window to isolate from webviews
// This ensures main window's auth data (localStorage) is stored separately and persists across restarts
partition: 'persist:main_window',
webSecurity: true,
preload,
nodeIntegration: true,
contextIsolation: true,
webviewTag: true,
spellcheck: false,
},
});
const createdWindow = win;
const disposeMainContextMenu = installSurfaceContextMenu(
createdWindow.webContents,
createdWindow,
'main-renderer'
);
createdWindow.on('move', () => scheduleWindowStateSave(createdWindow));
createdWindow.on('resize', () => scheduleWindowStateSave(createdWindow));
createdWindow.on('maximize', () => scheduleWindowStateSave(createdWindow));
createdWindow.on('unmaximize', () => scheduleWindowStateSave(createdWindow));
createdWindow.on('close', () => persistBrowserWindowState(createdWindow));
createdWindow.on('closed', () => {
disposeMainContextMenu();
if (windowStateSaveTimer) {
clearTimeout(windowStateSaveTimer);
windowStateSaveTimer = null;
}
});
// Renderer <webview> guests (session preview browser) host arbitrary web
// content, and the host window itself runs with elevated webPreferences.
// Enforce safe guest settings at attach time so no tag attribute (even one
// forged by a compromised renderer) can grant a guest host privileges.
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
webPreferences.webSecurity = true;
webPreferences.partition = PREVIEW_WEBVIEW_PARTITION;
if (
params.partition !== PREVIEW_WEBVIEW_PARTITION ||
!isHttpOrHttpsUrl(params.src)
) {
event.preventDefault();
}
});
// Route window.open / target=_blank into the same guest instead of spawning
// popup windows, and only allow web URLs. Together with the attach guard
// above, this is the only main-process involvement the guests need.
win.webContents.on('did-attach-webview', (_event, contents) => {
const disposeGuestContextMenu = installSurfaceContextMenu(
contents,
createdWindow,
'preview-guest'
);
contents.once('destroyed', disposeGuestContextMenu);
const preventUnsafeNavigation = (
event: Electron.Event,
navigationUrl: string
) => {
if (!isHttpOrHttpsUrl(navigationUrl)) {
event.preventDefault();
}
};
const guestNavigationEvents = contents as unknown as {
on: (
eventName: string,
listener: (event: Electron.Event, navigationUrl: string) => void
) => void;
};
guestNavigationEvents.on('will-navigate', preventUnsafeNavigation);
guestNavigationEvents.on('will-frame-navigate', preventUnsafeNavigation);
guestNavigationEvents.on('will-redirect', preventUnsafeNavigation);
contents.setWindowOpenHandler(({ url }) => {
if (isHttpOrHttpsUrl(url)) {
void contents.loadURL(url);
}
return { action: 'deny' };
});
});
if (process.platform === 'darwin') {
win.once('ready-to-show', () => {
if (win && !win.isDestroyed()) {
try {
setRoundedCorners(win, 20);
} catch (error) {
log.error('[MacOS] Failed to apply rounded corners:', error);
}
}
});
}
// ==================== Handle renderer crashes and failed loads ====================
win.webContents.on('did-start-loading', () => {
appShellReadinessGate.markDocumentLoading();
rendererAppCommands.markNotReady('did-start-loading');
});
win.webContents.on('did-finish-load', () => {
appShellReadinessGate.markDocumentLoaded();
});
win.webContents.on('render-process-gone', (event, details) => {
log.error('[RENDERER] Process gone:', details.reason, details.exitCode);
appShellReadinessGate.markDocumentLoading();
rendererAppCommands.markNotReady('render-process-gone');
if (win && !win.isDestroyed()) {
// Reload the window after a brief delay
setTimeout(() => {
if (win && !win.isDestroyed()) {
log.info('[RENDERER] Attempting to reload after crash...');
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
win.loadFile(indexHtml);
}
}
}, 1000);
}
});
win.webContents.on(
'did-fail-load',
(event, errorCode, errorDescription, validatedURL, isMainFrame) => {
const shouldRetry = appShellReadinessGate.markDocumentLoadFailed(
isMainFrame,
errorCode
);
if (shouldRetry) rendererAppCommands.markNotReady('did-fail-load');
log.error(
`[RENDERER] Failed to load: ${errorCode} - ${errorDescription} - ${validatedURL}`
);
// Retry loading after a delay
if (shouldRetry) {
setTimeout(() => {
if (win && !win.isDestroyed()) {
log.info('[RENDERER] Retrying load after failure...');
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
win.loadFile(indexHtml);
}
}
}, 2000);
}
}
);
// Main window now uses default userData directly with partition 'persist:main_window'
// No migration needed - data is already persistent
// ==================== Import cookies from tool_controller to WebView BEFORE creating WebViews ====================
// Copy partition data files before any session accesses them
try {
const browserProfilesBase = path.join(
os.homedir(),
'.eigent',
'browser_profiles'
);
const toolControllerProfile = path.join(
browserProfilesBase,
'profile_user_login'
);
const toolControllerPartitionPath = path.join(
toolControllerProfile,
'Partitions',
'user_login'
);
if (fs.existsSync(toolControllerPartitionPath)) {
log.info(
'[COOKIE SYNC] Found tool_controller partition, copying to WebView partition...'
);
const targetPartitionPath = path.join(
app.getPath('userData'),
'Partitions',
'user_login'
);
log.info('[COOKIE SYNC] From:', toolControllerPartitionPath);
log.info('[COOKIE SYNC] To:', targetPartitionPath);
// Ensure target directory exists
if (!fs.existsSync(path.dirname(targetPartitionPath))) {
fs.mkdirSync(path.dirname(targetPartitionPath), { recursive: true });
}
// Copy the entire partition directory
fs.cpSync(toolControllerPartitionPath, targetPartitionPath, {
recursive: true,
force: true,
});
log.info('[COOKIE SYNC] Successfully copied partition data to WebView');
// Verify cookies were copied
const targetCookies = path.join(targetPartitionPath, 'Cookies');
if (fs.existsSync(targetCookies)) {
const stats = fs.statSync(targetCookies);
log.info(`[COOKIE SYNC] Cookies file size: ${stats.size} bytes`);
}
} else {
log.info(
'[COOKIE SYNC] No tool_controller partition found, WebView will start fresh'
);
}
} catch (error) {
log.error('[COOKIE SYNC] Failed to sync partition data:', error);
}
// ==================== initialize manager ====================
fileReader = new FileReader(win);
webViewManager = new WebViewManager(win, {
installContextMenu: (contents, ownerWindow) =>
installSurfaceContextMenu(contents, ownerWindow, 'automation-view'),
});
// create multiple webviews
log.info(
`[PROJECT BROWSER] Creating WebViews with partition: persist:user_login`
);
for (let i = 1; i <= 8; i++) {
webViewManager.createWebview(i === 1 ? undefined : i.toString());
}
log.info('[PROJECT BROWSER] WebViewManager initialized with webviews');
// ==================== set event listeners ====================
setupExternalLinkHandling();
handleBeforeClose();
// Start CDP health-check polling (probes every 3s, removes dead browsers)
startCdpHealthCheck();
// ==================== auto update ====================
update(win);
// ==================== CHECK IF INSTALLATION IS NEEDED BEFORE LOADING CONTENT ====================
log.info('Pre-checking if dependencies need to be installed...');
// Check if prebuilt dependencies are available (for packaged app)
let hasPrebuiltDeps = false;
if (app.isPackaged) {
const prebuiltBinDir = path.join(process.resourcesPath, 'prebuilt', 'bin');
const prebuiltDir = path.join(process.resourcesPath, 'prebuilt');
const prebuiltVenvDir = path.join(prebuiltDir, 'venv');
const uvPath = path.join(
prebuiltBinDir,
process.platform === 'win32' ? 'uv.exe' : 'uv'
);
const bunPath = path.join(
prebuiltBinDir,
process.platform === 'win32' ? 'bun.exe' : 'bun'
);
const pyvenvCfg = path.join(prebuiltVenvDir, 'pyvenv.cfg');
const hasVenv = fs.existsSync(pyvenvCfg);
hasPrebuiltDeps =
fs.existsSync(uvPath) && fs.existsSync(bunPath) && hasVenv;
if (hasPrebuiltDeps) {
log.info(
'[PRE-CHECK] Prebuilt dependencies found, skipping installation check'
);
}
}
// Check version and tools status synchronously
const currentVersion = app.getVersion();
const versionFile = path.join(app.getPath('userData'), 'version.txt');
const versionExists = fs.existsSync(versionFile);
let savedVersion = '';
if (versionExists) {
savedVersion = fs.readFileSync(versionFile, 'utf-8').trim();
}
const uvExists = await isBinaryExists('uv');
const bunExists = await isBinaryExists('bun');
// Check if installation was previously completed
const backendPath = getBackendPath();
const installedLockPath = path.join(backendPath, 'uv_installed.lock');
const installationCompleted = fs.existsSync(installedLockPath);
// Check venv existence WITHOUT triggering extraction (defers to startBackend when window is visible)
const { exists: venvExists, path: venvPath } =
checkVenvExistsForPreCheck(currentVersion);
// If prebuilt deps are available, skip installation
const needsInstallation = hasPrebuiltDeps
? false
: !versionExists ||
savedVersion !== currentVersion ||
!uvExists ||
!bunExists ||
!installationCompleted ||
!venvExists;
log.info('Installation check result:', {
needsInstallation,
versionExists,
versionMatch: savedVersion === currentVersion,
uvExists,
bunExists,
installationCompleted,
venvExists,
venvPath,
});
// Handle localStorage based on installation state
if (needsInstallation) {
log.info(
'Installation needed - resetting initState to carousel while preserving auth data'
);
// Instead of deleting the entire localStorage, we'll update only the initState
// This preserves login information while resetting the initialization flow
// Set up the injection for when page loads
win.webContents.once('dom-ready', () => {
if (!win || win.isDestroyed()) {
log.warn(
'Window destroyed before DOM ready - skipping localStorage injection'
);
return;
}
log.info(
'DOM ready - updating initState to carousel while preserving auth data'
);
win.webContents
.executeJavaScript(
`
(function() {
try {
const authStorage = localStorage.getItem('auth-storage');
if (authStorage) {
// Preserve existing auth data, only update initState
const parsed = JSON.parse(authStorage);
const updatedStorage = {
...parsed,
state: {
...parsed.state,
initState: 'carousel'
}
};
localStorage.setItem('auth-storage', JSON.stringify(updatedStorage));
console.log('[ELECTRON PRE-INJECT] Updated initState to carousel, preserved auth data');
} else {
// No existing storage, create new one with carousel state
const newAuthStorage = {
state: {
token: null,
username: null,
email: null,
user_id: null,
appearance: 'light',
language: 'system',
isFirstLaunch: true,
modelType: 'cloud',
cloud_model_type: 'gpt-5.4',
initState: 'carousel',
share_token: null,
workerListData: {}
},
version: 0
};
localStorage.setItem('auth-storage', JSON.stringify(newAuthStorage));
console.log('[ELECTRON PRE-INJECT] Created fresh auth-storage with carousel state');
}
} catch (e) {
console.error('[ELECTRON PRE-INJECT] Failed to update storage:', e);
}
})();
`
)
.catch((err) => {
log.error('Failed to inject script:', err);
});
});
} else {
// The proper flow is now handled by useInstallationSetup.ts with dual-check mechanism:
// 1. Installation complete event → installationCompleted.current = true
// 2. Backend ready event → backendReady.current = true
// 3. Only when BOTH are true → setInitState('done')
//
// This ensures frontend never shows before backend is ready.
log.info(
'Installation already complete - letting useInstallationSetup handle state transitions'
);
}
// Load content
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
win.webContents.openDevTools();
} else {
win.loadFile(indexHtml);
}
// Wait for window to be ready with timeout
await new Promise<void>((resolve) => {
const loadTimeout = setTimeout(() => {
log.warn('Window content load timeout (10s), showing window anyway...');
resolve();
}, 10000);
win!.webContents.once('did-finish-load', () => {
clearTimeout(loadTimeout);
log.info(
'Window content loaded, starting dependency check immediately...'
);
resolve();
});
});
// Show window now that content is loaded (or timeout reached)
if (win && !win.isDestroyed()) {
if (startupWindowState.shouldMaximize) win.maximize();
else win.show();
log.info('Window shown after content loaded');
}
// Mark window as ready and process any queued protocol URLs
isWindowReady = true;
log.info('Window is ready, processing queued protocol URLs...');
processQueuedProtocolUrls();
// Now check and install dependencies
let res: PromiseReturnType = await checkAndInstallDepsOnUpdate({ win });
if (!res.success) {
log.info('[DEPS INSTALL] Dependency Error: ', res.message);
// Note: install-dependencies-complete failure event is already sent by installDependencies function
// in install-deps.ts, so we don't send it again here to avoid duplicate events
return;
}
log.info('[DEPS INSTALL] Dependency Success: ', res.message);
// IMPORTANT: Wait a bit to ensure React components have mounted and registered event listeners
// This prevents race condition where events are sent before listeners are ready
await new Promise((resolve) => setTimeout(resolve, 500));
// IMPORTANT: Always send install-dependencies-complete event when installation check succeeds
// This includes both cases: actual installation completed AND installation was skipped (already installed)
// The frontend needs this event to properly transition from installation screen to main app
if (!win.isDestroyed()) {
win.webContents.send('install-dependencies-complete', {
success: true,
code: 0,
});
log.info(
'[DEPS INSTALL] Sent install-dependencies-complete event to frontend'
);
}
// Start backend after dependencies are ready
await startBackendAfterInstall();
}
// ==================== external link handle ====================
const setupExternalLinkHandling = () => {
if (!win) return;
// Helper function to check if URL is external
const isExternalUrl = (url: string): boolean => {
try {
const urlObj = new URL(url);
// Allow localhost and internal URLs
if (urlObj.hostname === 'localhost' || urlObj.hostname === '127.0.0.1') {
return false;
}
// Allow hash navigation
if (url.startsWith('#') || url.startsWith('/#')) {
return false;
}
// External URLs start with http/https and are not localhost
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
};
// handle new window open
win.webContents.setWindowOpenHandler(({ url }) => {
if (isExternalUrl(url)) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'deny' };
});
// handle navigation
win.webContents.on('will-navigate', (event, url) => {
// Only prevent navigation and open external URLs
// Allow internal navigation like hash changes
if (isExternalUrl(url)) {
event.preventDefault();
shell.openExternal(url);
}
// For internal URLs (localhost, hash navigation), allow navigation to proceed
});
// srcDoc report previews are sandboxed child frames. Never turn a scripted
// child-frame navigation into an outbound request: it could encode local
// workspace contents in the URL even when connect-src is disabled.
win.webContents.on('will-frame-navigate', (details) => {
if (!details.isMainFrame && isExternalUrl(details.url)) {
details.preventDefault();
log.warn('[HTML PREVIEW] Blocked external frame navigation');
}
});
};
// ==================== check and start backend ====================
async function restartBackendService(): Promise<BackendStartResult> {
if (backendStartPromise) {
log.info(
'Backend startup already in progress, waiting before forced restart...'
);
await backendStartPromise;
}
log.info('Restarting backend service...');
return checkAndStartBackend({ forceRestart: true });
}
const checkAndStartBackend = async (
options: BackendStartOptions = {}
): Promise<BackendStartResult> => {
if (backendStartPromise) {
log.info('Backend startup already in progress, waiting...');
return backendStartPromise;
}
backendStartPromise = (async () => {
log.info('Checking and starting backend service...');
try {
if (isPythonProcessRunning()) {
if (!options.forceRestart) {
const isHealthy = await checkBackendHealth(backendPort);
if (isHealthy) {
log.info('Backend service is already running', {
port: backendPort,
});
const result: BackendStartResult = {
success: true,
port: backendPort,
};
notifyBackendReady(result);
return result;
}
log.warn(
'Backend process is running but health check failed; restarting...'
);
} else {
log.info('Cleaning up existing backend process before restart...');
}
await cleanupPythonProcess();
} else if (python_process) {
python_process = null;
}
const isToolInstalled = await checkToolInstalled();
if (isToolInstalled.success) {
log.info('Tool installed, starting backend service...');
const codexResolverEnv = await getCodexResolverEnv();
const workspaceSecretBroker = await ensureWorkspaceSecretBroker();
const exampleSkillsDir = getExampleSkillsSourceDir();
// Start backend and wait for health check to pass
python_process = await startBackend(
(port) => {
backendPort = port;
log.info('Backend service started successfully', { port });
},
{
...codexResolverEnv,
EIGENT_EXAMPLE_SKILLS_DIR: exampleSkillsDir,
EIGENT_LOCAL_CONTROL_CAPABILITY: localControlCapability,
EIGENT_DESKTOP_INSTANCE_ID: resolveDesktopInstanceId(),
EIGENT_ELECTRON_CDP_PORT: String(browser_port),
EIGENT_WORKSPACE_SECRET_BROKER_ENDPOINT:
workspaceSecretBroker.endpoint,
EIGENT_WORKSPACE_SECRET_BROKER_CAPABILITY:
workspaceSecretBroker.capability,
}
);
// Notify frontend that backend is ready
log.info('Backend is ready, notifying frontend...');
const result: BackendStartResult = {
success: true,
port: backendPort,
};
notifyBackendReady(result);
python_process?.on('exit', (code, signal) => {
log.info('Python process exited', { code, signal });
});
return result;
} else {
log.warn('Tool not installed, cannot start backend service');
// Notify frontend that backend cannot start
const result: BackendStartResult = {
success: false,
error: 'Tools not installed',
};
notifyBackendReady(result);
return result;
}
} catch (error) {
log.error('Failed to start backend:', error);
// Notify frontend of backend startup failure
const result: BackendStartResult = {
success: false,
error: formatErrorMessage(error),
};
notifyBackendReady(result);
return result;
}
})().finally(() => {
backendStartPromise = null;
});
return backendStartPromise;
};
// ==================== process cleanup ====================
const cleanupPythonProcess = async () => {
try {
// First attempt: Try to kill using PID and all children
if (python_process?.pid) {
const pid = python_process.pid;
log.info('Cleaning up Python process and all children', { pid });
// Remove all listeners to prevent memory leaks
python_process.removeAllListeners();
await new Promise<void>((resolve) => {
// Kill the entire process tree (parent + all children)
kill(pid, 'SIGTERM', (err) => {
if (err) {
log.error('Failed to clean up process tree with SIGTERM:', err);
// Try SIGKILL as fallback for entire tree
kill(pid, 'SIGKILL', (killErr) => {
if (killErr) {
log.error('Failed to force kill process tree:', killErr);
}
resolve();
});
} else {
log.info('Successfully sent SIGTERM to process tree');
// Give processes 1 second to clean up, then SIGKILL
setTimeout(() => {
kill(pid, 'SIGKILL', () => {
log.info('Sent SIGKILL to ensure cleanup');
resolve();
});
}, 1000);
}
});
});
}
// Second attempt: Use port-based cleanup as fallback
const portFile = path.join(userData, 'port.txt');
if (fs.existsSync(portFile)) {
try {
const port = parseInt(fs.readFileSync(portFile, 'utf-8').trim(), 10);
if (!isNaN(port) && port > 0 && port < 65536) {
log.info(`Attempting to kill process on port: ${port}`);
await killProcessOnPort(port);
}
fs.unlinkSync(portFile);
} catch (error) {
log.error('Error handling port file:', error);
}
}
// Clean up any temporary files in userData
try {
const tempFiles = ['backend.lock', 'uv_installing.lock'];
for (const file of tempFiles) {
const filePath = path.join(userData, file);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
} catch (error) {
log.error('Error cleaning up temp files:', error);
}
python_process = null;
} catch (error) {
log.error('Error occurred while cleaning up process:', error);
}
};
// before close
const handleBeforeClose = () => {
if (win) closeCoordinator.bindWindow(win);
};
// ==================== app event handle ====================
app.whenReady().then(async () => {
// Wait for profile initialization to complete
log.info('[MAIN] Waiting for profile initialization...');
try {
await profileInitPromise;
log.info('[MAIN] Profile initialization completed');
} catch (error) {
log.error('[MAIN] Profile initialization failed:', error);
}
// ==================== install React DevTools ====================
// Only install in development mode
if (VITE_DEV_SERVER_URL) {
try {
log.info('[DEVTOOLS] Installing React DevTools extension...');
// Dynamic import to avoid bundling in production
const { default: installExtension, REACT_DEVELOPER_TOOLS } =
await import('electron-devtools-installer');
const name = await installExtension(REACT_DEVELOPER_TOOLS, {
loadExtensionOptions: { allowFileAccess: true },
});
log.info(`[DEVTOOLS] Successfully installed extension: ${name}`);
} catch (err) {
log.error('[DEVTOOLS] Failed to install React DevTools:', err);
// Don't throw - allow app to continue even if extension installation fails
}
}
// ==================== Anti-fingerprint: Set User Agent for all sessions ====================
// Use the same dynamic User Agent as app.userAgentFallback
session.defaultSession.setUserAgent(normalUserAgent);
// Also set for the user_login partition used by webviews
session.fromPartition('persist:user_login').setUserAgent(normalUserAgent);
// And for main_window partition
session.fromPartition('persist:main_window').setUserAgent(normalUserAgent);
log.info('[ANTI-FINGERPRINT] User Agent set for all sessions');
// ==================== Apply proxy to Electron sessions ====================
if (proxyUrl) {
const proxyConfig = { proxyRules: proxyUrl };
await session.defaultSession.setProxy(proxyConfig);
await session.fromPartition('persist:user_login').setProxy(proxyConfig);
await session.fromPartition('persist:main_window').setProxy(proxyConfig);
log.info(
`[PROXY] Applied proxy to all sessions: ${maskProxyUrl(proxyUrl)}`
);
}
// ==================== download handle ====================
session.defaultSession.on('will-download', (event, item, _webContents) => {
item.once('done', (_event, _state) => {
const itemUrl = item.getURL();
if (itemUrl.startsWith('localfile:')) {
shell.showItemInFolder(filePathFromLocalFileUrl(itemUrl));
}
});
});
// ==================== protocol handle ====================
// Register protocol handler for both default session and main window session
const protocolHandler = async (request: Request) => {
const url = filePathFromLocalFileUrl(request.url);
const normalizedUrl = url.replace(/^\/([A-Za-z]:[\\/])/, '$1');
const filePath = path.resolve(path.normalize(normalizedUrl));
log.info(`[PROTOCOL] Handling localfile request: ${request.url}`);
log.info(`[PROTOCOL] Resolved path: ${filePath}`);
const authorization = await authorizeLocalFilePath(
filePath,
localFileAllowedRoots()
);
if (!authorization.allowed) {
log.error(
`[PROTOCOL] Security: Blocked local file (${authorization.reason}): ${filePath}`
);
return new Response(
authorization.reason === 'missing' ? 'File Not Found' : 'Forbidden',
{ status: authorization.reason === 'missing' ? 404 : 403 }
);
}
const authorizedFilePath = authorization.filePath;
try {
// Check if file exists
const stats = await fsp.stat(authorizedFilePath);
if (!stats.isFile()) return new Response('Not Found', { status: 404 });
const contentType =
mime.getType(authorizedFilePath) || 'application/octet-stream';
const resolvedRange = resolveFileByteRange(
request.headers.get('range'),
stats.size
);
if (resolvedRange.status === 416) {
return new Response('Range Not Satisfiable', {
status: 416,
headers: { 'Content-Range': `bytes */${stats.size}` },
});
}
const { start, end, status } = resolvedRange;
const contentLength = stats.size === 0 ? 0 : end - start + 1;
const headers: Record<string, string> = {
'Accept-Ranges': 'bytes',
'Content-Type': contentType,
'Content-Length': contentLength.toString(),
};
if (status === 206) {
headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
}
if (request.method === 'HEAD' || stats.size === 0) {
return new Response(null, { status, headers });
}
const stream = fs.createReadStream(authorizedFilePath, { start, end });
return new Response(Readable.toWeb(stream) as BodyInit, {
status,
headers,
});
} catch (err) {
log.error(`[PROTOCOL] Error reading file: ${err}`);
return new Response('Internal Server Error', { status: 500 });
}
};
// Register on default session
protocol.handle('localfile', protocolHandler);
// Also register on main window session
const mainSession = session.fromPartition('persist:main_window');
mainSession.protocol.handle('localfile', protocolHandler);
log.info(
'[PROTOCOL] Registered localfile protocol on both default and main_window sessions'
);
// ==================== initialize app ====================
initializeApp();
registerIpcHandlers();
installNativeApplicationMenu();
createWindow();
});
// ==================== window close event ====================
app.on('window-all-closed', () => {
log.info('window-all-closed');
// Stop polling when no window is open (important on macOS reopen flow).
stopCdpHealthCheck();
// Clean up WebView manager
if (webViewManager) {
webViewManager.destroy();
webViewManager = null;
}
// Reset window state
closeCoordinator.unbindWindow();
rendererAppCommands.markNotReady('window-all-closed');
win = null;
isWindowReady = false;
appShellReadinessGate.markDocumentLoading();
protocolUrlQueue = [];
if (process.platform !== 'darwin') {
app.quit();
}
});
// ==================== app activate event ====================
app.on('activate', async () => {
const allWindows = BrowserWindow.getAllWindows();
log.info('activate', allWindows.length);
if (allWindows.length) {
allWindows[0].focus();
} else {
const backendStart = checkAndStartBackend();
await createWindow();
const result = await backendStart;
if (!result.success) {
log.warn('Backend start during app activation failed:', result.error);
} else {
notifyBackendReady(result);
}
}
});
// ==================== app exit event ====================
app.on('before-quit', async (event) => {
log.info('before-quit');
log.info('quit python_process.pid: ' + python_process?.pid);
if (
!closeCoordinator.isAppQuitInProgress() &&
rendererAppCommands.isReady() &&
win &&
!win.isDestroyed()
) {
event.preventDefault();
closeCoordinator.request('quit-app');
return;
}
closeCoordinator.markAppQuitInProgress();
// Stop CDP health-check polling
stopCdpHealthCheck();
// Prevent default quit to ensure cleanup completes
event.preventDefault();
if (isQuitCleanupInProgress) return;
isQuitCleanupInProgress = true;
try {
// NOTE: Profile sync removed - we now use app userData directly for all partitions
// No need to sync between different profile directories
// Clean up resources
disposeAllTerminals();
if (webViewManager) {
webViewManager.destroy();
webViewManager = null;
}
if (win && !win.isDestroyed()) {
persistBrowserWindowState(win);
win.destroy();
win = null;
}
// Wait for Python process cleanup
await cleanupPythonProcess();
await closeWorkspaceSecretBroker();
// Clean up file reader if exists
if (fileReader) {
fileReader = null;
}
// Clear any remaining timeouts/intervals
if (global.gc) {
global.gc();
}
// Reset protocol handling state
isWindowReady = false;
appShellReadinessGate.markDocumentLoading();
rendererAppCommands.markNotReady('app-exit');
protocolUrlQueue = [];
log.info('All cleanup completed, exiting...');
} catch (error) {
log.error('Error during cleanup:', error);
} finally {
// Force quit after cleanup
app.exit(0);
}
});