Files
qiming/qimingclaw/crates/agent-electron-client/src/main/main.ts
2026-06-09 11:06:25 +08:00

624 lines
19 KiB
TypeScript
Raw 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,
Menu,
dialog,
ipcMain,
Tray,
nativeImage,
session,
} from "electron";
import * as path from "path";
import log from "electron-log";
import { initDatabase, closeDb, readSetting } from "./db";
import { ManagedProcess } from "./processManager";
import { registerAllHandlers } from "./ipc/index";
import { unregisterEventForwarders } from "./ipc/eventForwarders";
import { runStartupTasks } from "./bootstrap/startup";
import { agentService } from "./services/engines/unifiedAgent";
import { stopComputerServer } from "./services/computerServer";
import { mcpProxyManager } from "./services/packages/mcp";
import { stopGuiAgentServer } from "./services/packages/guiAgentServer";
import { FEATURES } from "@shared/featureFlags";
import { stopWindowsMcp } from "./services/packages/windowsMcp";
import type { HandlerContext } from "@shared/types/ipc";
import { DEFAULT_DEV_SERVER_PORT } from "./services/constants";
import {
APP_DISPLAY_NAME,
CLEANUP_TIMEOUT,
DEFAULT_WINDOW_HEIGHT,
DEFAULT_WINDOW_MIN_HEIGHT,
DEFAULT_WINDOW_MIN_WIDTH,
DEFAULT_WINDOW_WIDTH,
} from "@shared/constants";
import { initLogging } from "./bootstrap/logConfig";
import { initI18n, setMainLang } from "./services/i18n";
import { createTrayManager, TrayStatus } from "./window/trayManager";
import { createServiceManager } from "./window/serviceManager";
import { initAutoUpdater } from "./services/autoUpdater";
import { migrateDataDir, migrateSettingsPaths } from "./bootstrap/migrate";
import { getDeviceId, logSystemInfo } from "./services/system/deviceId";
import { initWebviewPolicy } from "./services/system/webviewPolicy";
import { stopDigitalEmployeeSyncWorker } from "./services/digitalEmployee/syncService";
import { stopDigitalEmployeeScheduler } from "./services/digitalEmployee/schedulerService";
// macOS 26 Tahoe 兼容性:禁用 Fontations 字体后端
// 参考: https://github.com/electron/electron/issues/49522
if (process.platform === "darwin") {
app.commandLine.appendSwitch("disable-features", "FontationsFontBackend");
}
// Linux 沙箱处理
// 参考: https://github.com/electron/electron/issues/17972
// 参考: https://github.com/electron-userland/electron-builder/issues/8951
//
// 沙箱启用策略:
// 1. deb/rpm 包:通过 postinst 脚本设置 chrome-sandbox 的 SUID 权限
// 2. AppImage依赖 unprivileged user namespaces内核需要支持
// 3. 开发模式:禁用沙箱以方便调试
// 4. 用户可通过环境变量 ELECTRON_DISABLE_SANDBOX=1 强制禁用
//
// 注意: 此代码在 initLogging() 之前执行,所以使用 console 而不是 log
if (process.platform === "linux") {
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
const isAppImage = process.env.APPIMAGE !== undefined;
const disableSandbox = process.env.ELECTRON_DISABLE_SANDBOX === "1";
const isDev = !app.isPackaged;
// 警告: 以 root 身份运行存在安全风险
if (isRoot) {
console.warn("[Security] Running as root is not recommended.");
console.warn("[Security] This poses significant security risks.");
}
// AppImage 使用 namespace-based sandbox
if (isAppImage) {
console.info(
"[AppImage] Using namespace-based sandbox (requires kernel unprivileged user namespaces)",
);
}
if (disableSandbox) {
// 用户显式禁用沙箱
console.warn("[Security] Sandbox disabled by ELECTRON_DISABLE_SANDBOX=1");
app.commandLine.appendSwitch("no-sandbox");
app.commandLine.appendSwitch("disable-setuid-sandbox");
} else if (isDev) {
// 开发模式:禁用沙箱
console.info("[Dev] Sandbox disabled in development mode");
app.commandLine.appendSwitch("no-sandbox");
app.commandLine.appendSwitch("disable-setuid-sandbox");
} else {
// 生产模式:默认启用沙箱
console.info(
"[Production] Sandbox enabled (SUID for deb/rpm, namespace for AppImage)",
);
}
}
// 日志:轮转 + TTL 清理 + 开发/正式差异化(见 logConfig.ts
initLogging();
initI18n();
log.info("Application starting...");
log.info("[FeatureFlags][main]", FEATURES);
// Global references
let mainWindow: BrowserWindow | null = null;
let trayManager: ReturnType<typeof createTrayManager> | null = null;
let isQuitting = false; // 标志:是否正在真正退出应用
let isInstallingUpdate = false; // 标志:是否正在执行 quitAndInstall 安装更新
let pendingSecondInstanceFocus = false; // 标志:窗口未创建前收到 second-instance 事件
// 单实例保护Windows 托盘常驻场景下再次启动时,复用当前实例而不是创建新实例
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
log.warn("[App] Another instance is already running, quitting current one");
app.quit();
}
app.on("second-instance", () => {
log.info("[App] second-instance event received");
if (!mainWindow) {
pendingSecondInstanceFocus = true;
if (app.isReady()) {
createWindow();
}
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
// Get icon path (works in both dev and production)
function getIconPath() {
if (app.isPackaged) {
// Production: icons in app.asar (Resources)
if (process.platform === "darwin") {
return path.join(process.resourcesPath, "icon.icns");
}
return path.join(process.resourcesPath, "icon.png");
}
// Development: icons in project root
if (process.platform === "darwin") {
return path.join(process.cwd(), "public", "icon.icns");
}
return path.join(process.cwd(), "public", "icon.png");
}
// Get icon path for Dock (must be PNG - nativeImage cannot load .icns)
function getDockIconPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, "icon-dock.png");
}
return path.join(process.cwd(), "public", "icon-dock.png");
}
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const WEBVIEW_PERF_BRIDGE_PRELOAD = path.join(
__dirname,
"..",
"preload",
"webviewPerfBridge.js",
);
function shouldInjectWebviewPerfBridge(url: string): boolean {
try {
const parsed = new URL(url);
if (/^https?:$/.test(parsed.protocol)) return true;
return parsed.protocol === "file:" && parsed.pathname.includes("/sgrobot-digital/");
} catch {
return false;
}
}
// Managed child processes
const lanproxy = new ManagedProcess("lanproxy");
const fileServer = new ManagedProcess("fileServer");
const agentRunner = new ManagedProcess("agentRunner");
const guiServer = new ManagedProcess("gui-agent-server");
let agentRunnerPorts: { backendPort: number; proxyPort: number } | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: DEFAULT_WINDOW_WIDTH,
height: DEFAULT_WINDOW_HEIGHT,
minWidth: DEFAULT_WINDOW_MIN_WIDTH,
minHeight: DEFAULT_WINDOW_MIN_HEIGHT,
title: APP_DISPLAY_NAME,
icon: getIconPath(),
webPreferences: {
preload: path.join(__dirname, "..", "preload", "index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false, // Need to access node for MCP
webviewTag: true,
spellcheck: false, // 禁用拼写检查
},
show: false,
});
// 为 webview guest 注入轻量 BridgeQimingClawBridge
// 当前策略:对所有 http/https 页面注入;真正是否生效由 guest 侧路由+容器二次判断。
mainWindow.webContents.on(
"will-attach-webview",
(_event, webPreferences, params) => {
const targetUrl = String(params.src || "");
if (!shouldInjectWebviewPerfBridge(targetUrl)) {
return;
}
webPreferences.preload = WEBVIEW_PERF_BRIDGE_PRELOAD;
log.info("[WebviewBridge] Injected guest preload for:", targetUrl);
},
);
// Load the app
if (isDev) {
mainWindow.loadURL(`http://localhost:${DEFAULT_DEV_SERVER_PORT}`);
mainWindow.webContents.openDevTools();
} else {
// 生产环境dist 目录被打包到 app.asar 中
// 使用 file:// 协议直接加载 asar 内的文件
const indexUrl = `file://${process.resourcesPath}/app.asar/dist/index.html`;
log.info("Loading app from:", indexUrl);
mainWindow.loadURL(indexUrl);
}
// Handle load failures
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL) => {
log.error("Failed to load:", validatedURL, errorCode, errorDescription);
dialog.showErrorBox(
"Load Error",
`Failed to load application: ${errorDescription}\n\nURL: ${validatedURL}`,
);
},
);
mainWindow.once("ready-to-show", () => {
mainWindow?.show();
log.info("Main window shown");
// macOS 开发模式:窗口显示后再创建托盘,提高菜单栏图标出现概率
if (process.platform === "darwin" && !app.isPackaged && !trayManager) {
setTimeout(
() =>
initTrayManager().catch((e) =>
log.warn("[Tray] Delayed init failed:", e),
),
300,
);
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
// 所有平台:点击关闭按钮时隐藏到托盘,而不是退出应用
// 只有从托盘菜单点击"退出"时才真正退出
mainWindow.on("close", (e) => {
if (isQuitting) {
// 正在退出,允许关闭
return;
}
// 阻止关闭,改为隐藏
e.preventDefault();
mainWindow?.hide();
log.info("[App] Window hidden to tray (close intercepted)");
});
// Create application menu
createMenu();
}
function createMenu() {
if (process.platform === "darwin") {
// macOS: 保留最小菜单,确保 Cmd+C/V/Q 等快捷键正常
const template: Electron.MenuItemConstructorOptions[] = [
{ role: "appMenu" },
{ role: "editMenu" },
{ role: "windowMenu" },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
} else {
// Windows/Linux: 去掉菜单栏,功能由界面和系统托盘提供
Menu.setApplicationMenu(null);
}
}
async function initTrayManager() {
// 创建服务管理器
const serviceManager = createServiceManager({
lanproxy,
fileServer,
agentRunner,
});
trayManager = createTrayManager({
onShowWindow: () => {
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
} else {
// 窗口不存在时重新创建
createWindow();
}
},
onRestartServices: async () => {
log.info("[Tray] Restarting all services...");
await serviceManager.restartAllServices();
trayManager?.updateServicesStatus(true);
},
onStopServices: async () => {
log.info("[Tray] Stopping all services...");
await serviceManager.stopAllServices();
trayManager?.updateServicesStatus(false);
log.info("[Tray] All services stopped");
},
});
await trayManager.create();
log.info("[Tray] TrayManager initialized");
}
// IPC handler for tray status updates from renderer
ipcMain.handle("tray:updateStatus", (_, status: TrayStatus) => {
if (trayManager) {
trayManager.setStatus(status);
}
});
ipcMain.handle("tray:updateServicesStatus", (_, running: boolean) => {
if (trayManager) {
trayManager.updateServicesStatus(running);
}
});
async function cleanupAllProcesses(): Promise<void> {
log.info("[Cleanup] Stopping all processes...");
const stepTimeoutMs = Math.max(1500, Math.floor(CLEANUP_TIMEOUT / 6));
const runCleanupStep = async (
label: string,
fn: () => Promise<void> | void,
): Promise<void> => {
let completed = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const task = (async () => {
try {
await fn();
} catch (e) {
log.error(`[Cleanup] ${label} error:`, e);
} finally {
completed = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
})();
await Promise.race([
task,
new Promise<void>((resolve) => {
timeoutHandle = setTimeout(() => {
if (!completed) {
log.warn(`[Cleanup] ${label} timed out after ${stepTimeoutMs}ms`);
}
timeoutHandle = null;
resolve();
}, stepTimeoutMs);
}),
]);
};
await runCleanupStep("Digital employee scheduler stop", () => {
stopDigitalEmployeeScheduler();
});
await runCleanupStep("Computer server stop", async () => {
await stopComputerServer();
});
await runCleanupStep("Event forwarders unregister", () => {
unregisterEventForwarders();
});
await runCleanupStep("Agent service destroy", async () => {
await agentService.destroy();
});
await runCleanupStep("MCP proxy cleanup", async () => {
await mcpProxyManager.cleanup();
});
// Windowswindows-mcpuv/python由独立 ManagedProcess 管理
await runCleanupStep("Windows MCP stop", async () => {
await stopWindowsMcp();
});
// 非 Windowsagent-gui-server 进程
if (FEATURES.ENABLE_GUI_AGENT_SERVER) {
await runCleanupStep("GUI Agent server stop", async () => {
await stopGuiAgentServer();
});
}
await runCleanupStep("Engine processes stop", () => {
const { stopAllEngines } = require("./services/engines/engineManager");
stopAllEngines();
log.info("[Cleanup] Engine processes stopped");
});
await runCleanupStep("Process registry killAll", async () => {
const { processRegistry } = require("./services/system/processRegistry");
await processRegistry.killAll();
log.info("[Cleanup] Process registry cleared");
});
// Last-resort force kill for legacy managed processes.
// NOTE: guiServer is a legacy placeholder and typically not started directly.
agentRunner.kill();
lanproxy.kill();
fileServer.kill();
guiServer.kill();
log.info("[Cleanup] All processes stopped");
}
// App lifecycle
app.whenReady().then(async () => {
if (!hasSingleInstanceLock) {
return;
}
log.info("App ready");
logSystemInfo();
// Dev mode: fix CORS duplicate header issue
// Server returns both specific origin and '*', causing browser to reject.
// Strip duplicate Access-Control-Allow-Origin values.
if (isDev) {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const headers = details.responseHeaders;
if (headers) {
const acoKey = Object.keys(headers).find(
(k) => k.toLowerCase() === "access-control-allow-origin",
);
if (acoKey && headers[acoKey] && headers[acoKey].length > 1) {
// Keep only the specific origin (not '*')
const specific = headers[acoKey].find((v) => v !== "*");
headers[acoKey] = [specific || "*"];
// 仅在确实修改了 ACO 时才回传 responseHeaders
// 避免无条件替换导致 Set-Cookie 被 Chromium 网络服务丢弃
callback({ responseHeaders: headers });
return;
}
}
// 未修改任何 header → 不传 responseHeadersChromium 原样传递
callback({});
});
log.info("Dev CORS fix enabled");
}
// Set Dock icon on macOS (development mode needs this)
if (process.platform === "darwin" && app.dock) {
const iconPath = getDockIconPath();
log.info("Setting Dock icon from:", iconPath);
try {
const iconImage = nativeImage.createFromPath(iconPath);
log.info(
"Icon image size:",
iconImage.getSize(),
"isEmpty:",
iconImage.isEmpty(),
);
if (!iconImage.isEmpty()) {
app.dock.setIcon(iconImage);
log.info("Dock icon set successfully");
} else {
log.warn("Icon image is empty");
}
} catch (e) {
log.warn("Failed to set Dock icon:", e);
}
}
migrateDataDir();
initDatabase();
migrateSettingsPaths();
getDeviceId();
// 数据库就绪后,同步语言到主进程 i18n
// 优先级:本地保存 > Electron 系统语言 > 英文兜底
const savedLang = readSetting("i18n.active_lang") as string | undefined;
if (savedLang) {
setMainLang(savedLang);
} else {
// 无本地偏好:用 Electron 系统语言app.ready 后可靠)
setMainLang(app.getLocale() || "en");
}
const ctx: HandlerContext = {
getMainWindow: () => mainWindow,
lanproxy,
fileServer,
agentRunner,
guiServer,
get agentRunnerPorts() {
return agentRunnerPorts;
},
setAgentRunnerPorts: (ports) => {
agentRunnerPorts = ports;
},
};
registerAllHandlers(ctx);
await runStartupTasks();
createWindow();
if (pendingSecondInstanceFocus && mainWindow) {
pendingSecondInstanceFocus = false;
mainWindow.show();
mainWindow.focus();
}
initWebviewPolicy(() => mainWindow);
// 非 macOS 或已打包立即创建托盘。macOS 开发模式改为在 ready-to-show 后创建
if (!(process.platform === "darwin" && !app.isPackaged)) {
if (process.platform === "darwin" && app.dock) app.dock.show();
await initTrayManager();
}
initAutoUpdater(
() => mainWindow,
cleanupAllProcesses,
() => {
// 在 quitAndInstall 前被调用:
// - isQuitting=true 防止窗口 close 事件被拦截到托盘
// - isInstallingUpdate=true 让 before-quit 跳过 e.preventDefault()
// 保留 Squirrel.Mac 的正常退出流程
isQuitting = true;
isInstallingUpdate = true;
log.info(
"[App] Update install flagged: isQuitting=true, isInstallingUpdate=true",
);
},
);
});
app.on("window-all-closed", () => {
// 窗口已隐藏到托盘,此事件不应触发
// 如果触发,说明窗口被意外关闭,不退出应用
log.info(
"[App] window-all-closed event fired (should not happen with tray mode)",
);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
let isCleaningUp = false;
app.on("before-quit", (e) => {
if (isCleaningUp) return;
isCleaningUp = true;
isQuitting = true; // 通知窗口 close 事件允许关闭
if (isInstallingUpdate) {
// quitAndInstall 场景macOS Squirrel.Mac / Windows NSIS / Linux AppImage 均走此路径):
// - cleanup 已在 installUpdate() 中先行触发,无需重复执行
// - 不能调用 e.preventDefault():各平台安装器依赖 app.quit() 的正常退出流程;
// 若阻止退出再 app.exit(0),安装器可能已经失去对退出时机的感知,导致安装失败
// 只关闭数据库后直接 return让 Electron 正常完成退出,安装器接管
log.info(
"[App] Before quit - update install in progress, skipping preventDefault to allow installer",
);
stopDigitalEmployeeSyncWorker();
closeDb();
return;
}
// 普通退出流程:阻止立即退出,异步清理完成后再调用 app.exit(0)
e.preventDefault();
log.info("[App] Before quit - starting cleanup");
void (async () => {
const start = Date.now();
try {
stopDigitalEmployeeSyncWorker();
await cleanupAllProcesses();
} finally {
const elapsed = Date.now() - start;
if (elapsed > CLEANUP_TIMEOUT) {
log.warn(
`[App] Cleanup exceeded budget (${elapsed}ms > ${CLEANUP_TIMEOUT}ms), forcing exit`,
);
}
closeDb();
log.info("[App] Cleanup complete, exiting");
app.exit(0);
}
})();
});
app.on("will-quit", () => {
log.info("[App] Will quit");
});
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
log.error("Uncaught exception:", error);
});
process.on("unhandledRejection", (reason) => {
log.error("Unhandled rejection:", reason);
});