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:
DEV_DSW
2026-04-23 17:21:57 +08:00
parent 655e7c51d2
commit 71bcc3b3c5
39 changed files with 5504 additions and 313 deletions

View File

@@ -2,10 +2,12 @@ import type { BrowserWindow } from 'electron';
import type { gatewayManager } from '@electron/gateway/manager';
import type { providerApiService } from '@electron/service/provider-api-service';
import type { ClawHubService } from '@electron/gateway/clawhub';
import type { hostEventBus } from './event-bus';
export interface HostApiContext {
gatewayManager: typeof gatewayManager;
providerApiService: typeof providerApiService;
mainWindow: BrowserWindow | null;
clawHubService: ClawHubService;
eventBus: typeof hostEventBus;
}

64
electron/api/event-bus.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { ServerResponse } from 'node:http';
type EventPayload = unknown;
type EventListener = (payload: EventPayload) => void;
export class HostEventBus {
private readonly listeners = new Map<string, Set<EventListener>>();
private readonly sseClients = new Set<ServerResponse>();
on(eventName: string, listener: EventListener): () => void {
const bucket = this.listeners.get(eventName) ?? new Set<EventListener>();
bucket.add(listener);
this.listeners.set(eventName, bucket);
return () => {
bucket.delete(listener);
if (bucket.size === 0) {
this.listeners.delete(eventName);
}
};
}
addSseClient(res: ServerResponse): void {
this.sseClients.add(res);
res.on('close', () => {
this.sseClients.delete(res);
});
}
emit(eventName: string, payload: EventPayload): void {
const bucket = this.listeners.get(eventName);
if (bucket) {
for (const listener of bucket) {
listener(payload);
}
}
if (this.sseClients.size > 0) {
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of this.sseClients) {
try {
client.write(message);
} catch {
this.sseClients.delete(client);
}
}
}
}
closeAll(): void {
this.listeners.clear();
for (const client of this.sseClients) {
try {
client.end();
} catch {
// Ignore individual client close failures.
}
}
this.sseClients.clear();
}
}
export const hostEventBus = new HostEventBus();

View File

@@ -1,4 +1,5 @@
import type { HostApiResult } from '@src/types/runtime';
import type { IncomingMessage, ServerResponse } from 'node:http';
export interface HostApiRequest {
path: string;
@@ -16,6 +17,20 @@ export interface NormalizedHostApiRequest {
url: URL;
}
export async function parseRawJsonBody<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) {
return {} as T;
}
return JSON.parse(raw) as T;
}
export function normalizeRequest(request: HostApiRequest): NormalizedHostApiRequest {
const path = String(request.path || '/').trim() || '/';
@@ -61,3 +76,34 @@ export function fail<T = unknown>(status: number, error: string, data?: T): Host
data,
};
}
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
} else {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Host-Api-Token');
}
export function requireJsonContentType(req: IncomingMessage): boolean {
if (req.method === 'GET' || req.method === 'OPTIONS' || req.method === 'HEAD') {
return true;
}
const contentLength = req.headers['content-length'];
if (contentLength === '0' || contentLength === undefined) {
return true;
}
const contentType = req.headers['content-type'] || '';
return contentType.includes('application/json');
}
export function sendJsonResponse(res: ServerResponse, statusCode: number, payload: unknown): void {
res.statusCode = statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}

View File

