mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
fix: full review pass (main process, voice pipeline, Hermes client, HUD) and e2e-validated local voice (v2.1.0.1)
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
This commit is contained in:
55 files changed
+1181
-433
No files matched your search
+70
-14
@@ -22,12 +22,25 @@ const READABLE_EXT: Record<string, string> = {
|
||||
'.html': 'text/html'
|
||||
};
|
||||
|
||||
const FORBIDDEN_EXT = new Set([
|
||||
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh',
|
||||
'.com', '.hta', '.vbe', '.wsf', '.lnk', '.jar', '.dll', '.js', '.jse'
|
||||
/** Documents and media the assistant may open with the default application. */
|
||||
const OPENABLE_EXT = new Set([
|
||||
...Object.keys(READABLE_EXT),
|
||||
'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.odt', '.ods', '.odp', '.rtf',
|
||||
'.mp3', '.wav', '.ogg', '.flac', '.mp4', '.mkv', '.webm', '.zip', '.7z', '.py', '.ts', '.tsx', '.yaml', '.yml', '.toml', '.ini'
|
||||
]);
|
||||
|
||||
const FORBIDDEN_EXT = new Set([
|
||||
'.exe', '.bat', '.cmd', '.ps1', '.vbs', '.msi', '.scr', '.pif', '.reg', '.sh', '.com', '.hta', '.vbe', '.wsf', '.lnk',
|
||||
'.jar', '.dll', '.js', '.jse', '.pyw', '.msc', '.cpl', '.inf', '.url', '.scf', '.website', '.appref-ms', '.application',
|
||||
'.gadget', '.psm1', '.psd1', '.ps1xml', '.wsh', '.wsc', '.sct', '.desktop', '.app', '.command', '.appimage', '.run'
|
||||
]);
|
||||
|
||||
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\..*)?$/i;
|
||||
|
||||
let sharedDir: string | null = null;
|
||||
|
||||
export function getSharedDirectory(): string {
|
||||
if (sharedDir && fs.existsSync(sharedDir)) return sharedDir;
|
||||
const candidates = [
|
||||
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
|
||||
path.join(app.getPath('userData'), 'shared')
|
||||
@@ -35,32 +48,60 @@ export function getSharedDirectory(): string {
|
||||
for (const dir of candidates) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
sharedDir = dir;
|
||||
return dir;
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
return app.getPath('temp');
|
||||
sharedDir = app.getPath('temp');
|
||||
return sharedDir;
|
||||
}
|
||||
|
||||
function normalizeSafe(filePath: string): string {
|
||||
if (!filePath || typeof filePath !== 'string') throw new Error('Chemin invalide');
|
||||
if (!filePath || typeof filePath !== 'string' || filePath.length > 4096) throw new Error('Chemin invalide');
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function allowedRoots(): string[] {
|
||||
return [
|
||||
getSharedDirectory(),
|
||||
path.join(os.homedir(), 'Documents'),
|
||||
path.join(os.homedir(), 'Pictures'),
|
||||
path.join(os.homedir(), 'Downloads'),
|
||||
path.join(os.homedir(), 'Desktop'),
|
||||
app.getPath('temp')
|
||||
].map((r) => path.resolve(r));
|
||||
}
|
||||
|
||||
function isInside(root: string, target: string): boolean {
|
||||
const rel = path.relative(root, target);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
/** Resolves symlinks and checks the real path against the allowed roots (prefix tricks like "bob-backup" are rejected). */
|
||||
function isInsideAllowedRoots(target: string): boolean {
|
||||
const roots = [getSharedDirectory(), os.homedir(), app.getPath('temp')];
|
||||
const resolved = path.resolve(target);
|
||||
return roots.some((root) => resolved.startsWith(path.resolve(root)));
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = fs.realpathSync.native(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return allowedRoots().some((root) => isInside(root, resolved));
|
||||
}
|
||||
|
||||
function stripFileScheme(value: string): string {
|
||||
let out = value.replace(/^file:\/\//, '');
|
||||
if (!/^file:\/\//i.test(value)) return value;
|
||||
let out = value.replace(/^file:\/\//i, '');
|
||||
// file:///C:/x -> /C:/x on Windows-style paths
|
||||
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
|
||||
return decodeURIComponent(out);
|
||||
try {
|
||||
return decodeURIComponent(out);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerFilesIpc(): void {
|
||||
@@ -78,8 +119,16 @@ export function registerFilesIpc(): void {
|
||||
|
||||
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
|
||||
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
|
||||
if (typeof content !== 'string' || content.length > 50 * 1024 * 1024) throw new Error('Contenu invalide');
|
||||
const safeName = path.basename(filename);
|
||||
if (safeName !== filename || safeName.startsWith('.') || /[<>:"|?*]/.test(safeName)) {
|
||||
if (
|
||||
safeName !== filename ||
|
||||
safeName.startsWith('.') ||
|
||||
/[<>:"|?*\u0000-\u001f]/.test(safeName) ||
|
||||
WIN_RESERVED.test(safeName) ||
|
||||
/[. ]$/.test(safeName) ||
|
||||
safeName.length > 200
|
||||
) {
|
||||
throw new Error('Nom de fichier non securise');
|
||||
}
|
||||
const ext = path.extname(safeName).toLowerCase();
|
||||
@@ -97,10 +146,17 @@ export function registerFilesIpc(): void {
|
||||
|
||||
ipcMain.handle(IPC.openPath, async (_e, filePath: string) => {
|
||||
const target = normalizeSafe(stripFileScheme(filePath));
|
||||
const ext = path.extname(target).toLowerCase();
|
||||
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Ouverture refusee pour ${ext}`);
|
||||
if (!fs.existsSync(target)) throw new Error('Fichier introuvable');
|
||||
const result = await shell.openPath(target);
|
||||
const real = await fs.promises.realpath(target);
|
||||
const stat = await fs.promises.stat(real);
|
||||
const ext = path.extname(real).toLowerCase();
|
||||
if (!stat.isDirectory() && (FORBIDDEN_EXT.has(ext) || !OPENABLE_EXT.has(ext))) throw new Error(`Ouverture refusee pour ${ext || 'ce fichier'}`);
|
||||
// Outside the user's document folders we only reveal the file, never execute its handler.
|
||||
if (!isInsideAllowedRoots(real)) {
|
||||
shell.showItemInFolder(real);
|
||||
return true;
|
||||
}
|
||||
const result = await shell.openPath(real);
|
||||
if (result) throw new Error(result);
|
||||
return true;
|
||||
});
|
||||
|
||||
+11
-2
@@ -105,6 +105,8 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques
|
||||
headers: headersToObject(response.headers)
|
||||
};
|
||||
|
||||
const onDestroyed = () => controller.abort();
|
||||
sender.once('destroyed', onDestroyed);
|
||||
const pump = async () => {
|
||||
try {
|
||||
if (!response.body) {
|
||||
@@ -133,7 +135,10 @@ async function startStream(sender: WebContents, id: string, req: HttpProxyReques
|
||||
if (e.name === 'AbortError') send({ id, type: 'end' });
|
||||
else send({ id, type: 'error', message: e.message || String(err) });
|
||||
} finally {
|
||||
activeStreams.delete(id);
|
||||
// Releases the socket when we bailed out early (idle timeout, renderer gone).
|
||||
controller.abort();
|
||||
sender.removeListener('destroyed', onDestroyed);
|
||||
if (activeStreams.get(id) === controller) activeStreams.delete(id);
|
||||
}
|
||||
};
|
||||
void pump();
|
||||
@@ -147,7 +152,11 @@ export function abortAllStreams(): void {
|
||||
|
||||
export function registerHttpIpc(): void {
|
||||
ipcMain.handle(IPC.httpFetch, (_e, req: HttpProxyRequest) => proxyFetch(req));
|
||||
ipcMain.handle(IPC.httpStreamStart, (event, id: string, req: HttpProxyRequest) => startStream(event.sender, id, req));
|
||||
ipcMain.handle(IPC.httpStreamStart, (event, id: unknown, req: HttpProxyRequest) => {
|
||||
if (typeof id !== 'string' || !id || id.length > 64 || activeStreams.has(id)) throw new Error('Identifiant de flux invalide');
|
||||
if (!req || typeof req.url !== 'string') throw new Error('Requête invalide');
|
||||
return startStream(event.sender, id, req);
|
||||
});
|
||||
ipcMain.on(IPC.httpStreamAbort, (_e, id: string) => {
|
||||
const controller = activeStreams.get(id);
|
||||
if (controller) {
|
||||
|
||||
+61
-12
@@ -2,10 +2,16 @@ import { app, ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC } from '../../shared/ipc';
|
||||
import { log } from '../logger';
|
||||
|
||||
type StoreData = Record<string, unknown>;
|
||||
|
||||
let cache: StoreData | null = null;
|
||||
let dirty = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let writing: Promise<void> = Promise.resolve();
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
function storePath(): string {
|
||||
return path.join(app.getPath('userData'), 'userdata.json');
|
||||
@@ -13,19 +19,55 @@ function storePath(): string {
|
||||
|
||||
function load(): StoreData {
|
||||
if (cache) return cache;
|
||||
const target = storePath();
|
||||
try {
|
||||
cache = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as StoreData;
|
||||
} catch {
|
||||
cache = JSON.parse(fs.readFileSync(target, 'utf8')) as StoreData;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Keep a copy of an unreadable file instead of silently overwriting it.
|
||||
try {
|
||||
fs.copyFileSync(target, `${target}.corrupt-${Date.now()}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
log('WARN', 'store', `userdata.json unreadable, starting fresh: ${(err as Error).message}`);
|
||||
}
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
const target = storePath();
|
||||
const tmp = target + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(cache ?? {}, null, 2), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
/** Debounced, asynchronous, atomic write with retries (Windows AV scanners lock files briefly). */
|
||||
function schedulePersist(): void {
|
||||
dirty = true;
|
||||
if (!timer) timer = setTimeout(() => void flushStore(), 250);
|
||||
}
|
||||
|
||||
export function flushStore(): Promise<void> {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!dirty) return writing;
|
||||
dirty = false;
|
||||
const text = JSON.stringify(cache ?? {}, null, 2);
|
||||
writing = writing
|
||||
.then(async () => {
|
||||
const target = storePath();
|
||||
const tmp = `${target}.tmp`;
|
||||
await fs.promises.writeFile(tmp, text, 'utf8');
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(tmp, target);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt >= 5) throw err;
|
||||
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => log('ERROR', 'store', `cannot persist userdata.json: ${(err as Error).message}`));
|
||||
return writing;
|
||||
}
|
||||
|
||||
export function storeGet<T = unknown>(key: string): T | null {
|
||||
@@ -36,19 +78,26 @@ export function storeGet<T = unknown>(key: string): T | null {
|
||||
export function storeSet(key: string, value: unknown): void {
|
||||
const data = load();
|
||||
data[key] = value;
|
||||
persist();
|
||||
schedulePersist();
|
||||
}
|
||||
|
||||
function validKey(key: unknown): key is string {
|
||||
return typeof key === 'string' && key.length > 0 && key.length <= 128 && !FORBIDDEN_KEYS.has(key);
|
||||
}
|
||||
|
||||
export function registerStoreIpc(): void {
|
||||
ipcMain.handle(IPC.storeGet, (_e, key: string) => storeGet(key));
|
||||
ipcMain.handle(IPC.storeSet, (_e, key: string, value: unknown) => {
|
||||
ipcMain.handle(IPC.storeGet, (_e, key: unknown) => (validKey(key) ? storeGet(key) : null));
|
||||
ipcMain.handle(IPC.storeSet, (_e, key: unknown, value: unknown) => {
|
||||
if (!validKey(key)) throw new Error('Clé de stockage invalide');
|
||||
if (typeof value === 'function' || typeof value === 'symbol') throw new Error('Valeur non sérialisable');
|
||||
storeSet(key, value);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(IPC.storeDelete, (_e, key: string) => {
|
||||
ipcMain.handle(IPC.storeDelete, (_e, key: unknown) => {
|
||||
if (!validKey(key)) return false;
|
||||
const data = load();
|
||||
delete data[key];
|
||||
persist();
|
||||
schedulePersist();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
+32
-8
@@ -5,22 +5,46 @@ import type { LogEntry } from '../shared/ipc';
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
let stream: fs.WriteStream | null = null;
|
||||
let written = 0;
|
||||
|
||||
export function getLogPath(): string {
|
||||
return path.join(app.getPath('userData'), 'eveflow.log');
|
||||
}
|
||||
|
||||
function output(): fs.WriteStream {
|
||||
if (!stream) {
|
||||
const target = getLogPath();
|
||||
try {
|
||||
written = fs.statSync(target).size;
|
||||
} catch {
|
||||
written = 0;
|
||||
}
|
||||
stream = fs.createWriteStream(target, { flags: 'a' });
|
||||
stream.on('error', (err) => console.error('[logger] write failed:', err.message));
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
|
||||
function rotate(): void {
|
||||
const target = getLogPath();
|
||||
stream?.end();
|
||||
stream = null;
|
||||
try {
|
||||
fs.renameSync(target, `${target}.old`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
written = 0;
|
||||
}
|
||||
|
||||
export function writeLog(entry: LogEntry): void {
|
||||
try {
|
||||
const logPath = getLogPath();
|
||||
const data = entry.data !== undefined ? ' | ' + safeJson(entry.data) : '';
|
||||
const line = `[${entry.ts}] [${entry.level}] [${entry.tag}] ${entry.message}${data}\n`;
|
||||
try {
|
||||
const stat = fs.statSync(logPath);
|
||||
if (stat.size > MAX_LOG_BYTES) fs.renameSync(logPath, logPath + '.old');
|
||||
} catch {
|
||||
/* first start: file does not exist yet */
|
||||
}
|
||||
fs.appendFileSync(logPath, line, 'utf8');
|
||||
written += Buffer.byteLength(line);
|
||||
if (written > MAX_LOG_BYTES) rotate();
|
||||
output().write(line);
|
||||
} catch (err) {
|
||||
console.error('[logger] cannot write log file:', (err as Error).message);
|
||||
}
|
||||
|
||||
+24
-11
@@ -2,17 +2,19 @@ import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session
|
||||
import path from 'node:path';
|
||||
import { IPC, type AppInfo, type HotkeyEvent, type LogEntry, type WindowMode } from '../shared/ipc';
|
||||
import { getLogPath, log, writeLog } from './logger';
|
||||
import { registerStoreIpc, storeGet } from './ipc/store';
|
||||
import { flushStore, registerStoreIpc, storeGet } from './ipc/store';
|
||||
import { abortAllStreams, registerHttpIpc } from './ipc/http';
|
||||
import { registerTelemetryIpc } from './ipc/telemetry';
|
||||
import { getSharedDirectory, registerFilesIpc } from './ipc/files';
|
||||
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility } from './window';
|
||||
import { createMainWindow, getMainWindow, getWindowMode, setWindowMode, toggleWindowVisibility, windowEvents } from './window';
|
||||
import { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
|
||||
import { registerVoiceIpc } from './voice/ipc';
|
||||
import { stopEngine } from './voice/engine';
|
||||
|
||||
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
|
||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||
// Test/dev aid: isolate settings, logs and models in a custom directory.
|
||||
if (process.env.EVEFLOW_USER_DATA) app.setPath('userData', process.env.EVEFLOW_USER_DATA);
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
@@ -37,8 +39,9 @@ interface WebhookSettings {
|
||||
}
|
||||
|
||||
function readWebhookSettings(): WebhookSettings {
|
||||
const saved = storeGet<WebhookSettings>('webhook');
|
||||
return { enabled: true, port: 7842, secret: '', ...(saved ?? {}) };
|
||||
const saved = storeGet<WebhookSettings>('webhook') ?? {};
|
||||
const port = Number(saved.port);
|
||||
return { enabled: saved.enabled !== false, port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 7842, secret: typeof saved.secret === 'string' ? saved.secret : '' };
|
||||
}
|
||||
|
||||
async function bootWebhook(): Promise<void> {
|
||||
@@ -47,7 +50,7 @@ async function bootWebhook(): Promise<void> {
|
||||
await stopWebhookServer();
|
||||
return;
|
||||
}
|
||||
await startWebhookServer(getMainWindow, { port: Number(settings.port) || 7842, secret: settings.secret || undefined });
|
||||
await startWebhookServer(getMainWindow, { port: settings.port ?? 7842, secret: settings.secret || undefined });
|
||||
}
|
||||
|
||||
function sendHotkey(event: HotkeyEvent): void {
|
||||
@@ -63,7 +66,8 @@ function registerShortcuts(): void {
|
||||
sendHotkey('ptt-toggle');
|
||||
}],
|
||||
['CommandOrControl+Shift+J', () => toggleWindowVisibility()],
|
||||
['CommandOrControl+Shift+Escape', () => sendHotkey('stop-speaking')]
|
||||
// Ctrl+Shift+Escape is the Windows Task Manager shortcut; use a free combination.
|
||||
['CommandOrControl+Alt+Escape', () => sendHotkey('stop-speaking')]
|
||||
];
|
||||
for (const [accelerator, handler] of bindings) {
|
||||
try {
|
||||
@@ -96,7 +100,7 @@ function createTray(): void {
|
||||
};
|
||||
rebuildMenu();
|
||||
tray.on('click', () => toggleWindowVisibility());
|
||||
ipcMain.on(IPC.windowSetMode, () => setTimeout(rebuildMenu, 0));
|
||||
windowEvents.on('mode', rebuildMenu);
|
||||
} catch (err) {
|
||||
log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -127,7 +131,10 @@ function registerCoreIpc(): void {
|
||||
}
|
||||
});
|
||||
ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud'));
|
||||
ipcMain.on(IPC.log, (_e, entry: LogEntry) => writeLog(entry));
|
||||
ipcMain.on(IPC.log, (_e, entry: LogEntry) => {
|
||||
if (!entry || typeof entry.message !== 'string') return;
|
||||
writeLog({ ...entry, message: entry.message.slice(0, 8000), tag: String(entry.tag).slice(0, 64) });
|
||||
});
|
||||
ipcMain.handle(IPC.appInfo, (): AppInfo => ({
|
||||
version: app.getVersion(),
|
||||
platform: process.platform,
|
||||
@@ -143,7 +150,7 @@ function registerCoreIpc(): void {
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
async function boot(): Promise<void> {
|
||||
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
|
||||
configureSession();
|
||||
registerStoreIpc();
|
||||
@@ -160,13 +167,19 @@ app.whenReady().then(async () => {
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.on('will-quit', () => {
|
||||
if (app.hasSingleInstanceLock()) {
|
||||
app.whenReady().then(boot).catch((err) => log('ERROR', 'app', `boot failed: ${(err as Error).message}`));
|
||||
}
|
||||
|
||||
app.on('will-quit', (event) => {
|
||||
globalShortcut.unregisterAll();
|
||||
abortAllStreams();
|
||||
stopEngine();
|
||||
void stopWebhookServer();
|
||||
event.preventDefault();
|
||||
flushStore().finally(() => app.exit(0));
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ const api: EveFlowBridge = {
|
||||
window: {
|
||||
control: (action: 'minimize' | 'maximize' | 'close' | 'hide') => ipcRenderer.send(IPC.windowControl, action),
|
||||
setMode: (mode: WindowMode) => ipcRenderer.send(IPC.windowSetMode, mode),
|
||||
onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb)
|
||||
onModeChanged: (cb: (mode: WindowMode) => void) => subscribe<WindowMode>(IPC.windowModeChanged, cb),
|
||||
onVisibility: (cb: (visible: boolean) => void) => subscribe<boolean>(IPC.windowVisibility, cb)
|
||||
},
|
||||
log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry),
|
||||
store: {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
kind: 'stt',
|
||||
engine: 'whisper',
|
||||
name: 'Whisper base (multilingue)',
|
||||
description: 'Rapide sur CPU, bonne qualité en français pour des commandes courtes.',
|
||||
description: 'Le plus rapide sur CPU ; précision moyenne en français, suffisante pour des commandes courtes.',
|
||||
languages: ['fr', 'en', 'multi'],
|
||||
sizeMb: 208,
|
||||
url: `${ASR}/sherpa-onnx-whisper-base.tar.bz2`,
|
||||
@@ -35,12 +35,13 @@ export const VOICE_CATALOG: VoiceModelSpec[] = [
|
||||
kind: 'stt',
|
||||
engine: 'whisper',
|
||||
name: 'Whisper small (multilingue)',
|
||||
description: 'Plus précis que base, environ trois fois plus lent.',
|
||||
description: 'Recommandé pour le français : nettement plus précis que base, environ trois fois plus lent.',
|
||||
languages: ['fr', 'en', 'multi'],
|
||||
sizeMb: 640,
|
||||
url: `${ASR}/sherpa-onnx-whisper-small.tar.bz2`,
|
||||
dir: 'sherpa-onnx-whisper-small',
|
||||
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt']
|
||||
files: ['small-encoder.int8.onnx', 'small-decoder.int8.onnx', 'small-tokens.txt'],
|
||||
recommended: true
|
||||
},
|
||||
{
|
||||
id: 'whisper-turbo',
|
||||
|
||||
+49
-21
@@ -19,13 +19,22 @@ let child: UtilityProcess | null = null;
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
|
||||
function rejectAll(message: string): void {
|
||||
for (const [id, p] of pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error(message));
|
||||
pending.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function spawn(): UtilityProcess {
|
||||
if (child) return child;
|
||||
const script = path.join(__dirname, 'voice-worker.js');
|
||||
child = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' });
|
||||
child.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
|
||||
child.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
|
||||
child.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => {
|
||||
const proc = utilityProcess.fork(script, [], { serviceName: 'eveflow-voice', stdio: 'pipe' });
|
||||
child = proc;
|
||||
proc.stdout?.on('data', (d: Buffer) => log('DEBUG', 'voice-worker', d.toString().trim()));
|
||||
proc.stderr?.on('data', (d: Buffer) => log('WARN', 'voice-worker', d.toString().trim()));
|
||||
proc.on('message', (msg: { id: number; ok: boolean; result?: unknown; error?: string }) => {
|
||||
const p = pending.get(msg.id);
|
||||
if (!p) return;
|
||||
pending.delete(msg.id);
|
||||
@@ -33,17 +42,15 @@ function spawn(): UtilityProcess {
|
||||
if (msg.ok) p.resolve(msg.result);
|
||||
else p.reject(new Error(msg.error ?? 'erreur moteur vocal'));
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
proc.on('exit', (code) => {
|
||||
log(code === 0 ? 'INFO' : 'ERROR', 'voice-worker', `exited with code ${code}`);
|
||||
// A newer worker may already have replaced this one; only clean up if we are still current.
|
||||
if (child !== proc) return;
|
||||
child = null;
|
||||
for (const [id, p] of pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(new Error('Le moteur vocal local s’est arrêté de façon inattendue.'));
|
||||
pending.delete(id);
|
||||
}
|
||||
rejectAll('Le moteur vocal local s’est arrêté de façon inattendue.');
|
||||
});
|
||||
log('INFO', 'voice', 'voice worker started');
|
||||
return child;
|
||||
return proc;
|
||||
}
|
||||
|
||||
function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Promise<T> {
|
||||
@@ -53,9 +60,20 @@ function request<T>(message: Record<string, unknown>, timeoutMs = 120_000): Prom
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error('Délai dépassé pour le moteur vocal local.'));
|
||||
// The worker handles requests synchronously: a stuck request wedges everything behind it.
|
||||
if (child === proc) {
|
||||
log('WARN', 'voice', 'worker unresponsive, restarting');
|
||||
stopEngine();
|
||||
}
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve: (v) => resolve(v as T), reject, timer });
|
||||
proc.postMessage({ id, ...message });
|
||||
try {
|
||||
proc.postMessage({ id, ...message });
|
||||
} catch (err) {
|
||||
pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err as Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,21 +94,31 @@ export async function engineStatus(): Promise<VoiceEngineStatus> {
|
||||
}
|
||||
|
||||
export function transcribe(req: TranscribeRequest): Promise<TranscribeResult> {
|
||||
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav: req.wav, language: req.language }, 180_000);
|
||||
// Binary payloads are base64-encoded for the worker: the utility process channel rejects external buffers.
|
||||
const wav = Buffer.from(req.wav.buffer, req.wav.byteOffset, req.wav.byteLength).toString('base64');
|
||||
return request<TranscribeResult>({ type: 'transcribe', model: modelRef(req.modelId), wav, language: req.language }, 180_000);
|
||||
}
|
||||
|
||||
export function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
|
||||
return request<SynthesizeResult>({ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed }, 180_000);
|
||||
export async function synthesize(req: SynthesizeRequest): Promise<SynthesizeResult> {
|
||||
const result = await request<Omit<SynthesizeResult, 'wav'> & { wav: string }>(
|
||||
{ type: 'synthesize', model: modelRef(req.modelId), text: req.text, speaker: req.speaker, speed: req.speed },
|
||||
180_000
|
||||
);
|
||||
const buffer = Buffer.from(result.wav, 'base64');
|
||||
return { ...result, wav: new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) };
|
||||
}
|
||||
|
||||
export function unload(modelId?: string): Promise<unknown> {
|
||||
if (!child) return Promise.resolve({ ok: true });
|
||||
return request({ type: 'unload', modelId }, 20_000);
|
||||
/** Native model memory is only released deterministically by restarting the worker. */
|
||||
export function unload(_modelId?: string): Promise<unknown> {
|
||||
stopEngine();
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
|
||||
export function stopEngine(): void {
|
||||
if (child) {
|
||||
child.kill();
|
||||
child = null;
|
||||
const proc = child;
|
||||
child = null;
|
||||
if (proc) {
|
||||
rejectAll('Moteur vocal redémarré.');
|
||||
proc.kill();
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -16,7 +16,15 @@ export function registerVoiceIpc(): void {
|
||||
await removeModel(id);
|
||||
return listModels();
|
||||
});
|
||||
ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => transcribe(req));
|
||||
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => synthesize(req));
|
||||
ipcMain.handle(VOICE_IPC.transcribe, (_e, req: TranscribeRequest) => {
|
||||
if (!req || !(req.wav instanceof Uint8Array) || req.wav.byteLength < 44 || req.wav.byteLength > 64 * 1024 * 1024) throw new Error('Audio invalide');
|
||||
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
|
||||
return transcribe({ ...req, language: typeof req.language === 'string' ? req.language.slice(0, 8) : 'auto' });
|
||||
});
|
||||
ipcMain.handle(VOICE_IPC.synthesize, (_e, req: SynthesizeRequest) => {
|
||||
if (!req || typeof req.text !== 'string' || !req.text.trim() || req.text.length > 5000) throw new Error('Texte invalide');
|
||||
if (typeof req.modelId !== 'string') throw new Error('Modèle invalide');
|
||||
return synthesize({ ...req, speaker: Number.isFinite(req.speaker) ? req.speaker : 0, speed: Number.isFinite(req.speed) ? req.speed : 1 });
|
||||
});
|
||||
ipcMain.handle(VOICE_IPC.unload, (_e, id?: string) => unload(id));
|
||||
}
|
||||
@@ -11,9 +11,13 @@ import * as tar from 'tar';
|
||||
import unbzip2 from 'unbzip2-stream';
|
||||
import { VOICE_IPC, type VoiceDownloadProgress, type VoiceModelSpec, type VoiceModelStatus } from '../../shared/voice';
|
||||
import { VOICE_CATALOG, findModel } from './catalog';
|
||||
import { stopEngine } from './engine';
|
||||
import { log } from '../logger';
|
||||
|
||||
const active = new Map<string, AbortController>();
|
||||
/** Windows keeps files locked briefly (AV scans, memory-mapped models): retry removals. */
|
||||
const RM = { recursive: true, force: true, maxRetries: 10, retryDelay: 200 } as const;
|
||||
const DOWNLOAD_IDLE_MS = 60_000;
|
||||
|
||||
export function modelsDir(): string {
|
||||
const dir = path.join(app.getPath('userData'), 'models');
|
||||
@@ -59,7 +63,8 @@ export function listModels(): VoiceModelStatus[] {
|
||||
export async function removeModel(id: string): Promise<void> {
|
||||
const spec = findModel(id);
|
||||
if (!spec) throw new Error(`Modèle inconnu : ${id}`);
|
||||
await fs.promises.rm(modelDir(spec), { recursive: true, force: true });
|
||||
stopEngine(); // releases memory-mapped model files before deleting them
|
||||
await fs.promises.rm(modelDir(spec), RM);
|
||||
log('INFO', 'voice', `model removed: ${id}`);
|
||||
}
|
||||
|
||||
@@ -78,8 +83,14 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
|
||||
};
|
||||
|
||||
const tmpDir = path.join(modelsDir(), `.tmp-${spec.id}`);
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.promises.rm(tmpDir, RM);
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
// Abort the transfer when no byte arrives for a while (fetch itself has no inactivity timeout).
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const touch = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => controller.abort(new Error('Téléchargement interrompu (aucune donnée reçue)')), DOWNLOAD_IDLE_MS);
|
||||
};
|
||||
|
||||
try {
|
||||
log('INFO', 'voice', `downloading ${spec.id} from ${spec.url}`);
|
||||
@@ -88,9 +99,11 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
|
||||
const total = Number(response.headers.get('content-length')) || Math.round(spec.sizeMb * 1024 * 1024);
|
||||
let received = 0;
|
||||
let lastReport = 0;
|
||||
touch();
|
||||
const counter = new Transform({
|
||||
transform(chunk: Buffer, _enc, cb) {
|
||||
received += chunk.length;
|
||||
touch();
|
||||
const now = Date.now();
|
||||
if (now - lastReport > 250) {
|
||||
lastReport = now;
|
||||
@@ -116,19 +129,30 @@ export async function downloadModel(id: string, sender: WebContents | null): Pro
|
||||
if (missing.length) throw new Error(`Archive incomplète, fichiers manquants : ${missing.join(', ')}`);
|
||||
|
||||
const finalDir = modelDir(spec);
|
||||
await fs.promises.rm(finalDir, { recursive: true, force: true });
|
||||
await fs.promises.rename(extracted, finalDir);
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
if (isInstalled(spec)) stopEngine();
|
||||
await fs.promises.rm(finalDir, RM);
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await fs.promises.rename(extracted, finalDir);
|
||||
break;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code ?? '';
|
||||
if (attempt >= 10 || !['EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES'].includes(code)) throw err;
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
}
|
||||
await fs.promises.rm(tmpDir, RM);
|
||||
report({ phase: 'done', received: total, total, percent: 100 });
|
||||
log('INFO', 'voice', `model installed: ${spec.id}`);
|
||||
} catch (err) {
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
const e = err as Error;
|
||||
const cancelled = e.name === 'AbortError' || controller.signal.aborted;
|
||||
await fs.promises.rm(tmpDir, RM).catch(() => undefined);
|
||||
const e = (controller.signal.reason instanceof Error ? controller.signal.reason : err) as Error;
|
||||
const cancelled = (e.name === 'AbortError' || controller.signal.aborted) && !(controller.signal.reason instanceof Error);
|
||||
report({ phase: cancelled ? 'cancelled' : 'error', received: 0, total: 0, percent: 0, message: cancelled ? 'annulé' : e.message });
|
||||
log(cancelled ? 'INFO' : 'ERROR', 'voice', `download ${spec.id} ${cancelled ? 'cancelled' : 'failed: ' + e.message}`);
|
||||
if (!cancelled) throw new Error(e.message);
|
||||
} finally {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
active.delete(id);
|
||||
}
|
||||
return listModels().find((m) => m.id === id)!;
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ModelRef {
|
||||
|
||||
type Request =
|
||||
| { id: number; type: 'status' }
|
||||
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array; language: string }
|
||||
| { id: number; type: 'transcribe'; model: ModelRef; wav: Uint8Array | string; language: string }
|
||||
| { id: number; type: 'synthesize'; model: ModelRef; text: string; speaker: number; speed: number }
|
||||
| { id: number; type: 'unload'; modelId?: string };
|
||||
|
||||
@@ -32,7 +32,7 @@ type Sherpa = {
|
||||
OfflineTts: new (config: unknown) => {
|
||||
numSpeakers: number;
|
||||
sampleRate: number;
|
||||
generate: (req: { text: string; sid: number; speed: number }) => { samples: Float32Array; sampleRate: number };
|
||||
generate: (req: { text: string; sid: number; speed: number; enableExternalBuffer?: boolean }) => { samples: Float32Array; sampleRate: number };
|
||||
};
|
||||
version: string;
|
||||
};
|
||||
@@ -136,16 +136,20 @@ function getSynthesizer(model: ModelRef) {
|
||||
// ── audio helpers ──────────────────────────────────────────────────────────
|
||||
function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: number } {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF') throw new Error('WAV invalide');
|
||||
if (String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]) !== 'RIFF' || String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]) !== 'WAVE') {
|
||||
throw new Error('WAV invalide');
|
||||
}
|
||||
let offset = 12;
|
||||
let sampleRate = 16000;
|
||||
let channels = 1;
|
||||
let bits = 16;
|
||||
let format = 1; // 1 = PCM, 3 = IEEE float
|
||||
let data: { start: number; length: number } | null = null;
|
||||
while (offset + 8 <= bytes.byteLength) {
|
||||
const id = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (id === 'fmt ') {
|
||||
format = view.getUint16(offset + 8, true);
|
||||
channels = view.getUint16(offset + 10, true);
|
||||
sampleRate = view.getUint32(offset + 12, true);
|
||||
bits = view.getUint16(offset + 22, true);
|
||||
@@ -163,7 +167,16 @@ function decodeWav(bytes: Uint8Array): { samples: Float32Array; sampleRate: numb
|
||||
let sum = 0;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const pos = data.start + (i * channels + c) * bytesPerSample;
|
||||
sum += bits === 16 ? view.getInt16(pos, true) / 32768 : bits === 32 ? view.getInt32(pos, true) / 2147483648 : (bytes[pos] - 128) / 128;
|
||||
sum +=
|
||||
format === 3 && bits === 32
|
||||
? view.getFloat32(pos, true)
|
||||
: bits === 16
|
||||
? view.getInt16(pos, true) / 32768
|
||||
: bits === 24
|
||||
? (((bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16)) << 8) >> 8) / 8388608
|
||||
: bits === 32
|
||||
? view.getInt32(pos, true) / 2147483648
|
||||
: (bytes[pos] - 128) / 128;
|
||||
}
|
||||
samples[i] = sum / channels;
|
||||
}
|
||||
@@ -225,8 +238,11 @@ function handle(req: Request): unknown {
|
||||
}
|
||||
case 'transcribe': {
|
||||
const started = Date.now();
|
||||
const { samples, sampleRate } = decodeWav(req.wav);
|
||||
// Audio crosses the process boundary as base64: V8 refuses to serialize external buffers.
|
||||
const bytes = typeof req.wav === 'string' ? new Uint8Array(Buffer.from(req.wav, 'base64')) : req.wav;
|
||||
const { samples, sampleRate } = decodeWav(bytes);
|
||||
const pcm = resampleTo16k(samples, sampleRate);
|
||||
if (pcm.length < 1600) throw new Error('Audio trop court');
|
||||
const recognizer = getRecognizer(req.model, req.language);
|
||||
const stream = recognizer.createStream();
|
||||
stream.acceptWaveform({ sampleRate: 16000, samples: pcm });
|
||||
@@ -238,8 +254,15 @@ function handle(req: Request): unknown {
|
||||
const started = Date.now();
|
||||
const tts = getSynthesizer(req.model);
|
||||
const sid = Math.max(0, Math.min(tts.numSpeakers - 1, Math.floor(req.speaker)));
|
||||
const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)) });
|
||||
return { wav: encodeWav(audio.samples, audio.sampleRate), sampleRate: audio.sampleRate, durationMs: Date.now() - started, audioSec: audio.samples.length / audio.sampleRate };
|
||||
// Electron forbids N-API external buffers: ask sherpa-onnx to copy the samples into a V8 buffer.
|
||||
const audio = tts.generate({ text: req.text, sid, speed: Math.max(0.5, Math.min(2, req.speed || 1)), enableExternalBuffer: false });
|
||||
const wav = encodeWav(audio.samples, audio.sampleRate);
|
||||
return {
|
||||
wav: Buffer.from(wav.buffer, wav.byteOffset, wav.byteLength).toString('base64'),
|
||||
sampleRate: audio.sampleRate,
|
||||
durationMs: Date.now() - started,
|
||||
audioSec: audio.samples.length / audio.sampleRate
|
||||
};
|
||||
}
|
||||
case 'unload': {
|
||||
if (req.modelId) {
|
||||
|
||||
+24
-10
@@ -35,16 +35,20 @@ export function getWebhookStatus(): WebhookStatus {
|
||||
export function stopWebhookServer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!server) return resolve();
|
||||
server.close(() => 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;
|
||||
const host = options.host ?? '0.0.0.0';
|
||||
// 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) => {
|
||||
@@ -69,19 +73,24 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
|
||||
}
|
||||
}
|
||||
|
||||
let body = '';
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let tooLarge = false;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (tooLarge) return;
|
||||
body += chunk.toString('utf8');
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
json(413, { error: 'Payload too large' });
|
||||
req.destroy();
|
||||
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);
|
||||
@@ -99,13 +108,18 @@ export async function startWebhookServer(getWindow: () => BrowserWindow | null,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server!.once('error', (err: NodeJS.ErrnoException) => {
|
||||
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 };
|
||||
server = null;
|
||||
if (server === instance) server = null;
|
||||
resolve(status);
|
||||
});
|
||||
server!.listen(port, host, () => {
|
||||
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);
|
||||
|
||||
+19
-6
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC, type WindowMode } from '../shared/ipc';
|
||||
@@ -9,6 +10,9 @@ const COMPACT_SIZE = { width: 380, height: 620, minWidth: 320, minHeight: 420 };
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentMode: WindowMode = 'hud';
|
||||
export const windowEvents = new EventEmitter();
|
||||
// Transparent frameless windows cannot be resized reliably on Windows; use an opaque, rounded window there.
|
||||
const TRANSPARENT = process.platform !== 'win32';
|
||||
let hudBounds: Electron.Rectangle | null = null;
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
@@ -49,8 +53,9 @@ export function createMainWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
...HUD_SIZE,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
transparent: TRANSPARENT,
|
||||
backgroundColor: TRANSPARENT ? '#00000000' : '#03070d',
|
||||
roundedCorners: true,
|
||||
hasShadow: true,
|
||||
show: false,
|
||||
title: 'EveFlow',
|
||||
@@ -78,11 +83,18 @@ export function createMainWindow(): BrowserWindow {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (!url.startsWith('file://') && !url.startsWith(process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173')) {
|
||||
event.preventDefault();
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
if (url === win.webContents.getURL()) return;
|
||||
event.preventDefault();
|
||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||
});
|
||||
win.webContents.on('will-redirect', (event) => event.preventDefault());
|
||||
const notifyVisibility = () => {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.windowVisibility, win.isVisible() && !win.isMinimized());
|
||||
};
|
||||
win.on('show', notifyVisibility);
|
||||
win.on('hide', notifyVisibility);
|
||||
win.on('minimize', notifyVisibility);
|
||||
win.on('restore', notifyVisibility);
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null;
|
||||
@@ -129,6 +141,7 @@ export function setWindowMode(mode: WindowMode): void {
|
||||
}
|
||||
currentMode = mode;
|
||||
win.webContents.send(IPC.windowModeChanged, mode);
|
||||
windowEvents.emit('mode', mode);
|
||||
}
|
||||
|
||||
export function toggleWindowVisibility(): void {
|
||||
|
||||
Reference in new issue
Block a user