const { app, BrowserWindow, ipcMain, dialog } = require("electron"); const { spawn, exec } = require("child_process"); const path = require("path"); const http = require("http"); const https = require("https"); const fs = require("fs"); const os = require("os"); let win; let engineProcess; let ollamaProcess; // only set if WE started Ollama // ── Paths ───────────────────────────────────────────────────────────────────── const isDev = !app.isPackaged; const isPkg = app.isPackaged; function getEnginePath() { if (isPkg) { // Bundled exe next to app return path.join(process.resourcesPath, "engine", "engine.exe"); } // Dev: run server.py directly return null; } // ── Engine start/stop ───────────────────────────────────────────────────────── function startEngine() { const exePath = getEnginePath(); if (exePath && fs.existsSync(exePath)) { // Production: run compiled exe engineProcess = spawn(exePath, [], { stdio: ["pipe","pipe","pipe"] }); } else { // Dev: run via Python const pyScript = path.join(__dirname, "../engine/server.py"); engineProcess = spawn("py", ["-3.11", pyScript], { stdio: ["pipe","pipe","pipe"] }); } engineProcess.stdout.on("data", d => console.log("[engine]", d.toString().trim())); engineProcess.stderr.on("data", d => console.error("[engine err]", d.toString().trim())); engineProcess.on("error", err => console.error("[engine] failed to start:", err.message)); engineProcess.on("exit", code => console.log("[engine] exited with code", code)); } function stopEngine() { if (engineProcess) { try { // taskkill /F /T kills the process and all its children on Windows spawn("taskkill", ["/F", "/T", "/PID", engineProcess.pid.toString()], { stdio: "ignore" }); } catch {} engineProcess = null; } // Also kill any orphaned engine.exe by name as a fallback try { spawn("taskkill", ["/F", "/IM", "engine.exe"], { stdio: "ignore" }); } catch {} } // ── Ollama detection & install ──────────────────────────────────────────────── function isOllamaInstalled() { return new Promise(resolve => { exec("ollama --version", err => resolve(!err)); }); } function isOllamaRunning() { return new Promise(resolve => { const req = http.get("http://127.0.0.1:11434/api/tags", res => { resolve(res.statusCode === 200); }); req.on("error", () => resolve(false)); req.setTimeout(2000, () => { req.destroy(); resolve(false); }); }); } function startOllama() { return new Promise(resolve => { ollamaProcess = spawn("ollama", ["serve"], { stdio: "ignore", detached: false, shell: true, }); ollamaProcess.on("error", () => resolve(false)); // Poll until it responds instead of fixed wait let attempts = 0; const check = setInterval(async () => { attempts++; const up = await isOllamaRunning(); if (up || attempts > 20) { clearInterval(check); resolve(up); } }, 1000); }); } function stopOllama() { if (ollamaProcess) { try { ollamaProcess.kill(); } catch {} ollamaProcess = null; } } // Safe send — never throws if window is destroyed function safeSend(channel, data) { try { if (win && !win.isDestroyed()) win.webContents.send(channel, data); } catch {} } function downloadFile(url, dest, onProgress) { return new Promise((resolve, reject) => { // Use a temp path while downloading, rename on completion const tmp = dest + ".part"; let file = fs.createWriteStream(tmp, { flags: "w" }); let settled = false; function done(err, result) { if (settled) return; settled = true; if (err) { try { file.destroy(); } catch {} try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {} reject(err); } else { file.end(() => { try { fs.renameSync(tmp, dest); } catch(e) { reject(e); return; } resolve(result); }); } } file.on("error", err => done(err)); function doGet(targetUrl, redirectCount = 0) { if (redirectCount > 10) { done(new Error("Too many redirects")); return; } const u = new URL(targetUrl); const options = { hostname: u.hostname, path: u.pathname + u.search, headers: { "User-Agent": "bRunner/1.0" } }; const proto = u.protocol === "https:" ? https : http; proto.get(options, res => { if ([301,302,307,308].includes(res.statusCode)) { const loc = res.headers.location; const next = loc.startsWith("http") ? loc : `${u.protocol}//${u.hostname}${loc}`; res.resume(); doGet(next, redirectCount + 1); return; } if (res.statusCode !== 200) { done(new Error(`HTTP ${res.statusCode}`)); return; } const total = parseInt(res.headers["content-length"] || "0"); let received = 0; res.on("data", chunk => { if (settled) return; received += chunk.length; try { file.write(chunk); } catch {} if (total > 0 && onProgress) onProgress(Math.round((received / total) * 100)); }); res.on("end", () => done(null, dest)); res.on("error", err => done(err)); }).on("error", err => done(err)); } doGet(url); }); } function downloadOllamaInstaller() { return new Promise((resolve, reject) => { const dest = path.join(app.getPath("userData"), "OllamaSetup.exe"); [dest, dest + ".part"].forEach(f => { try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch {} }); safeSend("ollama-install-progress", { status: "Downloading Ollama...", pct: 0 }); const url = "https://github.com/ollama/ollama/releases/latest/download/OllamaSetup.exe"; // Use full system32 path — Electron may not have PATH set correctly const curlPath = "C:\\Windows\\System32\\curl.exe"; const curlExists = fs.existsSync(curlPath); console.log("[download] curl exists:", curlExists, "dest:", dest); if (!curlExists) { reject(new Error("curl.exe not found at C:\\Windows\\System32\\curl.exe — Windows may be too old")); return; } const proc = spawn(curlPath, ["-L", "--silent", "--show-error", "--output", dest, url], { stdio: ["ignore", "pipe", "pipe"] }); let pct = 0; const ticker = setInterval(() => { if (pct < 85) { pct += 2; safeSend("ollama-install-progress", { status: "Downloading Ollama...", pct }); } }, 3000); let errOut = ""; proc.stdout.on("data", d => console.log("[curl stdout]", d.toString())); proc.stderr.on("data", d => { errOut += d.toString(); console.error("[curl stderr]", d.toString()); }); proc.on("exit", code => { clearInterval(ticker); const exists = fs.existsSync(dest); const size = exists ? fs.statSync(dest).size : 0; console.log("[curl] exit:", code, "exists:", exists, "size:", size); if (code === 0 && exists && size > 1000000) { safeSend("ollama-install-progress", { status: "Download complete.", pct: 100 }); resolve(dest); } else { reject(new Error(`Download failed — code ${code}, size ${size}, error: ${errOut.trim() || "none"}`)); } }); proc.on("error", err => { clearInterval(ticker); reject(new Error(`spawn error: ${err.message}`)); }); }); } function runOllamaInstaller(installerPath) { return new Promise((resolve, reject) => { safeSend("ollama-install-progress", { status: "Installing Ollama silently..." }); console.log("[install] running:", installerPath); // Use cmd to run installer minimized and wait for it to finish const proc = spawn("cmd", [ "/c", "start", "/wait", "/min", "", installerPath, "/S" ], { stdio: "ignore", windowsHide: true, }); proc.on("error", err => { console.error("[install error]", err); reject(new Error(`Installer error: ${err.message}`)); }); proc.on("exit", code => { console.log("[install] exit code:", code); safeSend("ollama-install-progress", { status: "Install complete, starting Ollama..." }); setTimeout(resolve, 4000); }); }); } async function ensureOllama() { safeSend("startup-status", "Checking Ollama..."); const running = await isOllamaRunning(); if (running) { safeSend("startup-status", "Ollama ready."); return true; } const installed = await isOllamaInstalled(); if (installed) { safeSend("startup-status", "Starting Ollama..."); await startOllama(); const nowRunning = await isOllamaRunning(); if (nowRunning) { safeSend("startup-status", "Ollama ready."); return true; } } // Need to install safeSend("startup-status", "Ollama not found — installing..."); try { const installer = await downloadOllamaInstaller(); await runOllamaInstaller(installer); safeSend("startup-status", "Starting Ollama..."); const started = await startOllama(); if (!started) { // Ollama may have auto-started during install, check again const running = await isOllamaRunning(); if (!running) throw new Error("Ollama installed but failed to start."); } safeSend("startup-status", "Ollama ready."); return true; } catch(e) { const msg = `Ollama install failed: ${e.message}\n\nPlease install Ollama manually from https://ollama.com and restart bRunner.`; safeSend("startup-status", "Install failed — see error dialog."); if (win && !win.isDestroyed()) dialog.showErrorBox("Ollama Install Failed", msg); return false; } } // ── Window ──────────────────────────────────────────────────────────────────── function createWindow() { win = new BrowserWindow({ width: 1280, height: 860, minWidth: 800, minHeight: 600, frame: false, backgroundColor: "#08080d", webPreferences: { nodeIntegration: true, contextIsolation: false, preload: path.join(__dirname, "preload.js"), }, }); win.loadFile(path.join(__dirname, "renderer/index.html")); win.webContents.on("did-finish-load", async () => { // Step 1: ensure Ollama is installed and running — MUST complete first safeSend("startup-status", "Checking Ollama..."); const ollamaOk = await ensureOllama(); if (!ollamaOk) { safeSend("startup-status", "Ollama unavailable. Please install manually and restart."); return; // don't start engine or signal ready } // Step 2: start engine only after Ollama is confirmed up safeSend("startup-status", "Starting engine..."); startEngine(); // Step 3: poll engine until ready, then signal UI pollEngine(); }); } function pollEngine(attempts = 0) { if (attempts > 30) { safeSend("startup-status", "Engine failed to start."); return; } const req = http.get("http://127.0.0.1:5001/health", res => { safeSend("engine-ready"); }); req.on("error", () => setTimeout(() => pollEngine(attempts + 1), 1000)); req.setTimeout(1000, () => { req.destroy(); setTimeout(() => pollEngine(attempts + 1), 1000); }); } // ── App lifecycle ───────────────────────────────────────────────────────────── app.whenReady().then(createWindow); function fullShutdown() { stopEngine(); stopOllama(); app.quit(); } // Window controls ipcMain.on("window-minimize", () => win && win.minimize()); ipcMain.on("window-maximize", () => win && (win.isMaximized() ? win.unmaximize() : win.maximize())); ipcMain.on("window-close", () => fullShutdown()); app.on("window-all-closed", fullShutdown); app.on("before-quit", () => { stopEngine(); stopOllama(); }); // Catch process signals too process.on("SIGTERM", fullShutdown); process.on("SIGINT", fullShutdown); // ── Ollama pull ─────────────────────────────────────────────────────────────── ipcMain.on("ollama-pull", (event, modelName) => { const body = JSON.stringify({ name: modelName, stream: true }); const req = http.request( { hostname:"127.0.0.1", port:11434, path:"/api/pull", method:"POST", headers:{"Content-Type":"application/json","Content-Length":Buffer.byteLength(body)} }, res => { res.on("data", chunk => { chunk.toString().split("\n").filter(Boolean).forEach(line => { try { event.sender.send("ollama-pull-progress", JSON.parse(line)); } catch {} }); }); res.on("end", () => event.sender.send("ollama-pull-done", { model: modelName })); } ); req.on("error", e => event.sender.send("ollama-pull-error", e.message)); req.write(body); req.end(); }); // ── Local GGUF import ───────────────────────────────────────────────────────── ipcMain.handle("pick-gguf", async () => { const result = await dialog.showOpenDialog(win, { title: "Select GGUF Model File", filters: [{ name: "GGUF Models", extensions: ["gguf"] }], properties: ["openFile"], }); if (result.canceled || result.filePaths.length === 0) return null; return result.filePaths[0]; }); ipcMain.handle("import-gguf", async (event, ggufPath) => { const dir = path.dirname(ggufPath); const baseName = path.basename(ggufPath, ".gguf").toLowerCase().replace(/[^a-z0-9_-]/g, "-"); const modelfilePath = path.join(dir, "Modelfile"); // Create Modelfile if not present if (!fs.existsSync(modelfilePath)) { fs.writeFileSync(modelfilePath, `FROM ${ggufPath}\n`); } return new Promise((resolve) => { event.sender.send("gguf-import-status", { status: `Creating model "${baseName}"...`, pct: 0 }); const proc = spawn("ollama", ["create", baseName, "-f", modelfilePath], { stdio: ["pipe","pipe","pipe"] }); proc.stdout.on("data", d => { const text = d.toString().trim(); event.sender.send("gguf-import-status", { status: text }); }); proc.stderr.on("data", d => { event.sender.send("gguf-import-status", { status: d.toString().trim() }); }); proc.on("exit", code => { if (code === 0) { event.sender.send("gguf-import-status", { status: `✓ Model "${baseName}" ready`, done: true, modelName: baseName }); resolve({ success: true, modelName: baseName }); } else { event.sender.send("gguf-import-status", { status: `Failed (exit ${code})`, done: true }); resolve({ success: false }); } }); proc.on("error", e => { event.sender.send("gguf-import-status", { status: `Error: ${e.message}`, done: true }); resolve({ success: false }); }); }); });