import { app, BrowserWindow, ipcMain, nativeImage, net, protocol, shell } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { BackendManager, devBackendLaunch, packagedBackendLaunch } from './backend'; import { BrowserHost } from './browser-host'; import { startControlServer } from './control-server'; import { readPorts } from './load-env'; import type { AppMeta, ViewMode, ViewportMessage } from './shared'; const ROOT = path.join(__dirname, '..'); const RENDERER_DIR = path.join(__dirname, 'renderer'); const UI_URL = 'app://ui/index.html'; const APP_VERSION = app.getVersion(); const APP_NAME = '橘子拓客'; const APP_ICON = path.join(__dirname, 'brand', 'app-icon.png'); // Display branding must not create a fresh profile/configuration directory. // Keep the existing application identity and storage location across renames. const USER_DATA = path.join(app.getPath('appData'), 'ai-prospecting-assistant'); fs.mkdirSync(USER_DATA, { recursive: true }); app.setPath('userData', USER_DATA); app.setName(APP_NAME); // 可写目录:源码模式就是仓库根(.env / data / profiles 都在这);打包后是 userData, // 第一次运行把 resources/.env.example 复制成 .env 让部署方填 Key、数据库、SCHOOL_ID const HOME = app.isPackaged ? app.getPath('userData') : ROOT; function ensureHome(): void { fs.mkdirSync(HOME, { recursive: true }); const envFile = path.join(HOME, '.env'); if (app.isPackaged && !fs.existsSync(envFile)) { // 模板由 scripts/build-desktop.mjs 复制成 dist/env.example 随 asar 打包(extraResources 会漏掉点开头的文件) const template = path.join(__dirname, 'env.example'); if (fs.existsSync(template)) fs.copyFileSync(template, envFile); } } ensureHome(); const ports = readPorts(HOME); const backend = new BackendManager( app.isPackaged ? packagedBackendLaunch(process.resourcesPath, HOME) : devBackendLaunch(ROOT), ports.apiPort, ); let mainWindow: BrowserWindow | null = null; let host: BrowserHost | null = null; // 界面页走自定义协议 app://ui/…:file:// 没有主机名,会被 browser-use 的安全看门狗当成非法标签页关掉 protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } }, ]); // browser-use 通过这个端口用 CDP 接管内嵌的 WebContentsView app.commandLine.appendSwitch('remote-debugging-port', String(ports.cdpPort)); app.commandLine.appendSwitch('remote-allow-origins', '*'); function serveRenderer(): void { protocol.handle('app', request => { const url = new URL(request.url); const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, ''); const resolved = path.normalize(path.join(RENDERER_DIR, relative)); if (!resolved.startsWith(RENDERER_DIR)) return new Response('forbidden', { status: 403 }); return net.fetch(pathToFileURL(resolved).toString()); }); } function emitToRenderer(channel: string, payload: unknown): void { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, payload); } function createWindow(): BrowserWindow { const win = new BrowserWindow({ width: 1360, height: 860, minWidth: 1080, minHeight: 680, title: APP_NAME, icon: APP_ICON, backgroundColor: '#eef2f7', // Keep the native traffic lights and window behaviors. The renderer paints // a matching draggable rail and left-aligned title only on macOS. ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset' as const, trafficLightPosition: { x: 16, y: 14 }, } : {}), webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false, devTools: !app.isPackaged, }, }); win.setMenuBarVisibility(false); win.webContents.setWindowOpenHandler(({ url }) => { try { const target = new URL(url); if (target.protocol === 'https:' && target.hostname === 'www.xiaohongshu.com' && !target.username && !target.password) { void shell.openExternal(target.href); } } catch { /* 无效来源链接保持关闭 */ } return { action: 'deny' }; }); win.webContents.on('will-navigate', (event, url) => { if (url !== UI_URL) event.preventDefault(); }); void win.loadURL(UI_URL); if (process.argv.includes('--devtools')) win.webContents.openDevTools({ mode: 'detach' }); return win; } function registerIpc(): void { ipcMain.handle('api:get', (_event, pathname: string) => backend.apiFetch(pathname)); ipcMain.handle('api:post', (_event, pathname: string, body: unknown) => backend.apiFetch(pathname, { method: 'POST', body })); ipcMain.handle('api:put', (_event, pathname: string, body: unknown) => backend.apiFetch(pathname, { method: 'PUT', body })); ipcMain.handle('api:delete', (_event, pathname: string) => backend.apiFetch(pathname, { method: 'DELETE' })); ipcMain.handle('shell:openPath', (_event, filePath: string) => shell.openPath(filePath)); ipcMain.handle('app:meta', (): AppMeta => ({ apiPort: ports.apiPort, cdpPort: ports.cdpPort, controlPort: ports.controlPort, version: APP_VERSION, })); ipcMain.handle('browser:viewport', (_event, message: ViewportMessage) => { host?.setViewport(message); }); ipcMain.handle('browser:mode', (_event, payload: { taskId: string; mode: ViewMode }) => { host?.setMode(payload.taskId, payload.mode); }); } app.whenReady().then(async () => { const icon = nativeImage.createFromPath(APP_ICON); if (!icon.isEmpty()) { app.dock?.setIcon(icon); app.setAboutPanelOptions({ applicationName: APP_NAME, applicationVersion: APP_VERSION, iconPath: APP_ICON }); } serveRenderer(); registerIpc(); mainWindow = createWindow(); host = new BrowserHost(mainWindow, emitToRenderer); const control = startControlServer(ports.controlPort, ports.cdpPort, host); await backend.stopStale(); backend.start({ CDP_URL: `http://127.0.0.1:${ports.cdpPort}`, ELECTRON_CTRL_URL: `http://127.0.0.1:${ports.controlPort}`, }); try { const info = await backend.waitReady(); console.log(`[electron] 后端就绪 pid ${String(info.pid)}`); } catch (error) { console.warn('Backend slow to start:', (error as Error).message); } mainWindow.on('closed', () => { host?.disposeAll(); control.close(); mainWindow = null; }); }).catch(error => { console.error('[electron] 启动失败', error); app.quit(); }); app.on('before-quit', () => backend.stop()); app.on('window-all-closed', () => { backend.stop(); app.quit(); });