mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Electron main - utility-process channel: sherpa output copied into V8 buffers (Electron rejects external buffers), audio exchanged as base64; worker restart on timeout, identity-safe exit handling, deterministic native unload by restart - webhook: loopback by default without secret, UTF-8-safe body assembly, clean restart, port validation - files IPC: realpath-based root check, openPath allow-list (reveal-only outside document folders), Windows reserved names; store: debounced async atomic writes with backup of corrupt files; logger: streaming writes with rotation; log message size cap - HTTP stream proxy: socket released on idle timeout / renderer destroyed, id validation - model manager: inactivity timeout, retrying rm/rename (Windows locks), engine stopped before replacing a model; WAV decoder handles float/24-bit; IPC payload validation - window: opaque rounded window on Windows, navigation lock-down, visibility events; Ctrl+Alt+Escape instead of the Task Manager shortcut; single-instance guard; EVEFLOW_USER_DATA Voice pipeline - abort semantics (SendHandle.aborted), abort before the stream opens, session id prefixes per transport - hands-free re-arm after replies without speech, start/stop race, no chime on auto re-arm, no silence shipped to STT (400 ms pre-roll), no transcription of empty manual stops - TTS: bounded prefetch, cancellable segments, non-interrupting notices, volume applied at play time - SSE CRLF split, usage in chat completions, finish_reason length, phonetic regex hoisted HUD - core renderer: no canvas shadows, cached colours, reusable spectrum buffer, theme read on change, 30 fps idle, stops when the window is hidden; ping flashes on send / tool / speech - deltas coalesced per animation frame; stable auto-scroll; narrow selectors everywhere - bundled fonts (offline), reduce-motion fix, error toasts, ops drawer below 1180 px, interim transcript and first-token latency in the core caption, Ctrl+K, dialog semantics, switch/aria roles, compact widget cleanup, Whisper small recommended for French - docs/ROADMAP.md: audit results, e2e results and the plan towards a real JARVIS Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
/**
|
|
* Local webhook server: Hermes (cron deliveries, gateway mirrors, scripts) pushes JSON here
|
|
* and EveFlow displays / speaks the result. POST /eveflow/hook, GET /health.
|
|
*/
|
|
import http from 'node:http';
|
|
import { timingSafeEqual } from 'node:crypto';
|
|
import type { BrowserWindow } from 'electron';
|
|
import { IPC, type WebhookStatus } from '../shared/ipc';
|
|
import { normalizeHermesPush } from '../shared/hermesPush';
|
|
import { log } from './logger';
|
|
|
|
export const WEBHOOK_PATH = '/eveflow/hook';
|
|
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
|
|
export interface WebhookOptions {
|
|
port: number;
|
|
secret?: string;
|
|
host?: string;
|
|
}
|
|
|
|
let server: http.Server | null = null;
|
|
let status: WebhookStatus = { listening: false, port: 7842, path: WEBHOOK_PATH, secretConfigured: false };
|
|
|
|
function secretMatches(provided: string | undefined, expected: string): boolean {
|
|
if (!provided) return false;
|
|
const a = Buffer.from(provided);
|
|
const b = Buffer.from(expected);
|
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
}
|
|
|
|
export function getWebhookStatus(): WebhookStatus {
|
|
return status;
|
|
}
|
|
|
|
export function stopWebhookServer(): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
if (!server) return resolve();
|
|
const current = server;
|
|
server = null;
|
|
status = { ...status, listening: false };
|
|
current.closeAllConnections();
|
|
current.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
export async function startWebhookServer(getWindow: () => BrowserWindow | null, options: WebhookOptions): Promise<WebhookStatus> {
|
|
await stopWebhookServer();
|
|
const { port, secret } = options;
|
|
// Without a shared secret the hook only listens on loopback: anyone on the LAN could otherwise inject messages.
|
|
const host = options.host ?? (secret ? '0.0.0.0' : '127.0.0.1');
|
|
if (host !== '127.0.0.1' && !secret) log('WARN', 'webhook', `listening on ${host} without a secret`);
|
|
|
|
server = http.createServer((req, res) => {
|
|
const json = (code: number, payload: unknown) => {
|
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(payload));
|
|
};
|
|
|
|
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
|
|
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH });
|
|
}
|
|
if (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH)) {
|
|
return json(404, { error: 'Not Found' });
|
|
}
|
|
|
|
if (secret) {
|
|
const auth = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
const custom = req.headers['x-eveflow-secret'];
|
|
const provided = typeof custom === 'string' ? custom : auth;
|
|
if (!secretMatches(provided, secret)) {
|
|
log('WARN', 'webhook', `rejected push from ${req.socket.remoteAddress}: bad secret`);
|
|
return json(401, { error: 'Unauthorized' });
|
|
}
|
|
}
|
|
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
let tooLarge = false;
|
|
req.on('data', (chunk: Buffer) => {
|
|
if (tooLarge) return;
|
|
bytes += chunk.length;
|
|
if (bytes > MAX_BODY_BYTES) {
|
|
tooLarge = true;
|
|
res.writeHead(413, { 'Content-Type': 'application/json', Connection: 'close' });
|
|
res.end(JSON.stringify({ error: 'Payload too large' }), () => req.socket.destroy());
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => {
|
|
if (tooLarge) return;
|
|
// Concatenate before decoding so multibyte UTF-8 (accents, emoji) split across chunks stays intact.
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
try {
|
|
const raw: unknown = body.trim() ? JSON.parse(body) : {};
|
|
const events = normalizeHermesPush(raw);
|
|
log('INFO', 'webhook', `${events.length} event(s) from ${events[0]?.source ?? '?'} (${req.socket.remoteAddress})`);
|
|
const win = getWindow();
|
|
if (win && !win.isDestroyed()) {
|
|
for (const event of events) win.webContents.send(IPC.hermesPush, event);
|
|
}
|
|
json(200, { ok: true, events: events.length });
|
|
} catch (err) {
|
|
log('WARN', 'webhook', `invalid JSON payload: ${(err as Error).message}`);
|
|
json(400, { error: 'Invalid JSON' });
|
|
}
|
|
});
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
const instance = server!;
|
|
instance.on('error', (err: NodeJS.ErrnoException) => {
|
|
log('ERROR', 'webhook', `server error: ${err.message}`);
|
|
status = { listening: false, port, path: WEBHOOK_PATH, secretConfigured: !!secret, error: err.message };
|
|
if (server === instance) server = null;
|
|
resolve(status);
|
|
});
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
instance.emit('error', new Error(`port invalide : ${port}`));
|
|
return;
|
|
}
|
|
instance.listen(port, host, () => {
|
|
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
|
|
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
|
|
resolve(status);
|
|
});
|
|
});
|
|
}
|