Files
EveFlow/electron/main.ts
T
Claude 5f0fa7a1e2 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
2026-09-03 18:08:09 +00:00

188 lines
7.0 KiB
TypeScript

import { app, BrowserWindow, globalShortcut, ipcMain, Menu, nativeImage, session, systemPreferences, Tray } from 'electron';
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 { 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, 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;
// -- Single instance --------------------------------------------------------
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on('second-instance', () => {
const win = getMainWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
});
}
interface WebhookSettings {
enabled?: boolean;
port?: number;
secret?: string;
}
function readWebhookSettings(): WebhookSettings {
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> {
const settings = readWebhookSettings();
if (settings.enabled === false) {
await stopWebhookServer();
return;
}
await startWebhookServer(getMainWindow, { port: settings.port ?? 7842, secret: settings.secret || undefined });
}
function sendHotkey(event: HotkeyEvent): void {
const win = getMainWindow();
if (win && !win.isDestroyed()) win.webContents.send(IPC.hotkey, event);
}
function registerShortcuts(): void {
const bindings: Array<[string, () => void]> = [
['CommandOrControl+Shift+Space', () => {
const win = getMainWindow();
if (win && !win.isVisible()) win.show();
sendHotkey('ptt-toggle');
}],
['CommandOrControl+Shift+J', () => toggleWindowVisibility()],
// 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 {
if (!globalShortcut.register(accelerator, handler)) log('WARN', 'hotkey', `cannot register ${accelerator}`);
} catch (err) {
log('WARN', 'hotkey', `cannot register ${accelerator}: ${(err as Error).message}`);
}
}
}
function createTray(): void {
try {
const iconPath = app.isPackaged
? path.join(process.resourcesPath, 'icon.png')
: path.join(__dirname, '..', 'build', 'icon.png');
let image = nativeImage.createFromPath(iconPath);
if (image.isEmpty()) image = nativeImage.createEmpty();
else image = image.resize({ width: 16, height: 16 });
tray = new Tray(image);
tray.setToolTip('EveFlow - JARVIS HUD');
const rebuildMenu = () => {
const mode = getWindowMode();
tray?.setContextMenu(Menu.buildFromTemplate([
{ label: 'Afficher / masquer', click: () => toggleWindowVisibility() },
{ label: mode === 'compact' ? 'Mode HUD complet' : 'Mode compact flottant', click: () => setWindowMode(mode === 'compact' ? 'hud' : 'compact') },
{ label: 'Micro (Ctrl+Shift+Espace)', click: () => sendHotkey('ptt-toggle') },
{ type: 'separator' },
{ label: 'Quitter', click: () => app.quit() }
]));
};
rebuildMenu();
tray.on('click', () => toggleWindowVisibility());
windowEvents.on('mode', rebuildMenu);
} catch (err) {
log('WARN', 'tray', `tray unavailable: ${(err as Error).message}`);
}
}
function configureSession(): void {
const ses = session.defaultSession;
ses.setPermissionRequestHandler((_wc, permission, callback) => {
callback(permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
});
ses.setPermissionCheckHandler((_wc, permission) => permission === 'media' || permission === 'notifications' || permission === 'clipboard-read');
if (process.platform === 'darwin') {
systemPreferences.askForMediaAccess('microphone').then((granted) => {
log('INFO', 'audio', `microphone access ${granted ? 'granted' : 'denied'}`);
}).catch(() => undefined);
}
}
function registerCoreIpc(): void {
ipcMain.on(IPC.windowControl, (_e, action: 'minimize' | 'maximize' | 'close' | 'hide') => {
const win = getMainWindow();
if (!win) return;
switch (action) {
case 'minimize': win.minimize(); break;
case 'maximize': win.isMaximized() ? win.unmaximize() : win.maximize(); break;
case 'hide': win.hide(); break;
case 'close': win.close(); break;
}
});
ipcMain.on(IPC.windowSetMode, (_e, mode: WindowMode) => setWindowMode(mode === 'compact' ? 'compact' : 'hud'));
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,
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
sharedDir: getSharedDirectory(),
logPath: getLogPath()
}));
ipcMain.handle(IPC.webhookStatus, () => getWebhookStatus());
ipcMain.handle(IPC.webhookRestart, async () => {
await bootWebhook();
return getWebhookStatus();
});
}
async function boot(): Promise<void> {
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
configureSession();
registerStoreIpc();
registerHttpIpc();
registerTelemetryIpc();
registerFilesIpc();
registerCoreIpc();
registerVoiceIpc();
createMainWindow();
createTray();
registerShortcuts();
await bootWebhook();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
}
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', () => {
if (process.platform !== 'darwin') app.quit();
});