mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
Complete rewrite of the application: - Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest; main process rewritten in TypeScript (electron/), typed IPC contract (shared/), sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process. - Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode, global push-to-talk hotkey, system-voice fallback. - Hermes: full API client (capabilities, health, models, skills, toolsets, sessions, jobs) with automatic transport selection: runs API (SSE lifecycle, approvals, steer, stop) > sessions stream > chat completions with local tools; tolerant event normalisation; webhook receiver hardened (secret, size limit). - UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal, holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/ skills/sessions/link tabs, telemetry), settings drawer, approval modals, compact floating widget, four themes, tray icon and global shortcuts. - Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
import { app, ipcMain, shell } from 'electron';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { IPC } from '../../shared/ipc';
|
|
import { log } from '../logger';
|
|
|
|
const READABLE_EXT: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.txt': 'text/plain',
|
|
'.log': 'text/plain',
|
|
'.csv': 'text/csv',
|
|
'.json': 'application/json',
|
|
'.md': 'text/markdown',
|
|
'.xml': 'application/xml',
|
|
'.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'
|
|
]);
|
|
|
|
export function getSharedDirectory(): string {
|
|
const candidates = [
|
|
path.join(os.homedir(), 'Documents', 'EveFlow_Shared'),
|
|
path.join(app.getPath('userData'), 'shared')
|
|
];
|
|
for (const dir of candidates) {
|
|
try {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
} catch {
|
|
/* try next candidate */
|
|
}
|
|
}
|
|
return app.getPath('temp');
|
|
}
|
|
|
|
function normalizeSafe(filePath: string): string {
|
|
if (!filePath || typeof filePath !== 'string') throw new Error('Chemin invalide');
|
|
const normalized = path.normalize(filePath);
|
|
if (normalized.split(/[\\/]/).includes('..')) throw new Error('Traversee de repertoire refusee');
|
|
return normalized;
|
|
}
|
|
|
|
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)));
|
|
}
|
|
|
|
function stripFileScheme(value: string): string {
|
|
let out = value.replace(/^file:\/\//, '');
|
|
// file:///C:/x -> /C:/x on Windows-style paths
|
|
if (/^\/[a-zA-Z]:/.test(out)) out = out.slice(1);
|
|
return decodeURIComponent(out);
|
|
}
|
|
|
|
export function registerFilesIpc(): void {
|
|
ipcMain.handle(IPC.readLocalFile, async (_e, filePath: string) => {
|
|
const target = normalizeSafe(stripFileScheme(filePath));
|
|
const ext = path.extname(target).toLowerCase();
|
|
const mime = READABLE_EXT[ext];
|
|
if (!mime) throw new Error(`Type de fichier non autorise : ${ext || '(aucune extension)'}`);
|
|
if (!isInsideAllowedRoots(target)) throw new Error('Chemin hors des dossiers autorises');
|
|
const stat = await fs.promises.stat(target);
|
|
if (stat.size > 25 * 1024 * 1024) throw new Error('Fichier trop volumineux (25 Mo max)');
|
|
const content = await fs.promises.readFile(target);
|
|
return `data:${mime};base64,${content.toString('base64')}`;
|
|
});
|
|
|
|
ipcMain.handle(IPC.writeSharedFile, async (_e, filename: string, content: string, isBase64 = false) => {
|
|
if (!filename || typeof filename !== 'string') throw new Error('Nom de fichier invalide');
|
|
const safeName = path.basename(filename);
|
|
if (safeName !== filename || safeName.startsWith('.') || /[<>:"|?*]/.test(safeName)) {
|
|
throw new Error('Nom de fichier non securise');
|
|
}
|
|
const ext = path.extname(safeName).toLowerCase();
|
|
if (FORBIDDEN_EXT.has(ext)) throw new Error(`Extension interdite : ${ext}`);
|
|
const target = path.join(getSharedDirectory(), safeName);
|
|
if (isBase64) {
|
|
const payload = content.replace(/^data:[^;]+;base64,/, '');
|
|
await fs.promises.writeFile(target, Buffer.from(payload, 'base64'));
|
|
} else {
|
|
await fs.promises.writeFile(target, content, 'utf8');
|
|
}
|
|
log('INFO', 'files', `shared file written: ${target}`);
|
|
return { path: target, url: `file:///${target.replace(/\\/g, '/')}` };
|
|
});
|
|
|
|
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);
|
|
if (result) throw new Error(result);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle(IPC.showInFolder, async (_e, filePath: string) => {
|
|
const target = normalizeSafe(stripFileScheme(filePath));
|
|
shell.showItemInFolder(target);
|
|
return true;
|
|
});
|
|
}
|