// ========= 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 | 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 | 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(); 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 { 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(); 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 { 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 { 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 | 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 { 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 { 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 { 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 { // 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((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; // 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(` Login Successful

Login Successful

You can close this tab and return to Eigent.

`); 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[]> = { 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(); 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//[project_/]task_/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 = {}; 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 = { 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 = {}; 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 { 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 { 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 { 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 { await fsp.writeFile( path.join(dstDir, EXAMPLE_SKILL_MARKER), `source=${sourceDirName}\n`, 'utf-8' ); } async function listRegularFiles( root: string, ignoredNames = new Set() ): Promise> { const files = new Map(); 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 { 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 { 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 { 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 = 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 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((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 { 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 => { 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((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 = { '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); } });