- Added telemetry utility to capture application events and metrics. - Integrated PostHog for event tracking with distinct user identification. - Implemented telemetry initialization, event capturing, and shutdown procedures. feat: add UV environment setup for Python management - Created utilities to manage Python installation and configuration. - Implemented network optimization checks for Python installation mirrors. - Added functions to set up managed Python environments with error handling. feat: enhance host API communication with token management - Introduced host API token retrieval and management for secure requests. - Updated host API fetch functions to include token in headers. - Added support for creating event sources with authentication. test: add comprehensive tests for gateway protocol and startup helpers - Implemented unit tests for gateway protocol helpers, event dispatching, and state management. - Added tests for startup recovery strategies and process policies. - Ensured coverage for connection monitoring and restart governance logic.
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
import type { Server } from 'node:http';
|
||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||
import { CONFIG_KEYS, IPC_EVENTS } from '@runtime/lib/constants'
|
||
import { setupMainWindow } from './wins';
|
||
import started from 'electron-squirrel-startup'
|
||
import configManager from '@electron/service/config-service'
|
||
import themeManager from '@electron/service/theme-service'
|
||
import { runTaskOperationService } from '@electron/process/runTaskOperationService'
|
||
import { initScriptStoreService } from '@electron/service/script-store-service'
|
||
import log from 'electron-log';
|
||
import 'bytenode'; // Ensure bytenode is bundled/externalized correctly
|
||
import { appUpdater } from '@electron/service/updater';
|
||
import axios from 'axios';
|
||
import { onProviderChange } from '@electron/service/provider-api-service';
|
||
import { gatewayManager } from '@electron/gateway/manager';
|
||
import { createHostApiContext, dispatchLocalHostApi } from '@electron/api/router';
|
||
import { hostEventBus } from '@electron/api/event-bus';
|
||
import { getHostApiBase, getHostApiToken, startHostApiServer } from '@electron/api/server';
|
||
import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync';
|
||
import { applyLaunchAtStartupSetting, syncLaunchAtStartupSettingFromConfig } from '@electron/service/launch-at-startup';
|
||
import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '@electron/utils/skill-config';
|
||
import { initTelemetry, shutdownTelemetry } from '@electron/utils/telemetry';
|
||
import { syncGatewayConfigBeforeLaunch } from '@electron/gateway/config-sync';
|
||
|
||
// 初始化 updater,确保在 app ready 之前或者之中注册好 IPC
|
||
appUpdater.init();
|
||
|
||
// 注册 hostapi:fetch IPC 代理
|
||
// 模型管理相关接口在本地处理(对齐 ClawX),其余接口代理到远端后端
|
||
const HOST_API_BASE_URL = process.env['ZN_AI_HOST_API_BASE_URL']
|
||
|| process.env['VITE_SERVICE_URL']
|
||
|| 'http://8.138.234.141/ingress';
|
||
const GATEWAY_QUIT_TIMEOUT_MS = 5_000;
|
||
|
||
let gatewayEventBridgeBound = false;
|
||
let gatewayQuitCleanupInProgress = false;
|
||
let gatewayQuitCleanupCompleted = false;
|
||
let hostApiServer: Server | null = null;
|
||
|
||
type HostApiProxyRequest = {
|
||
path: string;
|
||
method?: string;
|
||
headers?: Record<string, string>;
|
||
body?: unknown;
|
||
};
|
||
|
||
function refreshProviderRuntime(): { warnings: string[] } {
|
||
try {
|
||
return syncProviderRuntimeSnapshot();
|
||
} catch (error) {
|
||
log.error('provider runtime sync failed', error);
|
||
return {
|
||
warnings: [error instanceof Error ? error.message : String(error)],
|
||
};
|
||
}
|
||
}
|
||
|
||
async function requestUpstreamHostApi(path: string, method: string, headers: Record<string, string> | undefined, body: unknown) {
|
||
const url = `${HOST_API_BASE_URL}${path}`;
|
||
try {
|
||
const response = await axios({
|
||
url,
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...headers,
|
||
},
|
||
data: body ?? undefined,
|
||
timeout: 30000,
|
||
});
|
||
return {
|
||
success: true,
|
||
ok: true,
|
||
status: response.status,
|
||
json: response.data,
|
||
data: response.data,
|
||
};
|
||
} catch (error: any) {
|
||
if (error.response) {
|
||
return {
|
||
success: false,
|
||
ok: false,
|
||
status: error.response.status,
|
||
error: error.response.data?.message || error.message,
|
||
text: error.response.statusText,
|
||
data: error.response.data,
|
||
};
|
||
}
|
||
return {
|
||
success: false,
|
||
ok: false,
|
||
error: error.message || 'Unknown error',
|
||
};
|
||
}
|
||
}
|
||
|
||
async function closeHostApiServer(): Promise<void> {
|
||
if (!hostApiServer) {
|
||
return;
|
||
}
|
||
|
||
const server = hostApiServer;
|
||
hostApiServer = null;
|
||
|
||
await new Promise<void>((resolve) => {
|
||
server.close(() => resolve());
|
||
});
|
||
}
|
||
|
||
function normalizeProxyBody(body: unknown): string | undefined {
|
||
if (body == null) {
|
||
return undefined;
|
||
}
|
||
|
||
if (typeof body === 'string') {
|
||
return body;
|
||
}
|
||
|
||
return JSON.stringify(body);
|
||
}
|
||
|
||
async function proxyHostApiRequest(request: HostApiProxyRequest) {
|
||
const path = typeof request.path === 'string' ? request.path : '';
|
||
if (!path || !path.startsWith('/')) {
|
||
return {
|
||
success: false,
|
||
ok: false,
|
||
status: 400,
|
||
error: `Invalid host API path: ${String(request.path)}`,
|
||
};
|
||
}
|
||
|
||
const hostApiToken = getHostApiToken();
|
||
if (!hostApiServer || !hostApiToken) {
|
||
const localResult = await dispatchLocalHostApi(request);
|
||
if (localResult) {
|
||
return localResult;
|
||
}
|
||
|
||
return requestUpstreamHostApi(
|
||
path,
|
||
request.method || 'GET',
|
||
request.headers,
|
||
request.body,
|
||
);
|
||
}
|
||
|
||
const method = (request.method || 'GET').toUpperCase();
|
||
const headers: Record<string, string> = {
|
||
...(request.headers || {}),
|
||
'X-Host-Api-Token': hostApiToken,
|
||
};
|
||
const body = normalizeProxyBody(request.body);
|
||
|
||
if (body !== undefined && !headers['Content-Type'] && !headers['content-type']) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${getHostApiBase()}${path}`, {
|
||
method,
|
||
headers,
|
||
body,
|
||
});
|
||
const contentType = response.headers.get('content-type') || '';
|
||
|
||
if (contentType.includes('application/json')) {
|
||
return await response.json();
|
||
}
|
||
|
||
const text = await response.text();
|
||
return {
|
||
success: response.ok,
|
||
ok: response.ok,
|
||
status: response.status,
|
||
text,
|
||
...(response.ok ? {} : { error: text || response.statusText }),
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
success: false,
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
function emitGatewayRendererEvent(channel: string, payload: unknown): void {
|
||
BrowserWindow.getAllWindows().forEach((window) => {
|
||
if (!window.isDestroyed()) {
|
||
window.webContents.send(channel, payload);
|
||
}
|
||
});
|
||
}
|
||
|
||
function bindGatewayLifecycleEvents(): void {
|
||
if (gatewayEventBridgeBound) {
|
||
return;
|
||
}
|
||
|
||
gatewayEventBridgeBound = true;
|
||
|
||
gatewayManager.on('status', (status) => {
|
||
hostEventBus.emit('gateway:status', status);
|
||
emitGatewayRendererEvent('gateway:status-changed', status);
|
||
});
|
||
|
||
gatewayManager.on('message', (message) => {
|
||
hostEventBus.emit('gateway:message', message);
|
||
emitGatewayRendererEvent('gateway:message', message);
|
||
});
|
||
|
||
gatewayManager.on('notification', (notification) => {
|
||
hostEventBus.emit('gateway:notification', notification);
|
||
emitGatewayRendererEvent('gateway:notification', notification);
|
||
});
|
||
|
||
gatewayManager.on('channel:status', (data) => {
|
||
hostEventBus.emit('gateway:channel-status', data);
|
||
emitGatewayRendererEvent('gateway:channel-status', data);
|
||
});
|
||
|
||
gatewayManager.on('chat:message', (data) => {
|
||
hostEventBus.emit('gateway:chat-message', data);
|
||
emitGatewayRendererEvent('gateway:chat-message', data);
|
||
});
|
||
|
||
gatewayManager.on('exit', (code) => {
|
||
hostEventBus.emit('gateway:exit', { code });
|
||
emitGatewayRendererEvent('gateway:exit', code);
|
||
});
|
||
|
||
gatewayManager.on('error', (error) => {
|
||
hostEventBus.emit('gateway:error', { message: error.message });
|
||
emitGatewayRendererEvent('gateway:error', error.message);
|
||
});
|
||
}
|
||
|
||
function requestQuitOnSignal(signal: NodeJS.Signals): void {
|
||
log.info(`Received ${signal}; requesting app quit`);
|
||
app.quit();
|
||
}
|
||
|
||
function emergencyGatewayCleanup(reason: string, error: unknown): void {
|
||
log.error(`${reason}:`, error);
|
||
hostEventBus.closeAll();
|
||
void closeHostApiServer().catch(() => {
|
||
// ignore host API server close failures during emergency cleanup
|
||
});
|
||
try {
|
||
void gatewayManager.stop().catch(() => {
|
||
// ignore stop failures during emergency cleanup
|
||
});
|
||
} catch {
|
||
// ignore stop invocation failures if state is corrupted
|
||
}
|
||
|
||
setTimeout(() => {
|
||
void shutdownTelemetry().catch(() => {
|
||
// ignore telemetry flush failures during crash shutdown
|
||
});
|
||
void gatewayManager.forceTerminateOwnedProcessForQuit().catch(() => {
|
||
// ignore forced termination failures during crash shutdown
|
||
}).finally(() => {
|
||
process.exit(1);
|
||
});
|
||
}, 3_000).unref();
|
||
}
|
||
|
||
ipcMain.handle(IPC_EVENTS.HOST_API_TOKEN, async () => getHostApiToken());
|
||
|
||
ipcMain.handle(IPC_EVENTS.HOST_API_FETCH, async (_event, request: HostApiProxyRequest) => {
|
||
return proxyHostApiRequest({ ...request, method: request.method || 'GET' });
|
||
|
||
// 1. 优先本地处理 Host API 路由(逐步对齐 ClawX)
|
||
|
||
// 2. 其余接口代理到远端后端
|
||
});
|
||
|
||
// Gateway RPC IPC handler
|
||
ipcMain.handle(IPC_EVENTS.GATEWAY_RPC, async (_event, method: string, params: any) => {
|
||
return gatewayManager.rpc(method, params);
|
||
});
|
||
|
||
// import logManager from '@electron/service/logger'
|
||
|
||
|
||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||
if (started) {
|
||
app.quit();
|
||
}
|
||
|
||
// process.on('uncaughtException', (err) => {
|
||
// logManager.error('uncaughtException', err);
|
||
// });
|
||
|
||
// process.on('unhandledRejection', (reason, promise) => {
|
||
// logManager.error('unhandledRejection', reason, promise);
|
||
// });
|
||
|
||
process.once('SIGINT', () => requestQuitOnSignal('SIGINT'));
|
||
process.once('SIGTERM', () => requestQuitOnSignal('SIGTERM'));
|
||
process.on('uncaughtException', (error) => {
|
||
emergencyGatewayCleanup('Uncaught exception in main process', error);
|
||
});
|
||
process.on('unhandledRejection', (reason) => {
|
||
emergencyGatewayCleanup('Unhandled promise rejection in main process', reason);
|
||
});
|
||
|
||
app.on('before-quit', (event) => {
|
||
if (gatewayQuitCleanupCompleted) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
|
||
if (gatewayQuitCleanupInProgress) {
|
||
return;
|
||
}
|
||
|
||
gatewayQuitCleanupInProgress = true;
|
||
hostEventBus.closeAll();
|
||
const closeServerPromise = closeHostApiServer().catch((error) => {
|
||
log.warn('Host API server close failed during quit:', error);
|
||
});
|
||
|
||
const stopPromise = Promise.all([
|
||
closeServerPromise,
|
||
gatewayManager.stop(),
|
||
]).catch((error) => {
|
||
log.warn('gatewayManager.stop() error during quit:', error);
|
||
});
|
||
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
||
setTimeout(() => resolve('timeout'), GATEWAY_QUIT_TIMEOUT_MS);
|
||
});
|
||
|
||
void Promise.race([
|
||
stopPromise.then(() => 'stopped' as const),
|
||
timeoutPromise,
|
||
]).then(async (result) => {
|
||
if (result === 'timeout') {
|
||
log.warn('Gateway shutdown timed out during app quit; proceeding with forced quit');
|
||
try {
|
||
const terminated = await gatewayManager.forceTerminateOwnedProcessForQuit();
|
||
if (terminated) {
|
||
log.warn('Forced gateway process termination completed after quit timeout');
|
||
}
|
||
} catch (error) {
|
||
log.warn('Forced gateway termination failed after quit timeout:', error);
|
||
}
|
||
}
|
||
|
||
try {
|
||
await shutdownTelemetry();
|
||
} catch (error) {
|
||
log.warn('Telemetry shutdown failed during app quit:', error);
|
||
}
|
||
|
||
gatewayQuitCleanupCompleted = true;
|
||
app.quit();
|
||
}).catch((error) => {
|
||
gatewayQuitCleanupInProgress = false;
|
||
log.warn('Gateway quit cleanup failed:', error);
|
||
gatewayQuitCleanupCompleted = true;
|
||
app.quit();
|
||
});
|
||
});
|
||
|
||
app.whenReady().then(async () => {
|
||
await configManager.init();
|
||
await syncLaunchAtStartupSettingFromConfig();
|
||
await themeManager.init();
|
||
await initTelemetry();
|
||
bindGatewayLifecycleEvents();
|
||
|
||
try {
|
||
hostApiServer = startHostApiServer({
|
||
ctx: createHostApiContext(),
|
||
dispatchRequest: dispatchLocalHostApi,
|
||
fallbackRequest: async (request) => {
|
||
return requestUpstreamHostApi(
|
||
request.path,
|
||
request.method || 'GET',
|
||
request.headers,
|
||
request.body,
|
||
);
|
||
},
|
||
});
|
||
} catch (error) {
|
||
log.error('Failed to start Host API server:', error);
|
||
}
|
||
|
||
let launchAtStartup = Boolean(configManager.get(CONFIG_KEYS.LAUNCH_AT_STARTUP));
|
||
const stopLaunchAtStartupSync = configManager.onConfigChange((config) => {
|
||
const nextLaunchAtStartup = Boolean(config[CONFIG_KEYS.LAUNCH_AT_STARTUP]);
|
||
if (nextLaunchAtStartup === launchAtStartup) {
|
||
return;
|
||
}
|
||
|
||
launchAtStartup = nextLaunchAtStartup;
|
||
void applyLaunchAtStartupSetting(nextLaunchAtStartup);
|
||
});
|
||
|
||
app.once('will-quit', () => {
|
||
stopLaunchAtStartupSync();
|
||
void closeHostApiServer().catch(() => {
|
||
// ignore host API server close failures during final teardown
|
||
});
|
||
});
|
||
|
||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||
log.warn('Failed to install built-in skills:', error);
|
||
});
|
||
|
||
void ensurePreinstalledSkillsInstalled().catch((error) => {
|
||
log.warn('Failed to install preinstalled skills:', error);
|
||
});
|
||
|
||
try {
|
||
await syncGatewayConfigBeforeLaunch();
|
||
} catch (error) {
|
||
log.warn('Failed to sync OpenClaw config before launch:', error);
|
||
}
|
||
|
||
refreshProviderRuntime();
|
||
void gatewayManager.init().catch((error) => {
|
||
log.warn('Failed to initialize GatewayManager:', error);
|
||
});
|
||
|
||
onProviderChange(() => {
|
||
const runtimeSync = refreshProviderRuntime();
|
||
gatewayManager.reloadProviders({
|
||
topics: ['providers', 'models', 'agents'],
|
||
reason: 'providers:changed',
|
||
warnings: runtimeSync.warnings,
|
||
});
|
||
});
|
||
|
||
setupMainWindow();
|
||
|
||
// 初始化脚本存储服务
|
||
initScriptStoreService()
|
||
|
||
// 开启任务操作子进程
|
||
runTaskOperationService()
|
||
|
||
// 开启subagent子进程
|
||
});
|
||
|
||
// Quit when all windows are closed, except on macOS. There, it's common
|
||
// for applications and their menu bar to stay active until the user quits
|
||
// explicitly with Cmd + Q.
|
||
app.on('window-all-closed', () => {
|
||
if (process.platform !== 'darwin' && !configManager.get(CONFIG_KEYS.MINIMIZE_TO_TRAY)) {
|
||
log.info('app closing due to all windows being closed');
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
// On OS X it's common to re-create a window in the app when the
|
||
// dock icon is clicked and there are no other windows open.
|
||
app.on('activate', () => {
|
||
if (BrowserWindow.getAllWindows().length === 0) {
|
||
setupMainWindow();
|
||
}
|
||
});
|