mirror of
https://github.com/R0m1k3/EveFlow.git
synced 2026-10-11 17:29:03 +02:00
feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)
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
This commit is contained in:
239 files changed
+11351
-86495
No files matched your search
@@ -0,0 +1,113 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* HTTP proxy: the renderer never talks to the network directly (webSecurity stays ON).
|
||||
* Every request to Hermes / STT / TTS endpoints flows through here, which also removes
|
||||
* CORS and mixed-content restrictions for LAN services served over plain HTTP.
|
||||
*/
|
||||
import { ipcMain, type WebContents } from 'electron';
|
||||
import {
|
||||
IPC,
|
||||
type HttpProxyRequest,
|
||||
type HttpProxyResponse,
|
||||
type HttpStreamStart,
|
||||
type HttpStreamEvent
|
||||
} from '../../shared/ipc';
|
||||
import { log } from '../logger';
|
||||
|
||||
const activeStreams = new Map<string, AbortController>();
|
||||
|
||||
function assertUrl(url: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`URL invalide : ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Protocole non autorise : ${parsed.protocol}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildInit(req: HttpProxyRequest, signal: AbortSignal): RequestInit {
|
||||
const headers: Record<string, string> = { ...(req.headers ?? {}) };
|
||||
let body: RequestInit['body'];
|
||||
if (req.multipart) {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(req.multipart.fields)) form.append(k, v);
|
||||
const file = req.multipart.file;
|
||||
const bytes = new Uint8Array(file.data);
|
||||
form.append(req.multipart.fileField ?? 'file', new Blob([bytes], { type: file.type }), file.name);
|
||||
body = form;
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === 'content-type') delete headers[key];
|
||||
}
|
||||
} else if (req.body !== undefined) {
|
||||
body = req.body;
|
||||
}
|
||||
return { method: req.method ?? 'GET', headers, body, signal };
|
||||
}
|
||||
|
||||
function headersToObject(headers: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
headers.forEach((v, k) => {
|
||||
out[k.toLowerCase()] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function proxyFetch(req: HttpProxyRequest): Promise<HttpProxyResponse> {
|
||||
assertUrl(req.url);
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = req.timeoutMs ?? 60_000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(req.url, buildInit(req, controller.signal));
|
||||
const base = {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headersToObject(response.headers)
|
||||
};
|
||||
if (req.responseType === 'binary') {
|
||||
return { ...base, binary: new Uint8Array(await response.arrayBuffer()) };
|
||||
}
|
||||
return { ...base, text: await response.text() };
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
if (e.name === 'AbortError') throw new Error(`Delai depasse (${Math.round(timeoutMs / 1000)}s) : ${req.url}`);
|
||||
throw new Error(e.message || String(err));
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function startStream(sender: WebContents, id: string, req: HttpProxyRequest): Promise<HttpStreamStart> {
|
||||
assertUrl(req.url);
|
||||
const controller = new AbortController();
|
||||
activeStreams.set(id, controller);
|
||||
const send = (event: HttpStreamEvent) => {
|
||||
if (!sender.isDestroyed()) sender.send(IPC.httpStreamEvent, event);
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(req.url, buildInit(req, controller.signal));
|
||||
} catch (err) {
|
||||
activeStreams.delete(id);
|
||||
throw new Error((err as Error).message || String(err));
|
||||
}
|
||||
|
||||
const start: HttpStreamStart = {
|
||||
id,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headersToObject(response.headers)
|
||||
};
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
if (!response.body) {
|
||||
send({ id, type: 'end' });
|
||||
return;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
// Hermes runs can be long, but a totally silent stream is abandoned after the idle timeout.
|
||||
const idleMs = req.timeoutMs ?? 10 * 60_000;
|
||||
for (;;) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const idle = new Promise<never>((_, reject) => {
|
||||
idleTimer = setTimeout(() => reject(new Error('Flux silencieux (timeout)')), idleMs);
|
||||
});
|
||||
const { done, value } = await Promise.race([reader.read(), idle]);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
if (done) break;
|
||||
send({ id, type: 'chunk', text: decoder.decode(value, { stream: true }) });
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) send({ id, type: 'chunk', text: tail });
|
||||
send({ id, type: 'end' });
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
if (e.name === 'AbortError') send({ id, type: 'end' });
|
||||
else send({ id, type: 'error', message: e.message || String(err) });
|
||||
} finally {
|
||||
activeStreams.delete(id);
|
||||
}
|
||||
};
|
||||
void pump();
|
||||
return start;
|
||||
}
|
||||
|
||||
export function abortAllStreams(): void {
|
||||
for (const controller of activeStreams.values()) controller.abort();
|
||||
activeStreams.clear();
|
||||
}
|
||||
|
||||
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.on(IPC.httpStreamAbort, (_e, id: string) => {
|
||||
const controller = activeStreams.get(id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
activeStreams.delete(id);
|
||||
log('DEBUG', 'http', `stream ${id} aborted by renderer`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { app, ipcMain } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC } from '../../shared/ipc';
|
||||
|
||||
type StoreData = Record<string, unknown>;
|
||||
|
||||
let cache: StoreData | null = null;
|
||||
|
||||
function storePath(): string {
|
||||
return path.join(app.getPath('userData'), 'userdata.json');
|
||||
}
|
||||
|
||||
function load(): StoreData {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
cache = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as StoreData;
|
||||
} catch {
|
||||
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);
|
||||
}
|
||||
|
||||
export function storeGet<T = unknown>(key: string): T | null {
|
||||
const data = load();
|
||||
return (data[key] as T) ?? null;
|
||||
}
|
||||
|
||||
export function storeSet(key: string, value: unknown): void {
|
||||
const data = load();
|
||||
data[key] = value;
|
||||
persist();
|
||||
}
|
||||
|
||||
export function registerStoreIpc(): void {
|
||||
ipcMain.handle(IPC.storeGet, (_e, key: string) => storeGet(key));
|
||||
ipcMain.handle(IPC.storeSet, (_e, key: string, value: unknown) => {
|
||||
storeSet(key, value);
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(IPC.storeDelete, (_e, key: string) => {
|
||||
const data = load();
|
||||
delete data[key];
|
||||
persist();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import os from 'node:os';
|
||||
import { IPC, type SystemMetrics } from '../../shared/ipc';
|
||||
|
||||
interface CpuSnapshot {
|
||||
idle: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function snapshot(): CpuSnapshot {
|
||||
let idle = 0;
|
||||
let total = 0;
|
||||
for (const cpu of os.cpus()) {
|
||||
const t = cpu.times;
|
||||
idle += t.idle;
|
||||
total += t.user + t.nice + t.sys + t.idle + t.irq;
|
||||
}
|
||||
return { idle, total };
|
||||
}
|
||||
|
||||
let last = snapshot();
|
||||
|
||||
function cpuLoadPct(): number {
|
||||
const now = snapshot();
|
||||
const idleDiff = now.idle - last.idle;
|
||||
const totalDiff = now.total - last.total;
|
||||
last = now;
|
||||
if (totalDiff <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, 100 * (1 - idleDiff / totalDiff)));
|
||||
}
|
||||
|
||||
export function collectMetrics(): SystemMetrics {
|
||||
const cpus = os.cpus();
|
||||
const totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
return {
|
||||
hostname: os.hostname().toUpperCase(),
|
||||
platform: `${os.platform()} ${os.release()}`,
|
||||
cpuModel: cpus[0]?.model?.trim() ?? 'CPU',
|
||||
cpuCores: cpus.length,
|
||||
cpuLoad: Math.round(cpuLoadPct() * 10) / 10,
|
||||
cpuFreqMhz: cpus[0]?.speed ?? 0,
|
||||
memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 1000) / 10 : 0,
|
||||
memTotalGb: Math.round((totalMem / 1024 ** 3) * 10) / 10,
|
||||
uptimeSec: Math.round(os.uptime())
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTelemetryIpc(): void {
|
||||
ipcMain.handle(IPC.metrics, () => collectMetrics());
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { LogEntry } from '../shared/ipc';
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export function getLogPath(): string {
|
||||
return path.join(app.getPath('userData'), 'eveflow.log');
|
||||
}
|
||||
|
||||
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');
|
||||
} catch (err) {
|
||||
console.error('[logger] cannot write log file:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export function log(level: LogEntry['level'], tag: string, message: string, data?: unknown): void {
|
||||
const entry: LogEntry = { ts: new Date().toISOString(), level, tag, message, data };
|
||||
const consoleLine = `[${tag}] ${message}`;
|
||||
if (level === 'ERROR') console.error(consoleLine, data ?? '');
|
||||
else if (level === 'WARN') console.warn(consoleLine, data ?? '');
|
||||
else console.log(consoleLine, data ?? '');
|
||||
writeLog(entry);
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
return text.length > 4000 ? text.slice(0, 4000) + '...' : text;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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 { 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 { getWebhookStatus, startWebhookServer, stopWebhookServer } from './webhook';
|
||||
|
||||
// Audio playback must never be blocked behind a user gesture (TTS starts on incoming events).
|
||||
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
|
||||
|
||||
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');
|
||||
return { enabled: true, port: 7842, secret: '', ...(saved ?? {}) };
|
||||
}
|
||||
|
||||
async function bootWebhook(): Promise<void> {
|
||||
const settings = readWebhookSettings();
|
||||
if (settings.enabled === false) {
|
||||
await stopWebhookServer();
|
||||
return;
|
||||
}
|
||||
await startWebhookServer(getMainWindow, { port: Number(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()],
|
||||
['CommandOrControl+Shift+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());
|
||||
ipcMain.on(IPC.windowSetMode, () => setTimeout(rebuildMenu, 0));
|
||||
} 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) => writeLog(entry));
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
log('INFO', 'app', `EveFlow ${app.getVersion()} starting (electron ${process.versions.electron}, chrome ${process.versions.chrome})`);
|
||||
configureSession();
|
||||
registerStoreIpc();
|
||||
registerHttpIpc();
|
||||
registerTelemetryIpc();
|
||||
registerFilesIpc();
|
||||
registerCoreIpc();
|
||||
createMainWindow();
|
||||
createTray();
|
||||
registerShortcuts();
|
||||
await bootWebhook();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
abortAllStreams();
|
||||
void stopWebhookServer();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import {
|
||||
IPC,
|
||||
type AppInfo,
|
||||
type HermesPushEvent,
|
||||
type HotkeyEvent,
|
||||
type HttpProxyRequest,
|
||||
type HttpProxyResponse,
|
||||
type HttpStreamEvent,
|
||||
type HttpStreamStart,
|
||||
type LogEntry,
|
||||
type SystemMetrics,
|
||||
type WebhookStatus,
|
||||
type WindowMode
|
||||
} from '../shared/ipc';
|
||||
import type { EveFlowBridge, Unsubscribe } from '../shared/bridge';
|
||||
|
||||
function subscribe<T>(channel: string, callback: (payload: T) => void): Unsubscribe {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: T) => callback(payload);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
log: (entry: LogEntry) => ipcRenderer.send(IPC.log, entry),
|
||||
store: {
|
||||
get: <T = unknown>(key: string) => ipcRenderer.invoke(IPC.storeGet, key) as Promise<T | null>,
|
||||
set: (key: string, value: unknown) => ipcRenderer.invoke(IPC.storeSet, key, value) as Promise<boolean>,
|
||||
delete: (key: string) => ipcRenderer.invoke(IPC.storeDelete, key) as Promise<boolean>
|
||||
},
|
||||
http: {
|
||||
fetch: (req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpFetch, req) as Promise<HttpProxyResponse>,
|
||||
streamStart: (id: string, req: HttpProxyRequest) => ipcRenderer.invoke(IPC.httpStreamStart, id, req) as Promise<HttpStreamStart>,
|
||||
streamAbort: (id: string) => ipcRenderer.send(IPC.httpStreamAbort, id),
|
||||
onStreamEvent: (cb: (event: HttpStreamEvent) => void) => subscribe<HttpStreamEvent>(IPC.httpStreamEvent, cb)
|
||||
},
|
||||
system: {
|
||||
metrics: () => ipcRenderer.invoke(IPC.metrics) as Promise<SystemMetrics>,
|
||||
appInfo: () => ipcRenderer.invoke(IPC.appInfo) as Promise<AppInfo>
|
||||
},
|
||||
files: {
|
||||
readLocal: (filePath: string) => ipcRenderer.invoke(IPC.readLocalFile, filePath) as Promise<string>,
|
||||
writeShared: (filename: string, content: string, isBase64 = false) =>
|
||||
ipcRenderer.invoke(IPC.writeSharedFile, filename, content, isBase64) as Promise<{ path: string; url: string }>,
|
||||
openPath: (filePath: string) => ipcRenderer.invoke(IPC.openPath, filePath) as Promise<boolean>,
|
||||
showInFolder: (filePath: string) => ipcRenderer.invoke(IPC.showInFolder, filePath) as Promise<boolean>
|
||||
},
|
||||
hermes: {
|
||||
onPush: (cb: (event: HermesPushEvent) => void) => subscribe<HermesPushEvent>(IPC.hermesPush, cb),
|
||||
webhookStatus: () => ipcRenderer.invoke(IPC.webhookStatus) as Promise<WebhookStatus>,
|
||||
webhookRestart: () => ipcRenderer.invoke(IPC.webhookRestart) as Promise<WebhookStatus>
|
||||
},
|
||||
hotkeys: {
|
||||
on: (cb: (event: HotkeyEvent) => void) => subscribe<HotkeyEvent>(IPC.hotkey, cb)
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('eveflow', api);
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Local webhook server: Hermes (cron deliveries, gateway mirrors, scripts) pushes JSON here
|
||||
* and EveFlow displays / speaks the result. POST /eveflow/hook, GET /health.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC, type WebhookStatus } from '../shared/ipc';
|
||||
import { normalizeHermesPush } from '../shared/hermesPush';
|
||||
import { log } from './logger';
|
||||
|
||||
export const WEBHOOK_PATH = '/eveflow/hook';
|
||||
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export interface WebhookOptions {
|
||||
port: number;
|
||||
secret?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let status: WebhookStatus = { listening: false, port: 7842, path: WEBHOOK_PATH, secretConfigured: false };
|
||||
|
||||
function secretMatches(provided: string | undefined, expected: string): boolean {
|
||||
if (!provided) return false;
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(expected);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function getWebhookStatus(): WebhookStatus {
|
||||
return status;
|
||||
}
|
||||
|
||||
export function stopWebhookServer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!server) return resolve();
|
||||
server.close(() => resolve());
|
||||
server = null;
|
||||
status = { ...status, listening: false };
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
const json = (code: number, payload: unknown) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
|
||||
return json(200, { ok: true, app: 'eveflow', path: WEBHOOK_PATH });
|
||||
}
|
||||
if (req.method !== 'POST' || !req.url?.startsWith(WEBHOOK_PATH)) {
|
||||
return json(404, { error: 'Not Found' });
|
||||
}
|
||||
|
||||
if (secret) {
|
||||
const auth = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
||||
const custom = req.headers['x-eveflow-secret'];
|
||||
const provided = typeof custom === 'string' ? custom : auth;
|
||||
if (!secretMatches(provided, secret)) {
|
||||
log('WARN', 'webhook', `rejected push from ${req.socket.remoteAddress}: bad secret`);
|
||||
return json(401, { error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
let body = '';
|
||||
let tooLarge = false;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (tooLarge) return;
|
||||
body += chunk.toString('utf8');
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
tooLarge = true;
|
||||
json(413, { error: 'Payload too large' });
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (tooLarge) return;
|
||||
try {
|
||||
const raw: unknown = body.trim() ? JSON.parse(body) : {};
|
||||
const events = normalizeHermesPush(raw);
|
||||
log('INFO', 'webhook', `${events.length} event(s) from ${events[0]?.source ?? '?'} (${req.socket.remoteAddress})`);
|
||||
const win = getWindow();
|
||||
if (win && !win.isDestroyed()) {
|
||||
for (const event of events) win.webContents.send(IPC.hermesPush, event);
|
||||
}
|
||||
json(200, { ok: true, events: events.length });
|
||||
} catch (err) {
|
||||
log('WARN', 'webhook', `invalid JSON payload: ${(err as Error).message}`);
|
||||
json(400, { error: 'Invalid JSON' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server!.once('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;
|
||||
resolve(status);
|
||||
});
|
||||
server!.listen(port, host, () => {
|
||||
log('INFO', 'webhook', `listening on http://${host}:${port}${WEBHOOK_PATH}`);
|
||||
status = { listening: true, port, path: WEBHOOK_PATH, secretConfigured: !!secret };
|
||||
resolve(status);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { app, BrowserWindow, screen, shell } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IPC, type WindowMode } from '../shared/ipc';
|
||||
import { log } from './logger';
|
||||
|
||||
const HUD_SIZE = { width: 1320, height: 860, minWidth: 980, minHeight: 640 };
|
||||
const COMPACT_SIZE = { width: 380, height: 620, minWidth: 320, minHeight: 420 };
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentMode: WindowMode = 'hud';
|
||||
let hudBounds: Electron.Rectangle | null = null;
|
||||
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
export function getWindowMode(): WindowMode {
|
||||
return currentMode;
|
||||
}
|
||||
|
||||
function fallbackHtml(reason: string): string {
|
||||
return `<!doctype html><html><body style="margin:0;font-family:Segoe UI,Arial,sans-serif;background:#03070d;color:#9fe9ff;display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<div style="max-width:560px;padding:28px;border:1px solid #1d5c70;border-radius:14px;background:#061018">
|
||||
<h1 style="margin:0 0 12px;font-size:20px;letter-spacing:.08em">EVEFLOW - INTERFACE INDISPONIBLE</h1>
|
||||
<p style="line-height:1.5">${reason}</p><p>Lancez <code>npm run build</code> puis relancez l'application.</p></div></body></html>`;
|
||||
}
|
||||
|
||||
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||
const devUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (devUrl && !app.isPackaged) {
|
||||
try {
|
||||
await win.loadURL(devUrl);
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
return;
|
||||
} catch (err) {
|
||||
log('WARN', 'window', `dev server unreachable (${(err as Error).message}), loading built bundle`);
|
||||
}
|
||||
}
|
||||
const builtIndex = path.join(__dirname, '..', '..', 'dist', 'index.html');
|
||||
if (fs.existsSync(builtIndex)) {
|
||||
await win.loadFile(builtIndex);
|
||||
return;
|
||||
}
|
||||
await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml('Le bundle dist/index.html est introuvable.')));
|
||||
}
|
||||
|
||||
export function createMainWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
...HUD_SIZE,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
backgroundColor: '#00000000',
|
||||
hasShadow: true,
|
||||
show: false,
|
||||
title: 'EveFlow',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false,
|
||||
backgroundThrottling: false,
|
||||
spellcheck: false
|
||||
}
|
||||
});
|
||||
mainWindow = win;
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) win.show();
|
||||
}, 60);
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
loadRenderer(win).catch((err) => {
|
||||
log('ERROR', 'window', `renderer load failed: ${(err as Error).message}`);
|
||||
void win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(fallbackHtml("Une erreur inattendue a interrompu le chargement de l'interface.")));
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function setWindowMode(mode: WindowMode): void {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (mode === currentMode) {
|
||||
win.webContents.send(IPC.windowModeChanged, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'compact') {
|
||||
hudBounds = win.getBounds();
|
||||
const display = screen.getDisplayMatching(win.getBounds());
|
||||
const area = display.workArea;
|
||||
win.setMinimumSize(COMPACT_SIZE.minWidth, COMPACT_SIZE.minHeight);
|
||||
win.setBounds({
|
||||
x: area.x + area.width - COMPACT_SIZE.width - 24,
|
||||
y: area.y + area.height - COMPACT_SIZE.height - 24,
|
||||
width: COMPACT_SIZE.width,
|
||||
height: COMPACT_SIZE.height
|
||||
}, true);
|
||||
win.setAlwaysOnTop(true, 'floating');
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
} else {
|
||||
win.setAlwaysOnTop(false);
|
||||
win.setVisibleOnAllWorkspaces(false);
|
||||
win.setMinimumSize(HUD_SIZE.minWidth, HUD_SIZE.minHeight);
|
||||
if (hudBounds) win.setBounds(hudBounds, true);
|
||||
else {
|
||||
win.setSize(HUD_SIZE.width, HUD_SIZE.height, true);
|
||||
win.center();
|
||||
}
|
||||
}
|
||||
currentMode = mode;
|
||||
win.webContents.send(IPC.windowModeChanged, mode);
|
||||
}
|
||||
|
||||
export function toggleWindowVisibility(): void {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
if (win.isVisible() && win.isFocused()) {
|
||||
win.hide();
|
||||
} else {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user