624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
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 注入轻量 Bridge(QimingClawBridge)。
|
||
// 当前策略:对所有 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();
|
||
});
|
||
|
||
// Windows:windows-mcp(uv/python)由独立 ManagedProcess 管理
|
||
await runCleanupStep("Windows MCP stop", async () => {
|
||
await stopWindowsMcp();
|
||
});
|
||
|
||
// 非 Windows:agent-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 → 不传 responseHeaders,Chromium 原样传递
|
||
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);
|
||
});
|