assistant/electron/main.ts

164 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});