const { app, BrowserWindow, ipcMain, Menu, dialog, powerSaveBlocker, safeStorage } = require('electron'); const path = require('path'); const fs = require('fs-extra'); const { pathToFileURL } = require('url'); const { startLlamaServer, stopLlamaServer, ensureLlamaStarting } = require('./llama-launcher.cjs'); // Keep the renderer fully awake on an idle stage TV. Without these, Chromium throttles/freezes // a window it thinks is backgrounded or occluded after a few minutes — which caused the splash // to need a "wake-up" tap before the real dismiss tap registered. These flags are safe (they do // NOT touch GPU compositing) and make the very first tap always count. app.commandLine.appendSwitch('disable-background-timer-throttling'); app.commandLine.appendSwitch('disable-renderer-backgrounding'); app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); // Keep the DISPLAY awake while the app runs. If Windows turns the screen off (or the screensaver // kicks in) after the TV sits idle, the OS consumes the first tap just to wake the display — that // was the remaining "throwaway tap" on the splash. A live stage TV must never sleep mid-service. let __psbId = null; function ensureDisplayAwake() { try { if (__psbId === null || !powerSaveBlocker.isStarted(__psbId)) { __psbId = powerSaveBlocker.start('prevent-display-sleep'); console.log('[MAIN] Display sleep blocked (powerSaveBlocker id ' + __psbId + ')'); } } catch (e) { console.warn('[MAIN] Could not block display sleep:', e && e.message || e); } } let serverStarted = false; let serverModuleRef = null; let captureBusy = false; // one window capture at a time — see captureScreen in registerAppControl /* * Fast mirror path for the phone remote (see captureScreen): the RENDERER captures its own window * through getDisplayMedia and answers with ready-encoded JPEGs — ~20-40ms a frame where * capturePage() has a fixed ~120ms GPU-readback floor on the 4K stage window, and the encode work * happens off the main process entirely. capturePage stays as the fallback: first requests while * the stream warms up, and permanently if the renderer reports the capture API can't work. */ let mirrorReqId = 0; const mirrorPending = new Map(); // id -> { resolve, timer } let mirrorRendererBroken = false; // renderer said self-capture cannot work — stop asking ipcMain.on('mirror:frame-response', (_evt, msg) => { try { const rec = msg && mirrorPending.get(msg.id); if (!rec) return; clearTimeout(rec.timer); mirrorPending.delete(msg.id); if (msg.fatal) { mirrorRendererBroken = true; console.warn('[MIRROR] Renderer self-capture unavailable (' + (msg.error || 'unknown') + '); falling back to capturePage'); } if (msg.unchanged) { rec.resolve({ unchanged: true }); return; } if (msg.error || !msg.buf) { rec.resolve(null); return; } rec.resolve({ jpeg: Buffer.from(msg.buf), width: msg.w, height: msg.h, sc: msg.sc, sm: msg.sm, iv: msg.iv }); } catch (_) { try { const rec = mirrorPending.get(msg && msg.id); if (rec) rec.resolve(null); } catch (_) {} } }); /* * Which port the app window loads from. * * ASK THE SERVER FIRST. server-port.txt is only a fallback for a dev run, and is deliberately NOT * trusted in a packaged build: __dirname is inside app.asar there, so the server cannot rewrite the * file and a stale port baked in at build time would be believed forever. That exact thing shipped * once — a leftover "8081" from a dev session got packaged, the window failed to load, fell back to * file://index.html, and the app came up with no server behind it: default graphics, none of the * operator's content, and Content Manager pointing at file:///C:/content-manager.html. */ function resolveServerPort() { try { if (serverModuleRef && typeof serverModuleRef.getServerPort === 'function') { const live = Number(serverModuleRef.getServerPort()); if (live > 0) return String(live); } } catch (_) {} return resolveServerPortFallback(); } /* * Wait for the socket to be bound, then use the port it actually got. Without this the window can be * loaded before the server binds, fall back to 8080, and — if something else already holds 8080 — * attach to a foreign server while our own sits on 8081. */ async function awaitServerPort(timeoutMs = 8000) { try { if (serverModuleRef && typeof serverModuleRef.whenServerListening === 'function') { const port = await Promise.race([ serverModuleRef.whenServerListening(), new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)) ]); if (port) return String(port); console.warn('[MAIN] server did not report a listening port in time; using best guess'); } } catch (_) {} return resolveServerPort(); } function resolveServerPortFallback() { try { if (!app.isPackaged) { const portFile = path.join(__dirname, 'server-port.txt'); if (fs.existsSync(portFile)) { const fromFile = String(fs.readFileSync(portFile, 'utf-8')).trim(); if (fromFile) return fromFile; } } } catch (_) {} return '8080'; } function resolveUserContentRoot() { try { const root = path.join(app.getPath('userData'), 'user-content'); fs.ensureDirSync(root); return root; } catch (e) { const fallback = path.join(process.cwd(), 'user-content'); fs.ensureDirSync(fallback); console.warn('[MAIN] Falling back to cwd for user-content:', e.message || e); return fallback; } } function migrateLegacyDir(label, targetDir, sources) { try { fs.ensureDirSync(targetDir); sources .filter(Boolean) .forEach(src => { if (!src || src === targetDir) return; if (!fs.existsSync(src)) return; const entries = fs.readdirSync(src); if (!entries.length) return; entries.forEach(name => { const from = path.join(src, name); const to = path.join(targetDir, name); try { if (fs.existsSync(to)) return; const stat = fs.statSync(from); if (stat.isDirectory()) { fs.copySync(from, to, { overwrite: false, errorOnExist: false }); } else if (stat.isFile()) { fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); } } catch (_) { /* ignore per-file copy issues */ } }); console.log(`[MAIN] Migrated ${label} from ${src} -> ${targetDir}`); }); } catch (e) { console.warn(`[MAIN] Failed to migrate ${label}:`, e.message || e); } } async function startLocalHttpServer() { if (serverStarted) return; try { const execDir = path.dirname(process.execPath || ''); const userContentRoot = resolveUserContentRoot(); const notesDir = path.join(userContentRoot, 'Notes'); const announcementsDir = path.join(userContentRoot, 'Announcements'); // Ensure persistent directories exist and pull forward any legacy assets fs.ensureDirSync(notesDir); fs.ensureDirSync(announcementsDir); migrateLegacyDir('Notes', notesDir, [ path.join(execDir, 'Notes'), path.join(__dirname, 'Notes'), path.join(process.cwd(), 'Notes') ]); migrateLegacyDir('Announcements', announcementsDir, [ path.join(execDir, 'Announcements'), path.join(__dirname, 'Announcements'), path.join(process.cwd(), 'Announcements') ]); console.log(`[MAIN] Notes directory: ${notesDir}`); console.log(`[MAIN] Announcements directory: ${announcementsDir}`); console.log(`[MAIN] User content root: ${userContentRoot}`); // Set environment variables so server.js can use them process.env.BIBLE_APP_DATA_DIR = userContentRoot; process.env.BIBLE_APP_USER_DATA_DIR = userContentRoot; process.env.BIBLE_APP_NOTES_DIR = notesDir; process.env.BIBLE_APP_ANNOUNCEMENTS_DIR = announcementsDir; const serverModuleUrl = pathToFileURL(path.join(__dirname, 'server.js')).href; serverModuleRef = await import(serverModuleUrl); serverStarted = true; // server.js logs its own startup message and binds to 8080 (falls back to 8081 if busy) } catch (error) { console.error('Failed to start embedded HTTP server:', error); } } async function createWindow () { const win = new BrowserWindow({ width: 1200, height: 900, fullscreen: true, autoHideMenuBar: true, backgroundColor: '#1a1a1a', show: true, webPreferences: { nodeIntegration: false, contextIsolation: true, webSecurity: true, // Same-origin policy ON. App content is served over http://localhost, // so file:// access is unnecessary; external APIs used are CORS-enabled. backgroundThrottling: false, // never throttle timers/paint when idle — first tap always registers preload: path.join(__dirname, 'preload.cjs'), devTools: true // Enable DevTools for debugging } }); /* * Let the renderer capture ITS OWN window for the phone mirror, with no picker dialog: any * getDisplayMedia call from this window is answered with this window as the source. Nothing else * in the app calls getDisplayMedia, and the request can only come from our own loaded page. * * The source is the OS WINDOW via desktopCapturer, NOT webContents.mainFrame: tab capture of this * app was observed freezing after the first frames (annotation canvas layers never invalidated * it), where OS window capture duplicates the real composited output and keeps flowing. */ try { const { desktopCapturer } = require('electron'); win.webContents.session.setDisplayMediaRequestHandler((request, callback) => { desktopCapturer.getSources({ types: ['window'] }).then((sources) => { let src = null; try { const mid = win.getMediaSourceId(); src = sources.find(s => s.id === mid) || null; } catch (_) {} callback(src ? { video: src } : {}); }).catch((e) => { console.warn('[MIRROR] display-media source lookup failed:', e && e.message); try { callback({}); } catch (_) {} }); }, { useSystemPicker: false }); console.log('[MIRROR] Self-capture handler installed for the phone mirror'); } catch (e) { console.warn('[MIRROR] setDisplayMediaRequestHandler unavailable:', e && e.message); } // A reloaded page gets a fresh chance at self-capture (reset_app, crash recovery). win.webContents.on('did-finish-load', () => { mirrorRendererBroken = false; }); // Enable DevTools with keyboard shortcut (F12) // Also add menu option for DevTools win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (win.webContents.isDevToolsOpened()) { win.webContents.closeDevTools(); } else { win.webContents.openDevTools(); } } }); // Build a minimal Help menu with Content Manager and About options try { setupAppMenu(win); } catch (_) {} // Start the embedded server and load the app via HTTP so relative API routes work await startLocalHttpServer(); // Start the bundled llama.cpp inference server so the Ask tab works offline // out of the box. This is non-blocking; if it fails the Ask tab gracefully // shows a fallback message. try { ensureLlamaStarting().catch((e) => { console.error('[MAIN] startLlamaServer failed:', e); }); } catch (e) { console.error('[MAIN] startLlamaServer failed:', e); } // Register renderer invoker EARLY so OSC commands work immediately // This must happen before OSC listener starts (which happens 500ms after server starts) try { if (serverModuleRef && typeof serverModuleRef.registerRendererInvoker === 'function') { serverModuleRef.registerRendererInvoker((action, payload) => { try { if (win && !win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) { win.webContents.send('control:perform', { action, payload }); } } catch (e) { console.error('[Renderer Invoker] Error sending to renderer:', e); } }); console.log('[MAIN] Renderer invoker registered for OSC commands'); } // Give the server the app-level abilities the updater needs (version, quit, run installer). if (serverModuleRef && typeof serverModuleRef.registerAppControl === 'function') { serverModuleRef.registerAppControl({ /* * ProPresenter network password at rest (propresenter-link.js): OS-backed encryption — DPAPI on * Windows (the same store Credential Manager uses), Keychain on macOS. Only this Windows user on * this machine can decrypt it, so the config file on disk is useless anywhere else. */ secretsAvailable: () => { try { return safeStorage.isEncryptionAvailable(); } catch (_) { return false; } }, encryptSecret: (plain) => { try { return safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(String(plain)).toString('base64') : null; } catch (_) { return null; } }, decryptSecret: (b64) => { try { return safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(Buffer.from(String(b64), 'base64')) : null; } catch (_) { return null; } }, getVersion: () => { try { return app.getVersion(); } catch (_) { return '0.0.0'; } }, quit: () => { try { app.quit(); } catch (_) {} }, runInstaller: (installerPath) => { // Launch the installer detached, then quit so it can replace the running app. try { const { spawn } = require('child_process'); const child = spawn(installerPath, [], { detached: true, stdio: 'ignore', windowsHide: false }); child.unref(); setTimeout(() => { try { app.quit(); } catch (_) {} }, 1200); return true; } catch (e) { console.error('[UPDATE] Failed to launch installer:', e && e.message); return false; } }, /* * One downscaled JPEG of the live window, for the phone remote's TV view. * * capturePage() is a GPU readback of the whole 2160x3840 window, so it is NOT free: the phone * asks for frames one at a time (never a fixed timer) and only while the TV view is open, and * the resize happens here in the main process so the ~33 MB raw frame never crosses a socket. * `captureBusy` drops overlapping requests instead of queueing them — a slow LAN must not be * able to stack up captures and stutter the stage screen. */ captureScreen: async (maxWidth, quality) => { if (!win || win.isDestroyed() || !win.webContents || win.webContents.isDestroyed()) return null; const width = Math.max(240, Math.min(Number(maxWidth) || 540, 4096)); const q = Math.max(25, Math.min(Number(quality) || 55, 85)); // FAST PATH: ask the renderer for a frame from its getDisplayMedia self-capture stream // (see mirror-stream.js). Misses are normal while the stream warms up (~0.5s) — each one // just falls through to capturePage below, so the mirror never blanks. if (!mirrorRendererBroken) { const fast = await new Promise((resolve) => { const id = ++mirrorReqId; const timer = setTimeout(() => { mirrorPending.delete(id); resolve(null); }, 450); mirrorPending.set(id, { resolve, timer }); try { win.webContents.send('mirror:frame-request', { id, width, quality: q }); } catch (e) { clearTimeout(timer); mirrorPending.delete(id); resolve(null); } }); if (fast && fast.unchanged) return fast; // nothing painted since last frame if (fast && fast.jpeg && fast.jpeg.length) return fast; } // FALLBACK: main-process capturePage — the slow but always-available path. if (captureBusy) return null; captureBusy = true; try { /* * capturePage() can never be trusted to come back. On a window the compositor considers * occluded (another app in front, minimised) it simply does not resolve — observed hanging * indefinitely, and because captureBusy is still held every later request then answers 503 * forever: the phone's mirror stays dead even after the window is visible again. Race it * against a timeout so a hung capture costs one frame instead of the whole feature. */ const img = await Promise.race([ win.webContents.capturePage(), new Promise((resolve) => setTimeout(() => resolve(null), 1500)) ]); if (!img || img.isEmpty()) return null; const size = img.getSize(); const target = Math.min(width, size.width); // 'better' costs measurably more CPU per frame than 'good' and is invisible at these sizes. const out = target < size.width ? img.resize({ width: target, quality: 'good' }) : img; const outSize = out.getSize(); return { jpeg: out.toJPEG(q), width: outSize.width, height: outSize.height }; } catch (e) { console.error('[REMOTE] captureScreen failed:', e && e.message); return null; } finally { captureBusy = false; } } }); console.log('[MAIN] App control registered for updater'); } else { console.warn('[MAIN] Server module not available or registerRendererInvoker not found'); } } catch (e) { console.error('[MAIN] Failed to register renderer invoker:', e); } // Register log forwarder to send server logs to renderer try { if (serverModuleRef && typeof serverModuleRef.registerLogForwarder === 'function') { serverModuleRef.registerLogForwarder((logEntry) => { try { if (win && !win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) { win.webContents.send('server-log', logEntry); } } catch (e) { // Ignore forwarding errors } }); console.log('[MAIN] Log forwarder registered'); } } catch (e) { console.warn('[MAIN] Failed to register log forwarder:', e); } try { /* * Keep trying the real server before giving up on it. The file:// fallback below is a LAST resort * and a bad place to end up — with no http origin every /api call fails, so the app comes up * looking oddly empty (default graphics, no content) instead of visibly broken. A slow first boot * must not land there, so retry for a few seconds first. */ const firstPort = await awaitServerPort(); const attempt = (tries, port) => { win.loadURL(`http://localhost:${port}/`).catch((err) => { // loadURL rejects asynchronously (e.g. server not listening yet / ERR_CONNECTION_REFUSED), // which the synchronous catch below cannot see. if (win.isDestroyed()) return; if (tries > 0) { console.warn(`[MAIN] loadURL http://localhost:${port}/ failed (${err && err.message}); retrying, ${tries} left`); // Re-resolve each round: a late bind (or a move to 8081) changes the answer. setTimeout(() => attempt(tries - 1, resolveServerPort()), 500); return; } console.error('[MAIN] server never answered; falling back to file://index.html — API-backed features will not work:', err && err.message ? err.message : err); try { win.loadFile('index.html'); } catch (_) {} }); }; attempt(12, firstPort); // ~6s of retries before the degraded fallback } catch (_) { // Last resort: load file directly (some features depending on the server may not work) win.loadFile('index.html'); } } app.whenReady().then(() => { ensureDisplayAwake(); // Re-check every 5 minutes in case the blocker is ever stopped externally. setInterval(ensureDisplayAwake, 5 * 60 * 1000); createWindow(); }); app.on('window-all-closed', () => { stopLlamaServer(); if (process.platform !== 'darwin') app.quit(); }); app.on('before-quit', () => { stopLlamaServer(); }); // Restrict the file/dir IPC handlers to app-owned locations (defense-in-depth; the preload // allow-list already blocks the renderer from invoking these channels). function isPathInsideAllowedRoot(targetPath) { try { const resolved = path.resolve(String(targetPath || '')); const roots = [path.resolve(__dirname), path.resolve(app.getPath('userData'))]; return roots.some((root) => resolved === root || resolved.startsWith(root + path.sep)); } catch (_) { return false; } } // Handle local file operations ipcMain.handle('read-directory', async (event, dirPath) => { try { if (!isPathInsideAllowedRoot(dirPath)) { throw new Error('Access denied: path outside allowed roots'); } const files = await fs.readdir(dirPath); const fileStats = await Promise.all( files.map(async (file) => { const filePath = path.join(dirPath, file); const stats = await fs.stat(filePath); return { name: file, path: filePath, size: stats.size, type: path.extname(file).slice(1) }; }) ); return fileStats; } catch (error) { console.error('Error reading directory:', error); throw error; } }); ipcMain.handle('read-file', async (event, filePath) => { try { if (!isPathInsideAllowedRoot(filePath)) { throw new Error('Access denied: path outside allowed roots'); } const content = await fs.readFile(filePath); return content; } catch (error) { console.error('Error reading file:', error); throw error; } }); // IPC handler for showing About dialog ipcMain.handle('show-about', async (event) => { try { const win = BrowserWindow.fromWebContents(event.sender); const aboutText = await readTopCommentBlock(path.join(__dirname, 'app.js')); // Ask Electron for the version that is ACTUALLY running (it comes from package.json at build // time). This used to be a hand-typed "Build:" line in app.js's header comment, which nobody // remembered to bump — so About sat on an old number release after release. await dialog.showMessageBox(win, { type: 'info', buttons: ['OK'], defaultId: 0, title: 'About', message: `Bible App ${app.getVersion()}`, detail: aboutText || 'Bible App' }); return { success: true }; } catch (e) { try { const win = BrowserWindow.fromWebContents(event.sender); await dialog.showMessageBox(win, { type: 'info', buttons: ['OK'], title: 'About', message: 'Bible App' }); return { success: true }; } catch (e2) { return { success: false, error: e2.message }; } } }); // IPC handler for closing the app ipcMain.handle('close-app', async (event) => { try { app.quit(); return { success: true }; } catch (e) { return { success: false, error: e.message }; } }); // ---------- Menu & Helpers ---------- function setupAppMenu(win) { const fileSubmenu = [ { label: 'Content Manager', click: async () => { try { const port = resolveServerPort(); await win.loadURL(`http://localhost:${port}/content-manager.html`); } catch (e) { console.error('Failed to open content manager:', e); } } }, { label: 'Settings', click: async () => { try { const port = resolveServerPort(); await win.loadURL(`http://localhost:${port}/settings.html`); } catch (e) { console.error('Failed to open settings:', e); } } }, { label: 'Log Viewer', click: async () => { try { const port = resolveServerPort(); await win.loadURL(`http://localhost:${port}/log-viewer.html`); } catch (e) { console.error('Failed to open log viewer:', e); } } }, { type: 'separator' }, { role: 'quit', label: 'Exit' } ]; const helpSubmenu = [ { label: 'Toggle Developer Tools', accelerator: 'F12', click: () => { if (win.webContents.isDevToolsOpened()) { win.webContents.closeDevTools(); } else { win.webContents.openDevTools(); } } }, { type: 'separator' }, { label: 'About', click: async () => { try { const aboutText = await readTopCommentBlock(path.join(__dirname, 'app.js')); await dialog.showMessageBox(win, { type: 'info', buttons: ['OK'], defaultId: 0, title: 'About', message: 'Bible App', detail: aboutText || 'Bible App' }); } catch (e) { await dialog.showMessageBox(win, { type: 'info', buttons: ['OK'], title: 'About', message: 'Bible App' }); } } } ]; const template = [ { label: 'File', submenu: fileSubmenu }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' }, { label: 'Help', role: 'help', submenu: helpSubmenu } ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); } async function readTopCommentBlock(filePath) { try { const text = await fs.readFile(filePath, 'utf-8'); const m = text.match(/\/\*[\s\S]*?\*\//); return m ? m[0].replace(/^\/\*+\s*|\s*\*+\/$/g, '').trim() : ''; } catch (_) { return ''; } }