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:
Claude committed 2026-09-03 18:08:09 +00:00
1 parent b1b8d0d5d4
commit 5f0fa7a1e2
55 files changed
+1181 -433

No files matched your search

+70 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
}