@@ -2,6 +2,7 @@ import { BrowserWindow } from 'electron';
import { gatewayManager } from '@electron/gateway/manager';
import { providerApiService } from '@electron/service/provider-api-service';
import { ClawHubService } from '@electron/gateway/clawhub';
import { hostEventBus } from './event-bus';
import type { HostApiContext } from './context';
import type { HostApiRequest } from './route-utils';
import { normalizeRequest } from './route-utils';
@@ -36,18 +37,19 @@ const routeHandlers: RouteHandler[] = [
handleSkillRoutes,
];
function createContext(): HostApiContext {
export function createHostApiContext(): HostApiContext {
return {
gatewayManager,
providerApiService,
mainWindow: BrowserWindow.getAllWindows()[0] ?? null,
clawHubService: new ClawHubService(),
eventBus: hostEventBus,
};
}
export async function dispatchLocalHostApi(request: HostApiRequest) {
const normalized = normalizeRequest(request);
const ctx = createContext();
const ctx = createHostApiContext();
for (const handler of routeHandlers) {
const result = await handler(normalized, ctx);

158
electron/api/server.ts Normal file
View File

@@ -0,0 +1,158 @@
import { randomBytes } from 'node:crypto';
import { createServer, type Server } from 'node:http';
import log from 'electron-log';
import type { HostApiContext } from './context';
import type { HostApiRequest } from './route-utils';
import {
parseRawJsonBody,
requireJsonContentType,
sendJsonResponse,
setCorsHeaders,
} from './route-utils';
const DEFAULT_HOST_API_PORT = 13210;
type StartHostApiServerOptions = {
ctx: HostApiContext;
dispatchRequest: (request: HostApiRequest) => Promise<unknown | null>;
fallbackRequest?: (request: HostApiRequest) => Promise<unknown>;
port?: number;
};
let hostApiToken = '';
export function getHostApiPort(): number {
const raw = process.env['ZN_AI_HOST_API_PORT'];
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HOST_API_PORT;
}
export function getHostApiToken(): string {
return hostApiToken;
}
export function getHostApiBase(): string {
return `http://127.0.0.1:${getHostApiPort()}`;
}
export function startHostApiServer(options: StartHostApiServerOptions): Server {
const port = options.port ?? getHostApiPort();
hostApiToken = randomBytes(32).toString('hex');
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
setCorsHeaders(res, req.headers.origin);
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
const bearerHeader = req.headers.authorization || '';
const bearerToken = bearerHeader.startsWith('Bearer ')
? bearerHeader.slice('Bearer '.length)
: '';
const token = (
req.headers['x-host-api-token']
|| requestUrl.searchParams.get('token')
|| bearerToken
);
if (token !== hostApiToken) {
sendJsonResponse(res, 401, {
success: false,
ok: false,
error: 'Unauthorized',
});
return;
}
if (requestUrl.pathname === '/api/events' && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write(': connected\n\n');
options.ctx.eventBus.addSseClient(res);
res.write(`event: gateway:status\ndata: ${JSON.stringify(options.ctx.gatewayManager.getStatus())}\n\n`);
return;
}
if (!requireJsonContentType(req)) {
sendJsonResponse(res, 415, {
success: false,
ok: false,
error: 'Content-Type must be application/json',
});
return;
}
const body = req.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)
? await parseRawJsonBody<unknown>(req)
: null;
const forwardedHeaders: Record<string, string> = {};
Object.entries(req.headers).forEach(([key, value]) => {
if (typeof value === 'string') {
forwardedHeaders[key] = value;
}
});
delete forwardedHeaders['x-host-api-token'];
const request: HostApiRequest = {
path: `${requestUrl.pathname}${requestUrl.search}`,
method: req.method,
headers: forwardedHeaders,
body,
};
const localResult = await options.dispatchRequest(request);
const response = localResult ?? (
options.fallbackRequest
? await options.fallbackRequest(request)
: null
);
if (response == null) {
sendJsonResponse(res, 404, {
success: false,
ok: false,
error: `No route for ${req.method} ${requestUrl.pathname}`,
});
return;
}
const result = response as {
status?: number;
data?: unknown;
json?: unknown;
success?: boolean;
ok?: boolean;
error?: string;
text?: string;
};
sendJsonResponse(res, result.status ?? 200, response);
} catch (error) {
sendJsonResponse(res, 500, {
success: false,
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
});
server.on('error', (error) => {
log.error('Host API server failed:', error);
});
server.on('close', () => {
hostApiToken = '';
});
server.listen(port, '127.0.0.1');
return server;
}