164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
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();
|
||
});
|