mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-12 01:39:13 +02:00
Écoute permanente - Silero VAD dans le worker sherpa-onnx (vad.start/audio/stop) ; le renderer envoie des trames de 128 ms pendant la commande et reçoit le segment WAV complet (speech-start / segment). Repli sur le VAD énergétique si le modèle n'est pas installé ou si l'option est désactivée. - Modèle « silero-vad » (0,6 Mo) dans le catalogue, section « Fin de phrase » dans Modèles locaux, réglage dans Paramètres → Micro. Vision d'écran - IPC system:screen-capture (desktopCapturer, JPEG 1600 px) ; bouton dans la barre de commande ; « regarde mon écran… » joint la capture à la question envoyée à Hermes (transport chat completions pour les images). Actions locales - IPC system:action à liste blanche : verrouiller la session, touches média (volume, mute, lecture, piste), ouvrir une application connue ou une URL http(s), presse-papiers, recherche de fichiers dans les dossiers utilisateur. - Routeur d'intentions FR/EN exécuté avant Hermes (« coupe le son », « ouvre Spotify », « verrouille la session »…), résultat affiché et lu ; désactivable. Correctif - Chat completions : les premiers fragments SSE arrivés avant l'événement de démarrage étaient perdus (premier mot manquant) ; ils sont maintenant rejoués. Tests : 26 tests unitaires (intentions locales ajoutées), e2e Electron sous Xvfb avec les vrais modèles (Silero : un segment par phrase, 6,7 s d'audio traités en 190 ms ; capture 109 ko reçue par Hermes ; intention locale traitée sans Hermes). Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y Co-authored-by: Claude <noreply@anthropic.com>
190 lines
7.1 KiB
TypeScript
190 lines
7.1 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 { registerSystemIpc } from './ipc/system';
|
|
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();
|
|
registerSystemIpc();
|
|
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();
|
|
});
|