Files
bible-app/main.cjs
T
christianandClaude Opus 5.5 f0af453020 Installer: close everything running from the app's folder before copying
Updating 1.4.35 -> 1.4.36 on the TV stopped at "Bible App cannot be closed. Please
close it manually and click Retry" with the app plainly closed. That message also
comes from the installer's copy step (and from removing the old version) when any
file in the install folder is in use — not only when Bible App.exe is running.

The app runs programs from its own folder: the local AI server (llama-server.exe),
speech (whisper-cli.exe), the upscaler. electron-builder's stock check closes only
Bible App.exe, and when it has to force-close the app, the app's own shutdown (which
stops the AI server) never runs. The orphaned llama-server.exe then holds its files
open forever, so Retry can never succeed.

- build/installer.nsh: customCheckAppRunning replaces the stock check. It asks the
  app to close, force-closes any Bible App.exe left, force-closes the helpers by name,
  then, as a backstop, every process whose executable is inside the install folder
  (via WMI, which works from the 32-bit installer on 64-bit Windows; the folder is
  passed in an environment variable and a bare drive is refused). It runs before the
  old version is removed and before any file is copied.
- main.cjs: the in-app updater stops the AI server before launching the installer.

The fix is in the INSTALLER, so the TV still on 1.4.35 gets it with this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:37:14 -04:00

692 lines
29 KiB
JavaScript

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);
}
}
/*
* The Browser tab (browser-tab.js) shows a third-party web page inside the stage window, in an Electron
* <webview>. A page from the internet gets none of the app's powers, whatever the renderer asked for:
*
* - Its preferences are FORCED here, not trusted from the tag: no Node, context isolation, sandbox,
* and the one preload that serves the Bible app (browser-guest-preload.cjs). Only the Browser tab's
* own session may be used, and only http(s) pages may load.
* - Nothing it does can put anything else on the stage: new windows open in the same view, other URL
* schemes (mailto:, file:, custom apps) are refused, permission prompts (camera, notifications,
* location, fullscreen…) are denied without asking, downloads are cancelled, and pinch-zoom is off
* so a stray two-finger touch cannot blow the page up in front of the room.
* - It identifies as plain Chrome. Some sites refuse a browser that says "Electron"; this is Chrome's
* engine, so the claim is accurate.
*/
const BROWSER_TAB_PARTITION = 'persist:browser-tab';
const isWebUrl = (u) => /^https?:\/\//i.test(String(u || ''));
function secureBrowserTab(win) {
const { session } = require('electron');
const ses = session.fromPartition(BROWSER_TAB_PARTITION);
// Electron's agent is "... (KHTML, like Gecko) <app name>/<version> Chrome/x Electron/y Safari/z": keep only Chrome's.
try { ses.setUserAgent(ses.getUserAgent().replace(/(\(KHTML, like Gecko\))\s+.*?\s+(Chrome\/)/, '$1 $2').replace(/\s+Electron\/\S+/i, '')); } catch (_) {}
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
ses.setPermissionCheckHandler(() => false);
ses.on('will-download', (e) => { try { e.preventDefault(); } catch (_) {} });
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
if (params.partition !== BROWSER_TAB_PARTITION || !isWebUrl(params.src)) {
console.warn('[BROWSER TAB] refused a webview:', params.partition, params.src);
event.preventDefault();
return;
}
delete webPreferences.preloadURL;
webPreferences.preload = path.join(__dirname, 'browser-guest-preload.cjs');
webPreferences.nodeIntegration = false;
webPreferences.nodeIntegrationInSubFrames = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;
webPreferences.webSecurity = true;
webPreferences.backgroundThrottling = false; // a page in the background keeps loading, ready when shown
});
win.webContents.on('did-attach-webview', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
if (isWebUrl(url)) { try { contents.loadURL(url); } catch (_) {} }
return { action: 'deny' };
});
contents.on('will-navigate', (e, url) => { if (!isWebUrl(url)) e.preventDefault(); });
// A page's "Leave site?" guard would otherwise silently cancel Reload and Start page — the very
// buttons used to recover the tab in front of the room. They always go through.
contents.on('will-prevent-unload', (e) => { try { e.preventDefault(); } catch (_) {} });
contents.on('will-redirect', (e, url) => { if (!isWebUrl(url)) e.preventDefault(); });
const noPinch = () => { try { contents.setVisualZoomLevelLimits(1, 1); } catch (_) {} };
noPinch();
contents.on('did-finish-load', noPinch);
});
}
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
// The Browser tab's pages (browser-tab.js). Every <webview> is locked down in will-attach-webview below.
webviewTag: true,
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 (_) {}
try { secureBrowserTab(win); } catch (e) { console.warn('[BROWSER TAB] setup failed:', e && e.message); }
// 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.
// Stop the local AI server FIRST: it runs from the install folder, and one left running holds
// its files open, so the installer cannot copy the new version over them ("cannot be closed").
try { stopLlamaServer(); } catch (_) {}
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 '';
}
}