Files

628 lines
25 KiB
JavaScript
Raw Permalink Normal View History

2026-09-24 13:05:20 -04:00
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 '';
}
}