'use strict'; const { app, BrowserWindow, ipcMain, Menu, globalShortcut, systemPreferences, nativeImage } = require('electron'); const path = require('path'); const os = require('os'); const fs = require('fs'); const http = require('http'); const { spawn, execSync, execFile } = require('child_process'); // ============================================================ // CONFIG // ============================================================ const PORT = 52736; const WHISPER_PORT = 52737; function pickWhisperModel() { if (process.env.JARVIS_WHISPER_MODEL) return process.env.JARVIS_WHISPER_MODEL; const dir = path.join(os.homedir(), 'whisper-models'); const prefer = ['ggml-medium.bin', 'ggml-small.bin', 'ggml-base.bin']; for (const f of prefer) { const p = path.join(dir, f); if (fs.existsSync(p)) return p; } return path.join(dir, 'ggml-base.bin'); } const WHISPER_MODEL = pickWhisperModel(); const SYSTEM_PROMPT = `You are J.A.R.V.I.S. (Just A Rather Very Intelligent System), the AI assistant from Iron Man. You are highly intelligent, efficient, and professional, with a touch of refined British wit. Critical rules: - Your responses will be spoken aloud by a text-to-speech engine. Be concise and conversational. No markdown, no bullet points, no asterisks, no code blocks in your verbal responses. - You can use the Bash tool to execute commands on the user's Mac for real tasks. - Respond in the same language the user speaks (French or English). - When you run commands, briefly describe what you are doing in natural speech. - Address the user respectfully.`; // ============================================================ // LOCAL HTTP SERVER (sert le renderer sur http://localhost — requis // pour que l'API getUserMedia fonctionne dans un contexte sécurisé) // ============================================================ function startLocalServer() { const mime = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript' }; http.createServer((req, res) => { const url = req.url.split('?')[0]; if (url.startsWith('/whisper/')) { const proxyReq = http.request({ host: '127.0.0.1', port: WHISPER_PORT, method: req.method, path: url.replace('/whisper', ''), headers: req.headers, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }); proxyReq.on('error', (e) => { res.writeHead(502); res.end(e.message); }); req.pipe(proxyReq); return; } const filePath = path.join(__dirname, url === '/' ? 'index.html' : url); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end('Not found'); return; } res.writeHead(200, { 'Content-Type': mime[path.extname(filePath)] || 'text/plain' }); res.end(data); }); }).listen(PORT, '127.0.0.1'); } // ============================================================ // WHISPER SERVER (daemon local, modèle chargé une seule fois) // ============================================================ let whisperProc = null; function startWhisperServer() { if (!fs.existsSync(WHISPER_MODEL)) { console.error('[JARVIS] modèle Whisper introuvable :', WHISPER_MODEL); console.error('[JARVIS] Télécharge-le avec :'); console.error(' mkdir -p ~/whisper-models && curl -L -o ~/whisper-models/ggml-base.bin \\'); console.error(' https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin'); return false; } const whisperBin = findBin('whisper-server'); if (!whisperBin) { console.error('[JARVIS] whisper-server introuvable dans le PATH. Installe-le avec : brew install whisper-cpp'); return false; } console.log('[JARVIS] whisper binary :', whisperBin); whisperProc = spawn(whisperBin, [ '-m', WHISPER_MODEL, '--host', '127.0.0.1', '--port', String(WHISPER_PORT), '-l', 'auto', '-nt', '-t', '6', ], { stdio: ['ignore', 'pipe', 'pipe'] }); whisperProc.on('error', (e) => { console.error('[JARVIS] whisper-server spawn error :', e.message); whisperProc = null; }); whisperProc.stdout.on('data', d => process.stdout.write('[whisper] ' + d)); whisperProc.stderr.on('data', d => process.stderr.write('[whisper] ' + d)); whisperProc.on('exit', (code) => { console.log('[JARVIS] whisper-server terminé, code =', code); whisperProc = null; }); return true; } function stopWhisperServer() { if (whisperProc) { try { whisperProc.kill('SIGTERM'); } catch (_) {} whisperProc = null; } } // ============================================================ // CLAUDE CLI // ============================================================ function findBin(name, extraCandidates = []) { try { const result = execSync(`/bin/zsh --login -c "which ${name}"`, { encoding: 'utf8', timeout: 5000, }).trim(); if (result && fs.existsSync(result)) return result; } catch (_) {} const candidates = [ `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`, `/usr/bin/${name}`, `${os.homedir()}/.local/bin/${name}`, ...extraCandidates, ]; for (const p of candidates) { try { fs.accessSync(p, fs.constants.X_OK); return p; } catch (_) {} } return null; } function findClaudeBin() { return findBin('claude', [`${os.homedir()}/.npm-global/bin/claude`]) || 'claude'; } const CLAUDE_BIN = findClaudeBin(); console.log('[JARVIS] claude binary :', CLAUDE_BIN); function stripAnsi(str) { // eslint-disable-next-line no-control-regex return str.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '').trim(); } function buildPrompt(userMessage) { let prompt = SYSTEM_PROMPT + '\n\n'; if (history.length > 0) { prompt += 'CONVERSATION HISTORY:\n'; for (const msg of history) { const label = msg.role === 'user' ? 'USER' : 'JARVIS'; prompt += `${label}: ${msg.content}\n`; } prompt += '\n'; } prompt += `USER: ${userMessage}\n\nJARVIS:`; return prompt; } const CLAUDE_CWD = '/Users/david/Documents/jarvis_result'; function ensureClaudeCwd() { try { if (!fs.existsSync(CLAUDE_CWD)) { fs.mkdirSync(CLAUDE_CWD, { recursive: true }); console.log('[JARVIS] créé dossier', CLAUDE_CWD); } } catch (e) { console.error('[JARVIS] impossible de créer', CLAUDE_CWD, ':', e.message); } } async function askClaude(userMessage) { const prompt = buildPrompt(userMessage); ensureClaudeCwd(); // PATH enrichi pour que Claude trouve git, node, gh, etc. même en mode .app const env = { ...process.env, PATH: [ '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', `${os.homedir()}/.local/bin`, `${os.homedir()}/.npm-global/bin`, process.env.PATH || '', ].join(':'), HOME: os.homedir(), }; return new Promise((resolve) => { const args = [ '-p', prompt, '--output-format', 'text', '--model', 'opus', '--allowed-tools', 'Bash', '--dangerously-skip-permissions', ]; execFile(CLAUDE_BIN, args, { timeout: 120000, maxBuffer: 2 * 1024 * 1024, cwd: CLAUDE_CWD, env, }, (err, stdout, stderr) => { if (err) { console.error('[JARVIS claude error]', err.message, stderr); resolve({ success: false, error: err.message || stderr }); return; } const response = stripAnsi(stdout); console.log('[JARVIS response]', response.slice(0, 200)); history.push({ role: 'user', content: userMessage }); history.push({ role: 'assistant', content: response }); if (history.length > 20) history = history.slice(-20); resolve({ success: true, response }); }); }); } // ============================================================ // STATE // ============================================================ let mainWin = null; let speakProc = null; let alwaysOnTop = true; let history = []; let BEST_VOICE_FR = null; let BEST_VOICE_EN = null; function pickBestVoices() { try { const out = execSync('/usr/bin/say -v "?"', { encoding: 'utf8', timeout: 5000 }); const lines = out.split('\n').filter(Boolean); const parse = lines.map(l => { const m = l.match(/^(.+?)\s+([a-z]{2}[-_][A-Z]{2})\s+/); if (!m) return null; const name = m[1].trim(); const lang = m[2]; const premium = /\(Premium\)/i.test(name); const enhanced = /\(Enhanced\)/i.test(name); return { name, lang, premium, enhanced }; }).filter(Boolean); const rank = v => (v.premium ? 3 : v.enhanced ? 2 : 1); const fr = parse.filter(v => v.lang.startsWith('fr')).sort((a, b) => rank(b) - rank(a))[0]; const en = parse.filter(v => v.lang.startsWith('en')).sort((a, b) => rank(b) - rank(a))[0]; BEST_VOICE_FR = fr ? fr.name : null; BEST_VOICE_EN = en ? en.name : null; console.log('[JARVIS] voix FR :', BEST_VOICE_FR, '| voix EN :', BEST_VOICE_EN); } catch (e) { console.error('[JARVIS] pickBestVoices :', e.message); } } // ============================================================ // WINDOW // ============================================================ function createWindow() { mainWin = new BrowserWindow({ width: 560, height: 600, transparent: true, frame: false, alwaysOnTop: true, hasShadow: false, backgroundColor: '#00000000', resizable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false, }, }); mainWin.setAlwaysOnTop(true, 'floating'); mainWin.center(); mainWin.loadURL(`http://localhost:${PORT}`); mainWin.webContents.on('render-process-gone', (_e, d) => console.error('[JARVIS renderer gone]', d)); mainWin.webContents.on('did-fail-load', (_e, code, desc) => console.error('[JARVIS load fail]', code, desc)); mainWin.webContents.session.setPermissionRequestHandler((_wc, permission, callback) => { callback(['microphone', 'media', 'audioCapture'].includes(permission)); }); mainWin.webContents.session.setPermissionCheckHandler((_wc, permission) => { return ['microphone', 'media', 'audioCapture'].includes(permission); }); mainWin.on('closed', () => { mainWin = null; }); } // ============================================================ // IPC // ============================================================ ipcMain.handle('askClaude', async (_event, message) => askClaude(message)); ipcMain.handle('whisperUrl', () => `http://localhost:${PORT}/whisper/inference`); ipcMain.handle('setDockIcon', (_event, dataUrl) => { try { const img = nativeImage.createFromDataURL(dataUrl); if (!img.isEmpty() && app.dock) app.dock.setIcon(img); if (mainWin) mainWin.setIcon(img); } catch (e) { console.error('[JARVIS] setDockIcon :', e.message); } }); ipcMain.handle('speak', async (_event, text) => { if (!text) return; if (speakProc) { speakProc.kill('SIGTERM'); speakProc = null; } const frPattern = /\b(je|tu|il|elle|nous|vous|ils|le|la|les|un|une|des|est|sont|avec|pour|dans|sur|qui|que|très|aussi|mais|patron|bonjour|merci|oui|non|votre|notre)\b/i; const isFr = frPattern.test(text); const voice = isFr ? (BEST_VOICE_FR || 'Thomas') : (BEST_VOICE_EN || 'Alex'); return new Promise((resolve) => { speakProc = spawn('/usr/bin/say', ['-v', voice, '-r', '185', text]); speakProc.on('close', (code) => { console.log('[say] exit', code, 'voice=', voice); speakProc = null; resolve(); }); speakProc.on('error', (e) => { speakProc = null; console.error('[say]', e.message); resolve(); }); }); }); ipcMain.handle('stopSpeak', () => { if (speakProc) { speakProc.kill('SIGTERM'); speakProc = null; } }); ipcMain.handle('showContextMenu', () => { const menu = Menu.buildFromTemplate([ { label: `${alwaysOnTop ? '✓' : ' '} Toujours au-dessus / Always on Top`, click: () => { alwaysOnTop = !alwaysOnTop; mainWin?.setAlwaysOnTop(alwaysOnTop, 'floating'); }, }, { type: 'separator' }, { label: 'Effacer historique / Clear History', click: () => { history = []; } }, { type: 'separator' }, { label: 'Quitter / Quit JARVIS', click: () => app.quit() }, ]); menu.popup({ window: mainWin }); }); // ============================================================ // LIFECYCLE // ============================================================ app.disableHardwareAcceleration(); app.commandLine.appendSwitch('disable-features', 'WebRtcHideLocalIpsWithMdns,HardwareMediaKeyHandling,MediaSessionService'); app.commandLine.appendSwitch('no-sandbox'); app.whenReady().then(async () => { try { const status = systemPreferences.getMediaAccessStatus('microphone'); console.log('[JARVIS] mic access status :', status); if (status !== 'granted') { const ok = await systemPreferences.askForMediaAccess('microphone'); console.log('[JARVIS] mic access granted :', ok); } } catch (e) { console.error('[JARVIS] mic permission error :', e.message); } pickBestVoices(); ensureClaudeCwd(); startLocalServer(); startWhisperServer(); createWindow(); globalShortcut.register('CommandOrControl+Q', () => app.quit()); }); app.on('before-quit', () => { stopWhisperServer(); if (speakProc) { try { speakProc.kill('SIGTERM'); } catch (_) {} } }); app.on('window-all-closed', () => { globalShortcut.unregisterAll(); app.quit(); }); app.on('activate', () => { if (!mainWin) createWindow(); });