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.
This commit is contained in:
297
electron/main.ts
297
electron/main.ts
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -12,10 +13,13 @@ 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 { dispatchLocalHostApi } from '@electron/api/router';
|
||||
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
|
||||
@@ -26,6 +30,19 @@ appUpdater.init();
|
||||
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 {
|
||||
@@ -54,6 +71,7 @@ async function requestUpstreamHostApi(path: string, method: string, headers: Rec
|
||||
return {
|
||||
success: true,
|
||||
ok: true,
|
||||
status: response.status,
|
||||
json: response.data,
|
||||
data: response.data,
|
||||
};
|
||||
@@ -76,20 +94,187 @@ async function requestUpstreamHostApi(path: string, method: string, headers: Rec
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(IPC_EVENTS.HOST_API_FETCH, async (_event, { path, method, headers, body }) => {
|
||||
const normalizedMethod = method || 'GET';
|
||||
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)
|
||||
const localResult = await dispatchLocalHostApi({
|
||||
path,
|
||||
method: normalizedMethod,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
if (localResult) return localResult;
|
||||
|
||||
// 2. 其余接口代理到远端后端
|
||||
return await requestUpstreamHostApi(path, normalizedMethod, headers, body);
|
||||
});
|
||||
|
||||
// Gateway RPC IPC handler
|
||||
@@ -113,10 +298,97 @@ if (started) {
|
||||
// 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) => {
|
||||
@@ -131,6 +403,9 @@ app.whenReady().then(async () => {
|
||||
|
||||
app.once('will-quit', () => {
|
||||
stopLaunchAtStartupSync();
|
||||
void closeHostApiServer().catch(() => {
|
||||
// ignore host API server close failures during final teardown
|
||||
});
|
||||
});
|
||||
|
||||
void ensureBuiltinSkillsInstalled().catch((error) => {
|
||||
|
||||
Reference in New Issue
Block a user