Files
zn-ai/electron/main.ts
DEV_DSW 71bcc3b3c5 feat: implement telemetry system for application usage tracking
- 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.
2026-04-23 17:21:57 +08:00

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